@classic-homes/theme-svelte 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/components/Combobox.svelte +187 -0
- package/dist/lib/components/Combobox.svelte.d.ts +38 -0
- package/dist/lib/components/DateTimePicker.svelte +415 -0
- package/dist/lib/components/DateTimePicker.svelte.d.ts +31 -0
- package/dist/lib/components/MultiSelect.svelte +244 -0
- package/dist/lib/components/MultiSelect.svelte.d.ts +40 -0
- package/dist/lib/components/NumberInput.svelte +205 -0
- package/dist/lib/components/NumberInput.svelte.d.ts +33 -0
- package/dist/lib/components/OTPInput.svelte +213 -0
- package/dist/lib/components/OTPInput.svelte.d.ts +23 -0
- package/dist/lib/components/RadioGroup.svelte +124 -0
- package/dist/lib/components/RadioGroup.svelte.d.ts +31 -0
- package/dist/lib/components/Signature.svelte +1070 -0
- package/dist/lib/components/Signature.svelte.d.ts +74 -0
- package/dist/lib/components/Slider.svelte +136 -0
- package/dist/lib/components/Slider.svelte.d.ts +30 -0
- package/dist/lib/components/layout/AppShell.svelte +1 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +63 -16
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +12 -10
- package/dist/lib/components/layout/QuickLinks.svelte +49 -29
- package/dist/lib/components/layout/QuickLinks.svelte.d.ts +4 -2
- package/dist/lib/components/layout/Sidebar.svelte +345 -86
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +12 -0
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +182 -0
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte.d.ts +18 -0
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +369 -0
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +25 -0
- package/dist/lib/components/layout/sidebar/SidebarSearch.svelte +121 -0
- package/dist/lib/components/layout/sidebar/SidebarSearch.svelte.d.ts +17 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +144 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +25 -0
- package/dist/lib/components/layout/sidebar/index.d.ts +10 -0
- package/dist/lib/components/layout/sidebar/index.js +10 -0
- package/dist/lib/index.d.ts +9 -1
- package/dist/lib/index.js +8 -0
- package/dist/lib/schemas/auth.d.ts +6 -6
- package/dist/lib/stores/sidebar.svelte.d.ts +54 -0
- package/dist/lib/stores/sidebar.svelte.js +171 -1
- package/dist/lib/types/components.d.ts +105 -0
- package/dist/lib/types/layout.d.ts +32 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import { cn } from '../utils.js';
|
|
4
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
5
|
+
import type {
|
|
6
|
+
SignatureData,
|
|
7
|
+
SignatureStroke,
|
|
8
|
+
SignaturePoint,
|
|
9
|
+
SignatureMetadata,
|
|
10
|
+
SignatureFont,
|
|
11
|
+
SignatureValidationResult,
|
|
12
|
+
} from '../types/components.js';
|
|
13
|
+
|
|
14
|
+
// Default cursive fonts from Google Fonts
|
|
15
|
+
const defaultFonts: SignatureFont[] = [
|
|
16
|
+
{ family: 'Dancing Script', label: 'Dancing Script', googleFont: true },
|
|
17
|
+
{ family: 'Pacifico', label: 'Pacifico', googleFont: true },
|
|
18
|
+
{ family: 'Satisfy', label: 'Satisfy', googleFont: true },
|
|
19
|
+
{ family: 'Allura', label: 'Allura', googleFont: true },
|
|
20
|
+
{ family: 'Great Vibes', label: 'Great Vibes', googleFont: true },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Styling variants
|
|
24
|
+
const signatureVariants = tv({
|
|
25
|
+
slots: {
|
|
26
|
+
container: 'space-y-3',
|
|
27
|
+
header: 'flex items-center justify-between gap-2',
|
|
28
|
+
labelText: 'text-sm font-medium leading-none',
|
|
29
|
+
modeToggle: 'inline-flex items-center rounded-lg bg-muted p-1',
|
|
30
|
+
modeButton:
|
|
31
|
+
'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
|
32
|
+
modeButtonActive: 'bg-background text-foreground shadow-sm',
|
|
33
|
+
modeButtonInactive: 'text-muted-foreground hover:text-foreground',
|
|
34
|
+
canvasContainer: 'relative w-full overflow-hidden rounded-lg border bg-background',
|
|
35
|
+
canvas: 'block w-full cursor-crosshair touch-none',
|
|
36
|
+
toolbar: 'flex flex-wrap items-center gap-2',
|
|
37
|
+
toolButton:
|
|
38
|
+
'inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
39
|
+
toolGroup: 'flex items-center gap-1.5',
|
|
40
|
+
toolLabel: 'text-xs text-muted-foreground',
|
|
41
|
+
colorPicker:
|
|
42
|
+
'h-8 w-8 cursor-pointer rounded-md border border-input p-0.5 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
43
|
+
thicknessSlider:
|
|
44
|
+
'h-2 w-20 cursor-pointer appearance-none rounded-full bg-muted accent-primary',
|
|
45
|
+
typeInput: 'w-full',
|
|
46
|
+
fontPicker: 'flex flex-wrap gap-2',
|
|
47
|
+
fontButton: 'rounded-lg border-2 px-3 py-2 text-lg transition-colors hover:border-primary/50',
|
|
48
|
+
fontButtonSelected: 'border-primary bg-primary/5',
|
|
49
|
+
fontButtonUnselected: 'border-input',
|
|
50
|
+
consent: 'flex items-start gap-2',
|
|
51
|
+
consentCheckbox:
|
|
52
|
+
'mt-0.5 h-4 w-4 shrink-0 rounded border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
53
|
+
consentLabel: 'text-sm leading-tight text-muted-foreground',
|
|
54
|
+
errorText: 'text-sm text-destructive',
|
|
55
|
+
hintText: 'text-sm text-muted-foreground',
|
|
56
|
+
},
|
|
57
|
+
variants: {
|
|
58
|
+
disabled: {
|
|
59
|
+
true: {
|
|
60
|
+
canvas: 'cursor-not-allowed opacity-50',
|
|
61
|
+
canvasContainer: 'opacity-50',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
hasError: {
|
|
65
|
+
true: {
|
|
66
|
+
canvasContainer: 'border-destructive focus-within:ring-destructive',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
type SignatureVariants = VariantProps<typeof signatureVariants>;
|
|
73
|
+
|
|
74
|
+
interface Props {
|
|
75
|
+
/** Signature data containing image output and metadata */
|
|
76
|
+
value?: SignatureData | null;
|
|
77
|
+
/** Callback when signature changes */
|
|
78
|
+
onValueChange?: (data: SignatureData | null) => void;
|
|
79
|
+
/** Callback when signature is completed (meets validation) */
|
|
80
|
+
onComplete?: (data: SignatureData) => void;
|
|
81
|
+
/** Callback when consent state changes */
|
|
82
|
+
onConsentChange?: (consented: boolean) => void;
|
|
83
|
+
|
|
84
|
+
/** Input mode: draw with stylus/mouse or type with fonts */
|
|
85
|
+
mode?: 'draw' | 'type';
|
|
86
|
+
/** Whether to show mode toggle */
|
|
87
|
+
showModeToggle?: boolean;
|
|
88
|
+
/** Default typed name for type mode */
|
|
89
|
+
typedName?: string;
|
|
90
|
+
|
|
91
|
+
/** Stroke color (default: currentColor) */
|
|
92
|
+
strokeColor?: string;
|
|
93
|
+
/** Stroke width in pixels */
|
|
94
|
+
strokeWidth?: number;
|
|
95
|
+
/** Minimum stroke width for pressure */
|
|
96
|
+
minStrokeWidth?: number;
|
|
97
|
+
/** Maximum stroke width for pressure */
|
|
98
|
+
maxStrokeWidth?: number;
|
|
99
|
+
/** Whether to show stroke customization */
|
|
100
|
+
showStrokeCustomization?: boolean;
|
|
101
|
+
|
|
102
|
+
/** Available fonts for typed signatures */
|
|
103
|
+
fonts?: SignatureFont[];
|
|
104
|
+
/** Selected font family */
|
|
105
|
+
selectedFont?: string;
|
|
106
|
+
|
|
107
|
+
/** Preferred output format */
|
|
108
|
+
outputFormat?: 'png' | 'svg' | 'dataUrl';
|
|
109
|
+
/** PNG/image quality (0-1) */
|
|
110
|
+
imageQuality?: number;
|
|
111
|
+
/** Background color */
|
|
112
|
+
backgroundColor?: 'transparent' | 'white';
|
|
113
|
+
|
|
114
|
+
/** Minimum stroke points required (draw mode) */
|
|
115
|
+
minStrokePoints?: number;
|
|
116
|
+
/** Minimum stroke length in pixels (draw mode) */
|
|
117
|
+
minStrokeLength?: number;
|
|
118
|
+
/** Minimum characters for typed signature */
|
|
119
|
+
minTypedLength?: number;
|
|
120
|
+
/** Custom validation function */
|
|
121
|
+
validate?: (data: SignatureData) => SignatureValidationResult;
|
|
122
|
+
|
|
123
|
+
/** Whether to show consent checkbox */
|
|
124
|
+
showConsent?: boolean;
|
|
125
|
+
/** Consent checkbox text */
|
|
126
|
+
consentText?: string;
|
|
127
|
+
/** Whether consent is required */
|
|
128
|
+
requireConsent?: boolean;
|
|
129
|
+
/** Current consent state */
|
|
130
|
+
consented?: boolean;
|
|
131
|
+
|
|
132
|
+
/** Input ID */
|
|
133
|
+
id?: string;
|
|
134
|
+
/** Name attribute for form submission */
|
|
135
|
+
name?: string;
|
|
136
|
+
/** Whether disabled */
|
|
137
|
+
disabled?: boolean;
|
|
138
|
+
/** Whether required */
|
|
139
|
+
required?: boolean;
|
|
140
|
+
/** Error message */
|
|
141
|
+
error?: string;
|
|
142
|
+
/** Hint text */
|
|
143
|
+
hint?: string;
|
|
144
|
+
/** Label text */
|
|
145
|
+
label?: string;
|
|
146
|
+
|
|
147
|
+
/** Canvas height (CSS value) */
|
|
148
|
+
height?: string;
|
|
149
|
+
|
|
150
|
+
/** Additional class */
|
|
151
|
+
class?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let {
|
|
155
|
+
value = $bindable(null),
|
|
156
|
+
onValueChange,
|
|
157
|
+
onComplete,
|
|
158
|
+
onConsentChange,
|
|
159
|
+
|
|
160
|
+
mode: initialMode = 'draw',
|
|
161
|
+
showModeToggle = true,
|
|
162
|
+
typedName: initialTypedName = '',
|
|
163
|
+
|
|
164
|
+
strokeColor: initialStrokeColor = '#000000',
|
|
165
|
+
strokeWidth: initialStrokeWidth = 2,
|
|
166
|
+
minStrokeWidth = 1,
|
|
167
|
+
maxStrokeWidth = 5,
|
|
168
|
+
showStrokeCustomization = true,
|
|
169
|
+
|
|
170
|
+
fonts = defaultFonts,
|
|
171
|
+
selectedFont: initialSelectedFont,
|
|
172
|
+
|
|
173
|
+
outputFormat = 'dataUrl',
|
|
174
|
+
imageQuality = 0.92,
|
|
175
|
+
backgroundColor = 'white',
|
|
176
|
+
|
|
177
|
+
minStrokePoints = 10,
|
|
178
|
+
minStrokeLength = 50,
|
|
179
|
+
minTypedLength = 2,
|
|
180
|
+
validate: customValidate,
|
|
181
|
+
|
|
182
|
+
showConsent = true,
|
|
183
|
+
consentText = 'I agree to use this as my legal signature and consent to electronic signing.',
|
|
184
|
+
requireConsent = true,
|
|
185
|
+
consented: initialConsented = $bindable(false),
|
|
186
|
+
|
|
187
|
+
id,
|
|
188
|
+
name,
|
|
189
|
+
disabled = false,
|
|
190
|
+
required = false,
|
|
191
|
+
error,
|
|
192
|
+
hint,
|
|
193
|
+
label,
|
|
194
|
+
|
|
195
|
+
height = '200px',
|
|
196
|
+
|
|
197
|
+
class: className,
|
|
198
|
+
}: Props = $props();
|
|
199
|
+
|
|
200
|
+
// Mode state (untrack to intentionally capture initial value only)
|
|
201
|
+
let mode = $state<'draw' | 'type'>(untrack(() => initialMode));
|
|
202
|
+
|
|
203
|
+
// Canvas references
|
|
204
|
+
let canvasContainer: HTMLDivElement;
|
|
205
|
+
let canvas: HTMLCanvasElement;
|
|
206
|
+
let ctx: CanvasRenderingContext2D | null = null;
|
|
207
|
+
|
|
208
|
+
// Drawing state
|
|
209
|
+
let isDrawing = $state(false);
|
|
210
|
+
let currentStroke = $state<SignatureStroke | null>(null);
|
|
211
|
+
let strokes = $state<SignatureStroke[]>([]);
|
|
212
|
+
let devicePixelRatio = $state(1);
|
|
213
|
+
|
|
214
|
+
// Draw customization state (untrack to intentionally capture initial values only)
|
|
215
|
+
let strokeColor = $state(untrack(() => initialStrokeColor));
|
|
216
|
+
let strokeWidth = $state(untrack(() => initialStrokeWidth));
|
|
217
|
+
|
|
218
|
+
// Type mode state (untrack to intentionally capture initial values only)
|
|
219
|
+
let typedName = $state(untrack(() => initialTypedName));
|
|
220
|
+
let selectedFont = $state(
|
|
221
|
+
untrack(() => initialSelectedFont || fonts[0]?.family || 'Dancing Script')
|
|
222
|
+
);
|
|
223
|
+
let fontsLoaded = $state<Set<string>>(new Set());
|
|
224
|
+
|
|
225
|
+
// Consent state (untrack to intentionally capture initial value only)
|
|
226
|
+
let consented = $state(untrack(() => initialConsented));
|
|
227
|
+
|
|
228
|
+
// Validation state
|
|
229
|
+
let validationResult = $state<SignatureValidationResult>({ valid: false });
|
|
230
|
+
|
|
231
|
+
// Track if onComplete has been fired for current signature to prevent duplicates
|
|
232
|
+
let completeFired = $state(false);
|
|
233
|
+
|
|
234
|
+
// Get styles
|
|
235
|
+
const styles = $derived(signatureVariants({ disabled, hasError: !!error }));
|
|
236
|
+
|
|
237
|
+
// Derived: has signature
|
|
238
|
+
const hasSignature = $derived(mode === 'draw' ? strokes.length > 0 : typedName.trim().length > 0);
|
|
239
|
+
|
|
240
|
+
// Derived: is valid
|
|
241
|
+
const isValid = $derived(validationResult.valid && (!requireConsent || consented));
|
|
242
|
+
|
|
243
|
+
// Generate unique ID
|
|
244
|
+
function generateId(): string {
|
|
245
|
+
return `sig_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Calculate stroke length
|
|
249
|
+
function calculateStrokeLength(strokesData: SignatureStroke[]): number {
|
|
250
|
+
let total = 0;
|
|
251
|
+
for (const stroke of strokesData) {
|
|
252
|
+
for (let i = 1; i < stroke.points.length; i++) {
|
|
253
|
+
const dx = stroke.points[i].x - stroke.points[i - 1].x;
|
|
254
|
+
const dy = stroke.points[i].y - stroke.points[i - 1].y;
|
|
255
|
+
total += Math.sqrt(dx * dx + dy * dy);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return total;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Count stroke points
|
|
262
|
+
function countStrokePoints(strokesData: SignatureStroke[]): number {
|
|
263
|
+
return strokesData.reduce((sum, s) => sum + s.points.length, 0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Run validation
|
|
267
|
+
function runValidation(): void {
|
|
268
|
+
if (mode === 'draw') {
|
|
269
|
+
if (strokes.length === 0) {
|
|
270
|
+
validationResult = { valid: false, error: 'Please draw your signature' };
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const points = countStrokePoints(strokes);
|
|
274
|
+
const length = calculateStrokeLength(strokes);
|
|
275
|
+
if (minStrokePoints && points < minStrokePoints) {
|
|
276
|
+
validationResult = {
|
|
277
|
+
valid: false,
|
|
278
|
+
error: 'Signature too simple. Please draw a more complete signature.',
|
|
279
|
+
};
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (minStrokeLength && length < minStrokeLength) {
|
|
283
|
+
validationResult = {
|
|
284
|
+
valid: false,
|
|
285
|
+
error: 'Signature too short. Please draw a more complete signature.',
|
|
286
|
+
};
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
const trimmed = typedName.trim();
|
|
291
|
+
if (!trimmed) {
|
|
292
|
+
validationResult = { valid: false, error: 'Please enter your name' };
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (minTypedLength && trimmed.length < minTypedLength) {
|
|
296
|
+
validationResult = {
|
|
297
|
+
valid: false,
|
|
298
|
+
error: `Name must be at least ${minTypedLength} characters`,
|
|
299
|
+
};
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
validationResult = { valid: true };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Generate hash for tamper detection
|
|
307
|
+
async function calculateHash(data: string): Promise<string> {
|
|
308
|
+
const encoder = new TextEncoder();
|
|
309
|
+
const dataBuffer = encoder.encode(data);
|
|
310
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
|
|
311
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
312
|
+
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Export as PNG data URL
|
|
316
|
+
function exportAsPNG(): string {
|
|
317
|
+
if (!canvas || !ctx) return '';
|
|
318
|
+
const exportCanvas = document.createElement('canvas');
|
|
319
|
+
exportCanvas.width = canvas.width;
|
|
320
|
+
exportCanvas.height = canvas.height;
|
|
321
|
+
const exportCtx = exportCanvas.getContext('2d')!;
|
|
322
|
+
if (backgroundColor === 'white') {
|
|
323
|
+
exportCtx.fillStyle = 'white';
|
|
324
|
+
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
|
325
|
+
}
|
|
326
|
+
exportCtx.drawImage(canvas, 0, 0);
|
|
327
|
+
return exportCanvas.toDataURL('image/png', imageQuality);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Export as PNG Blob
|
|
331
|
+
async function exportAsPNGBlob(): Promise<Blob | undefined> {
|
|
332
|
+
if (!canvas || !ctx) return undefined;
|
|
333
|
+
return new Promise((resolve) => {
|
|
334
|
+
const exportCanvas = document.createElement('canvas');
|
|
335
|
+
exportCanvas.width = canvas.width;
|
|
336
|
+
exportCanvas.height = canvas.height;
|
|
337
|
+
const exportCtx = exportCanvas.getContext('2d')!;
|
|
338
|
+
if (backgroundColor === 'white') {
|
|
339
|
+
exportCtx.fillStyle = 'white';
|
|
340
|
+
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
|
341
|
+
}
|
|
342
|
+
exportCtx.drawImage(canvas, 0, 0);
|
|
343
|
+
exportCanvas.toBlob((blob) => resolve(blob || undefined), 'image/png', imageQuality);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Escape XML special characters
|
|
348
|
+
function escapeXML(str: string): string {
|
|
349
|
+
return str
|
|
350
|
+
.replace(/&/g, '&')
|
|
351
|
+
.replace(/</g, '<')
|
|
352
|
+
.replace(/>/g, '>')
|
|
353
|
+
.replace(/"/g, '"')
|
|
354
|
+
.replace(/'/g, ''');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Export as SVG
|
|
358
|
+
function exportAsSVG(): string {
|
|
359
|
+
if (!canvas) return '';
|
|
360
|
+
const width = canvas.width / devicePixelRatio;
|
|
361
|
+
const height = canvas.height / devicePixelRatio;
|
|
362
|
+
|
|
363
|
+
let paths = '';
|
|
364
|
+
|
|
365
|
+
if (mode === 'draw') {
|
|
366
|
+
for (const stroke of strokes) {
|
|
367
|
+
if (stroke.points.length < 2) continue;
|
|
368
|
+
let d = `M ${stroke.points[0].x} ${stroke.points[0].y}`;
|
|
369
|
+
for (let i = 1; i < stroke.points.length; i++) {
|
|
370
|
+
const prev = stroke.points[i - 1];
|
|
371
|
+
const curr = stroke.points[i];
|
|
372
|
+
const midX = (prev.x + curr.x) / 2;
|
|
373
|
+
const midY = (prev.y + curr.y) / 2;
|
|
374
|
+
d += ` Q ${prev.x} ${prev.y} ${midX} ${midY}`;
|
|
375
|
+
}
|
|
376
|
+
const lastPoint = stroke.points[stroke.points.length - 1];
|
|
377
|
+
d += ` L ${lastPoint.x} ${lastPoint.y}`;
|
|
378
|
+
paths += `<path d="${d}" stroke="${stroke.color}" stroke-width="${stroke.width}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
const fontSize = calculateFontSize(typedName, width * 0.9);
|
|
382
|
+
paths = `<text x="${width / 2}" y="${height / 2}" font-family="${selectedFont}" font-size="${fontSize}" fill="${strokeColor}" text-anchor="middle" dominant-baseline="middle">${escapeXML(typedName)}</text>`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
386
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
387
|
+
${backgroundColor === 'white' ? `<rect width="${width}" height="${height}" fill="white"/>` : ''}
|
|
388
|
+
${paths}
|
|
389
|
+
</svg>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Generate signature data
|
|
393
|
+
async function generateSignatureData(): Promise<SignatureData> {
|
|
394
|
+
const dataUrl = exportAsPNG();
|
|
395
|
+
const svg = exportAsSVG();
|
|
396
|
+
const png = outputFormat === 'png' ? await exportAsPNGBlob() : undefined;
|
|
397
|
+
|
|
398
|
+
const metadata: SignatureMetadata = {
|
|
399
|
+
timestamp: new Date().toISOString(),
|
|
400
|
+
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
|
401
|
+
dimensions: {
|
|
402
|
+
width: canvas ? canvas.width / devicePixelRatio : 0,
|
|
403
|
+
height: canvas ? canvas.height / devicePixelRatio : 0,
|
|
404
|
+
},
|
|
405
|
+
consented,
|
|
406
|
+
consentText: showConsent ? consentText : undefined,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// Calculate hash of signature data
|
|
410
|
+
const hashInput = mode === 'draw' ? JSON.stringify(strokes) : typedName;
|
|
411
|
+
metadata.hash = await calculateHash(hashInput + metadata.timestamp);
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
id: generateId(),
|
|
415
|
+
mode,
|
|
416
|
+
dataUrl,
|
|
417
|
+
png,
|
|
418
|
+
svg,
|
|
419
|
+
typedName: mode === 'type' ? typedName : undefined,
|
|
420
|
+
font: mode === 'type' ? selectedFont : undefined,
|
|
421
|
+
strokes: mode === 'draw' ? [...strokes] : undefined,
|
|
422
|
+
metadata,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Calculate font size to fit canvas
|
|
427
|
+
function calculateFontSize(text: string, maxWidth: number): number {
|
|
428
|
+
if (!ctx) return 48;
|
|
429
|
+
let fontSize = 72;
|
|
430
|
+
ctx.font = `${fontSize}px "${selectedFont}"`;
|
|
431
|
+
let textWidth = ctx.measureText(text).width;
|
|
432
|
+
while (textWidth > maxWidth && fontSize > 16) {
|
|
433
|
+
fontSize -= 2;
|
|
434
|
+
ctx.font = `${fontSize}px "${selectedFont}"`;
|
|
435
|
+
textWidth = ctx.measureText(text).width;
|
|
436
|
+
}
|
|
437
|
+
return fontSize;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Load Google Font
|
|
441
|
+
async function loadFont(font: SignatureFont): Promise<void> {
|
|
442
|
+
if (fontsLoaded.has(font.family)) return;
|
|
443
|
+
if (!font.googleFont) {
|
|
444
|
+
fontsLoaded = new Set([...fontsLoaded, font.family]);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const link = document.createElement('link');
|
|
449
|
+
link.href = `https://fonts.googleapis.com/css2?family=${font.family.replace(/\s+/g, '+')}&display=swap`;
|
|
450
|
+
link.rel = 'stylesheet';
|
|
451
|
+
document.head.appendChild(link);
|
|
452
|
+
|
|
453
|
+
// Wait for font to load
|
|
454
|
+
await document.fonts.load(`48px "${font.family}"`);
|
|
455
|
+
fontsLoaded = new Set([...fontsLoaded, font.family]);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Render typed signature on canvas
|
|
459
|
+
function renderTypedSignature(): void {
|
|
460
|
+
if (!ctx || !canvas) return;
|
|
461
|
+
|
|
462
|
+
const width = canvas.width / devicePixelRatio;
|
|
463
|
+
const height = canvas.height / devicePixelRatio;
|
|
464
|
+
|
|
465
|
+
// Clear canvas
|
|
466
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
467
|
+
|
|
468
|
+
if (!typedName.trim()) return;
|
|
469
|
+
|
|
470
|
+
// Calculate font size
|
|
471
|
+
const fontSize = calculateFontSize(typedName, width * 0.9);
|
|
472
|
+
|
|
473
|
+
// Render text
|
|
474
|
+
ctx.font = `${fontSize}px "${selectedFont}"`;
|
|
475
|
+
ctx.fillStyle = strokeColor;
|
|
476
|
+
ctx.textAlign = 'center';
|
|
477
|
+
ctx.textBaseline = 'middle';
|
|
478
|
+
ctx.fillText(typedName, width / 2, height / 2);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Initialize canvas
|
|
482
|
+
function initializeCanvas(): void {
|
|
483
|
+
if (!canvas) return;
|
|
484
|
+
|
|
485
|
+
devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
|
486
|
+
const rect = canvas.getBoundingClientRect();
|
|
487
|
+
|
|
488
|
+
canvas.width = rect.width * devicePixelRatio;
|
|
489
|
+
canvas.height = rect.height * devicePixelRatio;
|
|
490
|
+
|
|
491
|
+
ctx = canvas.getContext('2d');
|
|
492
|
+
if (!ctx) return;
|
|
493
|
+
|
|
494
|
+
ctx.scale(devicePixelRatio, devicePixelRatio);
|
|
495
|
+
ctx.lineCap = 'round';
|
|
496
|
+
ctx.lineJoin = 'round';
|
|
497
|
+
ctx.strokeStyle = strokeColor;
|
|
498
|
+
ctx.lineWidth = strokeWidth;
|
|
499
|
+
|
|
500
|
+
// Redraw existing content
|
|
501
|
+
if (mode === 'draw') {
|
|
502
|
+
redrawStrokes();
|
|
503
|
+
} else {
|
|
504
|
+
renderTypedSignature();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Redraw all strokes
|
|
509
|
+
function redrawStrokes(): void {
|
|
510
|
+
if (!ctx || !canvas) return;
|
|
511
|
+
|
|
512
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
513
|
+
|
|
514
|
+
for (const stroke of strokes) {
|
|
515
|
+
if (stroke.points.length < 2) continue;
|
|
516
|
+
|
|
517
|
+
ctx.strokeStyle = stroke.color;
|
|
518
|
+
ctx.lineWidth = stroke.width;
|
|
519
|
+
ctx.beginPath();
|
|
520
|
+
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
|
|
521
|
+
|
|
522
|
+
for (let i = 1; i < stroke.points.length; i++) {
|
|
523
|
+
const prev = stroke.points[i - 1];
|
|
524
|
+
const curr = stroke.points[i];
|
|
525
|
+
const midX = (prev.x + curr.x) / 2;
|
|
526
|
+
const midY = (prev.y + curr.y) / 2;
|
|
527
|
+
ctx.quadraticCurveTo(prev.x, prev.y, midX, midY);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const lastPoint = stroke.points[stroke.points.length - 1];
|
|
531
|
+
ctx.lineTo(lastPoint.x, lastPoint.y);
|
|
532
|
+
ctx.stroke();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Get canvas point from pointer event
|
|
537
|
+
function getCanvasPoint(e: PointerEvent): SignaturePoint {
|
|
538
|
+
const rect = canvas.getBoundingClientRect();
|
|
539
|
+
return {
|
|
540
|
+
x: e.clientX - rect.left,
|
|
541
|
+
y: e.clientY - rect.top,
|
|
542
|
+
pressure: e.pressure || 0.5,
|
|
543
|
+
time: Date.now(),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Calculate dynamic stroke width based on pressure
|
|
548
|
+
function calculateDynamicWidth(pressure: number): number {
|
|
549
|
+
return minStrokeWidth + pressure * (maxStrokeWidth - minStrokeWidth);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Scroll lock state
|
|
553
|
+
let scrollLocked = false;
|
|
554
|
+
let previousBodyOverflow = '';
|
|
555
|
+
let previousBodyPosition = '';
|
|
556
|
+
let previousBodyTop = '';
|
|
557
|
+
let previousBodyWidth = '';
|
|
558
|
+
let scrollY = 0;
|
|
559
|
+
|
|
560
|
+
// Lock scroll to prevent page scrolling while drawing on mobile
|
|
561
|
+
function lockScroll(): void {
|
|
562
|
+
if (typeof document === 'undefined' || scrollLocked) return;
|
|
563
|
+
|
|
564
|
+
scrollLocked = true;
|
|
565
|
+
scrollY = window.scrollY;
|
|
566
|
+
|
|
567
|
+
// Save current body styles
|
|
568
|
+
previousBodyOverflow = document.body.style.overflow;
|
|
569
|
+
previousBodyPosition = document.body.style.position;
|
|
570
|
+
previousBodyTop = document.body.style.top;
|
|
571
|
+
previousBodyWidth = document.body.style.width;
|
|
572
|
+
|
|
573
|
+
// Lock the body
|
|
574
|
+
document.body.style.overflow = 'hidden';
|
|
575
|
+
document.body.style.position = 'fixed';
|
|
576
|
+
document.body.style.top = `-${scrollY}px`;
|
|
577
|
+
document.body.style.width = '100%';
|
|
578
|
+
|
|
579
|
+
// Prevent touchmove on document
|
|
580
|
+
document.addEventListener('touchmove', preventTouchMove, { passive: false });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Unlock scroll when done drawing
|
|
584
|
+
function unlockScroll(): void {
|
|
585
|
+
if (typeof document === 'undefined' || !scrollLocked) return;
|
|
586
|
+
|
|
587
|
+
scrollLocked = false;
|
|
588
|
+
|
|
589
|
+
// Restore body styles
|
|
590
|
+
document.body.style.overflow = previousBodyOverflow;
|
|
591
|
+
document.body.style.position = previousBodyPosition;
|
|
592
|
+
document.body.style.top = previousBodyTop;
|
|
593
|
+
document.body.style.width = previousBodyWidth;
|
|
594
|
+
|
|
595
|
+
// Restore scroll position
|
|
596
|
+
window.scrollTo(0, scrollY);
|
|
597
|
+
|
|
598
|
+
// Remove touchmove listener
|
|
599
|
+
document.removeEventListener('touchmove', preventTouchMove);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Prevent touch move events while drawing
|
|
603
|
+
function preventTouchMove(e: TouchEvent): void {
|
|
604
|
+
if (scrollLocked) {
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Start drawing
|
|
610
|
+
function handlePointerDown(e: PointerEvent): void {
|
|
611
|
+
if (disabled || mode !== 'draw') return;
|
|
612
|
+
|
|
613
|
+
e.preventDefault();
|
|
614
|
+
canvas.setPointerCapture(e.pointerId);
|
|
615
|
+
|
|
616
|
+
// Lock scroll on touch devices
|
|
617
|
+
if (e.pointerType === 'touch') {
|
|
618
|
+
lockScroll();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
isDrawing = true;
|
|
622
|
+
const point = getCanvasPoint(e);
|
|
623
|
+
|
|
624
|
+
currentStroke = {
|
|
625
|
+
points: [point],
|
|
626
|
+
color: strokeColor,
|
|
627
|
+
width: strokeWidth,
|
|
628
|
+
startTime: Date.now(),
|
|
629
|
+
endTime: 0,
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
if (ctx) {
|
|
633
|
+
ctx.strokeStyle = strokeColor;
|
|
634
|
+
ctx.lineWidth = strokeWidth;
|
|
635
|
+
ctx.beginPath();
|
|
636
|
+
ctx.moveTo(point.x, point.y);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Continue drawing
|
|
641
|
+
function handlePointerMove(e: PointerEvent): void {
|
|
642
|
+
if (!isDrawing || !currentStroke || !ctx || disabled) return;
|
|
643
|
+
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
const point = getCanvasPoint(e);
|
|
646
|
+
currentStroke.points.push(point);
|
|
647
|
+
|
|
648
|
+
// Draw smooth curve
|
|
649
|
+
if (currentStroke.points.length > 1) {
|
|
650
|
+
const prev = currentStroke.points[currentStroke.points.length - 2];
|
|
651
|
+
const midX = (prev.x + point.x) / 2;
|
|
652
|
+
const midY = (prev.y + point.y) / 2;
|
|
653
|
+
ctx.quadraticCurveTo(prev.x, prev.y, midX, midY);
|
|
654
|
+
ctx.stroke();
|
|
655
|
+
ctx.beginPath();
|
|
656
|
+
ctx.moveTo(midX, midY);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// End drawing
|
|
661
|
+
function handlePointerUp(e: PointerEvent): void {
|
|
662
|
+
if (!isDrawing || !currentStroke || disabled) return;
|
|
663
|
+
|
|
664
|
+
canvas.releasePointerCapture(e.pointerId);
|
|
665
|
+
isDrawing = false;
|
|
666
|
+
currentStroke.endTime = Date.now();
|
|
667
|
+
|
|
668
|
+
// Unlock scroll when done drawing
|
|
669
|
+
unlockScroll();
|
|
670
|
+
|
|
671
|
+
// Only add stroke if it has enough points
|
|
672
|
+
if (currentStroke.points.length > 1) {
|
|
673
|
+
strokes = [...strokes, currentStroke];
|
|
674
|
+
}
|
|
675
|
+
currentStroke = null;
|
|
676
|
+
|
|
677
|
+
runValidation();
|
|
678
|
+
updateValue();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Handle pointer leave
|
|
682
|
+
function handlePointerLeave(e: PointerEvent): void {
|
|
683
|
+
if (isDrawing) {
|
|
684
|
+
handlePointerUp(e);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Clear signature
|
|
689
|
+
function clearSignature(): void {
|
|
690
|
+
strokes = [];
|
|
691
|
+
typedName = '';
|
|
692
|
+
completeFired = false;
|
|
693
|
+
if (ctx && canvas) {
|
|
694
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
695
|
+
}
|
|
696
|
+
runValidation();
|
|
697
|
+
updateValue();
|
|
698
|
+
announceToScreenReader('Signature cleared');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Undo last stroke
|
|
702
|
+
function undoStroke(): void {
|
|
703
|
+
if (strokes.length === 0) return;
|
|
704
|
+
strokes = strokes.slice(0, -1);
|
|
705
|
+
redrawStrokes();
|
|
706
|
+
runValidation();
|
|
707
|
+
updateValue();
|
|
708
|
+
announceToScreenReader('Stroke undone');
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Update value
|
|
712
|
+
async function updateValue(): Promise<void> {
|
|
713
|
+
if (!hasSignature) {
|
|
714
|
+
value = null;
|
|
715
|
+
onValueChange?.(null);
|
|
716
|
+
completeFired = false;
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const data = await generateSignatureData();
|
|
721
|
+
value = data;
|
|
722
|
+
onValueChange?.(data);
|
|
723
|
+
|
|
724
|
+
// Only fire onComplete once per valid signature
|
|
725
|
+
if (isValid && hasSignature && !completeFired) {
|
|
726
|
+
completeFired = true;
|
|
727
|
+
onComplete?.(data);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Handle consent change
|
|
732
|
+
function handleConsentChange(e: Event): void {
|
|
733
|
+
const target = e.target as HTMLInputElement;
|
|
734
|
+
consented = target.checked;
|
|
735
|
+
onConsentChange?.(consented);
|
|
736
|
+
updateValue();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Handle mode change
|
|
740
|
+
function setMode(newMode: 'draw' | 'type'): void {
|
|
741
|
+
if (mode === newMode) return;
|
|
742
|
+
mode = newMode;
|
|
743
|
+
clearSignature();
|
|
744
|
+
announceToScreenReader(`Signature mode changed to ${newMode}`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Handle typed name change
|
|
748
|
+
function handleTypedNameChange(e: Event): void {
|
|
749
|
+
const target = e.target as HTMLInputElement;
|
|
750
|
+
typedName = target.value;
|
|
751
|
+
runValidation();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Handle font selection
|
|
755
|
+
function selectFont(font: SignatureFont): void {
|
|
756
|
+
selectedFont = font.family;
|
|
757
|
+
loadFont(font).then(() => {
|
|
758
|
+
renderTypedSignature();
|
|
759
|
+
updateValue();
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Screen reader announcements
|
|
764
|
+
function announceToScreenReader(message: string): void {
|
|
765
|
+
if (typeof document === 'undefined') return;
|
|
766
|
+
const announcement = document.createElement('div');
|
|
767
|
+
announcement.setAttribute('role', 'status');
|
|
768
|
+
announcement.setAttribute('aria-live', 'polite');
|
|
769
|
+
announcement.setAttribute('aria-atomic', 'true');
|
|
770
|
+
announcement.className = 'sr-only';
|
|
771
|
+
announcement.textContent = message;
|
|
772
|
+
document.body.appendChild(announcement);
|
|
773
|
+
setTimeout(() => announcement.remove(), 1000);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Handle keyboard shortcuts
|
|
777
|
+
function handleKeyDown(e: KeyboardEvent): void {
|
|
778
|
+
if (disabled) return;
|
|
779
|
+
|
|
780
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
|
781
|
+
e.preventDefault();
|
|
782
|
+
if (mode === 'draw') {
|
|
783
|
+
undoStroke();
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
788
|
+
if (document.activeElement === canvas) {
|
|
789
|
+
e.preventDefault();
|
|
790
|
+
clearSignature();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Effects
|
|
796
|
+
$effect(() => {
|
|
797
|
+
// Load all fonts on mount
|
|
798
|
+
fonts.forEach((font) => loadFont(font));
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
$effect(() => {
|
|
802
|
+
// Initialize canvas after mount
|
|
803
|
+
if (canvas) {
|
|
804
|
+
initializeCanvas();
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
$effect(() => {
|
|
809
|
+
// Handle resize
|
|
810
|
+
if (!canvasContainer || typeof window === 'undefined') return;
|
|
811
|
+
|
|
812
|
+
const observer = new ResizeObserver(() => {
|
|
813
|
+
initializeCanvas();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
observer.observe(canvasContainer);
|
|
817
|
+
|
|
818
|
+
return () => observer.disconnect();
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
$effect(() => {
|
|
822
|
+
// Cleanup scroll lock on unmount
|
|
823
|
+
return () => {
|
|
824
|
+
unlockScroll();
|
|
825
|
+
};
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
$effect(() => {
|
|
829
|
+
// Re-render typed signature when font or name changes
|
|
830
|
+
if (mode === 'type' && fontsLoaded.has(selectedFont)) {
|
|
831
|
+
renderTypedSignature();
|
|
832
|
+
updateValue();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
$effect(() => {
|
|
837
|
+
// Update stroke styles when changed
|
|
838
|
+
if (ctx) {
|
|
839
|
+
ctx.strokeStyle = strokeColor;
|
|
840
|
+
ctx.lineWidth = strokeWidth;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
// Computed IDs
|
|
845
|
+
const inputId = $derived(id || `signature-${Math.random().toString(36).substring(2, 9)}`);
|
|
846
|
+
const errorId = $derived(`${inputId}-error`);
|
|
847
|
+
const hintId = $derived(`${inputId}-hint`);
|
|
848
|
+
const ariaDescribedBy = $derived(
|
|
849
|
+
[error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
|
|
850
|
+
);
|
|
851
|
+
</script>
|
|
852
|
+
|
|
853
|
+
<div
|
|
854
|
+
class={cn(styles.container(), className)}
|
|
855
|
+
role="group"
|
|
856
|
+
aria-labelledby={label ? `${inputId}-label` : undefined}
|
|
857
|
+
>
|
|
858
|
+
<!-- Header with label and mode toggle -->
|
|
859
|
+
<div class={styles.header()}>
|
|
860
|
+
{#if label}
|
|
861
|
+
<span id="{inputId}-label" class={styles.labelText()}>
|
|
862
|
+
{label}
|
|
863
|
+
{#if required}
|
|
864
|
+
<span class="text-destructive" aria-hidden="true">*</span>
|
|
865
|
+
<span class="sr-only">(required)</span>
|
|
866
|
+
{/if}
|
|
867
|
+
</span>
|
|
868
|
+
{/if}
|
|
869
|
+
|
|
870
|
+
{#if showModeToggle}
|
|
871
|
+
<div class={styles.modeToggle()} role="radiogroup" aria-label="Signature input method">
|
|
872
|
+
<button
|
|
873
|
+
type="button"
|
|
874
|
+
role="radio"
|
|
875
|
+
aria-checked={mode === 'draw'}
|
|
876
|
+
class={cn(
|
|
877
|
+
styles.modeButton(),
|
|
878
|
+
mode === 'draw' ? styles.modeButtonActive() : styles.modeButtonInactive()
|
|
879
|
+
)}
|
|
880
|
+
onclick={() => setMode('draw')}
|
|
881
|
+
{disabled}
|
|
882
|
+
>
|
|
883
|
+
Draw
|
|
884
|
+
</button>
|
|
885
|
+
<button
|
|
886
|
+
type="button"
|
|
887
|
+
role="radio"
|
|
888
|
+
aria-checked={mode === 'type'}
|
|
889
|
+
class={cn(
|
|
890
|
+
styles.modeButton(),
|
|
891
|
+
mode === 'type' ? styles.modeButtonActive() : styles.modeButtonInactive()
|
|
892
|
+
)}
|
|
893
|
+
onclick={() => setMode('type')}
|
|
894
|
+
{disabled}
|
|
895
|
+
>
|
|
896
|
+
Type
|
|
897
|
+
</button>
|
|
898
|
+
</div>
|
|
899
|
+
{/if}
|
|
900
|
+
</div>
|
|
901
|
+
|
|
902
|
+
<!-- Canvas container -->
|
|
903
|
+
<div bind:this={canvasContainer} class={styles.canvasContainer()} style="height: {height};">
|
|
904
|
+
<canvas
|
|
905
|
+
bind:this={canvas}
|
|
906
|
+
class={styles.canvas()}
|
|
907
|
+
style="height: 100%;"
|
|
908
|
+
tabindex={disabled ? -1 : 0}
|
|
909
|
+
aria-label={mode === 'draw'
|
|
910
|
+
? 'Signature drawing area. Use mouse or touch to draw.'
|
|
911
|
+
: 'Signature preview area.'}
|
|
912
|
+
aria-describedby={ariaDescribedBy}
|
|
913
|
+
onpointerdown={handlePointerDown}
|
|
914
|
+
onpointermove={handlePointerMove}
|
|
915
|
+
onpointerup={handlePointerUp}
|
|
916
|
+
onpointerleave={handlePointerLeave}
|
|
917
|
+
onkeydown={handleKeyDown}
|
|
918
|
+
></canvas>
|
|
919
|
+
</div>
|
|
920
|
+
|
|
921
|
+
<!-- Draw mode toolbar -->
|
|
922
|
+
{#if mode === 'draw'}
|
|
923
|
+
<div class={styles.toolbar()}>
|
|
924
|
+
<button
|
|
925
|
+
type="button"
|
|
926
|
+
class={styles.toolButton()}
|
|
927
|
+
onclick={undoStroke}
|
|
928
|
+
disabled={disabled || strokes.length === 0}
|
|
929
|
+
aria-label="Undo last stroke"
|
|
930
|
+
title="Undo (Ctrl+Z)"
|
|
931
|
+
>
|
|
932
|
+
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
933
|
+
<path d="M3 10h10a5 5 0 0 1 5 5v2M3 10l4-4M3 10l4 4" />
|
|
934
|
+
</svg>
|
|
935
|
+
</button>
|
|
936
|
+
|
|
937
|
+
<button
|
|
938
|
+
type="button"
|
|
939
|
+
class={styles.toolButton()}
|
|
940
|
+
onclick={clearSignature}
|
|
941
|
+
disabled={disabled || strokes.length === 0}
|
|
942
|
+
aria-label="Clear signature"
|
|
943
|
+
title="Clear"
|
|
944
|
+
>
|
|
945
|
+
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
946
|
+
<path
|
|
947
|
+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
948
|
+
/>
|
|
949
|
+
</svg>
|
|
950
|
+
</button>
|
|
951
|
+
|
|
952
|
+
{#if showStrokeCustomization}
|
|
953
|
+
<div class={styles.toolGroup()}>
|
|
954
|
+
<label class={styles.toolLabel()} for="{inputId}-color">Color</label>
|
|
955
|
+
<input
|
|
956
|
+
id="{inputId}-color"
|
|
957
|
+
type="color"
|
|
958
|
+
bind:value={strokeColor}
|
|
959
|
+
class={styles.colorPicker()}
|
|
960
|
+
{disabled}
|
|
961
|
+
/>
|
|
962
|
+
</div>
|
|
963
|
+
|
|
964
|
+
<div class={styles.toolGroup()}>
|
|
965
|
+
<label class={styles.toolLabel()} for="{inputId}-thickness">Thickness</label>
|
|
966
|
+
<input
|
|
967
|
+
id="{inputId}-thickness"
|
|
968
|
+
type="range"
|
|
969
|
+
min={minStrokeWidth}
|
|
970
|
+
max={maxStrokeWidth}
|
|
971
|
+
step="0.5"
|
|
972
|
+
bind:value={strokeWidth}
|
|
973
|
+
class={styles.thicknessSlider()}
|
|
974
|
+
{disabled}
|
|
975
|
+
/>
|
|
976
|
+
</div>
|
|
977
|
+
{/if}
|
|
978
|
+
</div>
|
|
979
|
+
{/if}
|
|
980
|
+
|
|
981
|
+
<!-- Type mode controls -->
|
|
982
|
+
{#if mode === 'type'}
|
|
983
|
+
<div class="space-y-3">
|
|
984
|
+
<input
|
|
985
|
+
id={inputId}
|
|
986
|
+
type="text"
|
|
987
|
+
value={typedName}
|
|
988
|
+
oninput={handleTypedNameChange}
|
|
989
|
+
placeholder="Enter your full name"
|
|
990
|
+
class={cn(
|
|
991
|
+
'flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
992
|
+
error && 'border-destructive focus-visible:ring-destructive'
|
|
993
|
+
)}
|
|
994
|
+
{disabled}
|
|
995
|
+
aria-describedby={ariaDescribedBy}
|
|
996
|
+
aria-invalid={error ? 'true' : undefined}
|
|
997
|
+
/>
|
|
998
|
+
|
|
999
|
+
<div class={styles.fontPicker()} role="listbox" aria-label="Select signature font">
|
|
1000
|
+
{#each fonts as font}
|
|
1001
|
+
<button
|
|
1002
|
+
type="button"
|
|
1003
|
+
role="option"
|
|
1004
|
+
aria-selected={selectedFont === font.family}
|
|
1005
|
+
class={cn(
|
|
1006
|
+
styles.fontButton(),
|
|
1007
|
+
selectedFont === font.family
|
|
1008
|
+
? styles.fontButtonSelected()
|
|
1009
|
+
: styles.fontButtonUnselected()
|
|
1010
|
+
)}
|
|
1011
|
+
style="font-family: '{font.family}', cursive;"
|
|
1012
|
+
onclick={() => selectFont(font)}
|
|
1013
|
+
{disabled}
|
|
1014
|
+
>
|
|
1015
|
+
{typedName || 'Preview'}
|
|
1016
|
+
</button>
|
|
1017
|
+
{/each}
|
|
1018
|
+
</div>
|
|
1019
|
+
</div>
|
|
1020
|
+
{/if}
|
|
1021
|
+
|
|
1022
|
+
<!-- Consent checkbox -->
|
|
1023
|
+
{#if showConsent}
|
|
1024
|
+
<div class={styles.consent()}>
|
|
1025
|
+
<input
|
|
1026
|
+
id="{inputId}-consent"
|
|
1027
|
+
type="checkbox"
|
|
1028
|
+
checked={consented}
|
|
1029
|
+
onchange={handleConsentChange}
|
|
1030
|
+
class={styles.consentCheckbox()}
|
|
1031
|
+
{disabled}
|
|
1032
|
+
aria-describedby={!consented && requireConsent ? `${inputId}-consent-required` : undefined}
|
|
1033
|
+
/>
|
|
1034
|
+
<label for="{inputId}-consent" class={styles.consentLabel()}>
|
|
1035
|
+
{consentText}
|
|
1036
|
+
</label>
|
|
1037
|
+
</div>
|
|
1038
|
+
{#if !consented && requireConsent && hasSignature}
|
|
1039
|
+
<p id="{inputId}-consent-required" class={styles.errorText()} role="alert">
|
|
1040
|
+
You must agree to the terms to submit your signature
|
|
1041
|
+
</p>
|
|
1042
|
+
{/if}
|
|
1043
|
+
{/if}
|
|
1044
|
+
|
|
1045
|
+
<!-- Validation error (from signature) -->
|
|
1046
|
+
{#if hasSignature && !validationResult.valid && validationResult.error}
|
|
1047
|
+
<p class={styles.errorText()} role="alert">
|
|
1048
|
+
{validationResult.error}
|
|
1049
|
+
</p>
|
|
1050
|
+
{/if}
|
|
1051
|
+
|
|
1052
|
+
<!-- External error -->
|
|
1053
|
+
{#if error}
|
|
1054
|
+
<p id={errorId} class={styles.errorText()} role="alert">
|
|
1055
|
+
{error}
|
|
1056
|
+
</p>
|
|
1057
|
+
{/if}
|
|
1058
|
+
|
|
1059
|
+
<!-- Hint -->
|
|
1060
|
+
{#if hint && !error}
|
|
1061
|
+
<p id={hintId} class={styles.hintText()}>
|
|
1062
|
+
{hint}
|
|
1063
|
+
</p>
|
|
1064
|
+
{/if}
|
|
1065
|
+
|
|
1066
|
+
<!-- Hidden input for form submission -->
|
|
1067
|
+
{#if name && value}
|
|
1068
|
+
<input type="hidden" {name} value={value.dataUrl} />
|
|
1069
|
+
{/if}
|
|
1070
|
+
</div>
|