Skip to content

Commit e9bf7ba

Browse files
authored
[WC-3537]: Fix resizing issue with Signature pad (#2375)
2 parents b21bba9 + f742863 commit e9bf7ba

6 files changed

Lines changed: 181 additions & 70 deletions

File tree

packages/pluggableWidgets/signature-web/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- We fixed an issue where strokes stopped being registered in some cases.
12+
13+
- We fixed an issue where the signature canvas was initialized at the wrong size and did not fill its container.
14+
915
## [2.0.1] - 2026-07-17
1016

1117
### Fixed

packages/pluggableWidgets/signature-web/src/Signature.editorPreview.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@ export function preview(props: SignaturePreviewProps): ReactElement {
2626
return (
2727
<SizeContainer
2828
className={classNames("widget-signature-preview", props.class)}
29-
classNameInner={classNames("widget-signature-wrapper", "form-control", "mx-textarea-input", "mx-textarea", {
30-
disabled: props.readOnly
31-
})}
3229
widthUnit={widthUnit}
3330
width={width || 100}
3431
heightUnit={heightUnit}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import "@testing-library/jest-dom";
2+
import { act, render } from "@testing-library/react";
3+
import { ReactElement } from "react";
4+
import SignaturePad from "signature_pad";
5+
import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils";
6+
import { SignatureContainerProps } from "../../typings/SignatureProps";
7+
import { useSignaturePad } from "../utils/useSignaturePad";
8+
9+
type ImageSource = SignatureContainerProps["imageSource"];
10+
11+
jest.mock("signature_pad", () => ({
12+
__esModule: true,
13+
default: jest.fn().mockImplementation(function (this: any) {
14+
this.on = jest.fn();
15+
this.off = jest.fn();
16+
this.redraw = jest.fn();
17+
this.addEventListener = jest.fn();
18+
this.isEmpty = jest.fn(() => true);
19+
})
20+
}));
21+
22+
const MockSignaturePad = SignaturePad as jest.MockedClass<typeof SignaturePad>;
23+
24+
global.ResizeObserver = jest.fn().mockImplementation(() => ({
25+
observe: jest.fn(),
26+
unobserve: jest.fn(),
27+
disconnect: jest.fn()
28+
}));
29+
30+
function buildImageSource(overrides: Partial<ImageSource> = {}): ImageSource {
31+
return {
32+
...new EditableValueBuilder<string>().isUnavailable().build(),
33+
...overrides
34+
} as unknown as ImageSource;
35+
}
36+
37+
// Wrapper component that mounts both refs into real DOM nodes
38+
function TestHarness({ imageSource }: { imageSource: ImageSource }): ReactElement {
39+
const { containerRef, canvasRef } = useSignaturePad({ imageSource, penType: "ballpoint", penColor: "#000000" });
40+
return (
41+
<div ref={containerRef} data-testid="container">
42+
<canvas ref={canvasRef} data-testid="canvas" />
43+
</div>
44+
);
45+
}
46+
47+
describe("useSignaturePad — canvas initialization", () => {
48+
// jsdom doesn't do layout, so offsetWidth/Height are always 0.
49+
// Stub them on the prototype before each test so the init effect reads real numbers.
50+
let offsetWidthSpy: jest.SpyInstance;
51+
let offsetHeightSpy: jest.SpyInstance;
52+
53+
beforeEach(() => {
54+
jest.clearAllMocks();
55+
});
56+
57+
afterEach(() => {
58+
offsetWidthSpy?.mockRestore();
59+
offsetHeightSpy?.mockRestore();
60+
});
61+
62+
function stubContainerDimensions(width: number, height: number): void {
63+
offsetWidthSpy = jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(width);
64+
offsetHeightSpy = jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(height);
65+
}
66+
67+
it("sizes canvas to containerRef dimensions when imageSource is unavailable", () => {
68+
stubContainerDimensions(400, 200);
69+
const imageSource = buildImageSource();
70+
71+
const { getByTestId } = render(<TestHarness imageSource={imageSource} />);
72+
73+
const canvas = getByTestId("canvas") as HTMLCanvasElement;
74+
expect(canvas.width).toBe(400);
75+
expect(canvas.height).toBe(200);
76+
expect(MockSignaturePad).toHaveBeenCalledWith(canvas, expect.any(Object));
77+
});
78+
79+
it("sizes canvas to containerRef dimensions when imageSource is available with a value", () => {
80+
stubContainerDimensions(600, 300);
81+
const imageSource = buildImageSource({
82+
status: "available" as any,
83+
value: { uri: "data:image/png;base64,abc" } as any,
84+
readOnly: false
85+
});
86+
87+
const { getByTestId } = render(<TestHarness imageSource={imageSource} />);
88+
89+
const canvas = getByTestId("canvas") as HTMLCanvasElement;
90+
expect(canvas.width).toBe(600);
91+
expect(canvas.height).toBe(300);
92+
expect(MockSignaturePad).toHaveBeenCalledWith(canvas, expect.any(Object));
93+
});
94+
95+
it("does not initialize SignaturePad when imageSource is still loading", () => {
96+
const imageSource = buildImageSource({ status: "loading" as any, readOnly: true });
97+
98+
render(<TestHarness imageSource={imageSource} />);
99+
100+
act(() => {});
101+
102+
expect(MockSignaturePad).not.toHaveBeenCalled();
103+
});
104+
});

packages/pluggableWidgets/signature-web/src/components/Signature.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,13 @@ export function SignatureComponent(props: SignatureContainerProps): ReactElement
2525
}
2626
};
2727

28-
const { canvasRef, onResize } = useSignaturePad(props, handleSignEnd);
28+
const { canvasRef, containerRef } = useSignaturePad(props, handleSignEnd);
2929

3030
return (
3131
<SizeContainer
3232
{...props}
33+
ref={containerRef}
3334
className={classNames("widget-signature", className)}
34-
classNameInner={classNames("widget-signature-wrapper", "form-control", "mx-textarea-input", "mx-textarea", {
35-
disabled: readOnly
36-
})}
37-
onResize={onResize}
3835
readOnly={readOnly}
3936
>
4037
{validation && <ValidationAlert>{validation}</ValidationAlert>}

packages/pluggableWidgets/signature-web/src/components/SizeContainer.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
11
import classNames from "classnames";
2-
import { CSSProperties, FC, PropsWithChildren, RefObject, useMemo } from "react";
3-
import { useResizeObserver } from "@mendix/widget-plugin-hooks/useResizeObserver";
2+
import { CSSProperties, ForwardedRef, forwardRef, PropsWithChildren, useMemo } from "react";
43
import { constructWrapperStyle, DimensionsProps } from "../utils/dimensions";
4+
55
export interface SizeProps extends DimensionsProps, PropsWithChildren {
66
className: string;
7-
classNameInner?: string;
87
readOnly?: boolean;
98
style?: CSSProperties;
10-
onResize?: () => void;
119
tabIndex?: number;
1210
}
1311

14-
export const SizeContainer: FC<SizeProps> = (props: SizeProps) => {
12+
export const SizeContainer = forwardRef(function SizeContainer(props: SizeProps, ref: ForwardedRef<HTMLDivElement>) {
1513
const {
1614
className,
1715
children,
18-
classNameInner,
1916
readOnly = false,
20-
onResize,
2117
widthUnit,
2218
width,
2319
heightUnit,
@@ -29,7 +25,6 @@ export const SizeContainer: FC<SizeProps> = (props: SizeProps) => {
2925
overflowY,
3026
tabIndex
3127
} = props;
32-
const ref = useResizeObserver(() => onResize?.()) as RefObject<HTMLDivElement>;
3328
const wrapperStyle = useMemo(
3429
() =>
3530
constructWrapperStyle({
@@ -55,9 +50,19 @@ export const SizeContainer: FC<SizeProps> = (props: SizeProps) => {
5550
}}
5651
tabIndex={tabIndex}
5752
>
58-
<div className={classNames("size-box-inner", classNameInner)} aria-disabled={readOnly}>
53+
<div
54+
className={classNames(
55+
"size-box-inner",
56+
"widget-signature-wrapper",
57+
"form-control",
58+
"mx-textarea-input",
59+
"mx-textarea",
60+
{ disabled: readOnly }
61+
)}
62+
aria-disabled={readOnly}
63+
>
5964
{children}
6065
</div>
6166
</div>
6267
);
63-
};
68+
});
Lines changed: 54 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,23 @@
1-
import { RefObject, useCallback, useEffect, useMemo, useRef } from "react";
1+
import { RefObject, useCallback, useEffect, useRef } from "react";
22
import SignaturePad, { Options } from "signature_pad";
3-
import { SignatureContainerProps } from "../../typings/SignatureProps";
4-
5-
function usePrevious<T>(value: T): T | null {
6-
const ref = useRef<T>(null);
7-
useEffect(() => {
8-
ref.current = value;
9-
}, [value]);
10-
return ref.current;
11-
}
3+
import { useResizeObserver } from "@mendix/widget-plugin-hooks/useResizeObserver";
4+
import { PenTypeEnum, SignatureContainerProps } from "../../typings/SignatureProps";
125

136
export function useSignaturePad(
147
props: Pick<SignatureContainerProps, "imageSource" | "hasSignatureAttribute" | "penType" | "penColor">,
158
onSignEnd?: (imageDataURL?: string) => void
169
): {
17-
signaturePadRef: RefObject<SignaturePad | null>;
1810
canvasRef: RefObject<HTMLCanvasElement | null>;
19-
onResize?: () => void;
11+
containerRef: RefObject<HTMLDivElement | null>;
2012
} {
2113
const { imageSource, hasSignatureAttribute, penType, penColor } = props;
2214
const readOnly = imageSource.readOnly;
2315
const signaturePadRef = useRef<SignaturePad | null>(null);
2416
const canvasRef = useRef<HTMLCanvasElement | null>(null);
25-
const isSignatureInitialized = useRef(false);
2617
const hasSignature = usePrevious<boolean>(hasSignatureAttribute?.value ?? false) ?? false;
2718

28-
const signaturePadOptions: Options = useMemo(() => {
29-
let options: Options = {};
30-
if (penType === "fountain") {
31-
options = { minWidth: 0.6, maxWidth: 2.6, velocityFilterWeight: 0.6 };
32-
} else if (penType === "ballpoint") {
33-
options = { minWidth: 1.4, maxWidth: 1.5, velocityFilterWeight: 1.5 };
34-
} else if (penType === "marker") {
35-
options = { minWidth: 2, maxWidth: 4, velocityFilterWeight: 0.9 };
36-
}
37-
return options;
38-
}, [penType]);
39-
4019
const handleSignEnd = useCallback(() => {
4120
const imageDataUrl = signaturePadRef.current?.toDataURL();
42-
4321
if (hasSignatureAttribute) {
4422
hasSignatureAttribute.setValue(!signaturePadRef.current?.isEmpty());
4523
}
@@ -57,26 +35,31 @@ export function useSignaturePad(
5735
}
5836
}, [readOnly]);
5937

60-
const onResize = (): void => {
61-
if (canvasRef.current && signaturePadRef.current) {
62-
const data = signaturePadRef.current.toData();
63-
canvasRef.current.width =
64-
canvasRef.current && canvasRef.current.parentElement ? canvasRef.current.parentElement.offsetWidth : 0;
65-
canvasRef.current.height =
66-
canvasRef.current && canvasRef.current.parentElement ? canvasRef.current.parentElement.offsetHeight : 0;
67-
signaturePadRef.current.clear();
68-
signaturePadRef.current.fromData(data);
69-
}
70-
};
38+
const handleResize = useCallback(
39+
(element: HTMLDivElement) => {
40+
const pad = signaturePadRef.current;
41+
const canvas = canvasRef.current;
42+
if (pad && canvas) {
43+
// off()+on() resets _drawingStroke and clears stale pointer/move listeners,
44+
// preventing pointerdown from being silently dropped after a mid-stroke resize.
45+
pad.off();
46+
canvas.width = element.offsetWidth;
47+
canvas.height = element.offsetHeight;
48+
pad.redraw();
49+
if (!readOnly) {
50+
pad.on();
51+
}
52+
}
53+
},
54+
[readOnly]
55+
);
56+
57+
const containerRef = useResizeObserver(handleResize) as RefObject<HTMLDivElement | null>;
7158

7259
// Clear signature pad when hasSignature value changes from true to false
7360
useEffect(() => {
74-
if (hasSignatureAttribute?.status === "available") {
75-
if (hasSignatureAttribute?.value !== hasSignature) {
76-
if (hasSignature === true) {
77-
signaturePadRef.current?.clear();
78-
}
79-
}
61+
if (hasSignatureAttribute?.status === "available" && hasSignature && hasSignatureAttribute.value === false) {
62+
signaturePadRef.current?.clear();
8063
}
8164
}, [hasSignature, hasSignatureAttribute?.status, hasSignatureAttribute?.value]);
8265

@@ -88,19 +71,38 @@ export function useSignaturePad(
8871
const canInstantiateSignaturePad =
8972
signaturePadRef.current === null &&
9073
(imageSource?.status === "available" ? imageSource.value?.uri : imageSource.status === "unavailable");
91-
if (canInstantiateSignaturePad && !isSignatureInitialized.current) {
92-
signaturePadRef.current = new SignaturePad(localCanvas, {
93-
penColor,
94-
...signaturePadOptions
95-
});
74+
if (canInstantiateSignaturePad) {
75+
const container = containerRef.current;
76+
if (container) {
77+
localCanvas.width = container.offsetWidth;
78+
localCanvas.height = container.offsetHeight;
79+
}
80+
signaturePadRef.current = new SignaturePad(localCanvas, { penColor, ...getPenOptions(penType) });
9681
signaturePadRef.current.addEventListener("endStroke", handleSignEnd);
9782
if (readOnly) {
98-
signaturePadRef.current?.off();
83+
signaturePadRef.current.off();
9984
}
100-
isSignatureInitialized.current = true;
10185
}
10286
}
103-
}, [handleSignEnd, penColor, readOnly, signaturePadOptions, imageSource, hasSignatureAttribute]);
87+
}, [handleSignEnd, penColor, penType, readOnly, imageSource, hasSignatureAttribute, containerRef]);
88+
89+
return { canvasRef, containerRef };
90+
}
91+
92+
const PEN_OPTIONS: Record<PenTypeEnum, Options> = {
93+
fountain: { minWidth: 0.6, maxWidth: 2.6, velocityFilterWeight: 0.6 },
94+
ballpoint: { minWidth: 1.4, maxWidth: 1.5, velocityFilterWeight: 1.5 },
95+
marker: { minWidth: 2, maxWidth: 4, velocityFilterWeight: 0.9 }
96+
};
97+
98+
function getPenOptions(penType: PenTypeEnum): Options {
99+
return PEN_OPTIONS[penType];
100+
}
104101

105-
return { signaturePadRef, canvasRef, onResize };
102+
function usePrevious<T>(value: T): T | null {
103+
const ref = useRef<T>(null);
104+
useEffect(() => {
105+
ref.current = value;
106+
}, [value]);
107+
return ref.current;
106108
}

0 commit comments

Comments
 (0)