@delacour/react-native-ui 0.4.1 → 0.5.0-alpha.20261021202224
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/components/chart/chart-tooltip-x.tsx +1 -1
- package/src/components/flow/AGENTS.md +25 -9
- package/src/components/flow/flow-background.tsx +66 -47
- package/src/components/flow/flow-connection-preview.tsx +30 -27
- package/src/components/flow/flow-edge.tsx +31 -38
- package/src/components/flow/flow-minimap.tsx +28 -33
- package/src/components/flow/flow.context.tsx +26 -7
- package/src/components/flow/flow.tsx +38 -18
- package/src/components/flow/flow.variants.test.ts +55 -11
- package/src/components/flow/flow.variants.ts +46 -18
- package/src/components/sketch/AGENTS.md +6 -2
- package/src/components/sketch/sketch-canvas.tsx +2 -1
- package/src/components/sketch/sketch.paths.test.ts +1 -1
- package/src/components/sketch/sketch.skia.ts +2 -2
- package/src/hooks/use-theme-color.ts +25 -0
- package/src/lib/skia-native-id.test.ts +29 -0
- package/src/lib/skia-native-id.ts +42 -0
- package/src/components/flow/flow-svg.ts +0 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-alpha.20261021202224",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -162,6 +162,7 @@
|
|
|
162
162
|
"./lib/scroll-edges": "./src/lib/scroll-edges.ts",
|
|
163
163
|
"./lib/scroll-maths": "./src/lib/scroll-maths.ts",
|
|
164
164
|
"./lib/scroll-sections": "./src/lib/scroll-sections.ts",
|
|
165
|
+
"./lib/skia-native-id": "./src/lib/skia-native-id.ts",
|
|
165
166
|
"./lib/slot": "./src/lib/slot.tsx",
|
|
166
167
|
"./lib/tv": "./src/lib/tv.ts",
|
|
167
168
|
"./lib/wrap-text-children": "./src/lib/wrap-text-children.ts",
|
|
@@ -183,9 +184,9 @@
|
|
|
183
184
|
"@maplibre/maplibre-react-native": "^11.5.0",
|
|
184
185
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
185
186
|
"@delacour/react-native-bottom-sheet": ">=0.2.0",
|
|
186
|
-
"@delacour/react-native-charts": "
|
|
187
|
+
"@delacour/react-native-charts": "0.2.0-alpha.20261021202224",
|
|
187
188
|
"@legendapp/list": ">=3.3",
|
|
188
|
-
"
|
|
189
|
+
"react-native-skia": ">=3",
|
|
189
190
|
"expo-linear-gradient": ">=15",
|
|
190
191
|
"expo-router": ">=57",
|
|
191
192
|
"react": ">=19",
|
|
@@ -218,7 +219,7 @@
|
|
|
218
219
|
"@legendapp/list": {
|
|
219
220
|
"optional": true
|
|
220
221
|
},
|
|
221
|
-
"
|
|
222
|
+
"react-native-skia": {
|
|
222
223
|
"optional": true
|
|
223
224
|
},
|
|
224
225
|
"expo-router": {
|
|
@@ -238,6 +239,7 @@
|
|
|
238
239
|
"@delacour/react-native-bottom-sheet": "workspace:*",
|
|
239
240
|
"@delacour/react-native-charts": "workspace:*",
|
|
240
241
|
"@delacour/tsconfig": "workspace:*",
|
|
242
|
+
"react-native-skia": "catalog:",
|
|
241
243
|
"@types/bun": "^1.3.8",
|
|
242
244
|
"@types/react": "catalog:",
|
|
243
245
|
"expo-router": "~58.0.24",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ChartBounds, ChartOrientation, ChartScrubState, ChartStep } from "@delacour/react-native-charts";
|
|
2
2
|
import { ChartCursorLine, useChartContext as useEngineChart } from "@delacour/react-native-charts";
|
|
3
|
-
import { Rect } from "@shopify/react-native-skia";
|
|
4
3
|
import type { ReactElement } from "react";
|
|
5
4
|
import { useDerivedValue } from "react-native-reanimated";
|
|
5
|
+
import { Rect } from "react-native-skia";
|
|
6
6
|
import { useChart } from "./chart.context";
|
|
7
7
|
|
|
8
8
|
export type ChartTooltipXProps = {
|
|
@@ -14,7 +14,7 @@ parts: `Background`, `Node`, `Handle`, `Edge`, `Group`, `Controls`, `MiniMap`, a
|
|
|
14
14
|
| --- | --- |
|
|
15
15
|
| `index.ts` | → `@delacour/react-native-ui/flow` |
|
|
16
16
|
| `flow.tsx` | Root: the viewport, the canvas gestures, the three layers, fit/zoom/connect, the `Object.assign` surface |
|
|
17
|
-
| `flow-background.tsx` | `Flow.Background` — one
|
|
17
|
+
| `flow-background.tsx` | `Flow.Background` — one shader-filled rect sized to the visible graph |
|
|
18
18
|
| `flow-node.tsx` | `Flow.Node` — drag, tap, confine, and the node's accessibility actions |
|
|
19
19
|
| `flow-handle.tsx` | `Flow.Handle` — placement, registration, and the connection drag |
|
|
20
20
|
| `flow-edge.tsx` | `Flow.Edge` — the route worklet, dashes, arrowhead |
|
|
@@ -23,8 +23,7 @@ parts: `Background`, `Node`, `Handle`, `Edge`, `Group`, `Controls`, `MiniMap`, a
|
|
|
23
23
|
| `flow-minimap.tsx` | `Flow.MiniMap` — every entry as a block, plus the visible-area frame |
|
|
24
24
|
| `flow-connection-preview.tsx` | Internal: the line a connection drag draws, above the nodes |
|
|
25
25
|
| `flow-registry.ts` | Internal: the node/group index — a shared position, size and handle map per entry |
|
|
26
|
-
| `flow
|
|
27
|
-
| `flow.context.tsx` | `FlowProvider`, `useFlow()`, `useFlowContext()`, `useFlowPart()`, the SVG-layer, group and node contexts |
|
|
26
|
+
| `flow.context.tsx` | `FlowProvider`, `useFlow()`, `useFlowContext()`, `useFlowPart()`, the canvas-layer, group and node contexts |
|
|
28
27
|
| `flow-geometry.ts` | Types, routing, fit, zoom and minimap maths — one worklet, plus JS-thread copies |
|
|
29
28
|
| `flow-geometry.test.ts` | Every side pair, every variant, overlap, zero size, fit, zoom, clamp |
|
|
30
29
|
| `flow.variants.ts` | The slotted `tv()`, the defaults, and every non-geometry resolver — no RN imports |
|
|
@@ -49,7 +48,7 @@ parts: `Background`, `Node`, `Handle`, `Edge`, `Group`, `Controls`, `MiniMap`, a
|
|
|
49
48
|
function" the first time a node moves. Worklets reach geometry as `flowGeometry().route…`; the
|
|
50
49
|
JS thread and the tests use the destructured copies, which are not worklets.
|
|
51
50
|
- **Children draw in three layers whatever order they are written in.** `sortLayers` walks
|
|
52
|
-
children, fragments and arrays: `Edge` and `Background` go into one
|
|
51
|
+
children, fragments and arrays: `Edge` and `Background` go into one Skia canvas beneath the nodes,
|
|
53
52
|
`Node` and `Group` into the transformed view, `Controls` and `MiniMap` on top unscaled. An
|
|
54
53
|
edge wrapped in a custom component is not found — it renders `null` and warns in development.
|
|
55
54
|
- **Layers are chosen by `displayName`, never by `child.type === FlowEdge`.** In a development
|
|
@@ -72,9 +71,25 @@ parts: `Background`, `Node`, `Handle`, `Edge`, `Group`, `Controls`, `MiniMap`, a
|
|
|
72
71
|
parent draws but is not hit tested on every platform, so the layer extends
|
|
73
72
|
`FLOW_LAYER_EXTENT` past the canvas each way and scales about its own centre, which is the
|
|
74
73
|
graph's origin. An empty view allocates no backing store at that size.
|
|
75
|
-
- **The
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
- **The canvas is a second React reconciler, so nothing inside it reaches the app.** Context does
|
|
75
|
+
not cross `<Canvas>`: an edge calling `useThemeColor` there would read no `ScopedTheme`, and
|
|
76
|
+
`useCalmMotion` no `CalmMotionProvider`. The root collects every colour its canvas children
|
|
77
|
+
name (`collectFlowCanvasTokens`), resolves them above the canvas in one `useThemeColors` call
|
|
78
|
+
with `isCalm` beside them, and re-renders `FlowProvider` plus `FlowCanvasLayerProvider` inside
|
|
79
|
+
the canvas — the trick `@delacour/react-native-charts` uses. A part off the canvas gets `null`
|
|
80
|
+
from `useFlowCanvasLayer()` and warns. The preview and the minimap own their canvases and
|
|
81
|
+
resolve their colours just outside them.
|
|
82
|
+
- **`flow.tsx` imports `lib/skia-native-id` for its effect.** After a JS reload Skia reused the old
|
|
83
|
+
runtime's canvas ids, which its native registry still held as torn down, so the edges and the
|
|
84
|
+
background came up blank — the minimap too — until a re-render (pressing the lock was enough).
|
|
85
|
+
The import moves this runtime's ids to a fresh range before any of the three canvases asks for one.
|
|
86
|
+
- **Shared values go straight into Skia props.** The pan and zoom are one derived `transform` on
|
|
87
|
+
the canvas's `Group`; an edge's route is a derived `SkPath` (`Skia.Path.MakeFromSVGString` in
|
|
88
|
+
the worklet), its march a `DashPathEffect` `phase`. Nothing crosses to the JS thread per frame.
|
|
89
|
+
- **The background is a runtime shader, not a pattern.** One SkSL effect draws dots, a grid or
|
|
90
|
+
crosses as a signed distance in graph units, so the marks scale with the zoom and stay sharp at
|
|
91
|
+
any of it. Runtime SkSL has no `fwidth`, so the antialias width — one device pixel in graph
|
|
92
|
+
units — is a uniform, `1 / (zoom × PixelRatio)`.
|
|
78
93
|
- **Gestures nest innermost-first.** A handle's drag blocks its node's and the canvas's, a
|
|
79
94
|
node's drag blocks the canvas's, and a node that cannot be dragged lets the canvas pan
|
|
80
95
|
straight through it. The canvas's pan and pinch are simultaneous and both incremental, so a
|
|
@@ -103,8 +118,9 @@ parts: `Background`, `Node`, `Handle`, `Edge`, `Group`, `Controls`, `MiniMap`, a
|
|
|
103
118
|
layer is needed for something that never leaves the canvas.
|
|
104
119
|
- **The minimap's bounds hold the visible area too**, so its frame never slides off the map
|
|
105
120
|
when the canvas is panned past the graph. It takes no touches.
|
|
106
|
-
- **
|
|
107
|
-
|
|
121
|
+
- **Skia is an optional peer.** The renderer is `react-native-skia` + Reanimated +
|
|
122
|
+
Gesture Handler. Skia was already the `Chart` renderer, so an app with either has it; one with
|
|
123
|
+
neither installs it when it adds `flow`.
|
|
108
124
|
|
|
109
125
|
## Accessibility
|
|
110
126
|
|
|
@@ -1,12 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { PixelRatio } from "react-native";
|
|
3
|
+
import { useDerivedValue } from "react-native-reanimated";
|
|
4
|
+
import { Rect, Shader, Skia } from "react-native-skia";
|
|
5
|
+
import { useFlowCanvasLayer, useFlowPart } from "./flow.context";
|
|
6
|
+
import {
|
|
7
|
+
FLOW_DEFAULTS,
|
|
8
|
+
type FlowBackgroundVariant,
|
|
9
|
+
resolveBackgroundPattern,
|
|
10
|
+
resolveFlowCanvasToken,
|
|
11
|
+
} from "./flow.variants";
|
|
7
12
|
import { flowGeometry } from "./flow-geometry";
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Every mark as a signed distance in graph units, so one shader draws all three and the edge is
|
|
16
|
+
* antialiased by `aa` — a device pixel in graph units, since runtime SkSL has no `fwidth`.
|
|
17
|
+
* `shape` 0 is a dot in the middle of each tile, 1 a grid along the tile edges, 2 a centred cross.
|
|
18
|
+
*/
|
|
19
|
+
const BACKGROUND_SKSL = `
|
|
20
|
+
uniform float shape;
|
|
21
|
+
uniform float tile;
|
|
22
|
+
uniform float weight;
|
|
23
|
+
uniform float arm;
|
|
24
|
+
uniform float aa;
|
|
25
|
+
uniform vec4 color;
|
|
26
|
+
|
|
27
|
+
half4 main(vec2 p) {
|
|
28
|
+
vec2 q = mod(p, tile);
|
|
29
|
+
float d;
|
|
30
|
+
if (shape < 0.5) {
|
|
31
|
+
d = length(q - vec2(tile * 0.5)) - weight;
|
|
32
|
+
} else if (shape < 1.5) {
|
|
33
|
+
vec2 e = min(q, tile - q);
|
|
34
|
+
d = min(e.x, e.y) - weight;
|
|
35
|
+
} else {
|
|
36
|
+
vec2 c = abs(q - vec2(tile * 0.5));
|
|
37
|
+
d = min(max(c.x - arm, c.y - weight), max(c.y - arm, c.x - weight));
|
|
38
|
+
}
|
|
39
|
+
float a = color.a * (1.0 - smoothstep(-aa * 0.5, aa * 0.5, d));
|
|
40
|
+
return half4(color.rgb * a, a);
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const BACKGROUND_EFFECT = Skia.RuntimeEffect.Make(BACKGROUND_SKSL);
|
|
45
|
+
|
|
46
|
+
const PIXEL_RATIO = PixelRatio.get();
|
|
10
47
|
|
|
11
48
|
export type FlowBackgroundProps = {
|
|
12
49
|
/** The mark each tile repeats. */
|
|
@@ -19,32 +56,13 @@ export type FlowBackgroundProps = {
|
|
|
19
56
|
color?: string;
|
|
20
57
|
};
|
|
21
58
|
|
|
22
|
-
/**
|
|
23
|
-
* The tile's mark, drawn once inside the pattern.
|
|
24
|
-
*
|
|
25
|
-
* Above the `return` rather than inline: the three marks are a discriminated union and each
|
|
26
|
-
* branch reads only its own fields.
|
|
27
|
-
*/
|
|
28
|
-
function FlowBackgroundMark({
|
|
29
|
-
pattern,
|
|
30
|
-
color,
|
|
31
|
-
}: {
|
|
32
|
-
pattern: ReturnType<typeof resolveBackgroundPattern>;
|
|
33
|
-
color: string | undefined;
|
|
34
|
-
}): ReactElement | null {
|
|
35
|
-
if (pattern.kind === "none") return null;
|
|
36
|
-
if (pattern.kind === "dots") return <Circle cx={pattern.cx} cy={pattern.cy} fill={color} r={pattern.r} />;
|
|
37
|
-
return <Path d={pattern.d} fill="none" stroke={color} strokeWidth={pattern.strokeWidth} />;
|
|
38
|
-
}
|
|
39
|
-
FlowBackgroundMark.displayName = "DelacourUI.Flow.Background.Mark";
|
|
40
|
-
|
|
41
59
|
/**
|
|
42
60
|
* The canvas's backdrop — dots, lines or crosses on a grid that pans and zooms with the graph.
|
|
43
61
|
*
|
|
44
|
-
* **One rect, filled
|
|
45
|
-
* inside the canvas's transformed group, so
|
|
46
|
-
* every frame to the visible part of the graph, so there is no edge to pan past and
|
|
47
|
-
* off screen. Hidden from assistive technology with the rest of the
|
|
62
|
+
* **One rect, filled by a shader, sized to what is on screen.** The shader works in graph units
|
|
63
|
+
* inside the canvas's transformed group, so the marks scale with the zoom for free; the rect is
|
|
64
|
+
* resized every frame to the visible part of the graph, so there is no edge to pan past and
|
|
65
|
+
* nothing is shaded off screen. Hidden from assistive technology with the rest of the canvas.
|
|
48
66
|
*/
|
|
49
67
|
export function FlowBackground({
|
|
50
68
|
variant = "dots",
|
|
@@ -53,33 +71,34 @@ export function FlowBackground({
|
|
|
53
71
|
color,
|
|
54
72
|
}: FlowBackgroundProps): ReactElement | null {
|
|
55
73
|
const { viewport, container } = useFlowPart("Flow.Background");
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
74
|
+
const layer = useFlowCanvasLayer();
|
|
75
|
+
const token = resolveFlowCanvasToken(FlowBackground.displayName, color);
|
|
76
|
+
const fill = token === undefined ? undefined : layer?.colors[token];
|
|
77
|
+
const rgba = Array.from(Skia.Color(fill ?? "transparent"));
|
|
59
78
|
const pattern = resolveBackgroundPattern(variant, gap, size);
|
|
60
79
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
80
|
+
const visible = useDerivedValue(() => flowGeometry().visibleGraphRect(viewport.value, container.value));
|
|
81
|
+
const uniforms = useDerivedValue(() => ({
|
|
82
|
+
aa: 1 / (viewport.value.zoom * PIXEL_RATIO),
|
|
83
|
+
arm: pattern.kind === "none" ? 0 : pattern.arm,
|
|
84
|
+
color: rgba,
|
|
85
|
+
shape: pattern.kind === "none" ? 0 : pattern.shape,
|
|
86
|
+
tile: pattern.kind === "none" ? 1 : pattern.tile,
|
|
87
|
+
weight: pattern.kind === "none" ? 0 : pattern.weight,
|
|
88
|
+
}));
|
|
65
89
|
|
|
66
|
-
if (
|
|
90
|
+
if (layer === null) {
|
|
67
91
|
if (process.env.NODE_ENV !== "production") {
|
|
68
92
|
console.warn("Flow.Background: render it as a direct child of <Flow>, where the canvas can draw it.");
|
|
69
93
|
}
|
|
70
94
|
return null;
|
|
71
95
|
}
|
|
72
|
-
if (pattern.kind === "none") return null;
|
|
96
|
+
if (pattern.kind === "none" || BACKGROUND_EFFECT === null) return null;
|
|
73
97
|
|
|
74
98
|
return (
|
|
75
|
-
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
<FlowBackgroundMark color={fill} pattern={pattern} />
|
|
79
|
-
</Pattern>
|
|
80
|
-
</Defs>
|
|
81
|
-
<AnimatedRect animatedProps={rectProps} fill={`url(#${patternId})`} />
|
|
82
|
-
</>
|
|
99
|
+
<Rect rect={visible}>
|
|
100
|
+
<Shader source={BACKGROUND_EFFECT} uniforms={uniforms} />
|
|
101
|
+
</Rect>
|
|
83
102
|
);
|
|
84
103
|
}
|
|
85
104
|
FlowBackground.displayName = "DelacourUI.Flow.Background";
|
|
@@ -1,54 +1,57 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
|
-
import { View } from "react-native";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { useDerivedValue } from "react-native-reanimated";
|
|
4
|
+
import { Canvas, DashPathEffect, Group, Path, Skia } from "react-native-skia";
|
|
5
5
|
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
6
6
|
import { useFlowPart } from "./flow.context";
|
|
7
7
|
import { FLOW_DEFAULTS } from "./flow.variants";
|
|
8
8
|
import { flowGeometry } from "./flow-geometry";
|
|
9
|
-
import { AnimatedMatrixG, type FlowMatrixGProps } from "./flow-svg";
|
|
10
|
-
|
|
11
|
-
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
12
9
|
|
|
13
10
|
/** The side a preview arrives on — the one facing back at the handle it left. */
|
|
14
11
|
const OPPOSITE = { bottom: "top", left: "right", right: "left", top: "bottom" } as const;
|
|
15
12
|
|
|
13
|
+
const PREVIEW_DASH = [6, 4];
|
|
14
|
+
|
|
16
15
|
/**
|
|
17
16
|
* The line from a handle to the finger while a connection is dragged — internal.
|
|
18
17
|
*
|
|
19
|
-
* **Its own
|
|
20
|
-
* edge and wrong for a line the finger is drawing: it would vanish behind the very card it is
|
|
21
|
-
* being dragged onto. Nothing here takes a touch.
|
|
18
|
+
* **Its own canvas, above the nodes.** The edges' canvas sits beneath the nodes, which is right for
|
|
19
|
+
* an edge and wrong for a line the finger is drawing: it would vanish behind the very card it is
|
|
20
|
+
* being dragged onto. Nothing here takes a touch. Its colour is resolved out here, above the
|
|
21
|
+
* canvas, where the app's theme is still in reach.
|
|
22
22
|
*/
|
|
23
23
|
export function FlowConnectionPreview(): ReactElement {
|
|
24
24
|
const { viewport, preview } = useFlowPart("Flow.ConnectionPreview");
|
|
25
|
-
const stroke = useThemeColor("primary");
|
|
25
|
+
const stroke = useThemeColor("primary") ?? "transparent";
|
|
26
26
|
|
|
27
|
-
const
|
|
27
|
+
const transform = useDerivedValue(() => {
|
|
28
28
|
const { x, y, zoom } = viewport.value;
|
|
29
|
-
return {
|
|
29
|
+
return [{ translateX: x }, { translateY: y }, { scale: zoom }];
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
const
|
|
33
|
-
const {
|
|
32
|
+
const path = useDerivedValue(() => {
|
|
33
|
+
const { from, fromSide, to } = preview.value;
|
|
34
34
|
const d = flowGeometry().routeBezier(from, fromSide, to, OPPOSITE[fromSide], FLOW_DEFAULTS.edgeCurvature);
|
|
35
|
-
return
|
|
35
|
+
return Skia.Path.MakeFromSVGString(d) ?? Skia.Path.Make();
|
|
36
36
|
});
|
|
37
|
+
const opacity = useDerivedValue(() => (preview.value.isActive ? 1 : 0));
|
|
37
38
|
|
|
38
39
|
return (
|
|
39
|
-
<View
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
strokeLinecap="round"
|
|
40
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
41
|
+
<Canvas style={StyleSheet.absoluteFill}>
|
|
42
|
+
<Group transform={transform}>
|
|
43
|
+
<Path
|
|
44
|
+
color={stroke}
|
|
45
|
+
opacity={opacity}
|
|
46
|
+
path={path}
|
|
47
|
+
strokeCap="round"
|
|
48
48
|
strokeWidth={FLOW_DEFAULTS.edgeWidth}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
style="stroke"
|
|
50
|
+
>
|
|
51
|
+
<DashPathEffect intervals={PREVIEW_DASH} />
|
|
52
|
+
</Path>
|
|
53
|
+
</Group>
|
|
54
|
+
</Canvas>
|
|
52
55
|
</View>
|
|
53
56
|
);
|
|
54
57
|
}
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import { type ReactElement, useEffect } from "react";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
3
|
cancelAnimation,
|
|
4
4
|
Easing,
|
|
5
|
-
useAnimatedProps,
|
|
6
5
|
useDerivedValue,
|
|
7
6
|
useSharedValue,
|
|
8
7
|
withRepeat,
|
|
9
8
|
withTiming,
|
|
10
9
|
} from "react-native-reanimated";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { useFlowPart, useIsInFlowSvgLayer } from "./flow.context";
|
|
15
|
-
import { FLOW_DEFAULTS, resolveArrowSize, resolveEdgeDash } from "./flow.variants";
|
|
10
|
+
import { DashPathEffect, Group, Path, Skia, type SkPath } from "react-native-skia";
|
|
11
|
+
import { useFlowCanvasLayer, useFlowPart } from "./flow.context";
|
|
12
|
+
import { FLOW_DEFAULTS, resolveArrowSize, resolveEdgeDash, resolveFlowCanvasToken } from "./flow.variants";
|
|
16
13
|
import {
|
|
17
14
|
type FlowEdgeVariant,
|
|
18
15
|
type FlowEndpointReference,
|
|
@@ -21,10 +18,11 @@ import {
|
|
|
21
18
|
parseEndpoint,
|
|
22
19
|
} from "./flow-geometry";
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
/** A route as a Skia path — an empty one for the frames before both ends have measured. */
|
|
22
|
+
function toSkiaPath(d: string | undefined): SkPath {
|
|
23
|
+
"worklet";
|
|
24
|
+
return (d ? Skia.Path.MakeFromSVGString(d) : null) ?? Skia.Path.Make();
|
|
25
|
+
}
|
|
28
26
|
|
|
29
27
|
export type FlowEdgeProps = {
|
|
30
28
|
/** `"nodeId"` or `"nodeId.handleId"`. */
|
|
@@ -84,9 +82,10 @@ export function FlowEdge({
|
|
|
84
82
|
curvature = FLOW_DEFAULTS.edgeCurvature,
|
|
85
83
|
}: FlowEdgeProps): ReactElement | null {
|
|
86
84
|
const { registry } = useFlowPart("Flow.Edge");
|
|
87
|
-
const
|
|
88
|
-
const isCalm =
|
|
89
|
-
const
|
|
85
|
+
const layer = useFlowCanvasLayer();
|
|
86
|
+
const isCalm = layer?.isCalm ?? false;
|
|
87
|
+
const token = resolveFlowCanvasToken(FlowEdge.displayName, color);
|
|
88
|
+
const stroke = (token === undefined ? undefined : layer?.colors[token]) ?? "transparent";
|
|
90
89
|
|
|
91
90
|
const source = parseEndpoint(from);
|
|
92
91
|
const target = parseEndpoint(to);
|
|
@@ -133,18 +132,12 @@ export function FlowEdge({
|
|
|
133
132
|
return () => cancelAnimation(dashOffset);
|
|
134
133
|
}, [dash.cycle, dashOffset, shouldMarch]);
|
|
135
134
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}));
|
|
141
|
-
|
|
142
|
-
const arrowProps = useAnimatedProps(() => ({
|
|
143
|
-
d: geometry.value?.arrow || EMPTY_PATH,
|
|
144
|
-
opacity: geometry.value?.arrow ? 1 : 0,
|
|
145
|
-
}));
|
|
135
|
+
const linePath = useDerivedValue(() => toSkiaPath(geometry.value?.d));
|
|
136
|
+
const lineOpacity = useDerivedValue(() => (geometry.value ? 1 : 0));
|
|
137
|
+
const arrowPath = useDerivedValue(() => toSkiaPath(geometry.value?.arrow));
|
|
138
|
+
const arrowOpacity = useDerivedValue(() => (geometry.value?.arrow ? 1 : 0));
|
|
146
139
|
|
|
147
|
-
if (
|
|
140
|
+
if (layer === null) {
|
|
148
141
|
if (process.env.NODE_ENV !== "production") {
|
|
149
142
|
console.warn(
|
|
150
143
|
`Flow.Edge ${from} → ${to}: render edges as direct children of <Flow> (or from a .map() there), ` +
|
|
@@ -155,20 +148,20 @@ export function FlowEdge({
|
|
|
155
148
|
}
|
|
156
149
|
|
|
157
150
|
return (
|
|
158
|
-
<
|
|
159
|
-
<
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
strokeLinejoin="round"
|
|
151
|
+
<Group>
|
|
152
|
+
<Path
|
|
153
|
+
color={stroke}
|
|
154
|
+
opacity={lineOpacity}
|
|
155
|
+
path={linePath}
|
|
156
|
+
strokeCap="round"
|
|
157
|
+
strokeJoin="round"
|
|
166
158
|
strokeWidth={width}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
159
|
+
style="stroke"
|
|
160
|
+
>
|
|
161
|
+
{dash.dashArray ? <DashPathEffect intervals={dash.dashArray} phase={dashOffset} /> : null}
|
|
162
|
+
</Path>
|
|
163
|
+
{hasArrow ? <Path color={stroke} opacity={arrowOpacity} path={arrowPath} strokeJoin="round" /> : null}
|
|
164
|
+
</Group>
|
|
172
165
|
);
|
|
173
166
|
}
|
|
174
167
|
FlowEdge.displayName = "DelacourUI.Flow.Edge";
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { type ReactElement, useMemo } from "react";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import { type SharedValue, useDerivedValue } from "react-native-reanimated";
|
|
3
|
+
import { Canvas, Group, Rect, RoundedRect, Skia } from "react-native-skia";
|
|
4
4
|
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
5
5
|
import { Surface } from "../surface";
|
|
6
6
|
import { useFlowPart } from "./flow.context";
|
|
7
7
|
import { FLOW_DEFAULTS, type FlowMiniMapCorner, flowVariants } from "./flow.variants";
|
|
8
8
|
import { type FlowPoint, type FlowSize, flowGeometry } from "./flow-geometry";
|
|
9
|
-
import { AnimatedMatrixG, type FlowMatrixGProps } from "./flow-svg";
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
/** A block's corner, in graph units. */
|
|
11
|
+
const BLOCK_RADIUS = 4;
|
|
12
12
|
|
|
13
13
|
export type FlowMiniMapProps = {
|
|
14
14
|
/** Which corner of the canvas it floats in. */
|
|
@@ -22,16 +22,20 @@ export type FlowMiniMapProps = {
|
|
|
22
22
|
|
|
23
23
|
type MiniMapShape = { id: string; isGroup: boolean; position: SharedValue<FlowPoint>; size: SharedValue<FlowSize> };
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* One node or group, as a block that follows its drag. Drawn inside the minimap's canvas, so its
|
|
27
|
+
* colour arrives as a plain value — no theme hook can reach the app from in there.
|
|
28
|
+
*/
|
|
29
|
+
function FlowMiniMapBlock({ shape, fill }: { shape: MiniMapShape; fill: string }): ReactElement {
|
|
27
30
|
const { position, size } = shape;
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
const rect = useDerivedValue(() =>
|
|
32
|
+
Skia.RRectXY(
|
|
33
|
+
Skia.XYWHRect(position.value.x, position.value.y, size.value.width, size.value.height),
|
|
34
|
+
BLOCK_RADIUS,
|
|
35
|
+
BLOCK_RADIUS
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
return <RoundedRect color={fill} opacity={shape.isGroup ? 0.25 : 0.6} rect={rect} />;
|
|
35
39
|
}
|
|
36
40
|
FlowMiniMapBlock.displayName = "DelacourUI.Flow.MiniMap.Block";
|
|
37
41
|
|
|
@@ -54,8 +58,8 @@ export function FlowMiniMap({
|
|
|
54
58
|
}: FlowMiniMapProps): ReactElement {
|
|
55
59
|
const { entries, viewport, container } = useFlowPart("Flow.MiniMap");
|
|
56
60
|
const slots = flowVariants({ miniMapCorner: corner });
|
|
57
|
-
const nodeFill = useThemeColor(nodeColor ?? "muted-foreground");
|
|
58
|
-
const frameStroke = useThemeColor("primary");
|
|
61
|
+
const nodeFill = useThemeColor(nodeColor ?? "muted-foreground") ?? "transparent";
|
|
62
|
+
const frameStroke = useThemeColor("primary") ?? "transparent";
|
|
59
63
|
|
|
60
64
|
const shapes = useMemo<MiniMapShape[]>(
|
|
61
65
|
() =>
|
|
@@ -80,21 +84,12 @@ export function FlowMiniMap({
|
|
|
80
84
|
return { transform: geometry.miniMapTransform(bounds, width, height, FLOW_DEFAULTS.miniMapPadding), visible };
|
|
81
85
|
});
|
|
82
86
|
|
|
83
|
-
const
|
|
87
|
+
const transform = useDerivedValue(() => {
|
|
84
88
|
const { scale, x, y } = fit.value.transform;
|
|
85
|
-
return {
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const frameProps = useAnimatedProps(() => {
|
|
89
|
-
const { visible, transform } = fit.value;
|
|
90
|
-
return {
|
|
91
|
-
height: visible.height,
|
|
92
|
-
strokeWidth: 1.5 / transform.scale,
|
|
93
|
-
width: visible.width,
|
|
94
|
-
x: visible.x,
|
|
95
|
-
y: visible.y,
|
|
96
|
-
};
|
|
89
|
+
return [{ translateX: x }, { translateY: y }, { scale }];
|
|
97
90
|
});
|
|
91
|
+
const frame = useDerivedValue(() => fit.value.visible);
|
|
92
|
+
const frameWidth = useDerivedValue(() => 1.5 / fit.value.transform.scale);
|
|
98
93
|
|
|
99
94
|
return (
|
|
100
95
|
<Surface
|
|
@@ -105,14 +100,14 @@ export function FlowMiniMap({
|
|
|
105
100
|
pointerEvents="none"
|
|
106
101
|
style={{ height, width }}
|
|
107
102
|
>
|
|
108
|
-
<
|
|
109
|
-
<
|
|
103
|
+
<Canvas style={{ height, width }}>
|
|
104
|
+
<Group transform={transform}>
|
|
110
105
|
{shapes.map((shape) => (
|
|
111
106
|
<FlowMiniMapBlock fill={nodeFill} key={shape.id} shape={shape} />
|
|
112
107
|
))}
|
|
113
|
-
<
|
|
114
|
-
</
|
|
115
|
-
</
|
|
108
|
+
<Rect color={frameStroke} rect={frame} strokeWidth={frameWidth} style="stroke" />
|
|
109
|
+
</Group>
|
|
110
|
+
</Canvas>
|
|
116
111
|
</Surface>
|
|
117
112
|
);
|
|
118
113
|
}
|
|
@@ -78,16 +78,35 @@ export function useFlow(): UseFlowResult {
|
|
|
78
78
|
return { fitView: () => fitView(), getNodeRect, isLocked, setLocked, viewport, zoomBy };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
81
|
+
/**
|
|
82
|
+
* What a part drawing on the Skia canvas cannot look up for itself.
|
|
83
|
+
*
|
|
84
|
+
* `<Canvas>` is a second React reconciler, so a theme hook or `useCalmMotion` called inside it
|
|
85
|
+
* reads no provider from the app — no `ScopedTheme`, no `CalmMotionProvider`. The root resolves
|
|
86
|
+
* both above the canvas and re-provides them here, beside a re-rendered `FlowProvider`.
|
|
87
|
+
*/
|
|
88
|
+
export type FlowCanvasLayerValue = {
|
|
89
|
+
/** Every colour token the canvas's children name, resolved against the app's theme. */
|
|
90
|
+
colors: Readonly<Record<string, string | undefined>>;
|
|
91
|
+
isCalm: boolean;
|
|
92
|
+
};
|
|
83
93
|
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
const FlowCanvasLayerContext = createContext<FlowCanvasLayerValue | null>(null);
|
|
95
|
+
|
|
96
|
+
export function FlowCanvasLayerProvider({
|
|
97
|
+
value,
|
|
98
|
+
children,
|
|
99
|
+
}: {
|
|
100
|
+
value: FlowCanvasLayerValue;
|
|
101
|
+
children: ReactNode;
|
|
102
|
+
}): ReactElement {
|
|
103
|
+
return <FlowCanvasLayerContext.Provider value={value}>{children}</FlowCanvasLayerContext.Provider>;
|
|
86
104
|
}
|
|
87
|
-
|
|
105
|
+
FlowCanvasLayerProvider.displayName = "DelacourUI.Flow.CanvasLayerProvider";
|
|
88
106
|
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
/** The canvas layer's values, or `null` outside it — the only place an edge or a background can draw. */
|
|
108
|
+
export function useFlowCanvasLayer(): FlowCanvasLayerValue | null {
|
|
109
|
+
return useContext(FlowCanvasLayerContext);
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
export type FlowGroupContextValue = { id: string; entry: FlowRegistryEntry };
|
|
@@ -10,24 +10,29 @@ import {
|
|
|
10
10
|
useRef,
|
|
11
11
|
useState,
|
|
12
12
|
} from "react";
|
|
13
|
-
import { type LayoutChangeEvent, View } from "react-native";
|
|
13
|
+
import { type LayoutChangeEvent, StyleSheet, View } from "react-native";
|
|
14
14
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
15
15
|
import Animated, {
|
|
16
|
-
useAnimatedProps,
|
|
17
16
|
useAnimatedStyle,
|
|
17
|
+
useDerivedValue,
|
|
18
18
|
useReducedMotion,
|
|
19
19
|
useSharedValue,
|
|
20
20
|
withTiming,
|
|
21
21
|
} from "react-native-reanimated";
|
|
22
|
-
import
|
|
22
|
+
import { Canvas, Group } from "react-native-skia";
|
|
23
23
|
import { scheduleOnRN } from "react-native-worklets";
|
|
24
|
+
import "../../lib/skia-native-id";
|
|
25
|
+
import { useCalmMotion } from "../../hooks/use-calm-motion";
|
|
26
|
+
import { useThemeColors } from "../../hooks/use-theme-color";
|
|
24
27
|
import {
|
|
28
|
+
FlowCanvasLayerProvider,
|
|
29
|
+
type FlowCanvasLayerValue,
|
|
25
30
|
type FlowContextValue,
|
|
26
31
|
type FlowConnectionPreview as FlowPreviewValue,
|
|
27
32
|
FlowProvider,
|
|
28
|
-
FlowSvgLayerProvider,
|
|
29
33
|
} from "./flow.context";
|
|
30
34
|
import {
|
|
35
|
+
collectFlowCanvasTokens,
|
|
31
36
|
FLOW_DEFAULTS,
|
|
32
37
|
FLOW_LAYER_EXTENT,
|
|
33
38
|
type FlowChildLayer,
|
|
@@ -58,7 +63,6 @@ import { FlowHandle } from "./flow-handle";
|
|
|
58
63
|
import { FlowMiniMap } from "./flow-minimap";
|
|
59
64
|
import { FlowNode } from "./flow-node";
|
|
60
65
|
import { createFlowRegistry, type FlowSnapshotEntry } from "./flow-registry";
|
|
61
|
-
import { AnimatedMatrixG, type FlowMatrixGProps } from "./flow-svg";
|
|
62
66
|
|
|
63
67
|
export type FlowProps = {
|
|
64
68
|
/** Where the canvas starts, when it does not fit on mount. */
|
|
@@ -88,15 +92,27 @@ export type FlowProps = {
|
|
|
88
92
|
|
|
89
93
|
type FlowLayers = Record<FlowChildLayer, ReactNode[]>;
|
|
90
94
|
|
|
95
|
+
/** The `displayName` of an element's component, or `undefined` for a host element. */
|
|
96
|
+
function displayNameOf(element: ReactElement): string | undefined {
|
|
97
|
+
return typeof element.type === "string" ? undefined : (element.type as { displayName?: string }).displayName;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The colour prop of a canvas child, read without trusting its shape. */
|
|
101
|
+
function colorOf(element: ReactNode): { displayName: string | undefined; color: string | undefined } {
|
|
102
|
+
if (!isValidElement<{ color?: unknown }>(element)) return { color: undefined, displayName: undefined };
|
|
103
|
+
const { color } = element.props;
|
|
104
|
+
return { color: typeof color === "string" ? color : undefined, displayName: displayNameOf(element) };
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
/**
|
|
92
|
-
* Sorts the canvas's children into the three layers they draw in:
|
|
108
|
+
* Sorts the canvas's children into the three layers they draw in: the Skia canvas beneath, nodes in the
|
|
93
109
|
* transformed view, chrome on top. Fragments and arrays are walked, so a `.map()` of edges works,
|
|
94
110
|
* and every child is re-keyed under its path so two `.map()`s sharing ids never collide in a layer.
|
|
95
111
|
*/
|
|
96
112
|
function sortLayers(
|
|
97
113
|
children: ReactNode,
|
|
98
114
|
prefix = "",
|
|
99
|
-
layers: FlowLayers = {
|
|
115
|
+
layers: FlowLayers = { canvas: [], nodes: [], overlay: [] }
|
|
100
116
|
): FlowLayers {
|
|
101
117
|
for (const child of Children.toArray(children)) {
|
|
102
118
|
if (!isValidElement<{ children?: ReactNode }>(child)) {
|
|
@@ -108,9 +124,7 @@ function sortLayers(
|
|
|
108
124
|
sortLayers(child.props.children, `${key}/`, layers);
|
|
109
125
|
continue;
|
|
110
126
|
}
|
|
111
|
-
|
|
112
|
-
typeof child.type === "string" ? undefined : (child.type as { displayName?: string }).displayName;
|
|
113
|
-
layers[resolveFlowChildLayer(displayName)].push(cloneElement(child, { key }));
|
|
127
|
+
layers[resolveFlowChildLayer(displayNameOf(child))].push(cloneElement(child, { key }));
|
|
114
128
|
}
|
|
115
129
|
return layers;
|
|
116
130
|
}
|
|
@@ -343,14 +357,18 @@ function FlowRoot({
|
|
|
343
357
|
transform: [{ translateX: viewport.value.x }, { translateY: viewport.value.y }, { scale: viewport.value.zoom }],
|
|
344
358
|
}));
|
|
345
359
|
|
|
346
|
-
const
|
|
360
|
+
const canvasTransform = useDerivedValue(() => {
|
|
347
361
|
const { x, y, zoom } = viewport.value;
|
|
348
|
-
return {
|
|
362
|
+
return [{ translateX: x }, { translateY: y }, { scale: zoom }];
|
|
349
363
|
});
|
|
350
364
|
|
|
351
365
|
const slots = flowVariants();
|
|
352
366
|
const layers = sortLayers(children);
|
|
353
367
|
|
|
368
|
+
const isCalm = useCalmMotion();
|
|
369
|
+
const colors = useThemeColors(collectFlowCanvasTokens(layers.canvas.map(colorOf)));
|
|
370
|
+
const canvasLayer = useMemo<FlowCanvasLayerValue>(() => ({ colors, isCalm }), [colors, isCalm]);
|
|
371
|
+
|
|
354
372
|
return (
|
|
355
373
|
<FlowProvider value={context}>
|
|
356
374
|
<GestureDetector gesture={canvas.composed}>
|
|
@@ -361,11 +379,13 @@ function FlowRoot({
|
|
|
361
379
|
importantForAccessibility="no-hide-descendants"
|
|
362
380
|
pointerEvents="none"
|
|
363
381
|
>
|
|
364
|
-
<
|
|
365
|
-
<
|
|
366
|
-
<
|
|
367
|
-
|
|
368
|
-
|
|
382
|
+
<Canvas style={StyleSheet.absoluteFill}>
|
|
383
|
+
<FlowProvider value={context}>
|
|
384
|
+
<FlowCanvasLayerProvider value={canvasLayer}>
|
|
385
|
+
<Group transform={canvasTransform}>{layers.canvas}</Group>
|
|
386
|
+
</FlowCanvasLayerProvider>
|
|
387
|
+
</FlowProvider>
|
|
388
|
+
</Canvas>
|
|
369
389
|
</View>
|
|
370
390
|
<Animated.View className={slots.layer()} pointerEvents="box-none" style={[LAYER_BOX, layerStyle]}>
|
|
371
391
|
{layers.nodes}
|
|
@@ -394,7 +414,7 @@ const LAYER_BOX = {
|
|
|
394
414
|
* `onNodeDragEnd` and `onConnect` and never changes the graph itself.
|
|
395
415
|
*
|
|
396
416
|
* Children draw in three layers whatever order they are written in: `Flow.Background` and every
|
|
397
|
-
* `Flow.Edge`
|
|
417
|
+
* `Flow.Edge` on a Skia canvas beneath the nodes; `Flow.Node` and `Flow.Group` in the transformed layer;
|
|
398
418
|
* `Flow.Controls` and `Flow.MiniMap` on top, unscaled. Edges and backgrounds must be direct
|
|
399
419
|
* children (or a `.map()` / fragment there) so the canvas can find them.
|
|
400
420
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
collectFlowCanvasTokens,
|
|
3
4
|
FLOW_ACCESSIBILITY_MOVES,
|
|
4
5
|
FLOW_BACKGROUND_VARIANTS,
|
|
5
6
|
FLOW_CONTROL_LABELS,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
resolveBackgroundPattern,
|
|
13
14
|
resolveDraggedConnection,
|
|
14
15
|
resolveEdgeDash,
|
|
16
|
+
resolveFlowCanvasToken,
|
|
15
17
|
resolveFlowChildLayer,
|
|
16
18
|
resolveHandleStyle,
|
|
17
19
|
resolveNodeAccessibilityActions,
|
|
@@ -113,25 +115,33 @@ describe("resolveBackgroundPattern", () => {
|
|
|
113
115
|
}
|
|
114
116
|
});
|
|
115
117
|
|
|
116
|
-
test("dots sit in the middle of each tile,
|
|
117
|
-
expect(resolveBackgroundPattern("dots", 24, 1.6)).toEqual({
|
|
118
|
+
test("dots sit in the middle of each tile, `size` is their radius", () => {
|
|
119
|
+
expect(resolveBackgroundPattern("dots", 24, 1.6)).toEqual({
|
|
120
|
+
arm: 0,
|
|
121
|
+
kind: "dots",
|
|
122
|
+
shape: 0,
|
|
123
|
+
tile: 24,
|
|
124
|
+
weight: 1.6,
|
|
125
|
+
});
|
|
118
126
|
});
|
|
119
127
|
|
|
120
|
-
test("lines
|
|
128
|
+
test("lines run along the tile edges, half a stroke of `size / 2` either side", () => {
|
|
121
129
|
expect(resolveBackgroundPattern("lines", 20, 2)).toEqual({
|
|
122
|
-
|
|
130
|
+
arm: 0,
|
|
123
131
|
kind: "lines",
|
|
124
|
-
|
|
132
|
+
shape: 1,
|
|
125
133
|
tile: 20,
|
|
134
|
+
weight: 0.5,
|
|
126
135
|
});
|
|
127
136
|
});
|
|
128
137
|
|
|
129
|
-
test("a cross is centred in its tile", () => {
|
|
138
|
+
test("a cross is centred in its tile, each arm a quarter of the tile", () => {
|
|
130
139
|
expect(resolveBackgroundPattern("cross", 24, 2)).toEqual({
|
|
131
|
-
|
|
140
|
+
arm: 6,
|
|
132
141
|
kind: "cross",
|
|
133
|
-
|
|
142
|
+
shape: 2,
|
|
134
143
|
tile: 24,
|
|
144
|
+
weight: 0.5,
|
|
135
145
|
});
|
|
136
146
|
});
|
|
137
147
|
|
|
@@ -140,6 +150,40 @@ describe("resolveBackgroundPattern", () => {
|
|
|
140
150
|
});
|
|
141
151
|
});
|
|
142
152
|
|
|
153
|
+
describe("resolveFlowCanvasToken", () => {
|
|
154
|
+
test("an edge defaults to muted-foreground, the background to border", () => {
|
|
155
|
+
expect(resolveFlowCanvasToken("DelacourUI.Flow.Edge", undefined)).toBe("muted-foreground");
|
|
156
|
+
expect(resolveFlowCanvasToken("DelacourUI.Flow.Background", undefined)).toBe("border");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("an explicit colour wins", () => {
|
|
160
|
+
expect(resolveFlowCanvasToken("DelacourUI.Flow.Edge", "#ff0000")).toBe("#ff0000");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("anything else names no colour", () => {
|
|
164
|
+
expect(resolveFlowCanvasToken("DelacourUI.Flow.Node", "primary")).toBeUndefined();
|
|
165
|
+
expect(resolveFlowCanvasToken(undefined, undefined)).toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("collectFlowCanvasTokens", () => {
|
|
170
|
+
test("deduplicated and sorted, so the lookup array is stable across renders", () => {
|
|
171
|
+
expect(
|
|
172
|
+
collectFlowCanvasTokens([
|
|
173
|
+
{ color: undefined, displayName: "DelacourUI.Flow.Edge" },
|
|
174
|
+
{ color: "primary", displayName: "DelacourUI.Flow.Edge" },
|
|
175
|
+
{ color: undefined, displayName: "DelacourUI.Flow.Edge" },
|
|
176
|
+
{ color: undefined, displayName: "DelacourUI.Flow.Background" },
|
|
177
|
+
{ color: "primary", displayName: "DelacourUI.Flow.Node" },
|
|
178
|
+
])
|
|
179
|
+
).toEqual(["border", "muted-foreground", "primary"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("empty for no canvas children", () => {
|
|
183
|
+
expect(collectFlowCanvasTokens([])).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
143
187
|
describe("resolveArrowSize", () => {
|
|
144
188
|
test("scales with the stroke, with a floor", () => {
|
|
145
189
|
expect(resolveArrowSize(2, true)).toBe(8);
|
|
@@ -354,9 +398,9 @@ describe("sides", () => {
|
|
|
354
398
|
});
|
|
355
399
|
|
|
356
400
|
describe("resolveFlowChildLayer", () => {
|
|
357
|
-
test("edges and the background draw in the
|
|
358
|
-
expect(resolveFlowChildLayer("DelacourUI.Flow.Edge")).toBe("
|
|
359
|
-
expect(resolveFlowChildLayer("DelacourUI.Flow.Background")).toBe("
|
|
401
|
+
test("edges and the background draw in the Skia canvas", () => {
|
|
402
|
+
expect(resolveFlowChildLayer("DelacourUI.Flow.Edge")).toBe("canvas");
|
|
403
|
+
expect(resolveFlowChildLayer("DelacourUI.Flow.Background")).toBe("canvas");
|
|
360
404
|
});
|
|
361
405
|
|
|
362
406
|
test("controls and the minimap float on top", () => {
|
|
@@ -114,16 +114,20 @@ export function resolveHandleStyle(side: FlowSide, offset: number): FlowHandleSt
|
|
|
114
114
|
return { ...margins, left: 0, top: along };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* One tile of the background, in graph units, as the shader draws it. `shape` is the shader's
|
|
119
|
+
* branch (0 dots, 1 lines, 2 cross); `weight` is a dot's radius or half a stroke; `arm` is half a
|
|
120
|
+
* cross's span.
|
|
121
|
+
*/
|
|
118
122
|
export type FlowBackgroundPattern =
|
|
119
123
|
| { kind: "none" }
|
|
120
|
-
| { kind: "dots";
|
|
121
|
-
| { kind: "lines"; tile: number;
|
|
122
|
-
| { kind: "cross"; tile: number;
|
|
124
|
+
| { kind: "dots"; shape: 0; tile: number; weight: number; arm: 0 }
|
|
125
|
+
| { kind: "lines"; shape: 1; tile: number; weight: number; arm: 0 }
|
|
126
|
+
| { kind: "cross"; shape: 2; tile: number; weight: number; arm: number };
|
|
123
127
|
|
|
124
128
|
/**
|
|
125
129
|
* The tile the background repeats. `size` is the mark's weight: a dot's radius, and twice a line's
|
|
126
|
-
* or a cross's stroke. Lines
|
|
130
|
+
* or a cross's stroke. Lines run along the tile's edges so neighbours join into a grid.
|
|
127
131
|
*/
|
|
128
132
|
export function resolveBackgroundPattern(
|
|
129
133
|
variant: FlowBackgroundVariant,
|
|
@@ -131,16 +135,9 @@ export function resolveBackgroundPattern(
|
|
|
131
135
|
size: number
|
|
132
136
|
): FlowBackgroundPattern {
|
|
133
137
|
if (variant === "none" || gap < 1) return { kind: "none" };
|
|
134
|
-
|
|
135
|
-
if (variant === "
|
|
136
|
-
|
|
137
|
-
const arm = gap / 4;
|
|
138
|
-
return {
|
|
139
|
-
d: `M${centre - arm} ${centre} H${centre + arm} M${centre} ${centre - arm} V${centre + arm}`,
|
|
140
|
-
kind: "cross",
|
|
141
|
-
strokeWidth: size / 2,
|
|
142
|
-
tile: gap,
|
|
143
|
-
};
|
|
138
|
+
if (variant === "dots") return { arm: 0, kind: "dots", shape: 0, tile: gap, weight: size };
|
|
139
|
+
if (variant === "lines") return { arm: 0, kind: "lines", shape: 1, tile: gap, weight: size / 4 };
|
|
140
|
+
return { arm: gap / 4, kind: "cross", shape: 2, tile: gap, weight: size / 4 };
|
|
144
141
|
}
|
|
145
142
|
|
|
146
143
|
/** An arrowhead is four strokes long, and never smaller than six points. */
|
|
@@ -296,9 +293,14 @@ export function resolveNodeAccessibilityActions({
|
|
|
296
293
|
return { actions, connections };
|
|
297
294
|
}
|
|
298
295
|
|
|
299
|
-
export type FlowChildLayer = "
|
|
296
|
+
export type FlowChildLayer = "canvas" | "nodes" | "overlay";
|
|
300
297
|
|
|
301
|
-
|
|
298
|
+
/** The colour each canvas child draws in when it names none. */
|
|
299
|
+
const FLOW_CANVAS_DEFAULT_TOKENS: Readonly<Record<string, string>> = {
|
|
300
|
+
"DelacourUI.Flow.Background": "border",
|
|
301
|
+
"DelacourUI.Flow.Edge": "muted-foreground",
|
|
302
|
+
};
|
|
303
|
+
const FLOW_CANVAS_CHILDREN: readonly string[] = Object.keys(FLOW_CANVAS_DEFAULT_TOKENS);
|
|
302
304
|
const FLOW_OVERLAY_CHILDREN: readonly string[] = ["DelacourUI.Flow.Controls", "DelacourUI.Flow.MiniMap"];
|
|
303
305
|
|
|
304
306
|
/**
|
|
@@ -311,7 +313,33 @@ const FLOW_OVERLAY_CHILDREN: readonly string[] = ["DelacourUI.Flow.Controls", "D
|
|
|
311
313
|
*/
|
|
312
314
|
export function resolveFlowChildLayer(displayName: string | undefined): FlowChildLayer {
|
|
313
315
|
if (displayName === undefined) return "nodes";
|
|
314
|
-
if (
|
|
316
|
+
if (FLOW_CANVAS_CHILDREN.includes(displayName)) return "canvas";
|
|
315
317
|
if (FLOW_OVERLAY_CHILDREN.includes(displayName)) return "overlay";
|
|
316
318
|
return "nodes";
|
|
317
319
|
}
|
|
320
|
+
|
|
321
|
+
/** The colour token a canvas child draws in — its own `color`, or its default. `undefined` off the canvas. */
|
|
322
|
+
export function resolveFlowCanvasToken(displayName: string | undefined, color: string | undefined): string | undefined {
|
|
323
|
+
if (displayName === undefined) return undefined;
|
|
324
|
+
const fallback = FLOW_CANVAS_DEFAULT_TOKENS[displayName];
|
|
325
|
+
if (fallback === undefined) return undefined;
|
|
326
|
+
return color ?? fallback;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Every colour the canvas's children draw in, deduplicated and sorted.
|
|
331
|
+
*
|
|
332
|
+
* The Skia canvas is a second React reconciler, so a theme hook called inside it reads no
|
|
333
|
+
* `ScopedTheme`. The root resolves this list above the canvas in one lookup and hands the result
|
|
334
|
+
* in; sorted, the list is equal across renders and the lookup does not re-subscribe.
|
|
335
|
+
*/
|
|
336
|
+
export function collectFlowCanvasTokens(
|
|
337
|
+
children: readonly { displayName: string | undefined; color: string | undefined }[]
|
|
338
|
+
): string[] {
|
|
339
|
+
const tokens = new Set<string>();
|
|
340
|
+
for (const child of children) {
|
|
341
|
+
const token = resolveFlowCanvasToken(child.displayName, child.color);
|
|
342
|
+
if (token !== undefined) tokens.add(token);
|
|
343
|
+
}
|
|
344
|
+
return [...tokens].sort();
|
|
345
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A freehand drawing surface with its tools around it: a pen, eight shapes, an
|
|
4
4
|
eraser, colour swatches, a stroke width and undo — delivered as SVG or PNG.
|
|
5
|
-
Drawn with Skia (
|
|
5
|
+
Drawn with Skia (`react-native-skia`, an optional peer), driven by one
|
|
6
6
|
Gesture Handler pan on the UI thread.
|
|
7
7
|
|
|
8
8
|
`import { Sketch } from "@delacour/react-native-ui/sketch";`
|
|
@@ -136,7 +136,11 @@ DisplayNames: `DelacourUI.Sketch`, `.Sketch.Canvas`, `.Toolbar`, `.Tool`,
|
|
|
136
136
|
- **`toSVG()` is vector and `toPNG()` is raster; both hold the marks and the
|
|
137
137
|
background only.** Neither includes what `Sketch.Canvas` children paint under
|
|
138
138
|
the marks.
|
|
139
|
-
-
|
|
139
|
+
- **The canvas imports `lib/skia-native-id` for its effect.** After a JS reload Skia reused the
|
|
140
|
+
old runtime's canvas ids, which its native registry still held as torn down, so a sketch came up
|
|
141
|
+
blank until something re-rendered it. The import moves this runtime's ids to a fresh range
|
|
142
|
+
before the canvas asks for one.
|
|
143
|
+
- **`toPNG()` renders off screen**, on a CPU surface (`Skia.Surface.MakeRaster`) sized
|
|
140
144
|
to the canvas times `scale` (default the screen's pixel ratio), by the same
|
|
141
145
|
`drawSketchItems` the picture records — not a snapshot of the visible canvas,
|
|
142
146
|
which would need the view mounted and would capture nothing of the background
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { Canvas, Group, Path, Picture, Skia } from "@shopify/react-native-skia";
|
|
2
1
|
import { type ReactElement, type ReactNode, useCallback, useEffect, useMemo } from "react";
|
|
3
2
|
import { type AccessibilityActionEvent, type LayoutChangeEvent, StyleSheet, View, type ViewProps } from "react-native";
|
|
4
3
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
5
4
|
import { useDerivedValue, useSharedValue } from "react-native-reanimated";
|
|
5
|
+
import { Canvas, Group, Path, Picture, Skia } from "react-native-skia";
|
|
6
6
|
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
|
7
|
+
import "../../lib/skia-native-id";
|
|
7
8
|
import { useSketchPart } from "./sketch.context";
|
|
8
9
|
import {
|
|
9
10
|
appendSketchSegment,
|
|
@@ -352,7 +352,7 @@ describe("the drawing surface is Skia", () => {
|
|
|
352
352
|
test("the canvas and the renderer import Skia and not react-native-svg", () => {
|
|
353
353
|
for (const file of ["sketch-canvas.tsx", "sketch.skia.ts"]) {
|
|
354
354
|
const source = readFileSync(join(import.meta.dirname, file), "utf-8");
|
|
355
|
-
expect(source).toContain('from "
|
|
355
|
+
expect(source).toContain('from "react-native-skia"');
|
|
356
356
|
expect(source).not.toContain("react-native-svg");
|
|
357
357
|
}
|
|
358
358
|
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type SkPicture,
|
|
8
8
|
StrokeCap,
|
|
9
9
|
StrokeJoin,
|
|
10
|
-
} from "
|
|
10
|
+
} from "react-native-skia";
|
|
11
11
|
import type { SketchItem } from "./sketch.paths";
|
|
12
12
|
|
|
13
13
|
/** What `drawSketchItems` paints into: the canvas size in points. */
|
|
@@ -77,7 +77,7 @@ export function renderSketchPng(items: readonly SketchItem[], target: SketchPngT
|
|
|
77
77
|
const pixelHeight = Math.round(target.height * target.scale);
|
|
78
78
|
if (pixelWidth <= 0 || pixelHeight <= 0) return "";
|
|
79
79
|
|
|
80
|
-
const surface = Skia.Surface.
|
|
80
|
+
const surface = Skia.Surface.MakeRaster(pixelWidth, pixelHeight);
|
|
81
81
|
if (surface === null) return "";
|
|
82
82
|
const canvas = surface.getCanvas();
|
|
83
83
|
canvas.scale(target.scale, target.scale);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
1
2
|
import { useCSSVariable } from "uniwind";
|
|
2
3
|
import { isLiteralColor, themeVariableName } from "../lib/color";
|
|
3
4
|
|
|
@@ -42,3 +43,27 @@ export function useThemeColor(token: string): string | undefined {
|
|
|
42
43
|
|
|
43
44
|
return isLiteral ? token : resolved;
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* {@link useThemeColor} for several tokens in one lookup, keyed by token.
|
|
49
|
+
*
|
|
50
|
+
* For a renderer that cannot call a hook where it draws — a Skia canvas is a second React
|
|
51
|
+
* reconciler, so a theme hook inside it reads no `ScopedTheme`. Resolve above the canvas, pass the
|
|
52
|
+
* record in. Hand it a list that is equal across renders (sorted, deduplicated) and the lookup
|
|
53
|
+
* does not re-subscribe.
|
|
54
|
+
*/
|
|
55
|
+
export function useThemeColors(tokens: readonly string[]): Readonly<Record<string, string | undefined>> {
|
|
56
|
+
const names = tokens.map((token) => (isLiteralColor(token) ? PROBE_VARIABLE : themeVariableName(token)));
|
|
57
|
+
const resolved = useCSSVariable(names.length > 0 ? names : [PROBE_VARIABLE]);
|
|
58
|
+
const key = tokens.join("\n");
|
|
59
|
+
|
|
60
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `key` stands for `tokens`, which a caller may rebuild every render.
|
|
61
|
+
return useMemo(() => {
|
|
62
|
+
const colors: Record<string, string | undefined> = {};
|
|
63
|
+
tokens.forEach((token, index) => {
|
|
64
|
+
const value = resolved[index];
|
|
65
|
+
colors[token] = isLiteralColor(token) ? token : typeof value === "string" ? value : undefined;
|
|
66
|
+
});
|
|
67
|
+
return colors;
|
|
68
|
+
}, [key, resolved]);
|
|
69
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveSkiaNativeId, SKIA_NATIVE_ID_FLOOR } from "./skia-native-id";
|
|
3
|
+
|
|
4
|
+
describe("resolveSkiaNativeId", () => {
|
|
5
|
+
test("moves a fresh runtime's counter off Skia's starting id", () => {
|
|
6
|
+
expect(resolveSkiaNativeId(1000, 0)).toBe(SKIA_NATIVE_ID_FLOOR);
|
|
7
|
+
expect(resolveSkiaNativeId(1000, 0.5)).toBeGreaterThan(SKIA_NATIVE_ID_FLOOR);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("keeps the ids a runtime already handed out behind the new counter", () => {
|
|
11
|
+
expect(resolveSkiaNativeId(1003, 0)).toBe(SKIA_NATIVE_ID_FLOOR + 3);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("leaves a counter that is already seeded alone, so two copies seed once", () => {
|
|
15
|
+
const seeded = resolveSkiaNativeId(1000, 0.25);
|
|
16
|
+
expect(resolveSkiaNativeId(seeded, 0.75)).toBe(seeded);
|
|
17
|
+
expect(resolveSkiaNativeId(seeded + 12, 0.75)).toBe(seeded + 12);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("spreads runtimes far enough apart that one's ids never reach the next's", () => {
|
|
21
|
+
const a = resolveSkiaNativeId(1000, 0.1);
|
|
22
|
+
const b = resolveSkiaNativeId(1000, 0.1 + 1 / 100_000);
|
|
23
|
+
expect(b - a).toBeGreaterThanOrEqual(10_000);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("stays inside a signed 32-bit int, which Skia's native side casts ids to", () => {
|
|
27
|
+
expect(resolveSkiaNativeId(1000, 0.999_999_999)).toBeLessThan(2 ** 31 - 1_000_000);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { SkiaViewNativeId } from "react-native-skia/src/views/SkiaViewNativeId";
|
|
2
|
+
|
|
3
|
+
/** Skia's counter starts at 1000; anything at or above this has been seeded. */
|
|
4
|
+
export const SKIA_NATIVE_ID_FLOOR = 10_000;
|
|
5
|
+
|
|
6
|
+
/** Ids a runtime may hand out before it reaches the next runtime's range. */
|
|
7
|
+
const SKIA_NATIVE_ID_SPAN = 10_000;
|
|
8
|
+
|
|
9
|
+
/** How many ranges a runtime can land in — 1e9 at the top, inside a signed 32-bit int. */
|
|
10
|
+
const SKIA_NATIVE_ID_SLOTS = 100_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where a JS runtime's Skia canvas ids should start, given the counter as it stands and a number
|
|
14
|
+
* in `[0, 1)`. A counter already at or above the floor is returned as it is, so every copy of this
|
|
15
|
+
* module in an app can run it and only the first moves anything.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveSkiaNativeId(current: number, random: number): number {
|
|
18
|
+
if (current >= SKIA_NATIVE_ID_FLOOR) return current;
|
|
19
|
+
const slot = Math.floor(random * SKIA_NATIVE_ID_SLOTS);
|
|
20
|
+
return SKIA_NATIVE_ID_FLOOR + slot * SKIA_NATIVE_ID_SPAN + (current - 1000);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Starts this runtime's canvas ids somewhere the last runtime's never were.
|
|
25
|
+
*
|
|
26
|
+
* **A canvas mounted after a JS reload drew nothing until it re-rendered.** Skia numbers canvases
|
|
27
|
+
* from a module-level counter that restarts at 1000 in every runtime, while its native
|
|
28
|
+
* `ViewRegistry` is a process-wide singleton that outlives the reload. The old runtime's views are
|
|
29
|
+
* torn down after the new one has started, and each leaves a tombstone for its id; the new
|
|
30
|
+
* runtime's first canvases reuse those ids, their pictures are handed to a throwaway entry, and the
|
|
31
|
+
* view registers to an empty one. Nothing draws until the next React commit sends the picture again.
|
|
32
|
+
* A dev-menu reload hits it, and so does `Updates.reloadAsync` after an OTA update.
|
|
33
|
+
* Reported upstream as wcandillon/react-native-skia#4312; drop this once a release fixes it.
|
|
34
|
+
*
|
|
35
|
+
* Evaluated once per runtime, on import, before any canvas has asked for an id. `SkiaViewNativeId`
|
|
36
|
+
* is not exported from the package root, so this reaches it by path — the same file Metro resolves
|
|
37
|
+
* `Canvas` from through the package's `react-native` field.
|
|
38
|
+
*
|
|
39
|
+
* `@delacour/react-native-charts` keeps an identical copy in `src/skia/native-id.ts` for its own
|
|
40
|
+
* canvases, since `Chart` draws through it; change both together.
|
|
41
|
+
*/
|
|
42
|
+
SkiaViewNativeId.current = resolveSkiaNativeId(SkiaViewNativeId.current, Math.random());
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type { ComponentClass } from "react";
|
|
2
|
-
import Animated from "react-native-reanimated";
|
|
3
|
-
import { G, type GProps } from "react-native-svg";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A `G`'s props plus the native `matrix` it is drawn with.
|
|
7
|
-
*
|
|
8
|
-
* `react-native-svg` folds `transform` into `matrix` on the JS side and sends only `matrix` to the
|
|
9
|
-
* native view, so an animated prop has to write `matrix` itself — a `transform` written from a
|
|
10
|
-
* worklet never passes through that fold. The public `GProps` does not declare it, hence the type.
|
|
11
|
-
*/
|
|
12
|
-
export type FlowMatrixGProps = GProps & { matrix?: number[] };
|
|
13
|
-
|
|
14
|
-
/** An animated `G` whose `matrix` a worklet can drive — the canvas's pan and zoom, in one prop. */
|
|
15
|
-
export const AnimatedMatrixG = Animated.createAnimatedComponent(G as ComponentClass<FlowMatrixGProps>);
|