react-native-attachment-kit 1.0.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.
Files changed (166) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +327 -0
  3. package/android/CMakeLists.txt +31 -0
  4. package/android/build.gradle +121 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
  7. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +1159 -0
  8. package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitPackage.kt +37 -0
  9. package/android/src/oldarch/java/com/rnattachmentkit/NativeRNAttachmentKitSpec.kt +19 -0
  10. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +1142 -0
  11. package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitPackage.kt +19 -0
  12. package/ios/README.md +213 -0
  13. package/ios/RNAttachmentKit.m +132 -0
  14. package/ios/RNAttachmentKit.swift +1787 -0
  15. package/ios/RNAttachmentKit.xcframework/Info.plist +44 -0
  16. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Assets.car +0 -0
  17. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Base.lproj/LaunchScreen.storyboardc/01J-lp-oVM-view-Ze5-6b-2t3.nib +0 -0
  18. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Base.lproj/LaunchScreen.storyboardc/Info.plist +0 -0
  19. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Base.lproj/LaunchScreen.storyboardc/UIViewController-01J-lp-oVM.nib +0 -0
  20. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +581 -0
  21. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Info.plist +0 -0
  22. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +34376 -0
  23. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +517 -0
  24. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  25. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +517 -0
  26. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/module.modulemap +3 -0
  27. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Pretendard-SemiBold.otf +0 -0
  28. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  29. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~dark.webp +0 -0
  30. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~light.webp +0 -0
  31. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~dark.webp +0 -0
  32. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~light.webp +0 -0
  33. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~dark.webp +0 -0
  34. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~light.webp +0 -0
  35. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~dark.webp +0 -0
  36. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~light.webp +0 -0
  37. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~dark.webp +0 -0
  38. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~light.webp +0 -0
  39. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~dark.webp +0 -0
  40. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~light.webp +0 -0
  41. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~dark.webp +0 -0
  42. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~light.webp +0 -0
  43. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~dark.webp +0 -0
  44. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~light.webp +0 -0
  45. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~dark.webp +0 -0
  46. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~light.webp +0 -0
  47. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~dark.webp +0 -0
  48. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~light.webp +0 -0
  49. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~dark.webp +0 -0
  50. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~light.webp +0 -0
  51. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~dark.webp +0 -0
  52. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~light.webp +0 -0
  53. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  54. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeRequirements +0 -0
  55. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +498 -0
  56. package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  57. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
  58. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Base.lproj/LaunchScreen.storyboardc/01J-lp-oVM-view-Ze5-6b-2t3.nib +0 -0
  59. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Base.lproj/LaunchScreen.storyboardc/Info.plist +0 -0
  60. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Base.lproj/LaunchScreen.storyboardc/UIViewController-01J-lp-oVM.nib +0 -0
  61. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Headers/RNAttachmentKit-Swift.h +1158 -0
  62. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Info.plist +0 -0
  63. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +34376 -0
  64. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +517 -0
  65. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  66. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +517 -0
  67. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +34376 -0
  68. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +517 -0
  69. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  70. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +517 -0
  71. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/module.modulemap +3 -0
  72. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Pretendard-SemiBold.otf +0 -0
  73. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
  74. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~dark.webp +0 -0
  75. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_reflect~light.webp +0 -0
  76. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~dark.webp +0 -0
  77. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_crop_rotate~light.webp +0 -0
  78. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~dark.webp +0 -0
  79. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_delete~light.webp +0 -0
  80. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~dark.webp +0 -0
  81. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_forward~light.webp +0 -0
  82. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~dark.webp +0 -0
  83. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_drawing_reward~light.webp +0 -0
  84. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~dark.webp +0 -0
  85. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_crop~light.webp +0 -0
  86. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~dark.webp +0 -0
  87. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_drawing~light.webp +0 -0
  88. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~dark.webp +0 -0
  89. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_effect~light.webp +0 -0
  90. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~dark.webp +0 -0
  91. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_emoji~light.webp +0 -0
  92. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~dark.webp +0 -0
  93. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_eraser~light.webp +0 -0
  94. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~dark.webp +0 -0
  95. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_mosaic~light.webp +0 -0
  96. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~dark.webp +0 -0
  97. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/ToolbarIcons/ic_editor_text~light.webp +0 -0
  98. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
  99. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeRequirements +0 -0
  100. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +553 -0
  101. package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
  102. package/ios/RNAttachmentKitAssets/Assets.xcassets/AccentColor.colorset/Contents.json +11 -0
  103. package/ios/RNAttachmentKitAssets/Assets.xcassets/AppIcon.appiconset/Contents.json +13 -0
  104. package/ios/RNAttachmentKitAssets/Assets.xcassets/Contents.json +6 -0
  105. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_back.imageset/Contents.json +24 -0
  106. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_back.imageset/ic_back_dark.png +0 -0
  107. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_back.imageset/ic_back_normal.png +0 -0
  108. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_drawing_delete.imageset/Contents.json +56 -0
  109. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_drawing_delete.imageset/delete_dark.webp +0 -0
  110. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_drawing_delete.imageset/delete_normal.webp +0 -0
  111. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_drawing_forward.imageset/Contents.json +56 -0
  112. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_drawing_forward.imageset/forward_dark.webp +0 -0
  113. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_drawing_forward.imageset/forward_normal.webp +0 -0
  114. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_drawing_reward.imageset/Contents.json +56 -0
  115. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_drawing_reward.imageset/reward_dark.webp +0 -0
  116. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_drawing_reward.imageset/reward_normal.webp +0 -0
  117. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_crop.imageset/Contents.json +52 -0
  118. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_crop.imageset/crop_dark.webp +0 -0
  119. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_crop.imageset/crop_normal.webp +0 -0
  120. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_drawing.imageset/Contents.json +52 -0
  121. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_drawing.imageset/drawing_dark.webp +0 -0
  122. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_drawing.imageset/drawing_normal.webp +0 -0
  123. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_effect.imageset/Contents.json +52 -0
  124. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_effect.imageset/effect_dark.webp +0 -0
  125. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_effect.imageset/effect_normal.webp +0 -0
  126. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_emoji.imageset/Contents.json +52 -0
  127. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_emoji.imageset/emoji_dark.webp +0 -0
  128. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_emoji.imageset/emoji_normal.webp +0 -0
  129. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_text.imageset/Contents.json +52 -0
  130. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_text.imageset/text_dark.webp +0 -0
  131. package/ios/RNAttachmentKitAssets/Assets.xcassets/ic_editor_text.imageset/text_norml.webp +0 -0
  132. package/ios/RNAttachmentKitAssets/Assets.xcassets/rotate_icon.imageset/Contents.json +32 -0
  133. package/ios/RNAttachmentKitAssets/Assets.xcassets/rotate_icon.imageset/rotate_dark.png +0 -0
  134. package/ios/RNAttachmentKitAssets/Assets.xcassets/rotate_icon.imageset/rotate_normal.png +0 -0
  135. package/ios/RNAttachmentKitAssets/Fonts/Pretendard-SemiBold.otf +0 -0
  136. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_reflect~dark.webp +0 -0
  137. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_reflect~light.webp +0 -0
  138. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_rotate~dark.webp +0 -0
  139. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_crop_rotate~light.webp +0 -0
  140. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_delete~dark.webp +0 -0
  141. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_delete~light.webp +0 -0
  142. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_forward~dark.webp +0 -0
  143. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_forward~light.webp +0 -0
  144. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_reward~dark.webp +0 -0
  145. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_drawing_reward~light.webp +0 -0
  146. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_crop~dark.webp +0 -0
  147. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_crop~light.webp +0 -0
  148. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_drawing~dark.webp +0 -0
  149. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_drawing~light.webp +0 -0
  150. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_effect~dark.webp +0 -0
  151. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_effect~light.webp +0 -0
  152. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_emoji~dark.webp +0 -0
  153. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_emoji~light.webp +0 -0
  154. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_eraser~dark.webp +0 -0
  155. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_eraser~light.webp +0 -0
  156. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_mosaic~dark.webp +0 -0
  157. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_mosaic~light.webp +0 -0
  158. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_text~dark.webp +0 -0
  159. package/ios/RNAttachmentKitAssets/ToolbarIcons/ic_editor_text~light.webp +0 -0
  160. package/package.json +68 -0
  161. package/react-native-attachment-kit.podspec +51 -0
  162. package/react-native.config.js +14 -0
  163. package/src/NativeAttachmentKit.ts +26 -0
  164. package/src/index.d.ts +56 -0
  165. package/src/index.ts +359 -0
  166. package/src/types.ts +317 -0
