diff --git a/examples/drawing/README.md b/examples/drawing/README.md index 7e0708d2..f4bc602f 100644 --- a/examples/drawing/README.md +++ b/examples/drawing/README.md @@ -1,6 +1,6 @@ # Drawing Tools Example -This example shows how to use the [google.maps.drawing.DrawingManager][drawing-manager] to draw shapes or markers on the map. In addition the example implements an undo/redo flow for the drawing tools. If you only want to add the the drawing tools to your map take a look at the `use-drawing-manager` hook. +This example shows how to build custom drawing tools using Maps JavaScript API overlays to draw shapes or markers on the map. In addition the example implements an undo/redo flow for drawing interactions. ## Google Maps Platform API Key @@ -33,4 +33,3 @@ npm run start-local The regular `npm start` task is only used for the standalone versions of the example (CodeSandbox for example) [get-api-key]: https://developers.google.com/maps/documentation/javascript/get-api-key -[drawing-manager]: https://developers.google.com/maps/documentation/javascript/drawinglayer diff --git a/examples/drawing/src/app.tsx b/examples/drawing/src/app.tsx index 18a25f4a..da9b1fdc 100644 --- a/examples/drawing/src/app.tsx +++ b/examples/drawing/src/app.tsx @@ -9,7 +9,7 @@ const API_KEY = const App = () => { return ( - + ); diff --git a/examples/drawing/src/control-panel.tsx b/examples/drawing/src/control-panel.tsx index f418e310..316a3f81 100644 --- a/examples/drawing/src/control-panel.tsx +++ b/examples/drawing/src/control-panel.tsx @@ -3,11 +3,11 @@ import * as React from 'react'; function ControlPanel() { return (
-

Drawing Tools Example

+

Drawing Example

- Shows how to use the drawing tools of the Maps JavaScript API and - implements an undo/redo flow to show how to integrate the drawing - manager and its events into the state of a react-application. + Shows how to build custom drawing tools using Maps JavaScript API + overlays and implements an undo/redo flow to integrate drawing events + into the state of a React application.

