panelui-native 0.102.3 → 0.103.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/lib/module/components/bottom-sheet/index.js +95 -41
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/panelside/index.js +8 -28
  4. package/lib/module/components/panelside/index.js.map +1 -1
  5. package/lib/module/components/selection-mode/index.js.map +1 -1
  6. package/lib/module/components/sketch/index.js +1124 -0
  7. package/lib/module/components/sketch/index.js.map +1 -0
  8. package/lib/module/index.js +1 -0
  9. package/lib/module/index.js.map +1 -1
  10. package/lib/module/native/index.js.map +1 -1
  11. package/lib/typescript/src/components/bottom-sheet/index.d.ts +11 -2
  12. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  13. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  14. package/lib/typescript/src/components/selection-mode/index.d.ts +2 -4
  15. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
  16. package/lib/typescript/src/components/sketch/index.d.ts +100 -0
  17. package/lib/typescript/src/components/sketch/index.d.ts.map +1 -0
  18. package/lib/typescript/src/index.d.ts +1 -0
  19. package/lib/typescript/src/index.d.ts.map +1 -1
  20. package/lib/typescript/src/native/index.d.ts +8 -0
  21. package/lib/typescript/src/native/index.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/components/bottom-sheet/index.tsx +72 -10
  24. package/src/components/panelside/index.tsx +8 -38
  25. package/src/components/selection-mode/index.tsx +2 -4
  26. package/src/components/sketch/index.tsx +1326 -0
  27. package/src/index.ts +11 -0
  28. package/src/native/index.ts +8 -0
