Popper.js: how to set position fixed strategy with modifiers? - reactjs

I'm trying to implement a context menu using BlueprintJs Popover component; that uses Popper.js to position the popover, under the hood.
The problemis that: I have fixed elements and absolutely positioned elements (with transform css property set to translate3d - I believe these create new stacking contexts, possibly causing issues too) in the dom tree, above the context menu, that can not be changed. I've read somewhere in the Popper.js documentation, that I should use the fixed position strategy in this case.
Unfortunately BlueprintJs Popover does not allow me (as far as I know) to set Popper.js options, only modifiers.
So can the positioning strategy be changed with modifiers?
Here's the code and what I've tried:
import React, { useState } from 'react';
import { Popover, Position, Classes } from '#blueprintjs/core';
const getModifiers = (left, top) => {
return {
preventOverflow: { boundariesElement: 'viewport' },
computeStyle: {
// set to false to avoid using transform property to position element,
// as that clashes with other transform: translate3d styles set earlier
gpuAcceleration: false,
// I could just overwrite the computeStyles fn, and use position fixed;
// but I'd like to avoid that and let Popper.js do the coordinate arithmetics
// fn: (data) => {
// return {
// ...data,
// styles: {
// ...data.styles,
// position: 'fixed',
// left: `${left}px`,
// top: `${top}px`,
// }
// };
// },
},
// here's where I try to change the position strategy using custom modifier
changeStrategyWithModifier: {
order: 0,
enabled: true,
name: 'changeStrategyWithModifier',
phase: 'main',
fn: (data) => {
return {
...data,
instance: {
...data.instance,
options: {
...data.instance.options,
positionFixed: true, // does not seem ot have any effect
strategy: 'fixed', // does not seem ot have any effect
},
},
state: {
// reset set to true to restart process after changing strategy
...data.instance.state,
reset: true,
},
positionFixed: true, // does not seem ot have any effect
};
},
},
};
};
const ContextMenu = (props) => {
const [isOpen, setOpen] = useState(false);
const [offset, setOffset] = useState();
const portalContainer = useGetPortalContainer();
const handleCloseContextMenu = () => setOpen(false);
const handleInteraction = () => setOpen(false);
const handleOpenContextMenu = (mouseEvent) => {
mouseEvent.preventDefault();
setOffset({ left: mouseEvent.clientX, top: mouseEvent.clientY });
setOpen(true);
};
const modifiers = getModifiers(offset.left, offset.top);
return (
<>
<div className={Classes.CONTEXT_MENU_POPOVER_TARGET} style={offset}>
<Popover
isOpen={isOpen}
onInteraction={handleInteraction}
content={props.renderMenu(handleCloseContextMenu)}
target={<div />}
usePortal={true}
portalContainer={portalContainer}
position={Position.TOP_LEFT}
modifiers={modifiers}
/>
</div>
{props.renderComponent(handleOpenContextMenu)}
</>
);
};

Related

How to make dynamic changes in Arcgis JS map-component with React?

