UNPKG

@adamscybot/react-leaflet-component-marker

Version:

A tiny wrapper for react-leaflet's <Marker /> component that allows you to use a React component as a marker, with working state, handlers, and access to parent contexts.

142 lines (139 loc) 6.53 kB
import React, { useState, useId, useMemo, useCallback, isValidElement, useLayoutEffect, forwardRef, useEffect, } from 'react'; import { isValidElementType } from 'react-is'; import { createPortal } from 'react-dom'; import { Marker as ReactLeafletMarker, } from 'react-leaflet'; import { divIcon, DomEvent, } from 'leaflet'; import { createHtmlPortalNode, OutPortal, InPortal } from 'react-reverse-portal'; import { useCoordsFromPointExpression } from './lib/useCoordsFromPointExpression.js'; import { logCodedString } from './lib/logging.js'; const DEFAULT_ICON_SIZE = [0, 0]; const ComponentMarker = forwardRef(({ eventHandlers: providedEventHandlers, icon: providedIcon, componentIconOpts: { layoutMode = 'fit-content', disableClickPropagation, disableScrollPropagation, rootDivOpts, rootSizeWarning, } = {}, ...otherProps }, ref) => { const [markerRendered, setMarkerRendered] = useState(false); const [, setChangeCount] = useState(0); const id = 'marker-' + useId(); const portalNode = React.useMemo(() => createHtmlPortalNode({ attributes: { 'data-react-component-marker': 'portal-parent', style: 'width:100%;height:100%;', }, }), []); useEffect(() => { if (rootSizeWarning !== false && layoutMode === 'fit-parent' && rootDivOpts?.iconSize === undefined) { console.warn(logCodedString('UNBOUND_FIT_PARENT', `The 'componentIconOpts.rootDivOpts.iconSize' option was not set but 'componentIconOpts.layoutMode' was set to 'fit-parent'. This means your React component will not be properly bound by the parent. To disable this warning set 'componentIconOpts.rootSizeWarning' to false.`)); } }, [layoutMode, rootSizeWarning, rootDivOpts?.iconSize]); const { attribution, className, iconAnchor, iconSize = DEFAULT_ICON_SIZE, pane, popupAnchor, tooltipAnchor, } = rootDivOpts ?? {}; const iconDeps = [ id, layoutMode, attribution, className, pane, Boolean(disableClickPropagation), Boolean(disableScrollPropagation), ...useCoordsFromPointExpression(iconSize), ...useCoordsFromPointExpression(iconAnchor), ...useCoordsFromPointExpression(popupAnchor), ...useCoordsFromPointExpression(tooltipAnchor), ]; const icon = useMemo(() => { const parentStyles = layoutMode === 'fit-content' ? 'width: min-content; transform: translate(-50%, -50%)' : 'width: 100%; height: 100%'; return divIcon({ html: `<div data-react-component-marker="root" style="${parentStyles}" id="${id}"></div>`, ...(iconSize ? { iconSize } : []), ...(iconAnchor ? { iconAnchor } : []), ...(popupAnchor ? { popupAnchor } : []), ...(tooltipAnchor ? { tooltipAnchor } : []), pane, attribution, className, }); }, iconDeps); useLayoutEffect(() => { setChangeCount((prev) => prev + 1); }, [icon]); const handleAddEvent = useCallback((...args) => { setMarkerRendered(true); if (providedEventHandlers?.add) providedEventHandlers.add(...args); }, [providedEventHandlers?.add]); const handleRemoveEvent = useCallback((...args) => { setMarkerRendered(false); if (providedEventHandlers?.remove) providedEventHandlers.remove(...args); }, [providedEventHandlers?.remove]); const eventHandlers = useMemo(() => ({ ...providedEventHandlers, add: handleAddEvent, remove: handleRemoveEvent, }), [providedEventHandlers, handleAddEvent, handleRemoveEvent]); let portalTarget = null; if (markerRendered) { portalTarget = document.getElementById(id); } useEffect(() => { if (!portalTarget) return; if (disableClickPropagation) { DomEvent.disableClickPropagation(portalTarget); } if (disableScrollPropagation) { DomEvent.disableScrollPropagation(portalTarget); } }, [portalTarget, disableClickPropagation, disableScrollPropagation]); return (React.createElement(React.Fragment, null, React.createElement(ReactLeafletMarker, { ref: ref, ...otherProps, eventHandlers: eventHandlers, icon: icon }), markerRendered && portalTarget !== null && (React.createElement(React.Fragment, null, React.createElement(InPortal, { node: portalNode }, providedIcon), createPortal(React.createElement(OutPortal, { node: portalNode }), portalTarget))))); }); /** * A modified version of the [react-leaflet Marker](https://react-leaflet.js.org/docs/api-components/#marker) component that is extended such that it allows a {@link ReactElement} to be used as the icon. * * @example * Basic usage: * ``` * // Define marker component * const MarkerIconExample = () => { * return ( * <> * <button onClick={() => console.log("button 1 clicked")}>Button 1</button> * <button onClick={() => console.log("button 2 clicked")}>Button 2</button> * </> * ) * } * * // Use marker component * <Marker position={[51.505, -0.091]} icon={<MarkerIconExample />} /> * ``` **/ export const Marker = forwardRef(({ icon: Icon, componentIconOpts, ...otherProps }, ref) => { const validElement = isValidElement(Icon); const validComponent = isValidElementType(Icon); useEffect(() => { if (!validElement && !validComponent && componentIconOpts !== undefined && componentIconOpts.unusedOptsWarning !== false) { console.warn(logCodedString('UNUSED_OPTIONS', `The 'componentIconOpts' prop was set but the 'icon' prop was not set to a React component or element. These options will be unused. To disable this warning set 'componentIconOpts.unusedOptsWarning' to false.`)); } }, [ componentIconOpts, componentIconOpts?.unusedOptsWarning, validElement, validComponent, ]); if (validElement) { return (React.createElement(ComponentMarker, { ref: ref, icon: Icon, componentIconOpts: componentIconOpts, ...otherProps })); } if (validComponent) { return (React.createElement(ComponentMarker, { ref: ref, icon: React.createElement(Icon, null), componentIconOpts: componentIconOpts, ...otherProps })); } return (React.createElement(ReactLeafletMarker, { ref: ref, icon: Icon, ...otherProps })); });