@pinagent/react-native 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/src/native/Pinagent.tsx +156 -22
- package/src/native/brand.ts +15 -0
- package/src/native/inspector.ts +51 -7
- package/src/native/pin-icon.tsx +86 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pinagent/react-native",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "React Native & Expo plugin for Pinagent — tap a view, leave a comment, and a coding agent fixes it with file:line + a screenshot. The native widget ships as source for Metro; the dev-server middleware and Babel source-tagging plugin are built for Node.",
|
|
6
6
|
"keywords": [
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": ">=18",
|
|
51
51
|
"react-native": ">=0.74",
|
|
52
|
+
"react-native-svg": ">=13",
|
|
52
53
|
"react-native-view-shot": ">=3"
|
|
53
54
|
},
|
|
54
55
|
"peerDependenciesMeta": {
|
|
@@ -58,6 +59,9 @@
|
|
|
58
59
|
"react-native": {
|
|
59
60
|
"optional": true
|
|
60
61
|
},
|
|
62
|
+
"react-native-svg": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
61
65
|
"react-native-view-shot": {
|
|
62
66
|
"optional": true
|
|
63
67
|
}
|
package/src/native/Pinagent.tsx
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* analog. Renders `null` in production so it has zero cost in release
|
|
17
17
|
* builds.
|
|
18
18
|
*
|
|
19
|
-
* Flow: tap the
|
|
19
|
+
* Flow: tap the pin FAB to arm picking → tap a view → we resolve its source
|
|
20
20
|
* via the RN Inspector, hide our own overlay, and capture a screenshot →
|
|
21
21
|
* type a comment → submit POSTs to the Metro middleware, which stores it
|
|
22
22
|
* and (optionally) spawns an agent. When an agent is spawned, a live
|
|
@@ -33,8 +33,10 @@
|
|
|
33
33
|
import type { ReactElement } from 'react';
|
|
34
34
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
35
35
|
import {
|
|
36
|
+
Animated,
|
|
36
37
|
Keyboard,
|
|
37
38
|
Modal,
|
|
39
|
+
PanResponder,
|
|
38
40
|
Platform,
|
|
39
41
|
Pressable,
|
|
40
42
|
StyleSheet,
|
|
@@ -43,8 +45,10 @@ import {
|
|
|
43
45
|
useWindowDimensions,
|
|
44
46
|
View,
|
|
45
47
|
} from 'react-native';
|
|
48
|
+
import { BRAND_CREAM, BRAND_GOLD, BRAND_INK } from './brand';
|
|
46
49
|
import { resolvePick } from './inspector';
|
|
47
50
|
import { buildAdditionalAnchors, type ChipPick, removeChip } from './multi-pick';
|
|
51
|
+
import { PinIcon } from './pin-icon';
|
|
48
52
|
import { restorePills } from './restore';
|
|
49
53
|
import { StreamSheet } from './StreamSheet';
|
|
50
54
|
import { captureScreenshot } from './screenshot';
|
|
@@ -102,6 +106,109 @@ function useKeyboardHeight(): number {
|
|
|
102
106
|
return height;
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
const FAB_SIZE = 52;
|
|
110
|
+
// Resting insets matching the old fixed layout (right/bottom), plus a uniform
|
|
111
|
+
// edge margin used to keep the button on-screen once it's free to roam.
|
|
112
|
+
const FAB_MARGIN = 20;
|
|
113
|
+
const FAB_BOTTOM = 40;
|
|
114
|
+
// Movement (px) under which a press counts as a tap, not a drag.
|
|
115
|
+
const FAB_TAP_SLOP = 6;
|
|
116
|
+
|
|
117
|
+
interface DraggableFab {
|
|
118
|
+
panHandlers: ReturnType<typeof PanResponder.create>['panHandlers'];
|
|
119
|
+
transform: ReturnType<Animated.ValueXY['getTranslateTransform']>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Make the FAB draggable anywhere on screen.
|
|
124
|
+
*
|
|
125
|
+
* The button defaults to the bottom-right (matching the old fixed `right: 20,
|
|
126
|
+
* bottom: 40` layout) but can be dragged to any edge — handy when it sits over
|
|
127
|
+
* the very control the developer wants to comment on. A single PanResponder
|
|
128
|
+
* owns BOTH gestures: a stationary press (total movement under `FAB_TAP_SLOP`)
|
|
129
|
+
* fires `onTap` to arm picking, while any real movement relocates the button.
|
|
130
|
+
* Position lives in an `Animated.ValueXY` of the button's top-left in window
|
|
131
|
+
* coords; we keep a plain-object mirror (`committed`) because PanResponder
|
|
132
|
+
* callbacks can't read an Animated.Value synchronously.
|
|
133
|
+
*
|
|
134
|
+
* Position is session-local: RN keeps no device store (the dev-server DB is the
|
|
135
|
+
* source of truth and holds no ephemeral UI state), so it resets to the default
|
|
136
|
+
* corner on reload — same as the rest of the widget's transient UI.
|
|
137
|
+
*/
|
|
138
|
+
function useDraggableFab(width: number, height: number, onTap: () => void): DraggableFab {
|
|
139
|
+
// Clamp a top-left position so the whole button stays on-screen.
|
|
140
|
+
const clamp = useCallback(
|
|
141
|
+
(x: number, y: number) => {
|
|
142
|
+
const maxX = Math.max(FAB_MARGIN, width - FAB_SIZE - FAB_MARGIN);
|
|
143
|
+
const maxY = Math.max(FAB_MARGIN, height - FAB_SIZE - FAB_MARGIN);
|
|
144
|
+
return {
|
|
145
|
+
x: Math.min(Math.max(FAB_MARGIN, x), maxX),
|
|
146
|
+
y: Math.min(Math.max(FAB_MARGIN, y), maxY),
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
[width, height],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Default resting spot: bottom-right corner.
|
|
153
|
+
const home = useMemo(
|
|
154
|
+
() => clamp(width - FAB_SIZE - FAB_MARGIN, height - FAB_SIZE - FAB_BOTTOM),
|
|
155
|
+
[clamp, width, height],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const pos = useRef(new Animated.ValueXY(home)).current;
|
|
159
|
+
const committed = useRef(home);
|
|
160
|
+
|
|
161
|
+
// Keep the button on-screen across rotations / window-size changes.
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const next = clamp(committed.current.x, committed.current.y);
|
|
164
|
+
if (next.x !== committed.current.x || next.y !== committed.current.y) {
|
|
165
|
+
committed.current = next;
|
|
166
|
+
pos.setValue(next);
|
|
167
|
+
}
|
|
168
|
+
}, [clamp, pos]);
|
|
169
|
+
|
|
170
|
+
const responder = useMemo(
|
|
171
|
+
() =>
|
|
172
|
+
PanResponder.create({
|
|
173
|
+
// Claim the touch up front so a plain tap still reaches `onTap`; we
|
|
174
|
+
// discriminate tap vs drag by distance on release.
|
|
175
|
+
onStartShouldSetPanResponder: () => true,
|
|
176
|
+
onPanResponderGrant: () => {
|
|
177
|
+
// Drag relative to where the button currently rests.
|
|
178
|
+
pos.setOffset(committed.current);
|
|
179
|
+
pos.setValue({ x: 0, y: 0 });
|
|
180
|
+
},
|
|
181
|
+
onPanResponderMove: Animated.event([null, { dx: pos.x, dy: pos.y }], {
|
|
182
|
+
useNativeDriver: false,
|
|
183
|
+
}),
|
|
184
|
+
onPanResponderRelease: (_e, g) => {
|
|
185
|
+
pos.flattenOffset();
|
|
186
|
+
if (Math.abs(g.dx) <= FAB_TAP_SLOP && Math.abs(g.dy) <= FAB_TAP_SLOP) {
|
|
187
|
+
// No real movement → it was a tap; undo any sub-pixel drift.
|
|
188
|
+
pos.setValue(committed.current);
|
|
189
|
+
onTap();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// Commit the dragged spot, clamped on-screen, with a small settle.
|
|
193
|
+
const next = clamp(committed.current.x + g.dx, committed.current.y + g.dy);
|
|
194
|
+
committed.current = next;
|
|
195
|
+
Animated.spring(pos, { toValue: next, useNativeDriver: false, bounciness: 0 }).start();
|
|
196
|
+
},
|
|
197
|
+
onPanResponderTerminate: (_e, g) => {
|
|
198
|
+
// Lost the responder mid-gesture (e.g. to a parent scroll view):
|
|
199
|
+
// keep wherever the drag had reached rather than snapping away.
|
|
200
|
+
pos.flattenOffset();
|
|
201
|
+
const next = clamp(committed.current.x + g.dx, committed.current.y + g.dy);
|
|
202
|
+
committed.current = next;
|
|
203
|
+
pos.setValue(next);
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
[pos, clamp, onTap],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return { panHandlers: responder.panHandlers, transform: pos.getTranslateTransform() };
|
|
210
|
+
}
|
|
211
|
+
|
|
105
212
|
/**
|
|
106
213
|
* Hard dev-only gate. `__DEV__` is `false` in release bundles, so the
|
|
107
214
|
* whole widget — and its require()s into RN internals — drops out. Kept
|
|
@@ -160,6 +267,18 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
|
|
|
160
267
|
setExpandedId((cur) => (cur === id ? null : cur));
|
|
161
268
|
}, []);
|
|
162
269
|
|
|
270
|
+
// Tapping the FAB toggles picking; cancelling a pick also drops a pending
|
|
271
|
+
// "+ Add element" intent. Extracted so the draggable-FAB gesture can fire it
|
|
272
|
+
// on a stationary press (a drag relocates the button instead — see below).
|
|
273
|
+
const toggleFab = useCallback(() => {
|
|
274
|
+
setPhase((p) => {
|
|
275
|
+
if (p === 'picking') addingExtra.current = false;
|
|
276
|
+
return p === 'picking' ? 'idle' : 'picking';
|
|
277
|
+
});
|
|
278
|
+
}, []);
|
|
279
|
+
|
|
280
|
+
const fab = useDraggableFab(width, height, toggleFab);
|
|
281
|
+
|
|
163
282
|
// Restore minimized pills after an app reload (Fast Refresh, shake-reload,
|
|
164
283
|
// restart). The dev server (.pinagent/db.sqlite) is the source of truth — RN
|
|
165
284
|
// keeps no device-local store — so on mount we fetch the conversation list,
|
|
@@ -266,13 +385,15 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
|
|
|
266
385
|
|
|
267
386
|
// The source location the comment is currently anchored to: the precise
|
|
268
387
|
// tapped element while the innermost crumb is selected, otherwise the
|
|
269
|
-
// chosen ancestor component's own location.
|
|
388
|
+
// chosen ancestor component's own (nearest-source-resolved) location. Each
|
|
389
|
+
// ancestor falls back to the precise tapped loc so an untaggable crumb still
|
|
390
|
+
// shows a real path rather than degrading to a bare component name.
|
|
270
391
|
const activeLoc = useMemo(() => {
|
|
271
392
|
if (!pick) return null;
|
|
272
393
|
const last = pick.chain.length - 1;
|
|
273
394
|
if (selectedIndex >= 0 && selectedIndex < pick.chain.length) {
|
|
274
395
|
if (selectedIndex === last) return pick.loc ?? pick.chain[last]?.loc ?? null;
|
|
275
|
-
return pick.chain[selectedIndex]?.loc ?? null;
|
|
396
|
+
return pick.chain[selectedIndex]?.loc ?? pick.loc ?? null;
|
|
276
397
|
}
|
|
277
398
|
return pick.loc ?? null;
|
|
278
399
|
}, [pick, selectedIndex]);
|
|
@@ -537,22 +658,27 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
|
|
|
537
658
|
</View>
|
|
538
659
|
</Modal>
|
|
539
660
|
|
|
540
|
-
{/* Floating action button.
|
|
541
|
-
sending. Hidden during `capturing` so it stays
|
|
542
|
-
screenshot.
|
|
661
|
+
{/* Floating action button. Drag it anywhere; tap to toggle picking.
|
|
662
|
+
Shows status while sending. Hidden during `capturing` so it stays
|
|
663
|
+
out of the screenshot. Positioned via an animated translate so the
|
|
664
|
+
PanResponder can move it (see useDraggableFab). */}
|
|
543
665
|
{phase !== 'capturing' && (
|
|
544
|
-
<
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
666
|
+
<Animated.View
|
|
667
|
+
{...fab.panHandlers}
|
|
668
|
+
accessibilityRole="button"
|
|
669
|
+
accessibilityLabel="Pinagent — tap to comment, drag to move"
|
|
670
|
+
style={[
|
|
671
|
+
styles.fab,
|
|
672
|
+
phase === 'picking' && styles.fabActive,
|
|
673
|
+
{ transform: fab.transform },
|
|
674
|
+
]}
|
|
553
675
|
>
|
|
554
|
-
|
|
555
|
-
|
|
676
|
+
{phase === 'sending' ? (
|
|
677
|
+
<Text style={styles.fabText}>…</Text>
|
|
678
|
+
) : (
|
|
679
|
+
<PinIcon size={26} color={BRAND_CREAM} />
|
|
680
|
+
)}
|
|
681
|
+
</Animated.View>
|
|
556
682
|
)}
|
|
557
683
|
|
|
558
684
|
{toast && (
|
|
@@ -673,13 +799,20 @@ const styles = StyleSheet.create({
|
|
|
673
799
|
btnDisabled: { opacity: 0.4 },
|
|
674
800
|
btnPrimaryText: { color: '#fff', fontWeight: '600' },
|
|
675
801
|
fab: {
|
|
802
|
+
// Anchored top-left; the live position is applied via an animated
|
|
803
|
+
// translate so the FAB can be dragged (see useDraggableFab). FAB_SIZE
|
|
804
|
+
// must stay in sync with width/height below.
|
|
676
805
|
position: 'absolute',
|
|
677
|
-
|
|
678
|
-
|
|
806
|
+
left: 0,
|
|
807
|
+
top: 0,
|
|
679
808
|
width: 52,
|
|
680
809
|
height: 52,
|
|
681
810
|
borderRadius: 26,
|
|
682
|
-
backgroundColor:
|
|
811
|
+
backgroundColor: BRAND_INK,
|
|
812
|
+
// Constant-width rim so toggling the active gold ring never shifts layout;
|
|
813
|
+
// cream @ 14% is the same subtle idle rim the web widget FAB uses.
|
|
814
|
+
borderWidth: 2,
|
|
815
|
+
borderColor: 'rgba(252, 249, 232, 0.14)',
|
|
683
816
|
alignItems: 'center',
|
|
684
817
|
justifyContent: 'center',
|
|
685
818
|
shadowColor: '#000',
|
|
@@ -688,8 +821,9 @@ const styles = StyleSheet.create({
|
|
|
688
821
|
shadowOffset: { width: 0, height: 2 },
|
|
689
822
|
elevation: 5,
|
|
690
823
|
},
|
|
691
|
-
|
|
692
|
-
|
|
824
|
+
// Gold ring while picking (web widget parity) — replaces the old blue fill.
|
|
825
|
+
fabActive: { borderColor: BRAND_GOLD },
|
|
826
|
+
fabText: { fontSize: 22, color: BRAND_CREAM },
|
|
693
827
|
toast: {
|
|
694
828
|
position: 'absolute',
|
|
695
829
|
bottom: 110,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Pinagent brand palette for the React Native widget.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors BRAND_INK / BRAND_CREAM / BRAND_GOLD in `packages/ui/src/tokens.ts`. That
|
|
6
|
+
* package is web-only (React-DOM + Tailwind) so the native widget can't import
|
|
7
|
+
* it — keep these in sync if the brand palette changes there.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Dark charcoal — the FAB surface / ink-mode surfaces and primary text. */
|
|
11
|
+
export const BRAND_INK = '#201B21';
|
|
12
|
+
/** Warm cream — the pin mark on dark surfaces, light backgrounds, brand identity. */
|
|
13
|
+
export const BRAND_CREAM = '#FCF9E8';
|
|
14
|
+
/** Gold accent — focus rings; the active picking state. */
|
|
15
|
+
export const BRAND_GOLD = '#FFD700';
|
package/src/native/inspector.ts
CHANGED
|
@@ -282,19 +282,63 @@ interface RawCrumb {
|
|
|
282
282
|
measure?: (cb: RawMeasureCb) => void;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/**
|
|
286
|
+
* The nearest resolvable source to hierarchy index `i`, searching descendants
|
|
287
|
+
* first (deeper into the component, toward the tapped leaf) then ancestors
|
|
288
|
+
* (outward toward the root). Returns the first non-null `loc`, or null.
|
|
289
|
+
*
|
|
290
|
+
* Why descendants first: a crumb's own props come from RN's `getInspectorData`,
|
|
291
|
+
* which surfaces the first HOST fiber *inside* that component's render
|
|
292
|
+
* (`getHostProps`/`findCurrentHostFiber`). When that host carries no
|
|
293
|
+
* `data-pa-loc` (e.g. it's an untagged 3rd-party view), the most relevant real
|
|
294
|
+
* source is still the next tagged host *within* this component's subtree — a
|
|
295
|
+
* descendant — before we fall back to an enclosing ancestor.
|
|
296
|
+
*/
|
|
297
|
+
function nearestLoc(locs: (Loc | null)[], i: number): Loc | null {
|
|
298
|
+
for (let j = i + 1; j < locs.length; j++) {
|
|
299
|
+
if (locs[j]) return locs[j];
|
|
300
|
+
}
|
|
301
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
302
|
+
if (locs[j]) return locs[j];
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
285
307
|
/**
|
|
286
308
|
* Build the per-segment breadcrumb: each authored component in the hierarchy
|
|
287
309
|
* paired with its own `data-pa-loc` (so a press can re-anchor onto that
|
|
288
310
|
* ancestor) and a `measure` fn (so the highlight can follow the selection).
|
|
289
311
|
* Same order as {@link nameChainOf} — root first, tapped last.
|
|
312
|
+
*
|
|
313
|
+
* Each crumb's `loc` falls back to the nearest resolvable source in the
|
|
314
|
+
* hierarchy when its own host props carry none, so pressing ANY breadcrumb
|
|
315
|
+
* re-anchors the comment onto a real `file:line` — not just the tapped
|
|
316
|
+
* (innermost) one. Without the fallback, only the tapped element (which
|
|
317
|
+
* `pickLoc` resolves with its own hierarchy walk) got a path; ancestor crumbs
|
|
318
|
+
* whose first host child is untagged collapsed to `loc: null`, so re-focusing
|
|
319
|
+
* onto them showed a bare component name instead of a source snippet.
|
|
320
|
+
*
|
|
321
|
+
* Exported for unit testing — a pure function over the (duck-typed) inspector
|
|
322
|
+
* payload, so it needs no RN runtime (unlike `resolvePick`).
|
|
290
323
|
*/
|
|
291
|
-
function crumbsOf(data: RawInspectorData): RawCrumb[] {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
324
|
+
export function crumbsOf(data: RawInspectorData): RawCrumb[] {
|
|
325
|
+
// Resolve each hierarchy item's inspector data ONCE. We compute locs over the
|
|
326
|
+
// FULL hierarchy (not just the authored crumbs) so the nearest-source
|
|
327
|
+
// fallback can borrow a location from an untagged host sitting between two
|
|
328
|
+
// authored components.
|
|
329
|
+
const items = (data.hierarchy ?? []).map((h) => {
|
|
330
|
+
const inspector = h.getInspectorData?.(() => null);
|
|
331
|
+
return { name: h.name, loc: paLocOf(inspector?.props), measure: inspector?.measure };
|
|
332
|
+
});
|
|
333
|
+
const locs = items.map((it) => it.loc);
|
|
334
|
+
return items
|
|
335
|
+
.map((it, i) => ({ it, i }))
|
|
336
|
+
.filter(({ it }) => isAuthoredComponentName(it.name))
|
|
337
|
+
.map(({ it, i }) => ({
|
|
338
|
+
name: it.name as string,
|
|
339
|
+
loc: it.loc ?? nearestLoc(locs, i),
|
|
340
|
+
measure: it.measure,
|
|
341
|
+
}));
|
|
298
342
|
}
|
|
299
343
|
|
|
300
344
|
/**
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* PinIcon — pinagent's teardrop pin mark, for the React Native FAB.
|
|
4
|
+
*
|
|
5
|
+
* The mark is the canonical PIN_PATH / BRAND_VIEWBOX from `packages/ui/src/tokens.ts`
|
|
6
|
+
* (web-only, hence mirrored here). We draw it with `react-native-svg` when it's
|
|
7
|
+
* available — an OPTIONAL peer, lazily required exactly like
|
|
8
|
+
* `react-native-view-shot` in screenshot.ts, so a release build never pulls the
|
|
9
|
+
* native module in: the require only runs once <Pinagent/> actually renders,
|
|
10
|
+
* which it never does when `!__DEV__`.
|
|
11
|
+
*
|
|
12
|
+
* When the peer isn't installed we fall back to a View-drawn teardrop in the
|
|
13
|
+
* same colour, so the FAB always shows a brand pin — never a generic glyph.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactElement } from 'react';
|
|
16
|
+
import { View } from 'react-native';
|
|
17
|
+
|
|
18
|
+
// Canonical pinagent pin — mirror of PIN_PATH / BRAND_VIEWBOX in
|
|
19
|
+
// `packages/ui/src/tokens.ts`. Keep in sync if the mark changes there.
|
|
20
|
+
const PIN_PATH =
|
|
21
|
+
'M38.0761 27C24.2046 27 16.7486 43.8193 26.2852 53.7027L26.4587 53.8761L47.2659 74.6834L68.0732 53.8761L68.2466 53.7027C77.9567 43.8193 70.3273 27 56.4558 27L38.0761 27Z';
|
|
22
|
+
const VIEWBOX = '0 0 93 93';
|
|
23
|
+
|
|
24
|
+
// The two react-native-svg components we use, or `null` when the peer isn't
|
|
25
|
+
// installed. Typed loosely (the peer's types aren't a dep) — native/ isn't
|
|
26
|
+
// tsc-typechecked, and these are only ever rendered as JSX elements.
|
|
27
|
+
type SvgModule = { Svg: unknown; Path: unknown } | null;
|
|
28
|
+
|
|
29
|
+
// Resolved once, on first render (dev only — see header). `undefined` = not yet
|
|
30
|
+
// resolved, `null` = peer not installed.
|
|
31
|
+
let svgModule: SvgModule | undefined;
|
|
32
|
+
function resolveSvg(): SvgModule {
|
|
33
|
+
if (svgModule !== undefined) return svgModule;
|
|
34
|
+
try {
|
|
35
|
+
// Lazy require so a release build never pulls the native module in.
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
37
|
+
const mod = require('react-native-svg');
|
|
38
|
+
svgModule = { Svg: mod.Svg ?? mod.default, Path: mod.Path };
|
|
39
|
+
} catch {
|
|
40
|
+
svgModule = null;
|
|
41
|
+
}
|
|
42
|
+
return svgModule;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PinIconProps {
|
|
46
|
+
/** Square edge length, in px. */
|
|
47
|
+
size?: number;
|
|
48
|
+
/** Fill colour of the pin (pass a brand token). */
|
|
49
|
+
color: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function PinIcon({ size = 26, color }: PinIconProps): ReactElement {
|
|
53
|
+
const svg = resolveSvg();
|
|
54
|
+
if (svg?.Svg && svg.Path) {
|
|
55
|
+
const { Svg, Path } = svg;
|
|
56
|
+
return (
|
|
57
|
+
<Svg width={size} height={size} viewBox={VIEWBOX}>
|
|
58
|
+
<Path d={PIN_PATH} fill={color} />
|
|
59
|
+
</Svg>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return <FallbackPin size={size} color={color} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* react-native-svg-free fallback: a map-pin teardrop drawn from a single View —
|
|
67
|
+
* a rounded square with one squared corner, rotated so the point faces down.
|
|
68
|
+
* The classic CSS `border-radius: 50% 50% 50% 0` recipe, in the brand colour.
|
|
69
|
+
*/
|
|
70
|
+
function FallbackPin({ size, color }: { size: number; color: string }): ReactElement {
|
|
71
|
+
const d = Math.round(size * 0.8);
|
|
72
|
+
return (
|
|
73
|
+
<View
|
|
74
|
+
style={{
|
|
75
|
+
width: d,
|
|
76
|
+
height: d,
|
|
77
|
+
backgroundColor: color,
|
|
78
|
+
borderTopLeftRadius: d / 2,
|
|
79
|
+
borderTopRightRadius: d / 2,
|
|
80
|
+
borderBottomRightRadius: d / 2,
|
|
81
|
+
borderBottomLeftRadius: 0,
|
|
82
|
+
transform: [{ rotate: '-45deg' }],
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|