@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.5.0-alpha.20261021211439",
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.20261021211439",
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, or as
6
- a standalone SVG document.
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-path.tsx` | The finished strokes as static SVG paths. Internal |
18
- | `signature-live-path.tsx` | The stroke under the finger, an animated `d` rebuilt on the UI thread. Internal |
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 smoother (worklets), history reducer, bounds, re-fit, SVG export, JSON round trip |
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 the live stroke's `d` is an animated prop
44
- rebuilt from it, so a moving finger renders nothing. On finger-up the stroke
45
- crosses to the JS thread with `scheduleOnRN` and joins the static paths: one
46
- render per stroke, none per move. A signature is a few dozen strokes of a few
47
- hundred points; a render per move would be thousands.
48
- - **The live and the static path are drawn by the same function.**
49
- `smoothStroke` is a worklet, so the UI thread and the committed path compute
50
- the identical string and a stroke does not shift by a hair when it is handed
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 by keying the ink layer on a clear counter and
76
- letting the old layer's `exiting` animation run — instant under
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
- - **No raster export.** A PNG needs `react-native-view-shot` or Skia, and neither
112
- is worth a peer for a format SVG already covers; it is an open question rather
113
- than a missing feature. The point's `t` is stored so a speed-eased stroke width
114
- can follow without a format change.
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, StyleSheet, View, type ViewProps } from "react-native";
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, { FadeOut, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
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 { SignatureLivePath } from "./signature-live-path";
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's path is an animated prop rebuilt
44
- * from it, so a finger moving across the pad renders nothing in React. When the
45
- * finger lifts, the stroke crosses to the JS thread once and joins the finished
46
- * paths — **one render per stroke, none per move**.
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
- <Animated.View
207
- exiting={isCalm ? undefined : CLEAR_EXITING}
208
- key={clearCount}
209
- pointerEvents="none"
210
- style={StyleSheet.absoluteFill}
211
- >
212
- <SignaturePath color={ink} strokes={strokes} strokeWidth={strokeWidth} />
213
- </Animated.View>
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 keys its ink layer on it, so a clear unmounts
40
- * the old layer and lets it fade out rather than snapping the paths away.
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
- * The signature as a standalone SVG document.
364
+ * An export's options resolved against a value.
305
365
  *
306
- * One `<path>` per stroke, unfilled, with round caps and joins — the same
307
- * geometry the pad draws, so the file opens looking like what was signed.
308
- * `viewBox: "pad"` keeps the whole pad; `"tight"` crops to the ink plus
309
- * `padding`, and falls back to the pad when there is no ink to crop to.
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 toSVG(value: SignatureValue, options: SignatureExportOptions = {}): string {
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 file and
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());