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

707 lines
19 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
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
2023-06-15 01:05:04 +03:00
<label
class="label text-2xl"
2024-03-09 18:24:21 +02:00
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('signature') }}
</template>
</label>
2023-07-17 22:48:01 +03:00
<div class="space-x-2 flex">
<span
2024-02-12 21:22:04 +02:00
v-if="isTextSignature && field.preferences?.format !== 'typed'"
class="tooltip"
2023-11-18 13:57:49 +02:00
:data-tip="t('draw_signature')"
>
<a
2023-08-20 12:42:47 +03:00
id="type_text_button"
href="#"
2023-11-18 13:57:49 +02:00
class="btn btn-outline btn-sm font-medium"
2024-06-03 12:29:14 +03:00
@click.prevent="[toggleTextInput(), hideQr()]"
2023-11-18 13:57:49 +02:00
>
<IconSignature :width="16" />
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
</a>
</span>
<span
2024-02-12 21:22:04 +02:00
v-else-if="withTypedSignature && field.preferences?.format !== 'typed' && field.preferences?.format !== 'drawn'"
2024-01-07 14:19:21 +02:00
class="tooltip ml-2"
2023-11-18 13:57:49 +02:00
:data-tip="t('type_text')"
>
<a
id="type_text_button"
href="#"
2024-03-10 19:55:45 +02:00
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
2024-06-03 12:29:14 +03:00
@click.prevent="[toggleTextInput(), hideQr()]"
>
<IconTextSize :width="16" />
2023-11-18 13:57:49 +02:00
<span class="hidden sm:inline">
{{ t('type') }}
</span>
</a>
</span>
<span
v-if="field.preferences?.format !== 'typed' && field.preferences?.format !== 'drawn'"
class="tooltip"
2024-01-29 19:53:27 +02:00
:data-tip="t('take_photo')"
>
<label
2024-03-10 19:55:45 +02:00
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
>
<IconCamera :width="16" />
<input
2024-02-18 22:12:28 +02:00
:key="uploadImageInputKey"
type="file"
hidden
accept="image/*"
@change="drawImage"
>
2023-11-18 13:57:49 +02:00
<span class="hidden sm:inline">
{{ t('upload') }}
</span>
</label>
</span>
<a
v-if="modelValue || computedPreviousValue"
href="#"
2023-11-18 13:57:49 +02:00
class="btn btn-outline btn-sm font-medium"
@click.prevent="remove"
>
<IconReload :width="16" />
2023-09-20 00:13:14 +03:00
{{ t('redraw') }}
</a>
2024-06-06 16:54:16 +03:00
<span
v-if="withQrButton && !modelValue && !computedPreviousValue && field.preferences?.format !== 'typed'"
class=" tooltip"
:data-tip="t('drawn_signature_on_a_touchscreen_device')"
>
2024-06-06 16:54:16 +03:00
<a
href="#"
class="btn btn-sm btn-neutral font-medium hidden md:flex"
:class="{ 'btn-outline': !isShowQr, 'text-white': isShowQr }"
@click.prevent="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
>
<IconQrcode
:width="19"
:height="19"
/>
</a>
</span>
<a
href="#"
2024-02-19 23:52:47 +02:00
:title="t('minimize')"
2023-07-17 22:48:01 +03:00
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
/>
</a>
</div>
2023-06-15 01:05:04 +03:00
</div>
2024-03-09 18:24:21 +02:00
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<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}]`"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
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-06-02 14:50:37 +03:00
<div class="relative">
<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
>
<a
href="#"
2024-06-06 16:54:16 +03:00
class="btn btn-ghost font-medium btn-xs md:btn-sm"
@click.prevent="[clear(), hideQr()]"
2024-06-02 14:50:37 +03:00
>
2024-06-06 16:54:16 +03:00
<IconReload :width="16" />
{{ t('clear') }}
2024-06-02 14:50:37 +03:00
</a>
</div>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full"
/>
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"
>
<div
class="absolute top-1.5 right-1.5 tooltip"
>
<a
href="#"
class="btn btn-sm btn-circle btn-normal btn-outline"
@click.prevent="hideQr"
>
<IconX />
</a>
</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"
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
v-if="isTextSignature"
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"
2023-09-20 00:13:14 +03:00
:placeholder="`${t('type_signature_here')}...`"
type="text"
@input="updateWrittenSignature"
>
2024-08-06 00:19:33 +03:00
<select
v-if="requireSigningReason && !isOtherReason"
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
: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>
<option
v-for="(label, option) in defaultReasons"
:key="option"
:value="option"
:selected="reason === option"
2024-10-10 19:49:31 +03:00
class="text-base-content"
2024-08-06 00:19:33 +03:00
>
{{ label }}
</option>
2024-10-10 19:49:31 +03:00
<option
value="other"
class="text-base-content"
>
2024-08-06 00:19:33 +03:00
{{ t('other') }}
</option>
</select>
<input
v-if="requireSigningReason && isOtherReason"
class="base-input !text-2xl w-full mt-6"
required
:name="`values[${field.preferences.reason_field_uuid}]`"
:placeholder="t('type_here_')"
:value="reason"
type="text"
@input="$emit('update:reason', $event.target.value)"
>
<input
v-if="requireSigningReason"
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"
class="text-base-content/60 text-xs text-center w-full mt-1"
>
{{ t('by_clicking_you_agree_to_the').replace('{button}', buttonText.charAt(0).toUpperCase() + buttonText.slice(1)) }} <a
href="https://www.docuseal.co/esign-disclosure"
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'
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-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
2023-10-28 16:03:19 +03:00
let isFontLoaded = false
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,
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
},
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: ''
},
2024-08-06 00:19:33 +03:00
reason: {
type: String,
required: false,
default: ''
},
2023-05-25 22:47:52 +03:00
modelValue: {
type: String,
required: false,
default: ''
}
},
2024-08-06 00:19:33 +03:00
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
2023-06-24 17:15:36 +03:00
data () {
return {
isSignatureStarted: !!this.previousValue,
2024-06-02 14:50:37 +03:00
isShowQr: false,
2024-08-06 00:19:33 +03:00
isOtherReason: false,
isUsePreviousValue: true,
2024-02-18 22:12:28 +02:00
isTextSignature: this.field.preferences?.format === 'typed',
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-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 () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
2024-08-06 00:19:33 +03:00
created () {
if (this.requireSigningReason) {
this.field.preferences ||= {}
this.field.preferences.reason_field_uuid ||= v4()
this.isOtherReason = this.reason && !this.defaultReasons[this.reason]
}
},
2023-06-24 18:31:12 +03:00
async mounted () {
2023-06-30 20:31:17 +03:00
this.$nextTick(() => {
2023-09-20 00:13:14 +03:00
if (this.$refs.canvas) {
2024-02-09 22:33:18 +02:00
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
2023-09-20 00:13:14 +03:00
}
2023-06-30 20:31:17 +03:00
})
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-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
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
2024-02-09 22:33:18 +02:00
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
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)
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()
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: {
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 (!isFontLoaded) {
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
font.load().then((loadedFont) => {
document.fonts.add(loadedFont)
isFontLoaded = true
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
},
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
if (this.isTextSignature) {
2023-07-17 22:48:01 +03:00
this.$nextTick(() => {
this.$refs.textInput.focus()
2023-10-28 16:03:19 +03:00
this.loadFont()
2023-07-17 22:48:01 +03:00
this.$emit('start')
})
}
},
drawImage (event) {
2023-07-17 22:48:01 +03:00
this.remove()
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) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
2023-05-25 22:47:52 +03:00
return Promise.resolve({})
}
2024-03-10 19:55:45 +02: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)
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
this.maybeSetSignedUuid(attachment.signed_uuid)
return resolve(attachment)
})
}
2024-03-10 19:55:45 +02:00
}).catch((error) => {
2024-06-20 17:52:40 +03:00
if (error.message === 'Image too small' && this.field.required === false) {
return resolve({})
} else {
return reject(error)
}
})
2023-05-25 22:47:52 +03:00
})
}
}
}
</script>