@@ -0,0 +1,1326 @@
1
+ /**
2
+ * Sketch — a drawing surface with its tools around it: a pen, a set of
3
+ * shapes, an eraser, a stroke width, colours and undo.
4
+ *
5
+ * ```tsx
6
+ * <Sketch.Sheet open={open} onOpenChange={setOpen} onDone={({ svg }) => attach(svg)} />
7
+ *
8
+ * // or a whole screen of it
9
+ * <Sketch onClose={router.back} onDone={save} />
10
+ * ```
11
+ *
12
+ * ## Layout
13
+ *
14
+ * The chrome sits at the two ends of the surface — close, the tools and undo
15
+ * across the top; colours and confirm across the bottom — so the middle is all
16
+ * canvas and the controls are where thumbs already are. The stroke width is a
17
+ * short vertical slider against the leading edge, over the canvas rather than
18
+ * beside it, because a column of its own would take width from every drawing
19
+ * to hold one control.
20
+ *
21
+ * The shape grid and the colour picker open as cards over the canvas rather
22
+ * than as popovers. A popover is drawn through a portal at the root of the
23
+ * app, and inside the platform's own sheet the root is *behind* the sheet —
24
+ * the picker would open where nobody can see it.
25
+ *
26
+ * ## Why the live stroke never reaches React
27
+ *
28
+ * The stroke being drawn lives in shared values and becomes an SVG path in a
29
+ * worklet on the UI thread, so the line keeps up with the finger however much
30
+ * is already on the canvas. When the finger lifts, the finished path crosses
31
+ * to JavaScript once and is appended to the document as a static element.
32
+ *
33
+ * ## How the eraser rubs out
34
+ *
35
+ * The eraser paints into a mask rather than deleting what it touches. Each run
36
+ * of eraser strokes masks everything drawn before it, so ink drawn afterwards
37
+ * over an erased patch is untouched — the same as a pencil on paper. Undo
38
+ * takes an eraser stroke back like any other. The document stays vector, and
39
+ * `toSVG()` writes the same masks out, so an export matches the screen.
40
+ */
41
+ import {
42
+ forwardRef,
43
+ useCallback,
44
+ useEffect,
45
+ useId,
46
+ useImperativeHandle,
47
+ useMemo,
48
+ useRef,
49
+ useState,
50
+ type ReactNode,
51
+ type Ref,
52
+ } from 'react';
53
+ import {
54
+ ScrollView,
55
+ StyleSheet,
56
+ useWindowDimensions,
57
+ View,
58
+ type LayoutChangeEvent,
59
+ type ViewProps,
60
+ } from 'react-native';
61
+ import { useCSSVariable } from 'uniwind';
62
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
63
+ import Animated, {
64
+ runOnJS,
65
+ runOnUI,
66
+ useAnimatedProps,
67
+ useAnimatedStyle,
68
+ useSharedValue,
69
+ withTiming,
70
+ } from 'react-native-reanimated';
71
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
72
+ import Svg, { Circle, Defs, G, Mask, Path, Rect, Stop, LinearGradient } from 'react-native-svg';
73
+ import { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react-native';
74
+ import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
75
+ import EraserIcon from '@hugeicons/core-free-icons/EraserIcon';
76
+ import LineSquiggleIcon from '@hugeicons/core-free-icons/LineSquiggleIcon';
77
+ import Shapes01Icon from '@hugeicons/core-free-icons/Shapes01Icon';
78
+ import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
79
+ import Undo03Icon from '@hugeicons/core-free-icons/Undo03Icon';
80
+ import { BottomSheet, bottomSheetDetentHeight } from '../bottom-sheet';
81
+ import { ColorPicker } from '../color-picker';
82
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
83
+ import { cn } from '../../utils/cn';
84
+ import { selectionTick } from '../../utils/haptics';
85
+
86
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
87
+
88
+ /** `react-native-view-shot`'s capture function, or null. PNG export only. */
89
+ const captureRef: ((view: unknown, options: unknown) => Promise<string>) | null =
90
+ (() => {
91
+ try {
92
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
93
+ const mod = require('react-native-view-shot');
94
+ return (mod?.captureRef as (v: unknown, o: unknown) => Promise<string>) ?? null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ })();
99
+
100
+ /** True when a sketch can be rasterised to PNG. */
101
+ export const hasSketchRaster = captureRef !== null;
102
+
103
+ export type SketchTool = 'pen' | 'shape' | 'eraser';
104
+
105
+ export type SketchShape =
106
+ | 'line'
107
+ | 'arrow'
108
+ | 'rectangle'
109
+ | 'ellipse'
110
+ | 'triangle'
111
+ | 'diamond'
112
+ | 'star'
113
+ | 'heart';
114
+
115
+ const ALL_SHAPES: SketchShape[] = [
116
+ 'line',
117
+ 'arrow',
118
+ 'rectangle',
119
+ 'ellipse',
120
+ 'triangle',
121
+ 'diamond',
122
+ 'star',
123
+ 'heart',
124
+ ];
125
+
126
+ /**
127
+ * The ink on offer. A spread of hues rather than a palette of tints: in a
128
+ * sketch a colour is there to be told apart from the others at a glance.
129
+ */
130
+ const DEFAULT_COLORS = [
131
+ '#ffffff',
132
+ '#000000',
133
+ '#8e8e93',
134
+ '#ef4444',
135
+ '#f97316',
136
+ '#facc15',
137
+ '#22c55e',
138
+ '#3b82f6',
139
+ '#a855f7',
140
+ '#ec4899',
141
+ ];
142
+
143
+ const SHAPE_LABEL: Record<SketchShape, string> = {
144
+ line: 'Line',
145
+ arrow: 'Arrow',
146
+ rectangle: 'Rectangle',
147
+ ellipse: 'Ellipse',
148
+ triangle: 'Triangle',
149
+ diamond: 'Diamond',
150
+ star: 'Star',
151
+ heart: 'Heart',
152
+ };
153
+
154
+ const TOOL_LABEL: Record<SketchTool, string> = {
155
+ pen: 'Pen',
156
+ shape: 'Shapes',
157
+ eraser: 'Eraser',
158
+ };
159
+
160
+ /** How tall the stroke-width slider's track is, in points. */
161
+ const SLIDER_TRACK = 140;
162
+ /** The slider thumb's diameter. */
163
+ const SLIDER_THUMB = 28;
164
+ /** Width of the slider's touch area, wider than the track so it is findable. */
165
+ const SLIDER_HIT = 44;
166
+
167
+ /** Points closer together than this are dropped from a stroke as they arrive. */
168
+ const MIN_DISTANCE = 1.5;
169
+
170
+ /** The eraser is wider than the pen at the same setting, as a real one is. */
171
+ const ERASER_SCALE = 2.5;
172
+
173
+ const GLYPH_STROKE = 1.8;
174
+
175
+ /** One document entry. Shapes are stored as the path they were drawn as. */
176
+ export type SketchItem =
177
+ | { type: 'stroke'; d: string; color: string; width: number }
178
+ | { type: 'shape'; shape: SketchShape; d: string; color: string; width: number }
179
+ | { type: 'erase'; d: string; width: number };
180
+
181
+ export interface SketchResult {
182
+ /** The drawing as a standalone SVG document, sized to the canvas. */
183
+ svg: string;
184
+ /** The canvas size in points, which is the SVG's coordinate space. */
185
+ width: number;
186
+ height: number;
187
+ /** Everything drawn, in order, including eraser strokes. */
188
+ items: SketchItem[];
189
+ }
190
+
191
+ export interface SketchHandle {
192
+ /** Take back the last stroke, shape or eraser stroke. */
193
+ undo(): void;
194
+ /** Remove everything. */
195
+ clear(): void;
196
+ /** True until something has been drawn. */
197
+ isEmpty(): boolean;
198
+ /** The drawing as a standalone SVG document. Pure string building. */
199
+ toSVG(): string;
200
+ /**
201
+ * Rasterise the canvas to a PNG and resolve its temporary file URI. Needs
202
+ * the optional `react-native-view-shot`, and throws by name without it.
203
+ */
204
+ toPNG(options?: { scale?: number }): Promise<string>;
205
+ }
206
+
207
+ export interface SketchProps extends Omit<ViewProps, 'children'> {
208
+ className?: string;
209
+ /** The swatches along the bottom. The custom-colour button sits before them. */
210
+ colors?: string[];
211
+ /** The ink to start with. Defaults to the theme's foreground. */
212
+ defaultColor?: string;
213
+ /** The tool selected when the surface opens. Default `pen`. */
214
+ defaultTool?: SketchTool;
215
+ /** The shape the shape tool draws until another is picked. Default `rectangle`. */
216
+ defaultShape?: SketchShape;
217
+ /** Which shapes the grid offers, in order. Defaults to all eight. */
218
+ shapes?: SketchShape[];
219
+ /** Stroke width to start with, in points. Default 6. */
220
+ defaultSize?: number;
221
+ /** The thinnest stroke the slider reaches. Default 2. */
222
+ minSize?: number;
223
+ /** The thickest stroke the slider reaches. Default 24. */
224
+ maxSize?: number;
225
+ /** Show the custom-colour button before the swatches. Default true. */
226
+ customColor?: boolean;
227
+ /**
228
+ * Called by the close button in the top leading corner. Leave it out and
229
+ * there is no close button — for a sketch embedded in a screen that has its
230
+ * own way out.
231
+ */
232
+ onClose?: () => void;
233
+ /**
234
+ * Called by the confirm button with the finished drawing. Leave it out and
235
+ * there is no confirm button. The button is disabled until something has
236
+ * been drawn.
237
+ */
238
+ onDone?: (result: SketchResult) => void;
239
+ /** The number of items in the document changed — by drawing, undo or clear. */
240
+ onChange?: (itemCount: number) => void;
241
+ }
242
+
243
+ /**
244
+ * Turns a flat `[x, y, x, y, …]` buffer into a path, curving through the
245
+ * midpoint between each pair of points so the line is smooth without any
246
+ * lookahead. A worklet, because the live stroke is built on the UI thread and
247
+ * the committed one has to be the same string.
248
+ */
249
+ function strokePath(points: number[]): string {
250
+ 'worklet';
251
+ const count = points.length / 2;
252
+ if (count === 0) return '';
253
+ // A tap is a dot rather than nothing.
254
+ if (count === 1) return `M${points[0]},${points[1]} l0.01,0`;
255
+
256
+ let d = `M${points[0]},${points[1]}`;
257
+ for (let i = 1; i < count; i += 1) {
258
+ const px = points[(i - 1) * 2]!;
259
+ const py = points[(i - 1) * 2 + 1]!;
260
+ const x = points[i * 2]!;
261
+ const y = points[i * 2 + 1]!;
262
+ d += ` Q${px},${py} ${(px + x) / 2},${(py + y) / 2}`;
263
+ }
264
+ d += ` L${points[(count - 1) * 2]},${points[(count - 1) * 2 + 1]}`;
265
+ return d;
266
+ }
267
+
268
+ /** Numbers in a path, rounded to a tenth — enough for a screen, and shorter. */
269
+ function n(value: number): string {
270
+ 'worklet';
271
+ return `${Math.round(value * 10) / 10}`;
272
+ }
273
+
274
+ /**
275
+ * A shape as a path, from where the drag started to where it is now.
276
+ *
277
+ * Everything but the line and the arrow fills the box the two points make,
278
+ * whichever way the drag went. The line and arrow run from the first point to
279
+ * the second, because their direction is the thing being drawn.
280
+ *
281
+ * `shape` is an index into {@link ALL_SHAPES} rather than the name, so the
282
+ * worklet compares numbers.
283
+ */
284
+ function shapePath(
285
+ shape: number,
286
+ x1: number,
287
+ y1: number,
288
+ x2: number,
289
+ y2: number,
290
+ width: number
291
+ ): string {
292
+ 'worklet';
293
+ const left = Math.min(x1, x2);
294
+ const top = Math.min(y1, y2);
295
+ const w = Math.abs(x2 - x1);
296
+ const h = Math.abs(y2 - y1);
297
+ const cx = left + w / 2;
298
+ const cy = top + h / 2;
299
+
300
+ switch (shape) {
301
+ case 0: // line
302
+ return `M${n(x1)},${n(y1)} L${n(x2)},${n(y2)}`;
303
+ case 1: {
304
+ // arrow — the head scales with the stroke so a thick arrow still reads
305
+ const angle = Math.atan2(y2 - y1, x2 - x1);
306
+ const length = Math.hypot(x2 - x1, y2 - y1);
307
+ const head = Math.min(Math.max(14, width * 3), length * 0.5);
308
+ const spread = Math.PI / 6;
309
+ const ax = x2 - head * Math.cos(angle - spread);
310
+ const ay = y2 - head * Math.sin(angle - spread);
311
+ const bx = x2 - head * Math.cos(angle + spread);
312
+ const by = y2 - head * Math.sin(angle + spread);
313
+ return (
314
+ `M${n(x1)},${n(y1)} L${n(x2)},${n(y2)} ` +
315
+ `M${n(ax)},${n(ay)} L${n(x2)},${n(y2)} L${n(bx)},${n(by)}`
316
+ );
317
+ }
318
+ case 2: // rectangle
319
+ return (
320
+ `M${n(left)},${n(top)} H${n(left + w)} V${n(top + h)} ` +
321
+ `H${n(left)} Z`
322
+ );
323
+ case 3: {
324
+ // ellipse, as two arcs
325
+ const rx = Math.max(w / 2, 0.5);
326
+ const ry = Math.max(h / 2, 0.5);
327
+ return (
328
+ `M${n(cx - rx)},${n(cy)} A${n(rx)},${n(ry)} 0 1 0 ${n(cx + rx)},${n(cy)} ` +
329
+ `A${n(rx)},${n(ry)} 0 1 0 ${n(cx - rx)},${n(cy)} Z`
330
+ );
331
+ }
332
+ case 4: // triangle
333
+ return (
334
+ `M${n(cx)},${n(top)} L${n(left + w)},${n(top + h)} ` +
335
+ `L${n(left)},${n(top + h)} Z`
336
+ );
337
+ case 5: // diamond
338
+ return (
339
+ `M${n(cx)},${n(top)} L${n(left + w)},${n(cy)} ` +
340
+ `L${n(cx)},${n(top + h)} L${n(left)},${n(cy)} Z`
341
+ );
342
+ case 6: {
343
+ // star — five points, the inner radius a little under half the outer
344
+ let d = '';
345
+ for (let i = 0; i < 10; i += 1) {
346
+ const r = i % 2 === 0 ? 1 : 0.42;
347
+ const a = -Math.PI / 2 + (i * Math.PI) / 5;
348
+ const px = cx + (w / 2) * r * Math.cos(a);
349
+ const py = cy + (h / 2) * r * Math.sin(a) + h * 0.05;
350
+ d += `${i === 0 ? 'M' : 'L'}${n(px)},${n(py)} `;
351
+ }
352
+ return `${d}Z`;
353
+ }
354
+ default: {
355
+ // heart, drawn in a unit box and scaled into the drag's
356
+ const X = (u: number) => n(left + u * w);
357
+ const Y = (v: number) => n(top + v * h);
358
+ return (
359
+ `M${X(0.5)},${Y(0.92)} ` +
360
+ `C${X(0.18)},${Y(0.7)} ${X(0)},${Y(0.48)} ${X(0)},${Y(0.3)} ` +
361
+ `C${X(0)},${Y(0.12)} ${X(0.14)},${Y(0)} ${X(0.29)},${Y(0)} ` +
362
+ `C${X(0.4)},${Y(0)} ${X(0.47)},${Y(0.07)} ${X(0.5)},${Y(0.16)} ` +
363
+ `C${X(0.53)},${Y(0.07)} ${X(0.6)},${Y(0)} ${X(0.71)},${Y(0)} ` +
364
+ `C${X(0.86)},${Y(0)} ${X(1)},${Y(0.12)} ${X(1)},${Y(0.3)} ` +
365
+ `C${X(1)},${Y(0.48)} ${X(0.82)},${Y(0.7)} ${X(0.5)},${Y(0.92)} Z`
366
+ );
367
+ }
368
+ }
369
+ }
370
+
371
+ /** A mask id React and the SVG string can both use — `useId`'s colons are not. */
372
+ function safeId(id: string): string {
373
+ return id.replace(/[^a-zA-Z0-9_-]/g, '');
374
+ }
375
+
376
+ /**
377
+ * Splits the document into layers at each run of eraser strokes. Each layer
378
+ * is what was drawn before an erase, and the erase that masks it.
379
+ */
380
+ function foldLayers<T>(
381
+ items: SketchItem[],
382
+ ink: (item: Exclude<SketchItem, { type: 'erase' }>, index: number) => T,
383
+ wrap: (content: T[], erases: Extract<SketchItem, { type: 'erase' }>[], layer: number) => T
384
+ ): T[] {
385
+ let content: T[] = [];
386
+ let erases: Extract<SketchItem, { type: 'erase' }>[] = [];
387
+ let layer = 0;
388
+
389
+ const flush = () => {
390
+ if (erases.length === 0) return;
391
+ content = [wrap(content, erases, layer)];
392
+ erases = [];
393
+ layer += 1;
394
+ };
395
+
396
+ items.forEach((item, index) => {
397
+ if (item.type === 'erase') {
398
+ erases.push(item);
399
+ return;
400
+ }
401
+ flush();
402
+ content.push(ink(item, index));
403
+ });
404
+ flush();
405
+ return content;
406
+ }
407
+
408
+ function svgDocument(items: SketchItem[], width: number, height: number, id: string): string {
409
+ const w = Math.round(width) || 1;
410
+ const h = Math.round(height) || 1;
411
+ const defs: string[] = [];
412
+ const body = foldLayers<string>(
413
+ items,
414
+ (item) =>
415
+ `<path d="${item.d}" fill="none" stroke="${item.color}" stroke-width="${item.width}" ` +
416
+ 'stroke-linecap="round" stroke-linejoin="round"/>',
417
+ (content, erases, layer) => {
418
+ const mask = `${id}-m${layer}`;
419
+ defs.push(
420
+ `<mask id="${mask}" maskUnits="userSpaceOnUse" x="0" y="0" width="${w}" height="${h}">` +
421
+ `<rect x="0" y="0" width="${w}" height="${h}" fill="white"/>` +
422
+ erases
423
+ .map(
424
+ (erase) =>
425
+ `<path d="${erase.d}" fill="none" stroke="black" stroke-width="${erase.width}" ` +
426
+ 'stroke-linecap="round" stroke-linejoin="round"/>'
427
+ )
428
+ .join('') +
429
+ '</mask>'
430
+ );
431
+ return `<g mask="url(#${mask})">${content.join('')}</g>`;
432
+ }
433
+ );
434
+ return (
435
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
436
+ (defs.length ? `<defs>${defs.join('')}</defs>` : '') +
437
+ `${body.join('')}</svg>`
438
+ );
439
+ }
440
+
441
+ function Glyph({
442
+ icon,
443
+ color,
444
+ size = 22,
445
+ }: {
446
+ icon: IconSvgElement;
447
+ color?: string;
448
+ size?: number;
449
+ }) {
450
+ return (
451
+ <HugeiconsIcon icon={icon} size={size} color={color} strokeWidth={GLYPH_STROKE} />
452
+ );
453
+ }
454
+
455
+ /** A shape drawn by the same function that draws it on the canvas. */
456
+ function ShapeGlyph({
457
+ shape,
458
+ color,
459
+ size = 24,
460
+ }: {
461
+ shape: SketchShape;
462
+ color?: string;
463
+ size?: number;
464
+ }) {
465
+ const inset = 3;
466
+ const index = ALL_SHAPES.indexOf(shape);
467
+ // The line and arrow run corner to corner, bottom-left to top-right.
468
+ const diagonal = shape === 'line' || shape === 'arrow';
469
+ const d = diagonal
470
+ ? shapePath(index, inset, size - inset, size - inset, inset, 1.2)
471
+ : shapePath(index, inset, inset + 1, size - inset, size - inset - 1, 1.2);
472
+ return (
473
+ <Svg width={size} height={size}>
474
+ <Path
475
+ d={d}
476
+ fill="none"
477
+ stroke={color}
478
+ strokeWidth={GLYPH_STROKE}
479
+ strokeLinecap="round"
480
+ strokeLinejoin="round"
481
+ />
482
+ </Svg>
483
+ );
484
+ }
485
+
486
+ /** The rainbow ring that stands for "any colour", with the current one inside. */
487
+ function CustomColorRing({ color, size = 32 }: { color: string; size?: number }) {
488
+ const segments = 24;
489
+ const r = size / 2 - 2;
490
+ const c = size / 2;
491
+ const arcs = Array.from({ length: segments }, (_, i) => {
492
+ const a0 = (i / segments) * Math.PI * 2 - Math.PI / 2;
493
+ const a1 = ((i + 1.08) / segments) * Math.PI * 2 - Math.PI / 2;
494
+ const d =
495
+ `M${c + r * Math.cos(a0)},${c + r * Math.sin(a0)} ` +
496
+ `A${r},${r} 0 0 1 ${c + r * Math.cos(a1)},${c + r * Math.sin(a1)}`;
497
+ return <Path key={i} d={d} stroke={`hsl(${(i * 360) / segments}, 90%, 58%)`} strokeWidth={3.5} fill="none" />;
498
+ });
499
+ return (
500
+ <Svg width={size} height={size}>
501
+ {arcs}
502
+ <Circle cx={c} cy={c} r={r - 4} fill={color} />
503
+ </Svg>
504
+ );
505
+ }
506
+
507
+ /** A round control in the surface's chrome. */
508
+ function RoundButton({
509
+ label,
510
+ onPress,
511
+ disabled,
512
+ selected,
513
+ className,
514
+ children,
515
+ }: {
516
+ label: string;
517
+ onPress?: () => void;
518
+ disabled?: boolean;
519
+ selected?: boolean;
520
+ className?: string;
521
+ children: ReactNode;
522
+ }) {
523
+ return (
524
+ <AnimatedPressable
525
+ accessibilityRole="button"
526
+ accessibilityLabel={label}
527
+ accessibilityState={{ disabled: Boolean(disabled), selected }}
528
+ disabled={disabled}
529
+ onPress={onPress}
530
+ hitSlop={4}
531
+ className={cn(
532
+ 'h-12 w-12 items-center justify-center rounded-full bg-muted',
533
+ disabled && 'opacity-[0.4]',
534
+ className
535
+ )}
536
+ >
537
+ {children}
538
+ </AnimatedPressable>
539
+ );
540
+ }
541
+
542
+ function SketchRoot(
543
+ {
544
+ className,
545
+ colors = DEFAULT_COLORS,
546
+ defaultColor,
547
+ defaultTool = 'pen',
548
+ defaultShape = 'rectangle',
549
+ shapes = ALL_SHAPES,
550
+ defaultSize = 6,
551
+ minSize = 2,
552
+ maxSize = 24,
553
+ customColor = true,
554
+ onClose,
555
+ onDone,
556
+ onChange,
557
+ ...props
558
+ }: SketchProps,
559
+ ref: Ref<SketchHandle>
560
+ ) {
561
+ const foregroundToken = useCSSVariable('--color-foreground');
562
+ const foreground = typeof foregroundToken === 'string' ? foregroundToken : '#f5f5f5';
563
+ const surfaceToken = useCSSVariable('--color-popover');
564
+ const surface = typeof surfaceToken === 'string' ? surfaceToken : '#1d1d1d';
565
+ const primaryFgToken = useCSSVariable('--color-primary-foreground');
566
+ const primaryFg = typeof primaryFgToken === 'string' ? primaryFgToken : undefined;
567
+
568
+ const maskId = safeId(useId());
569
+
570
+ const [items, setItems] = useState<SketchItem[]>([]);
571
+ const [tool, setTool] = useState<SketchTool>(defaultTool);
572
+ const [shape, setShape] = useState<SketchShape>(defaultShape);
573
+ // Whether a shape has been picked yet. Until one has, the tool shows the
574
+ // generic shapes glyph rather than claiming a rectangle nobody chose.
575
+ const [shapePicked, setShapePicked] = useState(defaultTool === 'shape');
576
+ const [color, setColor] = useState<string | undefined>(defaultColor);
577
+ const [custom, setCustom] = useState<string | null>(null);
578
+ const [size, setSize] = useState(defaultSize);
579
+ const [card, setCard] = useState<'shapes' | 'color' | null>(null);
580
+ const [layout, setLayout] = useState({ width: 0, height: 0 });
581
+
582
+ const ink = color ?? foreground;
583
+ const canvasRef = useRef<View>(null);
584
+
585
+ // Mirrors of the state the gesture reads, so it is built once and the live
586
+ // stroke is drawn in the tool and width chosen at the moment it started.
587
+ const toolValue = useSharedValue<number>(0);
588
+ const shapeValue = useSharedValue(ALL_SHAPES.indexOf(defaultShape));
589
+ const sizeValue = useSharedValue(defaultSize);
590
+ useEffect(() => {
591
+ toolValue.value = tool === 'pen' ? 0 : tool === 'shape' ? 1 : 2;
592
+ }, [tool, toolValue]);
593
+ useEffect(() => {
594
+ shapeValue.value = ALL_SHAPES.indexOf(shape);
595
+ }, [shape, shapeValue]);
596
+ useEffect(() => {
597
+ sizeValue.value = size;
598
+ }, [size, sizeValue]);
599
+
600
+ // The live gesture: points for the pen and eraser, two corners for a shape.
601
+ const live = useSharedValue<number[]>([]);
602
+ const liveTool = useSharedValue(-1);
603
+ const anchor = useSharedValue({ x: 0, y: 0 });
604
+ const tip = useSharedValue({ x: 0, y: 0 });
605
+ /*
606
+ * Which gesture the live values belong to. A finished stroke stays drawn
607
+ * live until its committed copy has rendered: cleared the moment the finger
608
+ * lifted, it vanished for the frames React took to put the copy on screen,
609
+ * and the drawing flickered on every stroke. The number stops a slow commit
610
+ * from clearing a stroke that has started since.
611
+ */
612
+ const strokeSeq = useSharedValue(0);
613
+ const [settled, setSettled] = useState(0);
614
+
615
+ const changeRef = useRef(onChange);
616
+ changeRef.current = onChange;
617
+ const reported = useRef(0);
618
+ useEffect(() => {
619
+ if (reported.current === items.length) return;
620
+ reported.current = items.length;
621
+ changeRef.current?.(items.length);
622
+ }, [items.length]);
623
+
624
+ // Latest values for the commit, which runs from the gesture on the JS thread.
625
+ const commitState = useRef({ ink, size, shape });
626
+ commitState.current = { ink, size, shape };
627
+
628
+ const commitStroke = useCallback((d: string, erase: boolean, seq: number) => {
629
+ const { ink: currentInk, size: currentSize } = commitState.current;
630
+ setSettled(seq);
631
+ setItems((current) => [
632
+ ...current,
633
+ erase
634
+ ? { type: 'erase', d, width: currentSize * ERASER_SCALE }
635
+ : { type: 'stroke', d, color: currentInk, width: currentSize },
636
+ ]);
637
+ }, []);
638
+
639
+ const commitShape = useCallback((d: string, seq: number) => {
640
+ const { ink: currentInk, size: currentSize, shape: currentShape } = commitState.current;
641
+ setSettled(seq);
642
+ setItems((current) => [
643
+ ...current,
644
+ { type: 'shape', shape: currentShape, d, color: currentInk, width: currentSize },
645
+ ]);
646
+ }, []);
647
+
648
+ const pan = useMemo(
649
+ () =>
650
+ Gesture.Pan()
651
+ // A dot is a stroke too, so the gesture starts on the first touch.
652
+ .minDistance(0)
653
+ .averageTouches(true)
654
+ .onBegin((event) => {
655
+ 'worklet';
656
+ strokeSeq.value += 1;
657
+ liveTool.value = toolValue.value;
658
+ live.value = [event.x, event.y];
659
+ anchor.value = { x: event.x, y: event.y };
660
+ tip.value = { x: event.x, y: event.y };
661
+ })
662
+ .onUpdate((event) => {
663
+ 'worklet';
664
+ if (liveTool.value === 1) {
665
+ tip.value = { x: event.x, y: event.y };
666
+ return;
667
+ }
668
+ const points = live.value;
669
+ const last = points.length;
670
+ if (last >= 2) {
671
+ const dx = event.x - points[last - 2]!;
672
+ const dy = event.y - points[last - 1]!;
673
+ if (dx * dx + dy * dy < MIN_DISTANCE * MIN_DISTANCE) return;
674
+ }
675
+ live.value = [...points, event.x, event.y];
676
+ })
677
+ .onFinalize(() => {
678
+ 'worklet';
679
+ // The live values are left in place: the stroke stays drawn until
680
+ // its committed copy is on screen, and is cleared from there.
681
+ const mode = liveTool.value;
682
+ const seq = strokeSeq.value;
683
+ if (mode === 1) {
684
+ const a = anchor.value;
685
+ const b = tip.value;
686
+ // A tap with the shape tool draws nothing: a shape of no size is
687
+ // not a shape, and a dot would be the pen's job.
688
+ if (Math.abs(b.x - a.x) < 4 && Math.abs(b.y - a.y) < 4) {
689
+ liveTool.value = -1;
690
+ return;
691
+ }
692
+ runOnJS(commitShape)(
693
+ shapePath(shapeValue.value, a.x, a.y, b.x, b.y, sizeValue.value),
694
+ seq
695
+ );
696
+ return;
697
+ }
698
+ const points = live.value;
699
+ if (points.length === 0) {
700
+ liveTool.value = -1;
701
+ return;
702
+ }
703
+ runOnJS(commitStroke)(strokePath(points), mode === 2, seq);
704
+ }),
705
+ [anchor, commitShape, commitStroke, live, liveTool, shapeValue, sizeValue, strokeSeq, tip, toolValue]
706
+ );
707
+
708
+ // The committed copy of stroke `settled` has rendered, so its live copy
709
+ // can go — unless another stroke has begun since, which owns the values now.
710
+ useEffect(() => {
711
+ if (settled === 0) return;
712
+ runOnUI((seq: number) => {
713
+ 'worklet';
714
+ if (strokeSeq.value !== seq) return;
715
+ liveTool.value = -1;
716
+ live.value = [];
717
+ })(settled);
718
+ }, [items, live, liveTool, settled, strokeSeq]);
719
+
720
+ // The ink being drawn: a pen stroke or a shape. Empty while erasing.
721
+ const liveInkProps = useAnimatedProps(() => {
722
+ if (liveTool.value === 1) {
723
+ return {
724
+ d: shapePath(
725
+ shapeValue.value,
726
+ anchor.value.x,
727
+ anchor.value.y,
728
+ tip.value.x,
729
+ tip.value.y,
730
+ sizeValue.value
731
+ ),
732
+ };
733
+ }
734
+ return { d: liveTool.value === 0 ? strokePath(live.value) : '' };
735
+ });
736
+
737
+ /*
738
+ * The eraser being drawn, painted in the surface's own colour over the top.
739
+ * It is indistinguishable from the mask it becomes on release, and it keeps
740
+ * the per-frame work to one path rather than rebuilding a mask every frame.
741
+ */
742
+ const liveEraseProps = useAnimatedProps(() => ({
743
+ d: liveTool.value === 2 ? strokePath(live.value) : '',
744
+ }));
745
+
746
+ const undo = useCallback(() => {
747
+ setItems((current) => current.slice(0, -1));
748
+ }, []);
749
+ const clear = useCallback(() => setItems([]), []);
750
+
751
+ const toSVG = useCallback(
752
+ () => svgDocument(items, layout.width, layout.height, maskId),
753
+ [items, layout.height, layout.width, maskId]
754
+ );
755
+
756
+ const toPNG = useCallback(
757
+ async ({ scale = 2 }: { scale?: number } = {}) => {
758
+ if (!captureRef) {
759
+ throw new Error(
760
+ 'Sketch: PNG export needs the optional `react-native-view-shot` package. ' +
761
+ 'Install it, or export SVG with toSVG() instead.'
762
+ );
763
+ }
764
+ return captureRef(canvasRef.current, {
765
+ format: 'png',
766
+ quality: 1,
767
+ result: 'tmpfile',
768
+ width: Math.round(layout.width * scale),
769
+ height: Math.round(layout.height * scale),
770
+ });
771
+ },
772
+ [layout.height, layout.width]
773
+ );
774
+
775
+ useImperativeHandle(
776
+ ref,
777
+ (): SketchHandle => ({
778
+ undo,
779
+ clear,
780
+ isEmpty: () => items.length === 0,
781
+ toSVG,
782
+ toPNG,
783
+ }),
784
+ [clear, items.length, toPNG, toSVG, undo]
785
+ );
786
+
787
+ const pickTool = (next: SketchTool) => {
788
+ selectionTick();
789
+ if (next === 'shape') {
790
+ // The shape tool always opens its grid: the glyph shows one shape, and
791
+ // the grid is the only place to see the others.
792
+ setCard((current) => (current === 'shapes' ? null : 'shapes'));
793
+ if (shapePicked) setTool('shape');
794
+ return;
795
+ }
796
+ setCard(null);
797
+ setTool(next);
798
+ };
799
+
800
+ const pickShape = (next: SketchShape) => {
801
+ selectionTick();
802
+ setShape(next);
803
+ setShapePicked(true);
804
+ setTool('shape');
805
+ setCard(null);
806
+ };
807
+
808
+ const pickColor = (next: string) => {
809
+ selectionTick();
810
+ setColor(next);
811
+ setCustom(null);
812
+ // Picking ink while the eraser is up means you want to draw.
813
+ if (tool === 'eraser') setTool('pen');
814
+ setCard(null);
815
+ };
816
+
817
+ const done = () => {
818
+ if (!onDone || items.length === 0) return;
819
+ onDone({
820
+ svg: toSVG(),
821
+ width: Math.round(layout.width),
822
+ height: Math.round(layout.height),
823
+ items,
824
+ });
825
+ };
826
+
827
+ const onCanvasLayout = useCallback((event: LayoutChangeEvent) => {
828
+ const { width, height } = event.nativeEvent.layout;
829
+ setLayout({ width, height });
830
+ }, []);
831
+
832
+ const empty = items.length === 0;
833
+ const muted = useCSSVariable('--color-muted-foreground');
834
+ const mutedColor = typeof muted === 'string' ? muted : undefined;
835
+
836
+ const layers = foldLayers<ReactNode>(
837
+ items,
838
+ (item, index) => (
839
+ <Path
840
+ // Items are only appended or dropped from the end, so the index is
841
+ // stable for the life of each one.
842
+ key={`i${index}`}
843
+ d={item.d}
844
+ fill="none"
845
+ stroke={item.color}
846
+ strokeWidth={item.width}
847
+ strokeLinecap="round"
848
+ strokeLinejoin="round"
849
+ />
850
+ ),
851
+ (content, erases, layer) => (
852
+ <G key={`l${layer}`} mask={`url(#${maskId}-m${layer})`}>
853
+ {content}
854
+ </G>
855
+ )
856
+ );
857
+
858
+ const masks: ReactNode[] = [];
859
+ foldLayers<null>(
860
+ items,
861
+ () => null,
862
+ (_content, erases, layer) => {
863
+ masks.push(
864
+ <Mask
865
+ key={layer}
866
+ id={`${maskId}-m${layer}`}
867
+ maskUnits="userSpaceOnUse"
868
+ x={0}
869
+ y={0}
870
+ width={layout.width}
871
+ height={layout.height}
872
+ >
873
+ <Rect x={0} y={0} width={layout.width} height={layout.height} fill="white" />
874
+ {erases.map((erase, index) => (
875
+ <Path
876
+ key={index}
877
+ d={erase.d}
878
+ fill="none"
879
+ stroke="black"
880
+ strokeWidth={erase.width}
881
+ strokeLinecap="round"
882
+ strokeLinejoin="round"
883
+ />
884
+ ))}
885
+ </Mask>
886
+ );
887
+ return null;
888
+ }
889
+ );
890
+
891
+ const visibleShapes = shapes.filter((entry) => ALL_SHAPES.includes(entry));
892
+
893
+ return (
894
+ <View className={cn('flex-1 bg-popover', className)} {...props}>
895
+ {/* Close, the tools, undo. */}
896
+ <View className="flex-row items-center justify-between px-4 pb-2 pt-4">
897
+ {onClose ? (
898
+ <RoundButton label="Close" onPress={onClose}>
899
+ <Glyph icon={Cancel01Icon} color={foreground} />
900
+ </RoundButton>
901
+ ) : (
902
+ <View className="h-12 w-12" />
903
+ )}
904
+
905
+ <View
906
+ accessibilityRole="tablist"
907
+ className="flex-row items-center gap-1 rounded-full bg-muted p-1"
908
+ >
909
+ {(['pen', 'shape', 'eraser'] as const).map((entry) => {
910
+ const selected = tool === entry;
911
+ return (
912
+ <AnimatedPressable
913
+ key={entry}
914
+ accessibilityRole="tab"
915
+ accessibilityLabel={
916
+ entry === 'shape' && shapePicked
917
+ ? `${TOOL_LABEL.shape}, ${SHAPE_LABEL[shape]}`
918
+ : TOOL_LABEL[entry]
919
+ }
920
+ accessibilityState={{ selected }}
921
+ onPress={() => pickTool(entry)}
922
+ className={cn(
923
+ 'h-11 w-12 items-center justify-center rounded-full',
924
+ selected && 'bg-muted'
925
+ )}
926
+ >
927
+ {entry === 'pen' ? (
928
+ <Glyph icon={LineSquiggleIcon} color={foreground} />
929
+ ) : entry === 'eraser' ? (
930
+ <Glyph icon={EraserIcon} color={foreground} />
931
+ ) : shapePicked ? (
932
+ <ShapeGlyph shape={shape} color={foreground} />
933
+ ) : (
934
+ <Glyph icon={Shapes01Icon} color={foreground} />
935
+ )}
936
+ </AnimatedPressable>
937
+ );
938
+ })}
939
+ </View>
940
+
941
+ <RoundButton label="Undo" onPress={undo} disabled={empty}>
942
+ <Glyph icon={Undo03Icon} color={foreground} />
943
+ </RoundButton>
944
+ </View>
945
+
946
+ {/* The canvas. */}
947
+ <View className="flex-1">
948
+ <GestureDetector gesture={pan}>
949
+ {/* collapsable={false} keeps the view in the native tree, which the
950
+ gesture handler and the PNG capture both need. */}
951
+ <View
952
+ ref={canvasRef}
953
+ collapsable={false}
954
+ className="flex-1 overflow-hidden"
955
+ onLayout={onCanvasLayout}
956
+ accessible
957
+ accessibilityRole="image"
958
+ accessibilityLabel={
959
+ empty ? 'Drawing canvas, empty' : `Drawing canvas, ${items.length} marks`
960
+ }
961
+ accessibilityHint="Drag to draw with the selected tool."
962
+ >
963
+ <Svg width="100%" height="100%" pointerEvents="none">
964
+ {masks.length ? <Defs>{masks}</Defs> : null}
965
+ {layers}
966
+ <AnimatedPath
967
+ animatedProps={liveInkProps}
968
+ fill="none"
969
+ stroke={ink}
970
+ strokeWidth={size}
971
+ strokeLinecap="round"
972
+ strokeLinejoin="round"
973
+ />
974
+ <AnimatedPath
975
+ animatedProps={liveEraseProps}
976
+ fill="none"
977
+ stroke={surface}
978
+ strokeWidth={size * ERASER_SCALE}
979
+ strokeLinecap="round"
980
+ strokeLinejoin="round"
981
+ />
982
+ </Svg>
983
+ </View>
984
+ </GestureDetector>
985
+
986
+ <SizeSlider
987
+ value={size}
988
+ min={minSize}
989
+ max={maxSize}
990
+ color={tool === 'eraser' ? mutedColor ?? foreground : ink}
991
+ scale={tool === 'eraser' ? ERASER_SCALE : 1}
992
+ onChange={setSize}
993
+ />
994
+
995
+ {/* A tap anywhere outside an open card closes it, and does not draw. */}
996
+ {card ? (
997
+ <AnimatedPressable
998
+ accessibilityRole="button"
999
+ accessibilityLabel="Close"
1000
+ onPress={() => setCard(null)}
1001
+ style={StyleSheet.absoluteFill}
1002
+ />
1003
+ ) : null}
1004
+
1005
+ {card === 'shapes' ? (
1006
+ // Solid underneath: `muted` is a tint, and on its own the drawing
1007
+ // showed through the card.
1008
+ <View className="absolute left-4 right-4 top-2 overflow-hidden rounded-3xl bg-popover shadow-lg">
1009
+ <View className="flex-row flex-wrap bg-muted p-2">
1010
+ {visibleShapes.map((entry) => {
1011
+ const selected = tool === 'shape' && shape === entry;
1012
+ return (
1013
+ <AnimatedPressable
1014
+ key={entry}
1015
+ accessibilityRole="button"
1016
+ accessibilityLabel={SHAPE_LABEL[entry]}
1017
+ accessibilityState={{ selected }}
1018
+ onPress={() => pickShape(entry)}
1019
+ className={cn(
1020
+ 'h-16 w-1/4 items-center justify-center rounded-2xl',
1021
+ selected && 'bg-muted'
1022
+ )}
1023
+ >
1024
+ <ShapeGlyph shape={entry} color={foreground} size={28} />
1025
+ </AnimatedPressable>
1026
+ );
1027
+ })}
1028
+ </View>
1029
+ </View>
1030
+ ) : null}
1031
+
1032
+ {card === 'color' ? (
1033
+ <View className="absolute bottom-2 left-4 right-4 rounded-3xl border border-border bg-popover p-3 shadow-lg">
1034
+ <View className="gap-3 rounded-2xl">
1035
+ <ColorPicker
1036
+ value={custom ?? ink}
1037
+ onValueChange={(next) => {
1038
+ setCustom(next);
1039
+ setColor(next);
1040
+ if (tool === 'eraser') setTool('pen');
1041
+ }}
1042
+ size="sm"
1043
+ >
1044
+ <ColorPicker.Area height={150} />
1045
+ <ColorPicker.Hue />
1046
+ </ColorPicker>
1047
+ </View>
1048
+ </View>
1049
+ ) : null}
1050
+ </View>
1051
+
1052
+ {/* Colours, and confirm. */}
1053
+ <View className="flex-row items-center gap-3 px-4 pb-4 pt-2">
1054
+ {customColor ? (
1055
+ <AnimatedPressable
1056
+ accessibilityRole="button"
1057
+ accessibilityLabel="Custom colour"
1058
+ accessibilityState={{ selected: custom !== null, expanded: card === 'color' }}
1059
+ onPress={() => {
1060
+ selectionTick();
1061
+ setCard((current) => (current === 'color' ? null : 'color'));
1062
+ }}
1063
+ className="h-12 w-12 items-center justify-center"
1064
+ >
1065
+ <CustomColorRing color={custom ?? ink} size={36} />
1066
+ </AnimatedPressable>
1067
+ ) : null}
1068
+
1069
+ <View className="flex-1">
1070
+ <ScrollView
1071
+ horizontal
1072
+ showsHorizontalScrollIndicator={false}
1073
+ contentContainerClassName="items-center gap-1 pe-6"
1074
+ >
1075
+ {colors.map((swatch) => {
1076
+ const selected = custom === null && ink.toLowerCase() === swatch.toLowerCase();
1077
+ return (
1078
+ <AnimatedPressable
1079
+ key={swatch}
1080
+ accessibilityRole="button"
1081
+ accessibilityLabel={`Colour ${swatch}`}
1082
+ accessibilityState={{ selected }}
1083
+ onPress={() => pickColor(swatch)}
1084
+ className={cn(
1085
+ 'h-12 w-12 items-center justify-center rounded-full border-2',
1086
+ selected ? 'border-foreground' : 'border-transparent'
1087
+ )}
1088
+ >
1089
+ <View
1090
+ className="h-9 w-9 rounded-full border border-border"
1091
+ style={{ backgroundColor: swatch }}
1092
+ />
1093
+ </AnimatedPressable>
1094
+ );
1095
+ })}
1096
+ </ScrollView>
1097
+ {/* The row runs under a fade rather than stopping at a hard edge, so
1098
+ it reads as a row that scrolls rather than one that ran out. */}
1099
+ <Svg pointerEvents="none" width={28} height="100%" style={styles.fade}>
1100
+ <Defs>
1101
+ <LinearGradient id={`${maskId}-fade`} x1="0" y1="0" x2="1" y2="0">
1102
+ <Stop offset="0" stopColor={surface} stopOpacity={0} />
1103
+ <Stop offset="1" stopColor={surface} stopOpacity={1} />
1104
+ </LinearGradient>
1105
+ </Defs>
1106
+ <Rect x={0} y={0} width="100%" height="100%" fill={`url(#${maskId}-fade)`} />
1107
+ </Svg>
1108
+ </View>
1109
+
1110
+ {onDone ? (
1111
+ <RoundButton
1112
+ label="Done"
1113
+ onPress={done}
1114
+ disabled={empty}
1115
+ className="h-14 w-14 bg-primary"
1116
+ >
1117
+ <Glyph icon={Tick02Icon} color={primaryFg} size={24} />
1118
+ </RoundButton>
1119
+ ) : null}
1120
+ </View>
1121
+ </View>
1122
+ );
1123
+ }
1124
+
1125
+ /**
1126
+ * The stroke width, as a short vertical track against the leading edge. The
1127
+ * top is the thickest. While it is being dragged a dot beside the thumb shows
1128
+ * the width in the current ink, so the choice is made against the mark rather
1129
+ * than against a number.
1130
+ */
1131
+ function SizeSlider({
1132
+ value,
1133
+ min,
1134
+ max,
1135
+ color,
1136
+ scale,
1137
+ onChange,
1138
+ }: {
1139
+ value: number;
1140
+ min: number;
1141
+ max: number;
1142
+ color: string;
1143
+ scale: number;
1144
+ onChange: (value: number) => void;
1145
+ }) {
1146
+ const range = Math.max(max - min, 1);
1147
+ const fraction = useSharedValue((value - min) / range);
1148
+ const active = useSharedValue(0);
1149
+ const { height: screenHeight } = useWindowDimensions();
1150
+ // Short screens (a sheet on a small phone) get a shorter track.
1151
+ const track = Math.min(SLIDER_TRACK, screenHeight * 0.18);
1152
+
1153
+ useEffect(() => {
1154
+ fraction.value = (value - min) / range;
1155
+ }, [fraction, min, range, value]);
1156
+
1157
+ const pan = useMemo(
1158
+ () =>
1159
+ Gesture.Pan()
1160
+ .minDistance(0)
1161
+ .onBegin((event) => {
1162
+ 'worklet';
1163
+ active.value = withTiming(1, { duration: 120 });
1164
+ fraction.value = Math.min(Math.max(1 - (event.y - SLIDER_THUMB / 2) / track, 0), 1);
1165
+ })
1166
+ .onUpdate((event) => {
1167
+ 'worklet';
1168
+ // Only the thumb and the preview move while dragging. The width
1169
+ // reaches React once, on release, so the canvas does not re-render
1170
+ // every stroke it holds for every frame of the drag.
1171
+ fraction.value = Math.min(Math.max(1 - (event.y - SLIDER_THUMB / 2) / track, 0), 1);
1172
+ })
1173
+ .onFinalize(() => {
1174
+ 'worklet';
1175
+ active.value = withTiming(0, { duration: 200 });
1176
+ runOnJS(onChange)(Math.round(min + fraction.value * range));
1177
+ }),
1178
+ [active, fraction, min, onChange, range, track]
1179
+ );
1180
+
1181
+ const thumbStyle = useAnimatedStyle(() => ({
1182
+ transform: [{ translateY: (1 - fraction.value) * track }],
1183
+ }));
1184
+
1185
+ const previewStyle = useAnimatedStyle(() => {
1186
+ const diameter = (min + fraction.value * range) * scale;
1187
+ return {
1188
+ opacity: active.value,
1189
+ width: diameter,
1190
+ height: diameter,
1191
+ borderRadius: diameter / 2,
1192
+ transform: [
1193
+ { translateY: (1 - fraction.value) * track + SLIDER_THUMB / 2 - diameter / 2 },
1194
+ ],
1195
+ };
1196
+ });
1197
+
1198
+ return (
1199
+ <View
1200
+ pointerEvents="box-none"
1201
+ className="absolute bottom-0 start-0 top-0 justify-center"
1202
+ >
1203
+ <GestureDetector gesture={pan}>
1204
+ <View
1205
+ accessible
1206
+ accessibilityRole="adjustable"
1207
+ accessibilityLabel="Stroke width"
1208
+ accessibilityValue={{ min, max, now: value }}
1209
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
1210
+ onAccessibilityAction={(event) => {
1211
+ const step = Math.max(1, Math.round(range / 10));
1212
+ if (event.nativeEvent.actionName === 'increment') {
1213
+ onChange(Math.min(value + step, max));
1214
+ } else if (event.nativeEvent.actionName === 'decrement') {
1215
+ onChange(Math.max(value - step, min));
1216
+ }
1217
+ }}
1218
+ style={{ width: SLIDER_HIT + 16, height: track + SLIDER_THUMB }}
1219
+ className="items-center"
1220
+ >
1221
+ <View
1222
+ className="absolute w-0.5 rounded-full bg-muted-foreground/40"
1223
+ style={{ top: SLIDER_THUMB / 2, height: track }}
1224
+ />
1225
+ <Animated.View
1226
+ className="absolute top-0 rounded-full border border-border bg-muted"
1227
+ style={[{ width: SLIDER_THUMB, height: SLIDER_THUMB }, thumbStyle]}
1228
+ />
1229
+ <Animated.View
1230
+ pointerEvents="none"
1231
+ className="absolute top-0"
1232
+ style={[{ left: SLIDER_HIT + 12, backgroundColor: color }, previewStyle]}
1233
+ />
1234
+ </View>
1235
+ </GestureDetector>
1236
+ </View>
1237
+ );
1238
+ }
1239
+
1240
+ const styles = StyleSheet.create({
1241
+ fade: { position: 'absolute', top: 0, bottom: 0, right: 0 },
1242
+ });
1243
+
1244
+ const SketchForwarded = forwardRef<SketchHandle, SketchProps>(SketchRoot);
1245
+ SketchForwarded.displayName = 'Sketch';
1246
+
1247
+ export interface SketchSheetProps extends SketchProps {
1248
+ open?: boolean;
1249
+ onOpenChange?: (open: boolean) => void;
1250
+ /**
1251
+ * Present the platform's own sheet, painted solid in the theme's popover
1252
+ * surface. On by default. Requires the optional `@expo/ui` package; without
1253
+ * it the styled sheet is used instead.
1254
+ *
1255
+ * On iOS the sheet cannot be dragged away, so a downward stroke draws
1256
+ * rather than moving the sheet. The close button is the way out.
1257
+ */
1258
+ native?: boolean;
1259
+ }
1260
+
1261
+ /**
1262
+ * The sketch in a bottom sheet that fills the screen.
1263
+ *
1264
+ * The sheet cannot be dismissed by dragging or by tapping outside it. On a
1265
+ * canvas every drag is a stroke, and a sheet that took some of them would
1266
+ * throw away the drawing with a wrong-direction scribble. `onClose` and
1267
+ * `onDone` both close it.
1268
+ */
1269
+ const SketchSheet = forwardRef<SketchHandle, SketchSheetProps>(function SketchSheet(
1270
+ { open, onOpenChange, native = true, onClose, onDone, className, style, ...props },
1271
+ ref
1272
+ ) {
1273
+ const { height: screenHeight } = useWindowDimensions();
1274
+ const insets = useSafeAreaInsets();
1275
+ const close = () => onOpenChange?.(false);
1276
+
1277
+ // The platform sheet hosts the content at its own size, so the sketch is
1278
+ // given the full detent's height rather than asked to fill a parent.
1279
+ // The sheet adds its own bottom padding under the content, clear of the
1280
+ // home indicator, so that comes off the height.
1281
+ const detent = bottomSheetDetentHeight(['full'], screenHeight);
1282
+ const nativeHeight = detent ? detent - Math.max(insets.bottom, 16) : undefined;
1283
+
1284
+ return (
1285
+ <BottomSheet
1286
+ open={open}
1287
+ onOpenChange={onOpenChange}
1288
+ native={native}
1289
+ nativeBackground={native}
1290
+ snapPoints={native ? ['full'] : undefined}
1291
+ >
1292
+ <BottomSheet.Content
1293
+ size="full"
1294
+ dismissible={false}
1295
+ showClose={false}
1296
+ showGrabber={false}
1297
+ // The sketch draws its own padding, so the sheet's goes.
1298
+ className="px-0 pb-0 pt-0"
1299
+ >
1300
+ <SketchForwarded
1301
+ ref={ref}
1302
+ {...props}
1303
+ className={className}
1304
+ style={[native && nativeHeight ? { height: nativeHeight, flex: 0 } : null, style]}
1305
+ onClose={() => {
1306
+ onClose?.();
1307
+ close();
1308
+ }}
1309
+ onDone={
1310
+ onDone
1311
+ ? (result) => {
1312
+ onDone(result);
1313
+ close();
1314
+ }
1315
+ : undefined
1316
+ }
1317
+ />
1318
+ </BottomSheet.Content>
1319
+ </BottomSheet>
1320
+ );
1321
+ });
1322
+ SketchSheet.displayName = 'Sketch.Sheet';
1323
+
1324
+ export const Sketch = Object.assign(SketchForwarded, {
1325
+ Sheet: SketchSheet,
1326
+ });