@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
package/build/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["import {type ReactNode} from 'react'\nimport {type StyleProp, type ViewStyle} from 'react-native'\n\n/**\n * The subset of SVG metadata needed by the native menu icon renderer.\n * Any icon component that carries these three properties can be passed\n * to `MenuItemIcon`. In practice this is satisfied by every icon\n * created with `createSinglePathSVG` / `createMultiPathSVG`.\n */\nexport type SvgIconMeta = {\n svgPaths: string[]\n svgViewBox: string\n svgStrokeWidth: number\n}\n\n/**\n * Content to show during the peek preview. Discriminated by `type`; the native\n * side dispatches on it to build the right `UIViewController`.\n *\n * Only `image` is implemented on iOS today. `video` and `externalCard` are the\n * planned follow-ups; leaving them in the type keeps the JS call-sites honest.\n */\nexport type PreviewContent =\n | {\n type: 'image'\n uri: string\n /** Thumb URL. When present, the native side paints it in as an instant\n * placeholder (reading from the shared SDWebImage cache) while the\n * fullsize loads — avoids the black flash on first peek. */\n thumbUri?: string\n /** Aspect ratio as width / height. */\n aspectRatio: number\n }\n | {\n type: 'video'\n uri: string\n poster?: string\n aspectRatio: number\n }\n | {\n type: 'externalCard'\n thumbUri?: string\n title: string\n description?: string\n url: string\n }\n\nexport type MenuItemSpec = {\n id: string\n label: string\n destructive?: boolean\n disabled?: boolean\n icon?: {\n paths: string[]\n viewBox: string\n strokeWidth: number\n }\n}\n\nexport type MenuItemIconSource = SvgIconMeta\n\nexport type NativeViewProps = {\n preview?: PreviewContent\n menuItems: MenuItemSpec[]\n /** Named distinctly from `borderRadius`, which RN owns as a style prop. */\n previewCornerRadius: number\n onItemPress: (e: {nativeEvent: {id: string}}) => void\n onPreviewPress: (e: {nativeEvent: {}}) => void\n style?: StyleProp<ViewStyle>\n children?: ReactNode\n}\n"]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'ExpoBlueskyPeekMenu'
|
|
3
|
+
s.version = '1.0.0'
|
|
4
|
+
s.summary = 'Native iOS context menu (peek + long-press) for embeds'
|
|
5
|
+
s.description = 'Wraps UIContextMenuInteraction with a compositional JS API.'
|
|
6
|
+
s.author = ''
|
|
7
|
+
s.homepage = 'https://github.com/bluesky-social/toolbox'
|
|
8
|
+
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
|
9
|
+
s.source = { git: '' }
|
|
10
|
+
s.static_framework = true
|
|
11
|
+
|
|
12
|
+
s.dependency 'ExpoModulesCore'
|
|
13
|
+
# Must match the version pinned by expo-image so we share SDImageCache.shared.
|
|
14
|
+
s.dependency 'SDWebImage', '~> 5.21.0'
|
|
15
|
+
|
|
16
|
+
s.pod_target_xcconfig = {
|
|
17
|
+
'DEFINES_MODULE' => 'YES',
|
|
18
|
+
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
22
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class ExpoBlueskyPeekMenuModule: Module {
|
|
4
|
+
public func definition() -> ModuleDefinition {
|
|
5
|
+
Name("ExpoBlueskyPeekMenu")
|
|
6
|
+
|
|
7
|
+
View(ExpoBlueskyPeekMenuView.self) {
|
|
8
|
+
Events(["onItemPress", "onPreviewPress"])
|
|
9
|
+
|
|
10
|
+
Prop("preview") { (view: ExpoBlueskyPeekMenuView, value: [String: Any]?) in
|
|
11
|
+
view.setPreview(value)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
Prop("menuItems") { (view: ExpoBlueskyPeekMenuView, value: [[String: Any]]) in
|
|
15
|
+
view.setMenuItems(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Prop("previewCornerRadius") {
|
|
19
|
+
(view: ExpoBlueskyPeekMenuView, value: Double) in
|
|
20
|
+
view.setPreviewCornerRadius(value)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// Native view that hosts the children and attaches a
|
|
5
|
+
/// `UIContextMenuInteraction`. JS-shipped props drive behaviour:
|
|
6
|
+
/// - `preview`: discriminated union describing what to show during peek
|
|
7
|
+
/// - `menuItems`: array of menu item specs (see `MenuBuilder`)
|
|
8
|
+
/// - `previewCornerRadius`: used for the targeted preview's visible path so the
|
|
9
|
+
/// lift animation matches the thumbnail's clipping. (Named distinctly from
|
|
10
|
+
/// the RN-owned `borderRadius` style prop on UIView.)
|
|
11
|
+
class ExpoBlueskyPeekMenuView: ExpoView, UIContextMenuInteractionDelegate {
|
|
12
|
+
private var preview: [String: Any]?
|
|
13
|
+
private var menuItems: [[String: Any]] = []
|
|
14
|
+
private var previewCornerRadius: CGFloat = 0
|
|
15
|
+
|
|
16
|
+
private let onItemPress = EventDispatcher()
|
|
17
|
+
private let onPreviewPress = EventDispatcher()
|
|
18
|
+
|
|
19
|
+
required init(appContext: AppContext? = nil) {
|
|
20
|
+
super.init(appContext: appContext)
|
|
21
|
+
let interaction = UIContextMenuInteraction(delegate: self)
|
|
22
|
+
self.addInteraction(interaction)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// RN layout can leave bounds at fractional-pixel values. The targeted preview
|
|
26
|
+
// snapshots this view's bounds for its return animation, and subpixel mismatches
|
|
27
|
+
// cause a visible glitch when the preview shrinks back into the thumbnail.
|
|
28
|
+
// Snapping to whole-pixel values prevents that.
|
|
29
|
+
override var bounds: CGRect {
|
|
30
|
+
get {
|
|
31
|
+
let b = super.bounds
|
|
32
|
+
let s = self.window?.screen.scale ?? UIScreen.main.scale
|
|
33
|
+
return CGRect(
|
|
34
|
+
x: b.origin.x,
|
|
35
|
+
y: b.origin.y,
|
|
36
|
+
width: round(b.width * s) / s,
|
|
37
|
+
height: round(b.height * s) / s
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
set { super.bounds = newValue }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func setPreview(_ value: [String: Any]?) { self.preview = value }
|
|
44
|
+
func setMenuItems(_ value: [[String: Any]]) { self.menuItems = value }
|
|
45
|
+
func setPreviewCornerRadius(_ value: Double) {
|
|
46
|
+
self.previewCornerRadius = CGFloat(value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - UIContextMenuInteractionDelegate
|
|
50
|
+
|
|
51
|
+
func contextMenuInteraction(
|
|
52
|
+
_ interaction: UIContextMenuInteraction,
|
|
53
|
+
configurationForMenuAtLocation location: CGPoint
|
|
54
|
+
) -> UIContextMenuConfiguration? {
|
|
55
|
+
let previewSpec = self.preview
|
|
56
|
+
let items = self.menuItems
|
|
57
|
+
|
|
58
|
+
return UIContextMenuConfiguration(
|
|
59
|
+
identifier: nil,
|
|
60
|
+
previewProvider: { [weak self] in
|
|
61
|
+
guard self != nil else { return nil }
|
|
62
|
+
return PreviewFactory.makeController(from: previewSpec)
|
|
63
|
+
},
|
|
64
|
+
actionProvider: { [weak self] _ in
|
|
65
|
+
guard let self = self else { return nil }
|
|
66
|
+
return MenuBuilder.build(items: items) { [weak self] id in
|
|
67
|
+
self?.onItemPress(["id": id])
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func contextMenuInteraction(
|
|
74
|
+
_ interaction: UIContextMenuInteraction,
|
|
75
|
+
previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
|
76
|
+
) -> UITargetedPreview? {
|
|
77
|
+
return makeTargetedPreview()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func contextMenuInteraction(
|
|
81
|
+
_ interaction: UIContextMenuInteraction,
|
|
82
|
+
previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
|
83
|
+
) -> UITargetedPreview? {
|
|
84
|
+
return makeTargetedPreview()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func contextMenuInteraction(
|
|
88
|
+
_ interaction: UIContextMenuInteraction,
|
|
89
|
+
willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
|
|
90
|
+
animator: UIContextMenuInteractionCommitAnimating
|
|
91
|
+
) {
|
|
92
|
+
self.onPreviewPress([:])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// MARK: - Targeted preview
|
|
96
|
+
|
|
97
|
+
/// The targeted preview uses the view itself as target with a rounded-corner
|
|
98
|
+
/// visible path matching the thumbnail's clipping, so the lift animation
|
|
99
|
+
/// respects the existing corner radius.
|
|
100
|
+
private func makeTargetedPreview() -> UITargetedPreview {
|
|
101
|
+
let parameters = UIPreviewParameters()
|
|
102
|
+
parameters.backgroundColor = .clear
|
|
103
|
+
if previewCornerRadius > 0 {
|
|
104
|
+
parameters.visiblePath = UIBezierPath(
|
|
105
|
+
roundedRect: self.bounds,
|
|
106
|
+
cornerRadius: previewCornerRadius
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
return UITargetedPreview(view: self, parameters: parameters)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Renders SVG path data (the `d` attribute) into a `UIImage`. Supports the
|
|
4
|
+
/// subset of SVG path commands used by the Bluesky icon set: M/m, L/l, H/h,
|
|
5
|
+
/// V/v, C/c, S/s, Q/q, T/t, A/a, Z/z. Results are cached by (path, size, tint).
|
|
6
|
+
enum IconRenderer {
|
|
7
|
+
private static let cache = NSCache<NSString, UIImage>()
|
|
8
|
+
|
|
9
|
+
struct Spec: Hashable {
|
|
10
|
+
let paths: [String]
|
|
11
|
+
let viewBox: String
|
|
12
|
+
let strokeWidth: CGFloat
|
|
13
|
+
let pointSize: CGFloat
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static func image(for spec: Spec) -> UIImage? {
|
|
17
|
+
let key = cacheKey(spec) as NSString
|
|
18
|
+
if let cached = cache.object(forKey: key) { return cached }
|
|
19
|
+
|
|
20
|
+
guard let image = render(spec) else { return nil }
|
|
21
|
+
cache.setObject(image, forKey: key)
|
|
22
|
+
return image
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private static func cacheKey(_ spec: Spec) -> String {
|
|
26
|
+
return "\(spec.paths.joined(separator: "|"))|\(spec.viewBox)|\(spec.strokeWidth)|\(spec.pointSize)"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private static func render(_ spec: Spec) -> UIImage? {
|
|
30
|
+
let viewBox = parseViewBox(spec.viewBox) ?? CGRect(x: 0, y: 0, width: 24, height: 24)
|
|
31
|
+
let size = CGSize(width: spec.pointSize, height: spec.pointSize)
|
|
32
|
+
let scaleX = size.width / viewBox.width
|
|
33
|
+
let scaleY = size.height / viewBox.height
|
|
34
|
+
let scale = min(scaleX, scaleY)
|
|
35
|
+
|
|
36
|
+
let renderer = UIGraphicsImageRenderer(size: size)
|
|
37
|
+
let image = renderer.image { ctx in
|
|
38
|
+
let cg = ctx.cgContext
|
|
39
|
+
cg.translateBy(x: -viewBox.origin.x * scale, y: -viewBox.origin.y * scale)
|
|
40
|
+
cg.scaleBy(x: scale, y: scale)
|
|
41
|
+
|
|
42
|
+
// Render in opaque black; callers use `.alwaysTemplate` so iOS tints
|
|
43
|
+
// the icon with the menu's label color (and red for destructive items).
|
|
44
|
+
UIColor.black.setFill()
|
|
45
|
+
UIColor.black.setStroke()
|
|
46
|
+
|
|
47
|
+
for pathString in spec.paths {
|
|
48
|
+
let bezier = SVGPathParser.parse(pathString)
|
|
49
|
+
if spec.strokeWidth > 0 {
|
|
50
|
+
bezier.lineWidth = spec.strokeWidth
|
|
51
|
+
bezier.lineCapStyle = .round
|
|
52
|
+
bezier.lineJoinStyle = .round
|
|
53
|
+
bezier.stroke()
|
|
54
|
+
} else {
|
|
55
|
+
bezier.usesEvenOddFillRule = true
|
|
56
|
+
bezier.fill()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return image.withRenderingMode(.alwaysTemplate)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private static func parseViewBox(_ s: String) -> CGRect? {
|
|
64
|
+
let parts = s.split(whereSeparator: { $0 == " " || $0 == "," })
|
|
65
|
+
.compactMap { Double($0) }
|
|
66
|
+
guard parts.count == 4 else { return nil }
|
|
67
|
+
return CGRect(x: parts[0], y: parts[1], width: parts[2], height: parts[3])
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import SDWebImage
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// Preview view controller shown during a peek. Renders a single image sized
|
|
5
|
+
/// to the provided aspect ratio, capped to the screen bounds.
|
|
6
|
+
///
|
|
7
|
+
/// The aspect ratio drives `preferredContentSize` so iOS animates directly to
|
|
8
|
+
/// the final size without the mid-flight stretch that happens when a mis-sized
|
|
9
|
+
/// snapshot is scaled up.
|
|
10
|
+
///
|
|
11
|
+
/// Image loading cooperates with expo-image by sharing
|
|
12
|
+
/// `SDImageCache.shared` and `SDWebImageManager.shared`:
|
|
13
|
+
/// 1. Query the cache synchronously for the fullsize — if it's there
|
|
14
|
+
/// (e.g. prefetched on press-in), paint it immediately.
|
|
15
|
+
/// 2. Else, paint the thumbnail (almost always cached — it's what the feed
|
|
16
|
+
/// renders) as a placeholder.
|
|
17
|
+
/// 3. Asynchronously load the fullsize and swap it in when it arrives.
|
|
18
|
+
/// This eliminates the "black flash" on first peek of an unloaded image.
|
|
19
|
+
final class ImagePreviewController: UIViewController {
|
|
20
|
+
private let imageURL: URL?
|
|
21
|
+
private let thumbURL: URL?
|
|
22
|
+
private let aspectRatio: CGFloat
|
|
23
|
+
|
|
24
|
+
private let imageView = UIImageView()
|
|
25
|
+
|
|
26
|
+
init(imageURL: URL?, thumbURL: URL?, aspectRatio: CGFloat) {
|
|
27
|
+
self.imageURL = imageURL
|
|
28
|
+
self.thumbURL = thumbURL
|
|
29
|
+
self.aspectRatio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 1
|
|
30
|
+
super.init(nibName: nil, bundle: nil)
|
|
31
|
+
self.preferredContentSize = Self.sizeForAspect(self.aspectRatio)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
|
|
35
|
+
|
|
36
|
+
override func loadView() {
|
|
37
|
+
let root = UIView()
|
|
38
|
+
root.backgroundColor = .black
|
|
39
|
+
root.clipsToBounds = true
|
|
40
|
+
|
|
41
|
+
// Use autoresizing mask rather than AutoLayout so the imageView's frame
|
|
42
|
+
// interpolates cleanly during the dismiss animation — AutoLayout-driven
|
|
43
|
+
// relayout during a CALayer animation can cause a visible snap.
|
|
44
|
+
imageView.frame = root.bounds
|
|
45
|
+
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
46
|
+
imageView.contentMode = .scaleAspectFit
|
|
47
|
+
imageView.backgroundColor = .black
|
|
48
|
+
root.addSubview(imageView)
|
|
49
|
+
|
|
50
|
+
self.view = root
|
|
51
|
+
primeImage()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// MARK: - Image loading
|
|
55
|
+
|
|
56
|
+
private func primeImage() {
|
|
57
|
+
// 1. Fullsize cache hit? Paint it immediately.
|
|
58
|
+
if let url = imageURL, let cached = cachedImage(for: url) {
|
|
59
|
+
log("fullsize memory hit")
|
|
60
|
+
imageView.image = cached
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
log("fullsize memory miss")
|
|
64
|
+
// 2. Thumb placeholder — check memory first, fall back to disk.
|
|
65
|
+
// Thumbs are small so the sync disk read is acceptable here.
|
|
66
|
+
if let thumb = thumbURL {
|
|
67
|
+
let (image, source) = cachedImageWithDisk(for: thumb)
|
|
68
|
+
if let image = image {
|
|
69
|
+
log("thumb hit (\(source))")
|
|
70
|
+
imageView.image = image
|
|
71
|
+
} else {
|
|
72
|
+
log("thumb miss")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// 3. Kick off the async fullsize load.
|
|
76
|
+
guard let url = imageURL else { return }
|
|
77
|
+
SDWebImageManager.shared.loadImage(
|
|
78
|
+
with: url,
|
|
79
|
+
options: [.retryFailed],
|
|
80
|
+
progress: nil
|
|
81
|
+
) { [weak self] image, _, _, cacheType, _, _ in
|
|
82
|
+
self?.log("async fullsize loaded, cacheType=\(cacheType.rawValue)")
|
|
83
|
+
guard let self = self, let image = image else { return }
|
|
84
|
+
DispatchQueue.main.async {
|
|
85
|
+
self.imageView.image = image
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#if DEBUG
|
|
91
|
+
private func log(_ msg: String) {
|
|
92
|
+
print("[PeekMenu] \(msg)")
|
|
93
|
+
}
|
|
94
|
+
#else
|
|
95
|
+
@inline(__always) private func log(_ msg: String) {}
|
|
96
|
+
#endif
|
|
97
|
+
|
|
98
|
+
/// Memory-only cache lookup.
|
|
99
|
+
private func cachedImage(for url: URL) -> UIImage? {
|
|
100
|
+
let key = SDWebImageManager.shared.cacheKey(for: url) ?? url.absoluteString
|
|
101
|
+
return SDImageCache.shared.imageFromMemoryCache(forKey: key)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Memory + disk cache lookup. Only used for thumbnails where the file is
|
|
105
|
+
/// small enough that a synchronous disk read won't block the peek animation.
|
|
106
|
+
/// Returns the image and which cache layer it came from.
|
|
107
|
+
private func cachedImageWithDisk(for url: URL) -> (UIImage?, String) {
|
|
108
|
+
let key = SDWebImageManager.shared.cacheKey(for: url) ?? url.absoluteString
|
|
109
|
+
if let image = SDImageCache.shared.imageFromMemoryCache(forKey: key) {
|
|
110
|
+
return (image, "memory")
|
|
111
|
+
}
|
|
112
|
+
if let image = SDImageCache.shared.imageFromCache(forKey: key) {
|
|
113
|
+
return (image, "disk")
|
|
114
|
+
}
|
|
115
|
+
return (nil, "")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Caps the preview to a comfortable size within the current key window.
|
|
119
|
+
private static func sizeForAspect(_ aspect: CGFloat) -> CGSize {
|
|
120
|
+
let screenBounds = UIApplication.shared.connectedScenes
|
|
121
|
+
.compactMap { $0 as? UIWindowScene }
|
|
122
|
+
.first?.screen.bounds ?? UIScreen.main.bounds
|
|
123
|
+
let maxW = screenBounds.width - 32
|
|
124
|
+
let maxH = screenBounds.height * 0.7
|
|
125
|
+
var w = maxW
|
|
126
|
+
var h = w / aspect
|
|
127
|
+
if h > maxH {
|
|
128
|
+
h = maxH
|
|
129
|
+
w = h * aspect
|
|
130
|
+
}
|
|
131
|
+
return CGSize(width: w, height: h)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Builds a `UIMenu` from the JS-shipped item specs. Each item may carry an
|
|
4
|
+
/// icon spec (SVG path data) which is rasterized via `IconRenderer`.
|
|
5
|
+
enum MenuBuilder {
|
|
6
|
+
/// Expected item shape from JS:
|
|
7
|
+
/// {
|
|
8
|
+
/// id: String,
|
|
9
|
+
/// label: String,
|
|
10
|
+
/// destructive?: Bool,
|
|
11
|
+
/// disabled?: Bool,
|
|
12
|
+
/// icon?: {
|
|
13
|
+
/// paths: [String],
|
|
14
|
+
/// viewBox: String,
|
|
15
|
+
/// strokeWidth: Double
|
|
16
|
+
/// }
|
|
17
|
+
/// }
|
|
18
|
+
static func build(items: [[String: Any]], onSelect: @escaping (String) -> Void) -> UIMenu {
|
|
19
|
+
let actions: [UIMenuElement] = items.compactMap { spec in
|
|
20
|
+
guard let id = spec["id"] as? String,
|
|
21
|
+
let label = spec["label"] as? String else { return nil }
|
|
22
|
+
|
|
23
|
+
let destructive = (spec["destructive"] as? Bool) ?? false
|
|
24
|
+
let disabled = (spec["disabled"] as? Bool) ?? false
|
|
25
|
+
let image = icon(from: spec["icon"] as? [String: Any])
|
|
26
|
+
|
|
27
|
+
var attributes: UIMenuElement.Attributes = []
|
|
28
|
+
if destructive { attributes.insert(.destructive) }
|
|
29
|
+
if disabled { attributes.insert(.disabled) }
|
|
30
|
+
|
|
31
|
+
return UIAction(title: label, image: image, attributes: attributes) { _ in
|
|
32
|
+
onSelect(id)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return UIMenu(title: "", children: actions)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private static func icon(from spec: [String: Any]?) -> UIImage? {
|
|
39
|
+
guard let spec = spec,
|
|
40
|
+
let paths = spec["paths"] as? [String], !paths.isEmpty else { return nil }
|
|
41
|
+
let viewBox = (spec["viewBox"] as? String) ?? "0 0 24 24"
|
|
42
|
+
let strokeWidth = CGFloat((spec["strokeWidth"] as? Double) ?? 0)
|
|
43
|
+
let renderSpec = IconRenderer.Spec(
|
|
44
|
+
paths: paths,
|
|
45
|
+
viewBox: viewBox,
|
|
46
|
+
strokeWidth: strokeWidth,
|
|
47
|
+
pointSize: 24
|
|
48
|
+
)
|
|
49
|
+
return IconRenderer.image(for: renderSpec)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Decodes the `preview` prop shipped from JS and constructs the right
|
|
4
|
+
/// `UIViewController` for the peek. Day-one only handles `image`. Add cases
|
|
5
|
+
/// here for `video` and `externalCard` follow-ups.
|
|
6
|
+
enum PreviewFactory {
|
|
7
|
+
static func makeController(from spec: [String: Any]?) -> UIViewController? {
|
|
8
|
+
guard let spec = spec,
|
|
9
|
+
let type = spec["type"] as? String else { return nil }
|
|
10
|
+
|
|
11
|
+
switch type {
|
|
12
|
+
case "image":
|
|
13
|
+
let uri = spec["uri"] as? String
|
|
14
|
+
let thumbUri = spec["thumbUri"] as? String
|
|
15
|
+
let url = uri.flatMap(URL.init(string:))
|
|
16
|
+
let thumbURL = thumbUri.flatMap(URL.init(string:))
|
|
17
|
+
let aspect = CGFloat((spec["aspectRatio"] as? Double) ?? 1)
|
|
18
|
+
return ImagePreviewController(
|
|
19
|
+
imageURL: url,
|
|
20
|
+
thumbURL: thumbURL,
|
|
21
|
+
aspectRatio: aspect
|
|
22
|
+
)
|
|
23
|
+
default:
|
|
24
|
+
return nil
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|