I am quite new to Arcgis-JS and React. As suggested here, I am using a functional component with the useEffect hook to integrate my map.
Now I want to display a line within my map when I click on a certain row of a list. On click I am fetching the appropriate coordinates to be displayed and storing them to a context-variable (dbPageContext.currentGeom).
The problem: When I want to display another line, the entire map-component has to re-render as I am passing the line-array-variable as a second argument to the useEffect hook.
const MapComp = () => {
const mapRef = useRef();
const dbPageContext = useContext(DbPageContext);
useEffect(() => {
const mainMap = new Map({
layers: [layer],
basemap: "arcgis-topographic", // Basemap layer service
});
const graphicsLayer = new GraphicsLayer();
mainMap.add(graphicsLayer);
const polyline = {
type: "polyline",
paths: dbPageContext.currentGeom,
};
const simpleLineSymbol = {
type: "simple-line",
color: [0, 230, 250],
width: 4,
};
const polylineGraphic = new Graphic({
geometry: polyline,
symbol: simpleLineSymbol,
});
graphicsLayer.add(polylineGraphic);
const view = new MapView({
container: mapRef.current,
map: mainMap,
spatialReference: {
wkid: 3857,
},
});
return () => {
view && view.destroy();
};
}, [dbPageContext.currentGeom]);
return (
<div>
<div className="webmap" ref={mapRef} />
</div>
);
};
export default MapComp;
How can I update only the graphics-layer without updating the entire map-component? Would be great if someone could help me finding a solution for that.
EDIT: I also tried to implement the map without using the useeffect hook. But then, nothing was displayed.
You need to separate the effects. On page load, you should have one effect that creates the map. Then a second effect can update the map when dbPageContext.currentGeom changes.
const MapComp = () => {
const mapRef = useRef();
const dbPageContext = useContext(DbPageContext);
// Memoize this, as you only need to create it once, but you also need
// it to be available within scope of both of the following useEffects
const graphicsLayer = React.useMemo(
() => new GraphicsLayer(),
[]
);
// On mount, create the map, view, and teardown
useEffect(() => {
const mainMap = new Map({
layers: [layer],
basemap: "arcgis-topographic", // Basemap layer service
});
const view = new MapView({
container: mapRef.current,
map: mainMap,
spatialReference: {
wkid: 3857,
},
});
mainMap.add(graphicsLayer);
return () => {
view && view.destroy();
};
}, [])
// When dbPageContext.currentGeom changes, add a polyline
// to the graphics layer
useEffect(() => {
const polyline = {
type: "polyline",
paths: dbPageContext.currentGeom,
};
const simpleLineSymbol = {
type: "simple-line",
color: [0, 230, 250],
width: 4,
};
const polylineGraphic = new Graphic({
geometry: polyline,
symbol: simpleLineSymbol,
});
// Clear previously added lines (if that's what you want)
graphicsLayer.removeAll()
graphicsLayer.add(polylineGraphic);
}, [dbPageContext.currentGeom]);
return (
<div>
<div className="webmap" ref={mapRef} />
</div>
);
};
export default MapComp;

window.visualViewport.height doenst update useEffect when a dependency

This ugly code works. Every second viewportHeight is set to the value of window.visualViewport.height
const [viewportHeight, setViewportHeight] = React.useState(0);
React.useEffect(() => {
setInterval(() => {
setViewportHeight(window.visualViewport.height);
}, 1000);
}, []);
However this doesn't work. viewportHeight is set on page load but not when the height changes.
React.useEffect(() => {
setViewportHeight(window.visualViewport.height);
}, [window.visualViewport.height]);
Additional context: I need the page's height in state and I need the virtual keyboard's height to be subtracted from this on Mobile iOS.
You can only use state variables managed by React as dependencies - so a change in window.visualViewport.height will not trigger your effect.
You can instead create a div that spans the whole screen space and use a resize observer to trigger effects when its size changes:
import React from "react";
import useResizeObserver from "use-resize-observer";
const App = () => {
const { ref, width = 0, height = 0 } = useResizeObserver();
const [viewportHeight, setViewportHeight] = React.useState(height);
React.useEffect(() => {
setViewportHeight(window.visualViewport.height);
}, [height]);
return (
<div ref={ref} style={{ width: "100vw", height: "100vh" }}>
// ...
</div>
);
};
This custom hook works:
function useVisualViewportHeight() {
const [viewportHeight, setViewportHeight] = useState(undefined);
useEffect(() => {
function handleResize() {
setViewportHeight(window.visualViewport.height);
}
window.visualViewport.addEventListener('resize', handleResize);
handleResize();
return () => window.visualViewport.removeEventListener('resize', handleResize);
}, []);
return viewportHeight;
}

How to preserve aspect ratio upon resizing browser containing a react-lottie component?

