panelui-native 0.14.0 → 0.19.0

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.
Files changed (86) hide show
  1. package/README.md +2 -0
  2. package/lib/module/components/bottom-sheet/index.js +38 -10
  3. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  4. package/lib/module/components/checkbox/index.js +24 -11
  5. package/lib/module/components/checkbox/index.js.map +1 -1
  6. package/lib/module/components/direction/index.js +86 -0
  7. package/lib/module/components/direction/index.js.map +1 -0
  8. package/lib/module/components/field/index.js +337 -0
  9. package/lib/module/components/field/index.js.map +1 -0
  10. package/lib/module/components/flow/flow-paths.js +378 -0
  11. package/lib/module/components/flow/flow-paths.js.map +1 -0
  12. package/lib/module/components/flow/index.js +1598 -0
  13. package/lib/module/components/flow/index.js.map +1 -0
  14. package/lib/module/components/form/index.js +77 -0
  15. package/lib/module/components/form/index.js.map +1 -0
  16. package/lib/module/components/form/use-field.js +50 -0
  17. package/lib/module/components/form/use-field.js.map +1 -0
  18. package/lib/module/components/form/use-form.js +217 -0
  19. package/lib/module/components/form/use-form.js.map +1 -0
  20. package/lib/module/components/frame/index.js +124 -22
  21. package/lib/module/components/frame/index.js.map +1 -1
  22. package/lib/module/components/message/index.js +16 -1
  23. package/lib/module/components/message/index.js.map +1 -1
  24. package/lib/module/components/otp-input/index.js +303 -0
  25. package/lib/module/components/otp-input/index.js.map +1 -0
  26. package/lib/module/components/signature/index.js +500 -0
  27. package/lib/module/components/signature/index.js.map +1 -0
  28. package/lib/module/components/soundwave/index.js +813 -0
  29. package/lib/module/components/soundwave/index.js.map +1 -0
  30. package/lib/module/components/typography/index.js +196 -5
  31. package/lib/module/components/typography/index.js.map +1 -1
  32. package/lib/module/icons/index.js +287 -0
  33. package/lib/module/icons/index.js.map +1 -1
  34. package/lib/module/index.js +8 -1
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/typescript/src/components/bottom-sheet/index.d.ts +15 -1
  37. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  38. package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
  39. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/direction/index.d.ts +64 -0
  41. package/lib/typescript/src/components/direction/index.d.ts.map +1 -0
  42. package/lib/typescript/src/components/field/index.d.ts +94 -0
  43. package/lib/typescript/src/components/field/index.d.ts.map +1 -0
  44. package/lib/typescript/src/components/flow/flow-paths.d.ts +89 -0
  45. package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -0
  46. package/lib/typescript/src/components/flow/index.d.ts +310 -0
  47. package/lib/typescript/src/components/flow/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/form/index.d.ts +64 -0
  49. package/lib/typescript/src/components/form/index.d.ts.map +1 -0
  50. package/lib/typescript/src/components/form/use-field.d.ts +16 -0
  51. package/lib/typescript/src/components/form/use-field.d.ts.map +1 -0
  52. package/lib/typescript/src/components/form/use-form.d.ts +37 -0
  53. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -0
  54. package/lib/typescript/src/components/frame/index.d.ts +39 -2
  55. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  57. package/lib/typescript/src/components/otp-input/index.d.ts +141 -0
  58. package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -0
  59. package/lib/typescript/src/components/signature/index.d.ts +254 -0
  60. package/lib/typescript/src/components/signature/index.d.ts.map +1 -0
  61. package/lib/typescript/src/components/soundwave/index.d.ts +91 -0
  62. package/lib/typescript/src/components/soundwave/index.d.ts.map +1 -0
  63. package/lib/typescript/src/components/typography/index.d.ts +154 -1
  64. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  65. package/lib/typescript/src/icons/index.d.ts +23 -0
  66. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  67. package/lib/typescript/src/index.d.ts +9 -2
  68. package/lib/typescript/src/index.d.ts.map +1 -1
  69. package/package.json +9 -1
  70. package/src/components/bottom-sheet/index.tsx +50 -12
  71. package/src/components/checkbox/index.tsx +40 -12
  72. package/src/components/direction/index.tsx +90 -0
  73. package/src/components/field/index.tsx +329 -0
  74. package/src/components/flow/flow-paths.ts +348 -0
  75. package/src/components/flow/index.tsx +1924 -0
  76. package/src/components/form/index.tsx +85 -0
  77. package/src/components/form/use-field.ts +65 -0
  78. package/src/components/form/use-form.ts +252 -0
  79. package/src/components/frame/index.tsx +162 -14
  80. package/src/components/message/index.tsx +17 -1
  81. package/src/components/otp-input/index.tsx +396 -0
  82. package/src/components/signature/index.tsx +681 -0
  83. package/src/components/soundwave/index.tsx +1029 -0
  84. package/src/components/typography/index.tsx +231 -10
  85. package/src/icons/index.tsx +195 -0
  86. package/src/index.ts +87 -0
