@bsky.app/peek-menu 0.2.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/.eslintrc.js +5 -0
- package/CHANGELOG.md +7 -0
- package/README.md +121 -0
- package/build/ExpoContextMenuNativeView.android.d.ts +6 -0
- package/build/ExpoContextMenuNativeView.android.d.ts.map +1 -0
- package/build/ExpoContextMenuNativeView.android.js +9 -0
- package/build/ExpoContextMenuNativeView.android.js.map +1 -0
- package/build/ExpoContextMenuNativeView.d.ts +5 -0
- package/build/ExpoContextMenuNativeView.d.ts.map +1 -0
- package/build/ExpoContextMenuNativeView.js +4 -0
- package/build/ExpoContextMenuNativeView.js.map +1 -0
- package/build/ExpoContextMenuNativeView.web.d.ts +7 -0
- package/build/ExpoContextMenuNativeView.web.d.ts.map +1 -0
- package/build/ExpoContextMenuNativeView.web.js +10 -0
- package/build/ExpoContextMenuNativeView.web.js.map +1 -0
- package/build/Menu.d.ts +6 -0
- package/build/Menu.d.ts.map +1 -0
- package/build/Menu.js +10 -0
- package/build/Menu.js.map +1 -0
- package/build/MenuItem.d.ts +11 -0
- package/build/MenuItem.d.ts.map +1 -0
- package/build/MenuItem.js +10 -0
- package/build/MenuItem.js.map +1 -0
- package/build/MenuItemIcon.d.ts +6 -0
- package/build/MenuItemIcon.d.ts.map +1 -0
- package/build/MenuItemIcon.js +11 -0
- package/build/MenuItemIcon.js.map +1 -0
- package/build/MenuItemText.d.ts +5 -0
- package/build/MenuItemText.d.ts.map +1 -0
- package/build/MenuItemText.js +11 -0
- package/build/MenuItemText.js.map +1 -0
- package/build/Root.d.ts +8 -0
- package/build/Root.d.ts.map +1 -0
- package/build/Root.js +81 -0
- package/build/Root.js.map +1 -0
- package/build/Trigger.d.ts +15 -0
- package/build/Trigger.d.ts.map +1 -0
- package/build/Trigger.js +10 -0
- package/build/Trigger.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/build/registry.d.ts +13 -0
- package/build/registry.d.ts.map +1 -0
- package/build/registry.js +18 -0
- package/build/registry.js.map +1 -0
- package/build/types.d.ts +70 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/ios/ExpoBlueskyPeekMenu.podspec +22 -0
- package/ios/ExpoBlueskyPeekMenuModule.swift +24 -0
- package/ios/ExpoBlueskyPeekMenuView.swift +111 -0
- package/ios/IconRenderer.swift +69 -0
- package/ios/ImagePreviewController.swift +133 -0
- package/ios/MenuBuilder.swift +51 -0
- package/ios/PreviewFactory.swift +27 -0
- package/ios/SVGPathParser.swift +320 -0
- package/package.json +38 -0
- package/src/ExpoContextMenuNativeView.android.tsx +10 -0
- package/src/ExpoContextMenuNativeView.tsx +10 -0
- package/src/ExpoContextMenuNativeView.web.tsx +11 -0
- package/src/Menu.tsx +17 -0
- package/src/MenuItem.tsx +22 -0
- package/src/MenuItemIcon.tsx +17 -0
- package/src/MenuItemText.tsx +16 -0
- package/src/Root.tsx +119 -0
- package/src/Trigger.tsx +26 -0
- package/src/index.ts +12 -0
- package/src/registry.ts +32 -0
- package/src/types.ts +71 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Minimal SVG path `d` parser. Handles the subset used by Bluesky's icon set:
|
|
4
|
+
/// M m L l H h V v C c S s Q q T t A a Z z.
|
|
5
|
+
/// Assumes well-formed input from the app's own compiled-in icon paths.
|
|
6
|
+
enum SVGPathParser {
|
|
7
|
+
static func parse(_ d: String) -> UIBezierPath {
|
|
8
|
+
let path = UIBezierPath()
|
|
9
|
+
var tokens = Tokenizer(d)
|
|
10
|
+
var currentPoint = CGPoint.zero
|
|
11
|
+
var subpathStart = CGPoint.zero
|
|
12
|
+
var lastControl: CGPoint?
|
|
13
|
+
var lastQuadControl: CGPoint?
|
|
14
|
+
var command: Character = "M"
|
|
15
|
+
|
|
16
|
+
while let next = tokens.peek() {
|
|
17
|
+
if next.isLetter {
|
|
18
|
+
command = next
|
|
19
|
+
tokens.consume()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
switch command {
|
|
23
|
+
case "M", "m":
|
|
24
|
+
let p = tokens.readPoint()
|
|
25
|
+
let abs = command == "M" ? p : CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
|
|
26
|
+
path.move(to: abs)
|
|
27
|
+
currentPoint = abs
|
|
28
|
+
subpathStart = abs
|
|
29
|
+
lastControl = nil
|
|
30
|
+
lastQuadControl = nil
|
|
31
|
+
// Subsequent coordinate pairs after M/m are implicit L/l
|
|
32
|
+
command = command == "M" ? "L" : "l"
|
|
33
|
+
|
|
34
|
+
case "L", "l":
|
|
35
|
+
let p = tokens.readPoint()
|
|
36
|
+
let abs = command == "L" ? p : CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
|
|
37
|
+
path.addLine(to: abs)
|
|
38
|
+
currentPoint = abs
|
|
39
|
+
lastControl = nil
|
|
40
|
+
lastQuadControl = nil
|
|
41
|
+
|
|
42
|
+
case "H", "h":
|
|
43
|
+
let x = tokens.readNumber()
|
|
44
|
+
let abs = command == "H" ? CGPoint(x: x, y: currentPoint.y) : CGPoint(x: currentPoint.x + x, y: currentPoint.y)
|
|
45
|
+
path.addLine(to: abs)
|
|
46
|
+
currentPoint = abs
|
|
47
|
+
lastControl = nil
|
|
48
|
+
lastQuadControl = nil
|
|
49
|
+
|
|
50
|
+
case "V", "v":
|
|
51
|
+
let y = tokens.readNumber()
|
|
52
|
+
let abs = command == "V" ? CGPoint(x: currentPoint.x, y: y) : CGPoint(x: currentPoint.x, y: currentPoint.y + y)
|
|
53
|
+
path.addLine(to: abs)
|
|
54
|
+
currentPoint = abs
|
|
55
|
+
lastControl = nil
|
|
56
|
+
lastQuadControl = nil
|
|
57
|
+
|
|
58
|
+
case "C", "c":
|
|
59
|
+
let c1 = tokens.readPoint()
|
|
60
|
+
let c2 = tokens.readPoint()
|
|
61
|
+
let p = tokens.readPoint()
|
|
62
|
+
let (ac1, ac2, ap): (CGPoint, CGPoint, CGPoint)
|
|
63
|
+
if command == "C" {
|
|
64
|
+
ac1 = c1; ac2 = c2; ap = p
|
|
65
|
+
} else {
|
|
66
|
+
ac1 = CGPoint(x: currentPoint.x + c1.x, y: currentPoint.y + c1.y)
|
|
67
|
+
ac2 = CGPoint(x: currentPoint.x + c2.x, y: currentPoint.y + c2.y)
|
|
68
|
+
ap = CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
|
|
69
|
+
}
|
|
70
|
+
path.addCurve(to: ap, controlPoint1: ac1, controlPoint2: ac2)
|
|
71
|
+
currentPoint = ap
|
|
72
|
+
lastControl = ac2
|
|
73
|
+
lastQuadControl = nil
|
|
74
|
+
|
|
75
|
+
case "S", "s":
|
|
76
|
+
let c2 = tokens.readPoint()
|
|
77
|
+
let p = tokens.readPoint()
|
|
78
|
+
let reflected = lastControl.map {
|
|
79
|
+
CGPoint(x: 2 * currentPoint.x - $0.x, y: 2 * currentPoint.y - $0.y)
|
|
80
|
+
} ?? currentPoint
|
|
81
|
+
let (ac2, ap): (CGPoint, CGPoint)
|
|
82
|
+
if command == "S" {
|
|
83
|
+
ac2 = c2; ap = p
|
|
84
|
+
} else {
|
|
85
|
+
ac2 = CGPoint(x: currentPoint.x + c2.x, y: currentPoint.y + c2.y)
|
|
86
|
+
ap = CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
|
|
87
|
+
}
|
|
88
|
+
path.addCurve(to: ap, controlPoint1: reflected, controlPoint2: ac2)
|
|
89
|
+
currentPoint = ap
|
|
90
|
+
lastControl = ac2
|
|
91
|
+
lastQuadControl = nil
|
|
92
|
+
|
|
93
|
+
case "Q", "q":
|
|
94
|
+
let c = tokens.readPoint()
|
|
95
|
+
let p = tokens.readPoint()
|
|
96
|
+
let (ac, ap): (CGPoint, CGPoint)
|
|
97
|
+
if command == "Q" {
|
|
98
|
+
ac = c; ap = p
|
|
99
|
+
} else {
|
|
100
|
+
ac = CGPoint(x: currentPoint.x + c.x, y: currentPoint.y + c.y)
|
|
101
|
+
ap = CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
|
|
102
|
+
}
|
|
103
|
+
path.addQuadCurve(to: ap, controlPoint: ac)
|
|
104
|
+
currentPoint = ap
|
|
105
|
+
lastControl = nil
|
|
106
|
+
lastQuadControl = ac
|
|
107
|
+
|
|
108
|
+
case "T", "t":
|
|
109
|
+
let p = tokens.readPoint()
|
|
110
|
+
let reflected = lastQuadControl.map {
|
|
111
|
+
CGPoint(x: 2 * currentPoint.x - $0.x, y: 2 * currentPoint.y - $0.y)
|
|
112
|
+
} ?? currentPoint
|
|
113
|
+
let ap = command == "T" ? p : CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
|
|
114
|
+
path.addQuadCurve(to: ap, controlPoint: reflected)
|
|
115
|
+
currentPoint = ap
|
|
116
|
+
lastControl = nil
|
|
117
|
+
lastQuadControl = reflected
|
|
118
|
+
|
|
119
|
+
case "A", "a":
|
|
120
|
+
let rx = tokens.readNumber()
|
|
121
|
+
let ry = tokens.readNumber()
|
|
122
|
+
let xAxisRotation = tokens.readNumber() * .pi / 180
|
|
123
|
+
let largeArc = tokens.readNumber() != 0
|
|
124
|
+
let sweep = tokens.readNumber() != 0
|
|
125
|
+
let end = tokens.readPoint()
|
|
126
|
+
let absEnd = command == "A" ? end : CGPoint(x: currentPoint.x + end.x, y: currentPoint.y + end.y)
|
|
127
|
+
ArcBuilder.addArc(
|
|
128
|
+
to: path,
|
|
129
|
+
from: currentPoint,
|
|
130
|
+
to: absEnd,
|
|
131
|
+
rx: rx,
|
|
132
|
+
ry: ry,
|
|
133
|
+
xAxisRotation: xAxisRotation,
|
|
134
|
+
largeArc: largeArc,
|
|
135
|
+
sweep: sweep
|
|
136
|
+
)
|
|
137
|
+
currentPoint = absEnd
|
|
138
|
+
lastControl = nil
|
|
139
|
+
lastQuadControl = nil
|
|
140
|
+
|
|
141
|
+
case "Z", "z":
|
|
142
|
+
path.close()
|
|
143
|
+
currentPoint = subpathStart
|
|
144
|
+
lastControl = nil
|
|
145
|
+
lastQuadControl = nil
|
|
146
|
+
|
|
147
|
+
default:
|
|
148
|
+
tokens.consume()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return path
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private struct Tokenizer {
|
|
157
|
+
private let chars: [Character]
|
|
158
|
+
private var index = 0
|
|
159
|
+
|
|
160
|
+
init(_ s: String) { self.chars = Array(s) }
|
|
161
|
+
|
|
162
|
+
mutating func peek() -> Character? {
|
|
163
|
+
skipSeparators()
|
|
164
|
+
return index < chars.count ? chars[index] : nil
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
mutating func consume() {
|
|
168
|
+
if index < chars.count { index += 1 }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
mutating func readNumber() -> CGFloat {
|
|
172
|
+
skipSeparators()
|
|
173
|
+
let start = index
|
|
174
|
+
var sawDot = false
|
|
175
|
+
var sawE = false
|
|
176
|
+
while index < chars.count {
|
|
177
|
+
let c = chars[index]
|
|
178
|
+
if index == start && (c == "+" || c == "-") {
|
|
179
|
+
index += 1
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if c == "." {
|
|
183
|
+
if sawDot || sawE { break }
|
|
184
|
+
sawDot = true
|
|
185
|
+
index += 1
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
if c == "e" || c == "E" {
|
|
189
|
+
if sawE { break }
|
|
190
|
+
sawE = true
|
|
191
|
+
index += 1
|
|
192
|
+
if index < chars.count && (chars[index] == "+" || chars[index] == "-") {
|
|
193
|
+
index += 1
|
|
194
|
+
}
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
if c.isNumber {
|
|
198
|
+
index += 1
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
let slice = String(chars[start..<index])
|
|
204
|
+
return CGFloat(Double(slice) ?? 0)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
mutating func readPoint() -> CGPoint {
|
|
208
|
+
let x = readNumber()
|
|
209
|
+
let y = readNumber()
|
|
210
|
+
return CGPoint(x: x, y: y)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private mutating func skipSeparators() {
|
|
214
|
+
while index < chars.count {
|
|
215
|
+
let c = chars[index]
|
|
216
|
+
if c == " " || c == "," || c == "\t" || c == "\n" || c == "\r" {
|
|
217
|
+
index += 1
|
|
218
|
+
} else {
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private enum ArcBuilder {
|
|
226
|
+
/// Converts an SVG elliptical arc to a series of cubic Bezier segments and
|
|
227
|
+
/// appends them to the given path. Based on the W3C "Elliptical Arc
|
|
228
|
+
/// Implementation Notes" conversion.
|
|
229
|
+
static func addArc(
|
|
230
|
+
to path: UIBezierPath,
|
|
231
|
+
from start: CGPoint,
|
|
232
|
+
to end: CGPoint,
|
|
233
|
+
rx rxIn: CGFloat,
|
|
234
|
+
ry ryIn: CGFloat,
|
|
235
|
+
xAxisRotation phi: CGFloat,
|
|
236
|
+
largeArc: Bool,
|
|
237
|
+
sweep: Bool
|
|
238
|
+
) {
|
|
239
|
+
if start == end { return }
|
|
240
|
+
if rxIn == 0 || ryIn == 0 {
|
|
241
|
+
path.addLine(to: end)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
var rx = abs(rxIn)
|
|
246
|
+
var ry = abs(ryIn)
|
|
247
|
+
let cosPhi = cos(phi)
|
|
248
|
+
let sinPhi = sin(phi)
|
|
249
|
+
|
|
250
|
+
let dx = (start.x - end.x) / 2
|
|
251
|
+
let dy = (start.y - end.y) / 2
|
|
252
|
+
let x1p = cosPhi * dx + sinPhi * dy
|
|
253
|
+
let y1p = -sinPhi * dx + cosPhi * dy
|
|
254
|
+
|
|
255
|
+
let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
|
|
256
|
+
if lambda > 1 {
|
|
257
|
+
let s = sqrt(lambda)
|
|
258
|
+
rx *= s
|
|
259
|
+
ry *= s
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let sign: CGFloat = (largeArc == sweep) ? -1 : 1
|
|
263
|
+
let numerator = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
|
|
264
|
+
let denominator = rx * rx * y1p * y1p + ry * ry * x1p * x1p
|
|
265
|
+
let factor = sign * sqrt(max(0, numerator / denominator))
|
|
266
|
+
let cxp = factor * (rx * y1p / ry)
|
|
267
|
+
let cyp = factor * (-ry * x1p / rx)
|
|
268
|
+
|
|
269
|
+
let cx = cosPhi * cxp - sinPhi * cyp + (start.x + end.x) / 2
|
|
270
|
+
let cy = sinPhi * cxp + cosPhi * cyp + (start.y + end.y) / 2
|
|
271
|
+
|
|
272
|
+
let startVec = CGPoint(x: (x1p - cxp) / rx, y: (y1p - cyp) / ry)
|
|
273
|
+
let endVec = CGPoint(x: (-x1p - cxp) / rx, y: (-y1p - cyp) / ry)
|
|
274
|
+
let theta1 = angle(from: CGPoint(x: 1, y: 0), to: startVec)
|
|
275
|
+
var deltaTheta = angle(from: startVec, to: endVec)
|
|
276
|
+
if !sweep && deltaTheta > 0 {
|
|
277
|
+
deltaTheta -= 2 * .pi
|
|
278
|
+
} else if sweep && deltaTheta < 0 {
|
|
279
|
+
deltaTheta += 2 * .pi
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Split into up to 4 cubic beziers (each covering <= 90°).
|
|
283
|
+
let segments = max(1, Int(ceil(abs(deltaTheta) / (.pi / 2))))
|
|
284
|
+
let delta = deltaTheta / CGFloat(segments)
|
|
285
|
+
let t = (4.0 / 3.0) * tan(delta / 4)
|
|
286
|
+
|
|
287
|
+
var theta = theta1
|
|
288
|
+
for _ in 0..<segments {
|
|
289
|
+
let cosT = cos(theta)
|
|
290
|
+
let sinT = sin(theta)
|
|
291
|
+
let cosT2 = cos(theta + delta)
|
|
292
|
+
let sinT2 = sin(theta + delta)
|
|
293
|
+
|
|
294
|
+
let p1 = CGPoint(x: cosT - t * sinT, y: sinT + t * cosT)
|
|
295
|
+
let p2 = CGPoint(x: cosT2 + t * sinT2, y: sinT2 - t * cosT2)
|
|
296
|
+
let p3 = CGPoint(x: cosT2, y: sinT2)
|
|
297
|
+
|
|
298
|
+
let c1 = transformEllipsePoint(p1, rx: rx, ry: ry, phi: phi, cx: cx, cy: cy)
|
|
299
|
+
let c2 = transformEllipsePoint(p2, rx: rx, ry: ry, phi: phi, cx: cx, cy: cy)
|
|
300
|
+
let c3 = transformEllipsePoint(p3, rx: rx, ry: ry, phi: phi, cx: cx, cy: cy)
|
|
301
|
+
|
|
302
|
+
path.addCurve(to: c3, controlPoint1: c1, controlPoint2: c2)
|
|
303
|
+
theta += delta
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private static func transformEllipsePoint(_ p: CGPoint, rx: CGFloat, ry: CGFloat, phi: CGFloat, cx: CGFloat, cy: CGFloat) -> CGPoint {
|
|
308
|
+
let x = rx * p.x
|
|
309
|
+
let y = ry * p.y
|
|
310
|
+
let rx_ = cos(phi) * x - sin(phi) * y + cx
|
|
311
|
+
let ry_ = sin(phi) * x + cos(phi) * y + cy
|
|
312
|
+
return CGPoint(x: rx_, y: ry_)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private static func angle(from u: CGPoint, to v: CGPoint) -> CGFloat {
|
|
316
|
+
let dot = u.x * v.x + u.y * v.y
|
|
317
|
+
let det = u.x * v.y - u.y * v.x
|
|
318
|
+
return atan2(det, dot)
|
|
319
|
+
}
|
|
320
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bsky.app/peek-menu",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Native iOS context menu with peek preview for images.",
|
|
6
|
+
"repository": "https://github.com/bluesky-social/toolbox",
|
|
7
|
+
"author": "Bluesky Social PBC <hello@blueskyweb.xyz>",
|
|
8
|
+
"homepage": "https://github.com/bluesky-social/toolbox/tree/main/packages/peek-menu",
|
|
9
|
+
"main": "build/index.js",
|
|
10
|
+
"types": "build/index.d.ts",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"react-native",
|
|
13
|
+
"expo",
|
|
14
|
+
"context-menu",
|
|
15
|
+
"peek",
|
|
16
|
+
"ios"
|
|
17
|
+
],
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/react": "~19.1.1",
|
|
20
|
+
"expo": "^55.0.8",
|
|
21
|
+
"expo-module-scripts": "^55.0.2",
|
|
22
|
+
"expo-modules-core": "^55.0.22",
|
|
23
|
+
"react-native": "0.82.1",
|
|
24
|
+
"vitest": "^3.1.1"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"expo": "*",
|
|
28
|
+
"react": "*",
|
|
29
|
+
"react-native": "*"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "expo-module clean && EXPO_NONINTERACTIVE=1 expo-module build",
|
|
33
|
+
"clean": "expo-module clean",
|
|
34
|
+
"lint": "expo-module lint",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"expo-module": "expo-module"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import {View} from 'react-native'
|
|
2
|
+
|
|
3
|
+
import {type NativeViewProps} from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Android fallback: passthrough wrapper with no context menu interaction.
|
|
7
|
+
*/
|
|
8
|
+
export default function NativeView({children, style}: NativeViewProps) {
|
|
9
|
+
return <View style={style}>{children}</View>
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import {type ComponentType} from 'react'
|
|
2
|
+
import {requireNativeViewManager} from 'expo-modules-core'
|
|
3
|
+
|
|
4
|
+
import {type NativeViewProps} from './types'
|
|
5
|
+
|
|
6
|
+
const NativeView: ComponentType<NativeViewProps> = requireNativeViewManager(
|
|
7
|
+
'ExpoBlueskyPeekMenu',
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
export default NativeView
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {View} from 'react-native'
|
|
2
|
+
|
|
3
|
+
import {type NativeViewProps} from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Web fallback: passthrough. Long-press is a no-op; tap handling is delegated
|
|
7
|
+
* to children.
|
|
8
|
+
*/
|
|
9
|
+
export default function NativeView({children, style}: NativeViewProps) {
|
|
10
|
+
return <View style={style}>{children}</View>
|
|
11
|
+
}
|
package/src/Menu.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {type ReactNode} from 'react'
|
|
2
|
+
|
|
3
|
+
import {tag} from './registry'
|
|
4
|
+
|
|
5
|
+
export type MenuProps = {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sentinel: does not render. `Root` reads this element's children to collect
|
|
11
|
+
* menu items.
|
|
12
|
+
*/
|
|
13
|
+
function MenuImpl(_: MenuProps): null {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Menu = tag(MenuImpl, 'menu')
|
package/src/MenuItem.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {type ReactNode} from 'react'
|
|
2
|
+
|
|
3
|
+
import {tag} from './registry'
|
|
4
|
+
|
|
5
|
+
export type MenuItemProps = {
|
|
6
|
+
id: string
|
|
7
|
+
destructive?: boolean
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
onSelect: () => void
|
|
10
|
+
/** Children must include a `MenuItemIcon` and a `MenuItemText`. */
|
|
11
|
+
children: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sentinel: does not render. `Root` walks the children tree to extract icon +
|
|
16
|
+
* label, then ships a plain menu item spec to native.
|
|
17
|
+
*/
|
|
18
|
+
function MenuItemImpl(_: MenuItemProps): null {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const MenuItem = tag(MenuItemImpl, 'item')
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {tag} from './registry'
|
|
2
|
+
import {type MenuItemIconSource} from './types'
|
|
3
|
+
|
|
4
|
+
export type MenuItemIconProps = {
|
|
5
|
+
icon: MenuItemIconSource
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sentinel: does not render any React output. `Root` introspects this element
|
|
10
|
+
* during its collection pass to pull the SVG path data off the icon component,
|
|
11
|
+
* then ships the data to native.
|
|
12
|
+
*/
|
|
13
|
+
function MenuItemIconImpl(_: MenuItemIconProps): null {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const MenuItemIcon = tag(MenuItemIconImpl, 'item-icon')
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {tag} from './registry'
|
|
2
|
+
|
|
3
|
+
export type MenuItemTextProps = {
|
|
4
|
+
children: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sentinel: does not render. `Root` reads `children` as the menu item label.
|
|
9
|
+
* Keeping this a sentinel (vs. a real Text) mirrors how `Menu.ItemText` is
|
|
10
|
+
* used elsewhere while letting iOS draw the menu chrome natively.
|
|
11
|
+
*/
|
|
12
|
+
function MenuItemTextImpl(_: MenuItemTextProps): null {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const MenuItemText = tag(MenuItemTextImpl, 'item-text')
|
package/src/Root.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
isValidElement,
|
|
4
|
+
type ReactElement,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useMemo,
|
|
8
|
+
} from 'react'
|
|
9
|
+
import {type StyleProp, type ViewStyle} from 'react-native'
|
|
10
|
+
|
|
11
|
+
import NativeView from './ExpoContextMenuNativeView'
|
|
12
|
+
import {type MenuProps} from './Menu'
|
|
13
|
+
import {type MenuItemProps} from './MenuItem'
|
|
14
|
+
import {type MenuItemIconProps} from './MenuItemIcon'
|
|
15
|
+
import {type MenuItemTextProps} from './MenuItemText'
|
|
16
|
+
import {kindOf} from './registry'
|
|
17
|
+
import {type TriggerProps} from './Trigger'
|
|
18
|
+
import {type MenuItemSpec} from './types'
|
|
19
|
+
|
|
20
|
+
export type RootProps = {
|
|
21
|
+
children: ReactNode
|
|
22
|
+
style?: StyleProp<ViewStyle>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Root({children, style}: RootProps) {
|
|
26
|
+
const {trigger, menu} = collectTriggerAndMenu(children)
|
|
27
|
+
|
|
28
|
+
const {menuItems, selectById} = useMemo(() => {
|
|
29
|
+
const items: MenuItemSpec[] = []
|
|
30
|
+
const map: Record<string, () => void> = {}
|
|
31
|
+
if (menu) {
|
|
32
|
+
Children.forEach(menu.props.children, child => {
|
|
33
|
+
if (!isValidElement(child)) return
|
|
34
|
+
if (kindOf(child.type) !== 'item') return
|
|
35
|
+
const spec = specFromItem(child as ReactElement<MenuItemProps>)
|
|
36
|
+
if (!spec) return
|
|
37
|
+
items.push(spec.item)
|
|
38
|
+
map[spec.item.id] = spec.onSelect
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
return {menuItems: items, selectById: map}
|
|
42
|
+
}, [menu])
|
|
43
|
+
|
|
44
|
+
const handleItemPress = useCallback(
|
|
45
|
+
(e: {nativeEvent: {id: string}}) => {
|
|
46
|
+
selectById[e.nativeEvent.id]?.()
|
|
47
|
+
},
|
|
48
|
+
[selectById],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const onPreviewPress = trigger?.props.onPreviewPress
|
|
52
|
+
const handlePreviewPress = useCallback(() => {
|
|
53
|
+
onPreviewPress?.()
|
|
54
|
+
}, [onPreviewPress])
|
|
55
|
+
|
|
56
|
+
if (!trigger) {
|
|
57
|
+
return <>{children}</>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<NativeView
|
|
62
|
+
preview={trigger.props.preview}
|
|
63
|
+
menuItems={menuItems}
|
|
64
|
+
previewCornerRadius={trigger.props.borderRadius ?? 0}
|
|
65
|
+
onItemPress={handleItemPress}
|
|
66
|
+
onPreviewPress={handlePreviewPress}
|
|
67
|
+
style={[style, trigger.props.style]}>
|
|
68
|
+
{trigger.props.children}
|
|
69
|
+
</NativeView>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// -----------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
type Collected = {
|
|
76
|
+
trigger?: ReactElement<TriggerProps>
|
|
77
|
+
menu?: ReactElement<MenuProps>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function collectTriggerAndMenu(children: ReactNode): Collected {
|
|
81
|
+
const result: Collected = {}
|
|
82
|
+
Children.forEach(children, child => {
|
|
83
|
+
if (!isValidElement(child)) return
|
|
84
|
+
const kind = kindOf(child.type)
|
|
85
|
+
if (kind === 'trigger') result.trigger = child as ReactElement<TriggerProps>
|
|
86
|
+
else if (kind === 'menu') result.menu = child as ReactElement<MenuProps>
|
|
87
|
+
})
|
|
88
|
+
return result
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function specFromItem(
|
|
92
|
+
element: ReactElement<MenuItemProps>,
|
|
93
|
+
): {item: MenuItemSpec; onSelect: () => void} | null {
|
|
94
|
+
const {id, destructive, disabled, onSelect, children} = element.props
|
|
95
|
+
let label = ''
|
|
96
|
+
let icon: MenuItemSpec['icon']
|
|
97
|
+
Children.forEach(children, child => {
|
|
98
|
+
if (!isValidElement(child)) return
|
|
99
|
+
const kind = kindOf(child.type)
|
|
100
|
+
if (kind === 'item-text') {
|
|
101
|
+
const text = (child as ReactElement<MenuItemTextProps>).props.children
|
|
102
|
+
if (typeof text === 'string') label = text
|
|
103
|
+
} else if (kind === 'item-icon') {
|
|
104
|
+
const iconSource = (child as ReactElement<MenuItemIconProps>).props.icon
|
|
105
|
+
if (iconSource?.svgPaths?.length) {
|
|
106
|
+
icon = {
|
|
107
|
+
paths: iconSource.svgPaths,
|
|
108
|
+
viewBox: iconSource.svgViewBox,
|
|
109
|
+
strokeWidth: iconSource.svgStrokeWidth,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
if (!label) return null
|
|
115
|
+
return {
|
|
116
|
+
item: {id, label, destructive, disabled, icon},
|
|
117
|
+
onSelect,
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/Trigger.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {type ReactNode} from 'react'
|
|
2
|
+
import {type StyleProp, type ViewStyle} from 'react-native'
|
|
3
|
+
|
|
4
|
+
import {tag} from './registry'
|
|
5
|
+
import {type PreviewContent} from './types'
|
|
6
|
+
|
|
7
|
+
export type TriggerProps = {
|
|
8
|
+
preview?: PreviewContent
|
|
9
|
+
/** Fires when the user taps the expanded preview to "commit" into it. */
|
|
10
|
+
onPreviewPress?: () => void
|
|
11
|
+
/** Border radius of the thumbnail being wrapped. Used natively to clip the
|
|
12
|
+
* targeted-preview lift animation. */
|
|
13
|
+
borderRadius?: number
|
|
14
|
+
style?: StyleProp<ViewStyle>
|
|
15
|
+
children: ReactNode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sentinel: does not render. `Root` reads props + children off this element
|
|
20
|
+
* and hosts `children` inside the native context-menu view.
|
|
21
|
+
*/
|
|
22
|
+
function TriggerImpl(_: TriggerProps): null {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const Trigger = tag(TriggerImpl, 'trigger')
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {Menu} from './Menu'
|
|
2
|
+
export {MenuItem} from './MenuItem'
|
|
3
|
+
export {MenuItemIcon} from './MenuItemIcon'
|
|
4
|
+
export {MenuItemText} from './MenuItemText'
|
|
5
|
+
export {Root} from './Root'
|
|
6
|
+
export {Trigger} from './Trigger'
|
|
7
|
+
export type {
|
|
8
|
+
MenuItemIconSource,
|
|
9
|
+
MenuItemSpec,
|
|
10
|
+
PreviewContent,
|
|
11
|
+
SvgIconMeta,
|
|
12
|
+
} from './types'
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker keys and type tags shared between `Root`, `Trigger`, `Menu`, and
|
|
3
|
+
* `MenuItem*`. `Root` walks its children looking for these tags so the
|
|
4
|
+
* composition API doesn't rely on string component names or display names.
|
|
5
|
+
*/
|
|
6
|
+
export const CONTEXT_MENU_KIND = '__ExpoBlueskyPeekMenuKind__'
|
|
7
|
+
|
|
8
|
+
export type ContextMenuKind =
|
|
9
|
+
| 'trigger'
|
|
10
|
+
| 'menu'
|
|
11
|
+
| 'item'
|
|
12
|
+
| 'item-icon'
|
|
13
|
+
| 'item-text'
|
|
14
|
+
|
|
15
|
+
export type TaggedComponent<P> = React.FunctionComponent<P> & {
|
|
16
|
+
[CONTEXT_MENU_KIND]: ContextMenuKind
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function tag<P>(
|
|
20
|
+
component: React.FunctionComponent<P>,
|
|
21
|
+
kind: ContextMenuKind,
|
|
22
|
+
): TaggedComponent<P> {
|
|
23
|
+
;(component as TaggedComponent<P>)[CONTEXT_MENU_KIND] = kind
|
|
24
|
+
return component as TaggedComponent<P>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function kindOf(type: unknown): ContextMenuKind | undefined {
|
|
28
|
+
if (type && typeof type === 'function') {
|
|
29
|
+
return (type as TaggedComponent<unknown>)[CONTEXT_MENU_KIND]
|
|
30
|
+
}
|
|
31
|
+
return undefined
|
|
32
|
+
}
|