I have a Lottie animation that is rendered using react-lottie (https://www.npmjs.com/package/react-lottie)
import Lottie from 'react-lottie'
import animationData from './path/to/animation.json'
const MyComponent = () => {
const myOptions = {
loop: true,
autoplay: true,
renderer: 'canvas',
animationData: animationData
}
return (
<div>
...other stuff
<Lottie options={myOptions} width={1000} height={500} />
</div>
)
}
The animation displays and plays fine.
The issue is that when resizing the browser, the animation shrinks and squishes and does not preserve its original aspect ratio. If I do a page refresh after shrinking the browser, the animation is then rendered correctly. It is only when the resizing is actively occurring that the aspect ratio is off.
I have tried adding:
rendererSettings: {
preserveAspectRatio: 'xMidYMid slice' // also tried 'xMidYMid meet'
}
to my options but that does not work either.
The only other thing I can think of is to attach a resize event listener to the window which will in turn call lottie.resize()
useEffect(() => {
window.addEventListener('resize', ??)
}, [])
The stuff in ?? is where I get stuck. I know that using lottie-web, I have direct access to a lottie.resize() function. However, when using react-lottie, how do I call/access the resize function?
Any help would be greatly appreciated.
Edit: I have been asked to add an example animation JSON so it is here: https://pastebin.com/gXCEhKaR
Yes, thank you, I started it at home, but you have the width and height values set for the Lottie component. If you want Lottie to change its size depending on the size of the screen, then you need to add a hook to change the screen.
Hook resize
export function useSizeComponents (ref) {
const [size, setSize] = useState([0, 0])
useLayoutEffect(() => {
function updateSize () {
let newSize = [window.innerWidth, window.innerHeight]
if (ref?.current) {
newSize = [ref.current.offsetWidth, ref.current.offsetHeight]
}
setSize(newSize)
}
window.addEventListener('resize', updateSize)
updateSize()
return () => window.removeEventListener('resize', updateSize)
}, [])
return size
}
Your component
const MyComponent = () => {
const [width, height] = useSizeComponents()
const scaleLottie = 0.5
const myOptions = {
loop: true,
autoplay: true,
renderer: 'canvas',
animationData: animationData,
}
const control = useMemo(() => {
if (!width) return null
const xMidYMid = 0.5
const sizeComponent = {
width: width * scaleLottie,
height: width * scaleLottie * xMidYMid
}
return <Lottie key={width} options={myOptions} {...sizeComponent} />
}, [width])
return (
<div>
...other stuff
{control}
</div>
)
}

Click handler on mapboxGL not respondig to updated state using react hooks with typescript

I am working on this simple hiking application, with display a map in a modal where i want to set a start point and end point in the modal components state, and then update the parents state.
The problem is that the click handler in the modal does not "see" the updated state.
If i console log the state outside of the click handler, it gives me the updated state.
Im using mapboxGL js, and i wonder if someone knows why this is happening? I am thinking maybe it as something to do with the 'click' event, since it not react onClick event?
Here is the code for the modal component:
export const MapModalContent = ({
newHike, setNewHike, setShowMapModal,
}: MapModalProps) => {
const [map, setMap] = useState<mapboxgl.Map>();
const [startCoords, setStartCoords] = useState<LngLat>();
const [endCoords, setEndCoords] = useState<LngLat>();
const mapRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
const [helperString, setHelperString] = useState<IHelperString>(helperStrings[0]);
const [startMarker] = useState(new mapboxgl.Marker());
const [endMarker] = useState(new mapboxgl.Marker());
const [startPointIsSet, setStartPointIsSet] = useState<boolean>(false);
const [endPointIsSet, setEndPointIsSet] = useState<boolean>(false);
// initializes map
useEffect(() => {
if (mapRef.current && !map) {
setMap(new mapboxgl.Map({
accessToken: MAPBOX_ACCESS_TOKEN,
container: mapRef.current,
style: 'mapbox://styles/mapbox/outdoors-v11',
center: [10.748503539483494, 59.92003719905571],
zoom: 10,
}));
}
}, [mapRef]);
// adds controls and click listener to map
useEffect(() => {
if (map) {
addControls({ to: map });
map.on('click', (e) => {
handleSetMarker(e);
});
}
}, [map]);
// these effects console the updated state as wanted
useEffect(() => {
console.log('Start coord: ', startCoords);
}, [startCoords]);
useEffect(() => {
console.log('End coord: ', endCoords);
}, [endCoords]);
useEffect(() => {
console.log('Start is set: ', startPointIsSet);
}, [startPointIsSet]);
useEffect(() => {
console.log('End is set: ', endPointIsSet);
}, [endPointIsSet]);
// Todo: Remove this..
setTimeout(() => {
if (map) map.resize();
}, 500);
// this is the click handler that does not respond to state changes
const handleSetMarker = (e: mapboxgl.MapMouseEvent & mapboxgl.EventData) => {
console.log('👆', `start point: ${startPointIsSet}`, `end point: ${endPointIsSet}`);
if (!startPointIsSet) {
console.log('Start point not set.. Gonna set it now!');
// setStartCoords(e.lngLat);
startMarker.setLngLat(e.lngLat).addTo(map!);
setStartCoords(e.lngLat);
setStartPointIsSet(true);
}
if (startPointIsSet && !endPointIsSet) {
console.log('Start point is set! Setting end point..');
endMarker.setLngLat(e.lngLat).addTo(map!);
setEndCoords(e.lngLat);
setEndPointIsSet(true);
}
};
const handleButtonTap = () => {
if (startCoords) {
// displays a new message to the user after setting start point
setHelperString(helperStrings[1]);
} else {
console.warn('No start coords set!');
}
// sets parents state
if (startCoords && endCoords) {
setNewHike({
...newHike,
start_point: getPointString({ from: startCoords }),
end_point: getPointString({ from: endCoords }),
});
setShowMapModal(false);
} else {
console.warn('Some coords is null!', startCoords, endCoords);
}
};
return (
<>
<MapContainer ref={mapRef} />
<p style={{ margin: '1em auto 1em auto' }}>{ helperString.sentence }</p>
<IonButton onClick={handleButtonTap}>{ helperString.button }</IonButton>
</>
);
};
I've tried lifting the state up to the parent, but it gave me the exact same result.
I've also tried adding two separate click events to the map with no luck.
And I gave it a try with the 'react-mapbox-gl' wrapper, but the same problem arose.
It looks like because your handler is attached as a callback, it closes over the React state.
map.on('click', (e) => {
handleSetMarker(e);
});
Try useCallback with the proper dependencies.
const handleSetMarker = useCallback((e: mapboxgl.MapMouseEvent & mapboxgl.EventData) => {
console.log('👆', `start point: ${startPointIsSet}`, `end point: ${endPointIsSet}`);
if (!startPointIsSet) {
console.log('Start point not set.. Gonna set it now!');
// setStartCoords(e.lngLat);
startMarker.setLngLat(e.lngLat).addTo(map!);
setStartCoords(e.lngLat);
setStartPointIsSet(true);
}
if (startPointIsSet && !endPointIsSet) {
console.log('Start point is set! Setting end point..');
endMarker.setLngLat(e.lngLat).addTo(map!);
setEndCoords(e.lngLat);
setEndPointIsSet(true);
}
}, [startPointIsSet, endPointIsSet, endMarker, startMarker, map]);

How to refactor React mousemove Class to functional Component?

I have a button which closes a navigation. This button follows the mouse. Everything is working, but I have a depricationwarning, which I wanna get rid of, but don't know exactly how. (I only know that useEffect is playing a certain role:
Here is the class:
import React from "react"
class NavigationCloseMouseButton extends React.Component {
static defaultProps = {
visible: true,
offsetX: 0,
offsetY: 0,
}
state = {
xPosition: 0,
yPosition: 0,
mouseMoved: false,
listenerActive: false,
}
componentDidMount() {
this.addListener()
}
componentDidUpdate() {
this.updateListener()
}
componentWillUnmount() {
this.removeListener()
}
getTooltipPosition = ({ clientX: xPosition, clientY: yPosition }) => {
this.setState({
xPosition,
yPosition,
mouseMoved: true,
})
}
addListener = () => {
window.addEventListener("mousemove", this.getTooltipPosition)
this.setState({ listenerActive: true })
}
removeListener = () => {
window.removeEventListener("mousemove", this.getTooltipPosition)
this.setState({ listenerActive: false })
}
updateListener = () => {
if (!this.state.listenerActive && this.props.visible) {
this.addListener()
}
if (this.state.listenerActive && !this.props.visible) {
this.removeListener()
}
}
render() {
return (
<div
onClick={this.props.toggleNavigation}
className="tooltip color-bg"
style={{
display:
this.props.visible && this.state.mouseMoved ? "block" : "none",
opacity: this.props.visible && this.state.mouseMoved ? "1" : "0",
top: this.state.yPosition + this.props.offsetY,
left: this.state.xPosition + this.props.offsetX,
}}
>
Close Menu
</div>
)
}
}
export default NavigationCloseMouseButton
And this is what I've so far, but results with errors:
ReferenceError: getTooltipPosition is not defined
import React, { useState, useEffect } from "react"
const NavigationCloseMouseButton = () => {
const defaults = {
visible: true,
offsetX: 0,
offsetY: 0,
}
const defaultState = {
xPosition: 0,
yPosition: 0,
mouseMoved: false,
listenerActive: false,
}
const [defaultProps, setDefaultProps] = useState(defaults)
const [state, setState] = useState(defaultState)
useEffect(() => {
// Update the document title using the browser API
addListener()
}, [])
getTooltipPosition = ({ clientX: xPosition, clientY: yPosition }) => {
setState({
xPosition,
yPosition,
mouseMoved: true,
})
}
addListener = () => {
window.addEventListener("mousemove", getTooltipPosition)
setState({ listenerActive: true })
}
removeListener = () => {
window.removeEventListener("mousemove", getTooltipPosition)
setState({ listenerActive: false })
}
updateListener = () => {
if (!state.listenerActive && props.visible) {
addListener()
}
if (state.listenerActive && !props.visible) {
removeListener()
}
}
return (
<div
onClick={props.toggleNavigation}
className="tooltip color-bg"
style={{
display: props.visible && state.mouseMoved ? "block" : "none",
opacity: props.visible && state.mouseMoved ? "1" : "0",
top: state.yPosition + props.offsetY,
left: state.xPosition + props.offsetX,
}}
>
Close Menu
</div>
)
}
export default NavigationCloseMouseButton
Setting Defaults
You can destructure individual props from the props object (the argument of the function component). While destructuring, you can use the = operator to set a default value for when this prop is not set.
const NavigationCloseMouseButton = ({ visible = true, offsetX = 0, offsetY = 0, toggleNavigation }) => {
Updating a Listener
I'm sure there a lots of great answers about this so I won't go into too much detail.
You want to handle adding and removing the listener from inside your useEffect. You should use a useEffect cleanup function for the final remove. We don't want to be adding and removing the same listener so we can memoize it with useCallback.
I'm not sure what you are trying to do with listenerActive. This could be a prop, but it also seems a bit redundant with visible. I don't know that we need this at all.
Calculating Offset
I also don't know that it makes sense to pass offsetX and offsetY as props. We need the mouse to be on top of the tooltip in order for it to be clickable. We can measure the tooltip div inside this component and deal with it that way.
// ref to DOM node for measuring
const divRef = useRef<HTMLDivElement>(null);
// can caluculate offset instead of passing in props
const offsetX = -.5 * (divRef.current?.offsetWidth || 0);
const offsetY = -.5 * (divRef.current?.offsetHeight || 0);
Animation
Setting the style property display as "block" or "none" makes it hard to do any sort of CSS transition. Instead, I recommend that you handle style switching by changing the className. You could still set display: block and display: none on those classes, but I am choosing to use transform: scale(0); instead.
Code
const NavigationCloseMouseButton = ({
visible = true,
toggleNavigation
}) => {
// state of the movement
const [state, setState] = useState({
xPosition: 0,
yPosition: 0,
mouseMoved: false
});
// memoized event listener
const getTooltipPosition = useCallback(
// plain event, not a React synthetic event
({ clientX: xPosition, clientY: yPosition }) => {
setState({
xPosition,
yPosition,
mouseMoved: true
});
},
[]
); // never re-creates
useEffect(() => {
// don't need to listen when it's not visible
if (visible) {
window.addEventListener("mousemove", getTooltipPosition);
} else {
window.removeEventListener("mousemove", getTooltipPosition);
}
// clean-up function to remove on unmount
return () => {
window.removeEventListener("mousemove", getTooltipPosition);
};
}, [visible, getTooltipPosition]); // re-run the effect if prop `visible` changes
// ref to DOM node for measuring
const divRef = useRef(null);
// can caluculate offset instead of passing in props
const offsetX = -.5 * (divRef.current?.offsetWidth || 0);
const offsetY = -.5 * (divRef.current?.offsetHeight || 0);
// don't show until after mouse is moved
const isVisible = visible && state.mouseMoved;
return (
<div
ref={divRef}
onClick={toggleNavigation}
// control most styling through className
className={`tooltip ${isVisible ? "tooltip-visible" : "tooltip-hidden"}`}
style={{
// need absolute position to use top and left
position: "absolute",
top: state.yPosition + offsetY,
left: state.xPosition + offsetX
}}
>
Close Menu
</div>
);
};
Other Uses
We can easily make this NavigationCloseMouseButton into a more flexible MovingTooltip by removing some of the hard-coded specifics.
Get the contents from props.children instead of always using "Close Menu"
Accept a className as a prop
Change the name of toggleNavigation to onClick
Code Sandbox Demo

Resources