diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 18ad1a7a..ba3d818e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(grep:*)", "Bash(pnpm format:*)", "Bash(pnpm turbo:android:*)", - "Bash(git init:*)" + "Bash(git init:*)", + "Bash(pnpm run:*)" ], "deny": [], "ask": [] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 162bc4cd..515fba4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,14 @@ jobs: - name: Install Ktlint run: curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint && chmod a+x ktlint && sudo mv ktlint /usr/local/bin/ + - name: Install clang-format 21.1.8 + run: | + python3 -m pip install --user clang-format==21.1.8 + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Verify clang-format version + run: clang-format --version + - name: Check run: pnpm run t diff --git a/.gitignore b/.gitignore index 35167374..9b585dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -84,10 +84,3 @@ example/ios/Secret.xcconfig example/.watchman-* *.bak - - - - -# Git worktrees (Crystal) -/worktrees/ -/worktree-*/ diff --git a/.skills/dev-patterns.md b/.skills/dev-patterns.md new file mode 100644 index 00000000..0b27d766 --- /dev/null +++ b/.skills/dev-patterns.md @@ -0,0 +1,162 @@ +# NaverMap Dev Patterns + +This file is a quick pattern reference extracted from `CLAUDE.md`. +Use this when implementing native-spec-linked features and when you need concrete code shapes. + +## TypeScript: Fabric Native Component Spec + +Rules: +- Define props with codegen types (`Double`, `Int32`, `WithDefault`, `DirectEventHandler`). +- Keep component spec files in `src/spec/` as source of truth. +- Use `codegenNativeComponent()`. + +```ts +import { codegenNativeComponent, type ViewProps } from 'react-native'; +import type { + DirectEventHandler, + Double, + Int32, + WithDefault, +} from 'react-native/Libraries/Types/CodegenTypes'; + +interface Props extends ViewProps { + coord: Readonly<{ latitude: Double; longitude: Double }>; + onTapOverlay?: DirectEventHandler>; + isHidden?: WithDefault; + tintColor?: Int32; +} + +export default codegenNativeComponent('RNCNaverMapMarker'); +``` + +## TypeScript: Native Commands Spec + +Rules: +- Define imperative methods via `codegenNativeCommands`. +- First arg is always `React.ElementRef`. +- Keep command names aligned with native handlers. + +```ts +interface NativeCommands { + animateCameraTo: ( + ref: React.ElementRef, + latitude: Double, + longitude: Double, + duration?: Int32 + ) => void; +} + +export const Commands: NativeCommands = codegenNativeCommands({ + supportedCommands: ['animateCameraTo'], +}); +``` + +## TypeScript: TurboModule Spec + +Rules: +- Define module interface with `TurboModule`. +- Access via `TurboModuleRegistry.getEnforcing()`. + +```ts +import { type TurboModule, TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + createInfoWindow(id: string): void; +} + +export default TurboModuleRegistry.getEnforcing('RNCNaverMapUtil'); +``` + +## iOS: Fabric Component Pattern + +Rules: +- Extend `RCTViewComponentView`. +- Implement `initWithFrame`, `updateProps`. +- Cast props/emitter safely. + +```objc +- (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)oldProps { + const auto& prev = *std::static_pointer_cast(_props); + const auto& next = *std::static_pointer_cast(props); + + if (prev.width != next.width) { + _inner.width = next.width; + } + + [super updateProps:props oldProps:oldProps]; +} +``` + +## iOS: Command Handling + +Rules: +- Implement command methods on component view. +- Route commands through generated handler. + +```objc +- (void)handleCommand:(const NSString*)commandName args:(const NSArray*)args { + RCTMyComponentHandleCommand(self, commandName, args); +} +``` + +## Android: ViewManager + Codegen Delegate + +Rules: +- Extend generated manager spec class. +- Use generated delegate. + +```kt +abstract class MyManagerSpec : ViewGroupManager(), MyManagerInterface { + private val mDelegate: ViewManagerDelegate = MyManagerDelegate(this) + override fun getDelegate(): ViewManagerDelegate? = mDelegate +} +``` + +## Android: Event Emission Pattern + +Rules: +- Emit via ReactContext JS module. +- Event names must match TS `DirectEventHandler` props. + +```kt +val event = Arguments.createMap().apply { + putDouble("latitude", marker.position.latitude) + putDouble("longitude", marker.position.longitude) +} + +reactContext + .getJSModule(RCTEventEmitter::class.java) + .receiveEvent(view.id, "onTapOverlay", event) +``` + +## TypeScript: Color Prop to Native Int + +Rules: +- Accept `ColorValue` on JS side. +- Convert with `processColor` before passing native prop. + +```ts +import { processColor, type ColorValue } from 'react-native'; + +const tintColorNative = processColor(tintColor as ColorValue) as number; +``` + +## JSDoc Baseline + +Public APIs should include: +- `@param` +- `@returns` +- `@example` +- `@default` +- `@platform` (when platform-scoped) + +## Marker Image Handling (Native) + +Rules: +- Support symbol, RN asset, HTTP URL, native asset, and custom view cases. +- Cancel pending image work on cleanup. +- Keep behavior parity between iOS and Android implementations. + +--- + +For full details and extended examples, see `CLAUDE.md`. diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/RNCNaverMapPackage.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/RNCNaverMapPackage.kt index 871f0ab9..b3dc0bef 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/RNCNaverMapPackage.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/RNCNaverMapPackage.kt @@ -1,10 +1,13 @@ package com.mjstudio.reactnativenavermap -import com.facebook.react.ReactPackage +import com.facebook.react.BaseReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager import com.mjstudio.reactnativenavermap.mapview.RNCNaverMapViewManager +import com.mjstudio.reactnativenavermap.module.RNCNaverMapUtilModule import com.mjstudio.reactnativenavermap.overlay.arrowheadpath.RNCNaverMapArrowheadPathManager import com.mjstudio.reactnativenavermap.overlay.circle.RNCNaverMapCircleManager import com.mjstudio.reactnativenavermap.overlay.ground.RNCNaverMapGroundManager @@ -14,7 +17,7 @@ import com.mjstudio.reactnativenavermap.overlay.path.RNCNaverMapPathManager import com.mjstudio.reactnativenavermap.overlay.polygon.RNCNaverMapPolygonManager import com.mjstudio.reactnativenavermap.overlay.polyline.RNCNaverMapPolylineManager -class RNCNaverMapPackage : ReactPackage { +class RNCNaverMapPackage : BaseReactPackage() { override fun createViewManagers(reactContext: ReactApplicationContext): List> = mutableListOf>().apply { add(RNCNaverMapViewManager()) @@ -28,5 +31,27 @@ class RNCNaverMapPackage : ReactPackage { add(RNCNaverMapGroundManager()) } - override fun createNativeModules(reactContext: ReactApplicationContext): List = emptyList() + override fun getModule( + name: String, + reactContext: ReactApplicationContext, + ): NativeModule? = + when (name) { + NativeRNCNaverMapUtilSpec.NAME -> RNCNaverMapUtilModule(reactContext) + else -> null + } + + override fun getReactModuleInfoProvider() = + ReactModuleInfoProvider { + mapOf( + NativeRNCNaverMapUtilSpec.NAME to + ReactModuleInfo( + name = NativeRNCNaverMapUtilSpec.NAME, + className = NativeRNCNaverMapUtilSpec.NAME, + canOverrideExistingModule = false, + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true, + ), + ) + } } diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapViewManager.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapViewManager.kt index e2f54f72..a00eb1a5 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapViewManager.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapViewManager.kt @@ -846,6 +846,35 @@ class RNCNaverMapViewManager : RNCNaverMapViewManagerSpec + if (infoWindowId == null) return@withMap + + val module = reactAppContext.getNativeModule(com.mjstudio.reactnativenavermap.module.RNCNaverMapUtilModule::class.java) + val infoWindow = module?.getInfoWindow(infoWindowId) ?: return@withMap + + infoWindow.position = LatLng(latitude, longitude) + infoWindow.open(map) + module.markAsOpen(infoWindowId) + } + + override fun hideInfoWindow( + view: RNCNaverMapViewWrapper?, + infoWindowId: String?, + ) = view.withMap { map -> + if (infoWindowId == null) return@withMap + + val module = reactAppContext.getNativeModule(com.mjstudio.reactnativenavermap.module.RNCNaverMapUtilModule::class.java) + val infoWindow = module?.getInfoWindow(infoWindowId) ?: return@withMap + + infoWindow.close() + module.markAsClosed(infoWindowId) + } + companion object { const val NAME = "RNCNaverMapView" } diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/module/RNCNaverMapUtilModule.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/module/RNCNaverMapUtilModule.kt new file mode 100644 index 00000000..b496e4c6 --- /dev/null +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/module/RNCNaverMapUtilModule.kt @@ -0,0 +1,104 @@ +package com.mjstudio.reactnativenavermap.module + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.module.annotations.ReactModule +import com.mjstudio.reactnativenavermap.RNCNaverMapUtilSpec +import com.naver.maps.map.overlay.InfoWindow + +@ReactModule(name = RNCNaverMapUtilModule.NAME) +class RNCNaverMapUtilModule( + reactContext: ReactApplicationContext, +) : RNCNaverMapUtilSpec(reactContext) { + companion object { + const val NAME = "RNCNaverMapUtil" + } + + private val infoWindows = mutableMapOf() + private val infoWindowContents = mutableMapOf() + private val openInfoWindows = mutableSetOf() + + override fun getName(): String = NAME + + @ReactMethod + override fun createInfoWindow(id: String) { + if (infoWindows.containsKey(id)) return + + val infoWindow = + InfoWindow().apply { + adapter = + object : InfoWindow.DefaultTextAdapter(reactApplicationContext) { + override fun getText(infoWindow: InfoWindow): CharSequence { + val content = infoWindowContents[id] + return content?.let { + if (it.subtitle.isNullOrEmpty()) { + it.title + } else { + "${it.title}\n${it.subtitle}" + } + } ?: "" + } + } + // todo +// setOnClickListener { +// true +// } + } + + infoWindows[id] = infoWindow + } + + @ReactMethod + override fun destroyInfoWindow(id: String) { + infoWindows[id]?.let { infoWindow -> + infoWindow.close() + infoWindows.remove(id) + infoWindowContents.remove(id) + openInfoWindows.remove(id) + } + } + + @ReactMethod + override fun closeInfoWindow(id: String) { + infoWindows[id]?.let { infoWindow -> + infoWindow.close() + openInfoWindows.remove(id) + } + } + + @ReactMethod + override fun setInfoWindowContent( + id: String, + title: String, + subtitle: String?, + ) { + infoWindowContents[id] = InfoWindowContent(title, subtitle) + val infoWindow = infoWindows[id] ?: return + + // 이미 열려있다면 업데이트 + if (isInfoWindowActuallyOpen(infoWindow)) { + infoWindow.invalidate() + } + } + + @ReactMethod(isBlockingSynchronousMethod = true) + override fun isInfoWindowOpen(id: String): Boolean = infoWindows[id]?.let(::isInfoWindowActuallyOpen) ?: false + + // ViewManager에서 접근할 내부 메서드들 + fun getInfoWindow(id: String): InfoWindow? = infoWindows[id] + + fun markAsOpen(id: String) { + openInfoWindows.add(id) + } + + fun markAsClosed(id: String) { + openInfoWindows.remove(id) + } + + private fun isInfoWindowActuallyOpen(infoWindow: InfoWindow): Boolean = infoWindow.map != null || infoWindow.marker != null + + private data class InfoWindowContent( + val title: String, + val subtitle: String?, + ) +} diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarkerManager.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarkerManager.kt index 3d2f71c9..8ee0f19e 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarkerManager.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarkerManager.kt @@ -238,6 +238,20 @@ class RNCNaverMapMarkerManager : RNCNaverMapMarkerManagerSpec view?.updateSubCaption(value) } + override fun showInfoWindow( + view: RNCNaverMapMarker?, + infoWindowId: String?, + ) = view.withOverlay { marker -> + if (infoWindowId == null) return@withOverlay + + val reactContext = view?.reactContext ?: return@withOverlay + val module = reactContext.getNativeModule(com.mjstudio.reactnativenavermap.module.RNCNaverMapUtilModule::class.java) + val infoWindow = module?.getInfoWindow(infoWindowId) ?: return@withOverlay + + infoWindow.open(marker) + module.markAsOpen(infoWindowId) + } + companion object { const val NAME = "RNCNaverMapMarker" } diff --git a/android/src/newarch/RNCNaverMapUtilSpec.kt b/android/src/newarch/RNCNaverMapUtilSpec.kt new file mode 100644 index 00000000..01d716a4 --- /dev/null +++ b/android/src/newarch/RNCNaverMapUtilSpec.kt @@ -0,0 +1,10 @@ +package com.mjstudio.reactnativenavermap + +import com.facebook.react.bridge.ReactApplicationContext + +abstract class RNCNaverMapUtilSpec( + reactContext: ReactApplicationContext, +) : NativeRNCNaverMapUtilSpec(reactContext) { + // Codegen이 생성하는 abstract class를 상속 + // 실제 구현은 RNCNaverMapUtilModule에서 +} diff --git a/docs/package.json b/docs/package.json index dc2cdaaf..7c87cd5e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -15,8 +15,8 @@ "fumadocs-ui": "^16.0.14", "lucide-react": "^0.542.0", "next": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", + "react": "19.2.0", + "react-dom": "19.2.0", "sharp": "^0.34.3", "shiki": "^3.15.0" }, diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 1f458132..e9bd44c7 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -8,7 +8,7 @@ PODS: - hermes-engine (0.80.0): - hermes-engine/Pre-built (= 0.80.0) - hermes-engine/Pre-built (0.80.0) - - mj-studio-react-native-naver-map (2.6.7): + - mj-studio-react-native-naver-map (2.7.0): - boost - DoubleConversion - fast_float @@ -2585,7 +2585,7 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 7068e976238b29e97b3bafd09a994542af7d5c0b - mj-studio-react-native-naver-map: 402d1e0e8e5c9642868cd14b333a9f97e04398c5 + mj-studio-react-native-naver-map: 3255991793e94a1fcc1ddeff65b5e2d4699d083b NMapsGeometry: 4e02554fa9880ef02ed96b075dc84355d6352479 NMapsMap: 1964e6f9073301ad3cbe3a12235ba36f6f6cd905 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 diff --git a/example/package.json b/example/package.json index 0238c2d6..f9dcfabc 100644 --- a/example/package.json +++ b/example/package.json @@ -29,7 +29,6 @@ "@react-native/typescript-config": "catalog:", "@types/react": "catalog:", "@types/react-test-renderer": "catalog:", - "react-test-renderer": "catalog:", "typescript": "catalog:" }, "engines": { diff --git a/example/src/App.tsx b/example/src/App.tsx index f4702f59..cef1c3bf 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -20,6 +20,7 @@ import { CitiesScreen } from './screens/CitiesScreen'; import { ClusteringScreen } from './screens/ClusteringScreen'; import { CommonScreen } from './screens/CommonScreen'; import { GroundScreen } from './screens/GroundScreen'; +import { InfoWindowScreen } from './screens/InfoWindowScreen'; import { LocationOverlayScreen } from './screens/LocationOverlayScreen'; import { MarkerScreen } from './screens/MarkerScreen'; import { MultiPathScreen } from './screens/MultiPathScreen'; @@ -31,6 +32,7 @@ const SCREENS = [ { id: 'common', title: 'Common Settings' }, { id: 'camera', title: 'Camera Controls' }, { id: 'marker', title: 'Marker Overlay' }, + { id: 'infowindow', title: 'InfoWindow Example' }, { id: 'circle', title: 'Circle Overlay' }, { id: 'ground', title: 'Ground Overlay' }, { id: 'path', title: 'Path Overlay' }, @@ -87,6 +89,8 @@ export default function App() { return ; case 'marker': return ; + case 'infowindow': + return ; case 'circle': return ; case 'ground': diff --git a/example/src/screens/InfoWindowScreen.tsx b/example/src/screens/InfoWindowScreen.tsx new file mode 100644 index 00000000..63931614 --- /dev/null +++ b/example/src/screens/InfoWindowScreen.tsx @@ -0,0 +1,228 @@ +import { + NaverMapMarkerOverlay, + useInfoWindow, +} from '@mj-studio/react-native-naver-map'; +import React, { useRef, useState } from 'react'; +import { Button, StyleSheet, Text, View } from 'react-native'; +import { Header } from '../components/Header'; +import { ScreenLayout } from '../components/ScreenLayout'; + +const Cameras = { + Seoul: { + latitude: 37.5665, + longitude: 126.978, + zoom: 12, + }, +}; + +export const InfoWindowScreen = ({ onBack }: { onBack: () => void }) => { + const mapRef = useRef(null); + const marker1Ref = useRef(null); + const marker2Ref = useRef(null); + const marker3Ref = useRef(null); + + const [selectedMarkerId, setSelectedMarkerId] = useState(null); + + // 여러 InfoWindow 인스턴스 생성 + const infoWindow1 = useInfoWindow({ + title: '서울역', + subtitle: '서울특별시 중구 한강대로 405', + }); + + const infoWindow2 = useInfoWindow({ + title: '남산타워', + subtitle: '서울특별시 용산구 남산공원길 105', + }); + + const infoWindow3 = useInfoWindow({ + title: '경복궁', + subtitle: '서울특별시 종로구 사직로 161', + }); + + // 맵에 직접 표시할 InfoWindow + const infoWindowOnMap = useInfoWindow({ + title: '한강대교', + subtitle: '서울의 중심을 가로지르는 다리', + }); + + const handleMarker1Tap = () => { + setSelectedMarkerId('marker1'); + infoWindow1.showOnMarker({ markerRef: marker1Ref }); + // 다른 InfoWindow들 닫기 + infoWindow2.close(); + infoWindow3.close(); + infoWindowOnMap.close(); + }; + + const handleMarker2Tap = () => { + setSelectedMarkerId('marker2'); + infoWindow2.showOnMarker({ markerRef: marker2Ref }); + // 다른 InfoWindow들 닫기 + infoWindow1.close(); + infoWindow3.close(); + infoWindowOnMap.close(); + }; + + const handleMarker3Tap = () => { + setSelectedMarkerId('marker3'); + infoWindow3.showOnMarker({ markerRef: marker3Ref }); + // 다른 InfoWindow들 닫기 + infoWindow1.close(); + infoWindow2.close(); + infoWindowOnMap.close(); + }; + + const handleShowOnMap = () => { + if (!mapRef.current) { + console.warn('Map ref not ready'); + return; + } + + // 한강대교 위치에 InfoWindow 표시 + infoWindowOnMap.showOnMap({ + mapRef: mapRef, + position: { latitude: 37.5219, longitude: 126.9918 }, + }); + + // 다른 InfoWindow들 닫기 + infoWindow1.close(); + infoWindow2.close(); + infoWindow3.close(); + setSelectedMarkerId(null); + }; + + const handleCloseAll = () => { + infoWindow1.close(); + infoWindow2.close(); + infoWindow3.close(); + infoWindowOnMap.close(); + setSelectedMarkerId(null); + }; + + return ( + <> +
+ + {/* 서울역 마커 */} + + + {/* 남산타워 마커 */} + + + {/* 경복궁 마커 */} + + + + {/* 컨트롤 버튼들 */} + + + 마커를 탭하여 InfoWindow를 표시합니다 + + + +