@@ -90,7 +90,7 @@ export interface ScriptGoogleMapsOverlayViewExpose {
9090 </script >
9191
9292<script setup lang="ts">
93- import { computed , inject , ref , useTemplateRef , watch } from ' vue'
93+ import { computed , inject , shallowRef , useTemplateRef , watch } from ' vue'
9494import { MARKER_CLUSTERER_INJECTION_KEY } from ' ./types'
9595import { defineDeprecatedAlias , MARKER_INJECTION_KEY , normalizeLatLng , useGoogleMapsResource } from ' ./useGoogleMapsResource'
9696
@@ -159,30 +159,45 @@ const ANCHOR_TRANSFORMS: Record<ScriptGoogleMapsOverlayAnchor, string> = {
159159 ' right-center' : ' translate(-100%, -50%)' ,
160160}
161161
162- const overlayContent = useTemplateRef (' overlay-content ' )
162+ const overlayAnchor = useTemplateRef (' overlay-anchor ' )
163163
164- // Reactive open/closed state for CSS animations via data-state attribute.
165- // Tracks whether the overlay content is positioned and should be visually open.
166- const isPositioned = ref ( false )
167- const dataState = computed (() => isPositioned . value ? ' open ' : ' closed ' )
164+ // Reactive pixel position written by `draw()`. The template style binding
165+ // on the anchor element reads it, so position updates flow through Vue's
166+ // reactivity instead of imperative `el.style.left/top` writes.
167+ const overlayPosition = shallowRef <{ x : number , y : number } | undefined >( undefined )
168168
169- // Track all event listeners for clean teardown
170- const listeners: google .maps .MapsEventListener [] = []
169+ // `dataState` reflects the visible/hidden state of the overlay. It is bound
170+ // directly on the content element so CSS animations targeting `[data-state]`
171+ // react without any imperative DOM writes.
172+ const dataState = computed <' open' | ' closed' >(() =>
173+ open .value !== false && overlayPosition .value !== undefined ? ' open' : ' closed' ,
174+ )
171175
172- function setDataState(el : HTMLElement , state : ' open' | ' closed' ) {
173- el .dataset .state = state
174- // Propagate to the slot's root element imperatively (Vue template bindings
175- // don't reliably patch elements that have been moved to Google Maps panes)
176- const child = el .firstElementChild as HTMLElement | null
177- if (child )
178- child .dataset .state = state
179- }
176+ // Computed style for the anchor element. Vue patches the moved DOM node via
177+ // this binding even after Google Maps has reparented it into a pane.
178+ const overlayStyle = computed <Record <string , string | undefined >>(() => {
179+ const visible = open .value !== false && overlayPosition .value !== undefined
180+ if (! visible ) {
181+ return {
182+ position: ' absolute' ,
183+ visibility: ' hidden' ,
184+ pointerEvents: ' none' ,
185+ }
186+ }
187+ const { x, y } = overlayPosition .value !
188+ return {
189+ position: ' absolute' ,
190+ left: ` ${x + (offset ?.x ?? 0 )}px ` ,
191+ top: ` ${y + (offset ?.y ?? 0 )}px ` ,
192+ transform: ANCHOR_TRANSFORMS [anchor ],
193+ zIndex: zIndex !== undefined ? String (zIndex ) : undefined ,
194+ visibility: ' visible' ,
195+ pointerEvents: ' auto' ,
196+ }
197+ })
180198
181- function hideElement(el : HTMLElement ) {
182- el .style .visibility = ' hidden'
183- el .style .pointerEvents = ' none'
184- setDataState (el , ' closed' )
185- }
199+ // Track all event listeners for clean teardown
200+ const listeners: google .maps .MapsEventListener [] = []
186201
187202function panMapToFitOverlay(el : HTMLElement , map : google .maps .Map , padding : number ) {
188203 const child = el .firstElementChild
@@ -204,87 +219,76 @@ function panMapToFitOverlay(el: HTMLElement, map: google.maps.Map, padding: numb
204219 map .panBy (panX , panY )
205220}
206221
207- const overlay = useGoogleMapsResource <google .maps .OverlayView >({
208- // ready condition accesses .value on ShallowRefs — tracked by whenever() in useGoogleMapsResource
209- ready : () => !! overlayContent .value
210- && !! (position || markerContext ?.advancedMarkerElement .value ),
211- create({ mapsApi , map }) {
212- const el = overlayContent .value !
213-
214- class CustomOverlay extends mapsApi .OverlayView {
215- override onAdd() {
216- const panes = this .getPanes ()
217- if (panes ) {
218- panes [pane ].appendChild (el )
219- if (blockMapInteraction )
220- mapsApi .OverlayView .preventMapHitsAndGesturesFrom (el )
221- }
222- if (panOnOpen ) {
223- // Wait for draw() to position the element, then pan
224- const padding = typeof panOnOpen === ' number' ? panOnOpen : 40
225- requestAnimationFrame (() => {
226- panMapToFitOverlay (el , map , padding )
227- })
228- }
222+ // Factory that builds the OverlayView subclass. Lifted out of `create()`
223+ // so the create callback stays focused on wiring (instantiation, listeners).
224+ // The class still has to extend `mapsApi.OverlayView`, which is only
225+ // available after the script loads, so this stays a function rather than
226+ // a top-level class declaration.
227+ function makeOverlayClass(mapsApi : typeof google .maps , map : google .maps .Map ) {
228+ return class CustomOverlay extends mapsApi .OverlayView {
229+ override onAdd() {
230+ const panes = this .getPanes ()
231+ const el = overlayAnchor .value
232+ if (panes && el ) {
233+ panes [pane ].appendChild (el )
234+ if (blockMapInteraction )
235+ mapsApi .OverlayView .preventMapHitsAndGesturesFrom (el )
229236 }
230-
231- override draw() {
232- // v-model:open support: hide when explicitly closed
233- if (open .value === false ) {
234- isPositioned .value = false
235- hideElement (el )
236- return
237- }
238-
239- const resolvedPosition = getResolvedPosition ()
240- if (! resolvedPosition ) {
241- isPositioned .value = false
242- hideElement (el )
243- return
244- }
245- const projection = this .getProjection ()
246- if (! projection ) {
247- isPositioned .value = false
248- hideElement (el )
249- return
250- }
251- const pos = projection .fromLatLngToDivPixel (
252- new mapsApi .LatLng (resolvedPosition .lat , resolvedPosition .lng ),
253- )
254- if (! pos ) {
255- isPositioned .value = false
256- hideElement (el )
257- return
258- }
259-
260- el .style .position = ' absolute'
261- el .style .left = ` ${pos .x + (offset ?.x ?? 0 )}px `
262- el .style .top = ` ${pos .y + (offset ?.y ?? 0 )}px `
263- el .style .transform = ANCHOR_TRANSFORMS [anchor ]
264- if (zIndex !== undefined )
265- el .style .zIndex = String (zIndex )
266- el .style .visibility = ' visible'
267- el .style .pointerEvents = ' auto'
268- setDataState (el , ' open' )
269- isPositioned .value = true
237+ if (panOnOpen ) {
238+ // Wait for draw() to position the element, then pan
239+ const padding = typeof panOnOpen === ' number' ? panOnOpen : 40
240+ requestAnimationFrame (() => {
241+ if (overlayAnchor .value )
242+ panMapToFitOverlay (overlayAnchor .value , map , padding )
243+ })
270244 }
245+ }
271246
272- override onRemove() {
273- el .parentNode ?.removeChild (el )
247+ override draw() {
248+ if (open .value === false ) {
249+ overlayPosition .value = undefined
250+ return
251+ }
252+ const resolvedPosition = getResolvedPosition ()
253+ if (! resolvedPosition ) {
254+ overlayPosition .value = undefined
255+ return
256+ }
257+ const projection = this .getProjection ()
258+ if (! projection ) {
259+ overlayPosition .value = undefined
260+ return
261+ }
262+ const pos = projection .fromLatLngToDivPixel (
263+ new mapsApi .LatLng (resolvedPosition .lat , resolvedPosition .lng ),
264+ )
265+ if (! pos ) {
266+ overlayPosition .value = undefined
267+ return
274268 }
269+ overlayPosition .value = { x: pos .x , y: pos .y }
275270 }
276271
277- // Prevent flash: hide until first draw() positions content
278- el .style .visibility = ' hidden'
279- el .style .pointerEvents = ' none'
272+ override onRemove() {
273+ const el = overlayAnchor .value
274+ el ?.parentNode ?.removeChild (el )
275+ }
276+ }
277+ }
280278
279+ const overlay = useGoogleMapsResource <google .maps .OverlayView >({
280+ // ready condition accesses .value on ShallowRefs — tracked by whenever() in useGoogleMapsResource
281+ ready : () => !! overlayAnchor .value
282+ && !! (position || markerContext ?.advancedMarkerElement .value ),
283+ create({ mapsApi , map }) {
284+ const CustomOverlay = makeOverlayClass (mapsApi , map )
281285 const ov = new CustomOverlay ()
282286 ov .setMap (map )
283287
284- // Follow parent marker position changes
288+ // Follow parent marker position changes. AdvancedMarkerElement fires
289+ // `drag` continuously during drag, so the overlay tracks live.
285290 if (markerContext ?.advancedMarkerElement .value ) {
286291 const ame = markerContext .advancedMarkerElement .value
287- // AdvancedMarkerElement fires drag continuously during drag
288292 listeners .push (
289293 ame .addListener (' drag' , () => ov .draw ()),
290294 ame .addListener (' dragend' , () => ov .draw ()),
@@ -386,8 +390,20 @@ defineExpose<ScriptGoogleMapsOverlayViewExpose>(exposed)
386390
387391<template >
388392 <div style =" display : none ;" >
389- <div ref =" overlay-content" :data-state =" dataState" v-bind =" $attrs" >
390- <slot />
393+ <!--
394+ Two-element structure:
395+ - `overlay-anchor` is moved into a Google Maps pane on `onAdd()`. Its
396+ inline style is reactively bound to `overlayStyle`, so position
397+ updates from `draw()` flow through Vue's patcher even after the node
398+ has been reparented out of the component tree.
399+ - `overlay-content` carries `data-state`, attribute-based animations,
400+ and forwards parent attrs (e.g. `class`) so consumers can target it
401+ directly with `[data-state]` selectors.
402+ -->
403+ <div ref =" overlay-anchor" :style =" overlayStyle" >
404+ <div :data-state =" dataState" v-bind =" $attrs" >
405+ <slot />
406+ </div >
391407 </div >
392408 </div >
393409</template >
0 commit comments