{ - const drawingManager = useDrawingManager(); + const drawingController = useDrawingManager(); return ( <> - +
+ + drawingController.setActiveMode(null)} + /> +
); diff --git a/examples/drawing/src/drawing-toolbar.tsx b/examples/drawing/src/drawing-toolbar.tsx new file mode 100644 index 00000000..7207a697 --- /dev/null +++ b/examples/drawing/src/drawing-toolbar.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import {DrawingMode} from './types'; + +const MODES: Array<{mode: Exclude; label: string}> = [ + {mode: 'marker', label: 'Marker'}, + {mode: 'circle', label: 'Circle'}, + {mode: 'polygon', label: 'Polygon'}, + {mode: 'polyline', label: 'Polyline'}, + {mode: 'rectangle', label: 'Rectangle'} +]; + +interface Props { + activeMode: DrawingMode; + setActiveMode: (mode: DrawingMode) => void; +} + +export const DrawingToolbar = ({activeMode, setActiveMode}: Props) => { + return ( +
+ {MODES.map(({mode, label}) => ( + + ))} +
+ ); +}; diff --git a/examples/drawing/src/types.ts b/examples/drawing/src/types.ts index f61c6be4..0e30eb83 100644 --- a/examples/drawing/src/types.ts +++ b/examples/drawing/src/types.ts @@ -1,12 +1,23 @@ export type OverlayGeometry = | google.maps.Marker + | google.maps.marker.AdvancedMarkerElement | google.maps.Polygon | google.maps.Polyline | google.maps.Rectangle | google.maps.Circle; +export type DrawingMode = + | 'marker' + | 'circle' + | 'polygon' + | 'polyline' + | 'rectangle' + | null; + +export type OverlayType = Exclude; + export interface DrawResult { - type: google.maps.drawing.OverlayType; + type: OverlayType; overlay: OverlayGeometry; } @@ -19,7 +30,7 @@ export interface Snapshot { } export interface Overlay { - type: google.maps.drawing.OverlayType; + type: OverlayType; geometry: OverlayGeometry; snapshot: Snapshot; } @@ -33,12 +44,16 @@ export interface State { export enum DrawingActionKind { SET_OVERLAY = 'SET_OVERLAY', UPDATE_OVERLAYS = 'UPDATE_OVERLAYS', + DELETE_OVERLAY = 'DELETE_OVERLAY', UNDO = 'UNDO', REDO = 'REDO' } export interface ActionWithTypeOnly { - type: Exclude; + type: Exclude< + DrawingActionKind, + DrawingActionKind.SET_OVERLAY | DrawingActionKind.DELETE_OVERLAY + >; } export interface SetOverlayAction { @@ -46,7 +61,15 @@ export interface SetOverlayAction { payload: DrawResult; } -export type Action = ActionWithTypeOnly | SetOverlayAction; +export interface DeleteOverlayAction { + type: DrawingActionKind.DELETE_OVERLAY; + payload: OverlayGeometry; +} + +export type Action = + | ActionWithTypeOnly + | SetOverlayAction + | DeleteOverlayAction; export function isCircle( overlay: OverlayGeometry @@ -56,8 +79,69 @@ export function isCircle( export function isMarker( overlay: OverlayGeometry -): overlay is google.maps.Marker { - return (overlay as google.maps.Marker).getPosition !== undefined; +): overlay is google.maps.Marker | google.maps.marker.AdvancedMarkerElement { + return ( + (overlay as google.maps.Marker).getPosition !== undefined || + (overlay as google.maps.marker.AdvancedMarkerElement).gmpDraggable !== + undefined + ); +} + +export function isAdvancedMarker( + overlay: OverlayGeometry +): overlay is google.maps.marker.AdvancedMarkerElement { + return ( + (overlay as google.maps.marker.AdvancedMarkerElement).gmpDraggable !== + undefined + ); +} + +// AdvancedMarkerElement exposes `position` while legacy Marker uses getters. +// These helpers normalize read/write access across both APIs. +// AdvancedMarkerElement exposes `position` while legacy Marker uses getters. +// These helpers normalize read/write access across both APIs. +export function getMarkerPosition( + overlay: google.maps.Marker | google.maps.marker.AdvancedMarkerElement +): google.maps.LatLngLiteral | undefined { + if (isAdvancedMarker(overlay)) { + if (!overlay.position) return undefined; + + return overlay.position instanceof google.maps.LatLng + ? overlay.position.toJSON() + : overlay.position; + } + + return overlay.getPosition()?.toJSON(); +} + +export function setMarkerPosition( + overlay: google.maps.Marker | google.maps.marker.AdvancedMarkerElement, + position?: google.maps.LatLngLiteral +) { + if (!position) return; + + if (isAdvancedMarker(overlay)) { + overlay.position = position; + return; + } + + overlay.setPosition(position); +} + +// AdvancedMarkerElement uses the `map` property instead of `setMap`. +// Keep all overlay map changes centralized here. +// AdvancedMarkerElement uses the `map` property instead of `setMap`. +// Keep all overlay map changes centralized here. +export function setOverlayMap( + overlay: OverlayGeometry, + map: google.maps.Map | null +) { + if (isAdvancedMarker(overlay)) { + overlay.map = map; + return; + } + + overlay.setMap(map); } export function isPolygon( diff --git a/examples/drawing/src/undo-redo-control.tsx b/examples/drawing/src/undo-redo-control.tsx index b6d92103..b44f6672 100644 --- a/examples/drawing/src/undo-redo-control.tsx +++ b/examples/drawing/src/undo-redo-control.tsx @@ -1,20 +1,37 @@ -import React, {useReducer, useRef} from 'react'; +import React, { + useCallback, + useEffect, + useReducer, + useRef, + useState +} from 'react'; import {useMap} from '@vis.gl/react-google-maps'; import reducer, { useDrawingManagerEvents, + useOverlaySelection, useOverlaySnapshots } from './undo-redo'; -import {DrawingActionKind} from './types'; +import {DrawingActionKind, OverlayGeometry} from './types'; interface Props { - drawingManager: google.maps.drawing.DrawingManager | null; + drawingController: google.maps.MVCObject | null; + onOverlaySelect?: () => void; } -export const UndoRedoControl = ({drawingManager}: Props) => { +export const UndoRedoControl = ({ + drawingController, + onOverlaySelect +}: Props) => { const map = useMap(); + const [deleteMode, setDeleteMode] = useState(false); + const deleteModeRef = useRef(deleteMode); + const selectedOverlayRef = useRef(null); + const ignoreNextMapClickRef = useRef(false); + const onOverlaySelectRef = useRef(onOverlaySelect); + const [state, dispatch] = useReducer(reducer, { past: [], now: [], @@ -28,7 +45,29 @@ export const UndoRedoControl = ({drawingManager}: Props) => { // off the "updating" when snapshot changes are applied back to the overlays. const overlaysShouldUpdateRef = useRef(false); - useDrawingManagerEvents(drawingManager, overlaysShouldUpdateRef, dispatch); + useEffect(() => { + deleteModeRef.current = deleteMode; + }, [deleteMode]); + + useEffect(() => { + onOverlaySelectRef.current = onOverlaySelect; + }, [onOverlaySelect]); + + const handleOverlaySelect = useCallback(() => { + onOverlaySelectRef.current?.(); + }, []); + + useDrawingManagerEvents(drawingController, overlaysShouldUpdateRef, dispatch); + useOverlaySelection( + map, + state.now, + selectedOverlayRef, + deleteModeRef, + setDeleteMode, + ignoreNextMapClickRef, + dispatch, + handleOverlaySelect + ); useOverlaySnapshots(map, state, overlaysShouldUpdateRef); return ( @@ -55,6 +94,13 @@ export const UndoRedoControl = ({drawingManager}: Props) => { +
); }; diff --git a/examples/drawing/src/undo-redo.ts b/examples/drawing/src/undo-redo.ts index 1bd06ac3..08f16d3c 100644 --- a/examples/drawing/src/undo-redo.ts +++ b/examples/drawing/src/undo-redo.ts @@ -7,11 +7,14 @@ import { Overlay, Snapshot, State, + getMarkerPosition, isCircle, isMarker, isPolygon, isPolyline, - isRectangle + isRectangle, + setMarkerPosition, + setOverlayMap } from './types'; export default function reducer(state: State, action: Action) { @@ -28,7 +31,7 @@ export default function reducer(state: State, action: Action) { snapshot.center = geometry.getCenter()?.toJSON(); snapshot.radius = geometry.getRadius(); } else if (isMarker(geometry)) { - snapshot.position = geometry.getPosition()?.toJSON(); + snapshot.position = getMarkerPosition(geometry); } else if (isPolygon(geometry) || isPolyline(geometry)) { snapshot.path = geometry.getPath()?.getArray(); } else if (isRectangle(geometry)) { @@ -60,7 +63,7 @@ export default function reducer(state: State, action: Action) { snapshot.center = overlay.getCenter()?.toJSON(); snapshot.radius = overlay.getRadius(); } else if (isMarker(overlay)) { - snapshot.position = overlay.getPosition()?.toJSON(); + snapshot.position = getMarkerPosition(overlay); } else if (isPolygon(overlay) || isPolyline(overlay)) { snapshot.path = overlay.getPath()?.getArray(); } else if (isRectangle(overlay)) { @@ -81,6 +84,21 @@ export default function reducer(state: State, action: Action) { }; } + case DrawingActionKind.DELETE_OVERLAY: { + const overlayToDelete = action.payload; + const nextNow = state.now.filter( + overlay => overlay.geometry !== overlayToDelete + ); + + if (nextNow.length === state.now.length) return state; + + return { + past: [...state.past, state.now], + now: nextNow, + future: [] + }; + } + // This action is called when the undo button is clicked. // Get the top item from the "past" stack and set it as the new "now". // Add the old "now" to the "future" stack to enable redo functionality @@ -115,12 +133,12 @@ export default function reducer(state: State, action: Action) { // Handle drawing manager events export function useDrawingManagerEvents( - drawingManager: google.maps.drawing.DrawingManager | null, + drawingController: google.maps.MVCObject | null, overlaysShouldUpdateRef: MutableRefObject, dispatch: Dispatch ) { useEffect(() => { - if (!drawingManager) return; + if (!drawingController) return; const eventListeners: Array = []; @@ -147,30 +165,30 @@ export function useDrawingManagerEvents( }; const overlayCompleteListener = google.maps.event.addListener( - drawingManager, + drawingController, 'overlaycomplete', (drawResult: DrawResult) => { switch (drawResult.type) { - case google.maps.drawing.OverlayType.CIRCLE: + case 'circle': ['center_changed', 'radius_changed'].forEach(eventName => addUpdateListener(eventName, drawResult) ); break; - case google.maps.drawing.OverlayType.MARKER: + case 'marker': ['dragend'].forEach(eventName => addUpdateListener(eventName, drawResult) ); break; - case google.maps.drawing.OverlayType.POLYGON: - case google.maps.drawing.OverlayType.POLYLINE: + case 'polygon': + case 'polyline': ['mouseup'].forEach(eventName => addUpdateListener(eventName, drawResult) ); - case google.maps.drawing.OverlayType.RECTANGLE: + case 'rectangle': ['bounds_changed', 'dragstart', 'dragend'].forEach(eventName => addUpdateListener(eventName, drawResult) ); @@ -189,7 +207,118 @@ export function useDrawingManagerEvents( google.maps.event.removeListener(listener) ); }; - }, [dispatch, drawingManager, overlaysShouldUpdateRef]); + }, [dispatch, drawingController, overlaysShouldUpdateRef]); +} + +// Selection is reattached whenever `state.now` changes (e.g., undo/redo). +// This keeps overlays selectable after snapshot restores. +export function useOverlaySelection( + map: google.maps.Map | null, + overlays: Array, + selectedOverlayRef: MutableRefObject, + deleteModeRef: MutableRefObject, + setDeleteMode: Dispatch, + ignoreNextMapClickRef: MutableRefObject, + dispatch: Dispatch, + onOverlaySelect?: (() => void) | null +) { + useEffect(() => { + if (!map) return; + + const eventListeners: Array = []; + + const setOverlayEditable = (overlay: OverlayGeometry, enabled: boolean) => { + if (isCircle(overlay)) { + overlay.setEditable(enabled); + } else if (isRectangle(overlay)) { + overlay.setEditable(enabled); + overlay.setDraggable(enabled); + } else if (isPolygon(overlay) || isPolyline(overlay)) { + overlay.setEditable(enabled); + overlay.setDraggable(enabled); + } + }; + + const clearSelection = () => { + if (!selectedOverlayRef.current) return; + + setOverlayEditable(selectedOverlayRef.current, false); + selectedOverlayRef.current = null; + }; + + const selectOverlay = (overlay: OverlayGeometry) => { + if (selectedOverlayRef.current === overlay) return; + + clearSelection(); + + if (isMarker(overlay)) { + selectedOverlayRef.current = overlay; + return; + } + + setOverlayEditable(overlay, true); + selectedOverlayRef.current = overlay; + }; + + const handleOverlayClick = (overlay: OverlayGeometry) => { + // Workaround: prevent the next map click (bubbled from overlay) from + // immediately clearing selection. Reset on next tick. + ignoreNextMapClickRef.current = true; + window.setTimeout(() => { + ignoreNextMapClickRef.current = false; + }, 0); + onOverlaySelect?.(); + + if (deleteModeRef.current) { + setOverlayMap(overlay, null); + clearSelection(); + setDeleteMode(false); + dispatch({ + type: DrawingActionKind.DELETE_OVERLAY, + payload: overlay + }); + return; + } + + selectOverlay(overlay); + }; + + const mapClickListener = map.addListener('click', () => { + if (ignoreNextMapClickRef.current) { + ignoreNextMapClickRef.current = false; + return; + } + + clearSelection(); + }); + + eventListeners.push(mapClickListener); + + for (const overlay of overlays) { + const listener = google.maps.event.addListener( + overlay.geometry, + 'click', + () => handleOverlayClick(overlay.geometry) + ); + + eventListeners.push(listener); + } + + return () => { + eventListeners.forEach(listener => + google.maps.event.removeListener(listener) + ); + }; + }, [ + map, + overlays, + selectedOverlayRef, + deleteModeRef, + setDeleteMode, + ignoreNextMapClickRef, + dispatch, + onOverlaySelect + ]); } // Update overlays with the current "snapshot" when the "now" state changes @@ -204,7 +333,7 @@ export function useOverlaySnapshots( for (const overlay of state.now) { overlaysShouldUpdateRef.current = false; - overlay.geometry.setMap(map); + setOverlayMap(overlay.geometry, map); const {radius, center, position, path, bounds} = overlay.snapshot; @@ -212,7 +341,7 @@ export function useOverlaySnapshots( overlay.geometry.setRadius(radius ?? 0); overlay.geometry.setCenter(center ?? null); } else if (isMarker(overlay.geometry)) { - overlay.geometry.setPosition(position); + setMarkerPosition(overlay.geometry, position); } else if (isPolygon(overlay.geometry) || isPolyline(overlay.geometry)) { overlay.geometry.setPath(path ?? []); } else if (isRectangle(overlay.geometry)) { @@ -224,7 +353,7 @@ export function useOverlaySnapshots( return () => { for (const overlay of state.now) { - overlay.geometry.setMap(null); + setOverlayMap(overlay.geometry, null); } }; }, [map, overlaysShouldUpdateRef, state.now]); diff --git a/examples/drawing/src/use-drawing-manager.tsx b/examples/drawing/src/use-drawing-manager.tsx index c4472b79..0dd4d1a5 100644 --- a/examples/drawing/src/use-drawing-manager.tsx +++ b/examples/drawing/src/use-drawing-manager.tsx @@ -1,59 +1,535 @@ import {useMap, useMapsLibrary} from '@vis.gl/react-google-maps'; -import {useEffect, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; + +import { + DrawResult, + DrawingMode, + OverlayGeometry, + OverlayType, + setOverlayMap +} from './types'; + +export interface DrawingController { + eventTarget: google.maps.MVCObject | null; + activeMode: DrawingMode; + setActiveMode: (mode: DrawingMode) => void; +} + +const DEFAULT_ACTIVE_MODE: DrawingMode = 'circle'; export function useDrawingManager( - initialValue: google.maps.drawing.DrawingManager | null = null -) { + initialMode: DrawingMode = DEFAULT_ACTIVE_MODE +): DrawingController { const map = useMap(); - const drawing = useMapsLibrary('drawing'); + const geometry = useMapsLibrary('geometry'); + const markerLibrary = useMapsLibrary('marker'); - const [drawingManager, setDrawingManager] = - useState(initialValue); + const [activeMode, setActiveModeState] = useState(initialMode); + const activeModeRef = useRef(activeMode); + const [eventTarget, setEventTarget] = useState( + null + ); + const eventTargetRef = useRef(null); + const isDrawingRef = useRef(false); + const startPointRef = useRef(null); + const pathRef = useRef>([]); + const inProgressOverlayRef = useRef(null); + const firstVertexMarkerRef = useRef< + google.maps.Marker | google.maps.marker.AdvancedMarkerElement | null + >(null); + const inProgressListenersRef = useRef>( + [] + ); useEffect(() => { - if (!map || !drawing) return; - - // https://developers.google.com/maps/documentation/javascript/reference/drawing - const newDrawingManager = new drawing.DrawingManager({ - map, - drawingMode: google.maps.drawing.OverlayType.CIRCLE, - drawingControl: true, - drawingControlOptions: { - position: google.maps.ControlPosition.TOP_CENTER, - drawingModes: [ - google.maps.drawing.OverlayType.MARKER, - google.maps.drawing.OverlayType.CIRCLE, - google.maps.drawing.OverlayType.POLYGON, - google.maps.drawing.OverlayType.POLYLINE, - google.maps.drawing.OverlayType.RECTANGLE - ] - }, - markerOptions: { - draggable: true - }, - circleOptions: { - editable: true - }, - polygonOptions: { - editable: true, - draggable: true - }, - rectangleOptions: { - editable: true, - draggable: true - }, - polylineOptions: { - editable: true, - draggable: true - } + activeModeRef.current = activeMode; + }, [activeMode]); + + useEffect(() => { + if (!eventTargetRef.current && map) { + const target = new google.maps.MVCObject(); + eventTargetRef.current = target; + setEventTarget(target); + } + }, [map]); + + const clearFirstVertexMarker = useCallback(() => { + const marker = firstVertexMarkerRef.current; + + if (!marker) return; + + google.maps.event.clearInstanceListeners(marker); + setOverlayMap(marker, null); + firstVertexMarkerRef.current = null; + }, []); + + const clearInProgressListeners = useCallback(() => { + if (!inProgressListenersRef.current.length) return; + + inProgressListenersRef.current.forEach(listener => { + google.maps.event.removeListener(listener); }); - setDrawingManager(newDrawingManager); + inProgressListenersRef.current = []; + }, []); + + const setDrawingCursor = useCallback( + (cursor: string | null) => { + if (!map) return; + + map.setOptions({ + draggableCursor: cursor + }); + }, + [map] + ); + + const resetDrawingState = useCallback( + (removeOverlay: boolean) => { + const overlay = inProgressOverlayRef.current; + + if (overlay && removeOverlay) { + setOverlayMap(overlay, null); + } + + clearInProgressListeners(); + clearFirstVertexMarker(); + inProgressOverlayRef.current = null; + isDrawingRef.current = false; + startPointRef.current = null; + pathRef.current = []; + setDrawingCursor(null); + }, + [clearFirstVertexMarker, clearInProgressListeners, setDrawingCursor] + ); + + const finalizeOverlay = useCallback( + (type: OverlayType, overlay: OverlayGeometry) => { + if ('getPath' in overlay && pathRef.current.length) { + overlay.setPath(pathRef.current); + } + + if (type === 'circle') { + (overlay as google.maps.Circle).setOptions({ + editable: false, + clickable: true + }); + } + + if (type === 'rectangle') { + (overlay as google.maps.Rectangle).setOptions({ + editable: false, + draggable: false, + clickable: true + }); + } + + if (type === 'polygon' || type === 'polyline') { + const editableOverlay = overlay as + | google.maps.Polygon + | google.maps.Polyline; + + editableOverlay.setOptions({ + editable: false, + draggable: false, + clickable: true + }); + } + + clearInProgressListeners(); + clearFirstVertexMarker(); + inProgressOverlayRef.current = null; + isDrawingRef.current = false; + startPointRef.current = null; + pathRef.current = []; + setDrawingCursor(null); + + if (eventTargetRef.current) { + const payload: DrawResult = {type, overlay}; + google.maps.event.trigger( + eventTargetRef.current, + 'overlaycomplete', + payload + ); + } + }, + [clearFirstVertexMarker, setDrawingCursor] + ); + + const cancelDrawing = useCallback( + (resetMode: boolean) => { + resetDrawingState(true); + + if (resetMode) { + setActiveModeState(null); + } + }, + [resetDrawingState] + ); + + const setActiveMode = useCallback( + (mode: DrawingMode) => { + setActiveModeState(previous => { + if (previous === mode) { + cancelDrawing(true); + return null; + } + + resetDrawingState(true); + return mode; + }); + }, + [cancelDrawing, resetDrawingState] + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (!activeModeRef.current && !isDrawingRef.current) return; + + cancelDrawing(true); + return; + } + + // Enter finishes the active in-progress shape. + if (event.key !== 'Enter') return; + + if (!isDrawingRef.current) return; + + const overlay = inProgressOverlayRef.current; + const mode = activeModeRef.current; + + if (!overlay || !mode) return; + + if (mode === 'polygon' || mode === 'polyline') { + finalizeOverlay( + mode, + overlay as google.maps.Polygon | google.maps.Polyline + ); + return; + } + + if (mode === 'circle') { + finalizeOverlay('circle', overlay as google.maps.Circle); + return; + } + + if (mode === 'rectangle') { + finalizeOverlay('rectangle', overlay as google.maps.Rectangle); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [cancelDrawing]); + + useEffect(() => { + if (!map) return; + + const computeRadius = ( + start: google.maps.LatLng, + end: google.maps.LatLng + ) => { + if (geometry?.spherical?.computeDistanceBetween) { + return geometry.spherical.computeDistanceBetween(start, end); + } + + if (google.maps.geometry?.spherical?.computeDistanceBetween) { + return google.maps.geometry.spherical.computeDistanceBetween( + start, + end + ); + } + + return 0; + }; + + // Visible first-vertex marker enables polygon close by clicking the start. + const createFirstVertexMarker = (position: google.maps.LatLng) => { + const AdvancedMarker = + markerLibrary?.AdvancedMarkerElement ?? + google.maps.marker?.AdvancedMarkerElement; + + if (!AdvancedMarker) return; + + const markerElement = document.createElement('div'); + markerElement.style.width = '14px'; + markerElement.style.height = '14px'; + markerElement.style.borderRadius = '50%'; + markerElement.style.background = 'rgba(25, 82, 171, 0.9)'; + markerElement.style.border = '2px solid white'; + markerElement.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.35)'; + + const marker = new AdvancedMarker({ + map, + position, + gmpClickable: true, + zIndex: 1000, + content: markerElement, + anchorTop: '-50%', + anchorLeft: '-50%' + }); + + const listener = marker.addListener('click', () => { + if (!isDrawingRef.current) return; + if (activeModeRef.current !== 'polygon') return; + + const overlay = inProgressOverlayRef.current; + + if (overlay && 'getPath' in overlay) { + finalizeOverlay('polygon', overlay as google.maps.Polygon); + } + }); + + firstVertexMarkerRef.current = marker; + + return () => { + google.maps.event.removeListener(listener); + setOverlayMap(marker, null); + }; + }; + + const handleClick = (event: google.maps.MapMouseEvent) => { + if (!event.latLng) return; + + const mode = activeModeRef.current; + + if (!mode) return; + + if (mode === 'marker') { + const AdvancedMarker = + markerLibrary?.AdvancedMarkerElement ?? + google.maps.marker?.AdvancedMarkerElement; + + if (!AdvancedMarker) return; + + const marker = new AdvancedMarker({ + map, + position: event.latLng, + gmpDraggable: true + }); + + finalizeOverlay('marker', marker); + return; + } + + if (mode === 'circle') { + if (!isDrawingRef.current) { + const circle = new google.maps.Circle({ + map, + center: event.latLng, + radius: 0, + editable: false, + clickable: true + }); + + inProgressOverlayRef.current = circle; + startPointRef.current = event.latLng; + isDrawingRef.current = true; + setDrawingCursor('crosshair'); + + inProgressListenersRef.current = [ + google.maps.event.addListener(circle, 'mousemove', handleMouseMove), + google.maps.event.addListener(circle, 'click', handleClick) + ]; + return; + } + + const circle = + inProgressOverlayRef.current as google.maps.Circle | null; + + if (!circle || !startPointRef.current) return; + + const radius = computeRadius(startPointRef.current, event.latLng); + circle.setRadius(radius); + circle.setCenter(startPointRef.current); + finalizeOverlay('circle', circle); + return; + } + + if (mode === 'rectangle') { + if (!isDrawingRef.current) { + const bounds = new google.maps.LatLngBounds( + event.latLng, + event.latLng + ); + const rectangle = new google.maps.Rectangle({ + map, + bounds, + editable: false, + draggable: false, + clickable: true + }); + + inProgressOverlayRef.current = rectangle; + startPointRef.current = event.latLng; + isDrawingRef.current = true; + setDrawingCursor('crosshair'); + + inProgressListenersRef.current = [ + google.maps.event.addListener( + rectangle, + 'mousemove', + handleMouseMove + ), + google.maps.event.addListener(rectangle, 'click', handleClick) + ]; + return; + } + + const rectangle = + inProgressOverlayRef.current as google.maps.Rectangle | null; + + if (!rectangle || !startPointRef.current) return; + + const bounds = new google.maps.LatLngBounds( + startPointRef.current, + event.latLng + ); + rectangle.setBounds(bounds); + finalizeOverlay('rectangle', rectangle); + return; + } + + if (mode === 'polygon' || mode === 'polyline') { + if (!isDrawingRef.current) { + pathRef.current = [event.latLng]; + + const overlay = + mode === 'polygon' + ? new google.maps.Polygon({ + map, + paths: pathRef.current, + editable: false, + draggable: false, + clickable: false + }) + : new google.maps.Polyline({ + map, + path: pathRef.current, + editable: false, + draggable: false, + clickable: false + }); + + inProgressOverlayRef.current = overlay; + isDrawingRef.current = true; + setDrawingCursor('crosshair'); + + if (mode === 'polygon') { + createFirstVertexMarker(event.latLng); + } + + return; + } + + pathRef.current = [...pathRef.current, event.latLng]; + + const overlay = inProgressOverlayRef.current as + | google.maps.Polygon + | google.maps.Polyline + | null; + + if (!overlay) return; + + overlay.setPath(pathRef.current); + } + }; + + const handleMouseMove = (event: google.maps.MapMouseEvent) => { + if (!event.latLng) return; + + const mode = activeModeRef.current; + + if (!mode || !isDrawingRef.current) return; + + if (mode === 'circle') { + const circle = + inProgressOverlayRef.current as google.maps.Circle | null; + + if (!circle || !startPointRef.current) return; + + const radius = computeRadius(startPointRef.current, event.latLng); + circle.setRadius(radius); + circle.setCenter(startPointRef.current); + } + + if (mode === 'rectangle') { + const rectangle = + inProgressOverlayRef.current as google.maps.Rectangle | null; + + if (!rectangle || !startPointRef.current) return; + + const bounds = new google.maps.LatLngBounds( + startPointRef.current, + event.latLng + ); + rectangle.setBounds(bounds); + } + + if (mode === 'polygon' || mode === 'polyline') { + const overlay = inProgressOverlayRef.current as + | google.maps.Polygon + | google.maps.Polyline + | null; + + if (!overlay) return; + + const previewPath = [...pathRef.current, event.latLng]; + overlay.setPath(previewPath); + } + }; + + const handleDoubleClick = (event: google.maps.MapMouseEvent) => { + if (!event.latLng) return; + + event.domEvent?.preventDefault?.(); + event.domEvent?.stopPropagation?.(); + + const mode = activeModeRef.current; + + if (!mode || !isDrawingRef.current) return; + + if (mode !== 'polygon' && mode !== 'polyline') return; + + const overlay = inProgressOverlayRef.current as + | google.maps.Polygon + | google.maps.Polyline + | null; + + if (!overlay) return; + + const path = pathRef.current; + const lastPoint = path[path.length - 1]; + + if (lastPoint && event.latLng.equals(lastPoint)) { + pathRef.current = path.slice(0, -1); + overlay.setPath(pathRef.current); + } + + finalizeOverlay(mode, overlay); + }; + + const clickListener = map.addListener('click', handleClick); + const moveListener = map.addListener('mousemove', handleMouseMove); + const dblClickListener = map.addListener('dblclick', handleDoubleClick); return () => { - newDrawingManager.setMap(null); + google.maps.event.removeListener(clickListener); + google.maps.event.removeListener(moveListener); + google.maps.event.removeListener(dblClickListener); }; - }, [drawing, map]); + }, [finalizeOverlay, geometry, map, setDrawingCursor]); - return drawingManager; + return useMemo( + () => ({ + eventTarget, + activeMode, + setActiveMode + }), + [activeMode, eventTarget, setActiveMode] + ); } diff --git a/examples/examples.css b/examples/examples.css index f994e383..c247dee0 100644 --- a/examples/examples.css +++ b/examples/examples.css @@ -120,6 +120,11 @@ html[data-theme='dark'] .gm-style { background: rgb(235, 235, 235); } +.drawing-history button.is-active { + background: rgb(255, 231, 231); + color: rgb(171, 25, 25); +} + .drawing-history button:disabled:hover, .drawing-history button:disabled { background: rgb(255, 255, 255); @@ -127,6 +132,42 @@ html[data-theme='dark'] .gm-style { cursor: default; } +.drawing-controls { + display: flex; + align-items: center; + gap: 6px; +} + +.drawing-toolbar { + display: flex; + align-items: center; + gap: 2px; + height: 27px; + box-sizing: border-box; +} + +.drawing-toolbar button { + height: 100%; + background: rgb(255, 255, 255); + border: 0px; + margin: 0px; + cursor: pointer; + color: rgb(86, 86, 86); + padding: 0 8px; + border-radius: 2px; + box-shadow: rgba(0, 0, 0, 0.3) 0px 1px 4px -1px; + font-size: 12px; +} + +.drawing-toolbar button:hover { + background: rgb(235, 235, 235); +} + +.drawing-toolbar button.is-active { + background: rgb(227, 239, 255); + color: rgb(25, 82, 171); +} + .static-map-grid, .static-map-grid * { box-sizing: border-box;