allow to draw signature on mobile

pull/289/head
Pete Matsyburka 1 year ago
parent 91f3d8ed7e
commit f6c061a57b

@ -0,0 +1,21 @@
# frozen_string_literal: true
class SubmitFormDrawSignatureController < ApplicationController
layout false
around_action :with_browser_locale, only: %i[show]
skip_before_action :authenticate_user!
skip_authorization_check
def show
@submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.submission.template.archived_at? || @submitter.submission.archived_at?
return redirect_to submit_form_path(@submitter.slug)
end
render :show
end
end

@ -0,0 +1,21 @@
# frozen_string_literal: true
class SubmitFormValuesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at?
return render json: {} if submitter.submission.template.archived_at? || submitter.submission.archived_at?
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: {
value:,
attachment: attachment&.as_json(only: %i[uuid], methods: %i[url filename content_type])
}, head: :ok
end
end

@ -0,0 +1,113 @@
import SignaturePad from 'signature_pad'
import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas'
window.customElements.define('signature-form', class extends HTMLElement {
connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
this.pad = new SignaturePad(this.canvas)
this.pad.addEventListener('endStroke', () => {
this.updateSubmitButtonVisibility()
})
this.clearButton.addEventListener('click', (e) => {
e.preventDefault()
this.clearSignaturePad()
})
this.form.addEventListener('submit', (e) => {
e.preventDefault()
this.submitButton.disabled = true
this.submitImage().then((data) => {
this.valueInput.value = data.uuid
return fetch(this.form.action, {
method: 'PUT',
body: new FormData(this.form)
}).then((response) => {
this.form.classList.add('hidden')
this.success.classList.remove('hidden')
return response
})
}).finally(() => {
this.submitButton.disabled = false
})
})
}
clearSignaturePad () {
this.pad.clear()
this.updateSubmitButtonVisibility()
}
updateSubmitButtonVisibility () {
if (this.pad.isEmpty()) {
this.submitButton.style.display = 'none'
this.placeholderButton.style.display = 'block'
} else {
this.submitButton.style.display = 'block'
this.placeholderButton.style.display = 'none'
}
}
async submitImage () {
return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments')
return fetch('/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
return resolve(attachment)
})
}).catch((error) => {
return reject(error)
})
})
}
get submitButton () {
return this.querySelector('button[type="submit"]')
}
get clearButton () {
return this.querySelector('button[aria-label="Clear"]')
}
get placeholderButton () {
return this.querySelector('button[disabled]')
}
get canvas () {
return this.querySelector('canvas')
}
get valueInput () {
return this.querySelector('input[name^="values"]')
}
get form () {
return this.querySelector('form')
}
get success () {
return this.querySelector('#success')
}
})

@ -24,6 +24,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton || '{}'),
withQrButton: true,
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)),

