react-native-photo-video-editor 0.1.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/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +101 -0
- package/LICENSE +20 -0
- package/PhotoVideoEditor.podspec +23 -0
- package/README.md +53 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
- package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
- package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
- package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
- package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
- package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
- package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
- package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
- package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
- package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
- package/android/src/main/res/drawable/ic_add.xml +9 -0
- package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
- package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
- package/android/src/main/res/drawable/ic_check.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
- package/android/src/main/res/drawable/ic_close.xml +9 -0
- package/android/src/main/res/drawable/ic_compare.xml +9 -0
- package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
- package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +9 -0
- package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
- package/android/src/main/res/drawable/ic_face.xml +9 -0
- package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
- package/android/src/main/res/drawable/ic_flare.xml +9 -0
- package/android/src/main/res/drawable/ic_format_size.xml +9 -0
- package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
- package/android/src/main/res/drawable/ic_layers.xml +9 -0
- package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
- package/android/src/main/res/drawable/ic_lock.xml +9 -0
- package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
- package/android/src/main/res/drawable/ic_movie.xml +9 -0
- package/android/src/main/res/drawable/ic_opacity.xml +9 -0
- package/android/src/main/res/drawable/ic_palette.xml +9 -0
- package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
- package/android/src/main/res/drawable/ic_redo.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
- package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
- package/android/src/main/res/drawable/ic_share.xml +9 -0
- package/android/src/main/res/drawable/ic_speed.xml +9 -0
- package/android/src/main/res/drawable/ic_straighten.xml +9 -0
- package/android/src/main/res/drawable/ic_title.xml +9 -0
- package/android/src/main/res/drawable/ic_tune.xml +9 -0
- package/android/src/main/res/drawable/ic_undo.xml +9 -0
- package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
- package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/docs/api.md +9 -0
- package/docs/architecture.md +9 -0
- package/docs/customization.md +18 -0
- package/docs/development.md +21 -0
- package/docs/formats.md +27 -0
- package/docs/performance.md +3 -0
- package/docs/permissions.md +3 -0
- package/docs/photo-editor.md +41 -0
- package/docs/roadmap.md +12 -0
- package/docs/technology-decisions.md +17 -0
- package/docs/video-editor.md +76 -0
- package/ios/Editor/DesignTokens.swift +75 -0
- package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
- package/ios/Editor/README.md +5 -0
- package/ios/Editor/UI/EditorComponents.swift +138 -0
- package/ios/Files/SourceResolver.swift +13 -0
- package/ios/Models/EditorRequest.swift +3 -0
- package/ios/Photo/Export/PhotoExporter.swift +144 -0
- package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
- package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
- package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
- package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
- package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
- package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
- package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
- package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
- package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
- package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
- package/ios/Photo/UI/CropOverlayView.swift +161 -0
- package/ios/Photo/UI/LayerOverlayView.swift +176 -0
- package/ios/Photo/UI/ZoomableImageView.swift +79 -0
- package/ios/PhotoVideoEditor.mm +20 -0
- package/ios/PhotoVideoEditor.swift +70 -0
- package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
- package/ios/Stickers/OpenMojiCatalog.swift +188 -0
- package/ios/Stickers/RemoteImageLoader.swift +127 -0
- package/ios/Validation/EditorRequestValidator.swift +9 -0
- package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
- package/ios/Video/Export/VideoExporter.swift +275 -0
- package/ios/Video/Render/VideoClip.swift +21 -0
- package/ios/Video/Render/VideoEditSession.swift +99 -0
- package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
- package/ios/Video/Render/VideoTransformState.swift +23 -0
- package/ios/Video/UI/TrimRangeView.swift +91 -0
- package/lib/module/NativePhotoVideoEditor.js +5 -0
- package/lib/module/NativePhotoVideoEditor.js.map +1 -0
- package/lib/module/errors.js +29 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/events.js +27 -0
- package/lib/module/events.js.map +1 -0
- package/lib/module/index.js +91 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/project.js +4 -0
- package/lib/module/project.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +11 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/events.d.ts +5 -0
- package/lib/typescript/src/events.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +15 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/project.d.ts +24 -0
- package/lib/typescript/src/project.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +108 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +154 -0
- package/src/NativePhotoVideoEditor.ts +12 -0
- package/src/errors.ts +78 -0
- package/src/events.ts +43 -0
- package/src/index.ts +123 -0
- package/src/project.ts +17 -0
- package/src/types.ts +121 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Bottom sheet: search + category filter + a 4-column grid of OpenMoji stickers. Tapping one
|
|
4
|
+
/// downloads the full-res PNG, writes it to a temp file, and hands `(URL, UIImage)` back to
|
|
5
|
+
/// `onStickerPicked` — the caller feeds that straight into the existing
|
|
6
|
+
/// `handlePickedImage(_:image:purpose:)` insertion path (photo sticker chip / video sticker
|
|
7
|
+
/// action sheet), so this file has zero knowledge of `PhotoLayer`/session internals.
|
|
8
|
+
///
|
|
9
|
+
/// Built entirely in code (no storyboard/xib), consistent with the rest of this codebase.
|
|
10
|
+
final class OnlineStickerSheetViewController: UIViewController {
|
|
11
|
+
/// Fired once, after the full-res download completes and the image is written to a temp file.
|
|
12
|
+
/// The sheet dismisses itself first, then invokes this closure.
|
|
13
|
+
var onStickerPicked: ((URL, UIImage) -> Void)?
|
|
14
|
+
|
|
15
|
+
private let catalog = OpenMojiCatalog.shared
|
|
16
|
+
private let loader = RemoteImageLoader.shared
|
|
17
|
+
|
|
18
|
+
private enum LoadState {
|
|
19
|
+
case loading, loaded, error(String)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private var loadState: LoadState = .loading
|
|
23
|
+
private var allEntries: [OpenMojiEntry] = []
|
|
24
|
+
private var displayedEntries: [OpenMojiEntry] = []
|
|
25
|
+
private var selectedGroup: String? // nil == "All"
|
|
26
|
+
private var searchQuery: String = ""
|
|
27
|
+
private var searchDebounceTimer: Timer?
|
|
28
|
+
private var downloadInFlightHexcode: String?
|
|
29
|
+
|
|
30
|
+
private let searchBar = UISearchBar()
|
|
31
|
+
private let categoryScroll = UIScrollView()
|
|
32
|
+
private let categoryStack = UIStackView()
|
|
33
|
+
private var categoryButtons: [String: UIButton] = [:] // key "" == All
|
|
34
|
+
private let collectionView: UICollectionView
|
|
35
|
+
private let spinner = UIActivityIndicatorView(style: .large)
|
|
36
|
+
private let emptyLabel = UILabel()
|
|
37
|
+
private let errorLabel = UILabel()
|
|
38
|
+
private let retryButton = UIButton(type: .system)
|
|
39
|
+
private let attributionLabel = UILabel()
|
|
40
|
+
private let stateContainer = UIView()
|
|
41
|
+
|
|
42
|
+
private static let cellReuseID = "OpenMojiCell"
|
|
43
|
+
private static let columns = 4
|
|
44
|
+
private static let cellSpacing: CGFloat = 8
|
|
45
|
+
|
|
46
|
+
init() {
|
|
47
|
+
let layout = UICollectionViewFlowLayout()
|
|
48
|
+
layout.minimumInteritemSpacing = Self.cellSpacing
|
|
49
|
+
layout.minimumLineSpacing = Self.cellSpacing
|
|
50
|
+
layout.sectionInset = UIEdgeInsets(top: Self.cellSpacing, left: Self.cellSpacing, bottom: Self.cellSpacing, right: Self.cellSpacing)
|
|
51
|
+
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
|
52
|
+
super.init(nibName: nil, bundle: nil)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
required init?(coder: NSCoder) { nil }
|
|
56
|
+
|
|
57
|
+
override func viewDidLoad() {
|
|
58
|
+
super.viewDidLoad()
|
|
59
|
+
view.backgroundColor = DesignTokens.surfaceContainerLowest
|
|
60
|
+
buildUI()
|
|
61
|
+
loadCatalog()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// MARK: - UI construction
|
|
65
|
+
|
|
66
|
+
private func buildUI() {
|
|
67
|
+
searchBar.placeholder = "Search stickers"
|
|
68
|
+
searchBar.delegate = self
|
|
69
|
+
searchBar.searchBarStyle = .minimal
|
|
70
|
+
searchBar.backgroundColor = .clear
|
|
71
|
+
|
|
72
|
+
categoryScroll.showsHorizontalScrollIndicator = false
|
|
73
|
+
categoryStack.axis = .horizontal
|
|
74
|
+
categoryStack.spacing = 6
|
|
75
|
+
rebuildCategoryChips()
|
|
76
|
+
categoryScroll.addSubview(categoryStack)
|
|
77
|
+
categoryStack.translatesAutoresizingMaskIntoConstraints = false
|
|
78
|
+
NSLayoutConstraint.activate([
|
|
79
|
+
categoryStack.leadingAnchor.constraint(equalTo: categoryScroll.contentLayoutGuide.leadingAnchor, constant: 12),
|
|
80
|
+
categoryStack.trailingAnchor.constraint(equalTo: categoryScroll.contentLayoutGuide.trailingAnchor, constant: -12),
|
|
81
|
+
categoryStack.topAnchor.constraint(equalTo: categoryScroll.contentLayoutGuide.topAnchor),
|
|
82
|
+
categoryStack.bottomAnchor.constraint(equalTo: categoryScroll.contentLayoutGuide.bottomAnchor),
|
|
83
|
+
categoryStack.heightAnchor.constraint(equalTo: categoryScroll.frameLayoutGuide.heightAnchor),
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
collectionView.backgroundColor = .clear
|
|
87
|
+
collectionView.dataSource = self
|
|
88
|
+
collectionView.delegate = self
|
|
89
|
+
collectionView.register(OpenMojiCell.self, forCellWithReuseIdentifier: Self.cellReuseID)
|
|
90
|
+
|
|
91
|
+
spinner.hidesWhenStopped = true
|
|
92
|
+
|
|
93
|
+
emptyLabel.text = "No stickers found."
|
|
94
|
+
emptyLabel.textColor = DesignTokens.onSurfaceVariant
|
|
95
|
+
emptyLabel.textAlignment = .center
|
|
96
|
+
emptyLabel.font = .systemFont(ofSize: 14)
|
|
97
|
+
emptyLabel.isHidden = true
|
|
98
|
+
|
|
99
|
+
errorLabel.text = "Couldn't load stickers — check your connection."
|
|
100
|
+
errorLabel.textColor = DesignTokens.onSurfaceVariant
|
|
101
|
+
errorLabel.textAlignment = .center
|
|
102
|
+
errorLabel.font = .systemFont(ofSize: 14)
|
|
103
|
+
errorLabel.numberOfLines = 0
|
|
104
|
+
errorLabel.isHidden = true
|
|
105
|
+
|
|
106
|
+
retryButton.setTitle("Retry", for: .normal)
|
|
107
|
+
retryButton.setTitleColor(DesignTokens.primaryContainer, for: .normal)
|
|
108
|
+
retryButton.isHidden = true
|
|
109
|
+
retryButton.addAction(UIAction { [weak self] _ in self?.loadCatalog(forceRefresh: true) }, for: .touchUpInside)
|
|
110
|
+
|
|
111
|
+
let stateStack = UIStackView(arrangedSubviews: [spinner, emptyLabel, errorLabel, retryButton])
|
|
112
|
+
stateStack.axis = .vertical
|
|
113
|
+
stateStack.alignment = .center
|
|
114
|
+
stateStack.spacing = 12
|
|
115
|
+
stateContainer.addSubview(stateStack)
|
|
116
|
+
stateStack.translatesAutoresizingMaskIntoConstraints = false
|
|
117
|
+
NSLayoutConstraint.activate([
|
|
118
|
+
stateStack.centerXAnchor.constraint(equalTo: stateContainer.centerXAnchor),
|
|
119
|
+
stateStack.centerYAnchor.constraint(equalTo: stateContainer.centerYAnchor),
|
|
120
|
+
stateStack.leadingAnchor.constraint(greaterThanOrEqualTo: stateContainer.leadingAnchor, constant: 24),
|
|
121
|
+
stateStack.trailingAnchor.constraint(lessThanOrEqualTo: stateContainer.trailingAnchor, constant: -24),
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
attributionLabel.text = "Stickers by OpenMoji (CC BY-SA 4.0)"
|
|
125
|
+
attributionLabel.font = .systemFont(ofSize: 11)
|
|
126
|
+
attributionLabel.textColor = DesignTokens.outline
|
|
127
|
+
attributionLabel.textAlignment = .center
|
|
128
|
+
|
|
129
|
+
// stateContainer (spinner/empty/error) overlays the collection view's area rather than
|
|
130
|
+
// sharing stack space, since it's shown/hidden independently of the grid's own layout.
|
|
131
|
+
let root = UIStackView(arrangedSubviews: [searchBar, categoryScroll, collectionView, attributionLabel])
|
|
132
|
+
root.axis = .vertical
|
|
133
|
+
root.translatesAutoresizingMaskIntoConstraints = false
|
|
134
|
+
|
|
135
|
+
view.addSubview(root)
|
|
136
|
+
view.addSubview(stateContainer)
|
|
137
|
+
root.translatesAutoresizingMaskIntoConstraints = false
|
|
138
|
+
stateContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
139
|
+
NSLayoutConstraint.activate([
|
|
140
|
+
root.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
|
141
|
+
root.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
|
142
|
+
root.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
|
|
143
|
+
root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
|
|
144
|
+
categoryScroll.heightAnchor.constraint(equalToConstant: 40),
|
|
145
|
+
attributionLabel.heightAnchor.constraint(equalToConstant: 20),
|
|
146
|
+
|
|
147
|
+
stateContainer.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor),
|
|
148
|
+
stateContainer.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
|
|
149
|
+
stateContainer.topAnchor.constraint(equalTo: collectionView.topAnchor),
|
|
150
|
+
stateContainer.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor),
|
|
151
|
+
])
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func rebuildCategoryChips() {
|
|
155
|
+
categoryStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
|
156
|
+
categoryButtons.removeAll()
|
|
157
|
+
|
|
158
|
+
let all = [("", "All")] + catalog.groups().map { ($0, Self.displayName(for: $0)) }
|
|
159
|
+
all.forEach { key, title in
|
|
160
|
+
let chip = makeChip(title: title) { [weak self] in self?.selectCategory(key) }
|
|
161
|
+
categoryButtons[key] = chip
|
|
162
|
+
categoryStack.addArrangedSubview(chip)
|
|
163
|
+
}
|
|
164
|
+
updateCategorySelectionUI()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private static func displayName(for group: String) -> String {
|
|
168
|
+
switch group {
|
|
169
|
+
case "smileys-emotion": return "Smileys"
|
|
170
|
+
case "people-body": return "People"
|
|
171
|
+
case "animals-nature": return "Animals"
|
|
172
|
+
case "food-drink": return "Food"
|
|
173
|
+
case "travel-places": return "Travel"
|
|
174
|
+
case "activities": return "Activities"
|
|
175
|
+
case "objects": return "Objects"
|
|
176
|
+
case "symbols": return "Symbols"
|
|
177
|
+
case "flags": return "Flags"
|
|
178
|
+
default: return group.capitalized
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private func makeChip(title: String, handler: @escaping () -> Void) -> UIButton {
|
|
183
|
+
let chip = UIButton(type: .system)
|
|
184
|
+
chip.setTitle(title, for: .normal)
|
|
185
|
+
chip.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
186
|
+
chip.backgroundColor = DesignTokens.surfaceContainerHigh
|
|
187
|
+
chip.layer.cornerRadius = DesignTokens.radiusLg
|
|
188
|
+
chip.contentEdgeInsets = UIEdgeInsets(top: 0, left: DesignTokens.spaceMd, bottom: 0, right: DesignTokens.spaceMd)
|
|
189
|
+
chip.heightAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
|
|
190
|
+
chip.addAction(UIAction { _ in handler() }, for: .touchUpInside)
|
|
191
|
+
return chip
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private func updateCategorySelectionUI() {
|
|
195
|
+
let activeKey = selectedGroup ?? ""
|
|
196
|
+
categoryButtons.forEach { key, chip in
|
|
197
|
+
let selected = key == activeKey
|
|
198
|
+
chip.setTitleColor(selected ? DesignTokens.onPrimaryContainer : DesignTokens.onSurfaceVariant, for: .normal)
|
|
199
|
+
chip.backgroundColor = selected ? DesignTokens.primaryContainer : DesignTokens.surfaceContainerHigh
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// MARK: - Loading
|
|
204
|
+
|
|
205
|
+
private func loadCatalog(forceRefresh: Bool = false) {
|
|
206
|
+
setLoadState(.loading)
|
|
207
|
+
catalog.load { [weak self] entries, error in
|
|
208
|
+
guard let self else { return }
|
|
209
|
+
if let entries {
|
|
210
|
+
self.allEntries = entries
|
|
211
|
+
self.rebuildCategoryChips()
|
|
212
|
+
self.applyFilter()
|
|
213
|
+
self.setLoadState(.loaded)
|
|
214
|
+
} else {
|
|
215
|
+
self.setLoadState(.error((error as NSError?)?.localizedDescription ?? "Couldn't load stickers — check your connection."))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func setLoadState(_ state: LoadState) {
|
|
221
|
+
loadState = state
|
|
222
|
+
switch state {
|
|
223
|
+
case .loading:
|
|
224
|
+
stateContainer.isHidden = false
|
|
225
|
+
spinner.startAnimating()
|
|
226
|
+
emptyLabel.isHidden = true
|
|
227
|
+
errorLabel.isHidden = true
|
|
228
|
+
retryButton.isHidden = true
|
|
229
|
+
case .loaded:
|
|
230
|
+
spinner.stopAnimating()
|
|
231
|
+
let isEmpty = displayedEntries.isEmpty
|
|
232
|
+
stateContainer.isHidden = !isEmpty
|
|
233
|
+
emptyLabel.isHidden = !isEmpty
|
|
234
|
+
errorLabel.isHidden = true
|
|
235
|
+
retryButton.isHidden = true
|
|
236
|
+
case .error(let message):
|
|
237
|
+
spinner.stopAnimating()
|
|
238
|
+
stateContainer.isHidden = false
|
|
239
|
+
emptyLabel.isHidden = true
|
|
240
|
+
errorLabel.isHidden = false
|
|
241
|
+
errorLabel.text = message
|
|
242
|
+
retryButton.isHidden = false
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// MARK: - Filtering
|
|
247
|
+
|
|
248
|
+
private func selectCategory(_ group: String) {
|
|
249
|
+
selectedGroup = group.isEmpty ? nil : group
|
|
250
|
+
updateCategorySelectionUI()
|
|
251
|
+
applyFilter()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Search takes precedence over category when non-empty; otherwise filter by selected
|
|
255
|
+
/// category, with "All" (nil) meaning no filter.
|
|
256
|
+
private func applyFilter() {
|
|
257
|
+
if !searchQuery.isEmpty {
|
|
258
|
+
displayedEntries = catalog.search(searchQuery)
|
|
259
|
+
} else if let selectedGroup {
|
|
260
|
+
displayedEntries = catalog.byGroup(selectedGroup)
|
|
261
|
+
} else {
|
|
262
|
+
displayedEntries = allEntries
|
|
263
|
+
}
|
|
264
|
+
collectionView.reloadData()
|
|
265
|
+
if case .loaded = loadState { setLoadState(.loaded) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// MARK: - Sticker selection / download
|
|
269
|
+
|
|
270
|
+
private func stickerTapped(_ entry: OpenMojiEntry, at indexPath: IndexPath) {
|
|
271
|
+
guard downloadInFlightHexcode == nil else { return }
|
|
272
|
+
downloadInFlightHexcode = entry.hexcode
|
|
273
|
+
if let cell = collectionView.cellForItem(at: indexPath) as? OpenMojiCell {
|
|
274
|
+
cell.setDownloading(true)
|
|
275
|
+
}
|
|
276
|
+
let url = catalog.fullURL(entry.hexcode)
|
|
277
|
+
loader.downloadFull(url: url) { [weak self] fileURL, error in
|
|
278
|
+
guard let self else { return }
|
|
279
|
+
self.downloadInFlightHexcode = nil
|
|
280
|
+
if let cell = self.collectionView.cellForItem(at: indexPath) as? OpenMojiCell {
|
|
281
|
+
cell.setDownloading(false)
|
|
282
|
+
}
|
|
283
|
+
guard let fileURL, let data = try? Data(contentsOf: fileURL), let image = UIImage(data: data) else {
|
|
284
|
+
self.showInlineDownloadError()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
// Write to a fresh temp file (consistent with the "Upload" flow's `writeTempImage`) so the
|
|
288
|
+
// caller always receives a `file://` URL it can treat like any other picked image, decoupled
|
|
289
|
+
// from our own disk cache's lifetime.
|
|
290
|
+
guard let tempURL = self.writeTempImage(image) else {
|
|
291
|
+
self.showInlineDownloadError()
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
self.dismiss(animated: true) { [weak self] in
|
|
295
|
+
self?.onStickerPicked?(tempURL, image)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private func writeTempImage(_ image: UIImage) -> URL? {
|
|
301
|
+
guard let data = image.pngData() else { return nil }
|
|
302
|
+
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".png")
|
|
303
|
+
do {
|
|
304
|
+
try data.write(to: url)
|
|
305
|
+
return url
|
|
306
|
+
} catch {
|
|
307
|
+
return nil
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private func showInlineDownloadError() {
|
|
312
|
+
let alert = UIAlertController(title: "Download failed", message: "Couldn't download that sticker. Please try again.", preferredStyle: .alert)
|
|
313
|
+
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
|
314
|
+
present(alert, animated: true)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// MARK: - UISearchBarDelegate (debounced)
|
|
319
|
+
|
|
320
|
+
extension OnlineStickerSheetViewController: UISearchBarDelegate {
|
|
321
|
+
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
|
322
|
+
searchDebounceTimer?.invalidate()
|
|
323
|
+
searchDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { [weak self] _ in
|
|
324
|
+
guard let self else { return }
|
|
325
|
+
self.searchQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
326
|
+
self.applyFilter()
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
|
|
331
|
+
searchBar.resignFirstResponder()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
|
335
|
+
searchBar.text = ""
|
|
336
|
+
searchBar.resignFirstResponder()
|
|
337
|
+
searchDebounceTimer?.invalidate()
|
|
338
|
+
searchQuery = ""
|
|
339
|
+
applyFilter()
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// MARK: - UICollectionView
|
|
344
|
+
|
|
345
|
+
extension OnlineStickerSheetViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
|
|
346
|
+
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
|
347
|
+
displayedEntries.count
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
|
351
|
+
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Self.cellReuseID, for: indexPath) as! OpenMojiCell
|
|
352
|
+
let entry = displayedEntries[indexPath.item]
|
|
353
|
+
cell.configure(url: catalog.thumbnailURL(entry.hexcode), loader: loader)
|
|
354
|
+
return cell
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
358
|
+
collectionView.deselectItem(at: indexPath, animated: true)
|
|
359
|
+
guard indexPath.item < displayedEntries.count else { return }
|
|
360
|
+
stickerTapped(displayedEntries[indexPath.item], at: indexPath)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
|
364
|
+
let totalSpacing = Self.cellSpacing * CGFloat(Self.columns + 1)
|
|
365
|
+
let width = (collectionView.bounds.width - totalSpacing) / CGFloat(Self.columns)
|
|
366
|
+
return CGSize(width: max(width, 1), height: max(width, 1))
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/// One grid cell: a thumbnail image view plus a small overlay spinner shown while its full-res
|
|
371
|
+
/// download is in flight.
|
|
372
|
+
private final class OpenMojiCell: UICollectionViewCell {
|
|
373
|
+
private let imageView = UIImageView()
|
|
374
|
+
private let spinner = UIActivityIndicatorView(style: .medium)
|
|
375
|
+
|
|
376
|
+
override init(frame: CGRect) {
|
|
377
|
+
super.init(frame: frame)
|
|
378
|
+
contentView.backgroundColor = DesignTokens.surfaceContainerHigh
|
|
379
|
+
contentView.layer.cornerRadius = DesignTokens.radiusMd
|
|
380
|
+
contentView.clipsToBounds = true
|
|
381
|
+
|
|
382
|
+
imageView.contentMode = .scaleAspectFit
|
|
383
|
+
contentView.addSubview(imageView)
|
|
384
|
+
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
385
|
+
NSLayoutConstraint.activate([
|
|
386
|
+
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 4),
|
|
387
|
+
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4),
|
|
388
|
+
imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
|
|
389
|
+
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4),
|
|
390
|
+
])
|
|
391
|
+
|
|
392
|
+
spinner.hidesWhenStopped = true
|
|
393
|
+
contentView.addSubview(spinner)
|
|
394
|
+
spinner.translatesAutoresizingMaskIntoConstraints = false
|
|
395
|
+
NSLayoutConstraint.activate([
|
|
396
|
+
spinner.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
|
397
|
+
spinner.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
398
|
+
])
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
required init?(coder: NSCoder) { nil }
|
|
402
|
+
|
|
403
|
+
func configure(url: URL, loader: RemoteImageLoader) {
|
|
404
|
+
loader.loadThumbnail(url: url, into: imageView)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
func setDownloading(_ downloading: Bool) {
|
|
408
|
+
if downloading { spinner.startAnimating() } else { spinner.stopAnimating() }
|
|
409
|
+
imageView.alpha = downloading ? 0.4 : 1.0
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
override func prepareForReuse() {
|
|
413
|
+
super.prepareForReuse()
|
|
414
|
+
imageView.image = nil
|
|
415
|
+
spinner.stopAnimating()
|
|
416
|
+
imageView.alpha = 1.0
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A single entry from OpenMoji's `openmoji.json` index that we care about.
|
|
4
|
+
/// Mirrors Android's `OpenMojiCatalog.Entry` in spirit (same field set).
|
|
5
|
+
struct OpenMojiEntry {
|
|
6
|
+
let emoji: String
|
|
7
|
+
let hexcode: String
|
|
8
|
+
let group: String
|
|
9
|
+
let annotation: String
|
|
10
|
+
let tags: String
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Fetches, disk-caches, and serves the OpenMoji sticker index
|
|
14
|
+
/// (`https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@master/data/openmoji.json`).
|
|
15
|
+
///
|
|
16
|
+
/// No third-party networking/JSON dependency is used — this codebase is deliberately
|
|
17
|
+
/// dependency-light (see `stickerAssets: [[String: Any]]` in `PhotoVideoEditorViewController`
|
|
18
|
+
/// for the existing dictionary-based JSON precedent). Plain `URLSession` + `JSONSerialization`.
|
|
19
|
+
final class OpenMojiCatalog {
|
|
20
|
+
static let shared = OpenMojiCatalog()
|
|
21
|
+
|
|
22
|
+
/// Fixed, sensible display order for OpenMoji's `group` values.
|
|
23
|
+
static let orderedGroups: [String] = [
|
|
24
|
+
"smileys-emotion",
|
|
25
|
+
"people-body",
|
|
26
|
+
"animals-nature",
|
|
27
|
+
"food-drink",
|
|
28
|
+
"travel-places",
|
|
29
|
+
"activities",
|
|
30
|
+
"objects",
|
|
31
|
+
"symbols",
|
|
32
|
+
"flags",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
private static let indexURL = URL(string: "https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@master/data/openmoji.json")!
|
|
36
|
+
private static let cacheFileName = "openmoji_index.json"
|
|
37
|
+
private static let cacheTimestampKey = "OpenMojiCatalog.cacheTimestamp"
|
|
38
|
+
private static let cacheMaxAgeSeconds: TimeInterval = 7 * 24 * 60 * 60 // 7 days
|
|
39
|
+
|
|
40
|
+
private let queue = DispatchQueue(label: "com.photovideoeditor.openmojicatalog", qos: .userInitiated)
|
|
41
|
+
private var entries: [OpenMojiEntry]?
|
|
42
|
+
private var isLoading = false
|
|
43
|
+
private var pendingCompletions: [([OpenMojiEntry]?, Error?) -> Void] = []
|
|
44
|
+
|
|
45
|
+
private init() {}
|
|
46
|
+
|
|
47
|
+
private var cacheFileURL: URL? {
|
|
48
|
+
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
|
49
|
+
.appendingPathComponent(Self.cacheFileName)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
enum CatalogError: Error {
|
|
53
|
+
case network(Error)
|
|
54
|
+
case badResponse
|
|
55
|
+
case emptyCache
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Loads the catalog (cache-first, network fallback with cache-refresh) and calls `completion`
|
|
59
|
+
/// on the main thread. Safe to call multiple times concurrently; duplicate in-flight calls are coalesced.
|
|
60
|
+
func load(completion: @escaping ([OpenMojiEntry]?, Error?) -> Void) {
|
|
61
|
+
if let entries {
|
|
62
|
+
DispatchQueue.main.async { completion(entries, nil) }
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
queue.async { [weak self] in
|
|
66
|
+
guard let self else { return }
|
|
67
|
+
self.pendingCompletions.append(completion)
|
|
68
|
+
if self.isLoading { return }
|
|
69
|
+
self.isLoading = true
|
|
70
|
+
self.loadLocked()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Must be called on `queue`. Decides whether to use the on-disk cache or fetch fresh, then
|
|
75
|
+
/// fans results out to every completion queued while the load was in flight.
|
|
76
|
+
private func loadLocked() {
|
|
77
|
+
let cacheAge = self.cacheAgeSeconds()
|
|
78
|
+
if let cacheAge, cacheAge < Self.cacheMaxAgeSeconds, let cached = self.readCache() {
|
|
79
|
+
self.finishLoad(entries: cached, error: nil)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
self.fetchFromNetwork { [weak self] data, error in
|
|
83
|
+
guard let self else { return }
|
|
84
|
+
self.queue.async {
|
|
85
|
+
if let data {
|
|
86
|
+
self.writeCache(data)
|
|
87
|
+
let parsed = self.parse(data)
|
|
88
|
+
self.finishLoad(entries: parsed, error: parsed.isEmpty ? CatalogError.badResponse : nil)
|
|
89
|
+
} else if let cached = self.readCache() {
|
|
90
|
+
// Network failed but we have a cache of any age — fall back to it.
|
|
91
|
+
self.finishLoad(entries: cached, error: nil)
|
|
92
|
+
} else {
|
|
93
|
+
self.finishLoad(entries: nil, error: error ?? CatalogError.emptyCache)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Must be called on `queue`.
|
|
100
|
+
private func finishLoad(entries: [OpenMojiEntry]?, error: Error?) {
|
|
101
|
+
if let entries { self.entries = entries }
|
|
102
|
+
let completions = self.pendingCompletions
|
|
103
|
+
self.pendingCompletions = []
|
|
104
|
+
self.isLoading = false
|
|
105
|
+
DispatchQueue.main.async {
|
|
106
|
+
completions.forEach { $0(entries, entries == nil ? error : nil) }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func fetchFromNetwork(completion: @escaping (Data?, Error?) -> Void) {
|
|
111
|
+
let task = URLSession.shared.dataTask(with: Self.indexURL) { data, response, error in
|
|
112
|
+
if let error {
|
|
113
|
+
completion(nil, CatalogError.network(error))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), let data else {
|
|
117
|
+
completion(nil, CatalogError.badResponse)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
completion(data, nil)
|
|
121
|
+
}
|
|
122
|
+
task.resume()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private func cacheAgeSeconds() -> TimeInterval? {
|
|
126
|
+
let timestamp = UserDefaults.standard.double(forKey: Self.cacheTimestampKey)
|
|
127
|
+
guard timestamp > 0 else { return nil }
|
|
128
|
+
return Date().timeIntervalSince1970 - timestamp
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private func readCache() -> [OpenMojiEntry]? {
|
|
132
|
+
guard let url = cacheFileURL, let data = try? Data(contentsOf: url) else { return nil }
|
|
133
|
+
let parsed = parse(data)
|
|
134
|
+
return parsed.isEmpty ? nil : parsed
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private func writeCache(_ data: Data) {
|
|
138
|
+
guard let url = cacheFileURL else { return }
|
|
139
|
+
do {
|
|
140
|
+
try data.write(to: url, options: .atomic)
|
|
141
|
+
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.cacheTimestampKey)
|
|
142
|
+
} catch {
|
|
143
|
+
// Best-effort cache; ignore write failures.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private func parse(_ data: Data) -> [OpenMojiEntry] {
|
|
148
|
+
guard let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
|
|
149
|
+
return raw.compactMap { dict in
|
|
150
|
+
guard
|
|
151
|
+
let hexcode = dict["hexcode"] as? String, !hexcode.isEmpty,
|
|
152
|
+
let group = dict["group"] as? String
|
|
153
|
+
else { return nil }
|
|
154
|
+
let emoji = dict["emoji"] as? String ?? ""
|
|
155
|
+
let annotation = dict["annotation"] as? String ?? ""
|
|
156
|
+
let tags = dict["tags"] as? String ?? ""
|
|
157
|
+
return OpenMojiEntry(emoji: emoji, hexcode: hexcode, group: group, annotation: annotation, tags: tags)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// MARK: - Query surface
|
|
162
|
+
|
|
163
|
+
func groups() -> [String] {
|
|
164
|
+
guard let entries else { return [] }
|
|
165
|
+
let present = Set(entries.map { $0.group })
|
|
166
|
+
return Self.orderedGroups.filter { present.contains($0) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func byGroup(_ group: String) -> [OpenMojiEntry] {
|
|
170
|
+
(entries ?? []).filter { $0.group == group }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func search(_ query: String) -> [OpenMojiEntry] {
|
|
174
|
+
let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
175
|
+
guard !needle.isEmpty else { return entries ?? [] }
|
|
176
|
+
return (entries ?? []).filter {
|
|
177
|
+
$0.annotation.lowercased().contains(needle) || $0.tags.lowercased().contains(needle)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func thumbnailURL(_ hexcode: String) -> URL {
|
|
182
|
+
URL(string: "https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@master/color/72x72/\(hexcode).png")!
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func fullURL(_ hexcode: String) -> URL {
|
|
186
|
+
URL(string: "https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@master/color/618x618/\(hexcode).png")!
|
|
187
|
+
}
|
|
188
|
+
}
|