@delacour/react-native-ui 0.5.0-alpha.20261021211439 → 0.5.0-alpha.20261022200024
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/package.json +3 -4
- package/src/components/flow/AGENTS.md +0 -4
- package/src/components/flow/flow.tsx +0 -1
- package/src/components/section-progress/AGENTS.md +0 -3
- package/src/components/section-progress/section-progress-ring.tsx +0 -1
- package/src/components/signature/AGENTS.md +41 -22
- package/src/components/signature/index.ts +6 -0
- package/src/components/signature/signature-ink.tsx +109 -0
- package/src/components/signature/signature-pad.tsx +16 -20
- package/src/components/signature/signature.context.tsx +2 -2
- package/src/components/signature/signature.skia.ts +89 -0
- package/src/components/signature/signature.stroke.test.ts +142 -1
- package/src/components/signature/signature.stroke.ts +96 -10
- package/src/components/signature/signature.tsx +13 -4
- package/src/components/signature/signature.types.ts +12 -0
- package/src/components/sketch/AGENTS.md +0 -4
- package/src/components/sketch/sketch-canvas.tsx +0 -1
- package/src/components/tour/tour-mask.tsx +0 -1
- package/src/components/signature/signature-live-path.tsx +0 -41
- package/src/components/signature/signature-path.tsx +0 -56
- package/src/lib/skia-native-id.test.ts +0 -29
- package/src/lib/skia-native-id.ts +0 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.20261022200024",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -162,7 +162,6 @@
|
|
|
162
162
|
"./lib/scroll-edges": "./src/lib/scroll-edges.ts",
|
|
163
163
|
"./lib/scroll-maths": "./src/lib/scroll-maths.ts",
|
|
164
164
|
"./lib/scroll-sections": "./src/lib/scroll-sections.ts",
|
|
165
|
-
"./lib/skia-native-id": "./src/lib/skia-native-id.ts",
|
|
166
165
|
"./lib/slot": "./src/lib/slot.tsx",
|
|
167
166
|
"./lib/tv": "./src/lib/tv.ts",
|
|
168
167
|
"./lib/wrap-text-children": "./src/lib/wrap-text-children.ts",
|
|
@@ -184,9 +183,9 @@
|
|
|
184
183
|
"@maplibre/maplibre-react-native": "^11.5.0",
|
|
185
184
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
186
185
|
"@delacour/react-native-bottom-sheet": ">=0.2.0",
|
|
187
|
-
"@delacour/react-native-charts": "0.2.0-alpha.
|
|
186
|
+
"@delacour/react-native-charts": "0.2.0-alpha.20261022200024",
|
|
188
187
|
"@legendapp/list": ">=3.3",
|
|
189
|
-
"react-native-skia": ">=3",
|
|
188
|
+
"react-native-skia": ">=3.7.3",
|
|
190
189
|
"expo-linear-gradient": ">=15",
|
|
191
190
|
"expo-router": ">=57",
|
|
192
191
|
"react": ">=19",
|
|
@@ -79,10 +79,6 @@ parts: `Background`, `Node`, `Handle`, `Edge`, `Group`, `Controls`, `MiniMap`, a
|
|
|
79
79
|
the canvas — the trick `@delacour/react-native-charts` uses. A part off the canvas gets `null`
|
|
80
80
|
from `useFlowCanvasLayer()` and warns. The preview and the minimap own their canvases and
|
|
81
81
|
resolve their colours just outside them.
|
|
82
|
-
- **`flow.tsx` imports `lib/skia-native-id` for its effect.** After a JS reload Skia reused the old
|
|
83
|
-
runtime's canvas ids, which its native registry still held as torn down, so the edges and the
|
|
84
|
-
background came up blank — the minimap too — until a re-render (pressing the lock was enough).
|
|
85
|
-
The import moves this runtime's ids to a fresh range before any of the three canvases asks for one.
|
|
86
82
|
- **Shared values go straight into Skia props.** The pan and zoom are one derived `transform` on
|
|
87
83
|
the canvas's `Group`; an edge's route is a derived `SkPath` (`Skia.Path.MakeFromSVGString` in
|
|
88
84
|
the worklet), its march a `DashPathEffect` `phase`. Nothing crosses to the JS thread per frame.
|
|
@@ -21,7 +21,6 @@ import Animated, {
|
|
|
21
21
|
} from "react-native-reanimated";
|
|
22
22
|
import { Canvas, Group } from "react-native-skia";
|
|
23
23
|
import { scheduleOnRN } from "react-native-worklets";
|
|
24
|
-
import "../../lib/skia-native-id";
|
|
25
24
|
import { useCalmMotion } from "../../hooks/use-calm-motion";
|
|
26
25
|
import { useThemeColors } from "../../hooks/use-theme-color";
|
|
27
26
|
import {
|
|
@@ -167,9 +167,6 @@ rule keeps such a type in its consumer's file.
|
|
|
167
167
|
|
|
168
168
|
- **`react-native-skia` (3 or later) is an optional peer, needed here.** The ring draws with it, as
|
|
169
169
|
`Chart`, `Flow` and `Sketch` do; `react-native-svg` is no longer imported by this component.
|
|
170
|
-
- **The ring imports `lib/skia-native-id` for its effect.** After a JS reload Skia reuses the old
|
|
171
|
-
runtime's canvas ids, so a ring mounted then would draw nothing until it re-rendered. The import
|
|
172
|
-
moves this runtime's ids to a fresh range before the canvas asks for one, as `Sketch` does.
|
|
173
170
|
|
|
174
171
|
## What it does not do
|
|
175
172
|
|
|
@@ -2,7 +2,6 @@ import type { ReactElement } from "react";
|
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { useDerivedValue } from "react-native-reanimated";
|
|
4
4
|
import { Canvas, Circle, Path, Skia } from "react-native-skia";
|
|
5
|
-
import "../../lib/skia-native-id";
|
|
6
5
|
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
7
6
|
import { useSectionProgressPart } from "./section-progress.context";
|
|
8
7
|
import {
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A pad to sign with a finger or a stylus. Compound root plus `Signature.Pad`,
|
|
4
4
|
`Signature.Toolbar`, `Signature.Undo`, `Signature.Redo`, `Signature.Clear` and
|
|
5
|
-
`Signature.Alternative`. Hands back the strokes as JSON, as SVG path data,
|
|
6
|
-
|
|
5
|
+
`Signature.Alternative`. Hands back the strokes as JSON, as SVG path data, as a
|
|
6
|
+
standalone SVG document, or as a PNG.
|
|
7
7
|
|
|
8
8
|
`import { Signature } from "@delacour/react-native-ui/signature";`
|
|
9
9
|
|
|
@@ -14,8 +14,8 @@ a standalone SVG document.
|
|
|
14
14
|
| `index.ts` | → `@delacour/react-native-ui/signature` |
|
|
15
15
|
| `signature.tsx` | Root: the history, the controlled-value sync, the imperative handle, the announcements, and the `Object.assign` surface |
|
|
16
16
|
| `signature-pad.tsx` | `Signature.Pad` — the pan, the live stroke, the guideline, the placeholder and the accessibility surface |
|
|
17
|
-
| `signature-
|
|
18
|
-
| `signature
|
|
17
|
+
| `signature-ink.tsx` | The one Skia canvas: the finished strokes' picture, the live stroke traced on the UI thread, and the clear's fade. Internal |
|
|
18
|
+
| `signature.skia.ts` | Skia only: a stroke's cached `SkPath`, the recorded picture of the finished strokes, and the off-screen PNG render |
|
|
19
19
|
| `signature-toolbar.tsx` | `Signature.Toolbar` |
|
|
20
20
|
| `signature-action-button.tsx` | The shared body of the three action buttons. Internal |
|
|
21
21
|
| `signature-undo.tsx` | `Signature.Undo` |
|
|
@@ -23,8 +23,8 @@ a standalone SVG document.
|
|
|
23
23
|
| `signature-clear.tsx` | `Signature.Clear` |
|
|
24
24
|
| `signature-alternative.tsx` | `Signature.Alternative` — the typed-name link |
|
|
25
25
|
| `signature.context.tsx` | `SignatureContext`, `useSignature()`, `useSignatureContext()`, `useSignaturePart()` |
|
|
26
|
-
| `signature.types.ts` | `SignaturePoint`, `SignatureStroke`, `SignatureValue`, `SignatureHandle`, `SignatureExportOptions` |
|
|
27
|
-
| `signature.stroke.ts` | Pure, no RN: point filter and
|
|
26
|
+
| `signature.types.ts` | `SignaturePoint`, `SignatureStroke`, `SignatureValue`, `SignatureHandle`, `SignatureExportOptions`, `SignaturePngOptions` |
|
|
27
|
+
| `signature.stroke.ts` | Pure, no RN: point filter, smoother and path tracer (worklets), history reducer, bounds, re-fit, the export frame and PNG size, SVG export, JSON round trip |
|
|
28
28
|
| `signature.stroke.test.ts` | |
|
|
29
29
|
| `signature.variants.ts` | Pure `tv()` slots, the size tables, the axis ladder and the accessibility resolvers |
|
|
30
30
|
| `signature.variants.test.ts` | |
|
|
@@ -40,17 +40,27 @@ a standalone SVG document.
|
|
|
40
40
|
test pins the classes to the numbers so the decision is visible if that
|
|
41
41
|
changes.
|
|
42
42
|
- **Drawing is on the UI thread, and React sees a stroke once.** The pan writes
|
|
43
|
-
points into a shared value and
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
- **
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
points into a shared value and a derived value traces them into a Skia path,
|
|
44
|
+
so a moving finger renders nothing. On finger-up the stroke crosses to the JS
|
|
45
|
+
thread with `scheduleOnRN` and joins the finished strokes' picture: one render
|
|
46
|
+
per stroke, none per move. A signature is a few dozen strokes of a few hundred
|
|
47
|
+
points; a render per move would be thousands.
|
|
48
|
+
- **One Skia canvas holds all the ink, and no path is a string on screen.** The
|
|
49
|
+
finished strokes are one recorded `SkPicture` — one draw call however many
|
|
50
|
+
strokes — and each stroke's `SkPath` is cached against the immutable stroke,
|
|
51
|
+
so a commit traces one path, not the signature. The live stroke is traced
|
|
52
|
+
straight into a `PathBuilder` each frame: the SVG version wrote a `d` string
|
|
53
|
+
and had it parsed back on every move. Each stroke is still its own `drawPath`,
|
|
54
|
+
so a translucent ink darkens where two strokes cross exactly as separate SVG
|
|
55
|
+
paths did.
|
|
56
|
+
- **The live and the finished stroke are traced by the same function.**
|
|
57
|
+
`traceStroke` is a worklet, so the UI thread and the committed picture build
|
|
58
|
+
the identical path and a stroke does not shift by a hair when it is handed
|
|
51
59
|
over. Points are midpoint-quadratic smoothed — each sampled point is a control
|
|
52
60
|
and each midpoint an end — which passes near every point without overshooting
|
|
53
|
-
any, the failure a Catmull-Rom spline has on a sharp turn.
|
|
61
|
+
any, the failure a Catmull-Rom spline has on a sharp turn. `smoothStroke`
|
|
62
|
+
writes that curve as path data for the exports, verb for verb the same.<!-- registry:omit -->
|
|
63
|
+
`signature.stroke.test.ts` holds the two to identical output.<!-- /registry:omit -->
|
|
54
64
|
- **The live stroke is cleared after its committed copy renders, and only if it
|
|
55
65
|
is still that stroke.** Cleared at finger-up, the ink blinks out for a frame;
|
|
56
66
|
cleared unconditionally, a fast second stroke — the dot on an i — loses its
|
|
@@ -72,9 +82,13 @@ a standalone SVG document.
|
|
|
72
82
|
- **Clear is final.** It empties the redo stack too and is not undoable. A clear
|
|
73
83
|
that undo could reverse is a clear that needs explaining, and the button is
|
|
74
84
|
labelled "Clear signature" because that label is the only warning there is.
|
|
75
|
-
The ink fades out over 150 ms
|
|
76
|
-
|
|
77
|
-
`useCalmMotion()`.
|
|
85
|
+
The ink fades out over 150 ms: a clear counter bumps, the canvas keeps the
|
|
86
|
+
picture from before it as a ghost inside a layer whose paint's alpha runs to
|
|
87
|
+
zero, then drops it — instant under `useCalmMotion()`. A layer and not a group
|
|
88
|
+
opacity, because a picture replays with its own paints and ignores one. The
|
|
89
|
+
ghost is picked during the render the clear lands in, not in an effect after
|
|
90
|
+
it: from an effect it reaches the canvas a commit late, so the ink blinked out
|
|
91
|
+
and then popped back at full strength before fading.
|
|
78
92
|
- **`value` re-seeds; it does not gate.** The pad reports a value at each stroke
|
|
79
93
|
end and remembers it, so a parent echoing it back is a no-op and a parent
|
|
80
94
|
passing anything else replaces the drawing and its history. A parent cannot
|
|
@@ -108,7 +122,12 @@ a standalone SVG document.
|
|
|
108
122
|
- **Haptics**: the `haptic` preset (default `light`) as each stroke begins,
|
|
109
123
|
nothing while drawing — a buzz per point would be a motor running — and a
|
|
110
124
|
selection tick on clear. `haptic={false}` silences both.
|
|
111
|
-
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
- **`toPNG()` is `toSVG()` rasterised.** Both resolve their options through one
|
|
126
|
+
`resolveSignatureExport`, so the same options give the same box, the same
|
|
127
|
+
literal ink and the same background or none — a PNG is transparent unless
|
|
128
|
+
`background` is set. It renders the cached stroke paths off screen on a CPU
|
|
129
|
+
surface (`Skia.Surface.MakeRaster`) at `scale` pixels per point (default the screen's ratio) and returns
|
|
130
|
+
base64 with no `data:` prefix, as `Sketch.toPNG` does. Off screen rather than a
|
|
131
|
+
snapshot of the pad, so the guideline, the placeholder and the pad's fill never
|
|
132
|
+
reach the file. `""` before the pad is measured. The point's `t` is stored so
|
|
133
|
+
a speed-eased stroke width can follow without a format change.
|
|
@@ -10,20 +10,26 @@ export {
|
|
|
10
10
|
boundsOf,
|
|
11
11
|
isSignatureValue,
|
|
12
12
|
parseSignature,
|
|
13
|
+
resolveSignatureExport,
|
|
13
14
|
SIGNATURE_EXPORT_PADDING,
|
|
14
15
|
SIGNATURE_EXPORT_STROKE_COLOR,
|
|
15
16
|
SIGNATURE_MIN_DISTANCE,
|
|
16
17
|
SIGNATURE_STROKE_WIDTH,
|
|
17
18
|
type SignatureBounds,
|
|
19
|
+
type SignatureExport,
|
|
20
|
+
type SignaturePathSink,
|
|
18
21
|
scaleValue,
|
|
19
22
|
serializeSignature,
|
|
23
|
+
signaturePngSize,
|
|
20
24
|
smoothStroke,
|
|
21
25
|
strokesToPaths,
|
|
22
26
|
toSVG,
|
|
27
|
+
traceStroke,
|
|
23
28
|
} from "./signature.stroke";
|
|
24
29
|
export type {
|
|
25
30
|
SignatureExportOptions,
|
|
26
31
|
SignatureHandle,
|
|
32
|
+
SignaturePngOptions,
|
|
27
33
|
SignaturePoint,
|
|
28
34
|
SignatureStroke,
|
|
29
35
|
SignatureValue,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type ReactElement, useLayoutEffect, useMemo, useState } from "react";
|
|
2
|
+
import { StyleSheet } from "react-native";
|
|
3
|
+
import { type SharedValue, useDerivedValue, useSharedValue, withTiming } from "react-native-reanimated";
|
|
4
|
+
import { Canvas, Group, Path, Picture, Skia, type SkPicture } from "react-native-skia";
|
|
5
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
6
|
+
import { recordSignaturePicture } from "./signature.skia";
|
|
7
|
+
import { traceStroke } from "./signature.stroke";
|
|
8
|
+
import type { SignaturePoint, SignatureStroke } from "./signature.types";
|
|
9
|
+
|
|
10
|
+
/** A cleared signature's ink fades rather than vanishing, so the clear reads as an action. */
|
|
11
|
+
const CLEAR_FADE_MS = 150;
|
|
12
|
+
|
|
13
|
+
export type SignatureInkProps = {
|
|
14
|
+
/** The finished strokes. */
|
|
15
|
+
strokes: readonly SignatureStroke[];
|
|
16
|
+
/** The stroke under the finger, written by the pad's gesture on the UI thread. */
|
|
17
|
+
live: SharedValue<SignaturePoint[]>;
|
|
18
|
+
/** The ink, already resolved against the theme — a canvas cannot read one. */
|
|
19
|
+
color: string | undefined;
|
|
20
|
+
strokeWidth: number;
|
|
21
|
+
/** Bumped by every clear; a change fades out the ink it replaced. */
|
|
22
|
+
clearCount: number;
|
|
23
|
+
/** Under calm motion a clear is instant. */
|
|
24
|
+
isCalm: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every stroke, finished and live, on one Skia canvas.
|
|
29
|
+
*
|
|
30
|
+
* Internal. **The finished strokes are one recorded picture**, re-recorded once
|
|
31
|
+
* per commit and never while a finger moves. **The live stroke is a path built on
|
|
32
|
+
* the UI thread** by tracing the shared points straight into a path builder — no
|
|
33
|
+
* string is written or parsed per frame — and it is traced by the same
|
|
34
|
+
* `traceStroke` the picture's paths are, so a stroke does not shift by a hair
|
|
35
|
+
* when it is committed.
|
|
36
|
+
*
|
|
37
|
+
* **A clear fades the ink it replaced.** The picture from before the clear is
|
|
38
|
+
* kept as a ghost inside a layer whose paint's alpha runs to zero, then dropped.
|
|
39
|
+
* A layer rather than a group opacity: a picture replays with its own paints, so
|
|
40
|
+
* only the layer it is composited through can fade it, and fading the layer as a
|
|
41
|
+
* whole is what the old fading view did. Under calm motion there is no ghost.
|
|
42
|
+
*/
|
|
43
|
+
export function SignatureInk({
|
|
44
|
+
strokes,
|
|
45
|
+
live,
|
|
46
|
+
color,
|
|
47
|
+
strokeWidth,
|
|
48
|
+
clearCount,
|
|
49
|
+
isCalm,
|
|
50
|
+
}: SignatureInkProps): ReactElement {
|
|
51
|
+
const picture = useMemo(() => recordSignaturePicture(strokes, { color, strokeWidth }), [color, strokes, strokeWidth]);
|
|
52
|
+
|
|
53
|
+
const livePath = useDerivedValue(() => {
|
|
54
|
+
const builder = Skia.PathBuilder.Make();
|
|
55
|
+
traceStroke(live.value, builder);
|
|
56
|
+
return builder.detach();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The picture and clear count the last render drew. A clear swaps the strokes
|
|
60
|
+
// and bumps the count in one render, so the ghost is decided here, during
|
|
61
|
+
// that render, and lands in the same commit as the empty picture — set from
|
|
62
|
+
// an effect instead, the ink blinks out for a frame and then pops back.
|
|
63
|
+
const [drawn, setDrawn] = useState({ clearCount, picture });
|
|
64
|
+
const [ghost, setGhost] = useState<SkPicture | null>(null);
|
|
65
|
+
if (drawn.clearCount !== clearCount || drawn.picture !== picture) {
|
|
66
|
+
setDrawn({ clearCount, picture });
|
|
67
|
+
if (drawn.clearCount !== clearCount) setGhost(isCalm ? null : drawn.picture);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Rests at 1 while there is no ghost, so a ghost is drawn opaque on its first
|
|
71
|
+
// frame, before the fade below has started.
|
|
72
|
+
const ghostAlpha = useSharedValue(1);
|
|
73
|
+
const ghostLayer = useDerivedValue(() => {
|
|
74
|
+
const paint = Skia.Paint();
|
|
75
|
+
paint.setAlphaf(ghostAlpha.value);
|
|
76
|
+
return paint;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
useLayoutEffect(() => {
|
|
80
|
+
if (ghost === null) {
|
|
81
|
+
ghostAlpha.value = 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
ghostAlpha.value = withTiming(0, { duration: CLEAR_FADE_MS }, (isFinished) => {
|
|
85
|
+
"worklet";
|
|
86
|
+
if (isFinished) scheduleOnRN(setGhost, null);
|
|
87
|
+
});
|
|
88
|
+
}, [ghost, ghostAlpha]);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
92
|
+
{ghost === null ? null : (
|
|
93
|
+
<Group layer={ghostLayer}>
|
|
94
|
+
<Picture picture={ghost} />
|
|
95
|
+
</Group>
|
|
96
|
+
)}
|
|
97
|
+
<Picture picture={picture} />
|
|
98
|
+
<Path
|
|
99
|
+
color={color ?? "transparent"}
|
|
100
|
+
path={livePath}
|
|
101
|
+
strokeCap="round"
|
|
102
|
+
strokeJoin="round"
|
|
103
|
+
strokeWidth={strokeWidth}
|
|
104
|
+
style="stroke"
|
|
105
|
+
/>
|
|
106
|
+
</Canvas>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
SignatureInk.displayName = "DelacourUI.Signature.Ink";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactElement, useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
|
-
import { type AccessibilityActionEvent, type LayoutChangeEvent,
|
|
2
|
+
import { type AccessibilityActionEvent, type LayoutChangeEvent, View, type ViewProps } from "react-native";
|
|
3
3
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
4
|
-
import Animated, {
|
|
4
|
+
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
|
|
5
5
|
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
|
6
6
|
import { useCalmMotion } from "../../hooks/use-calm-motion";
|
|
7
7
|
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
@@ -19,11 +19,7 @@ import {
|
|
|
19
19
|
type SignatureAction,
|
|
20
20
|
signatureVariants,
|
|
21
21
|
} from "./signature.variants";
|
|
22
|
-
import {
|
|
23
|
-
import { SignaturePath } from "./signature-path";
|
|
24
|
-
|
|
25
|
-
/** A cleared signature's ink fades rather than vanishing, so the clear reads as an action. */
|
|
26
|
-
const CLEAR_EXITING = FadeOut.duration(150);
|
|
22
|
+
import { SignatureInk } from "./signature-ink";
|
|
27
23
|
|
|
28
24
|
/** How long the placeholder takes to fade on first touch, in ms. */
|
|
29
25
|
const PLACEHOLDER_FADE_MS = 150;
|
|
@@ -40,10 +36,11 @@ export type SignaturePadProps = Omit<ViewProps, "children" | "style"> & {
|
|
|
40
36
|
* the whole accessibility surface.
|
|
41
37
|
*
|
|
42
38
|
* **Drawing happens on the UI thread.** One `Gesture.Pan()` samples the finger
|
|
43
|
-
* into a shared value and the live stroke
|
|
44
|
-
*
|
|
45
|
-
* finger lifts, the stroke crosses to the JS thread once and joins the
|
|
46
|
-
*
|
|
39
|
+
* into a shared value and the live stroke is a Skia path traced from it on the
|
|
40
|
+
* UI thread, so a finger moving across the pad renders nothing in React. When
|
|
41
|
+
* the finger lifts, the stroke crosses to the JS thread once and joins the
|
|
42
|
+
* finished strokes' picture — **one render per stroke, none per move**. Both
|
|
43
|
+
* draw on one Skia canvas; see `SignatureInk`.
|
|
47
44
|
*
|
|
48
45
|
* **The stroke starts in `onBegin`**, with `minDistance(0)`, so a tap that never
|
|
49
46
|
* moves still leaves a dot and the pad wins the touch from an enclosing scroll
|
|
@@ -203,15 +200,14 @@ export function SignaturePad({ className, onLayout, ...props }: SignaturePadProp
|
|
|
203
200
|
<Animated.View className={slots.placeholder()} pointerEvents="none" style={placeholderStyle}>
|
|
204
201
|
<Text.Paragraph color="muted">{placeholder}</Text.Paragraph>
|
|
205
202
|
</Animated.View>
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
<SignatureLivePath color={ink} points={live} strokeWidth={strokeWidth} />
|
|
203
|
+
<SignatureInk
|
|
204
|
+
clearCount={clearCount}
|
|
205
|
+
color={ink}
|
|
206
|
+
isCalm={isCalm}
|
|
207
|
+
live={live}
|
|
208
|
+
strokes={strokes}
|
|
209
|
+
strokeWidth={strokeWidth}
|
|
210
|
+
/>
|
|
215
211
|
</View>
|
|
216
212
|
</GestureDetector>
|
|
217
213
|
);
|
|
@@ -36,8 +36,8 @@ export type SignatureContextValue = SignatureState & {
|
|
|
36
36
|
/** Played as a stroke begins. `false` silences every haptic the signature plays. */
|
|
37
37
|
haptic: false | HapticFeedback;
|
|
38
38
|
/**
|
|
39
|
-
* Bumped by every clear. The pad
|
|
40
|
-
*
|
|
39
|
+
* Bumped by every clear. The pad's ink fades out the picture a clear replaced
|
|
40
|
+
* when it changes, rather than snapping the strokes away.
|
|
41
41
|
*/
|
|
42
42
|
clearCount: number;
|
|
43
43
|
/** The typed-name escape hatch, when the caller offers one. */
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ImageFormat,
|
|
3
|
+
PaintStyle,
|
|
4
|
+
type SkCanvas,
|
|
5
|
+
Skia,
|
|
6
|
+
type SkPath,
|
|
7
|
+
type SkPicture,
|
|
8
|
+
StrokeCap,
|
|
9
|
+
StrokeJoin,
|
|
10
|
+
} from "react-native-skia";
|
|
11
|
+
import { resolveSignatureExport, signaturePngSize, traceStroke } from "./signature.stroke";
|
|
12
|
+
import type { SignaturePngOptions, SignatureStroke, SignatureValue } from "./signature.types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every stroke's traced path, keyed by the stroke itself.
|
|
16
|
+
*
|
|
17
|
+
* Strokes are immutable — the history only ever appends, pops or replaces the
|
|
18
|
+
* array — so a stroke traced once never needs tracing again, and a commit costs
|
|
19
|
+
* one new path rather than a pass over the whole signature. A `WeakMap` lets a
|
|
20
|
+
* stroke undone and dropped take its path with it.
|
|
21
|
+
*/
|
|
22
|
+
const PATH_CACHE = new WeakMap<SignatureStroke, SkPath>();
|
|
23
|
+
|
|
24
|
+
/** One stroke as a Skia path, traced by the same {@link traceStroke} the live stroke is. */
|
|
25
|
+
export function signatureStrokePath(stroke: SignatureStroke): SkPath {
|
|
26
|
+
const cached = PATH_CACHE.get(stroke);
|
|
27
|
+
if (cached !== undefined) return cached;
|
|
28
|
+
const builder = Skia.PathBuilder.Make();
|
|
29
|
+
traceStroke(stroke, builder);
|
|
30
|
+
const path = builder.detach();
|
|
31
|
+
PATH_CACHE.set(stroke, path);
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What {@link recordSignaturePicture} draws with. `color` is already resolved, or `undefined`. */
|
|
36
|
+
type SignatureInkStyle = { color: string | undefined; strokeWidth: number };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The finished strokes, recorded once per change of the strokes or the ink.
|
|
40
|
+
*
|
|
41
|
+
* A picture replays as one draw call, so the canvas holds one node however many
|
|
42
|
+
* strokes there are. Each stroke is its own `drawPath`, as each was its own SVG
|
|
43
|
+
* path before, so a translucent ink still darkens where two strokes cross. An ink
|
|
44
|
+
* that did not resolve draws nothing, as an SVG `stroke={undefined}` did.
|
|
45
|
+
*/
|
|
46
|
+
export function recordSignaturePicture(strokes: readonly SignatureStroke[], ink: SignatureInkStyle): SkPicture {
|
|
47
|
+
const recorder = Skia.PictureRecorder();
|
|
48
|
+
drawSignatureStrokes(recorder.beginRecording(), strokes, ink);
|
|
49
|
+
return recorder.finishRecordingAsPicture();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Each stroke as its own `drawPath`, with a round-capped pen. Nothing when the ink did not resolve. */
|
|
53
|
+
function drawSignatureStrokes(canvas: SkCanvas, strokes: readonly SignatureStroke[], ink: SignatureInkStyle): void {
|
|
54
|
+
if (ink.color === undefined) return;
|
|
55
|
+
const paint = Skia.Paint();
|
|
56
|
+
paint.setAntiAlias(true);
|
|
57
|
+
paint.setStyle(PaintStyle.Stroke);
|
|
58
|
+
paint.setStrokeCap(StrokeCap.Round);
|
|
59
|
+
paint.setStrokeJoin(StrokeJoin.Round);
|
|
60
|
+
paint.setStrokeWidth(ink.strokeWidth);
|
|
61
|
+
paint.setColor(Skia.Color(ink.color));
|
|
62
|
+
for (const stroke of strokes) canvas.drawPath(signatureStrokePath(stroke), paint);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The signature as a base64 PNG, rendered off screen on a CPU surface.
|
|
67
|
+
*
|
|
68
|
+
* Framed and coloured by {@link resolveSignatureExport}, the function `toSVG`
|
|
69
|
+
* uses, so the PNG is the SVG rasterised: the same box, the same literal ink,
|
|
70
|
+
* the same background or none. Off screen rather than a snapshot of the pad, so
|
|
71
|
+
* the guideline, the placeholder and the pad's own fill never reach the file.
|
|
72
|
+
* The strokes are the cached paths the pad draws. Returns `""` when the frame
|
|
73
|
+
* has no pixels — an unmeasured pad.
|
|
74
|
+
*/
|
|
75
|
+
export function renderSignaturePng(value: SignatureValue, options: SignaturePngOptions & { scale: number }): string {
|
|
76
|
+
const { background, box, strokeColor, strokeWidth } = resolveSignatureExport(value, options);
|
|
77
|
+
const size = signaturePngSize(box, options.scale);
|
|
78
|
+
if (size === null) return "";
|
|
79
|
+
|
|
80
|
+
const surface = Skia.Surface.MakeRaster(size.width, size.height);
|
|
81
|
+
if (surface === null) return "";
|
|
82
|
+
const canvas = surface.getCanvas();
|
|
83
|
+
canvas.scale(size.width / box.width, size.height / box.height);
|
|
84
|
+
canvas.translate(-box.x, -box.y);
|
|
85
|
+
if (background !== null) canvas.drawColor(Skia.Color(background));
|
|
86
|
+
drawSignatureStrokes(canvas, value.strokes, { color: strokeColor, strokeWidth });
|
|
87
|
+
surface.flush();
|
|
88
|
+
return surface.makeImageSnapshot().encodeToBase64(ImageFormat.PNG);
|
|
89
|
+
}
|
|
@@ -10,16 +10,20 @@ import {
|
|
|
10
10
|
historyToValue,
|
|
11
11
|
isHistoryEmpty,
|
|
12
12
|
parseSignature,
|
|
13
|
+
resolveSignatureExport,
|
|
13
14
|
SIGNATURE_EXPORT_PADDING,
|
|
14
15
|
SIGNATURE_EXPORT_STROKE_COLOR,
|
|
15
16
|
type SignatureHistory,
|
|
17
|
+
type SignaturePathSink,
|
|
16
18
|
scaleValue,
|
|
17
19
|
serializeSignature,
|
|
18
20
|
shouldAddPoint,
|
|
21
|
+
signaturePngSize,
|
|
19
22
|
smoothStroke,
|
|
20
23
|
strokeCount,
|
|
21
24
|
strokesToPaths,
|
|
22
25
|
toSVG,
|
|
26
|
+
traceStroke,
|
|
23
27
|
} from "./signature.stroke";
|
|
24
28
|
import type { SignatureStroke, SignatureValue } from "./signature.types";
|
|
25
29
|
|
|
@@ -104,6 +108,83 @@ describe("strokesToPaths", () => {
|
|
|
104
108
|
});
|
|
105
109
|
});
|
|
106
110
|
|
|
111
|
+
/** A sink that writes what it is handed back out as path data, rounded the way `smoothStroke` rounds. */
|
|
112
|
+
function recordingSink(): SignaturePathSink & { calls: string[]; d: () => string } {
|
|
113
|
+
const calls: string[] = [];
|
|
114
|
+
const n = (value: number): string => {
|
|
115
|
+
const rounded = Math.round(value * 100) / 100;
|
|
116
|
+
return String(rounded === 0 ? 0 : rounded);
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
calls,
|
|
120
|
+
d: () => calls.join(" "),
|
|
121
|
+
lineTo: (x, y) => calls.push(`L${n(x)} ${n(y)}`),
|
|
122
|
+
moveTo: (x, y) => calls.push(`M${n(x)} ${n(y)}`),
|
|
123
|
+
quadTo: (x1, y1, x2, y2) => calls.push(`Q${n(x1)} ${n(y1)} ${n(x2)} ${n(y2)}`),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe("traceStroke", () => {
|
|
128
|
+
test("no points draws nothing", () => {
|
|
129
|
+
const sink = recordingSink();
|
|
130
|
+
traceStroke([], sink);
|
|
131
|
+
expect(sink.calls).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("one point is a zero-length segment, which a round cap draws as a dot", () => {
|
|
135
|
+
const sink = recordingSink();
|
|
136
|
+
traceStroke([{ x: 4, y: 6 }], sink);
|
|
137
|
+
expect(sink.calls).toEqual(["M4 6", "L4 6"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("two points are a line", () => {
|
|
141
|
+
const sink = recordingSink();
|
|
142
|
+
traceStroke(LINE, sink);
|
|
143
|
+
expect(sink.calls).toEqual(["M0 0", "L10 10"]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("three or more points are midpoint quadratics ending on the last point", () => {
|
|
147
|
+
const sink = recordingSink();
|
|
148
|
+
traceStroke(CURVE, sink);
|
|
149
|
+
expect(sink.calls).toEqual(["M0 0", "Q10 0 15 5", "Q20 10 25 10", "L30 10"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("coordinates are passed unrounded", () => {
|
|
153
|
+
const calls: number[][] = [];
|
|
154
|
+
traceStroke([{ x: 1.23456, y: 2.34567 }], {
|
|
155
|
+
lineTo: (x, y) => calls.push([x, y]),
|
|
156
|
+
moveTo: (x, y) => calls.push([x, y]),
|
|
157
|
+
quadTo: () => undefined,
|
|
158
|
+
});
|
|
159
|
+
expect(calls).toEqual([
|
|
160
|
+
[1.23456, 2.34567],
|
|
161
|
+
[1.23456, 2.34567],
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a non-finite coordinate is traced as zero, as the string form writes it", () => {
|
|
166
|
+
const sink = recordingSink();
|
|
167
|
+
traceStroke([{ x: Number.NaN, y: Number.POSITIVE_INFINITY }], sink);
|
|
168
|
+
expect(sink.calls).toEqual(["M0 0", "L0 0"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const SAMPLES: readonly SignatureStroke[] = [
|
|
172
|
+
[],
|
|
173
|
+
[{ x: 0.004, y: -0.004 }],
|
|
174
|
+
LINE,
|
|
175
|
+
CURVE,
|
|
176
|
+
Array.from({ length: 40 }, (_, index) => ({ x: index * 3.137, y: Math.sin(index / 3) * 21.5 + 40 })),
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
for (const [index, stroke] of SAMPLES.entries()) {
|
|
180
|
+
test(`draws exactly what smoothStroke writes (sample ${index})`, () => {
|
|
181
|
+
const sink = recordingSink();
|
|
182
|
+
traceStroke(stroke, sink);
|
|
183
|
+
expect(sink.d()).toBe(smoothStroke(stroke));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
107
188
|
describe("the smoother is a worklet", () => {
|
|
108
189
|
const source = readFileSync(join(import.meta.dirname, "signature.stroke.ts"), "utf-8");
|
|
109
190
|
|
|
@@ -111,7 +192,7 @@ describe("the smoother is a worklet", () => {
|
|
|
111
192
|
expect(source).not.toMatch(/from "react-native/);
|
|
112
193
|
});
|
|
113
194
|
|
|
114
|
-
for (const name of ["shouldAddPoint", "smoothStroke", "formatCoordinate"]) {
|
|
195
|
+
for (const name of ["shouldAddPoint", "smoothStroke", "formatCoordinate", "traceStroke"]) {
|
|
115
196
|
test(`${name} carries the worklet directive`, () => {
|
|
116
197
|
const body = source.slice(source.indexOf(`function ${name}(`));
|
|
117
198
|
expect(body.slice(0, body.indexOf("\n}"))).toContain('"worklet";');
|
|
@@ -352,6 +433,66 @@ describe("toSVG", () => {
|
|
|
352
433
|
});
|
|
353
434
|
});
|
|
354
435
|
|
|
436
|
+
describe("resolveSignatureExport", () => {
|
|
437
|
+
test("the default is the whole pad, black ink, the default width, no background", () => {
|
|
438
|
+
expect(resolveSignatureExport(VALUE)).toEqual({
|
|
439
|
+
background: null,
|
|
440
|
+
box: { x: 0, y: 0, width: 200, height: 100 },
|
|
441
|
+
strokeColor: SIGNATURE_EXPORT_STROKE_COLOR,
|
|
442
|
+
strokeWidth: 2.5,
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("literal colours and the width pass through", () => {
|
|
447
|
+
const resolved = resolveSignatureExport(VALUE, { background: "#fff", strokeColor: "#123456", strokeWidth: 4 });
|
|
448
|
+
expect(resolved.background).toBe("#fff");
|
|
449
|
+
expect(resolved.strokeColor).toBe("#123456");
|
|
450
|
+
expect(resolved.strokeWidth).toBe(4);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("a token name or transparent is no colour", () => {
|
|
454
|
+
const resolved = resolveSignatureExport(VALUE, { background: "transparent", strokeColor: "foreground" });
|
|
455
|
+
expect(resolved.background).toBeNull();
|
|
456
|
+
expect(resolved.strokeColor).toBe(SIGNATURE_EXPORT_STROKE_COLOR);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("tight crops to the ink plus padding", () => {
|
|
460
|
+
const value: SignatureValue = { width: 200, height: 100, strokes: [CURVE] };
|
|
461
|
+
expect(resolveSignatureExport(value, { padding: 0, strokeWidth: 2, viewBox: "tight" }).box).toEqual({
|
|
462
|
+
x: -1,
|
|
463
|
+
y: -1,
|
|
464
|
+
width: 32,
|
|
465
|
+
height: 12,
|
|
466
|
+
});
|
|
467
|
+
const pad = SIGNATURE_EXPORT_PADDING;
|
|
468
|
+
expect(resolveSignatureExport(value, { strokeWidth: 2, viewBox: "tight" }).box).toEqual({
|
|
469
|
+
x: -1 - pad,
|
|
470
|
+
y: -1 - pad,
|
|
471
|
+
width: 32 + pad * 2,
|
|
472
|
+
height: 12 + pad * 2,
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("tight with no ink falls back to the pad", () => {
|
|
477
|
+
const resolved = resolveSignatureExport({ width: 200, height: 100, strokes: [] }, { viewBox: "tight" });
|
|
478
|
+
expect(resolved.box).toEqual({ x: 0, y: 0, width: 200, height: 100 });
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe("signaturePngSize", () => {
|
|
483
|
+
test("points times scale, rounded to whole pixels", () => {
|
|
484
|
+
expect(signaturePngSize({ x: 0, y: 0, width: 200, height: 100 }, 3)).toEqual({ width: 600, height: 300 });
|
|
485
|
+
expect(signaturePngSize({ x: -9, y: -9, width: 50.4, height: 10.2 }, 2)).toEqual({ width: 101, height: 20 });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("an unmeasured box or a bad scale has no pixels", () => {
|
|
489
|
+
expect(signaturePngSize({ x: 0, y: 0, width: 0, height: 100 }, 3)).toBeNull();
|
|
490
|
+
expect(signaturePngSize({ x: 0, y: 0, width: 200, height: 100 }, 0)).toBeNull();
|
|
491
|
+
expect(signaturePngSize({ x: 0, y: 0, width: 200, height: 100 }, Number.NaN)).toBeNull();
|
|
492
|
+
expect(signaturePngSize({ x: 0, y: 0, width: 0.1, height: 0.1 }, 1)).toBeNull();
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
355
496
|
describe("scaleValue", () => {
|
|
356
497
|
test("the same size is the same value", () => {
|
|
357
498
|
expect(scaleValue(VALUE, 200, 100)).toBe(VALUE);
|
|
@@ -93,6 +93,56 @@ export function smoothStroke(points: SignatureStroke): string {
|
|
|
93
93
|
return `${path}${end}`;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* What {@link traceStroke} draws into — the three path verbs it uses. A Skia
|
|
98
|
+
* `SkPathBuilder` is one; so is anything that records the calls.
|
|
99
|
+
*/
|
|
100
|
+
export type SignaturePathSink = {
|
|
101
|
+
moveTo: (x: number, y: number) => unknown;
|
|
102
|
+
lineTo: (x: number, y: number) => unknown;
|
|
103
|
+
quadTo: (x1: number, y1: number, x2: number, y2: number) => unknown;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One stroke drawn straight into a path, verb by verb — the curve
|
|
108
|
+
* {@link smoothStroke} writes, without a string to build and parse back.
|
|
109
|
+
*
|
|
110
|
+
* Coordinates go in unrounded, so the live stroke and its committed copy, both
|
|
111
|
+
* traced here, match to the bit; a non-finite one is traced as zero, as the
|
|
112
|
+
* string form writes it. Self-contained, with no helper of this module's: a
|
|
113
|
+
* module-scope function called from a worklet can be missing from its closure on
|
|
114
|
+
* the UI thread.
|
|
115
|
+
*
|
|
116
|
+
* A worklet: the live stroke calls this on the UI thread on every move.
|
|
117
|
+
*/
|
|
118
|
+
export function traceStroke(points: SignatureStroke, sink: SignaturePathSink): void {
|
|
119
|
+
"worklet";
|
|
120
|
+
const count = points.length;
|
|
121
|
+
if (count === 0) return;
|
|
122
|
+
|
|
123
|
+
const first = points[0] as SignaturePoint;
|
|
124
|
+
const firstX = Number.isFinite(first.x) ? first.x : 0;
|
|
125
|
+
const firstY = Number.isFinite(first.y) ? first.y : 0;
|
|
126
|
+
sink.moveTo(firstX, firstY);
|
|
127
|
+
if (count === 1) {
|
|
128
|
+
sink.lineTo(firstX, firstY);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (let index = 1; index < count - 1; index++) {
|
|
133
|
+
const control = points[index] as SignaturePoint;
|
|
134
|
+
const next = points[index + 1] as SignaturePoint;
|
|
135
|
+
const controlX = Number.isFinite(control.x) ? control.x : 0;
|
|
136
|
+
const controlY = Number.isFinite(control.y) ? control.y : 0;
|
|
137
|
+
const midX = (control.x + next.x) / 2;
|
|
138
|
+
const midY = (control.y + next.y) / 2;
|
|
139
|
+
sink.quadTo(controlX, controlY, Number.isFinite(midX) ? midX : 0, Number.isFinite(midY) ? midY : 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const last = points[count - 1] as SignaturePoint;
|
|
143
|
+
sink.lineTo(Number.isFinite(last.x) ? last.x : 0, Number.isFinite(last.y) ? last.y : 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
96
146
|
/** One smoothed path per stroke, in order. */
|
|
97
147
|
export function strokesToPaths(strokes: readonly SignatureStroke[]): readonly string[] {
|
|
98
148
|
return strokes.map((stroke) => smoothStroke(stroke));
|
|
@@ -300,19 +350,25 @@ function formatNumber(value: number): string {
|
|
|
300
350
|
return formatCoordinate(value);
|
|
301
351
|
}
|
|
302
352
|
|
|
353
|
+
/** What an export draws, with every option resolved: the same for `toSVG` and `toPNG`. */
|
|
354
|
+
export type SignatureExport = {
|
|
355
|
+
/** The frame, in pad coordinates. */
|
|
356
|
+
box: SignatureBounds;
|
|
357
|
+
strokeColor: string;
|
|
358
|
+
strokeWidth: number;
|
|
359
|
+
/** A literal colour, or `null` for none. */
|
|
360
|
+
background: string | null;
|
|
361
|
+
};
|
|
362
|
+
|
|
303
363
|
/**
|
|
304
|
-
*
|
|
364
|
+
* An export's options resolved against a value.
|
|
305
365
|
*
|
|
306
|
-
* One
|
|
307
|
-
*
|
|
308
|
-
* `
|
|
309
|
-
* `
|
|
310
|
-
*
|
|
311
|
-
* Colours must be literal. A token name is ignored — the ink falls back to
|
|
312
|
-
* {@link SIGNATURE_EXPORT_STROKE_COLOR} and the background to none — because a
|
|
313
|
-
* file has no theme to resolve one against. Every attribute is escaped.
|
|
366
|
+
* One function so the SVG and the PNG frame and colour a signature identically:
|
|
367
|
+
* `viewBox: "pad"` keeps the whole pad, `"tight"` crops to the ink plus
|
|
368
|
+
* `padding` and falls back to the pad with no ink; a token name or
|
|
369
|
+
* `transparent` is no colour.
|
|
314
370
|
*/
|
|
315
|
-
export function
|
|
371
|
+
export function resolveSignatureExport(value: SignatureValue, options: SignatureExportOptions = {}): SignatureExport {
|
|
316
372
|
const strokeWidth = options.strokeWidth ?? SIGNATURE_STROKE_WIDTH;
|
|
317
373
|
const strokeColor =
|
|
318
374
|
options.strokeColor !== undefined && isLiteralColor(options.strokeColor)
|
|
@@ -331,6 +387,36 @@ export function toSVG(value: SignatureValue, options: SignatureExportOptions = {
|
|
|
331
387
|
}
|
|
332
388
|
: { x: 0, y: 0, width: value.width, height: value.height };
|
|
333
389
|
|
|
390
|
+
return { background, box, strokeColor, strokeWidth };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A PNG's size in whole pixels: the box times `scale`, rounded. `null` when that
|
|
395
|
+
* is no pixels at all — an unmeasured pad, or a scale that is not positive.
|
|
396
|
+
*/
|
|
397
|
+
export function signaturePngSize(box: SignatureBounds, scale: number): { width: number; height: number } | null {
|
|
398
|
+
if (!(Number.isFinite(scale) && scale > 0)) return null;
|
|
399
|
+
const width = Math.round(box.width * scale);
|
|
400
|
+
const height = Math.round(box.height * scale);
|
|
401
|
+
if (!(width > 0 && height > 0)) return null;
|
|
402
|
+
return { width, height };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The signature as a standalone SVG document.
|
|
407
|
+
*
|
|
408
|
+
* One `<path>` per stroke, unfilled, with round caps and joins — the same
|
|
409
|
+
* geometry the pad draws, so the file opens looking like what was signed.
|
|
410
|
+
* `viewBox: "pad"` keeps the whole pad; `"tight"` crops to the ink plus
|
|
411
|
+
* `padding`, and falls back to the pad when there is no ink to crop to.
|
|
412
|
+
*
|
|
413
|
+
* Colours must be literal. A token name is ignored — the ink falls back to
|
|
414
|
+
* {@link SIGNATURE_EXPORT_STROKE_COLOR} and the background to none — because a
|
|
415
|
+
* file has no theme to resolve one against. Every attribute is escaped.
|
|
416
|
+
*/
|
|
417
|
+
export function toSVG(value: SignatureValue, options: SignatureExportOptions = {}): string {
|
|
418
|
+
const { background, box, strokeColor, strokeWidth } = resolveSignatureExport(value, options);
|
|
419
|
+
|
|
334
420
|
const x = formatNumber(box.x);
|
|
335
421
|
const y = formatNumber(box.y);
|
|
336
422
|
const width = formatNumber(box.width);
|
|
@@ -9,10 +9,11 @@ import {
|
|
|
9
9
|
useRef,
|
|
10
10
|
useState,
|
|
11
11
|
} from "react";
|
|
12
|
-
import { AccessibilityInfo, View, type ViewProps } from "react-native";
|
|
12
|
+
import { AccessibilityInfo, PixelRatio, View, type ViewProps } from "react-native";
|
|
13
13
|
import { useFieldContext } from "../field/field.context";
|
|
14
14
|
import { type HapticFeedback, playHaptic } from "../pressable";
|
|
15
15
|
import { type SignatureContextValue, SignatureProvider } from "./signature.context";
|
|
16
|
+
import { renderSignaturePng } from "./signature.skia";
|
|
16
17
|
import {
|
|
17
18
|
canRedo,
|
|
18
19
|
canUndo,
|
|
@@ -88,7 +89,7 @@ export type SignatureProps = Omit<ViewProps, "children" | "style"> & {
|
|
|
88
89
|
haptic?: false | HapticFeedback;
|
|
89
90
|
/** Offers a typed-name route for anyone who cannot draw; renders `Signature.Alternative`. */
|
|
90
91
|
onRequestAlternative?: () => void;
|
|
91
|
-
/** `undo`, `redo`, `clear`, `isEmpty`, `strokeCount`, `value`, `toSVG()` and `toPaths()`. */
|
|
92
|
+
/** `undo`, `redo`, `clear`, `isEmpty`, `strokeCount`, `value`, `toSVG()`, `toPNG()` and `toPaths()`. */
|
|
92
93
|
ref?: Ref<SignatureHandle>;
|
|
93
94
|
className?: string;
|
|
94
95
|
/** Classes for the default pad, when no children are given. */
|
|
@@ -231,6 +232,13 @@ function SignatureRoot({
|
|
|
231
232
|
{ strokeWidth, ...options }
|
|
232
233
|
);
|
|
233
234
|
},
|
|
235
|
+
toPNG: (options) => {
|
|
236
|
+
const current = historyRef.current;
|
|
237
|
+
return renderSignaturePng(
|
|
238
|
+
{ height: current.height, strokes: current.strokes, width: current.width },
|
|
239
|
+
{ strokeWidth, ...options, scale: options?.scale ?? PixelRatio.get() }
|
|
240
|
+
);
|
|
241
|
+
},
|
|
234
242
|
toPaths: () => strokesToPaths(historyRef.current.strokes),
|
|
235
243
|
}),
|
|
236
244
|
[clear, redo, strokeWidth, undo]
|
|
@@ -330,8 +338,8 @@ function SignatureRoot({
|
|
|
330
338
|
*
|
|
331
339
|
* **The value is plain JSON** — `{ width, height, strokes }` — so it can be stored
|
|
332
340
|
* and handed back as `value` or `defaultValue` later. A pad rendered at another
|
|
333
|
-
* size re-fits it, keeping its aspect. The ref adds `toSVG()` for a
|
|
334
|
-
* `toPaths()` for drawing it elsewhere.
|
|
341
|
+
* size re-fits it, keeping its aspect. The ref adds `toSVG()` and `toPNG()` for a
|
|
342
|
+
* file, framed and coloured alike, and `toPaths()` for drawing it elsewhere.
|
|
335
343
|
*
|
|
336
344
|
* **Offer `onRequestAlternative`.** A signature is a path a finger has to trace,
|
|
337
345
|
* and no accessibility label makes that possible for everyone; the prop renders
|
|
@@ -361,6 +369,7 @@ function SignatureRoot({
|
|
|
361
369
|
*
|
|
362
370
|
* @example
|
|
363
371
|
* const svg = pad.current?.toSVG({ viewBox: "tight", background: "#ffffff" });
|
|
372
|
+
* const png = pad.current?.toPNG({ viewBox: "tight", background: "#ffffff" }); // base64
|
|
364
373
|
*/
|
|
365
374
|
export const Signature = Object.assign(SignatureRoot, {
|
|
366
375
|
/** The drawing surface — the gesture, the ink, the guideline and the accessibility surface. */
|
|
@@ -46,6 +46,12 @@ export type SignatureExportOptions = {
|
|
|
46
46
|
padding?: number;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/** Options for `toPNG()`: the SVG's, plus the pixel density. */
|
|
50
|
+
export type SignaturePngOptions = SignatureExportOptions & {
|
|
51
|
+
/** Pixels per point. Default: the screen's pixel ratio. */
|
|
52
|
+
scale?: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
49
55
|
/** What a signature's ref is handed. */
|
|
50
56
|
export type SignatureHandle = {
|
|
51
57
|
/** Removes the last stroke. A no-op with nothing to undo. */
|
|
@@ -60,6 +66,12 @@ export type SignatureHandle = {
|
|
|
60
66
|
readonly value: SignatureValue | null;
|
|
61
67
|
/** The signature as a standalone SVG document. Valid, if blank, when empty. */
|
|
62
68
|
toSVG: (options?: SignatureExportOptions) => string;
|
|
69
|
+
/**
|
|
70
|
+
* The signature as a base64-encoded PNG (no `data:` prefix), framed and
|
|
71
|
+
* coloured as `toSVG` with the same options would be, at `scale` pixels per
|
|
72
|
+
* point. Transparent unless `background` is set. `""` before the pad is measured.
|
|
73
|
+
*/
|
|
74
|
+
toPNG: (options?: SignaturePngOptions) => string;
|
|
63
75
|
/** One smoothed SVG path `d` per stroke, in pad coordinates. */
|
|
64
76
|
toPaths: () => readonly string[];
|
|
65
77
|
};
|
|
@@ -136,10 +136,6 @@ DisplayNames: `DelacourUI.Sketch`, `.Sketch.Canvas`, `.Toolbar`, `.Tool`,
|
|
|
136
136
|
- **`toSVG()` is vector and `toPNG()` is raster; both hold the marks and the
|
|
137
137
|
background only.** Neither includes what `Sketch.Canvas` children paint under
|
|
138
138
|
the marks.
|
|
139
|
-
- **The canvas imports `lib/skia-native-id` for its effect.** After a JS reload Skia reused the
|
|
140
|
-
old runtime's canvas ids, which its native registry still held as torn down, so a sketch came up
|
|
141
|
-
blank until something re-rendered it. The import moves this runtime's ids to a fresh range
|
|
142
|
-
before the canvas asks for one.
|
|
143
139
|
- **`toPNG()` renders off screen**, on a CPU surface (`Skia.Surface.MakeRaster`) sized
|
|
144
140
|
to the canvas times `scale` (default the screen's pixel ratio), by the same
|
|
145
141
|
`drawSketchItems` the picture records — not a snapshot of the visible canvas,
|
|
@@ -4,7 +4,6 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
|
4
4
|
import { useDerivedValue, useSharedValue } from "react-native-reanimated";
|
|
5
5
|
import { Canvas, Group, Path, Picture, Skia } from "react-native-skia";
|
|
6
6
|
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
|
7
|
-
import "../../lib/skia-native-id";
|
|
8
7
|
import { useSketchPart } from "./sketch.context";
|
|
9
8
|
import {
|
|
10
9
|
appendSketchSegment,
|
|
@@ -2,7 +2,6 @@ import type { ReactElement } from "react";
|
|
|
2
2
|
import { StyleSheet } from "react-native";
|
|
3
3
|
import { type SharedValue, useDerivedValue } from "react-native-reanimated";
|
|
4
4
|
import { Canvas, Group, Rect, RoundedRect, Skia } from "react-native-skia";
|
|
5
|
-
import "../../lib/skia-native-id";
|
|
6
5
|
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
7
6
|
import { resolveMaskHole, TOUR_SCRIM_TOKEN, type TourSize } from "./tour.variants";
|
|
8
7
|
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { ReactElement } from "react";
|
|
2
|
-
import { StyleSheet } from "react-native";
|
|
3
|
-
import Animated, { type SharedValue, useAnimatedProps } from "react-native-reanimated";
|
|
4
|
-
import Svg, { Path } from "react-native-svg";
|
|
5
|
-
import { smoothStroke } from "./signature.stroke";
|
|
6
|
-
import type { SignaturePoint } from "./signature.types";
|
|
7
|
-
|
|
8
|
-
/** Created once at module scope: per render it would mint a new component type and remount the path. */
|
|
9
|
-
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
10
|
-
|
|
11
|
-
export type SignatureLivePathProps = {
|
|
12
|
-
/** The stroke under the finger, written by the pad's gesture on the UI thread. */
|
|
13
|
-
points: SharedValue<SignaturePoint[]>;
|
|
14
|
-
color: string | undefined;
|
|
15
|
-
strokeWidth: number;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* The stroke being drawn, rebuilt on the UI thread on every move.
|
|
20
|
-
*
|
|
21
|
-
* Internal. Its `d` is an animated prop computed by the same `smoothStroke` the
|
|
22
|
-
* finished paths use, so React never renders while a finger moves and the
|
|
23
|
-
* stroke does not shift by a hair when it is committed.
|
|
24
|
-
*/
|
|
25
|
-
export function SignatureLivePath({ points, color, strokeWidth }: SignatureLivePathProps): ReactElement {
|
|
26
|
-
const animatedProps = useAnimatedProps(() => ({ d: smoothStroke(points.value) }));
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<Svg height="100%" pointerEvents="none" style={StyleSheet.absoluteFill} width="100%">
|
|
30
|
-
<AnimatedPath
|
|
31
|
-
animatedProps={animatedProps}
|
|
32
|
-
fill="none"
|
|
33
|
-
stroke={color}
|
|
34
|
-
strokeLinecap="round"
|
|
35
|
-
strokeLinejoin="round"
|
|
36
|
-
strokeWidth={strokeWidth}
|
|
37
|
-
/>
|
|
38
|
-
</Svg>
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
SignatureLivePath.displayName = "DelacourUI.Signature.LivePath";
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { type ReactElement, useMemo } from "react";
|
|
2
|
-
import { StyleSheet } from "react-native";
|
|
3
|
-
import Svg, { Path } from "react-native-svg";
|
|
4
|
-
import { smoothStroke } from "./signature.stroke";
|
|
5
|
-
import type { SignatureStroke } from "./signature.types";
|
|
6
|
-
|
|
7
|
-
export type SignaturePathProps = {
|
|
8
|
-
strokes: readonly SignatureStroke[];
|
|
9
|
-
color: string | undefined;
|
|
10
|
-
strokeWidth: number;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Every stroke's smoothed path, keyed by the stroke itself.
|
|
15
|
-
*
|
|
16
|
-
* Strokes are immutable — the history only ever appends, pops or replaces the
|
|
17
|
-
* array — so a stroke that has been smoothed once never needs smoothing again,
|
|
18
|
-
* and a commit costs one new path rather than a pass over the whole signature.
|
|
19
|
-
* A `WeakMap` lets a stroke undone and dropped take its path with it.
|
|
20
|
-
*/
|
|
21
|
-
const PATH_CACHE = new WeakMap<SignatureStroke, string>();
|
|
22
|
-
|
|
23
|
-
function pathOf(stroke: SignatureStroke): string {
|
|
24
|
-
const cached = PATH_CACHE.get(stroke);
|
|
25
|
-
if (cached !== undefined) return cached;
|
|
26
|
-
const path = smoothStroke(stroke);
|
|
27
|
-
PATH_CACHE.set(stroke, path);
|
|
28
|
-
return path;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* The finished strokes, as static SVG paths filling the pad.
|
|
33
|
-
*
|
|
34
|
-
* Internal. Re-renders once per committed stroke and never while a finger moves
|
|
35
|
-
* — the stroke under the finger is {@link SignatureLivePath}'s.
|
|
36
|
-
*/
|
|
37
|
-
export function SignaturePath({ strokes, color, strokeWidth }: SignaturePathProps): ReactElement {
|
|
38
|
-
const paths = useMemo(() => strokes.map(pathOf), [strokes]);
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<Svg height="100%" pointerEvents="none" style={StyleSheet.absoluteFill} width="100%">
|
|
42
|
-
{paths.map((d, index) => (
|
|
43
|
-
<Path
|
|
44
|
-
d={d}
|
|
45
|
-
fill="none"
|
|
46
|
-
key={index}
|
|
47
|
-
stroke={color}
|
|
48
|
-
strokeLinecap="round"
|
|
49
|
-
strokeLinejoin="round"
|
|
50
|
-
strokeWidth={strokeWidth}
|
|
51
|
-
/>
|
|
52
|
-
))}
|
|
53
|
-
</Svg>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
SignaturePath.displayName = "DelacourUI.Signature.Path";
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { resolveSkiaNativeId, SKIA_NATIVE_ID_FLOOR } from "./skia-native-id";
|
|
3
|
-
|
|
4
|
-
describe("resolveSkiaNativeId", () => {
|
|
5
|
-
test("moves a fresh runtime's counter off Skia's starting id", () => {
|
|
6
|
-
expect(resolveSkiaNativeId(1000, 0)).toBe(SKIA_NATIVE_ID_FLOOR);
|
|
7
|
-
expect(resolveSkiaNativeId(1000, 0.5)).toBeGreaterThan(SKIA_NATIVE_ID_FLOOR);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
test("keeps the ids a runtime already handed out behind the new counter", () => {
|
|
11
|
-
expect(resolveSkiaNativeId(1003, 0)).toBe(SKIA_NATIVE_ID_FLOOR + 3);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test("leaves a counter that is already seeded alone, so two copies seed once", () => {
|
|
15
|
-
const seeded = resolveSkiaNativeId(1000, 0.25);
|
|
16
|
-
expect(resolveSkiaNativeId(seeded, 0.75)).toBe(seeded);
|
|
17
|
-
expect(resolveSkiaNativeId(seeded + 12, 0.75)).toBe(seeded + 12);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("spreads runtimes far enough apart that one's ids never reach the next's", () => {
|
|
21
|
-
const a = resolveSkiaNativeId(1000, 0.1);
|
|
22
|
-
const b = resolveSkiaNativeId(1000, 0.1 + 1 / 100_000);
|
|
23
|
-
expect(b - a).toBeGreaterThanOrEqual(10_000);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("stays inside a signed 32-bit int, which Skia's native side casts ids to", () => {
|
|
27
|
-
expect(resolveSkiaNativeId(1000, 0.999_999_999)).toBeLessThan(2 ** 31 - 1_000_000);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { SkiaViewNativeId } from "react-native-skia/src/views/SkiaViewNativeId";
|
|
2
|
-
|
|
3
|
-
/** Skia's counter starts at 1000; anything at or above this has been seeded. */
|
|
4
|
-
export const SKIA_NATIVE_ID_FLOOR = 10_000;
|
|
5
|
-
|
|
6
|
-
/** Ids a runtime may hand out before it reaches the next runtime's range. */
|
|
7
|
-
const SKIA_NATIVE_ID_SPAN = 10_000;
|
|
8
|
-
|
|
9
|
-
/** How many ranges a runtime can land in — 1e9 at the top, inside a signed 32-bit int. */
|
|
10
|
-
const SKIA_NATIVE_ID_SLOTS = 100_000;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Where a JS runtime's Skia canvas ids should start, given the counter as it stands and a number
|
|
14
|
-
* in `[0, 1)`. A counter already at or above the floor is returned as it is, so every copy of this
|
|
15
|
-
* module in an app can run it and only the first moves anything.
|
|
16
|
-
*/
|
|
17
|
-
export function resolveSkiaNativeId(current: number, random: number): number {
|
|
18
|
-
if (current >= SKIA_NATIVE_ID_FLOOR) return current;
|
|
19
|
-
const slot = Math.floor(random * SKIA_NATIVE_ID_SLOTS);
|
|
20
|
-
return SKIA_NATIVE_ID_FLOOR + slot * SKIA_NATIVE_ID_SPAN + (current - 1000);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Starts this runtime's canvas ids somewhere the last runtime's never were.
|
|
25
|
-
*
|
|
26
|
-
* **A canvas mounted after a JS reload drew nothing until it re-rendered.** Skia numbers canvases
|
|
27
|
-
* from a module-level counter that restarts at 1000 in every runtime, while its native
|
|
28
|
-
* `ViewRegistry` is a process-wide singleton that outlives the reload. The old runtime's views are
|
|
29
|
-
* torn down after the new one has started, and each leaves a tombstone for its id; the new
|
|
30
|
-
* runtime's first canvases reuse those ids, their pictures are handed to a throwaway entry, and the
|
|
31
|
-
* view registers to an empty one. Nothing draws until the next React commit sends the picture again.
|
|
32
|
-
* A dev-menu reload hits it, and so does `Updates.reloadAsync` after an OTA update.
|
|
33
|
-
* Reported upstream as wcandillon/react-native-skia#4312; drop this once a release fixes it.
|
|
34
|
-
*
|
|
35
|
-
* Evaluated once per runtime, on import, before any canvas has asked for an id. `SkiaViewNativeId`
|
|
36
|
-
* is not exported from the package root, so this reaches it by path — the same file Metro resolves
|
|
37
|
-
* `Canvas` from through the package's `react-native` field.
|
|
38
|
-
*
|
|
39
|
-
* `@delacour/react-native-charts` keeps an identical copy in `src/skia/native-id.ts` for its own
|
|
40
|
-
* canvases, since `Chart` draws through it; change both together.
|
|
41
|
-
*/
|
|
42
|
-
SkiaViewNativeId.current = resolveSkiaNativeId(SkiaViewNativeId.current, Math.random());
|