From f45310db64e61ef6bd032e4bdbc4f5b766df43a8 Mon Sep 17 00:00:00 2001 From: Bernardo Anderson Date: Wed, 15 Jul 2026 12:51:02 -0500 Subject: [PATCH] CP-14191 - Fix signature offset when drawing in landscape on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What Signature and initials pads recalculate canvas size on resize and orientation change so strokes follow the finger in landscape. Why On mobile landscape, the pad kept portrait canvas dimensions after rotation, so ink appeared offset from touch placement and made signing unreliable. How to test 1. Open a Docuseal signature field on a phone (or device emulation). 2. In portrait, tap the top-left of the pad — ink should match the finger. 3. Rotate to landscape, clear if needed, tap the top-left again — ink should still match (no inset down/right). 4. Draw left-to-right in landscape — stroke should track the finger. 5. Optionally repeat with initials draw mode. --- app/javascript/draw.js | 105 +++++++++++++- app/javascript/elements/signature_form.js | 105 +++++++++++++- .../submission_form/initials_step.vue | 127 ++++++++++++++--- .../submission_form/signature_step.vue | 129 ++++++++++++++---- 4 files changed, 409 insertions(+), 57 deletions(-) diff --git a/app/javascript/draw.js b/app/javascript/draw.js index a4936e8da0..a33d7ea80a 100644 --- a/app/javascript/draw.js +++ b/app/javascript/draw.js @@ -4,12 +4,7 @@ import { isValidSignatureCanvas } from './submission_form/validate_signature' window.customElements.define('draw-signature', 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.scale = 3 this.pad = new SignaturePad(this.canvas) @@ -50,6 +45,104 @@ window.customElements.define('draw-signature', class extends HTMLElement { this.submitButton.disabled = false }) }) + + this.setupCanvasSizing() + } + + disconnectedCallback () { + this.teardownCanvasSizing() + } + + setupCanvasSizing () { + this.resizeCanvas() + + this.onResizeCanvas = () => { + if (this.resizeRaf) { + cancelAnimationFrame(this.resizeRaf) + } + + this.resizeRaf = requestAnimationFrame(() => { + this.resizeRaf = requestAnimationFrame(() => { + this.resizeCanvas() + }) + }) + } + + window.addEventListener('resize', this.onResizeCanvas) + screen?.orientation?.addEventListener('change', this.onResizeCanvas) + + if (typeof ResizeObserver !== 'undefined' && this.canvas?.parentNode) { + this.resizeObserver = new ResizeObserver(this.onResizeCanvas) + this.resizeObserver.observe(this.canvas.parentNode) + } + } + + teardownCanvasSizing () { + if (this.resizeRaf) { + cancelAnimationFrame(this.resizeRaf) + this.resizeRaf = null + } + + if (this.onResizeCanvas) { + window.removeEventListener('resize', this.onResizeCanvas) + screen?.orientation?.removeEventListener('change', this.onResizeCanvas) + } + + this.resizeObserver?.disconnect() + } + + resizeCanvas () { + if (!this.canvas?.parentNode) { + return + } + + const width = this.canvas.parentNode.clientWidth + const height = this.canvas.parentNode.clientHeight + + if (!width || !height) { + return + } + + const nextW = width * this.scale + const nextH = height * this.scale + + if (this.canvas.width === nextW && this.canvas.height === nextH) { + return + } + + const prevCssW = this.canvas.width / this.scale + const prevCssH = this.canvas.height / this.scale + const ratioX = prevCssW > 0 ? width / prevCssW : 1 + const ratioY = prevCssH > 0 ? height / prevCssH : 1 + + let data = [] + + if (this.pad) { + data = this.pad.toData() + + if (data.length && (ratioX !== 1 || ratioY !== 1)) { + data = data.map((group) => ({ + ...group, + points: group.points.map((point) => ({ + ...point, + x: point.x * ratioX, + y: point.y * ratioY + })) + })) + } + } + + this.canvas.width = nextW + this.canvas.height = nextH + this.canvas.getContext('2d').scale(this.scale, this.scale) + + if (this.pad) { + this.pad.clear() + + if (data.length) { + this.pad.fromData(data) + } + } } clearSignaturePad () { diff --git a/app/javascript/elements/signature_form.js b/app/javascript/elements/signature_form.js index 55020081a9..f0efad8564 100644 --- a/app/javascript/elements/signature_form.js +++ b/app/javascript/elements/signature_form.js @@ -5,12 +5,7 @@ export default targetable(class extends HTMLElement { static [target.static] = ['canvas', 'input', 'clear', 'button'] async 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.scale = 3 const { default: SignaturePad } = await import('signature_pad') @@ -29,6 +24,104 @@ export default targetable(class extends HTMLElement { this.submit() }) + + this.setupCanvasSizing() + } + + disconnectedCallback () { + this.teardownCanvasSizing() + } + + setupCanvasSizing () { + this.resizeCanvas() + + this.onResizeCanvas = () => { + if (this.resizeRaf) { + cancelAnimationFrame(this.resizeRaf) + } + + this.resizeRaf = requestAnimationFrame(() => { + this.resizeRaf = requestAnimationFrame(() => { + this.resizeCanvas() + }) + }) + } + + window.addEventListener('resize', this.onResizeCanvas) + screen?.orientation?.addEventListener('change', this.onResizeCanvas) + + if (typeof ResizeObserver !== 'undefined' && this.canvas?.parentNode) { + this.resizeObserver = new ResizeObserver(this.onResizeCanvas) + this.resizeObserver.observe(this.canvas.parentNode) + } + } + + teardownCanvasSizing () { + if (this.resizeRaf) { + cancelAnimationFrame(this.resizeRaf) + this.resizeRaf = null + } + + if (this.onResizeCanvas) { + window.removeEventListener('resize', this.onResizeCanvas) + screen?.orientation?.removeEventListener('change', this.onResizeCanvas) + } + + this.resizeObserver?.disconnect() + } + + resizeCanvas () { + if (!this.canvas?.parentNode) { + return + } + + const width = this.canvas.parentNode.clientWidth + const height = this.canvas.parentNode.clientHeight + + if (!width || !height) { + return + } + + const nextW = width * this.scale + const nextH = height * this.scale + + if (this.canvas.width === nextW && this.canvas.height === nextH) { + return + } + + const prevCssW = this.canvas.width / this.scale + const prevCssH = this.canvas.height / this.scale + const ratioX = prevCssW > 0 ? width / prevCssW : 1 + const ratioY = prevCssH > 0 ? height / prevCssH : 1 + + let data = [] + + if (this.pad) { + data = this.pad.toData() + + if (data.length && (ratioX !== 1 || ratioY !== 1)) { + data = data.map((group) => ({ + ...group, + points: group.points.map((point) => ({ + ...point, + x: point.x * ratioX, + y: point.y * ratioY + })) + })) + } + } + + this.canvas.width = nextW + this.canvas.height = nextH + this.canvas.getContext('2d').scale(this.scale, this.scale) + + if (this.pad) { + this.pad.clear() + + if (data.length) { + this.pad.fromData(data) + } + } } async submit () { diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue index 4bc61cd454..5190329fe7 100644 --- a/app/javascript/submission_form/initials_step.vue +++ b/app/javascript/submission_form/initials_step.vue @@ -218,15 +218,6 @@ export default { } }, async mounted () { - this.$nextTick(() => { - if (this.$refs.canvas) { - this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale - this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 4.5) * scale - - this.$refs.canvas.getContext('2d').scale(scale, scale) - } - }) - if (this.$refs.canvas) { this.pad = new SignaturePad(this.$refs.canvas) @@ -240,26 +231,118 @@ export default { this.$emit('start') }) - this.intersectionObserver = new IntersectionObserver((entries, observer) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale - this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 4.5) * scale + this.setupCanvasSizing() + } + }, + beforeUnmount () { + this.teardownCanvasSizing() + }, + methods: { + canvasHeightForWidth (width) { + return (width / 4.5) * scale + }, + setupCanvasSizing () { + this.resizeCanvas() + + this.onResizeCanvas = () => { + if (this.resizeRaf) { + cancelAnimationFrame(this.resizeRaf) + } - this.$refs.canvas.getContext('2d').scale(scale, scale) + // Double rAF: orientationchange often fires before layout has settled. + this.resizeRaf = requestAnimationFrame(() => { + this.resizeRaf = requestAnimationFrame(() => { + this.resizeCanvas() + }) + }) + } - this.intersectionObserver?.disconnect() + window.addEventListener('resize', this.onResizeCanvas) + screen?.orientation?.addEventListener('change', this.onResizeCanvas) + + if (typeof ResizeObserver !== 'undefined' && this.$refs.canvas?.parentNode) { + this.resizeObserver = new ResizeObserver(this.onResizeCanvas) + this.resizeObserver.observe(this.$refs.canvas.parentNode) + } + + this.intersectionObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + this.resizeCanvas() } }) }) this.intersectionObserver.observe(this.$refs.canvas) - } - }, - beforeUnmount () { - this.intersectionObserver?.disconnect() - }, - methods: { + }, + teardownCanvasSizing () { + if (this.resizeRaf) { + cancelAnimationFrame(this.resizeRaf) + this.resizeRaf = null + } + + if (this.onResizeCanvas) { + window.removeEventListener('resize', this.onResizeCanvas) + screen?.orientation?.removeEventListener('change', this.onResizeCanvas) + } + + this.resizeObserver?.disconnect() + this.intersectionObserver?.disconnect() + }, + resizeCanvas () { + const canvas = this.$refs.canvas + + if (!canvas?.parentNode) { + return + } + + const width = canvas.parentNode.clientWidth + + if (!width) { + return + } + + const nextW = width * scale + const nextH = this.canvasHeightForWidth(width) + + if (canvas.width === nextW && canvas.height === nextH) { + return + } + + const prevCssW = canvas.width / scale + const ratio = prevCssW > 0 ? width / prevCssW : 1 + + let data = [] + + if (this.pad) { + data = this.pad.toData() + + if (data.length && ratio !== 1) { + data = data.map((group) => ({ + ...group, + points: group.points.map((point) => ({ + ...point, + x: point.x * ratio, + y: point.y * ratio + })) + })) + } + } + + canvas.width = nextW + canvas.height = nextH + canvas.getContext('2d').scale(scale, scale) + + if (this.pad) { + this.pad.clear() + + if (data.length) { + this.pad.fromData(data) + } else if (!this.isDrawInitials && this.$refs.textInput?.value) { + this.updateWrittenInitials({ target: this.$refs.textInput }) + } + } + }, drawOnCanvas: SignatureStep.methods.drawOnCanvas, drawImage (event) { this.remove() diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index d85c8c8594..efbfafe7cb 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -428,15 +428,6 @@ export default { } }, async mounted () { - this.$nextTick(() => { - if (this.$refs.canvas) { - 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) - } - }) - if (this.$refs.canvas) { this.pad = new SignaturePad(this.$refs.canvas) @@ -450,27 +441,119 @@ export default { this.$emit('start') }) - this.intersectionObserver = new IntersectionObserver((entries, observer) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale - this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3 + this.setupCanvasSizing() + } + }, + beforeUnmount () { + this.teardownCanvasSizing() + this.stopCheckSignature() + }, + methods: { + canvasHeightForWidth (width) { + return width * scale / 3 + }, + setupCanvasSizing () { + this.resizeCanvas() - this.$refs.canvas.getContext('2d').scale(scale, scale) + this.onResizeCanvas = () => { + if (this.resizeRaf) { + cancelAnimationFrame(this.resizeRaf) + } - this.intersectionObserver?.disconnect() + // Double rAF: orientationchange often fires before layout has settled. + this.resizeRaf = requestAnimationFrame(() => { + this.resizeRaf = requestAnimationFrame(() => { + this.resizeCanvas() + }) + }) + } + + window.addEventListener('resize', this.onResizeCanvas) + screen?.orientation?.addEventListener('change', this.onResizeCanvas) + + if (typeof ResizeObserver !== 'undefined' && this.$refs.canvas?.parentNode) { + this.resizeObserver = new ResizeObserver(this.onResizeCanvas) + this.resizeObserver.observe(this.$refs.canvas.parentNode) + } + + this.intersectionObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + this.resizeCanvas() } }) }) this.intersectionObserver.observe(this.$refs.canvas) - } - }, - beforeUnmount () { - this.intersectionObserver?.disconnect() - this.stopCheckSignature() - }, - methods: { + }, + teardownCanvasSizing () { + if (this.resizeRaf) { + cancelAnimationFrame(this.resizeRaf) + this.resizeRaf = null + } + + if (this.onResizeCanvas) { + window.removeEventListener('resize', this.onResizeCanvas) + screen?.orientation?.removeEventListener('change', this.onResizeCanvas) + } + + this.resizeObserver?.disconnect() + this.intersectionObserver?.disconnect() + }, + resizeCanvas () { + const canvas = this.$refs.canvas + + if (!canvas?.parentNode) { + return + } + + const width = canvas.parentNode.clientWidth + + if (!width) { + return + } + + const nextW = width * scale + const nextH = this.canvasHeightForWidth(width) + + if (canvas.width === nextW && canvas.height === nextH) { + return + } + + const prevCssW = canvas.width / scale + const ratio = prevCssW > 0 ? width / prevCssW : 1 + + let data = [] + + if (this.pad) { + data = this.pad.toData() + + if (data.length && ratio !== 1) { + data = data.map((group) => ({ + ...group, + points: group.points.map((point) => ({ + ...point, + x: point.x * ratio, + y: point.y * ratio + })) + })) + } + } + + canvas.width = nextW + canvas.height = nextH + canvas.getContext('2d').scale(scale, scale) + + if (this.pad) { + this.pad.clear() + + if (data.length) { + this.pad.fromData(data) + } else if (this.isTextSignature && this.$refs.textInput?.value) { + this.updateWrittenSignature({ target: this.$refs.textInput }) + } + } + }, remove () { this.$emit('update:model-value', '')