Skip to content

Commit b709509

Browse files
authored
Signature Pad Form Input (#178)
1 parent ed6a1a7 commit b709509

File tree

14 files changed

+390
-8
lines changed

14 files changed

+390
-8
lines changed

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"i18next": "^24.2.0",
128128
"i18next-browser-languagedetector": "^8.0.0",
129129
"jsdom": "^26.0.0",
130+
"perfect-freehand": "^1.2.2",
130131
"pulltorefreshjs": "^0.1.22",
131132
"svelte": "^5.16.2",
132133
"svelte-i18next": "^2.2.2"

setupTest.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,11 @@ import * as matchers from "@testing-library/jest-dom/matchers";
22
import { expect } from "vitest";
33

44
expect.extend(matchers);
5+
6+
class ResizeObserver {
7+
observe() {}
8+
unobserve() {}
9+
disconnect() {}
10+
}
11+
12+
window.ResizeObserver = ResizeObserver;

src/lib/modules/forms/form-inputs/StringInput.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
label: string;
99
placeholder: string;
1010
value: string;
11-
error: string;
11+
error?: string;
1212
} = $props();
1313
</script>
1414

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<script lang="ts">
2+
import { t } from "$lib/stores/i18n.store";
3+
import { onMount } from "svelte";
4+
import { SignaturePadAction } from "./signature-pad-action";
5+
6+
let { signature = $bindable(), disabled }: { signature: string; disabled?: boolean } = $props();
7+
let layers: { path: string; width: number; height: number }[] = $state([]);
8+
let width: number = $state(undefined);
9+
let height: number = $state(undefined);
10+
let preview: string = $state(undefined);
11+
12+
onMount(() => {
13+
if (signature) {
14+
const parser = new DOMParser();
15+
const svg = parser.parseFromString(atob(signature.split(",")[1]), "image/svg+xml");
16+
layers = Array.from(svg.querySelectorAll("path")).map((path) => ({
17+
path: path.getAttribute("d"),
18+
width,
19+
height
20+
}));
21+
}
22+
});
23+
24+
const ondraw = (path: string) => {
25+
if (disabled) return;
26+
preview = path;
27+
};
28+
const oncomplete = (path: string) => {
29+
if (disabled) return;
30+
preview = "";
31+
layers = [...layers, { width, height, path }];
32+
signature =
33+
"data:image/svg+xml;base64," +
34+
btoa(
35+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${layers.map((l) => `<path d="${l.path}" />`)}</svg>`
36+
);
37+
};
38+
39+
const clear = () => {
40+
layers = [];
41+
signature = undefined;
42+
};
43+
</script>
44+
45+
<!-- svelte-ignore a11y_no_static_element_interactions -->
46+
<div class="input input-bordered relative h-[360px] w-full px-0">
47+
<div class="absolute bottom-24 left-4 right-4 border-t border-dotted border-gray-300"></div>
48+
<!-- svelte-ignore a11y_click_events_have_key_events -->
49+
<div
50+
class="h-full w-full"
51+
use:SignaturePadAction={{ ondraw, oncomplete }}
52+
bind:clientWidth={width}
53+
bind:clientHeight={height}
54+
on:click|preventDefault|self={() => false}
55+
on:touchmove|preventDefault|self={() => false}
56+
>
57+
<svg
58+
class="pointer-events-none absolute h-full w-full fill-black"
59+
viewBox="0 0 {width} {height}"
60+
>
61+
{#each layers as layer}
62+
<path d={layer.path} />
63+
{/each}
64+
</svg>
65+
66+
{#if preview}
67+
<svg
68+
class="pointer-events-none absolute h-full w-full fill-gray-900"
69+
viewBox="0 0 {width} {height}"
70+
>
71+
<path d={preview} />
72+
</svg>
73+
{/if}
74+
</div>
75+
{#if !disabled}
76+
<button class="btn btn-outline btn-primary btn-sm absolute right-2 top-2" on:click={clear}
77+
>{$t("actions.clear")}</button
78+
>
79+
{/if}
80+
</div>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, test, expect } from "vitest";
2+
import { SignaturePadInput } from "../..";
3+
import { render } from "@testing-library/svelte";
4+
import { ticTacToeBase64 } from "./snapshots/tic-tac-toe";
5+
6+
describe("SignaturePadInput", () => {
7+
test("should render", async () => {
8+
const { container } = render(SignaturePadInput);
9+
await expect(container).toMatchFileSnapshot("snapshots/SignaturePadInput-1.html");
10+
});
11+
12+
test("should render with signature", async () => {
13+
const { container } = render(SignaturePadInput, {
14+
props: {
15+
signature: ticTacToeBase64
16+
}
17+
});
18+
19+
await expect(container).toMatchFileSnapshot("snapshots/SignaturePadInput-2.html");
20+
});
21+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { getStroke, type StrokeOptions } from "perfect-freehand";
2+
import { cubicInOut } from "svelte/easing";
3+
4+
const linear = (t: number) => t;
5+
const average = (a: number, b: number) => (a + b) / 2;
6+
7+
function getSvgPathFromStroke(points: number[][], closed = true): string {
8+
const len = points.length;
9+
10+
if (len < 4) {
11+
return "";
12+
}
13+
14+
let a = points[0];
15+
let b = points[1];
16+
const c = points[2];
17+
18+
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(2)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
19+
b[1],
20+
c[1]
21+
).toFixed(2)} T`;
22+
23+
for (let i = 2, max = len - 1; i < max; i++) {
24+
a = points[i];
25+
b = points[i + 1];
26+
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(2)} `;
27+
}
28+
29+
if (closed) {
30+
result += "Z";
31+
}
32+
33+
return result;
34+
}
35+
36+
const strokeOptions: StrokeOptions = {
37+
size: 8,
38+
thinning: 0.7,
39+
smoothing: 0.4,
40+
streamline: 0.6,
41+
easing: linear,
42+
start: {
43+
taper: 0,
44+
easing: cubicInOut,
45+
cap: true
46+
},
47+
end: {
48+
taper: 0,
49+
easing: cubicInOut,
50+
cap: true
51+
}
52+
};
53+
54+
interface Options {
55+
ondraw: (path: string) => void;
56+
oncomplete: (path: string) => void;
57+
strokeOptions?: StrokeOptions;
58+
}
59+
60+
export function SignaturePadAction(node: HTMLElement, options: Options) {
61+
const points: number[][] = [];
62+
63+
if (!options.strokeOptions) options.strokeOptions = {};
64+
options.strokeOptions = { ...strokeOptions, ...options.strokeOptions };
65+
66+
function render(complete: boolean) {
67+
const stroke = getStroke(points, strokeOptions);
68+
const path = getSvgPathFromStroke(stroke);
69+
if (complete) {
70+
options.oncomplete(path);
71+
} else {
72+
options.ondraw(path);
73+
}
74+
}
75+
76+
let down = false;
77+
78+
function pointerDown(e: PointerEvent) {
79+
node.setPointerCapture(e.pointerId);
80+
points.push([e.offsetX, e.offsetY, e.pressure]);
81+
render(false);
82+
down = true;
83+
}
84+
85+
function pointerMove(e: PointerEvent) {
86+
if (down && e.isPrimary) {
87+
points.push([e.offsetX, e.offsetY, e.pressure]);
88+
render(false);
89+
}
90+
}
91+
92+
function pointerUp(e: PointerEvent) {
93+
node.releasePointerCapture(e.pointerId);
94+
95+
render(true);
96+
97+
down = false;
98+
points.length = 0;
99+
}
100+
101+
node.addEventListener("pointerdown", pointerDown, { passive: true });
102+
node.addEventListener("pointermove", pointerMove, { passive: true });
103+
node.addEventListener("pointerup", pointerUp, { passive: true });
104+
node.addEventListener("pointercancel", pointerUp, { passive: true });
105+
106+
return {
107+
destroy() {
108+
node.removeEventListener("pointerdown", pointerDown);
109+
node.removeEventListener("pointermove", pointerMove);
110+
node.removeEventListener("pointerup", pointerUp);
111+
node.removeEventListener("pointercancel", pointerUp);
112+
}
113+
};
114+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<div>
2+
<div
3+
class="input input-bordered relative h-[360px] w-full px-0"
4+
>
5+
<div
6+
class="absolute bottom-24 left-4 right-4 border-t border-dotted border-gray-300"
7+
/>
8+
9+
<div
10+
class="h-full w-full"
11+
>
12+
<svg
13+
class="pointer-events-none absolute h-full w-full fill-black"
14+
viewBox="0 0 0 0"
15+
>
16+
17+
</svg>
18+
19+
<!---->
20+
</div>
21+
22+
<button
23+
class="btn btn-outline btn-primary btn-sm absolute right-2 top-2"
24+
>
25+
26+
</button>
27+
<!---->
28+
</div>
29+
30+
</div>

0 commit comments

Comments
 (0)