Added UI elements for the new drawing system
Removed old gesture system Refactored map interaction into separate component
This commit is contained in:
parent
3112890fd3
commit
2cf93ab77f
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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 (
|
||||
|
152
src/components/map/MapInteraction.js
Normal file
152
src/components/map/MapInteraction.js
Normal 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;
|
19
src/components/map/controls/AlphaBlendToggle.js
Normal file
19
src/components/map/controls/AlphaBlendToggle.js
Normal 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;
|
51
src/components/map/controls/BrushToolSettings.js
Normal file
51
src/components/map/controls/BrushToolSettings.js
Normal 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;
|
107
src/components/map/controls/ColorControl.js
Normal file
107
src/components/map/controls/ColorControl.js
Normal 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;
|
24
src/components/map/controls/Divider.js
Normal file
24
src/components/map/controls/Divider.js
Normal 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;
|
21
src/components/map/controls/EdgeSnappingToggle.js
Normal file
21
src/components/map/controls/EdgeSnappingToggle.js
Normal 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;
|
16
src/components/map/controls/EraseToolSettings.js
Normal file
16
src/components/map/controls/EraseToolSettings.js
Normal 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;
|
55
src/components/map/controls/FogToolSettings.js
Normal file
55
src/components/map/controls/FogToolSettings.js
Normal 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;
|
21
src/components/map/controls/GridSnappingToggle.js
Normal file
21
src/components/map/controls/GridSnappingToggle.js
Normal 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;
|
22
src/components/map/controls/RadioIconButton.js
Normal file
22
src/components/map/controls/RadioIconButton.js
Normal 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;
|
59
src/components/map/controls/ShapeToolSettings.js
Normal file
59
src/components/map/controls/ShapeToolSettings.js
Normal 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;
|
@ -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");
|
||||
}
|
||||
}
|
18
src/icons/BrushFillIcon.js
Normal file
18
src/icons/BrushFillIcon.js
Normal 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;
|
18
src/icons/BrushStrokeIcon.js
Normal file
18
src/icons/BrushStrokeIcon.js
Normal 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
18
src/icons/FogAddIcon.js
Normal 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;
|
18
src/icons/FogRemoveIcon.js
Normal file
18
src/icons/FogRemoveIcon.js
Normal 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;
|
18
src/icons/FogToggleIcon.js
Normal file
18
src/icons/FogToggleIcon.js
Normal 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
18
src/icons/FogToolIcon.js
Normal 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;
|
18
src/icons/ShapeCircleIcon.js
Normal file
18
src/icons/ShapeCircleIcon.js
Normal 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;
|
18
src/icons/ShapeRectangleIcon.js
Normal file
18
src/icons/ShapeRectangleIcon.js
Normal 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;
|
20
src/icons/ShapeToolIcon.js
Normal file
20
src/icons/ShapeToolIcon.js
Normal 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;
|
18
src/icons/ShapeTriangleIcon.js
Normal file
18
src/icons/ShapeTriangleIcon.js
Normal 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;
|
18
src/icons/SnappingOffIcon.js
Normal file
18
src/icons/SnappingOffIcon.js
Normal 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;
|
18
src/icons/SnappingOnIcon.js
Normal file
18
src/icons/SnappingOnIcon.js
Normal 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;
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user