Added UI elements for the new drawing system

Removed old gesture system
Refactored map interaction into separate component
This commit is contained in:
Mitchell McCaffrey 2020-04-27 17:29:46 +10:00
parent 3112890fd3
commit 2cf93ab77f
29 changed files with 952 additions and 540 deletions

View File

@ -16,7 +16,6 @@
"react-modal": "^3.11.2",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.0",
"shape-detector": "^0.2.1",
"shortid": "^2.2.15",
"simple-peer": "^9.6.2",
"simplebar-react": "^2.1.0",

View File

@ -1,6 +1,5 @@
import React, { useRef, useEffect, useState } from "react";
import { Box, Image } from "theme-ui";
import interact from "interactjs";
import ProxyToken from "../token/ProxyToken";
import TokenMenu from "../token/TokenMenu";
@ -10,13 +9,12 @@ import MapControls from "./MapControls";
import { omit } from "../../helpers/shared";
import useDataSource from "../../helpers/useDataSource";
import MapInteraction from "./MapInteraction";
import { mapSources as defaultMapSources } from "../../maps";
const mapTokenProxyClassName = "map-token__proxy";
const mapTokenMenuClassName = "map-token__menu";
const zoomSpeed = -0.005;
const minZoom = 0.1;
const maxZoom = 5;
function Map({
map,
@ -49,11 +47,31 @@ function Map({
* Map drawing
*/
const [selectedTool, setSelectedTool] = useState("pan");
const [brushColor, setBrushColor] = useState("black");
const [useBrushGridSnapping, setUseBrushGridSnapping] = useState(false);
const [useBrushBlending, setUseBrushBlending] = useState(false);
const [useBrushGesture, setUseBrushGesture] = useState(false);
const [selectedToolId, setSelectedToolId] = useState("pan");
const [toolSettings, setToolSettings] = useState({
fog: { type: "add", useGridSnapping: false, useEdgeSnapping: true },
brush: {
color: "darkGray",
type: "stroke",
useBlending: false,
useGridSnapping: false,
},
shape: {
color: "red",
type: "rectangle",
useBlending: true,
useGridSnapping: true,
},
});
function handleToolSettingChange(tool, change) {
setToolSettings((prevSettings) => ({
...prevSettings,
[tool]: {
...prevSettings[tool],
...change,
},
}));
}
const [drawnShapes, setDrawnShapes] = useState([]);
function handleShapeAdd(shape) {
@ -88,121 +106,36 @@ function Map({
setDrawnShapes(Object.values(shapesById));
}, [mapState]);
const disabledTools = [];
const disabledControls = [];
if (!allowMapChange) {
disabledControls.push("map");
}
if (!allowDrawing) {
disabledControls.push("drawing");
}
if (!map) {
disabledTools.push("pan");
disabledTools.push("brush");
disabledControls.push("pan");
disabledControls.push("brush");
}
if (drawnShapes.length === 0) {
disabledTools.push("erase");
disabledControls.push("erase");
}
/**
* Map movement
*/
const mapTranslateRef = useRef({ x: 0, y: 0 });
const mapScaleRef = useRef(1);
const mapMoveContainerRef = useRef();
function setTranslateAndScale(newTranslate, newScale) {
const moveContainer = mapMoveContainerRef.current;
moveContainer.style.transform = `translate(${newTranslate.x}px, ${newTranslate.y}px) scale(${newScale})`;
mapScaleRef.current = newScale;
mapTranslateRef.current = newTranslate;
if (!mapState || mapState.drawActionIndex < 0) {
disabledControls.push("undo");
}
if (
!mapState ||
mapState.drawActionIndex === mapState.drawActions.length - 1
) {
disabledControls.push("redo");
}
useEffect(() => {
function handleMove(event, isGesture) {
const scale = mapScaleRef.current;
const translate = mapTranslateRef.current;
let newScale = scale;
let newTranslate = translate;
if (isGesture) {
newScale = Math.max(Math.min(scale + event.ds, maxZoom), minZoom);
}
if (selectedTool === "pan" || isGesture) {
newTranslate = {
x: translate.x + event.dx,
y: translate.y + event.dy,
};
}
setTranslateAndScale(newTranslate, newScale);
}
const mapInteract = interact(".map")
.gesturable({
listeners: {
move: (e) => handleMove(e, true),
},
})
.draggable({
inertia: true,
listeners: {
move: (e) => handleMove(e, false),
},
cursorChecker: () => {
return selectedTool === "pan" && map ? "move" : "default";
},
})
.on("doubletap", (event) => {
event.preventDefault();
if (selectedTool === "pan") {
setTranslateAndScale({ x: 0, y: 0 }, 1);
}
});
return () => {
mapInteract.unset();
};
}, [selectedTool, map]);
// Reset map transform when map changes
useEffect(() => {
setTranslateAndScale({ x: 0, y: 0 }, 1);
}, [map]);
// Bind the wheel event of the map via a ref
// in order to support non-passive event listening
// to allow the track pad zoom to be interrupted
// see https://github.com/facebook/react/issues/14856
useEffect(() => {
const mapContainer = mapContainerRef.current;
function handleZoom(event) {
// Stop overscroll on chrome and safari
// also stop pinch to zoom on chrome
event.preventDefault();
const scale = mapScaleRef.current;
const translate = mapTranslateRef.current;
const deltaY = event.deltaY * zoomSpeed;
const newScale = Math.max(Math.min(scale + deltaY, maxZoom), minZoom);
setTranslateAndScale(translate, newScale);
}
if (mapContainer) {
mapContainer.addEventListener("wheel", handleZoom, {
passive: false,
});
}
return () => {
if (mapContainer) {
mapContainer.removeEventListener("wheel", handleZoom);
}
};
}, []);
/**
* Member setup
*/
const mapRef = useRef(null);
const mapContainerRef = useRef();
const gridX = map && map.gridX;
const gridY = map && map.gridY;
const gridSizeNormalized = { x: 1 / gridX || 0, y: 1 / gridY || 0 };
@ -260,83 +193,43 @@ function Map({
<MapDrawing
width={map ? map.width : 0}
height={map ? map.height : 0}
selectedTool={selectedTool}
selectedTool={selectedToolId}
toolSettings={toolSettings[selectedToolId]}
shapes={drawnShapes}
onShapeAdd={handleShapeAdd}
onShapeRemove={handleShapeRemove}
brushColor={brushColor}
useGridSnapping={useBrushGridSnapping}
gridSize={gridSizeNormalized}
useBrushBlending={useBrushBlending}
useBrushGesture={useBrushGesture}
/>
);
const mapControls = (
<MapControls
onMapChange={onMapChange}
onMapStateChange={onMapStateChange}
currentMap={map}
onSelectedToolChange={setSelectedToolId}
selectedToolId={selectedToolId}
toolSettings={toolSettings}
onToolSettingChange={handleToolSettingChange}
disabledControls={disabledControls}
onUndo={onMapDrawUndo}
onRedo={onMapDrawRedo}
/>
);
return (
<>
<Box
className="map"
sx={{
flexGrow: 1,
position: "relative",
overflow: "hidden",
backgroundColor: "rgba(0, 0, 0, 0.1)",
userSelect: "none",
touchAction: "none",
}}
bg="background"
ref={mapContainerRef}
<MapInteraction
map={map}
aspectRatio={aspectRatio}
selectedTool={selectedToolId}
toolSettings={toolSettings[selectedToolId]}
controls={(allowMapChange || allowDrawing) && mapControls}
>
<Box
sx={{
position: "relative",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
<Box ref={mapMoveContainerRef}>
<Box
sx={{
width: "100%",
height: 0,
paddingBottom: `${(1 / aspectRatio) * 100}%`,
}}
/>
{map && mapImage}
{map && mapDrawing}
{map && mapTokens}
</Box>
</Box>
{(allowMapChange || allowDrawing) && (
<MapControls
onMapChange={onMapChange}
onMapStateChange={onMapStateChange}
currentMap={map}
onToolChange={setSelectedTool}
selectedTool={selectedTool}
disabledTools={disabledTools}
onUndo={onMapDrawUndo}
onRedo={onMapDrawRedo}
undoDisabled={!mapState || mapState.drawActionIndex < 0}
redoDisabled={
!mapState ||
mapState.drawActionIndex === mapState.drawActions.length - 1
}
brushColor={brushColor}
onBrushColorChange={setBrushColor}
onEraseAll={handleShapeRemoveAll}
useBrushGridSnapping={useBrushGridSnapping}
onBrushGridSnappingChange={setUseBrushGridSnapping}
useBrushBlending={useBrushBlending}
onBrushBlendingChange={setUseBrushBlending}
useBrushGesture={useBrushGesture}
onBrushGestureChange={setUseBrushGesture}
allowDrawing={allowDrawing}
allowMapChange={allowMapChange}
/>
)}
</Box>
{map && mapImage}
{map && mapDrawing}
{map && mapTokens}
</MapInteraction>
{allowTokenChange && (
<>
<ProxyToken

View File

@ -1,207 +1,74 @@
import React, { useState, useEffect, useRef, Fragment } from "react";
import { Flex, Box, IconButton, Label } from "theme-ui";
import React, { useState, Fragment } from "react";
import { IconButton, Flex, Box } from "theme-ui";
import RadioIconButton from "./controls/RadioIconButton";
import Divider from "./controls/Divider";
import SelectMapButton from "./SelectMapButton";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import FogToolSettings from "./controls/FogToolSettings";
import BrushToolSettings from "./controls/BrushToolSettings";
import ShapeToolSettings from "./controls/ShapeToolSettings";
import EraseToolSettings from "./controls/EraseToolSettings";
import PanToolIcon from "../../icons/PanToolIcon";
import FogToolIcon from "../../icons/FogToolIcon";
import BrushToolIcon from "../../icons/BrushToolIcon";
import ShapeToolIcon from "../../icons/ShapeToolIcon";
import EraseToolIcon from "../../icons/EraseToolIcon";
import UndoIcon from "../../icons/UndoIcon";
import RedoIcon from "../../icons/RedoIcon";
import GridOnIcon from "../../icons/GridOnIcon";
import GridOffIcon from "../../icons/GridOffIcon";
import BlendOnIcon from "../../icons/BlendOnIcon";
import BlendOffIcon from "../../icons/BlendOffIcon";
import GestureOnIcon from "../../icons/GestureOnIcon";
import GestureOffIcon from "../../icons/GestureOffIcon";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import colors, { colorOptions } from "../../helpers/colors";
import MapMenu from "./MapMenu";
import EraseAllIcon from "../../icons/EraseAllIcon";
function MapControls({
function MapContols({
onMapChange,
onMapStateChange,
currentMap,
onToolChange,
selectedTool,
disabledTools,
selectedToolId,
onSelectedToolChange,
toolSettings,
onToolSettingChange,
disabledControls,
onUndo,
onRedo,
undoDisabled,
redoDisabled,
brushColor,
onBrushColorChange,
onEraseAll,
useBrushGridSnapping,
onBrushGridSnappingChange,
useBrushBlending,
onBrushBlendingChange,
useBrushGesture,
onBrushGestureChange,
allowDrawing,
allowMapChange,
}) {
const [isExpanded, setIsExpanded] = useState(false);
const subMenus = {
brush: (
<Box sx={{ width: "104px" }} p={1}>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
}}
>
{colorOptions.map((color) => (
<Box
key={color}
sx={{
width: "25%",
paddingTop: "25%",
borderRadius: "50%",
transform: "scale(0.75)",
backgroundColor: colors[color],
cursor: "pointer",
}}
onClick={() => onBrushColorChange(color)}
aria-label={`Brush Color ${color}`}
>
{brushColor === color && (
<Box
sx={{
width: "100%",
height: "100%",
border: "2px solid white",
position: "absolute",
top: 0,
borderRadius: "50%",
}}
/>
)}
</Box>
))}
</Box>
<Flex sx={{ justifyContent: "space-between" }}>
<IconButton
aria-label={
useBrushGridSnapping
? "Disable Brush Grid Snapping"
: "Enable Brush Grid Snapping"
}
title={
useBrushGridSnapping
? "Disable Brush Grid Snapping"
: "Enable Brush Grid Snapping"
}
onClick={() => onBrushGridSnappingChange(!useBrushGridSnapping)}
>
{useBrushGridSnapping ? <GridOnIcon /> : <GridOffIcon />}
</IconButton>
<IconButton
aria-label={
useBrushBlending
? "Disable Brush Blending"
: "Enable Brush Blending"
}
title={
useBrushBlending
? "Disable Brush Blending"
: "Enable Brush Blending"
}
onClick={() => onBrushBlendingChange(!useBrushBlending)}
>
{useBrushBlending ? <BlendOnIcon /> : <BlendOffIcon />}
</IconButton>
<IconButton
aria-label={
useBrushGesture
? "Disable Gesture Detection"
: "Enable Gesture Detection"
}
title={
useBrushGesture
? "Disable Gesture Detection"
: "Enable Gesture Detection"
}
onClick={() => onBrushGestureChange(!useBrushGesture)}
>
{useBrushGesture ? <GestureOnIcon /> : <GestureOffIcon />}
</IconButton>
</Flex>
</Box>
),
erase: (
<Box p={1} pr={3}>
<Label
sx={{
fontSize: 1,
alignItems: "center",
":hover": { color: "primary", cursor: "pointer" },
":active": { color: "secondary" },
}}
>
<IconButton
aria-label="Erase All"
title="Erase All"
onClick={() => {
onEraseAll();
setCurrentSubmenu(null);
setCurrentSubmenuOptions({});
}}
>
<EraseAllIcon />
</IconButton>
Erase All
</Label>
</Box>
),
const toolsById = {
pan: {
id: "pan",
icon: <PanToolIcon />,
title: "Pan Tool",
},
fog: {
id: "fog",
icon: <FogToolIcon />,
title: "Fog Tool",
SettingsComponent: FogToolSettings,
},
brush: {
id: "brush",
icon: <BrushToolIcon />,
title: "Brush Tool",
SettingsComponent: BrushToolSettings,
},
shape: {
id: "shape",
icon: <ShapeToolIcon />,
title: "Shape Tool",
SettingsComponent: ShapeToolSettings,
},
erase: {
id: "erase",
icon: <EraseToolIcon />,
title: "Erase tool",
SettingsComponent: EraseToolSettings,
},
};
const [currentSubmenu, setCurrentSubmenu] = useState(null);
const [currentSubmenuOptions, setCurrentSubmenuOptions] = useState({});
function handleToolClick(event, tool) {
if (tool !== selectedTool) {
onToolChange(tool);
} else if (currentSubmenu) {
setCurrentSubmenu(null);
setCurrentSubmenuOptions({});
} else if (subMenus[tool]) {
const toolRect = event.target.getBoundingClientRect();
setCurrentSubmenu(tool);
setCurrentSubmenuOptions({
// Align the right of the submenu to the left of the tool and center vertically
left: `${toolRect.left - 16}px`,
top: `${toolRect.bottom - toolRect.height / 2}px`,
style: { transform: "translate(-100%, -50%)" },
// Exclude this node from the sub menus auto close
excludeNode: event.target,
});
}
}
// Detect when a tool becomes disabled and switch to to the pan tool
useEffect(() => {
if (disabledTools.includes(selectedTool)) {
onToolChange("pan");
}
}, [selectedTool, disabledTools, onToolChange]);
const divider = (
<Box
my={2}
bg="text"
sx={{ height: "2px", width: "24px", borderRadius: "2px", opacity: 0.5 }}
></Box>
);
const expanedMenuRef = useRef();
const tools = ["pan", "fog", "brush", "shape", "erase"];
const sections = [];
if (allowMapChange) {
if (!disabledControls.includes("map")) {
sections.push({
id: "map",
component: (
@ -213,52 +80,34 @@ function MapControls({
),
});
}
if (allowDrawing) {
if (!disabledControls.includes("drawing")) {
sections.push({
id: "drawing",
component: tools.map((tool) => (
<RadioIconButton
key={tool}
title={toolsById[tool].title}
onClick={() => onSelectedToolChange(tool)}
isSelected={selectedToolId === tool}
disabled={disabledControls.includes(tool)}
>
{toolsById[tool].icon}
</RadioIconButton>
)),
});
sections.push({
id: "history",
component: (
<>
<IconButton
aria-label="Pan Tool"
title="Pan Tool"
onClick={(e) => handleToolClick(e, "pan")}
sx={{ color: selectedTool === "pan" ? "primary" : "text" }}
disabled={disabledTools.includes("pan")}
>
<PanToolIcon />
</IconButton>
<IconButton
aria-label="Brush Tool"
title="Brush Tool"
onClick={(e) => handleToolClick(e, "brush")}
sx={{ color: selectedTool === "brush" ? "primary" : "text" }}
disabled={disabledTools.includes("brush")}
>
<BrushToolIcon />
</IconButton>
<IconButton
aria-label="Erase Tool"
title="Erase Tool"
onClick={(e) => handleToolClick(e, "erase")}
sx={{ color: selectedTool === "erase" ? "primary" : "text" }}
disabled={disabledTools.includes("erase")}
>
<EraseToolIcon />
</IconButton>
{divider}
<IconButton
aria-label="Undo"
title="Undo"
onClick={() => onUndo()}
disabled={undoDisabled}
onClick={onUndo}
disabled={disabledControls.includes("undo")}
>
<UndoIcon />
</IconButton>
<IconButton
aria-label="Redo"
title="Redo"
onClick={() => onRedo()}
disabled={redoDisabled}
onClick={onRedo}
disabled={disabledControls.includes("redo")}
>
<RedoIcon />
</IconButton>
@ -298,7 +147,6 @@ function MapControls({
>
<ExpandMoreIcon />
</IconButton>
<Box
sx={{
flexDirection: "column",
@ -308,12 +156,11 @@ function MapControls({
borderRadius: "4px",
}}
p={2}
ref={expanedMenuRef}
>
{sections.map((section, index) => (
<Fragment key={section.id}>
{section.component}
{index !== sections.length - 1 && divider}
{index !== sections.length - 1 && <Divider />}
</Fragment>
))}
</Box>
@ -321,6 +168,34 @@ function MapControls({
);
}
function getToolSettings() {
const Settings = toolsById[selectedToolId].SettingsComponent;
if (Settings) {
return (
<Box
sx={{
position: "absolute",
top: "4px",
left: "50%",
transform: "translateX(-50%)",
backgroundColor: "overlay",
borderRadius: "4px",
}}
p={1}
>
<Settings
settings={toolSettings[selectedToolId]}
onSettingChange={(change) =>
onToolSettingChange(selectedToolId, change)
}
/>
</Box>
);
} else {
return null;
}
}
return (
<>
<Flex
@ -335,18 +210,9 @@ function MapControls({
>
{controls}
</Flex>
<MapMenu
isOpen={!!currentSubmenu}
onRequestClose={() => {
setCurrentSubmenu(null);
setCurrentSubmenuOptions({});
}}
{...currentSubmenuOptions}
>
{currentSubmenu && subMenus[currentSubmenu]}
</MapMenu>
{getToolSettings()}
</>
);
}
export default MapControls;
export default MapContols;

View File

@ -5,24 +5,23 @@ import shortid from "shortid";
import colors from "../../helpers/colors";
import { snapPositionToGrid } from "../../helpers/shared";
import { pointsToGesture, gestureToData } from "../../helpers/gestures";
function MapDrawing({
width,
height,
selectedTool,
toolSettings,
shapes,
onShapeAdd,
onShapeRemove,
brushColor,
useGridSnapping,
gridSize,
useBrushBlending,
useBrushGesture,
}) {
const canvasRef = useRef();
const containerRef = useRef();
const toolColor = toolSettings && toolSettings.color;
const useToolBlending = toolSettings && toolSettings.useBlending;
const useGridSnapping = toolSettings && toolSettings.useGridSnapping;
const [brushPoints, setBrushPoints] = useState([]);
const [isDrawing, setIsDrawing] = useState(false);
const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
@ -91,22 +90,16 @@ function MapDrawing({
if (selectedTool === "brush") {
if (brushPoints.length > 1) {
const simplifiedPoints = simplify(brushPoints, 0.001);
const type = useBrushGesture
? pointsToGesture(simplifiedPoints)
: "path";
const type = "path";
if (type !== null) {
const data =
type === "path"
? { points: simplifiedPoints }
: gestureToData(simplifiedPoints, type);
const data = { points: simplifiedPoints };
onShapeAdd({
type,
data,
id: shortid.generate(),
color: brushColor,
blend: useBrushBlending,
color: toolColor,
blend: useToolBlending,
});
}
@ -188,7 +181,7 @@ function MapDrawing({
}
if (selectedTool === "brush" && brushPoints.length > 0) {
const path = pointsToPath(brushPoints);
drawPath(path, colors[brushColor], useBrushBlending, context);
drawPath(path, colors[toolColor], useToolBlending, context);
}
if (hoveredShape) {
const path = shapeToPath(hoveredShape);
@ -204,9 +197,8 @@ function MapDrawing({
isDrawing,
selectedTool,
brushPoints,
brushColor,
useBrushGesture,
useBrushBlending,
toolColor,
useToolBlending,
]);
return (

View File

@ -0,0 +1,152 @@
import React, { useRef, useEffect } from "react";
import { Box } from "theme-ui";
import interact from "interactjs";
const zoomSpeed = -0.005;
const minZoom = 0.1;
const maxZoom = 5;
function MapInteraction({
map,
aspectRatio,
selectedTool,
selectedToolSettings,
children,
controls,
}) {
const mapContainerRef = useRef();
const mapMoveContainerRef = useRef();
const mapTranslateRef = useRef({ x: 0, y: 0 });
const mapScaleRef = useRef(1);
function setTranslateAndScale(newTranslate, newScale) {
const moveContainer = mapMoveContainerRef.current;
moveContainer.style.transform = `translate(${newTranslate.x}px, ${newTranslate.y}px) scale(${newScale})`;
mapScaleRef.current = newScale;
mapTranslateRef.current = newTranslate;
}
useEffect(() => {
function handleMove(event, isGesture) {
const scale = mapScaleRef.current;
const translate = mapTranslateRef.current;
let newScale = scale;
let newTranslate = translate;
if (isGesture) {
newScale = Math.max(Math.min(scale + event.ds, maxZoom), minZoom);
}
if (selectedTool === "pan" || isGesture) {
newTranslate = {
x: translate.x + event.dx,
y: translate.y + event.dy,
};
}
setTranslateAndScale(newTranslate, newScale);
}
const mapInteract = interact(".map")
.gesturable({
listeners: {
move: (e) => handleMove(e, true),
},
})
.draggable({
inertia: true,
listeners: {
move: (e) => handleMove(e, false),
},
cursorChecker: () => {
return selectedTool === "pan" && map ? "move" : "default";
},
})
.on("doubletap", (event) => {
event.preventDefault();
if (selectedTool === "pan") {
setTranslateAndScale({ x: 0, y: 0 }, 1);
}
});
return () => {
mapInteract.unset();
};
}, [selectedTool, map]);
// Reset map transform when map changes
useEffect(() => {
setTranslateAndScale({ x: 0, y: 0 }, 1);
}, [map]);
// Bind the wheel event of the map via a ref
// in order to support non-passive event listening
// to allow the track pad zoom to be interrupted
// see https://github.com/facebook/react/issues/14856
useEffect(() => {
const mapContainer = mapContainerRef.current;
function handleZoom(event) {
// Stop overscroll on chrome and safari
// also stop pinch to zoom on chrome
event.preventDefault();
const scale = mapScaleRef.current;
const translate = mapTranslateRef.current;
const deltaY = event.deltaY * zoomSpeed;
const newScale = Math.max(Math.min(scale + deltaY, maxZoom), minZoom);
setTranslateAndScale(translate, newScale);
}
if (mapContainer) {
mapContainer.addEventListener("wheel", handleZoom, {
passive: false,
});
}
return () => {
if (mapContainer) {
mapContainer.removeEventListener("wheel", handleZoom);
}
};
}, []);
return (
<Box
className="map"
sx={{
flexGrow: 1,
position: "relative",
overflow: "hidden",
backgroundColor: "rgba(0, 0, 0, 0.1)",
userSelect: "none",
touchAction: "none",
}}
bg="background"
ref={mapContainerRef}
>
<Box
sx={{
position: "relative",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
<Box ref={mapMoveContainerRef}>
<Box
sx={{
width: "100%",
height: 0,
paddingBottom: `${(1 / aspectRatio) * 100}%`,
}}
/>
{children}
</Box>
</Box>
{controls}
</Box>
);
}
export default MapInteraction;

View File

@ -0,0 +1,19 @@
import React from "react";
import { IconButton } from "theme-ui";
import BlendOnIcon from "../../../icons/BlendOnIcon";
import BlendOffIcon from "../../../icons/BlendOffIcon";
function AlphaBlendToggle({ useBlending, onBlendingChange }) {
return (
<IconButton
aria-label={useBlending ? "Disable Blending" : "Enable Blending"}
title={useBlending ? "Disable Blending" : "Enable Blending"}
onClick={() => onBlendingChange(!useBlending)}
>
{useBlending ? <BlendOnIcon /> : <BlendOffIcon />}
</IconButton>
);
}
export default AlphaBlendToggle;

View File

@ -0,0 +1,51 @@
import React from "react";
import { Flex } from "theme-ui";
import ColorControl from "./ColorControl";
import AlphaBlendToggle from "./AlphaBlendToggle";
import GridSnappingToggle from "./GridSnappingToggle";
import RadioIconButton from "./RadioIconButton";
import BrushStrokeIcon from "../../../icons/BrushStrokeIcon";
import BrushFillIcon from "../../../icons/BrushFillIcon";
import Divider from "./Divider";
function BrushToolSettings({ settings, onSettingChange }) {
return (
<Flex sx={{ alignItems: "center" }}>
<ColorControl
color={settings.color}
onColorChange={(color) => onSettingChange({ color })}
/>
<Divider vertical />
<RadioIconButton
title="Brush Type Stroke"
onClick={() => onSettingChange({ type: "stroke" })}
isSelected={settings.type === "stroke"}
>
<BrushStrokeIcon />
</RadioIconButton>
<RadioIconButton
title="Brush Type Fill"
onClick={() => onSettingChange({ type: "fill" })}
isSelected={settings.type === "fill"}
>
<BrushFillIcon />
</RadioIconButton>
<Divider vertical />
<AlphaBlendToggle
useBlending={settings.useBlending}
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
/>
<GridSnappingToggle
useGridSnapping={settings.useGridSnapping}
onGridSnappingChange={(useGridSnapping) =>
onSettingChange({ useGridSnapping })
}
/>
</Flex>
);
}
export default BrushToolSettings;

View File

@ -0,0 +1,107 @@
import React, { useState } from "react";
import { Box } from "theme-ui";
import colors, { colorOptions } from "../../../helpers/colors";
import MapMenu from "../MapMenu";
function ColorCircle({ color, selected, onClick, sx }) {
return (
<Box
key={color}
sx={{
borderRadius: "50%",
transform: "scale(0.75)",
backgroundColor: colors[color],
cursor: "pointer",
...sx,
}}
onClick={onClick}
aria-label={`Brush Color ${color}`}
>
{selected && (
<Box
sx={{
width: "100%",
height: "100%",
border: "2px solid white",
position: "absolute",
top: 0,
borderRadius: "50%",
}}
/>
)}
</Box>
);
}
function ColorControl({ color, onColorChange }) {
const [showColorMenu, setShowColorMenu] = useState(false);
const [colorMenuOptions, setColorMenuOptions] = useState({});
function handleControlClick(event) {
if (showColorMenu) {
setShowColorMenu(false);
setColorMenuOptions({});
} else {
setShowColorMenu(true);
const rect = event.target.getBoundingClientRect();
setColorMenuOptions({
// Align the right of the submenu to the left of the tool and center vertically
left: `${rect.left + rect.width / 2}px`,
top: `${rect.bottom + 16}px`,
style: { transform: "translateX(-50%)" },
// Exclude this node from the sub menus auto close
excludeNode: event.target,
});
}
}
const colorMenu = (
<MapMenu
isOpen={showColorMenu}
onRequestClose={() => {
setShowColorMenu(false);
setColorMenuOptions({});
}}
{...colorMenuOptions}
>
<Box
sx={{
width: "104px",
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
}}
p={1}
>
{colorOptions.map((c) => (
<ColorCircle
key={c}
color={c}
selected={c === color}
onClick={() => {
onColorChange(c);
setShowColorMenu(false);
setColorMenuOptions({});
}}
sx={{ width: "25%", paddingTop: "25%" }}
/>
))}
</Box>
</MapMenu>
);
return (
<>
<ColorCircle
color={color}
selected
onClick={handleControlClick}
sx={{ width: "24px", height: "24px" }}
/>
{colorMenu}
</>
);
}
export default ColorControl;

View File

@ -0,0 +1,24 @@
import React from "react";
import { Divider } from "theme-ui";
function StyledDivider({ vertical }) {
return (
<Divider
my={vertical ? 0 : 2}
mx={vertical ? 2 : 0}
bg="text"
sx={{
height: vertical ? "24px" : "2px",
width: vertical ? "2px" : "24px",
borderRadius: "2px",
opacity: 0.5,
}}
/>
);
}
StyledDivider.defaultProps = {
vertical: false,
};
export default StyledDivider;

View File

@ -0,0 +1,21 @@
import React from "react";
import { IconButton } from "theme-ui";
import SnappingOnIcon from "../../../icons/SnappingOnIcon";
import SnappingOffIcon from "../../../icons/SnappingOffIcon";
function EdgeSnappingToggle({ useEdgeSnapping, onEdgeSnappingChange }) {
return (
<IconButton
aria-label={
useEdgeSnapping ? "Disable Edge Snapping" : "Enable Edge Snapping"
}
title={useEdgeSnapping ? "Disable Edge Snapping" : "Enable Edge Snapping"}
onClick={() => onEdgeSnappingChange(!useEdgeSnapping)}
>
{useEdgeSnapping ? <SnappingOnIcon /> : <SnappingOffIcon />}
</IconButton>
);
}
export default EdgeSnappingToggle;

View File

@ -0,0 +1,16 @@
import React from "react";
import { Flex, IconButton } from "theme-ui";
import EraseAllIcon from "../../../icons/EraseAllIcon";
function EraseToolSettings({ onEraseAll }) {
return (
<Flex sx={{ alignItems: "center" }}>
<IconButton aria-label="Erase All" title="Erase All" onClick={onEraseAll}>
<EraseAllIcon />
</IconButton>
</Flex>
);
}
export default EraseToolSettings;

View File

@ -0,0 +1,55 @@
import React from "react";
import { Flex } from "theme-ui";
import GridSnappingToggle from "./GridSnappingToggle";
import EdgeSnappingToggle from "./EdgeSnappingToggle";
import RadioIconButton from "./RadioIconButton";
import FogAddIcon from "../../../icons/FogAddIcon";
import FogRemoveIcon from "../../../icons/FogRemoveIcon";
import FogToggleIcon from "../../../icons/FogToggleIcon";
import Divider from "./Divider";
function BrushToolSettings({ settings, onSettingChange }) {
return (
<Flex sx={{ alignItems: "center" }}>
<RadioIconButton
title="Add Fog"
onClick={() => onSettingChange({ type: "add" })}
isSelected={settings.type === "add"}
>
<FogAddIcon />
</RadioIconButton>
<RadioIconButton
title="Remove Fog"
onClick={() => onSettingChange({ type: "remove" })}
isSelected={settings.type === "remove"}
>
<FogRemoveIcon />
</RadioIconButton>
<RadioIconButton
title="Toggle Fog"
onClick={() => onSettingChange({ type: "toggle" })}
isSelected={settings.type === "toggle"}
>
<FogToggleIcon />
</RadioIconButton>
<Divider vertical />
<GridSnappingToggle
useGridSnapping={settings.useGridSnapping}
onGridSnappingChange={(useGridSnapping) =>
onSettingChange({ useGridSnapping })
}
/>
<EdgeSnappingToggle
useEdgeSnapping={settings.useEdgeSnapping}
onEdgeSnappingChange={(useEdgeSnapping) =>
onSettingChange({ useEdgeSnapping })
}
/>
</Flex>
);
}
export default BrushToolSettings;

View File

@ -0,0 +1,21 @@
import React from "react";
import { IconButton } from "theme-ui";
import GridOnIcon from "../../../icons/GridOnIcon";
import GridOffIcon from "../../../icons/GridOffIcon";
function GridSnappingToggle({ useGridSnapping, onGridSnappingChange }) {
return (
<IconButton
aria-label={
useGridSnapping ? "Disable Grid Snapping" : "Enable Grid Snapping"
}
title={useGridSnapping ? "Disable Grid Snapping" : "Enable Grid Snapping"}
onClick={() => onGridSnappingChange(!useGridSnapping)}
>
{useGridSnapping ? <GridOnIcon /> : <GridOffIcon />}
</IconButton>
);
}
export default GridSnappingToggle;

View File

@ -0,0 +1,22 @@
import React from "react";
import { IconButton } from "theme-ui";
function RadioIconButton({ title, onClick, isSelected, disabled, children }) {
return (
<IconButton
aria-label={title}
title={title}
onClick={onClick}
sx={{ color: isSelected ? "primary" : "text" }}
disabled={disabled}
>
{children}
</IconButton>
);
}
RadioIconButton.defaultProps = {
disabled: false,
};
export default RadioIconButton;

View File

@ -0,0 +1,59 @@
import React from "react";
import { Flex } from "theme-ui";
import ColorControl from "./ColorControl";
import AlphaBlendToggle from "./AlphaBlendToggle";
import GridSnappingToggle from "./GridSnappingToggle";
import RadioIconButton from "./RadioIconButton";
import ShapeRectangleIcon from "../../../icons/ShapeRectangleIcon";
import ShapeCircleIcon from "../../../icons/ShapeCircleIcon";
import ShapeTriangleIcon from "../../../icons/ShapeTriangleIcon";
import Divider from "./Divider";
function ShapeToolSettings({ settings, onSettingChange }) {
return (
<Flex sx={{ alignItems: "center" }}>
<ColorControl
color={settings.color}
onColorChange={(color) => onSettingChange({ color })}
/>
<Divider vertical />
<RadioIconButton
title="Shape Type Rectangle"
onClick={() => onSettingChange({ type: "rectangle" })}
isSelected={settings.type === "rectangle"}
>
<ShapeRectangleIcon />
</RadioIconButton>
<RadioIconButton
title="Shape Type Circle"
onClick={() => onSettingChange({ type: "circle" })}
isSelected={settings.type === "cricle"}
>
<ShapeTriangleIcon />
</RadioIconButton>
<RadioIconButton
title="Shape Type Triangle"
onClick={() => onSettingChange({ type: "triangle" })}
isSelected={settings.type === "triangle"}
>
<ShapeCircleIcon />
</RadioIconButton>
<Divider vertical />
<AlphaBlendToggle
useBlending={settings.useBlending}
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
/>
<GridSnappingToggle
useGridSnapping={settings.useGridSnapping}
onGridSnappingChange={(useGridSnapping) =>
onSettingChange({ useGridSnapping })
}
/>
</Flex>
);
}
export default ShapeToolSettings;

View File

@ -1,98 +0,0 @@
import ShapeDetector from "shape-detector";
import simplify from "simplify-js";
import { normalize, subtract, dot, length } from "./vector2";
import gestures from "./gesturesData";
const detector = new ShapeDetector(gestures);
export function pointsToGesture(points) {
return detector.spot(points).pattern;
}
export function getBounds(points) {
let minX = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE;
let minY = Number.MAX_VALUE;
let maxY = Number.MIN_VALUE;
for (let point of points) {
minX = point.x < minX ? point.x : minX;
maxX = point.x > maxX ? point.x : maxX;
minY = point.y < minY ? point.y : minY;
maxY = point.y > maxY ? point.y : maxY;
}
return { minX, maxX, minY, maxY };
}
function getTrianglePoints(points) {
if (points.length < 3) {
return points;
}
// Simplify edges up to the average distance between points
let perimeterDistance = 0;
for (let i = 0; i < points.length - 1; i++) {
perimeterDistance += length(subtract(points[i + 1], points[i]));
}
const averagePointDistance = perimeterDistance / points.length;
const simplifiedPoints = simplify(points, averagePointDistance);
const edges = [];
// Find edges of the simplified points that have the highest angular change
for (let i = 0; i < simplifiedPoints.length; i++) {
// Ensure index loops over to start and end of erray
const prevIndex = i - 1 < 0 ? simplifiedPoints.length - i - 1 : i - 1;
const nextIndex = (i + 1) % simplifiedPoints.length;
const prev = normalize(
subtract(simplifiedPoints[i], simplifiedPoints[prevIndex])
);
const next = normalize(
subtract(simplifiedPoints[nextIndex], simplifiedPoints[i])
);
const similarity = dot(prev, next);
if (similarity < 0.25) {
edges.push({ similarity, point: simplifiedPoints[i] });
}
}
edges.sort((a, b) => a.similarity - b.similarity);
const trianglePoints = edges.slice(0, 3).map((edge) => edge.point);
// Return the points with the highest angular change or fallback to a heuristic
if (trianglePoints.length === 3) {
return trianglePoints;
} else {
return [
{ x: points[0].x, y: points[0].y },
{
x: points[Math.floor(points.length / 2)].x,
y: points[Math.floor(points.length / 2)].y,
},
{ x: points[points.length - 1].x, y: points[points.length - 1].y },
];
}
}
export function gestureToData(points, gesture) {
const bounds = getBounds(points);
const width = bounds.maxX - bounds.minX;
const height = bounds.maxY - bounds.minY;
const maxSide = width > height ? width : height;
switch (gesture) {
case "rectangle":
return { x: bounds.minX, y: bounds.minY, width, height };
case "triangle":
return {
points: getTrianglePoints(points),
};
case "circle":
return {
x: bounds.minX + width / 2,
y: bounds.minY + height / 2,
radius: maxSide / 2,
};
default:
throw Error("Gesture not implemented");
}
}

View File

@ -0,0 +1,18 @@
import React from "react";
function BrushFillIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M18 4V3c0-.55-.45-1-1-1H5c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V6h1v4h-9c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-9h7c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1h-2z" />
</svg>
);
}
export default BrushFillIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function BrushStrokeIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3.72 6.04c.47.46 1.21.48 1.71.06.37-.32.69-.51.87-.43.5.2 0 1.03-.3 1.52-.25.42-2.86 3.89-2.86 6.31 0 1.28.48 2.34 1.34 2.98.75.56 1.74.73 2.64.46 1.07-.31 1.95-1.4 3.06-2.77 1.21-1.49 2.83-3.44 4.08-3.44 1.63 0 1.65 1.01 1.76 1.79-3.78.64-5.38 3.67-5.38 5.37 0 1.7 1.44 3.09 3.21 3.09 1.63 0 4.29-1.33 4.69-6.1h1.21c.69 0 1.25-.56 1.25-1.25s-.56-1.25-1.25-1.25h-1.22c-.15-1.65-1.09-4.2-4.03-4.2-2.25 0-4.18 1.91-4.94 2.84-.58.73-2.06 2.48-2.29 2.72-.25.3-.68.84-1.11.84-.45 0-.72-.83-.36-1.92.35-1.09 1.4-2.86 1.85-3.52.78-1.14 1.3-1.92 1.3-3.28C8.95 3.69 7.31 3 6.44 3c-1.09 0-2.04.63-2.7 1.22-.53.48-.53 1.32-.02 1.82zm10.16 12.51c-.31 0-.74-.26-.74-.72 0-.6.73-2.2 2.87-2.76-.3 2.69-1.43 3.48-2.13 3.48z" />
</svg>
);
}
export default BrushStrokeIcon;

18
src/icons/FogAddIcon.js Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
function FogAddIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM16 14h-3v3c0 .55-.45 1-1 1s-1-.45-1-1v-3H8c-.55 0-1-.45-1-1s.45-1 1-1h3V9c0-.55.45-1 1-1s1 .45 1 1v3h3c.55 0 1 .45 1 1s-.45 1-1 1z" />
</svg>
);
}
export default FogAddIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function FogRemoveIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M12 4a7.49 7.49 0 017.35 6.04c2.6.18 4.65 2.32 4.65 4.96 0 2.76-2.24 5-5 5H6c-3.31 0-6-2.69-6-6 0-3.09 2.34-5.64 5.35-5.96A7.496 7.496 0 0112 4zm4 8H8c-.55 0-1 .45-1 1s.45 1 1 1h8c.55 0 1-.45 1-1s-.45-1-1-1z" />
</svg>
);
}
export default FogRemoveIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function FogToggleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M12 4a7.49 7.49 0 017.35 6.04c2.6.18 4.65 2.32 4.65 4.96 0 2.76-2.24 5-5 5H6c-3.31 0-6-2.69-6-6 0-3.09 2.34-5.64 5.35-5.96A7.496 7.496 0 0112 4zm0 2a5.493 5.493 0 00-4.759 2.75l-.117.214-.496.951-1.067.114A3.994 3.994 0 002 14a4.005 4.005 0 003.8 3.995L6 18h6.042v-.103L12 6z" />
</svg>
);
}
export default FogToggleIcon;

18
src/icons/FogToolIcon.js Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
function FogToolIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
</svg>
);
}
export default FogToolIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function ShapeCircleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
</svg>
);
}
export default ShapeCircleIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function ShapeRectangleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M19 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</svg>
);
}
export default ShapeRectangleIcon;

View File

@ -0,0 +1,20 @@
import React from "react";
function ShapeToolIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M11.15 3.4L7.43 9.48c-.41.66.07 1.52.85 1.52h7.43c.78 0 1.26-.86.85-1.52L12.85 3.4c-.39-.64-1.31-.64-1.7 0z" />
<circle cx="17.5" cy="17.5" r="4.5" />
<path d="M4 21.5h6c.55 0 1-.45 1-1v-6c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1z" />
</svg>
);
}
export default ShapeToolIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function ShapeTriangleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M2.73 21h18.53c.77 0 1.25-.83.87-1.5l-9.27-16a.996.996 0 00-1.73 0l-9.27 16c-.38.67.1 1.5.87 1.5z" />
</svg>
);
}
export default ShapeTriangleIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function SnappingOffIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M24 24H0V0h24z" fill="none" />
<path d="M12 21c-5.364 0-8.873-3.694-8.997-8.724L3 12V6.224L1.105 4.661a1 1 0 111.273-1.543l18.519 15.266a1 1 0 01-1.273 1.543l-1.444-1.192C16.701 20.125 14.641 21 12 21zm0-6.014c.404 0 .85-.098 1.273-.294L9 11.17 9 12c0 1.983 1.716 2.986 3 2.986zM9 6.262l-1.999-1.64L7 4.213l-.012-.002-.02-.014a1.157 1.157 0 00-.411-.188L6.483 4h-.238L4.382 2.472c.42-.268.912-.435 1.441-.466L6 2h.483c1.054 0 2.4.827 2.51 1.864L9 4v2.262zm11.302 9.276L15 11.187V4c0-1.05.82-1.918 1.851-1.994L17 2h1c1.6 0 2.904 1.246 2.995 2.823L21 5v6.69c0 1.334-.233 2.647-.698 3.848zM17 4v4.027l1.6-.012c.236-.004.4-.01.4-.02V5a.996.996 0 00-.883-.993L18 4h-1z" />
</svg>
);
}
export default SnappingOffIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function SnappingOnIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M24 24H0V0h24z" fill="none" />
<path d="M12 21c-5.462 0-9-3.83-9-9V5c0-1.66 1.34-3 3-3h.483C7.583 2 9 2.9 9 4v8c0 1.983 1.716 2.986 3 2.986S15 14 15 12V4c0-1.1.9-2 2-2h1c1.66 0 3 1.34 3 3v6.69c0 4.79-3 9.31-9 9.31zm5-12.973h.28c.55-.002 1.72-.008 1.72-.032V5a.996.996 0 00-.883-.993L18 4h-1v4.027zM5 8.014l.064-.001L7 7.987V4.213l-.012-.002-.02-.014a1.157 1.157 0 00-.411-.188L6.483 4H6a.996.996 0 00-.993.883L5 5v3.014z" />
</svg>
);
}
export default SnappingOnIcon;

View File

@ -9837,11 +9837,6 @@ shallow-clone@^3.0.0:
dependencies:
kind-of "^6.0.2"
shape-detector@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/shape-detector/-/shape-detector-0.2.1.tgz#d69acf8a5f595100fee08b2d69d6b5c74d887e1e"
integrity sha1-1prPil9ZUQD+4Istada1x02Ifh4=
shebang-command@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"