@@ -0,0 +1,1787 @@
1
+ //
2
+ // RNAttachmentKit.swift
3
+ // RNAttachmentKit
4
+ //
5
+ // Supports both Turbo Module (New Architecture) and Legacy Native Module
6
+ //
7
+
8
+ import UIKit
9
+ import Photos
10
+ import ImageIO
11
+ import RNAttachmentKit
12
+
13
+ #if RCT_NEW_ARCH_ENABLED
14
+ import React
15
+ #else
16
+ import React
17
+ #endif
18
+
19
+ #if canImport(SDWebImageWebPCoder)
20
+ import SDWebImageWebPCoder
21
+ #endif
22
+
23
+ @objc(RNAttachmentKit)
24
+ class RNAttachmentKit: RCTEventEmitter {
25
+
26
+ public static var shared: RNAttachmentKit?
27
+
28
+ private var zoomAnimator = ZoomTransitionAnimator()
29
+ private weak var activeViewerVC: RemoteImageViewerViewController?
30
+
31
+ override init() {
32
+ super.init()
33
+ RNAttachmentKit.shared = self
34
+ }
35
+
36
+ // 현재 표시 중인 갤러리 참조
37
+ private weak var currentGalleryVC: GalleryViewController?
38
+ private var editorTransitionDelegate: ImageEditorTransitionDelegate?
39
+ /// 열려 있는 미디어 갤러리 (galleryId → 화면)
40
+ fileprivate var mediaGalleries: [String: MediaGalleryViewController] = [:]
41
+
42
+ // MARK: - Module Setup
43
+
44
+ @objc
45
+ override static func moduleName() -> String! {
46
+ return "RNAttachmentKit"
47
+ }
48
+
49
+ @objc
50
+ override static func requiresMainQueueSetup() -> Bool {
51
+ return true
52
+ }
53
+
54
+ @objc
55
+ override func supportedEvents() -> [String]! {
56
+ return ["onSelectionChange", "onImageProcessed", "onPageSelected", "onViewerWillClose", "onImageDeleted",
57
+ "onGalleryItemDeleted", "onGalleryNeedPage", "onGalleryClosed", "onGalleryGridSizeChanged"]
58
+ }
59
+
60
+ // MARK: - Public Methods
61
+
62
+ static let defaultAnimationConfig: [String: Double] = [
63
+ "galleryOpen": 300.0,
64
+ "galleryClose": 250.0,
65
+ "editorOpen": 350.0,
66
+ "editorClose": 350.0,
67
+ "viewerOpen": 370.0,
68
+ "viewerClose": 370.0
69
+ ]
70
+
71
+ @objc
72
+ func getDefaultAnimationConfig(_ resolve: @escaping RCTPromiseResolveBlock,
73
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
74
+ resolve(RNAttachmentKit.defaultAnimationConfig)
75
+ }
76
+
77
+
78
+ @objc
79
+ func `init`(_ licenseKey: String,
80
+ resolve: @escaping RCTPromiseResolveBlock,
81
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
82
+ let result = LicenseManager.shared.initialize(with: licenseKey)
83
+ resolve(result)
84
+ }
85
+
86
+ // MARK: - Public Methods
87
+
88
+ @objc
89
+ func injectImageCache(_ urlString: String, localPath: String,
90
+ resolve: @escaping RCTPromiseResolveBlock,
91
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
92
+ if ViewerImageCache.shared.hasCache(for: urlString) {
93
+ resolve(true)
94
+ return
95
+ }
96
+
97
+ DispatchQueue.global(qos: .userInitiated).async {
98
+ // localPath might be a file:// URI or raw path
99
+ var path = localPath
100
+ if path.hasPrefix("file://") {
101
+ path = path.replacingOccurrences(of: "file://", with: "")
102
+ }
103
+ if let image = UIImage(contentsOfFile: path) {
104
+ if #available(iOS 15.0, *) {
105
+ image.prepareForDisplay { preparedImage in
106
+ let finalImage = preparedImage ?? image
107
+ ViewerImageCache.shared.saveImage(finalImage, for: urlString)
108
+ resolve(true)
109
+ }
110
+ } else {
111
+ UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
112
+ image.draw(at: .zero)
113
+ let decodedImage = UIGraphicsGetImageFromCurrentImageContext() ?? image
114
+ UIGraphicsEndImageContext()
115
+ ViewerImageCache.shared.saveImage(decodedImage, for: urlString)
116
+ resolve(true)
117
+ }
118
+ } else {
119
+ reject("ERROR", "Failed to load image from local path", nil)
120
+ }
121
+ }
122
+ }
123
+
124
+ @objc
125
+ func updateSourceRect(_ options: NSDictionary,
126
+ resolve: @escaping RCTPromiseResolveBlock,
127
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
128
+ let optDict = options as? [String: Any] ?? [:]
129
+ let x = (optDict["x"] as? NSNumber).map { CGFloat(truncating: $0) } ?? (optDict["x"] as? Double).map { CGFloat($0) }
130
+ let y = (optDict["y"] as? NSNumber).map { CGFloat(truncating: $0) } ?? (optDict["y"] as? Double).map { CGFloat($0) }
131
+ let w = (optDict["width"] as? NSNumber).map { CGFloat(truncating: $0) } ?? (optDict["width"] as? Double).map { CGFloat($0) }
132
+ let h = (optDict["height"] as? NSNumber).map { CGFloat(truncating: $0) } ?? (optDict["height"] as? Double).map { CGFloat($0) }
133
+
134
+ if let x = x, let y = y, let w = w, let h = h {
135
+ DispatchQueue.main.async {
136
+ self.zoomAnimator.sourceRect = CGRect(x: x, y: y, width: w, height: h)
137
+
138
+ var mask: CACornerMask = []
139
+ if let corners = optDict["sourceBorderCorners"] as? [String] {
140
+ if corners.contains("topLeft") { mask.insert(.layerMinXMinYCorner) }
141
+ if corners.contains("topRight") { mask.insert(.layerMaxXMinYCorner) }
142
+ if corners.contains("bottomLeft") { mask.insert(.layerMinXMaxYCorner) }
143
+ if corners.contains("bottomRight") { mask.insert(.layerMaxXMaxYCorner) }
144
+ } else {
145
+ mask = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
146
+ }
147
+ self.zoomAnimator.sourceBorderCorners = mask
148
+ // 넘긴 사진 자리의 라운드 값도 갱신 (예: 원형 대표 사진 → 둥근 사각 카드).
149
+ // 이전에는 처음 연 사진의 라운드가 그대로 남아, 다른 모양의 자리로 닫힐 때 모양이 틀어졌음.
150
+ if let r = optDict["sourceBorderRadius"] as? NSNumber {
151
+ self.zoomAnimator.sourceBorderRadius = CGFloat(truncating: r)
152
+ } else if let r = optDict["sourceBorderRadius"] as? Double {
153
+ self.zoomAnimator.sourceBorderRadius = CGFloat(r)
154
+ }
155
+ self.zoomAnimator.updateMaskView()
156
+
157
+ resolve(true)
158
+ }
159
+ } else {
160
+ reject("ERROR", "Invalid coordinates", nil)
161
+ }
162
+ }
163
+
164
+ @objc
165
+ func openViewer(_ options: NSDictionary,
166
+ resolve: @escaping RCTPromiseResolveBlock,
167
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
168
+ guard let images = options["images"] as? [String] else {
169
+ reject("ERROR", "images array is required", nil)
170
+ return
171
+ }
172
+
173
+ let initialIndex = options["initialIndex"] as? Int ?? 0
174
+ let placeholderImages = options["placeholderImages"] as? [String]
175
+ // cacheFiles: images 와 같은 순서의 로컬 캐시 파일 경로 (null 항목은 NSNull 로 옴 → 빈 문자열)
176
+ let cacheFiles: [String]? = (options["cacheFiles"] as? [Any])?.map { ($0 as? String) ?? "" }
177
+ let themeColorHex = options["themeColor"] as? String
178
+ let languageCode = options["languageCode"] as? String ?? "en"
179
+ let viewerTitle = options["title"] as? String
180
+ let animationType = options["animationType"] as? String ?? (options["sourceRect"] != nil ? "zoom" : "slide")
181
+ let closeAnimationType = options["closeAnimationType"] as? String
182
+ // 저장/공유/삭제 상단바 버튼 - 사용자가 준 스크린샷(전체 저장/이 사진만 저장 등)에
183
+ // 맞춘 기본 한국어 문구가 있으므로, JS에서 안 넘기면 그 기본값을 그대로 씁니다.
184
+ let showActionButtons = (options["showActionButtons"] as? Bool) ?? true
185
+ let saveAllText = options["saveAllText"] as? String
186
+ let saveOneText = options["saveOneText"] as? String
187
+ let shareAllText = options["shareAllText"] as? String
188
+ let shareOneText = options["shareOneText"] as? String
189
+ let actionCancelText = options["actionCancelText"] as? String
190
+ let deleteConfirmTitle = options["deleteConfirmTitle"] as? String
191
+ let deleteConfirmMessage = options["deleteConfirmMessage"] as? String
192
+ let deleteConfirmGroupMessage = options["deleteConfirmGroupMessage"] as? String
193
+ let deleteButtonText = options["deleteButtonText"] as? String
194
+ // patch405: 삭제 방식 선택지 [{ key, label, destructive }]
195
+ let deleteOptions: [RemoteImageViewerViewController.DeleteOption] =
196
+ ((options["deleteOptions"] as? [Any]) ?? []).compactMap { raw in
197
+ guard let d = raw as? [String: Any],
198
+ let key = d["key"] as? String, !key.isEmpty,
199
+ let label = d["label"] as? String, !label.isEmpty else { return nil }
200
+ let destructive = (d["destructive"] as? Bool) ?? ((d["destructive"] as? NSNumber)?.boolValue ?? false)
201
+ return RemoteImageViewerViewController.DeleteOption(key: key, label: label, destructive: destructive)
202
+ }
203
+
204
+ var sourceBackgroundColor: UIColor = .clear
205
+ if let bgString = options["sourceBackgroundColorString"] as? String {
206
+ let comps = bgString.split(separator: ",")
207
+ if comps.count >= 3,
208
+ let r = Double(comps[0].trimmingCharacters(in: .whitespaces)),
209
+ let g = Double(comps[1].trimmingCharacters(in: .whitespaces)),
210
+ let b = Double(comps[2].trimmingCharacters(in: .whitespaces)) {
211
+ sourceBackgroundColor = UIColor(red: CGFloat(r/255.0), green: CGFloat(g/255.0), blue: CGFloat(b/255.0), alpha: 1.0)
212
+ }
213
+ } else if let bgHex = options["sourceBackgroundColor"] as? String, let color = UIColor(hexString: bgHex) {
214
+ sourceBackgroundColor = color
215
+ }
216
+
217
+ var parsedRect: CGRect?
218
+ if let sourceRectDict = options["sourceRect"] as? NSDictionary {
219
+ var x: CGFloat = 0
220
+ var y: CGFloat = 0
221
+ var w: CGFloat = 0
222
+ var h: CGFloat = 0
223
+ if let xNum = sourceRectDict["x"] as? NSNumber { x = CGFloat(truncating: xNum) } else if let xDbl = sourceRectDict["x"] as? Double { x = CGFloat(xDbl) }
224
+ if let yNum = sourceRectDict["y"] as? NSNumber { y = CGFloat(truncating: yNum) } else if let yDbl = sourceRectDict["y"] as? Double { y = CGFloat(yDbl) }
225
+ if let wNum = sourceRectDict["width"] as? NSNumber { w = CGFloat(truncating: wNum) } else if let wDbl = sourceRectDict["width"] as? Double { w = CGFloat(wDbl) }
226
+ if let hNum = sourceRectDict["height"] as? NSNumber { h = CGFloat(truncating: hNum) } else if let hDbl = sourceRectDict["height"] as? Double { h = CGFloat(hDbl) }
227
+ parsedRect = CGRect(x: x, y: y, width: w, height: h)
228
+ }
229
+
230
+ var sourceBorderRadius: CGFloat = 0
231
+ if let radiusNum = options["sourceBorderRadius"] as? NSNumber {
232
+ sourceBorderRadius = CGFloat(truncating: radiusNum)
233
+ } else if let radiusDbl = options["sourceBorderRadius"] as? Double {
234
+ sourceBorderRadius = CGFloat(radiusDbl)
235
+ }
236
+
237
+ var mask: CACornerMask = []
238
+ if let corners = options["sourceBorderCorners"] as? [String] {
239
+ if corners.contains("topLeft") { mask.insert(.layerMinXMinYCorner) }
240
+ if corners.contains("topRight") { mask.insert(.layerMaxXMinYCorner) }
241
+ if corners.contains("bottomLeft") { mask.insert(.layerMinXMaxYCorner) }
242
+ if corners.contains("bottomRight") { mask.insert(.layerMaxXMaxYCorner) }
243
+ // Empty array means no rounded corners (e.g. interior group image cell)
244
+ } else {
245
+ mask = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
246
+ }
247
+
248
+ DispatchQueue.main.async {
249
+ guard let rootViewController = self.getRootViewController() else {
250
+ let error = NSError(domain: "RNAttachmentKit", code: 0, userInfo: nil)
251
+ reject("ERROR", "Unable to get root view controller", error)
252
+ return
253
+ }
254
+
255
+ let viewerVC = RemoteImageViewerViewController(imageUrls: images, initialIndex: initialIndex)
256
+ viewerVC.languageCode = languageCode
257
+ viewerVC.viewerTitle = viewerTitle
258
+ viewerVC.placeholderImages = placeholderImages
259
+ viewerVC.cacheFiles = cacheFiles
260
+ if let hex = themeColorHex, let color = UIColor(hexString: hex) {
261
+ viewerVC.themeColor = color
262
+ }
263
+ viewerVC.showActionButtons = showActionButtons
264
+ if let saveAllText = saveAllText { viewerVC.saveAllText = saveAllText }
265
+ if let saveOneText = saveOneText { viewerVC.saveOneText = saveOneText }
266
+ if let shareAllText = shareAllText { viewerVC.shareAllText = shareAllText }
267
+ if let shareOneText = shareOneText { viewerVC.shareOneText = shareOneText }
268
+ if let actionCancelText = actionCancelText { viewerVC.actionCancelText = actionCancelText }
269
+ if let deleteConfirmTitle = deleteConfirmTitle { viewerVC.deleteConfirmTitle = deleteConfirmTitle }
270
+ viewerVC.deleteConfirmMessage = deleteConfirmMessage
271
+ viewerVC.deleteConfirmGroupMessage = deleteConfirmGroupMessage
272
+ if let deleteButtonText = deleteButtonText { viewerVC.deleteButtonText = deleteButtonText }
273
+ viewerVC.deleteOptions = deleteOptions
274
+
275
+ self.zoomAnimator.animationType = animationType
276
+ self.zoomAnimator.closeAnimationType = closeAnimationType
277
+ if let rect = parsedRect {
278
+ self.zoomAnimator.sourceRect = rect
279
+ }
280
+ self.zoomAnimator.sourceBorderRadius = sourceBorderRadius
281
+ self.zoomAnimator.sourceBorderCorners = mask
282
+ self.zoomAnimator.sourceBackgroundColor = sourceBackgroundColor
283
+ // Reset any sourceImage left over from a previous openViewer() call - it's only
284
+ // meant to carry this call's local placeholder (see below), and must not leak
285
+ // into a later call that has no placeholder of its own.
286
+ self.zoomAnimator.sourceImage = nil
287
+
288
+ viewerVC.modalPresentationStyle = .custom
289
+ viewerVC.transitioningDelegate = self
290
+
291
+ self.activeViewerVC = viewerVC
292
+ viewerVC.onPageChanged = { [weak self] (index: Int) -> Void in
293
+ guard let self = self else { return }
294
+ let eventBody: [String: Any] = ["index": index]
295
+ self.sendEvent(withName: "onPageSelected", body: eventBody)
296
+ }
297
+ // ⚠️ 이전에는 이 클로저를 전혀 연결하지 않아서 "onViewerWillClose" 네이티브 이벤트가
298
+ // iOS에서는 절대 발생하지 않았습니다. JS 쪽(src/index.ts)은 정확히 이 이벤트가 와야만
299
+ // "onPageSelected" 구독을 정리(subscriptionPageSelected.remove())하기 때문에, 뷰어를
300
+ // 열 때마다 그 구독이 영구히 누수됐습니다. onPageSelected는 뷰어별로 구분되지 않는
301
+ // 전역 이벤트라서, 그룹 이미지를 여러 번 열면 예전 메시지의 리스너까지 전부 같이
302
+ // 반응해서 서로 다른 메시지의 measureMap 기준으로 updateSourceRect를 호출해
303
+ // 공유된 zoomAnimator.sourceRect를 덮어써버렸습니다 - "스와이프 후 닫으면 전혀
304
+ // 다른 그룹 이미지 위치로 애니메이션되는" 버그의 근본 원인이었습니다. 이제 실제로
305
+ // 뷰어가 닫히기 시작할 때 이벤트를 보내서 JS가 제때 구독을 정리하도록 합니다.
306
+ viewerVC.onViewerWillClose = { [weak self] () -> Void in
307
+ self?.sendEvent(withName: "onViewerWillClose", body: nil)
308
+ }
309
+ viewerVC.onImageDeleted = { [weak self] (index: Int, url: String, option: String?) -> Void in
310
+ guard let self = self else { return }
311
+ var eventBody: [String: Any] = ["index": index, "url": url]
312
+ if let option = option { eventBody["option"] = option } // patch405
313
+ self.sendEvent(withName: "onImageDeleted", body: eventBody)
314
+ }
315
+ viewerVC.onWillDismissForDelete = { [weak self] () -> Void in
316
+ self?.zoomAnimator.fadeOutOnDismiss = true
317
+ }
318
+
319
+ let resolveResult: [String: Any] = ["success": true]
320
+
321
+ // Cache-first, then local placeholder, then wait-for-download - in that priority
322
+ // order. If the full-resolution image is ALREADY cached (memory or disk), we must
323
+ // animate with THAT, not with a lower-quality local thumbnail placeholder: using the
324
+ // placeholder here would show a needless blurry-then-sharp flash for an image that's
325
+ // already available in full quality. The placeholder is only a stand-in for when the
326
+ // full image genuinely isn't available yet (mirrors Android's onlyRetrieveFromCache
327
+ // gating - "use the real image if it's already cached, otherwise the local thumbnail").
328
+ // NOTE: only the memory cache is checked here (not the disk cache) - this bridge
329
+ // file is compiled directly as a pod source file, a SEPARATE module from the
330
+ // ViewerImageCache class (which ships compiled inside RNAttachmentKit.xcframework).
331
+ // Cross-module Swift visibility requires `public`, and getDiskImageSynchronously()
332
+ // wasn't public until this same fix - until the xcframework is rebuilt (deploy.rb /
333
+ // build_xcframework.sh) and re-synced to Dist, calling it here would fail to compile.
334
+ // Memory-cache is the common case anyway (an image just viewed/downloaded this
335
+ // session); a disk-only cache hit will fall through to the placeholder path below,
336
+ // which is still correct (just not maximally optimal) until that rebuild happens.
337
+ var cachedFullImage: UIImage? = nil
338
+ if images.indices.contains(initialIndex) {
339
+ let urlString = images[initialIndex]
340
+ cachedFullImage = ViewerImageCache.shared.getMemoryImage(for: urlString)
341
+ }
342
+
343
+ var initialPlaceholderImage: UIImage? = nil
344
+ if cachedFullImage == nil, let placeholderImages = placeholderImages, placeholderImages.indices.contains(initialIndex) {
345
+ let placeholderUrlString = placeholderImages[initialIndex]
346
+ if placeholderUrlString.hasPrefix("file://"), let url = URL(string: placeholderUrlString) {
347
+ initialPlaceholderImage = UIImage(contentsOfFile: url.path)
348
+ }
349
+ }
350
+
351
+ if let cachedImage = cachedFullImage {
352
+ self.zoomAnimator.sourceImage = cachedImage
353
+ rootViewController.present(viewerVC, animated: true, completion: {
354
+ resolve(resolveResult)
355
+ })
356
+ } else if let placeholderImage = initialPlaceholderImage {
357
+ self.zoomAnimator.sourceImage = placeholderImage
358
+ rootViewController.present(viewerVC, animated: true, completion: {
359
+ resolve(resolveResult)
360
+ })
361
+ if images.indices.contains(initialIndex) {
362
+ // Kick off/continue the full-resolution fetch in the background so it's
363
+ // ready (and cached) as soon as possible; the viewer's own page loading
364
+ // (ZoomableImageItemView.loadImage) will pick it up and swap it in.
365
+ RemoteImageViewerViewController.prefetchImage(from: images[initialIndex], completion: { _ in })
366
+ }
367
+ } else if images.indices.contains(initialIndex) {
368
+ // No local placeholder available (exceptional case) - fall back to the previous
369
+ // behavior: present immediately if already cached, otherwise wait for the
370
+ // download to complete since there's nothing to animate with yet.
371
+ let initialUrl = images[initialIndex]
372
+ RemoteImageViewerViewController.prefetchImage(from: initialUrl) { _ in
373
+ DispatchQueue.main.async {
374
+ rootViewController.present(viewerVC, animated: true, completion: {
375
+ resolve(resolveResult)
376
+ })
377
+ }
378
+ }
379
+ } else {
380
+ rootViewController.present(viewerVC, animated: true, completion: {
381
+ resolve(resolveResult)
382
+ })
383
+ }
384
+ }
385
+ }
386
+
387
+ @objc
388
+ func updateViewerSourceRect(_ rect: NSDictionary,
389
+ resolve: @escaping RCTPromiseResolveBlock,
390
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
391
+ DispatchQueue.main.async {
392
+ let x = (rect["x"] as? NSNumber).map { CGFloat(truncating: $0) } ?? (rect["x"] as? Double).map { CGFloat($0) } ?? 0
393
+ let y = (rect["y"] as? NSNumber).map { CGFloat(truncating: $0) } ?? (rect["y"] as? Double).map { CGFloat($0) } ?? 0
394
+ let w = (rect["width"] as? NSNumber).map { CGFloat(truncating: $0) } ?? (rect["width"] as? Double).map { CGFloat($0) } ?? 0
395
+ let h = (rect["height"] as? NSNumber).map { CGFloat(truncating: $0) } ?? (rect["height"] as? Double).map { CGFloat($0) } ?? 0
396
+
397
+ var mask: CACornerMask = []
398
+ if let corners = rect["sourceBorderCorners"] as? [String] {
399
+ if corners.contains("topLeft") { mask.insert(.layerMinXMinYCorner) }
400
+ if corners.contains("topRight") { mask.insert(.layerMaxXMinYCorner) }
401
+ if corners.contains("bottomLeft") { mask.insert(.layerMinXMaxYCorner) }
402
+ if corners.contains("bottomRight") { mask.insert(.layerMaxXMaxYCorner) }
403
+ if mask.isEmpty { mask = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] }
404
+ } else {
405
+ mask = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
406
+ }
407
+
408
+ self.zoomAnimator.sourceRect = CGRect(x: x, y: y, width: w, height: h)
409
+ self.zoomAnimator.sourceBorderCorners = mask
410
+ self.zoomAnimator.updateMaskView()
411
+ resolve(nil)
412
+ }
413
+ }
414
+
415
+ @objc
416
+ func openEditor(_ options: NSDictionary,
417
+ resolve: @escaping RCTPromiseResolveBlock,
418
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
419
+ guard let uri = options["uri"] as? String else {
420
+ reject("ERROR", "uri is required", nil)
421
+ return
422
+ }
423
+
424
+ var themeColor: UIColor? = nil
425
+ if let themeColorHex = options["themeColor"] as? String {
426
+ themeColor = UIColor(hexString: themeColorHex)
427
+ }
428
+
429
+ var parsedMaxWidth: Int? = nil
430
+ var parsedMaxHeight: Int? = nil
431
+ if let maxW = options["maxWidth"] as? Int { parsedMaxWidth = maxW }
432
+ if let maxH = options["maxHeight"] as? Int { parsedMaxHeight = maxH }
433
+
434
+ DispatchQueue.main.async {
435
+ guard let rootViewController = self.getRootViewController() else {
436
+ reject("ERROR", "Unable to get root view controller", nil)
437
+ return
438
+ }
439
+
440
+
441
+ if uri.hasPrefix("ph://") {
442
+ let localIdentifier = uri.replacingOccurrences(of: "ph://", with: "")
443
+ let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
444
+
445
+ guard let asset = fetchResult.firstObject else {
446
+ reject("ERROR", "Asset not found", nil)
447
+ return
448
+ }
449
+
450
+ let editorVC = ImageEditorViewController()
451
+ editorVC.languageCode = options["languageCode"] as? String ?? "en"
452
+ editorVC.allAssets = [asset]
453
+ editorVC.currentIndex = 0
454
+ editorVC.modalPresentationStyle = .fullScreen
455
+ editorVC.singlePhotoMode = true
456
+ if let color = themeColor {
457
+ editorVC.themeColor = color
458
+ }
459
+
460
+ if let editedUriStr = options["editedFileUri"] as? String,
461
+ let url = URL(string: editedUriStr) ?? URL(string: "file://" + editedUriStr.replacingOccurrences(of: "file://", with: "")),
462
+ let imgData = try? Data(contentsOf: url),
463
+ let img = UIImage(data: imgData) {
464
+ editorVC.croppedImages[0] = img
465
+ }
466
+
467
+ editorVC.onConfirm = { [weak self, weak editorVC] (editedAsset, editedImage, filterState, caption) in
468
+ editorVC?.dismiss(animated: true) {
469
+ self?.processEditorResult(asset: editedAsset, croppedImage: editedImage, filterState: filterState, caption: caption, galleryVC: nil, targetMaxWidth: parsedMaxWidth, targetMaxHeight: parsedMaxHeight, resolve: { resultArr in
470
+ if let arr = resultArr as? [[String: Any]], let first = arr.first {
471
+ resolve(first)
472
+ } else {
473
+ resolve(resultArr)
474
+ }
475
+ }, reject: reject)
476
+ }
477
+ }
478
+
479
+ editorVC.onCancel = { [weak editorVC] in
480
+ editorVC?.dismiss(animated: true) {
481
+ reject("CANCELLED", "User cancelled editor", nil)
482
+ }
483
+ }
484
+
485
+ var topController = rootViewController
486
+ while let presented = topController.presentedViewController {
487
+ topController = presented
488
+ }
489
+ topController.present(editorVC, animated: true)
490
+
491
+ } else {
492
+ guard let url = URL(string: uri) ?? URL(string: "file://" + uri.replacingOccurrences(of: "file://", with: "")) else {
493
+ reject("ERROR", "Invalid URI format", nil)
494
+ return
495
+ }
496
+
497
+ DispatchQueue.global().async {
498
+ if let imgData = try? Data(contentsOf: url), let image = UIImage(data: imgData) {
499
+ DispatchQueue.main.async {
500
+ let editorVC = ImageEditorViewController()
501
+ editorVC.languageCode = options["languageCode"] as? String ?? "en"
502
+ editorVC.standaloneImage = image
503
+ editorVC.currentIndex = 0
504
+ editorVC.modalPresentationStyle = .fullScreen
505
+ editorVC.singlePhotoMode = true
506
+ if let color = themeColor {
507
+ editorVC.themeColor = color
508
+ }
509
+
510
+ editorVC.onConfirm = { [weak self, weak editorVC] (editedAsset, editedImage, filterState, caption) in
511
+ editorVC?.dismiss(animated: true) {
512
+ self?.processEditorResult(asset: editedAsset, croppedImage: editedImage, filterState: filterState, caption: caption, galleryVC: nil, targetMaxWidth: parsedMaxWidth, targetMaxHeight: parsedMaxHeight, resolve: { resultArr in
513
+ var resultDict = (resultArr as? [[String: Any]])?.first ?? (resultArr as? [String: Any]) ?? [:]
514
+ // For remote URLs, we preserve originalUri if editedFileUri isn't provided
515
+ let originalRemoteUrl = (options["editedFileUri"] as? String) ?? uri
516
+ resultDict["originalUri"] = originalRemoteUrl
517
+ resolve(resultDict)
518
+ }, reject: reject)
519
+ }
520
+ }
521
+
522
+ editorVC.onCancel = { [weak editorVC] in
523
+ editorVC?.dismiss(animated: true) {
524
+ reject("CANCELLED", "User cancelled editor", nil)
525
+ }
526
+ }
527
+
528
+ var topController = rootViewController
529
+ while let presented = topController.presentedViewController {
530
+ topController = presented
531
+ }
532
+ topController.present(editorVC, animated: true)
533
+ }
534
+ } else {
535
+ DispatchQueue.main.async {
536
+ reject("ERROR", "Failed to load image from URI", nil)
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ }
543
+ }
544
+
545
+ @objc
546
+ func openGallery(_ options: NSDictionary,
547
+ resolve: @escaping RCTPromiseResolveBlock,
548
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
549
+ let startTime = CFAbsoluteTimeGetCurrent()
550
+ print("✅ [DEBUG] received options: \(options)")
551
+ print("🔵 [DEBUG] openGallery 시작 - 시간: \(startTime)")
552
+
553
+ // Main thread에서 실행
554
+ DispatchQueue.main.async { [weak self] () -> Void in
555
+ guard let self = self else {
556
+ reject("ERROR", "Module deallocated", nil)
557
+ return
558
+ }
559
+
560
+ print("🔵 [DEBUG] workItem 실행 시작 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - startTime))초")
561
+
562
+ let step1 = CFAbsoluteTimeGetCurrent()
563
+ guard let rootViewController = self.getRootViewController() else {
564
+ print("❌ [DEBUG] getRootViewController 실패")
565
+ reject("ERROR", "Unable to get root view controller", nil)
566
+ return
567
+ }
568
+ print("🔵 [DEBUG] getRootViewController 완료 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - step1))초")
569
+
570
+ let step2 = CFAbsoluteTimeGetCurrent()
571
+ print("🔵 [DEBUG] GalleryViewController 생성 시작...")
572
+ let galleryVC = GalleryViewController()
573
+ print("🔵 [DEBUG] GalleryViewController 생성 완료 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - step2))초")
574
+
575
+ // 현재 갤러리 참조 저장
576
+ self.currentGalleryVC = galleryVC
577
+
578
+ // 선택 변경 이벤트 콜백 설정
579
+ galleryVC.onSelectionChanged = { [weak self] selectedCount, maxSelection in
580
+ self?.sendEvent(withName: "onSelectionChange", body: [
581
+ "selectedCount": selectedCount,
582
+ "maxSelection": maxSelection
583
+ ])
584
+ }
585
+
586
+ // 선택 방식 (selectionMode: 'single' | 'multi', 생략 시 기존 규칙)
587
+ // - 'multi' : maxSelection 이 1 이어도 멀티와 똑같이 (autoCloseOnSelect 무시, 1 미만이면 1장)
588
+ // - 'single': 기존 단일 동작 (maxSelection 을 1로, autoCloseOnSelect 면 0)
589
+ // - 생략 : 기존 그대로 (autoCloseOnSelect → 0, 아니면 maxSelection)
590
+ let selectionMode = (options["selectionMode"] as? String)?.lowercased()
591
+ let autoCloseOnSelect = options["autoCloseOnSelect"] as? Bool ?? false
592
+ galleryVC.forceMultiMode = (selectionMode == "multi")
593
+ if selectionMode == "multi" {
594
+ let requested = options["maxSelection"] as? Int ?? 1
595
+ galleryVC.maxSelection = requested < 1 ? 1 : requested
596
+ } else if autoCloseOnSelect {
597
+ // maxSelection 설정 (autoCloseOnSelect가 true면 0으로 설정하여 단일 선택 자동 닫기 처리)
598
+ galleryVC.maxSelection = 0
599
+ } else if selectionMode == "single" {
600
+ galleryVC.maxSelection = 1
601
+ } else if let maxSelection = options["maxSelection"] as? Int {
602
+ galleryVC.maxSelection = maxSelection
603
+ }
604
+
605
+ // maxWidth, maxHeight 옵션 (미설정 또는 0이면 원본 크기 유지)
606
+ galleryVC.maxWidth = (options["maxWidth"] as? Int) ?? 0
607
+ galleryVC.maxHeight = (options["maxHeight"] as? Int) ?? 0
608
+
609
+ // outputFormat: "webp" | "jpg" | "png" (기본값: "webp")
610
+ let fmt = (options["outputFormat"] as? String)?.lowercased() ?? "webp"
611
+ galleryVC.outputFormat = ["webp", "jpg", "png"].contains(fmt) ? fmt : "webp"
612
+
613
+ if let themeColorHex = options["themeColor"] as? String {
614
+ galleryVC.themeColorHex = themeColorHex
615
+ }
616
+
617
+ // 언어 코드 설정 (기본값: "en") - 이것이 먼저 설정되어 텍스트 자동 적용
618
+ galleryVC.languageCode = options["languageCode"] as? String ?? "en"
619
+
620
+ // 개별 텍스트 커스터마이징 (옵션, languageCode 오버라이드)
621
+ if let allText = options["allItemsText"] as? String {
622
+ galleryVC.allItemsText = allText
623
+ }
624
+ if let selectedText = options["selectedItemsText"] as? String {
625
+ galleryVC.selectedItemsText = selectedText
626
+ }
627
+ if let doneText = options["doneButtonText"] as? String {
628
+ galleryVC.doneButtonText = doneText
629
+ }
630
+ if let recentsText = options["recentsAlbumText"] as? String {
631
+ galleryVC.recentsAlbumText = recentsText
632
+ }
633
+
634
+ let enableEditor = options["enableEditor"] as? Bool ?? false
635
+ galleryVC.profileMode = options["profileMode"] as? Bool ?? false
636
+
637
+ if galleryVC.profileMode {
638
+ galleryVC.allowsEditing = enableEditor
639
+ galleryVC.onProfileCropComplete = { [weak self, weak galleryVC] asset, croppedImage in
640
+ guard let self = self, let currentGallery = galleryVC else { return }
641
+
642
+ if enableEditor {
643
+ let editorVC = ImageEditorViewController()
644
+ editorVC.languageCode = options["languageCode"] as? String ?? "en"
645
+ editorVC.allAssets = [asset]
646
+ editorVC.currentIndex = 0
647
+ editorVC.singlePhotoMode = true
648
+ editorVC.disableCrop = true
649
+ editorVC.croppedImages = [0: croppedImage]
650
+ editorVC.modalPresentationStyle = .overFullScreen
651
+
652
+ if let hex = currentGallery.themeColorHex, let color = UIColor(hexString: hex) {
653
+ editorVC.themeColor = color
654
+ }
655
+
656
+ editorVC.onConfirm = { [weak self] (editedAsset: PHAsset?, finalCroppedImage: UIImage?, filterState: FilterState, caption: String) in
657
+ guard let self = self else { return }
658
+ let navToDismiss = currentGallery.navigationController ?? currentGallery
659
+ let presenter = navToDismiss.presentingViewController ?? navToDismiss
660
+ presenter.dismiss(animated: true) {
661
+ self.processEditorResult(asset: editedAsset, croppedImage: finalCroppedImage ?? croppedImage, filterState: filterState, caption: caption, galleryVC: currentGallery, resolve: resolve, reject: reject)
662
+ }
663
+ }
664
+
665
+ editorVC.onCancel = { [weak currentGallery] in
666
+ currentGallery?.selectedAssets.removeAll()
667
+ currentGallery?.refreshSelectionAfterEdit()
668
+ }
669
+
670
+ var savedDetent: UISheetPresentationController.Detent.Identifier? = nil
671
+ if #available(iOS 15.0, *) {
672
+ savedDetent = currentGallery.navigationController?.sheetPresentationController?.selectedDetentIdentifier
673
+ }
674
+
675
+ let presenterVC = currentGallery.presentedViewController ?? currentGallery
676
+ presenterVC.present(editorVC, animated: true) {
677
+ if #available(iOS 15.0, *) {
678
+ if let saved = savedDetent {
679
+ currentGallery.navigationController?.sheetPresentationController?.selectedDetentIdentifier = saved
680
+ }
681
+ }
682
+ }
683
+ } else {
684
+ // Edit is disabled, directly process the cropped image
685
+ let navToDismiss = currentGallery.navigationController ?? currentGallery
686
+ let presenter = navToDismiss.presentingViewController ?? navToDismiss
687
+ presenter.dismiss(animated: true) {
688
+ self.processEditorResult(asset: asset, croppedImage: croppedImage, filterState: FilterState(filterId: "original", intensity: 1.0), caption: "", galleryVC: currentGallery, resolve: resolve, reject: reject)
689
+ }
690
+ }
691
+ }
692
+ }
693
+
694
+ if enableEditor {
695
+ // allowsEditing 활성화: GalleryVC 내 탭 동작 변경
696
+ galleryVC.allowsEditing = true
697
+
698
+ galleryVC.onSingleImageTappedForEdit = { [weak self] (asset: PHAsset, sourceFrame: CGRect, sourceImage: UIImage?) -> Void in
699
+ self?._turbo_handleSingleImageTappedForEdit(asset: asset, sourceFrame: sourceFrame, sourceImage: sourceImage, options: options, resolve: resolve, reject: reject)
700
+ }
701
+ }
702
+
703
+ galleryVC.onImagesSelected = { [weak self] selectedImages in
704
+ guard let self = self else { resolve([]); return }
705
+
706
+ guard !selectedImages.isEmpty else {
707
+ self.currentGalleryVC = nil
708
+ reject("CANCELLED", "User cancelled the image picker", nil)
709
+ return
710
+ }
711
+
712
+ var results: [[String: Any]] = []
713
+ let tempDir = self.prepareCacheDirectory()
714
+ let maxWidth = galleryVC.maxWidth
715
+ let maxHeight = galleryVC.maxHeight
716
+ let format = galleryVC.outputFormat // "webp" | "jpg" | "png"
717
+ let mimeType = self.mimeType(for: format)
718
+ let needResize = (maxWidth > 0 || maxHeight > 0)
719
+
720
+ let resultsLock = NSLock()
721
+
722
+ DispatchQueue.global(qos: .userInitiated).async {
723
+ for (index, pair) in selectedImages.enumerated() {
724
+ let (asset, image) = pair
725
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
726
+ let originalSize = image.size
727
+
728
+ if let phAsset = asset {
729
+ // ✅ 1순위: PHAsset
730
+ let originalUri = "ph://\(phAsset.localIdentifier)"
731
+
732
+ var targetImage: UIImage
733
+ if needResize, let resized = self.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) {
734
+ targetImage = resized
735
+ } else {
736
+ targetImage = image
737
+ }
738
+ targetImage = ImageProcessor.shared.applyWatermarkIfNeeded(targetImage)
739
+
740
+ var result: [String: Any] = [
741
+ "originalUri": originalUri,
742
+ "originalWidth": Int(originalSize.width),
743
+ "originalHeight": Int(originalSize.height),
744
+ "type": mimeType
745
+ ]
746
+
747
+ if let savedUri = self.saveImage(targetImage, format: format,
748
+ prefix: "resized",
749
+ index: index, timestamp: timestamp,
750
+ tempDir: tempDir) {
751
+ result["uri"] = savedUri
752
+ result["width"] = Int(targetImage.size.width)
753
+ result["height"] = Int(targetImage.size.height)
754
+ let meta = self.fileMetadata(fromPath: savedUri)
755
+ result["fileName"] = meta.fileName
756
+ result["fileExtension"] = meta.fileExtension
757
+ result["fileSize"] = meta.fileSize
758
+ }
759
+
760
+ resultsLock.lock()
761
+ results.append(result)
762
+ let currentResult = result
763
+ resultsLock.unlock()
764
+
765
+ self.sendEvent(withName: "onImageProcessed", body: [
766
+ "index": index,
767
+ "total": selectedImages.count,
768
+ "image": currentResult
769
+ ])
770
+ } else {
771
+ // ✅ 2순위: 카메라 촬영본
772
+ guard let originalFilePath = self.saveOriginalImageAsJPEG(
773
+ image, index: index, timestamp: timestamp, tempDir: tempDir) else {
774
+ return
775
+ }
776
+
777
+ var targetImage: UIImage
778
+ if needResize, let resized = self.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) {
779
+ targetImage = resized
780
+ } else {
781
+ targetImage = image
782
+ }
783
+ targetImage = ImageProcessor.shared.applyWatermarkIfNeeded(targetImage)
784
+
785
+ var result: [String: Any] = [
786
+ "originalUri": originalFilePath,
787
+ "originalFileUri": originalFilePath,
788
+ "originalWidth": Int(originalSize.width),
789
+ "originalHeight": Int(originalSize.height),
790
+ "type": mimeType
791
+ ]
792
+ let origMeta = self.fileMetadata(fromPath: originalFilePath)
793
+ result["fileName"] = origMeta.fileName
794
+ result["fileExtension"] = origMeta.fileExtension
795
+ result["fileSize"] = origMeta.fileSize
796
+
797
+ if let savedUri = self.saveImage(targetImage, format: format,
798
+ prefix: "resized",
799
+ index: index, timestamp: timestamp,
800
+ tempDir: tempDir) {
801
+ result["uri"] = savedUri
802
+ result["width"] = Int(targetImage.size.width)
803
+ result["height"] = Int(targetImage.size.height)
804
+ let meta = self.fileMetadata(fromPath: savedUri)
805
+ result["fileName"] = meta.fileName
806
+ result["fileExtension"] = meta.fileExtension
807
+ result["fileSize"] = meta.fileSize
808
+ }
809
+
810
+ resultsLock.lock()
811
+ results.append(result)
812
+ let currentResult = result
813
+ resultsLock.unlock()
814
+
815
+ self.sendEvent(withName: "onImageProcessed", body: [
816
+ "index": index,
817
+ "total": selectedImages.count,
818
+ "image": currentResult
819
+ ])
820
+ }
821
+ } // end for
822
+
823
+ DispatchQueue.main.async {
824
+ self.currentGalleryVC = nil
825
+ resolve(results)
826
+ }
827
+ }
828
+ }
829
+
830
+ let navigationController = UINavigationController(rootViewController: galleryVC)
831
+ navigationController.modalPresentationStyle = .custom
832
+ navigationController.transitioningDelegate = galleryVC.customTransitioningDelegate
833
+
834
+ // navigationController.view.backgroundColor 설정 제거 (투명한 탑바 지원을 위함)
835
+
836
+ let step3 = CFAbsoluteTimeGetCurrent()
837
+ // Configure sheet presentation - 최소 설정만 사용 (기본 iOS 동작 보장)
838
+ if #available(iOS 15.0, *) {
839
+ if let sheet = navigationController.sheetPresentationController {
840
+ if #available(iOS 16.0, *) {
841
+ // iOS 16+: Use custom 60% detent
842
+ let customDetent = UISheetPresentationController.Detent.custom { context in
843
+ return context.maximumDetentValue * 0.6
844
+ }
845
+
846
+ sheet.detents = [customDetent, .large()]
847
+ } else {
848
+ // iOS 15: Use medium and large detents
849
+ sheet.detents = [.medium(), .large()]
850
+ }
851
+
852
+ sheet.prefersGrabberVisible = false
853
+ sheet.prefersScrollingExpandsWhenScrolledToEdge = true
854
+
855
+ if #available(iOS 16.0, *) {
856
+ sheet.preferredCornerRadius = 20
857
+ }
858
+ sheet.largestUndimmedDetentIdentifier = nil
859
+
860
+ if #available(iOS 16.0, *) {
861
+ sheet.preferredCornerRadius = 20
862
+ }
863
+ }
864
+ }
865
+ print("🔵 [DEBUG] Sheet 설정 완료 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - step3))초")
866
+
867
+ let step4 = CFAbsoluteTimeGetCurrent()
868
+ print("🔵 [DEBUG] present 시작...")
869
+ rootViewController.present(navigationController, animated: true)
870
+ print("🔵 [DEBUG] present 완료 - 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - step4))초")
871
+ print("✅ [DEBUG] openGallery 전체 완료 - 총 경과: \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - startTime))초")
872
+ }
873
+ }
874
+
875
+ @objc
876
+ func closeGallery(_ resolve: @escaping RCTPromiseResolveBlock,
877
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
878
+
879
+ DispatchQueue.main.async { [weak self] () -> Void in
880
+ guard let self = self else {
881
+ reject("ERROR", "Module deallocated", nil)
882
+ return
883
+ }
884
+
885
+ guard let galleryVC = self.currentGalleryVC else {
886
+ reject("ERROR", "Gallery is not open", nil)
887
+ return
888
+ }
889
+
890
+ // 갤러리 닫기 전 강제 정리
891
+ galleryVC.forceCleanupBeforeDismiss()
892
+
893
+ // 갤러리 닫기
894
+ galleryVC.dismiss(animated: true) { [weak self] in
895
+ // dismiss 완료 후 참조 정리
896
+ self?.currentGalleryVC = nil
897
+ resolve(true)
898
+ }
899
+ }
900
+ }
901
+
902
+ // MARK: - Private Methods
903
+
904
+ /// iOS가 저장 공간 부족 시 자동으로 정리하는 캐시 디렉토리
905
+ /// picker 결과물은 일시적으로만 필요하므로 cache 사용
906
+ private var cacheDirectory: String {
907
+ return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
908
+ .first?.appendingPathComponent("RNAttachmentKit").path
909
+ ?? NSTemporaryDirectory()
910
+ }
911
+
912
+ /// 캐시 디렉토리 준비 (없으면 생성)
913
+ private func prepareCacheDirectory() -> String {
914
+ let dir = cacheDirectory
915
+ if !FileManager.default.fileExists(atPath: dir) {
916
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
917
+ }
918
+ return dir
919
+ }
920
+
921
+ // 이미지 리사이징 (비율 유지)
922
+ private func resizeImage(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> UIImage? {
923
+ let originalSize = image.size
924
+ var targetSize = originalSize
925
+
926
+ // maxWidth와 maxHeight 둘 다 사용 (기본값 500이므로 항상 리사이징)
927
+ if maxWidth > 0 && maxHeight > 0 {
928
+ // 둘 다 설정된 경우: 더 작은 비율로 리사이징
929
+ let widthRatio = CGFloat(maxWidth) / originalSize.width
930
+ let heightRatio = CGFloat(maxHeight) / originalSize.height
931
+ let ratio = min(widthRatio, heightRatio, 1.0) // 1.0 이상이면 리사이징 안 함
932
+
933
+ if ratio < 1.0 {
934
+ targetSize = CGSize(width: originalSize.width * ratio, height: originalSize.height * ratio)
935
+ }
936
+ } else if maxWidth > 0 && originalSize.width > CGFloat(maxWidth) {
937
+ // maxWidth만 설정된 경우
938
+ let ratio = CGFloat(maxWidth) / originalSize.width
939
+ targetSize = CGSize(width: CGFloat(maxWidth), height: originalSize.height * ratio)
940
+ } else if maxHeight > 0 && originalSize.height > CGFloat(maxHeight) {
941
+ // maxHeight만 설정된 경우
942
+ let ratio = CGFloat(maxHeight) / originalSize.height
943
+ targetSize = CGSize(width: originalSize.width * ratio, height: CGFloat(maxHeight))
944
+ } else {
945
+ // 리사이징 불필요
946
+ return nil
947
+ }
948
+
949
+ // 같은 크기면 리사이징 안 함
950
+ if targetSize.width >= originalSize.width && targetSize.height >= originalSize.height {
951
+ return nil
952
+ }
953
+
954
+ // 이미지 리사이징 (UIGraphicsImageRenderer 방식 - 메모리 최적화)
955
+ let format = UIGraphicsImageRendererFormat()
956
+ format.scale = 1.0 // 실제 픽셀 크기로 저장하기 위함
957
+ format.opaque = false
958
+ if #available(iOS 12.0, *) {
959
+ format.preferredRange = .standard // 광색역 대신 표준 색역 사용으로 메모리 절약
960
+ }
961
+
962
+ let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
963
+ return renderer.image { _ in
964
+ image.draw(in: CGRect(origin: .zero, size: targetSize))
965
+ }
966
+ }
967
+
968
+ // ✅ 2순위: 원본 이미지를 JPEG으로 저장 (WebP 인코딩 없음 → 빠름)
969
+ // 카메라 촬영본 및 에디터 결과물에 사용
970
+ private func saveOriginalImageAsJPEG(_ image: UIImage, index: Int, timestamp: Int, tempDir: String) -> String? {
971
+ let fileName = "turbo_picker_original_\(timestamp)_\(index).jpg"
972
+ let filePath = (tempDir as NSString).appendingPathComponent(fileName)
973
+ let fileURL = URL(fileURLWithPath: filePath)
974
+
975
+ if let jpegData = image.jpegData(compressionQuality: 0.9) {
976
+ do {
977
+ try jpegData.write(to: fileURL, options: [.atomic])
978
+ return fileURL.absoluteString
979
+ } catch {
980
+ print("❌ [TurboImagePicker] JPEG 원본 이미지 저장 실패: \(error)")
981
+ }
982
+ }
983
+ return nil
984
+ }
985
+
986
+ // 리사이즈된 이미지 저장 (WebP 포맷)
987
+ private func saveResizedImage(_ image: UIImage, index: Int, timestamp: Int, tempDir: String) -> String? {
988
+ let fileName = "turbo_picker_resized_\(timestamp)_\(index).webp"
989
+ let filePath = (tempDir as NSString).appendingPathComponent(fileName)
990
+ let fileURL = URL(fileURLWithPath: filePath)
991
+
992
+ if let data = encodeWebP(image: image, quality: 0.85) {
993
+ do {
994
+ try data.write(to: fileURL, options: [.atomic])
995
+ return fileURL.absoluteString
996
+ } catch {
997
+ print("❌ [TurboImagePicker] WebP 리사이즈 이미지 저장 실패: \(error)")
998
+ }
999
+ }
1000
+ // Fallback to JPEG
1001
+ if let jpegData = image.jpegData(compressionQuality: 0.85) {
1002
+ let fallbackPath = (tempDir as NSString).appendingPathComponent("turbo_picker_resized_\(timestamp)_\(index).jpg")
1003
+ let fallbackURL = URL(fileURLWithPath: fallbackPath)
1004
+ do {
1005
+ try jpegData.write(to: fallbackURL, options: [.atomic])
1006
+ return fallbackURL.absoluteString
1007
+ } catch {}
1008
+ }
1009
+ return nil
1010
+ }
1011
+ // WebP 인코딩 - iOS 16+ 네이티브 CGImageDestination 사용 또는 런타임 Fallback
1012
+ private func encodeWebP(image: UIImage, quality: CGFloat) -> Data? {
1013
+ if #available(iOS 16.0, *) {
1014
+ if let cgImage = image.cgImage {
1015
+ let data = NSMutableData()
1016
+ if let destination = CGImageDestinationCreateWithData(data as CFMutableData, "public.webp" as CFString, 1, nil) {
1017
+ let options: [CFString: Any] = [kCGImageDestinationLossyCompressionQuality: quality]
1018
+ CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
1019
+ if CGImageDestinationFinalize(destination) {
1020
+ NSLog("✅ [encodeWebP] iOS 16 네이티브 WebP 인코딩 성공 - %d bytes", data.length)
1021
+ return data as Data
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ NSLog("🟣 [encodeWebP] SDImageWebPCoder 런타임 폴백 시도")
1028
+ // iOS 14, 15이거나 네이티브 실패 시 SDImageWebPCoder 런타임 호출
1029
+ if let coderClass = NSClassFromString("SDImageWebPCoder") as? NSObject.Type {
1030
+ if coderClass.responds(to: NSSelectorFromString("sharedCoder")) {
1031
+ let sharedCoder = coderClass.perform(NSSelectorFromString("sharedCoder")).takeUnretainedValue()
1032
+ let encodeSel = NSSelectorFromString("encodedDataWithImage:format:options:")
1033
+
1034
+ if sharedCoder.responds(to: encodeSel),
1035
+ let method = class_getInstanceMethod(type(of: sharedCoder), encodeSel) {
1036
+
1037
+ // ObjC method signature: - (NSData *)encodedDataWithImage:(UIImage *)image format:(NSInteger)format options:(NSDictionary *)options
1038
+ typealias EncodeFunc = @convention(c) (AnyObject, Selector, UIImage?, Int, [String: Any]?) -> Data?
1039
+ let implementation = method_getImplementation(method)
1040
+ let encode = unsafeBitCast(implementation, to: EncodeFunc.self)
1041
+
1042
+ // format: 3 (SDImageFormatWebP)
1043
+ let options: [String: Any] = ["encodeCompressionQuality": quality]
1044
+ if let data = encode(sharedCoder, encodeSel, image, 3, options) {
1045
+ NSLog("✅ [encodeWebP] SDImageWebPCoder 런타임 인코딩 성공 - %d bytes", data.count)
1046
+ return data
1047
+ } else {
1048
+ NSLog("❌ [encodeWebP] SDImageWebPCoder 런타임 인코딩 실패 (nil 반환)")
1049
+ }
1050
+ } else {
1051
+ NSLog("❌ [encodeWebP] SDImageWebPCoder에 encodedDataWithImage:format:options: 메서드가 없습니다.")
1052
+ }
1053
+ } else {
1054
+ NSLog("❌ [encodeWebP] SDImageWebPCoder에 sharedCoder가 없습니다.")
1055
+ }
1056
+ } else {
1057
+ NSLog("❌ [encodeWebP] SDImageWebPCoder 클래스를 찾을 수 없습니다.")
1058
+ }
1059
+
1060
+ NSLog("❌ [encodeWebP] 모든 WebP 인코딩 실패 → nil 반환")
1061
+ return nil
1062
+ }
1063
+
1064
+
1065
+ // MARK: - Format-aware save helpers
1066
+
1067
+ /// outputFormat("webp"|"jpg"|"png")에 맞는 MIME 타입 반환
1068
+ private func mimeType(for format: String) -> String {
1069
+ switch format {
1070
+ case "png": return "image/png"
1071
+ case "jpg": return "image/jpeg"
1072
+ default: return "image/webp"
1073
+ }
1074
+ }
1075
+
1076
+ /// 이미지를 지정 포맷으로 저장하고 file:// URI 반환
1077
+ /// - Parameters:
1078
+ /// - image: 저장할 UIImage
1079
+ /// - format: "webp" | "jpg" | "png"
1080
+ /// - prefix: 파일명 prefix (e.g. "resized", "original")
1081
+ /// - index: 배치 내 인덱스
1082
+ /// - timestamp: 타임스탬프(ms)
1083
+ /// - tempDir: 저장 디렉토리 경로
1084
+ private func saveImage(_ image: UIImage, format: String,
1085
+ prefix: String, index: Int,
1086
+ timestamp: Int, tempDir: String, forceJPEG: Bool = false) -> String? {
1087
+ let ext: String
1088
+ let data: Data?
1089
+
1090
+ let actualFormat = forceJPEG ? "jpg" : format
1091
+
1092
+ switch actualFormat {
1093
+ case "png":
1094
+ ext = "png"
1095
+ data = image.pngData()
1096
+ case "jpg":
1097
+ ext = "jpg"
1098
+ data = image.jpegData(compressionQuality: 0.9)
1099
+ default: // "webp"
1100
+ ext = "webp"
1101
+ data = encodeWebP(image: image, quality: 0.85)
1102
+ }
1103
+
1104
+ guard let imageData = data else {
1105
+ // WebP 인코딩 실패 시 JPEG fallback
1106
+ if format == "webp", let fallbackData = image.jpegData(compressionQuality: 0.85) {
1107
+ let path = (tempDir as NSString).appendingPathComponent(
1108
+ "turbo_picker_\(prefix)_\(timestamp)_\(index).jpg")
1109
+ let url = URL(fileURLWithPath: path)
1110
+ try? fallbackData.write(to: url)
1111
+ return url.absoluteString
1112
+ }
1113
+ return nil
1114
+ }
1115
+
1116
+ let fileName = "turbo_picker_\(prefix)_\(timestamp)_\(index).\(ext)"
1117
+ let filePath = (tempDir as NSString).appendingPathComponent(fileName)
1118
+ let fileURL = URL(fileURLWithPath: filePath)
1119
+
1120
+ do {
1121
+ try imageData.write(to: fileURL)
1122
+ return fileURL.absoluteString
1123
+ } catch {
1124
+ print("❌ [TurboImagePicker] \(format.uppercased()) 저장 실패: \(error)")
1125
+ return nil
1126
+ }
1127
+ }
1128
+
1129
+
1130
+ /// 저장된 파일 경로에서 fileName, fileExtension, fileSize 반환
1131
+ private func fileMetadata(fromPath filePath: String) -> (fileName: String, fileExtension: String, fileSize: Int64) {
1132
+ let url = URL(fileURLWithPath: filePath.hasPrefix("file://")
1133
+ ? String(filePath.dropFirst(7))
1134
+ : filePath)
1135
+ let fileName = url.lastPathComponent
1136
+ let fileExtension = url.pathExtension.lowercased()
1137
+ let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
1138
+ return (fileName, fileExtension, fileSize)
1139
+ }
1140
+
1141
+ private func getRootViewController() -> UIViewController? {
1142
+ var rootViewController: UIViewController?
1143
+
1144
+ if #available(iOS 13.0, *) {
1145
+ if let windowScene = UIApplication.shared.connectedScenes
1146
+ .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
1147
+ let window = windowScene.windows.first(where: { $0.isKeyWindow }) {
1148
+ rootViewController = window.rootViewController
1149
+ }
1150
+ }
1151
+
1152
+ if rootViewController == nil {
1153
+ rootViewController = UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.rootViewController
1154
+ }
1155
+
1156
+ if rootViewController == nil {
1157
+ rootViewController = UIApplication.shared.keyWindow?.rootViewController
1158
+ }
1159
+
1160
+ // Handle presented view controllers
1161
+ while let presented = rootViewController?.presentedViewController {
1162
+ rootViewController = presented
1163
+ }
1164
+
1165
+ return rootViewController
1166
+ }
1167
+
1168
+ // MARK: - Editor Result Processing
1169
+
1170
+ private func processEditorResult(asset: PHAsset?, croppedImage: UIImage?, filterState: FilterState, caption: String, galleryVC: GalleryViewController?, targetMaxWidth: Int? = nil, targetMaxHeight: Int? = nil, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
1171
+ let tempDir = prepareCacheDirectory()
1172
+ let maxWidth = targetMaxWidth ?? galleryVC?.maxWidth ?? 1024
1173
+ let maxHeight = targetMaxHeight ?? galleryVC?.maxHeight ?? 1024
1174
+ let format = galleryVC?.outputFormat ?? "webp"
1175
+
1176
+ DispatchQueue.global(qos: .userInitiated).async {
1177
+ let photoManager = PhotoManager.shared
1178
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
1179
+
1180
+ let originalUri = asset != nil ? "ph://\(asset!.localIdentifier)" : ""
1181
+ var originalFilePath: String? = nil
1182
+ var resultImage: UIImage? = nil
1183
+
1184
+ let dispatchGroup = DispatchGroup()
1185
+ dispatchGroup.enter()
1186
+
1187
+ let handleBaseImage = { (baseImage: UIImage) in
1188
+ var finalImage = baseImage
1189
+
1190
+ if filterState.filterId != "original", let filter = FilterManager.shared.filters.first(where: { $0.id == filterState.filterId }) {
1191
+ if let ciImage = CIImage(image: baseImage),
1192
+ let out = filter.apply(ciImage, filterState.intensity),
1193
+ let cgImg = FilterManager.shared.context.createCGImage(out, from: out.extent) {
1194
+ finalImage = UIImage(cgImage: cgImg, scale: baseImage.scale, orientation: baseImage.imageOrientation)
1195
+ }
1196
+ }
1197
+
1198
+ finalImage = ImageProcessor.shared.applyWatermarkIfNeeded(finalImage)
1199
+ resultImage = finalImage
1200
+ // ✅ 2순위: 에디터 결과 원본 저장 (WebP 소프트웨어 인코딩 방지를 위해 하드웨어 가속되는 JPG 강제 사용)
1201
+ originalFilePath = self.saveImage(finalImage, format: format, prefix: "original", index: 0, timestamp: timestamp, tempDir: tempDir, forceJPEG: true)
1202
+ dispatchGroup.leave()
1203
+ }
1204
+
1205
+ let targetSize = (maxWidth > 0 && maxHeight > 0) ? CGSize(width: maxWidth, height: maxHeight) : PHImageManagerMaximumSize
1206
+
1207
+ if let cropped = croppedImage {
1208
+ handleBaseImage(cropped)
1209
+ } else {
1210
+ _ = photoManager.requestFullImage(for: asset!, targetSize: targetSize, progressHandler: nil) { fullImage in
1211
+ if let fullImage = fullImage {
1212
+ handleBaseImage(fullImage)
1213
+ } else {
1214
+ dispatchGroup.leave()
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ dispatchGroup.wait()
1220
+
1221
+ guard let image = resultImage else {
1222
+ DispatchQueue.main.async {
1223
+ self.currentGalleryVC = nil
1224
+ reject("ERROR", "Failed to load image from editor", nil)
1225
+ }
1226
+ return
1227
+ }
1228
+
1229
+ let originalSize = image.size
1230
+
1231
+ var result: [String: Any] = [
1232
+ "originalUri": originalUri,
1233
+ "originalWidth": Int(originalSize.width),
1234
+ "originalHeight": Int(originalSize.height),
1235
+ "type": self.mimeType(for: format),
1236
+ "caption": caption
1237
+ ]
1238
+
1239
+ if let originalFilePath = originalFilePath {
1240
+ result["originalFileUri"] = originalFilePath
1241
+ result["uri"] = originalFilePath // 기본적으로 uri에도 파일 경로 저장
1242
+ let meta = self.fileMetadata(fromPath: originalFilePath)
1243
+ result["fileName"] = meta.fileName
1244
+ result["fileExtension"] = meta.fileExtension
1245
+ result["fileSize"] = meta.fileSize
1246
+ }
1247
+
1248
+ let finalImageForFormat = self.resizeImage(image, maxWidth: maxWidth, maxHeight: maxHeight) ?? image
1249
+
1250
+ if let finalUri = self.saveImage(finalImageForFormat, format: format, prefix: "final", index: 0, timestamp: timestamp, tempDir: tempDir) {
1251
+ result["uri"] = finalUri
1252
+ result["width"] = Int(finalImageForFormat.size.width)
1253
+ result["height"] = Int(finalImageForFormat.size.height)
1254
+ let finalMeta = self.fileMetadata(fromPath: finalUri)
1255
+ result["fileName"] = finalMeta.fileName
1256
+ result["fileExtension"] = finalMeta.fileExtension
1257
+ result["fileSize"] = finalMeta.fileSize
1258
+ }
1259
+
1260
+ DispatchQueue.main.async {
1261
+ self.currentGalleryVC = nil
1262
+ resolve([result])
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+
1268
+ private func processEditorResultMulti(assets: [PHAsset], croppedImages: [PHAsset: UIImage], filterStates: [PHAsset: FilterState], galleryVC: GalleryViewController, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
1269
+ let tempDir = prepareCacheDirectory()
1270
+ let maxWidth = galleryVC.maxWidth
1271
+ let maxHeight = galleryVC.maxHeight
1272
+ let format = galleryVC.outputFormat
1273
+
1274
+ DispatchQueue.global(qos: .userInitiated).async {
1275
+ let photoManager = PhotoManager.shared
1276
+ var results: [[String: Any]] = []
1277
+ let dispatchGroup = DispatchGroup()
1278
+ let resultsLock = NSLock()
1279
+
1280
+ for (index, asset) in assets.enumerated() {
1281
+ dispatchGroup.enter()
1282
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
1283
+ let originalUri = "ph://\(asset.localIdentifier)"
1284
+ let filterState = filterStates[asset] ?? FilterState(filterId: "original", intensity: 1.0)
1285
+ let croppedImage = croppedImages[asset]
1286
+
1287
+ let handleBaseImage = { (baseImage: UIImage) in
1288
+ var finalImage = baseImage
1289
+
1290
+ if filterState.filterId != "original", let filter = FilterManager.shared.filters.first(where: { $0.id == filterState.filterId }) {
1291
+ if let ciImage = CIImage(image: baseImage),
1292
+ let out = filter.apply(ciImage, filterState.intensity),
1293
+ let cgImg = FilterManager.shared.context.createCGImage(out, from: out.extent) {
1294
+ finalImage = UIImage(cgImage: cgImg, scale: baseImage.scale, orientation: baseImage.imageOrientation)
1295
+ }
1296
+ }
1297
+
1298
+ finalImage = ImageProcessor.shared.applyWatermarkIfNeeded(finalImage)
1299
+
1300
+ var result: [String: Any] = [
1301
+ "originalUri": originalUri,
1302
+ "originalWidth": Int(finalImage.size.width),
1303
+ "originalHeight": Int(finalImage.size.height),
1304
+ "type": self.mimeType(for: format)
1305
+ ]
1306
+
1307
+ // ✅ 2순위: 에디터 다중 결과 원본 저장 (JPG 강제 사용으로 속도 최적화)
1308
+ if let originalFilePath = self.saveImage(finalImage, format: format, prefix: "original", index: index, timestamp: timestamp, tempDir: tempDir, forceJPEG: true) {
1309
+ result["originalFileUri"] = originalFilePath
1310
+ result["uri"] = originalFilePath // 기본적으로 uri에도 파일 경로 저장
1311
+ let meta = self.fileMetadata(fromPath: originalFilePath)
1312
+ result["fileName"] = meta.fileName
1313
+ result["fileExtension"] = meta.fileExtension
1314
+ result["fileSize"] = meta.fileSize
1315
+ }
1316
+
1317
+ if (maxWidth > 0 || maxHeight > 0), let resizedImage = self.resizeImage(finalImage, maxWidth: maxWidth, maxHeight: maxHeight) {
1318
+ if let resizedUri = self.saveImage(resizedImage, format: format, prefix: "resized", index: index, timestamp: timestamp, tempDir: tempDir) {
1319
+ result["uri"] = resizedUri
1320
+ result["width"] = Int(resizedImage.size.width)
1321
+ result["height"] = Int(resizedImage.size.height)
1322
+ let resizedMeta = self.fileMetadata(fromPath: resizedUri)
1323
+ result["fileName"] = resizedMeta.fileName
1324
+ result["fileExtension"] = resizedMeta.fileExtension
1325
+ result["fileSize"] = resizedMeta.fileSize
1326
+ }
1327
+ }
1328
+
1329
+ resultsLock.lock()
1330
+ results.append(result)
1331
+ let currentResult = result
1332
+ resultsLock.unlock()
1333
+
1334
+ self.sendEvent(withName: "onImageProcessed", body: [
1335
+ "index": index,
1336
+ "total": assets.count,
1337
+ "image": currentResult
1338
+ ])
1339
+ dispatchGroup.leave()
1340
+ }
1341
+
1342
+ let targetSize = (maxWidth > 0 && maxHeight > 0) ? CGSize(width: maxWidth, height: maxHeight) : PHImageManagerMaximumSize
1343
+
1344
+ if let cropped = croppedImage {
1345
+ handleBaseImage(cropped)
1346
+ } else {
1347
+ _ = photoManager.requestFullImage(for: asset, targetSize: targetSize, progressHandler: nil) { fullImage in
1348
+ if let fullImage = fullImage {
1349
+ handleBaseImage(fullImage)
1350
+ } else {
1351
+ dispatchGroup.leave()
1352
+ }
1353
+ }
1354
+ }
1355
+ }
1356
+
1357
+ dispatchGroup.wait()
1358
+
1359
+ DispatchQueue.main.async {
1360
+ self.currentGalleryVC = nil
1361
+ if results.isEmpty {
1362
+ reject("ERROR", "Failed to load images from editor", nil)
1363
+ } else {
1364
+ // 순서를 유지하기 위해
1365
+ resolve(results)
1366
+ }
1367
+ }
1368
+ }
1369
+ }
1370
+ }
1371
+
1372
+ // MARK: - UIColor Extension for Hex Support
1373
+ extension UIColor {
1374
+ convenience init?(hexString: String) {
1375
+ var cString: String = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
1376
+ if cString.hasPrefix("#") { cString.remove(at: cString.startIndex) }
1377
+
1378
+ var rgbValue: UInt64 = 0
1379
+ Scanner(string: cString).scanHexInt64(&rgbValue)
1380
+
1381
+ if cString.count == 6 {
1382
+ self.init(
1383
+ red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
1384
+ green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
1385
+ blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
1386
+ alpha: 1.0
1387
+ )
1388
+ } else if cString.count == 8 {
1389
+ self.init(
1390
+ red: CGFloat((rgbValue & 0xFF000000) >> 24) / 255.0,
1391
+ green: CGFloat((rgbValue & 0x00FF0000) >> 16) / 255.0,
1392
+ blue: CGFloat((rgbValue & 0x0000FF00) >> 8) / 255.0,
1393
+ alpha: CGFloat(rgbValue & 0x000000FF) / 255.0
1394
+ )
1395
+ } else {
1396
+ return nil
1397
+ }
1398
+ }
1399
+ }
1400
+
1401
+ // MARK: - Media Gallery (사진 모아보기)
1402
+
1403
+ extension RNAttachmentKit {
1404
+
1405
+ /// openMediaGallery(options) → { galleryId }
1406
+ /// - dataSource: 등록된 네이티브 데이터 소스 이름 (MediaGalleryRegistry) — 페이지 요청이 JS 를 거치지 않음
1407
+ /// - initialItems / nextCursor / hasMore: JS 공급 방식 (끝에 가까우면 onGalleryNeedPage → appendGalleryItems)
1408
+ @objc
1409
+ func openMediaGallery(_ options: NSDictionary,
1410
+ resolve: @escaping RCTPromiseResolveBlock,
1411
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
1412
+ let o = options as? [String: Any] ?? [:]
1413
+ DispatchQueue.main.async {
1414
+ let config = MediaGalleryConfig(dictionary: o)
1415
+ let galleryId = config.galleryId
1416
+ if self.mediaGalleries[galleryId] != nil {
1417
+ reject("E_GALLERY_OPEN", "Media gallery '\(galleryId)' is already open", nil)
1418
+ return
1419
+ }
1420
+ let store: MediaGalleryStore
1421
+ if let name = o["dataSource"] as? String, !name.isEmpty {
1422
+ guard let dataSource = MediaGalleryRegistry.source(named: name) else {
1423
+ reject("E_NO_DATA_SOURCE", "Media gallery data source '\(name)' is not registered", nil)
1424
+ return
1425
+ }
1426
+ let params = o["dataSourceParams"] as? [String: Any] ?? [:]
1427
+ store = MediaGalleryStore(galleryId: galleryId, source: .native(dataSource, params: params))
1428
+ } else {
1429
+ let raw = o["initialItems"] as? [[String: Any]] ?? []
1430
+ let items = raw.compactMap { MediaGalleryItem(dictionary: $0) }
1431
+ let hasMore = (o["hasMore"] as? NSNumber)?.boolValue ?? false
1432
+ store = MediaGalleryStore(galleryId: galleryId, source: .javascript, initialItems: items,
1433
+ nextCursor: o["nextCursor"] as? String, hasMore: hasMore)
1434
+ store.onNeedPage = { [weak self] cursor in
1435
+ let body: [String: Any] = ["galleryId": galleryId, "cursor": cursor.map { $0 as Any } ?? NSNull()]
1436
+ self?.sendEvent(withName: "onGalleryNeedPage", body: body)
1437
+ }
1438
+ }
1439
+ guard let root = self.getRootViewController() else {
1440
+ reject("E_NO_ROOT", "No root view controller", nil)
1441
+ return
1442
+ }
1443
+ let vc = MediaGalleryViewController(store: store, config: config)
1444
+ vc.onClosed = { [weak self] in
1445
+ guard let self = self else { return }
1446
+ self.mediaGalleries.removeValue(forKey: galleryId)
1447
+ self.sendEvent(withName: "onGalleryClosed", body: ["galleryId": galleryId])
1448
+ }
1449
+ // 뷰어에서 삭제를 고르면: 모듈은 그 묶음을 그리드·뷰어에서 지우고, 실제 삭제는 앱이 하도록 이벤트만 보냄
1450
+ vc.onItemDeleted = { [weak self] item, option in
1451
+ var body: [String: Any] = ["galleryId": galleryId, "id": item.id, "groupId": item.groupId]
1452
+ body["option"] = option.map { $0 as Any } ?? NSNull()
1453
+ self?.sendEvent(withName: "onGalleryItemDeleted", body: body)
1454
+ }
1455
+ // 상단 바 메뉴에서 그리드 크기를 바꾸면 (모듈이 기기에 저장, 앱에도 알림)
1456
+ vc.onGridSizeChanged = { [weak self] columns in
1457
+ self?.sendEvent(withName: "onGalleryGridSizeChanged",
1458
+ body: ["galleryId": galleryId, "gridSize": MediaGalleryGridPreference.gridSizeString(columns)])
1459
+ }
1460
+ self.mediaGalleries[galleryId] = vc
1461
+ // 피커 갤러리와 같은 바텀시트로 표시
1462
+ root.present(vc.makePresentableController(), animated: true)
1463
+ resolve(["galleryId": galleryId])
1464
+ }
1465
+ }
1466
+
1467
+ @objc
1468
+ func closeMediaGallery(_ galleryId: String,
1469
+ resolve: @escaping RCTPromiseResolveBlock,
1470
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
1471
+ DispatchQueue.main.async {
1472
+ guard let vc = self.mediaGalleries[galleryId] else {
1473
+ resolve(nil)
1474
+ return
1475
+ }
1476
+ // 갤러리 위에 띄운 화면(뷰어 등)까지 함께 닫음
1477
+ if let presenting = vc.navigationController?.presentingViewController ?? vc.presentingViewController {
1478
+ presenting.dismiss(animated: true) { resolve(nil) }
1479
+ } else {
1480
+ resolve(nil)
1481
+ }
1482
+ }
1483
+ }
1484
+
1485
+ /// JS 공급 방식: { galleryId, items, nextCursor?, hasMore }
1486
+ @objc
1487
+ func appendGalleryItems(_ payload: NSDictionary,
1488
+ resolve: @escaping RCTPromiseResolveBlock,
1489
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
1490
+ let p = payload as? [String: Any] ?? [:]
1491
+ let galleryId = p["galleryId"] as? String ?? ""
1492
+ let raw = p["items"] as? [[String: Any]] ?? []
1493
+ let items = raw.compactMap { MediaGalleryItem(dictionary: $0) }
1494
+ let page = MediaGalleryPage(items: items, nextCursor: p["nextCursor"] as? String,
1495
+ hasMore: (p["hasMore"] as? NSNumber)?.boolValue ?? false)
1496
+ DispatchQueue.main.async {
1497
+ self.mediaGalleries[galleryId]?.store.appendExternal(page)
1498
+ resolve(nil)
1499
+ }
1500
+ }
1501
+
1502
+ /// 앱이 다른 경로로 지운 묶음 반영: { galleryId, groupIds }
1503
+ @objc
1504
+ func removeGalleryItems(_ payload: NSDictionary,
1505
+ resolve: @escaping RCTPromiseResolveBlock,
1506
+ reject: @escaping RCTPromiseRejectBlock) -> Void {
1507
+ let p = payload as? [String: Any] ?? [:]
1508
+ let galleryId = p["galleryId"] as? String ?? ""
1509
+ let groupIds = (p["groupIds"] as? [Any] ?? []).compactMap { v -> String? in
1510
+ if let s = v as? String { return s }
1511
+ if let n = v as? NSNumber { return n.stringValue }
1512
+ return nil
1513
+ }
1514
+ DispatchQueue.main.async {
1515
+ self.mediaGalleries[galleryId]?.store.removeGroups(Set(groupIds))
1516
+ resolve(nil)
1517
+ }
1518
+ }
1519
+ }
1520
+
1521
+ #if RCT_NEW_ARCH_ENABLED
1522
+ // MARK: - Turbo Module Protocol Conformance (New Architecture)
1523
+ // extension RNAttachmentKit: RCTTurboModule {
1524
+ // // Turbo Module specific implementation if needed
1525
+ // }
1526
+ #endif
1527
+ import Foundation
1528
+ import UIKit
1529
+
1530
+
1531
+ fileprivate func _turbo_handleConfirmMulti(assets: [PHAsset], croppedImages: [PHAsset: UIImage], filterStates: [PHAsset: FilterState], currentGallery: GalleryViewController) {
1532
+ DispatchQueue.global(qos: .userInitiated).async {
1533
+ var newlyEditedAssets: [PHAsset] = []
1534
+ for asset in assets {
1535
+ if let baseImage = croppedImages[asset] {
1536
+ var finalImage = baseImage
1537
+ if let state = filterStates[asset], state.filterId != "original" {
1538
+ if let filter = FilterManager.shared.filters.first(where: { (f: ImageFilter) -> Bool in return f.id == state.filterId }) {
1539
+ if let ciImage = CIImage(image: baseImage) {
1540
+ if let out = filter.apply(ciImage, state.intensity) {
1541
+ if let cgImg = FilterManager.shared.context.createCGImage(out, from: out.extent) {
1542
+ finalImage = UIImage(cgImage: cgImg, scale: baseImage.scale, orientation: baseImage.imageOrientation)
1543
+ }
1544
+ }
1545
+ }
1546
+ }
1547
+ }
1548
+ DispatchQueue.main.async {
1549
+ currentGallery.editedImages[asset.localIdentifier] = finalImage
1550
+ }
1551
+ newlyEditedAssets.append(asset)
1552
+ }
1553
+ }
1554
+ DispatchQueue.main.async {
1555
+ for asset in newlyEditedAssets {
1556
+ if !currentGallery.selectedAssetsSet.contains(asset.localIdentifier) {
1557
+ if currentGallery.maxSelection == 0 || currentGallery.selectedAssets.count < currentGallery.maxSelection {
1558
+ currentGallery.selectedAssets.append(asset)
1559
+ currentGallery.selectedAssetsSet.insert(asset.localIdentifier)
1560
+ }
1561
+ }
1562
+ }
1563
+ currentGallery.refreshSelectionAfterEdit()
1564
+
1565
+ // These updates are required per the original code
1566
+ currentGallery.updateSelectedCellNumbers()
1567
+ currentGallery.notifySelectionChanged()
1568
+ currentGallery.updateNavigationBarForSelection()
1569
+ }
1570
+ }
1571
+ }
1572
+
1573
+ fileprivate extension RNAttachmentKit {
1574
+ func _turbo_handleSingleImageTappedForEdit(asset: PHAsset, sourceFrame: CGRect, sourceImage: UIImage?, options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
1575
+ guard let currentGallery = self.currentGalleryVC else { return }
1576
+ let galleryNav = currentGallery.navigationController
1577
+ let isMultiSelect = currentGallery.forceMultiMode || (currentGallery.maxSelection != 1 && currentGallery.maxSelection != 0)
1578
+
1579
+ let editorVC = ImageEditorViewController()
1580
+ editorVC.languageCode = options["languageCode"] as? String ?? "en"
1581
+ var editorAssets = currentGallery.allAssets
1582
+ var targetIndex = 0
1583
+ if let idx = editorAssets.firstIndex(of: asset) {
1584
+ targetIndex = idx
1585
+ } else {
1586
+ editorAssets.insert(asset, at: 0)
1587
+ targetIndex = 0
1588
+ }
1589
+
1590
+ editorVC.allAssets = editorAssets
1591
+ editorVC.currentIndex = targetIndex
1592
+ editorVC.selectedAssets = currentGallery.selectedAssets
1593
+
1594
+ editorVC.onSelectionToggled = { [weak currentGallery] (toggledAsset, isSelected) in
1595
+ guard let currentGallery = currentGallery else { return }
1596
+ if isSelected {
1597
+ if !currentGallery.selectedAssets.contains(toggledAsset) {
1598
+ currentGallery.selectedAssets.append(toggledAsset)
1599
+ currentGallery.selectedAssetsSet.insert(toggledAsset.localIdentifier)
1600
+ }
1601
+ } else {
1602
+ currentGallery.selectedAssetsSet.remove(toggledAsset.localIdentifier)
1603
+ currentGallery.selectedAssets.removeAll(where: { $0.localIdentifier == toggledAsset.localIdentifier })
1604
+ }
1605
+ currentGallery.refreshSelectionAfterEdit()
1606
+ currentGallery.updateSelectedCellNumbers()
1607
+ currentGallery.notifySelectionChanged()
1608
+ currentGallery.updateNavigationBarForSelection()
1609
+ }
1610
+
1611
+ editorVC.onEditDeleted = { [weak currentGallery] asset in
1612
+ currentGallery?.editedImages.removeValue(forKey: asset.localIdentifier)
1613
+ }
1614
+
1615
+ editorVC.modalPresentationStyle = .overFullScreen
1616
+ editorVC.singlePhotoMode = !isMultiSelect
1617
+ // selectionMode 'multi' + 최대 1장: 편집 화면에서 다른 사진을 선택하면 기존 선택과 교체
1618
+ editorVC.replaceSelectionWhenFull = currentGallery.forceMultiMode && currentGallery.maxSelection == 1
1619
+
1620
+ var initialCroppedImages: [Int: UIImage] = [:]
1621
+ for (i, a) in editorAssets.enumerated() {
1622
+ if let editedImg = currentGallery.editedImages[a.localIdentifier] {
1623
+ initialCroppedImages[i] = editedImg
1624
+ }
1625
+ }
1626
+ editorVC.croppedImages = initialCroppedImages
1627
+
1628
+ if sourceFrame != .zero {
1629
+ self.editorTransitionDelegate = ImageEditorTransitionDelegate()
1630
+ self.editorTransitionDelegate?.sourceFrame = sourceFrame
1631
+ self.editorTransitionDelegate?.sourceImage = sourceImage
1632
+
1633
+ if let editedImg = currentGallery.editedImages[asset.localIdentifier] {
1634
+ self.editorTransitionDelegate?.uncroppedImage = editedImg
1635
+ self.editorTransitionDelegate?.asset = nil
1636
+ } else {
1637
+ let options = PHImageRequestOptions()
1638
+ options.deliveryMode = .fastFormat
1639
+ options.isSynchronous = true
1640
+ options.resizeMode = .fast
1641
+ var fastUncropped: UIImage? = nil
1642
+ PHImageManager.default().requestImage(for: asset, targetSize: CGSize(width: 400, height: 400), contentMode: .aspectFit, options: options) { img, _ in
1643
+ fastUncropped = img
1644
+ }
1645
+ self.editorTransitionDelegate?.uncroppedImage = fastUncropped ?? sourceImage
1646
+ self.editorTransitionDelegate?.asset = asset
1647
+ }
1648
+
1649
+ var ratio = CGFloat(asset.pixelWidth) / CGFloat(max(1, asset.pixelHeight))
1650
+ if let editedImg = currentGallery.editedImages[asset.localIdentifier] {
1651
+ ratio = editedImg.size.width / max(1.0, editedImg.size.height)
1652
+ }
1653
+ self.editorTransitionDelegate?.assetAspectRatio = ratio
1654
+ self.editorTransitionDelegate?.frameProvider = { [weak currentGallery] currentAsset in
1655
+ return currentGallery?.frameForAsset(currentAsset)
1656
+ }
1657
+ // sticky: 편집 화면이 열려 있는 동안 지금 사진의 갤러리 칸을 비워 두고(넘기면 따라감), 닫히면 다시 보이게
1658
+ self.editorTransitionDelegate?.onCurrentAssetChanged = { [weak currentGallery] currentAsset in
1659
+ currentGallery?.setHiddenAsset(currentAsset, scrollIntoView: true)
1660
+ }
1661
+ self.editorTransitionDelegate?.onEditorClosed = { [weak currentGallery] in
1662
+ currentGallery?.setHiddenAsset(nil)
1663
+ }
1664
+ currentGallery.setHiddenAsset(asset)
1665
+
1666
+ editorVC.transitioningDelegate = self.editorTransitionDelegate
1667
+ }
1668
+
1669
+ if let hex = currentGallery.themeColorHex, let color = UIColor(hexString: hex) {
1670
+ editorVC.themeColor = color
1671
+ }
1672
+
1673
+ editorVC.onConfirmMulti = { [weak currentGallery] (assets, croppedImages, filterStates) in
1674
+ guard let currentGallery = currentGallery else { return }
1675
+
1676
+ editorVC.dismiss(animated: true) {
1677
+ DispatchQueue.global(qos: .userInitiated).async {
1678
+ var newlyEditedAssets: [PHAsset] = []
1679
+
1680
+ for asset in assets {
1681
+ if let baseImage = croppedImages[asset] {
1682
+ var finalImage = baseImage
1683
+ if let state = filterStates[asset],
1684
+ state.filterId != "original",
1685
+ let filter = FilterManager.shared.filters.first(where: { $0.id == state.filterId }),
1686
+ let ciImage = CIImage(image: baseImage),
1687
+ let out = filter.apply(ciImage, state.intensity),
1688
+ let cgImg = FilterManager.shared.context.createCGImage(out, from: out.extent) {
1689
+ finalImage = UIImage(cgImage: cgImg, scale: baseImage.scale, orientation: baseImage.imageOrientation)
1690
+ }
1691
+
1692
+ DispatchQueue.main.async {
1693
+ currentGallery.editedImages[asset.localIdentifier] = finalImage
1694
+ }
1695
+ newlyEditedAssets.append(asset)
1696
+ }
1697
+ }
1698
+
1699
+ DispatchQueue.main.async {
1700
+ for asset in newlyEditedAssets {
1701
+ if !currentGallery.selectedAssetsSet.contains(asset.localIdentifier) {
1702
+ if currentGallery.maxSelection == 0 || currentGallery.selectedAssets.count < currentGallery.maxSelection {
1703
+ currentGallery.selectedAssets.append(asset)
1704
+ currentGallery.selectedAssetsSet.insert(asset.localIdentifier)
1705
+ }
1706
+ }
1707
+ }
1708
+ currentGallery.updateSelectedCellNumbers()
1709
+ currentGallery.notifySelectionChanged()
1710
+ currentGallery.updateNavigationBarForSelection()
1711
+ currentGallery.collectionView.reloadData()
1712
+ }
1713
+ }
1714
+ }
1715
+ }
1716
+
1717
+ // patch398: 편집 화면의 전송 버튼 — 편집본을 갤러리에 반영하고 선택한 사진을 바로 전송(편집 화면/갤러리 모두 닫힘)
1718
+ editorVC.onSendMulti = { [weak currentGallery] (assets, croppedImages, filterStates) in
1719
+ guard let currentGallery = currentGallery else { return }
1720
+ for asset in assets {
1721
+ guard var img = croppedImages[asset] else { continue }
1722
+ if let state = filterStates[asset],
1723
+ state.filterId != "original",
1724
+ let filter = FilterManager.shared.filters.first(where: { $0.id == state.filterId }),
1725
+ let ciImage = CIImage(image: img),
1726
+ let out = filter.apply(ciImage, state.intensity),
1727
+ let cgImg = FilterManager.shared.context.createCGImage(out, from: out.extent) {
1728
+ img = UIImage(cgImage: cgImg, scale: img.scale, orientation: img.imageOrientation)
1729
+ }
1730
+ currentGallery.editedImages[asset.localIdentifier] = img
1731
+ }
1732
+ // patch399: 편집 화면의 이미지가 갤러리 자리로 돌아가는 애니메이션이 끝난 뒤에 전송합니다.
1733
+ currentGallery.refreshThumbnails(for: assets)
1734
+ currentGallery.updateSelectedCellNumbers()
1735
+ editorVC.dismiss(animated: true) { [weak currentGallery] in
1736
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
1737
+ currentGallery?.sendSelectionFromEditor()
1738
+ }
1739
+ }
1740
+ }
1741
+
1742
+ editorVC.onConfirm = { [weak self, weak galleryNav, weak currentGallery] (editedAsset: PHAsset?, croppedImage: UIImage?, filterState: FilterState, caption: String) in
1743
+ guard let self = self else { return }
1744
+ // 단일 편집 → 전송: 결과 전달 책임이 processEditorResult 로 넘어가므로, 갤러리를 닫기 "전에"
1745
+ // 가드를 세웁니다. 안 그러면 갤러리 viewDidDisappear 의 "스와이프로 닫힘" 안전장치가
1746
+ // 먼저 onImagesSelected([]) → reject("CANCELLED") 를 보내서, 편집된 사진이 JS 로 오지
1747
+ // 않았습니다 (autoCloseOnSelect + enableEditor 조합에서 사진이 안 들어오던 버그).
1748
+ currentGallery?.hasReturnedImages = true
1749
+ let navToDismiss = galleryNav ?? currentGallery
1750
+ let presenter = navToDismiss?.presentingViewController ?? navToDismiss
1751
+ presenter?.dismiss(animated: true) {
1752
+ self.processEditorResult(asset: editedAsset, croppedImage: croppedImage, filterState: filterState, caption: caption, galleryVC: currentGallery, resolve: resolve, reject: reject)
1753
+ }
1754
+ }
1755
+
1756
+ editorVC.onCancel = { [weak editorVC] in
1757
+ // 에디터만 닫고 갤러리의 선택 상태는 유지합니다.
1758
+ editorVC?.dismiss(animated: true, completion: nil)
1759
+ }
1760
+
1761
+ var savedDetent: UISheetPresentationController.Detent.Identifier? = nil
1762
+ if #available(iOS 15.0, *) {
1763
+ savedDetent = galleryNav?.sheetPresentationController?.selectedDetentIdentifier
1764
+ }
1765
+
1766
+ let presenterVC = currentGallery.presentedViewController ?? currentGallery
1767
+ presenterVC.present(editorVC, animated: true) {
1768
+ if #available(iOS 15.0, *) {
1769
+ if let saved = savedDetent {
1770
+ galleryNav?.sheetPresentationController?.selectedDetentIdentifier = saved
1771
+ }
1772
+ }
1773
+ }
1774
+ }
1775
+ }
1776
+
1777
+ extension RNAttachmentKit: UIViewControllerTransitioningDelegate {
1778
+ func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
1779
+ zoomAnimator.isPresenting = true
1780
+ return zoomAnimator
1781
+ }
1782
+
1783
+ func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
1784
+ zoomAnimator.isPresenting = false
1785
+ return zoomAnimator
1786
+ }
1787
+ }