diff --git a/app/javascript/draw.js b/app/javascript/draw.js index c42c3c72..a4936e8d 100644 --- a/app/javascript/draw.js +++ b/app/javascript/draw.js @@ -1,5 +1,6 @@ import SignaturePad from 'signature_pad' import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas' +import { isValidSignatureCanvas } from './submission_form/validate_signature' window.customElements.define('draw-signature', class extends HTMLElement { connectedCallback () { @@ -43,6 +44,8 @@ window.customElements.define('draw-signature', class extends HTMLElement { return response }) + }).catch(error => { + console.log(error) }).finally(() => { this.submitButton.disabled = false }) @@ -65,26 +68,26 @@ window.customElements.define('draw-signature', class extends HTMLElement { } 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') - formData.append('remember_signature', 'true') - - return fetch('/api/attachments', { - method: 'POST', - body: formData - }).then((resp) => resp.json()).then((attachment) => { - return resolve(attachment) - }) - }).catch((error) => { - return reject(error) - }) + if (!isValidSignatureCanvas(this.pad.toData())) { + alert('Signature is too small or simple. Please redraw.') + + return Promise.reject(new Error('Image too small or simple')) + } + + return cropCanvasAndExportToPNG(this.canvas).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') + formData.append('remember_signature', 'true') + + return fetch('/api/attachments', { + method: 'POST', + body: formData + }).then(resp => resp.json()) }) } diff --git a/app/javascript/submission_form/crop_canvas.js b/app/javascript/submission_form/crop_canvas.js index 472d2764..977c5ade 100644 --- a/app/javascript/submission_form/crop_canvas.js +++ b/app/javascript/submission_form/crop_canvas.js @@ -1,4 +1,4 @@ -function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) { +function cropCanvasAndExportToPNG (canvas) { const ctx = canvas.getContext('2d') const width = canvas.width @@ -33,10 +33,6 @@ function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSma croppedCanvas.height = croppedHeight const croppedCtx = croppedCanvas.getContext('2d') - if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) { - return Promise.reject(new Error('Image too small')) - } - croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight) return new Promise((resolve, reject) => { diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index cea04694..5569ff27 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -1445,11 +1445,7 @@ export default { this.isSubmittingComplete = false }) }).catch(error => { - if (error?.message === 'Image too small') { - alert(this.t('signature_is_too_small_please_redraw')) - } else { - console.log(error) - } + console.log(error) }).finally(() => { this.isSubmitting = false this.isSubmittingComplete = false diff --git a/app/javascript/submission_form/i18n.js b/app/javascript/submission_form/i18n.js index ab1e0685..252058e7 100644 --- a/app/javascript/submission_form/i18n.js +++ b/app/javascript/submission_form/i18n.js @@ -93,7 +93,7 @@ const en = { reupload: 'Reupload', upload: 'Upload', files: 'Files', - signature_is_too_small_please_redraw: 'Signature is too small. Please redraw.', + signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.', wait_countdown_seconds: 'Wait {countdown} seconds' } @@ -191,7 +191,7 @@ const es = { reupload: 'Volver a subir', upload: 'Subir', files: 'Archivos', - signature_is_too_small_please_redraw: 'La firma es demasiado pequeña. Por favor, dibújala de nuevo.', + signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.', wait_countdown_seconds: 'Espera {countdown} segundos' } @@ -289,7 +289,7 @@ const it = { reupload: 'Ricarica', upload: 'Carica', files: 'File', - signature_is_too_small_please_redraw: 'La firma è troppo piccola. Ridisegnala per favore.', + signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.', wait_countdown_seconds: 'Attendi {countdown} secondi' } @@ -387,7 +387,7 @@ const de = { reupload: 'Erneut hochladen', upload: 'Hochladen', files: 'Dateien', - signature_is_too_small_please_redraw: 'Die Unterschrift ist zu klein. Bitte erneut zeichnen.', + signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.', wait_countdown_seconds: 'Warte {countdown} Sekunden' } @@ -485,7 +485,7 @@ const fr = { reupload: 'Recharger', upload: 'Télécharger', files: 'Fichiers', - signature_is_too_small_please_redraw: 'La signature est trop petite. Veuillez la redessiner.', + signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.', wait_countdown_seconds: 'Attendez {countdown} secondes' } @@ -583,7 +583,7 @@ const pl = { reupload: 'Ponowne przesłanie', upload: 'Przesyłanie', files: 'Pliki', - signature_is_too_small_please_redraw: 'Podpis jest zbyt mały. Proszę narysować go ponownie.' + signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.' } const uk = { @@ -680,7 +680,7 @@ const uk = { reupload: 'Перезавантажити', upload: 'Завантажити', files: 'Файли', - signature_is_too_small_please_redraw: 'Підпис занадто малий. Будь ласка, перемалюйте його.', + signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.', wait_countdown_seconds: 'Зачекайте {countdown} секунд' } @@ -778,7 +778,7 @@ const cs = { reupload: 'Znovu nahrát', upload: 'Nahrát', files: 'Soubory', - signature_is_too_small_please_redraw: 'Podpis je příliš malý. Prosím, překreslete ho.', + signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.', wait_countdown_seconds: 'Počkejte {countdown} sekund' } @@ -876,7 +876,7 @@ const pt = { reupload: 'Reenviar', upload: 'Carregar', files: 'Arquivos', - signature_is_too_small_please_redraw: 'A assinatura é muito pequena. Por favor, redesenhe-a.', + signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.', wait_countdown_seconds: 'Aguarde {countdown} segundos' } @@ -975,7 +975,7 @@ const he = { reupload: 'העלה שוב', upload: 'העלאה', files: 'קבצים', - signature_is_too_small_please_redraw: 'החתימה קטנה מדי. אנא צייר מחדש.', + signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.', wait_countdown_seconds: 'המתן {countdown} שניות' } @@ -1074,7 +1074,7 @@ const nl = { reupload: 'Opnieuw uploaden', upload: 'Uploaden', files: 'Bestanden', - signature_is_too_small_please_redraw: 'De handtekening is te klein. Teken deze opnieuw, alstublieft.', + signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.', wait_countdown_seconds: 'Wacht {countdown} seconden' } @@ -1172,7 +1172,7 @@ const ar = { reupload: 'إعادة التحميل', upload: 'تحميل', files: 'الملفات', - signature_is_too_small_please_redraw: 'التوقيع صغير جدًا. يرجى إعادة الرسم.', + signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.', wait_countdown_seconds: 'انتظر {countdown} ثانية' } @@ -1269,7 +1269,7 @@ const ko = { reupload: '다시 업로드', upload: '업로드', files: '파일', - signature_is_too_small_please_redraw: '서명이 너무 작습니다. 다시 그려주세요.', + signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.', wait_countdown_seconds: '{countdown}초 기다리세요' } diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue index 8abaab7b..99b1bc8a 100644 --- a/app/javascript/submission_form/initials_step.vue +++ b/app/javascript/submission_form/initials_step.vue @@ -118,11 +118,17 @@ :src="attachmentsIndex[modelValue || computedPreviousValue].url" class="mx-auto bg-white border border-base-300 rounded max-h-44" > - +
+
+ +
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue' import { cropCanvasAndExportToPNG } from './crop_canvas' +import { isValidSignatureCanvas } from './validate_signature' import SignaturePad from 'signature_pad' import AppearsOn from './appears_on' import FileDropzone from './dropzone' @@ -680,8 +681,14 @@ export default { return Promise.resolve({}) } - return new Promise((resolve, reject) => { - cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => { + if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) { + alert(this.t('signature_is_too_small_or_simple_please_redraw')) + + return Promise.reject(new Error('Image too small or simple')) + } + + return new Promise((resolve) => { + cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { const file = new File([blob], 'signature.png', { type: 'image/png' }) if (this.dryRun) { @@ -717,12 +724,6 @@ export default { return resolve(attachment) }) } - }).catch((error) => { - if (error.message === 'Image too small' && this.field.required === false) { - return resolve({}) - } else { - return reject(error) - } }) }) } diff --git a/app/javascript/submission_form/validate_signature.js b/app/javascript/submission_form/validate_signature.js new file mode 100644 index 00000000..26f04a23 --- /dev/null +++ b/app/javascript/submission_form/validate_signature.js @@ -0,0 +1,38 @@ +function isValidSignatureCanvas (data) { + if (data.length === 0) return false + + const strokes = data.filter(stroke => Array.isArray(stroke.points) && stroke.points.length > 2) + + if (strokes.length === 0) return false + + let skippedStraightLine = 0 + + const validStrokes = strokes.filter(stroke => { + const points = stroke.points + const first = points[0] + const last = points[points.length - 1] + const A = last.y - first.y + const B = first.x - last.x + const C = last.x * first.y - first.x * last.y + const lineLength = Math.sqrt(A * A + B * B) + + const totalDeviation = points.reduce((sum, p) => { + const distanceToLine = Math.abs(A * p.x + B * p.y + C) / lineLength + return sum + distanceToLine + }, 0) + + const avgDeviation = totalDeviation / points.length + + if (avgDeviation < 3 && skippedStraightLine < 2) { + skippedStraightLine++ + + return false + } + + return true + }) + + return validStrokes.length > 0 +} + +export { isValidSignatureCanvas } diff --git a/spec/signing_form_helper.rb b/spec/signing_form_helper.rb index 6d86484b..b00d5f8b 100644 --- a/spec/signing_form_helper.rb +++ b/spec/signing_form_helper.rb @@ -4,21 +4,49 @@ module SigningFormHelper module_function def draw_canvas - page.find('canvas').click([], { x: 150, y: 100 }) page.execute_script <<~JS const canvas = document.getElementsByTagName('canvas')[0]; - const ctx = canvas.getContext('2d'); + const rect = canvas.getBoundingClientRect(); - ctx.beginPath(); - ctx.moveTo(150, 100); - ctx.lineTo(450, 100); - ctx.stroke(); + const startX = rect.left + 50; + const startY = rect.top + 100; - ctx.beginPath(); - ctx.moveTo(150, 100); - ctx.lineTo(150, 150); - ctx.stroke(); + const amplitude = 20; + const wavelength = 30; + const length = 300; + + function dispatchPointerEvent(type, x, y) { + const event = new PointerEvent(type, { + pointerId: 1, + pointerType: 'pen', + isPrimary: true, + clientX: x, + clientY: y, + bubbles: true, + pressure: 0.5 + }); + + canvas.dispatchEvent(event); + } + + dispatchPointerEvent('pointerdown', startX, startY); + + let x = 0; + function drawStep() { + if (x > length) { + dispatchPointerEvent('pointerup', startX + x, startY); + return; + } + + const y = startY + amplitude * Math.sin((x / wavelength) * 2 * Math.PI); + dispatchPointerEvent('pointermove', startX + x, y); + x += 5; + requestAnimationFrame(drawStep); + } + + drawStep(); JS + sleep 0.5 end diff --git a/spec/system/signing_form_spec.rb b/spec/system/signing_form_spec.rb index e9c41fce..bbf1d797 100644 --- a/spec/system/signing_form_spec.rb +++ b/spec/system/signing_form_spec.rb @@ -360,6 +360,19 @@ RSpec.describe 'Signing Form', type: :system do expect(field_value(submitter, 'Signature')).to be_present end + it 'shows an error message if the canvas is not drawn or too simple' do + visit submit_form_path(slug: submitter.slug) + + find('#expand_form_button').click + page.find('canvas').click([], { x: 150, y: 100 }) + + alert_text = page.accept_alert do + click_button 'Sign and Complete' + end + + expect(alert_text).to eq 'Signature is too small or simple. Please redraw.' + end + it 'completes the form if the canvas is typed' do visit submit_form_path(slug: submitter.slug)