@@ -0,0 +1,681 @@
1
+ /**
2
+ * Signature — a surface you sign with a finger, and a handle for getting the
3
+ * result back out.
4
+ *
5
+ * ```tsx
6
+ * const pad = useRef<SignatureHandle>(null);
7
+ *
8
+ * <Signature ref={pad} guideline />
9
+ * <Button onPress={() => pad.current?.save({ filename: 'agreement' })}>
10
+ * Finish signing
11
+ * </Button>
12
+ * ```
13
+ *
14
+ * ## Why the stroke never reaches React
15
+ *
16
+ * A finger produces touch events far faster than a component tree can usefully
17
+ * re-render, and a signature is exactly the case where the lag shows: the line
18
+ * trails the fingertip and the whole thing feels like drawing through syrup.
19
+ *
20
+ * So the stroke being drawn lives in a shared value and is turned into an SVG
21
+ * `d` string by a worklet on the UI thread — React is not involved in a single
22
+ * frame of it. When the finger lifts, that one finished string crosses to
23
+ * JavaScript once and becomes a static path. Committed strokes are ordinary
24
+ * elements that never animate again, so the hundredth stroke costs what the
25
+ * first one did.
26
+ *
27
+ * Points closer together than `minDistance` are dropped as they arrive. A slow
28
+ * finger otherwise emits a point per frame in the same spot, which is a longer
29
+ * path describing the same shape.
30
+ *
31
+ * ## Smoothing
32
+ *
33
+ * Raw touch points joined with straight lines look like a seismograph. Each
34
+ * segment is drawn as a quadratic curve through the midpoint between two
35
+ * points instead — the point itself becomes the control handle, the midpoints
36
+ * become the anchors, and consecutive curves meet with a shared tangent. It
37
+ * needs no lookahead, so a point can be appended to a stroke already on screen
38
+ * without redrawing what came before it differently.
39
+ *
40
+ * ## Getting it out
41
+ *
42
+ * `toSVG()` is pure string building and always works. Writing a file needs
43
+ * `expo-file-system`, and rasterising to PNG needs `react-native-view-shot` as
44
+ * well; both are optional and both are resolved lazily, so a project that only
45
+ * ever reads the SVG installs neither. Asking for something the missing
46
+ * package provides throws with its name in the message rather than failing
47
+ * somewhere further down.
48
+ */
49
+ import {
50
+ forwardRef,
51
+ useCallback,
52
+ useEffect,
53
+ useImperativeHandle,
54
+ useMemo,
55
+ useRef,
56
+ useState,
57
+ type ComponentType,
58
+ type ReactNode,
59
+ type Ref,
60
+ } from 'react';
61
+ import { View, type ViewProps } from 'react-native';
62
+ import { useCSSVariable } from 'uniwind';
63
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
64
+ import Animated, {
65
+ runOnJS,
66
+ useAnimatedProps,
67
+ useSharedValue,
68
+ } from 'react-native-reanimated';
69
+ import Svg, { Path } from 'react-native-svg';
70
+ import { tv, type VariantProps } from 'tailwind-variants';
71
+ import { PencilIcon, RotateCcwIcon, TrashIcon, type IconProps } from '../../icons';
72
+ import {
73
+ AnimatedPressable,
74
+ type AnimatedPressableProps,
75
+ } from '../../primitives/animated-pressable';
76
+ import { Text } from '../../primitives/text';
77
+ import { cn } from '../../utils/cn';
78
+
79
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
80
+
81
+ /**
82
+ * `expo-file-system`, or null when it is not installed. Resolved once at module
83
+ * load, the way every optional dependency in the library is.
84
+ */
85
+ const FileSystem: {
86
+ documentDirectory?: string | null;
87
+ Paths?: { document?: { uri?: string } };
88
+ writeAsStringAsync?: (uri: string, contents: string, options?: unknown) => Promise<void>;
89
+ EncodingType?: { Base64?: string; UTF8?: string };
90
+ File?: new (...args: unknown[]) => {
91
+ create: (options?: { overwrite?: boolean }) => void;
92
+ write: (contents: string) => void;
93
+ uri: string;
94
+ };
95
+ } | null = (() => {
96
+ try {
97
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
98
+ return require('expo-file-system');
99
+ } catch {
100
+ return null;
101
+ }
102
+ })();
103
+
104
+ /** `react-native-view-shot`'s capture function, or null. PNG export only. */
105
+ const captureRef: ((view: unknown, options: unknown) => Promise<string>) | null =
106
+ (() => {
107
+ try {
108
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
109
+ const mod = require('react-native-view-shot');
110
+ return (mod?.captureRef as (v: unknown, o: unknown) => Promise<string>) ?? null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ })();
115
+
116
+ /** True when a signature can be written to a file. */
117
+ export const hasSignatureFileSystem = FileSystem !== null;
118
+
119
+ /** True when a signature can be rasterised to PNG. */
120
+ export const hasSignatureRaster = captureRef !== null;
121
+
122
+ const signatureVariants = tv({
123
+ slots: {
124
+ root: 'overflow-hidden rounded-2xl border border-border bg-card',
125
+ pad: 'flex-1',
126
+ placeholder: 'absolute inset-0 items-center justify-center gap-2',
127
+ guide: 'absolute inset-x-6 flex-row items-center gap-3',
128
+ toolbar: 'flex-row items-center justify-between gap-2 px-3 py-2',
129
+ },
130
+ variants: {
131
+ size: {
132
+ sm: { root: 'h-32' },
133
+ md: { root: 'h-48' },
134
+ lg: { root: 'h-64' },
135
+ /** Fills whatever it is given — for a full-screen signing surface. */
136
+ full: { root: 'h-auto flex-1 rounded-none border-0' },
137
+ },
138
+ disabled: {
139
+ true: { root: 'opacity-[0.64]' },
140
+ },
141
+ },
142
+ defaultVariants: {
143
+ size: 'md',
144
+ },
145
+ });
146
+
147
+ type SignatureVariantProps = VariantProps<typeof signatureVariants>;
148
+
149
+ export interface SignatureProps
150
+ extends Omit<ViewProps, 'children'>,
151
+ Omit<SignatureVariantProps, 'disabled'> {
152
+ className?: string;
153
+ /** How tall the pad is. `full` fills its parent instead. */
154
+ size?: 'sm' | 'md' | 'lg' | 'full';
155
+ /** Ink colour. Defaults to the theme's foreground. */
156
+ strokeColor?: string;
157
+ /** Ink width in points. */
158
+ strokeWidth?: number;
159
+ /**
160
+ * Points closer together than this are dropped as they arrive, so a finger
161
+ * resting still does not add hundreds of points describing one spot.
162
+ */
163
+ minDistance?: number;
164
+ /** Draw the baseline and its ✕ mark, the way a paper form does. */
165
+ guideline?: boolean;
166
+ /** Caption beside the baseline. Only shown with `guideline`. */
167
+ guidelineLabel?: string;
168
+ /** Prompt shown over an empty pad. Pass `null` for none. */
169
+ placeholder?: ReactNode;
170
+ /** Take no input. The strokes already there stay visible. */
171
+ disabled?: boolean;
172
+ /** A stroke has started. */
173
+ onBegin?: () => void;
174
+ /** A stroke has finished. */
175
+ onEnd?: () => void;
176
+ /**
177
+ * The number of committed strokes changed — by drawing, undoing, redoing or
178
+ * clearing. The cheap way to enable a Save button only once something is
179
+ * there to save.
180
+ */
181
+ onChange?: (strokeCount: number) => void;
182
+ /** Class on the drawing surface inside the border. */
183
+ padClassName?: string;
184
+ /** Class on the empty-pad prompt. */
185
+ placeholderClassName?: string;
186
+ /** Class on the baseline. */
187
+ guideClassName?: string;
188
+ }
189
+
190
+ export interface SignatureSaveOptions {
191
+ /**
192
+ * Directory to write into, as a `file://` URI. Defaults to the app's own
193
+ * document directory, which survives restarts and is not visible to the user.
194
+ */
195
+ directory?: string;
196
+ /** Filename without an extension. Defaults to `signature-<timestamp>`. */
197
+ filename?: string;
198
+ /** `svg` needs no extra packages; `png` rasterises the pad. */
199
+ format?: 'svg' | 'png';
200
+ /** PNG only — pixels per point. Higher is sharper and larger. */
201
+ scale?: number;
202
+ }
203
+
204
+ export interface SignatureFile {
205
+ /** Where it was written, as a `file://` URI. */
206
+ uri: string;
207
+ format: 'svg' | 'png';
208
+ /** The pad's size in points, which is the SVG's coordinate space. */
209
+ width: number;
210
+ height: number;
211
+ }
212
+
213
+ export interface SignatureHandle {
214
+ /** Drop every stroke. */
215
+ clear(): void;
216
+ /** Remove the last stroke. Repeatable down to empty. */
217
+ undo(): void;
218
+ /** Put back the last undone stroke. Drawing again discards the redo stack. */
219
+ redo(): void;
220
+ /** True until the first stroke lands. */
221
+ isEmpty(): boolean;
222
+ /** How many strokes are on the pad. */
223
+ strokeCount(): number;
224
+ /**
225
+ * The signature as a standalone SVG document, sized to the pad. Pure string
226
+ * building — no optional package, no async, safe to call every render.
227
+ */
228
+ toSVG(): string;
229
+ /**
230
+ * A `data:` URI. `svg` is always available; `png` needs
231
+ * `react-native-view-shot` and throws by name without it.
232
+ */
233
+ toDataURL(format?: 'svg' | 'png'): Promise<string>;
234
+ /**
235
+ * Write the signature to a file and resolve where it went. Needs
236
+ * `expo-file-system`, plus `react-native-view-shot` for `png`.
237
+ */
238
+ save(options?: SignatureSaveOptions): Promise<SignatureFile>;
239
+ }
240
+
241
+ /**
242
+ * Turns a flat `[x, y, x, y, …]` buffer into a path, curving through the
243
+ * midpoint between each pair of points.
244
+ *
245
+ * A worklet, because it runs against the live stroke on the UI thread — and
246
+ * because the same function has to produce the committed string once the
247
+ * finger lifts, so the line does not shift the instant it stops being live.
248
+ */
249
+ function strokePath(points: number[]): string {
250
+ 'worklet';
251
+ const count = points.length / 2;
252
+ if (count === 0) return '';
253
+
254
+ // A tap is a dot. Without this it would be an empty path and the touch would
255
+ // leave no mark at all, which reads as the pad having missed it.
256
+ if (count === 1) {
257
+ return `M${points[0]},${points[1]} l0.01,0`;
258
+ }
259
+
260
+ let d = `M${points[0]},${points[1]}`;
261
+ for (let i = 1; i < count; i += 1) {
262
+ const px = points[(i - 1) * 2]!;
263
+ const py = points[(i - 1) * 2 + 1]!;
264
+ const x = points[i * 2]!;
265
+ const y = points[i * 2 + 1]!;
266
+ // The point is the control handle and the midpoint is the anchor, so the
267
+ // curve leaving this segment shares a tangent with the one entering the
268
+ // next and the join is invisible.
269
+ d += ` Q${px},${py} ${(px + x) / 2},${(py + y) / 2}`;
270
+ }
271
+
272
+ // Finish at the real last point rather than at the midpoint before it, or
273
+ // the stroke stops visibly short of where the finger lifted.
274
+ d += ` L${points[(count - 1) * 2]},${points[(count - 1) * 2 + 1]}`;
275
+ return d;
276
+ }
277
+
278
+ function SignatureRoot(
279
+ {
280
+ className,
281
+ size = 'md',
282
+ strokeColor,
283
+ strokeWidth = 2.5,
284
+ minDistance = 1.5,
285
+ guideline = false,
286
+ guidelineLabel,
287
+ placeholder,
288
+ disabled = false,
289
+ onBegin,
290
+ onEnd,
291
+ onChange,
292
+ padClassName,
293
+ placeholderClassName,
294
+ guideClassName,
295
+ ...props
296
+ }: SignatureProps,
297
+ ref: Ref<SignatureHandle>
298
+ ) {
299
+ const slots = signatureVariants({ size, disabled });
300
+
301
+ const [strokes, setStrokes] = useState<string[]>([]);
302
+ const [undone, setUndone] = useState<string[]>([]);
303
+ const [layout, setLayout] = useState({ width: 0, height: 0 });
304
+
305
+ // The live stroke: a flat buffer so the worklet reads numbers rather than
306
+ // objects, which is what keeps the per-frame cost flat.
307
+ const live = useSharedValue<number[]>([]);
308
+ const padRef = useRef<View>(null);
309
+
310
+ const inkToken = useCSSVariable('--color-foreground');
311
+ const ink =
312
+ strokeColor ?? (typeof inkToken === 'string' ? inkToken : '#0a0a0a');
313
+
314
+ // Latest-props refs, so the gesture is built once and still calls the
315
+ // handlers the component was last rendered with.
316
+ const changeRef = useRef(onChange);
317
+ changeRef.current = onChange;
318
+ const beginRef = useRef(onBegin);
319
+ beginRef.current = onBegin;
320
+ const endRef = useRef(onEnd);
321
+ endRef.current = onEnd;
322
+
323
+ // `onChange` fires from an effect rather than from inside a state updater.
324
+ // An updater can be replayed during a render, and calling a parent's setState
325
+ // from there is the "cannot update a component while rendering a different
326
+ // component" warning — earned, not spurious.
327
+ const reported = useRef(0);
328
+ useEffect(() => {
329
+ if (reported.current === strokes.length) return;
330
+ reported.current = strokes.length;
331
+ changeRef.current?.(strokes.length);
332
+ }, [strokes.length]);
333
+
334
+ const commit = useCallback((d: string) => {
335
+ setStrokes((current) => [...current, d]);
336
+ // Drawing again is a new branch of history, so what was undone is gone.
337
+ setUndone([]);
338
+ endRef.current?.();
339
+ }, []);
340
+
341
+ const begin = useCallback(() => beginRef.current?.(), []);
342
+
343
+ const pan = useMemo(
344
+ () =>
345
+ Gesture.Pan()
346
+ // Without this a signature's first, shortest stroke — the dot on an
347
+ // i, a full stop — never activates the gesture and is simply lost.
348
+ .minDistance(0)
349
+ .averageTouches(true)
350
+ .enabled(!disabled)
351
+ .onBegin((event) => {
352
+ 'worklet';
353
+ live.value = [event.x, event.y];
354
+ runOnJS(begin)();
355
+ })
356
+ .onUpdate((event) => {
357
+ 'worklet';
358
+ const points = live.value;
359
+ const last = points.length;
360
+ if (last >= 2) {
361
+ const dx = event.x - points[last - 2]!;
362
+ const dy = event.y - points[last - 1]!;
363
+ if (dx * dx + dy * dy < minDistance * minDistance) return;
364
+ }
365
+ // A new array, not a push: a shared value only notifies on
366
+ // assignment, and mutating in place leaves the path stale.
367
+ live.value = [...points, event.x, event.y];
368
+ })
369
+ .onFinalize(() => {
370
+ 'worklet';
371
+ const points = live.value;
372
+ live.value = [];
373
+ if (points.length === 0) return;
374
+ runOnJS(commit)(strokePath(points));
375
+ }),
376
+ [begin, commit, disabled, live, minDistance]
377
+ );
378
+
379
+ const liveProps = useAnimatedProps(() => ({ d: strokePath(live.value) }));
380
+
381
+ const svgDocument = useCallback(() => {
382
+ const width = Math.round(layout.width) || 1;
383
+ const height = Math.round(layout.height) || 1;
384
+ const paths = strokes
385
+ .map(
386
+ (d) =>
387
+ `<path d="${d}" fill="none" stroke="${ink}" stroke-width="${strokeWidth}" ` +
388
+ 'stroke-linecap="round" stroke-linejoin="round"/>'
389
+ )
390
+ .join('');
391
+ return (
392
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
393
+ `viewBox="0 0 ${width} ${height}">${paths}</svg>`
394
+ );
395
+ }, [ink, layout.height, layout.width, strokeWidth, strokes]);
396
+
397
+ const rasterise = useCallback(
398
+ async (scale: number) => {
399
+ if (!captureRef) {
400
+ throw new Error(
401
+ 'Signature: PNG export needs the optional `react-native-view-shot` package. ' +
402
+ 'Install it, or export SVG instead.'
403
+ );
404
+ }
405
+ return captureRef(padRef.current, {
406
+ format: 'png',
407
+ quality: 1,
408
+ result: 'tmpfile',
409
+ width: Math.round(layout.width * scale),
410
+ height: Math.round(layout.height * scale),
411
+ });
412
+ },
413
+ [layout.height, layout.width]
414
+ );
415
+
416
+ useImperativeHandle(
417
+ ref,
418
+ (): SignatureHandle => ({
419
+ // These read the current arrays and set flat values rather than nesting
420
+ // one updater inside another — a nested updater runs during the render
421
+ // pass, which is not a safe place to schedule another component's update.
422
+ clear() {
423
+ setStrokes([]);
424
+ setUndone([]);
425
+ },
426
+ undo() {
427
+ const last = strokes[strokes.length - 1];
428
+ if (last === undefined) return;
429
+ setUndone((stack) => [...stack, last]);
430
+ setStrokes(strokes.slice(0, -1));
431
+ },
432
+ redo() {
433
+ const restored = undone[undone.length - 1];
434
+ if (restored === undefined) return;
435
+ setUndone(undone.slice(0, -1));
436
+ setStrokes([...strokes, restored]);
437
+ },
438
+ isEmpty: () => strokes.length === 0,
439
+ strokeCount: () => strokes.length,
440
+ toSVG: svgDocument,
441
+ async toDataURL(format = 'svg') {
442
+ if (format === 'svg') {
443
+ // encodeURIComponent rather than base64: no polyfill needed, and the
444
+ // result is legible in a log, which matters when debugging a save.
445
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svgDocument())}`;
446
+ }
447
+ const uri = await rasterise(2);
448
+ if (!FileSystem) {
449
+ throw new Error(
450
+ 'Signature: reading a PNG back as a data URI needs the optional ' +
451
+ '`expo-file-system` package. The file itself is at ' +
452
+ `${uri} — pass that to an <Image> instead.`
453
+ );
454
+ }
455
+ const read = (
456
+ FileSystem as unknown as {
457
+ readAsStringAsync?: (u: string, o?: unknown) => Promise<string>;
458
+ }
459
+ ).readAsStringAsync;
460
+ if (!read) throw new Error('Signature: `expo-file-system` has no readAsStringAsync.');
461
+ const base64 = await read(uri, { encoding: 'base64' });
462
+ return `data:image/png;base64,${base64}`;
463
+ },
464
+ async save(options = {}) {
465
+ const { directory, filename, format = 'svg', scale = 2 } = options;
466
+
467
+ if (!FileSystem) {
468
+ throw new Error(
469
+ 'Signature: save() needs the optional `expo-file-system` package. ' +
470
+ 'Install it, or use toSVG() and write the string yourself.'
471
+ );
472
+ }
473
+
474
+ const base =
475
+ directory ??
476
+ FileSystem.documentDirectory ??
477
+ FileSystem.Paths?.document?.uri;
478
+ if (!base) {
479
+ throw new Error(
480
+ 'Signature: no document directory available — pass `directory` explicitly.'
481
+ );
482
+ }
483
+
484
+ const name = filename ?? `signature-${Date.now()}`;
485
+ const dir = base.endsWith('/') ? base : `${base}/`;
486
+ const uri = `${dir}${name}.${format}`;
487
+
488
+ if (format === 'png') {
489
+ const temporary = await rasterise(scale);
490
+ const copy = (
491
+ FileSystem as unknown as {
492
+ copyAsync?: (o: { from: string; to: string }) => Promise<void>;
493
+ }
494
+ ).copyAsync;
495
+ if (!copy) {
496
+ throw new Error('Signature: `expo-file-system` has no copyAsync.');
497
+ }
498
+ await copy({ from: temporary, to: uri });
499
+ } else if (FileSystem.File) {
500
+ // The current file-object API. Tried first: `writeAsStringAsync` is
501
+ // the legacy one and warns on every call in recent versions.
502
+ const file = new FileSystem.File(uri);
503
+ file.create({ overwrite: true });
504
+ file.write(svgDocument());
505
+ } else if (FileSystem.writeAsStringAsync) {
506
+ await FileSystem.writeAsStringAsync(uri, svgDocument());
507
+ } else {
508
+ throw new Error(
509
+ 'Signature: `expo-file-system` exposes neither writeAsStringAsync nor File.'
510
+ );
511
+ }
512
+
513
+ return {
514
+ uri,
515
+ format,
516
+ width: Math.round(layout.width),
517
+ height: Math.round(layout.height),
518
+ };
519
+ },
520
+ }),
521
+ [layout.height, layout.width, rasterise, strokes, undone, svgDocument]
522
+ );
523
+
524
+ const empty = strokes.length === 0;
525
+
526
+ return (
527
+ <View className={slots.root({ className })} {...props}>
528
+ <GestureDetector gesture={pan}>
529
+ {/* collapsable={false} keeps the view in the native tree, which both
530
+ the gesture handler and the screenshot capture need. */}
531
+ <View
532
+ ref={padRef}
533
+ collapsable={false}
534
+ className={slots.pad({ className: padClassName })}
535
+ onLayout={(event) => setLayout(event.nativeEvent.layout)}
536
+ accessible
537
+ accessibilityRole="image"
538
+ accessibilityLabel="Signature pad"
539
+ accessibilityHint="Draw your signature with your finger"
540
+ accessibilityState={{ disabled }}
541
+ >
542
+ {guideline && layout.height > 0 ? (
543
+ <View
544
+ className={slots.guide({ className: guideClassName })}
545
+ style={{ top: layout.height * 0.72 }}
546
+ pointerEvents="none"
547
+ >
548
+ <Text size="lg" muted>
549
+ ✕
550
+ </Text>
551
+ <View className="h-px flex-1 bg-border" />
552
+ {guidelineLabel ? (
553
+ <Text size="xs" muted>
554
+ {guidelineLabel}
555
+ </Text>
556
+ ) : null}
557
+ </View>
558
+ ) : null}
559
+
560
+ <Svg width="100%" height="100%" pointerEvents="none">
561
+ {strokes.map((d, index) => (
562
+ <Path
563
+ // Strokes are only ever appended or dropped from the end, so
564
+ // the index is stable for the life of each one.
565
+ key={index}
566
+ d={d}
567
+ fill="none"
568
+ stroke={ink}
569
+ strokeWidth={strokeWidth}
570
+ strokeLinecap="round"
571
+ strokeLinejoin="round"
572
+ />
573
+ ))}
574
+ <AnimatedPath
575
+ animatedProps={liveProps}
576
+ fill="none"
577
+ stroke={ink}
578
+ strokeWidth={strokeWidth}
579
+ strokeLinecap="round"
580
+ strokeLinejoin="round"
581
+ />
582
+ </Svg>
583
+
584
+ {empty && placeholder !== null ? (
585
+ <View
586
+ className={slots.placeholder({ className: placeholderClassName })}
587
+ pointerEvents="none"
588
+ >
589
+ {placeholder ?? (
590
+ <>
591
+ <PencilIcon size={20} />
592
+ <Text size="sm" muted>
593
+ Sign here
594
+ </Text>
595
+ </>
596
+ )}
597
+ </View>
598
+ ) : null}
599
+ </View>
600
+ </GestureDetector>
601
+ </View>
602
+ );
603
+ }
604
+
605
+ const SignatureForwarded = forwardRef<SignatureHandle, SignatureProps>(SignatureRoot);
606
+ SignatureForwarded.displayName = 'Signature';
607
+
608
+ export interface SignatureToolbarProps extends ViewProps {
609
+ className?: string;
610
+ children?: ReactNode;
611
+ }
612
+
613
+ /** A row of controls under or over the pad. Purely layout. */
614
+ function SignatureToolbar({ className, ...props }: SignatureToolbarProps) {
615
+ return (
616
+ <View
617
+ className={cn('flex-row items-center justify-between gap-2', className)}
618
+ {...props}
619
+ />
620
+ );
621
+ }
622
+ SignatureToolbar.displayName = 'Signature.Toolbar';
623
+
624
+ export interface SignatureButtonProps
625
+ extends Omit<AnimatedPressableProps, 'children'> {
626
+ className?: string;
627
+ /** Take no input, and dim to say so. */
628
+ disabled?: boolean;
629
+ /** Replaces the default icon. */
630
+ children?: ReactNode;
631
+ }
632
+
633
+ /**
634
+ * The two pad controls are the same round button with a different icon and a
635
+ * different spoken name, so they are built rather than written twice.
636
+ */
637
+ function circleButton(
638
+ Icon: ComponentType<IconProps>,
639
+ label: string,
640
+ displayName: string
641
+ ) {
642
+ function Control({
643
+ className,
644
+ disabled,
645
+ children,
646
+ accessibilityLabel,
647
+ ...props
648
+ }: SignatureButtonProps) {
649
+ return (
650
+ <AnimatedPressable
651
+ accessibilityRole="button"
652
+ accessibilityLabel={accessibilityLabel ?? label}
653
+ accessibilityState={{ disabled }}
654
+ disabled={disabled}
655
+ hitSlop={8}
656
+ className={cn(
657
+ 'h-9 w-9 items-center justify-center rounded-full bg-muted',
658
+ disabled && 'opacity-[0.48]',
659
+ className
660
+ )}
661
+ {...props}
662
+ >
663
+ {children ?? <Icon size={16} />}
664
+ </AnimatedPressable>
665
+ );
666
+ }
667
+ Control.displayName = displayName;
668
+ return Control;
669
+ }
670
+
671
+ /** Removes the last stroke. Wire it to `ref.current?.undo()`. */
672
+ const SignatureUndo = circleButton(RotateCcwIcon, 'Undo last stroke', 'Signature.Undo');
673
+
674
+ /** Drops every stroke. Wire it to `ref.current?.clear()`. */
675
+ const SignatureClear = circleButton(TrashIcon, 'Clear signature', 'Signature.Clear');
676
+
677
+ export const Signature = Object.assign(SignatureForwarded, {
678
+ Toolbar: SignatureToolbar,
679
+ Undo: SignatureUndo,
680
+ Clear: SignatureClear,
681
+ });