@ -326,6 +326,7 @@
:attachments-index="attachmentsIndex"
:button-text="buttonText"
:with-disclosure="withDisclosure"
:with-qr-button="withQrButton"
:submitter-slug="submitterSlug"
:show-field-names="showFieldNames"
@attached="attachments.push($event)"
@ -384,7 +385,7 @@
</div>
<div
v-if="currentField.type !== 'payment' || submittedValues[currentField.uuid]"
:class="withDisclosure && currentField.type === 'signature' ? 'mt-2' : 'mt-6 md:mt-8'"
:class="currentField.type === 'signature' ? 'mt-2' : 'mt-6 md:mt-8'"
>
<button
id="submit_form_button"
@ -578,6 +579,11 @@ export default {
required: false,
default: true
},
withQrButton: {
type: Boolean,
required: false,
default: false
},
withTypedSignature: {
type: Boolean,
required: false,

@ -5,6 +5,8 @@ const en = {
esignature_disclosure: 'eSignature Disclosure',
signature: 'Signature',
initials: 'Initials',
drawn_signature_on_a_touchscreen_device: 'Drawn signature on a touchscreen device',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scan the QR code with the camera app to open the form on mobile and draw your signature',
date: 'Date',
number: 'Number',
image: 'Image',
@ -68,6 +70,8 @@ const en = {
}
const es = {
drawn_signature_on_a_touchscreen_device: 'Firma dibujada en un dispositivo con pantalla táctil',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el',
electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
esignature_disclosure: 'Divulgación de eFirma',
@ -136,6 +140,8 @@ const es = {
}
const it = {
drawn_signature_on_a_touchscreen_device: 'Firma disegnata su un dispositivo con schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scansiona il codice QR con l\'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma',
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
esignature_disclosure: 'Divulgazione della eFirma',
@ -204,6 +210,8 @@ const it = {
}
const de = {
drawn_signature_on_a_touchscreen_device: 'Gezeichnete Unterschrift auf einem Touchscreen-Gerät',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannen Sie den QR-Code mit der Kamera-App, um das Formular auf dem Handy zu öffnen und Ihre Unterschrift zu zeichnen',
by_clicking_you_agree_to_the: 'Durch Klicken auf "{button}" stimmen Sie zu, dass Sie die',
electronic_signature_disclosure: 'Elektronische Unterschriftenoffenlegung',
esignature_disclosure: 'eSignatur Offenlegung',
@ -272,6 +280,8 @@ const de = {
}
const fr = {
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannez le code QR avec l\'application de l\'appareil photo pour ouvrir le formulaire sur mobile et dessiner votre signature',
by_clicking_you_agree_to_the: 'En cliquant sur "{button}", vous acceptez la',
electronic_signature_disclosure: 'Divulgation de Signature Électronique',
esignature_disclosure: 'Divulgation de la eSignature',
@ -340,6 +350,8 @@ const fr = {
}
const pl = {
drawn_signature_on_a_touchscreen_device: 'Podpis odręczny na urządzeniu z ekranem dotykowym',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
esignature_disclosure: 'Ujawnienie ePodpisu',
@ -408,6 +420,8 @@ const pl = {
}
const uk = {
drawn_signature_on_a_touchscreen_device: 'Підпис на сенсорному пристрої',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
esignature_disclosure: 'Розголошення еПідпису',
@ -476,6 +490,8 @@ const uk = {
}
const cs = {
drawn_signature_on_a_touchscreen_device: 'Namalovaný podpis na dotykovém zařízení',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
esignature_disclosure: 'Zveřejnění ePodpisu',
@ -544,6 +560,8 @@ const cs = {
}
const pt = {
drawn_signature_on_a_touchscreen_device: 'Assinatura desenhada em um dispositivo com tela sensível ao toque',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o',
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
esignature_disclosure: 'Divulgação da eAssinatura',
@ -612,6 +630,8 @@ const pt = {
}
const he = {
drawn_signature_on_a_touchscreen_device: 'חתימה שנוצרה במכשיר עם מסך מגע',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
esignature_disclosure: 'חשיפת ה-eחתימה',
@ -681,6 +701,8 @@ const he = {
}
const nl = {
drawn_signature_on_a_touchscreen_device: 'Getekende handtekening op een apparaat met een touchscreen',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scan de QR-code met de camera-app om het formulier op mobiel te openen en uw handtekening te tekenen',
by_clicking_you_agree_to_the: 'Door op "{button}" te klikken, gaat u akkoord met de',
electronic_signature_disclosure: 'Openbaarmaking van Elektronische Handtekening',
esignature_disclosure: 'Openbaarmaking van eHandtekening',
@ -750,6 +772,8 @@ const nl = {
}
const ar = {
drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك',
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
electronic_signature_disclosure: 'كشف التوقيع الإلكتروني',
esignature_disclosure: 'كشف التوقيع الإلكتروني',
@ -819,6 +843,8 @@ const ar = {
}
const ko = {
drawn_signature_on_a_touchscreen_device: '터치스크린 장치에서 그린 서명',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: '카메라 앱으로 QR 코드를 스캔하여 모바일에서 양식을 열고 서명을 그리세요',
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
electronic_signature_disclosure: '전자 서명 공개',
esignature_disclosure: '전자 서명 공개',

@ -28,7 +28,7 @@
id="type_text_button"
href="#"
class="btn btn-outline btn-sm font-medium"
@click.prevent="toggleTextInput"
@click.prevent="[toggleTextInput(), showQr()]"
>
<IconSignature :width="16" />
<span class="hidden sm:inline">
@ -87,7 +87,7 @@
v-else
href="#"
class="btn btn-outline btn-sm font-medium"
@click.prevent="clear"
@click.prevent="[clear(), hideQr()]"
>
<IconReload :width="16" />
{{ t('clear') }}
@ -123,12 +123,56 @@
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full"
/>
<div class="relative">
<div
v-if="withQrButton"
class="absolute top-1.5 right-1.5 tooltip hidden md:inline"
:data-tip="t('drawn_signature_on_a_touchscreen_device')"
>
<a
v-if="!isShowQr && !isSignatureStarted && !isTextSignature && !modelValue"
href="#"
class="btn btn-sm btn-circle btn-ghost"
@click.prevent="showQr"
>
<IconQrcode />
</a>
</div>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full"
/>
<div
v-show="isShowQr"
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
v-if="withQrButton"
ref="qr"
class="bg-white p-4 rounded-xl h-full"
>
<canvas
ref="qrCanvas"
class="h-full"
/>
</div>
</div>
</div>
</div>
<input
v-if="isTextSignature"
id="signature_text_input"
@ -140,7 +184,14 @@
@input="updateWrittenSignature"
>
<div
v-if="withDisclosure"
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"
dir="auto"
class="text-base-content/60 text-xs text-center w-full mt-1"
>
@ -156,11 +207,15 @@
</span>
</a>
</div>
<div
v-else
class="mt-5 md:mt-7"
/>
</div>
</template>
<script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
@ -176,7 +231,9 @@ export default {
AppearsOn,
IconReload,
IconCamera,
IconQrcode,
MarkdownContent,
IconX,
IconTextSize,
IconSignature,
IconArrowsDiagonalMinimize2
@ -201,6 +258,11 @@ export default {
required: false,
default: false
},
withQrButton: {
type: Boolean,
required: false,
default: false
},
buttonText: {
type: String,
required: false,
@ -231,6 +293,7 @@ export default {
data () {
return {
isSignatureStarted: !!this.previousValue,
isShowQr: false,
isUsePreviousValue: true,
isTextSignature: this.field.preferences?.format === 'typed',
uploadImageInputKey: Math.random().toString()
@ -253,6 +316,18 @@ export default {
this.$refs.canvas.getContext('2d').scale(scale, scale)
}
if (this.withQrButton) {
import('qr-creator').then(({ default: Qr }) => {
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)
})
}
})
if (this.$refs.canvas) {
@ -282,6 +357,7 @@ export default {
},
beforeUnmount () {
this.intersectionObserver?.disconnect()
this.stopCheckSignature()
},
methods: {
remove () {
@ -303,6 +379,41 @@ export default {
})
}
},
showQr () {
this.isShowQr = true
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()
}
})
},
clear () {
this.pad.clear()

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html data-theme="docuseal" lang="en">
<head>
<%= render 'layouts/head_tags' %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<% if ENV['ROLLBAR_CLIENT_TOKEN'] %>
<meta name="rollbar-token" content="<%= ENV.fetch('ROLLBAR_CLIENT_TOKEN', nil) %>">
<%= javascript_pack_tag 'rollbar', 'draw', defer: true %>
<% else %>
<%= javascript_pack_tag 'draw', defer: true %>
<% end %>
<%= stylesheet_pack_tag 'form', media: 'all' %>
<%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %>
</head>
<body>
<signature-form data-slug="<%= params[:slug] %>" class="flex items-center h-screen p-2 justify-center">
<%= form_for '', url: submit_form_path(params[:slug]), html: { style: 'max-width: 900px; width: 100%; margin-bottom: 120px' }, method: :put do |f| %>
<input value="" type="hidden" name="values[<%= (@submitter.submission.template_fields || @submitter.template.fields).find { |f| f['type'] == 'signature' && f['uuid'].starts_with?(params[:f]) }['uuid'] %>]">
<div class="font-semibold text-4xl text-center w-full mb-2">
Draw Signature
</div>
<div class="w-full bg-white rounded-2xl border relative" style="height: 300px">
<canvas class="w-full"></canvas>
<button aria-label="Clear" class="btn btn-ghost btn-sm font-medium top-0 right-0 absolute mt-1 mr-1">
<%= svg_icon('reload', class: 'w-5 h-5') %>
<span class="inline">Clear</span>
</button>
</div>
<div class="mt-4">
<button disabled class="base-button w-full">
Submit
</button>
<%= f.button button_title(title: 'Submit'), class: 'base-button w-full', style: 'display: none' %>
</div>
<% end %>
<div id="success" class="text-center p-2 hidden" style="margin-bottom: 100px">
<div class="flex items-center space-x-1 items-center justify-center text-2xl font-semibold mb-2">
<%= svg_icon('circle_check', class: 'text-green-600') %>
<span>
Signature Uploaded
</span>
</div>
<div>
Return back to your desktop device to complete the form or <a class="link" href="<%= submit_form_path(params[:slug]) %>">continue on mobile</a>
</div>
</div>
</signature-form>
</body>
</html>

@ -105,9 +105,12 @@ Rails.application.routes.draw do
end
resources :submit_form, only: %i[show update], path: 's', param: 'slug' do
resources :values, only: %i[index], controller: 'submit_form_values'
get :completed
end
resources :submit_form_draw_signature, only: %i[show], path: 'p', param: 'slug'
resources :submissions_preview, only: %i[show], path: 'e', param: 'slug' do
get :completed
end

@ -21,6 +21,10 @@ const configs = generateWebpackConfig({
test: /\/node_modules\//,
chunks: chunk => chunk.name === 'application'
},
drawVendors: {
test: /\/node_modules\//,
chunks: chunk => chunk.name === 'draw'
},
formVendors: {
test: /\/node_modules\//,
chunks: chunk => chunk.name === 'form'

@ -25,6 +25,7 @@
"postcss": "^8.4.23",
"postcss-import": "^15.1.0",
"postcss-loader": "^7.3.0",
"qr-creator": "^1.0.0",
"rollbar": "^2.26.4",
"sass": "^1.62.1",
"sass-loader": "^13.2.2",

@ -4592,6 +4592,11 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
qr-creator@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/qr-creator/-/qr-creator-1.0.0.tgz#f350a8f0b5be02bd1fc1ef133a038a06ef8bc5ef"
integrity sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==
qs@6.11.0:
version "6.11.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"

Loading…
Cancel
Save