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