Merge branch 'master' into typescript
This commit is contained in:
89
src/components/map/DragOverlay.js
Normal file
89
src/components/map/DragOverlay.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Box, IconButton } from "theme-ui";
|
||||
|
||||
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
|
||||
|
||||
function DragOverlay({ dragging, node, onRemove }) {
|
||||
const [isRemoveHovered, setIsRemoveHovered] = useState(false);
|
||||
const removeTokenRef = useRef();
|
||||
|
||||
// Detect token hover on remove icon manually to support touch devices
|
||||
useEffect(() => {
|
||||
const map = document.querySelector(".map");
|
||||
const mapRect = map.getBoundingClientRect();
|
||||
|
||||
function detectRemoveHover() {
|
||||
if (!node || !dragging || !removeTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const stage = node.getStage();
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
const pointerPosition = stage.getPointerPosition();
|
||||
const screenSpacePointerPosition = {
|
||||
x: pointerPosition.x + mapRect.left,
|
||||
y: pointerPosition.y + mapRect.top,
|
||||
};
|
||||
const removeIconPosition = removeTokenRef.current.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
screenSpacePointerPosition.x > removeIconPosition.left &&
|
||||
screenSpacePointerPosition.y > removeIconPosition.top &&
|
||||
screenSpacePointerPosition.x < removeIconPosition.right &&
|
||||
screenSpacePointerPosition.y < removeIconPosition.bottom
|
||||
) {
|
||||
if (!isRemoveHovered) {
|
||||
setIsRemoveHovered(true);
|
||||
}
|
||||
} else if (isRemoveHovered) {
|
||||
setIsRemoveHovered(false);
|
||||
}
|
||||
}
|
||||
|
||||
let handler;
|
||||
if (node && dragging) {
|
||||
handler = setInterval(detectRemoveHover, 100);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (handler) {
|
||||
clearInterval(handler);
|
||||
}
|
||||
};
|
||||
}, [isRemoveHovered, dragging, node]);
|
||||
|
||||
// Detect drag end of token image and remove it if it is over the remove icon
|
||||
useEffect(() => {
|
||||
if (!dragging && node && isRemoveHovered) {
|
||||
onRemove();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
dragging && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "32px",
|
||||
left: "50%",
|
||||
borderRadius: "50%",
|
||||
transform: isRemoveHovered
|
||||
? "translateX(-50%) scale(2.0)"
|
||||
: "translateX(-50%) scale(1.5)",
|
||||
transition: "transform 250ms ease",
|
||||
color: isRemoveHovered ? "primary" : "text",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
bg="overlay"
|
||||
ref={removeTokenRef}
|
||||
>
|
||||
<IconButton>
|
||||
<RemoveTokenIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default DragOverlay;
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box } from "theme-ui";
|
||||
import { useToasts } from "react-toast-notifications";
|
||||
|
||||
import MapControls from "./MapControls";
|
||||
import MapInteraction from "./MapInteraction";
|
||||
@@ -141,6 +143,8 @@ function Map({
|
||||
disabledTokens: any,
|
||||
session: Session
|
||||
}) {
|
||||
const { addToast } = useToasts();
|
||||
|
||||
const { tokensById } = useTokenData();
|
||||
|
||||
const [selectedToolId, setSelectedToolId] = useState("move");
|
||||
@@ -324,6 +328,7 @@ function Map({
|
||||
onShapesCut={handleFogShapesCut}
|
||||
onShapesRemove={handleFogShapesRemove}
|
||||
onShapesEdit={handleFogShapesEdit}
|
||||
onShapeError={addToast}
|
||||
active={selectedToolId === "fog"}
|
||||
toolSettings={settings.fog}
|
||||
editable={allowFogDrawing && !settings.fog.preview}
|
||||
@@ -427,30 +432,32 @@ function Map({
|
||||
);
|
||||
|
||||
return (
|
||||
<MapInteraction
|
||||
map={map}
|
||||
mapState={mapState}
|
||||
controls={
|
||||
<>
|
||||
{mapControls}
|
||||
{tokenMenu}
|
||||
{noteMenu}
|
||||
{tokenDragOverlay}
|
||||
{noteDragOverlay}
|
||||
</>
|
||||
}
|
||||
selectedToolId={selectedToolId}
|
||||
onSelectedToolChange={setSelectedToolId}
|
||||
disabledControls={disabledControls}
|
||||
>
|
||||
{mapGrid}
|
||||
{mapDrawing}
|
||||
{mapNotes}
|
||||
{mapTokens}
|
||||
{mapFog}
|
||||
{mapPointer}
|
||||
{mapMeasure}
|
||||
</MapInteraction>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<MapInteraction
|
||||
map={map}
|
||||
mapState={mapState}
|
||||
controls={
|
||||
<>
|
||||
{mapControls}
|
||||
{tokenMenu}
|
||||
{noteMenu}
|
||||
{tokenDragOverlay}
|
||||
{noteDragOverlay}
|
||||
</>
|
||||
}
|
||||
selectedToolId={selectedToolId}
|
||||
onSelectedToolChange={setSelectedToolId}
|
||||
disabledControls={disabledControls}
|
||||
>
|
||||
{mapGrid}
|
||||
{mapDrawing}
|
||||
{mapNotes}
|
||||
{mapTokens}
|
||||
{mapFog}
|
||||
{mapPointer}
|
||||
{mapMeasure}
|
||||
</MapInteraction>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,7 @@ function MapDrawing({
|
||||
}
|
||||
const simplified = simplifyPoints(
|
||||
[...prevPoints, brushPosition],
|
||||
gridCellNormalizedSize,
|
||||
stageScale
|
||||
1 / 1000 / stageScale
|
||||
);
|
||||
return {
|
||||
...prevShape,
|
||||
|
||||
159
src/components/map/MapEditBar.js
Normal file
159
src/components/map/MapEditBar.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Flex, Close, IconButton } from "theme-ui";
|
||||
|
||||
import { groupsFromIds, itemsFromGroups } from "../../helpers/group";
|
||||
|
||||
import ConfirmModal from "../../modals/ConfirmModal";
|
||||
|
||||
import ResetMapIcon from "../../icons/ResetMapIcon";
|
||||
import RemoveMapIcon from "../../icons/RemoveMapIcon";
|
||||
|
||||
import { useGroup } from "../../contexts/GroupContext";
|
||||
import { useMapData } from "../../contexts/MapDataContext";
|
||||
import { useKeyboard } from "../../contexts/KeyboardContext";
|
||||
|
||||
import shortcuts from "../../shortcuts";
|
||||
|
||||
function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
|
||||
const [hasMapState, setHasMapState] = useState(false);
|
||||
|
||||
const { maps, mapStates, removeMaps, resetMap } = useMapData();
|
||||
|
||||
const { activeGroups, selectedGroupIds, onGroupSelect } = useGroup();
|
||||
|
||||
useEffect(() => {
|
||||
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
|
||||
const selectedMapStates = itemsFromGroups(
|
||||
selectedGroups,
|
||||
mapStates,
|
||||
"mapId"
|
||||
);
|
||||
|
||||
let _hasMapState = false;
|
||||
for (let state of selectedMapStates) {
|
||||
if (
|
||||
Object.values(state.tokens).length > 0 ||
|
||||
Object.values(state.drawShapes).length > 0 ||
|
||||
Object.values(state.fogShapes).length > 0 ||
|
||||
Object.values(state.notes).length > 0
|
||||
) {
|
||||
_hasMapState = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setHasMapState(_hasMapState);
|
||||
}, [selectedGroupIds, mapStates, activeGroups]);
|
||||
|
||||
function getSelectedMaps() {
|
||||
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
|
||||
return itemsFromGroups(selectedGroups, maps);
|
||||
}
|
||||
|
||||
const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
|
||||
async function handleMapsRemove() {
|
||||
onLoad(true);
|
||||
setIsMapsRemoveModalOpen(false);
|
||||
const selectedMaps = getSelectedMaps();
|
||||
const selectedMapIds = selectedMaps.map((map) => map.id);
|
||||
onGroupSelect();
|
||||
await removeMaps(selectedMapIds);
|
||||
// Removed the map from the map screen if needed
|
||||
if (currentMap && selectedMapIds.includes(currentMap.id)) {
|
||||
onMapChange(null, null);
|
||||
}
|
||||
onLoad(false);
|
||||
}
|
||||
|
||||
const [isMapsResetModalOpen, setIsMapsResetModalOpen] = useState(false);
|
||||
async function handleMapsReset() {
|
||||
onLoad(true);
|
||||
setIsMapsResetModalOpen(false);
|
||||
const selectedMaps = getSelectedMaps();
|
||||
const selectedMapIds = selectedMaps.map((map) => map.id);
|
||||
for (let id of selectedMapIds) {
|
||||
const newState = await resetMap(id);
|
||||
// Reset the state of the current map if needed
|
||||
if (currentMap && currentMap.id === id) {
|
||||
onMapReset(newState);
|
||||
}
|
||||
}
|
||||
onLoad(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcuts
|
||||
*/
|
||||
function handleKeyDown(event) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.delete(event)) {
|
||||
const selectedMaps = getSelectedMaps();
|
||||
if (selectedMaps.length > 0) {
|
||||
setIsMapsResetModalOpen(false);
|
||||
setIsMapsRemoveModalOpen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(handleKeyDown);
|
||||
|
||||
if (selectedGroupIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
bg="overlay"
|
||||
>
|
||||
<Close
|
||||
title="Clear Selection"
|
||||
aria-label="Clear Selection"
|
||||
onClick={() => onGroupSelect()}
|
||||
/>
|
||||
<Flex>
|
||||
<IconButton
|
||||
aria-label="Reset Selected Map(s)"
|
||||
title="Reset Selected Map(s)"
|
||||
onClick={() => setIsMapsResetModalOpen(true)}
|
||||
disabled={!hasMapState}
|
||||
>
|
||||
<ResetMapIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="Remove Selected Map(s)"
|
||||
title="Remove Selected Map(s)"
|
||||
onClick={() => setIsMapsRemoveModalOpen(true)}
|
||||
>
|
||||
<RemoveMapIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
<ConfirmModal
|
||||
isOpen={isMapsResetModalOpen}
|
||||
onRequestClose={() => setIsMapsResetModalOpen(false)}
|
||||
onConfirm={handleMapsReset}
|
||||
confirmText="Reset"
|
||||
label="Reset Selected Map(s)"
|
||||
description="This will remove all fog, drawings and tokens from the selected maps."
|
||||
/>
|
||||
<ConfirmModal
|
||||
isOpen={isMapsRemoveModalOpen}
|
||||
onRequestClose={() => setIsMapsRemoveModalOpen(false)}
|
||||
onConfirm={handleMapsRemove}
|
||||
confirmText="Remove"
|
||||
label="Remove Selected Map(s)"
|
||||
description="This operation cannot be undone."
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapEditBar;
|
||||
@@ -23,7 +23,7 @@ import MapGrid from "./MapGrid";
|
||||
import MapGridEditor from "./MapGridEditor";
|
||||
|
||||
function MapEditor({ map, onSettingsChange }) {
|
||||
const [mapImageSource] = useMapImage(map);
|
||||
const [mapImage] = useMapImage(map);
|
||||
|
||||
const [stageWidth, setStageWidth] = useState(1);
|
||||
const [stageHeight, setStageHeight] = useState(1);
|
||||
@@ -93,14 +93,14 @@ function MapEditor({ map, onSettingsChange }) {
|
||||
interactionEmitter: null,
|
||||
};
|
||||
|
||||
const canEditGrid = map.type !== "default";
|
||||
|
||||
const gridChanged =
|
||||
map.grid.inset.topLeft.x !== defaultInset.topLeft.x ||
|
||||
map.grid.inset.topLeft.y !== defaultInset.topLeft.y ||
|
||||
map.grid.inset.bottomRight.x !== defaultInset.bottomRight.x ||
|
||||
map.grid.inset.bottomRight.y !== defaultInset.bottomRight.y;
|
||||
|
||||
const gridValid = map.grid.size.x !== 0 && map.grid.size.y !== 0;
|
||||
|
||||
const layout = useResponsiveLayout();
|
||||
|
||||
return (
|
||||
@@ -132,12 +132,8 @@ function MapEditor({ map, onSettingsChange }) {
|
||||
)}
|
||||
>
|
||||
<Layer ref={mapLayerRef}>
|
||||
<Image
|
||||
image={mapImageSource}
|
||||
width={mapWidth}
|
||||
height={mapHeight}
|
||||
/>
|
||||
{showGridControls && canEditGrid && (
|
||||
<Image image={mapImage} width={mapWidth} height={mapHeight} />
|
||||
{showGridControls && gridValid && (
|
||||
<>
|
||||
<MapGrid map={map} />
|
||||
<MapGridEditor map={map} onGridChange={handleGridChange} />
|
||||
@@ -146,7 +142,7 @@ function MapEditor({ map, onSettingsChange }) {
|
||||
</Layer>
|
||||
</KonvaBridge>
|
||||
</ReactResizeDetector>
|
||||
{gridChanged && (
|
||||
{gridChanged && gridValid && (
|
||||
<IconButton
|
||||
title="Reset Grid"
|
||||
aria-label="Reset Grid"
|
||||
@@ -163,28 +159,26 @@ function MapEditor({ map, onSettingsChange }) {
|
||||
<ResetMapIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{canEditGrid && (
|
||||
<IconButton
|
||||
title={
|
||||
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
|
||||
}
|
||||
aria-label={
|
||||
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
|
||||
}
|
||||
onClick={() => setShowGridControls(!showGridControls)}
|
||||
bg="overlay"
|
||||
sx={{
|
||||
borderRadius: "50%",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}}
|
||||
m={2}
|
||||
p="6px"
|
||||
>
|
||||
{showGridControls ? <GridOnIcon /> : <GridOffIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
title={
|
||||
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
|
||||
}
|
||||
aria-label={
|
||||
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
|
||||
}
|
||||
onClick={() => setShowGridControls(!showGridControls)}
|
||||
bg="overlay"
|
||||
sx={{
|
||||
borderRadius: "50%",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}}
|
||||
m={2}
|
||||
p="6px"
|
||||
>
|
||||
{showGridControls ? <GridOnIcon /> : <GridOffIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</GridProvider>
|
||||
</MapInteractionProvider>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import shortid from "shortid";
|
||||
import { Group, Rect, Line } from "react-konva";
|
||||
import { Group, Line } from "react-konva";
|
||||
import useImage from "use-image";
|
||||
import Color from "color";
|
||||
|
||||
import diagonalPattern from "../../images/DiagonalPattern.png";
|
||||
|
||||
@@ -37,8 +38,10 @@ import {
|
||||
Tick,
|
||||
getRelativePointerPosition,
|
||||
} from "../../helpers/konva";
|
||||
import { keyBy } from "../../helpers/shared";
|
||||
|
||||
import SubtractShapeAction from "../../actions/SubtractShapeAction";
|
||||
import CutShapeAction from "../../actions/CutShapeAction";
|
||||
|
||||
import useSetting from "../../hooks/useSetting";
|
||||
|
||||
@@ -51,6 +54,7 @@ function MapFog({
|
||||
onShapesCut,
|
||||
onShapesRemove,
|
||||
onShapesEdit,
|
||||
onShapeError,
|
||||
active,
|
||||
toolSettings,
|
||||
editable,
|
||||
@@ -175,8 +179,7 @@ function MapFog({
|
||||
}
|
||||
const simplified = simplifyPoints(
|
||||
[...prevPoints, brushPosition],
|
||||
gridCellNormalizedSize,
|
||||
stageScale / 4
|
||||
1 / 1000 / stageScale
|
||||
);
|
||||
return {
|
||||
...prevShape,
|
||||
@@ -214,6 +217,8 @@ function MapFog({
|
||||
) {
|
||||
const cut = toolSettings.useFogCut;
|
||||
let drawingShapes = [drawingShape];
|
||||
|
||||
// Filter out hidden or visible shapes if single layer enabled
|
||||
if (!toolSettings.multilayer) {
|
||||
const shapesToSubtract = shapes.filter((shape) =>
|
||||
cut ? !shape.visible : shape.visible
|
||||
@@ -228,22 +233,32 @@ function MapFog({
|
||||
}
|
||||
|
||||
if (drawingShapes.length > 0) {
|
||||
drawingShapes = drawingShapes.map((shape) => {
|
||||
if (cut) {
|
||||
return {
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
data: shape.data,
|
||||
};
|
||||
} else {
|
||||
return { ...shape, color: "black" };
|
||||
}
|
||||
});
|
||||
|
||||
if (cut) {
|
||||
onShapesCut(drawingShapes);
|
||||
// Run a pre-emptive cut action to check whether we've cut anything
|
||||
const cutAction = new CutShapeAction(drawingShapes);
|
||||
const state = cutAction.execute(keyBy(shapes, "id"));
|
||||
|
||||
if (Object.keys(state).length === shapes.length) {
|
||||
onShapeError("No fog to cut");
|
||||
} else {
|
||||
onShapesCut(
|
||||
drawingShapes.map((shape) => ({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
data: shape.data,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
onShapesAdd(drawingShapes);
|
||||
onShapesAdd(
|
||||
drawingShapes.map((shape) => ({ ...shape, color: "black" }))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (cut) {
|
||||
onShapeError("Fog already cut");
|
||||
} else {
|
||||
onShapeError("Fog already placed");
|
||||
}
|
||||
}
|
||||
setDrawingShape(null);
|
||||
@@ -373,6 +388,7 @@ function MapFog({
|
||||
};
|
||||
|
||||
let polygonShapes = [polygonShape];
|
||||
// Filter out hidden or visible shapes if single layer enabled
|
||||
if (!toolSettings.multilayer) {
|
||||
const shapesToSubtract = shapes.filter((shape) =>
|
||||
cut ? !shape.visible : shape.visible
|
||||
@@ -388,7 +404,15 @@ function MapFog({
|
||||
|
||||
if (polygonShapes.length > 0) {
|
||||
if (cut) {
|
||||
onShapesCut(polygonShapes);
|
||||
// Run a pre-emptive cut action to check whether we've cut anything
|
||||
const cutAction = new CutShapeAction(polygonShapes);
|
||||
const state = cutAction.execute(keyBy(shapes, "id"));
|
||||
|
||||
if (Object.keys(state).length === shapes.length) {
|
||||
onShapeError("No fog to cut");
|
||||
} else {
|
||||
onShapesCut(polygonShapes);
|
||||
}
|
||||
} else {
|
||||
onShapesAdd(
|
||||
polygonShapes.map((shape) => ({
|
||||
@@ -399,10 +423,23 @@ function MapFog({
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (cut) {
|
||||
onShapeError("Fog already cut");
|
||||
} else {
|
||||
onShapeError("Fog already placed");
|
||||
}
|
||||
}
|
||||
|
||||
setDrawingShape(null);
|
||||
}, [toolSettings, drawingShape, onShapesCut, onShapesAdd, shapes]);
|
||||
}, [
|
||||
toolSettings,
|
||||
drawingShape,
|
||||
onShapesCut,
|
||||
onShapesAdd,
|
||||
onShapeError,
|
||||
shapes,
|
||||
]);
|
||||
|
||||
// Add keyboard shortcuts
|
||||
function handleKeyDown(event) {
|
||||
@@ -489,6 +526,15 @@ function MapFog({
|
||||
const holes =
|
||||
shape.data.holes &&
|
||||
shape.data.holes.map((hole) => hole.reduce(reducePoints, []));
|
||||
const opacity = editable ? editOpacity : 1;
|
||||
// Control opacity only on fill as using opacity with stroke leads to performance issues
|
||||
const fill = new Color(colors[shape.color] || shape.color)
|
||||
.alpha(opacity)
|
||||
.string();
|
||||
const stroke =
|
||||
editable && active
|
||||
? colors.lightGray
|
||||
: colors[shape.color] || shape.color;
|
||||
return (
|
||||
<HoleyLine
|
||||
key={shape.id}
|
||||
@@ -499,19 +545,12 @@ function MapFog({
|
||||
onMouseUp={eraseHoveredShapes}
|
||||
onTouchEnd={eraseHoveredShapes}
|
||||
points={points}
|
||||
stroke={
|
||||
editable && active
|
||||
? colors.lightGray
|
||||
: colors[shape.color] || shape.color
|
||||
}
|
||||
fill={colors[shape.color] || shape.color}
|
||||
stroke={stroke}
|
||||
fill={fill}
|
||||
closed
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
strokeWidth={gridStrokeWidth * shape.strokeWidth}
|
||||
opacity={
|
||||
editable ? (!shape.visible ? editOpacity / 2 : editOpacity) : 1
|
||||
}
|
||||
fillPatternImage={patternImage}
|
||||
fillPriority={editable && !shape.visible ? "pattern" : "color"}
|
||||
holes={holes}
|
||||
@@ -590,15 +629,9 @@ function MapFog({
|
||||
}
|
||||
}, [shapes, editable, active, toolSettings, shouldRenderGuides]);
|
||||
|
||||
const fogGroupRef = useRef();
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Group ref={fogGroupRef}>
|
||||
{/* Render a blank shape so cache works with no fog shapes */}
|
||||
<Rect width={1} height={1} />
|
||||
{fogShapes.map(renderShape)}
|
||||
</Group>
|
||||
<Group>{fogShapes.map(renderShape)}</Group>
|
||||
{shouldRenderGuides && renderGuides()}
|
||||
{drawingShape && renderShape(drawingShape)}
|
||||
{drawingShape &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useImage from "use-image";
|
||||
|
||||
import { useImageSource } from "../../contexts/ImageSourceContext";
|
||||
import { useDataURL } from "../../contexts/AssetsContext";
|
||||
|
||||
import { mapSources as defaultMapSources } from "../../maps";
|
||||
|
||||
@@ -11,15 +11,13 @@ import Grid from "../Grid";
|
||||
|
||||
function MapGrid({ map }) {
|
||||
let mapSourceMap = map;
|
||||
// Use lowest resolution for grid lightness
|
||||
if (map && map.type === "file" && map.resolutions) {
|
||||
const resolutionArray = Object.keys(map.resolutions);
|
||||
if (resolutionArray.length > 0) {
|
||||
mapSourceMap = map.resolutions[resolutionArray[0]];
|
||||
}
|
||||
}
|
||||
const mapSource = useImageSource(mapSourceMap, defaultMapSources);
|
||||
const [mapImage, mapLoadingStatus] = useImage(mapSource);
|
||||
const mapURL = useDataURL(
|
||||
mapSourceMap,
|
||||
defaultMapSources,
|
||||
undefined,
|
||||
map.type === "file"
|
||||
);
|
||||
const [mapImage, mapLoadingStatus] = useImage(mapURL);
|
||||
|
||||
const [isImageLight, setIsImageLight] = useState(true);
|
||||
|
||||
|
||||
@@ -77,7 +77,10 @@ function MapGridEditor({ map, onGridChange }) {
|
||||
Vector2.subtract(position, previousPosition)
|
||||
);
|
||||
|
||||
const inset = map.grid.inset;
|
||||
const inset = {
|
||||
topLeft: { ...map.grid.inset.topLeft },
|
||||
bottomRight: { ...map.grid.inset.bottomRight },
|
||||
};
|
||||
|
||||
if (direction.x === 0 && direction.y === 0) {
|
||||
return inset;
|
||||
|
||||
18
src/components/map/MapImage.js
Normal file
18
src/components/map/MapImage.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Image } from "theme-ui";
|
||||
|
||||
import { useDataURL } from "../../contexts/AssetsContext";
|
||||
import { mapSources as defaultMapSources } from "../../maps";
|
||||
|
||||
const MapTileImage = React.forwardRef(({ map, ...props }, ref) => {
|
||||
const mapURL = useDataURL(
|
||||
map,
|
||||
defaultMapSources,
|
||||
undefined,
|
||||
map.type === "file"
|
||||
);
|
||||
|
||||
return <Image src={mapURL} ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
export default MapTileImage;
|
||||
@@ -28,22 +28,16 @@ function MapInteraction({
|
||||
onSelectedToolChange,
|
||||
disabledControls,
|
||||
}) {
|
||||
const [mapImageSource, mapImageSourceStatus] = useMapImage(map);
|
||||
const [mapImage, mapImageStatus] = useMapImage(map);
|
||||
|
||||
// Map loaded taking in to account different resolutions
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!map ||
|
||||
!mapState ||
|
||||
(map.type === "file" && !map.file && !map.resolutions) ||
|
||||
mapState.mapId !== map.id
|
||||
) {
|
||||
if (!map || !mapState || mapState.mapId !== map.id) {
|
||||
setMapLoaded(false);
|
||||
} else if (mapImageSourceStatus === "loaded") {
|
||||
} else if (mapImageStatus === "loaded") {
|
||||
setMapLoaded(true);
|
||||
}
|
||||
}, [mapImageSourceStatus, map, mapState]);
|
||||
}, [mapImageStatus, map, mapState]);
|
||||
|
||||
const [stageWidth, setStageWidth] = useState(1);
|
||||
const [stageHeight, setStageHeight] = useState(1);
|
||||
@@ -187,11 +181,12 @@ function MapInteraction({
|
||||
<GridProvider grid={map?.grid} width={mapWidth} height={mapHeight}>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
position: "relative",
|
||||
cursor: getCursorForTool(selectedToolId),
|
||||
touchAction: "none",
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
ref={containerRef}
|
||||
className="map"
|
||||
@@ -211,7 +206,7 @@ function MapInteraction({
|
||||
>
|
||||
<Layer ref={mapLayerRef}>
|
||||
<Image
|
||||
image={mapLoaded && mapImageSource}
|
||||
image={mapLoaded && mapImage}
|
||||
width={mapWidth}
|
||||
height={mapHeight}
|
||||
id="mapImage"
|
||||
|
||||
@@ -44,7 +44,10 @@ function MapMeasure({ map, active }) {
|
||||
|
||||
const gridScale = parseGridScale(active && grid.measurement.scale);
|
||||
|
||||
const snapPositionToGrid = useGridSnapping();
|
||||
const snapPositionToGrid = useGridSnapping(
|
||||
grid.measurement.type === "euclidean" ? 0 : 1,
|
||||
false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Group } from "react-konva";
|
||||
|
||||
import { useInteractionEmitter } from "../../contexts/MapInteractionContext";
|
||||
import { useMapStage } from "../../contexts/MapStageContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useUserId } from "../../contexts/UserIdContext";
|
||||
|
||||
import Vector2 from "../../helpers/Vector2";
|
||||
import { getRelativePointerPosition } from "../../helpers/konva";
|
||||
@@ -28,7 +28,7 @@ function MapNotes({
|
||||
fadeOnHover,
|
||||
}) {
|
||||
const interactionEmitter = useInteractionEmitter();
|
||||
const { userId } = useAuth();
|
||||
const userId = useUserId();
|
||||
const mapStageRef = useMapStage();
|
||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||
const [noteData, setNoteData] = useState(null);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from "react";
|
||||
import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui";
|
||||
|
||||
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Flex, Box, Label, Input, Checkbox } from "theme-ui";
|
||||
|
||||
import { isEmpty } from "../../helpers/shared";
|
||||
import { getGridUpdatedInset } from "../../helpers/grid";
|
||||
|
||||
import { useDataURL } from "../../contexts/AssetsContext";
|
||||
import { mapSources as defaultMapSources } from "../../maps";
|
||||
|
||||
import Divider from "../Divider";
|
||||
import Select from "../Select";
|
||||
|
||||
@@ -40,8 +41,6 @@ function MapSettings({
|
||||
mapState,
|
||||
onSettingsChange,
|
||||
onStateSettingsChange,
|
||||
showMore,
|
||||
onShowMoreChange,
|
||||
}) {
|
||||
function handleFlagChange(event, flag) {
|
||||
if (event.target.checked) {
|
||||
@@ -116,16 +115,22 @@ function MapSettings({
|
||||
onSettingsChange("grid", grid);
|
||||
}
|
||||
|
||||
function getMapSize() {
|
||||
let size = 0;
|
||||
if (map.quality === "original") {
|
||||
size = map.file.length;
|
||||
} else {
|
||||
size = map.resolutions[map.quality].file.length;
|
||||
const mapURL = useDataURL(map, defaultMapSources);
|
||||
const [mapSize, setMapSize] = useState(0);
|
||||
useEffect(() => {
|
||||
async function updateMapSize() {
|
||||
if (mapURL) {
|
||||
const response = await fetch(mapURL);
|
||||
const blob = await response.blob();
|
||||
let size = blob.size;
|
||||
size /= 1000000; // Bytes to Megabytes
|
||||
setMapSize(size.toFixed(2));
|
||||
} else {
|
||||
setMapSize(0);
|
||||
}
|
||||
}
|
||||
size /= 1000000; // Bytes to Megabytes
|
||||
return `${size.toFixed(2)}MB`;
|
||||
}
|
||||
updateMapSize();
|
||||
}, [mapURL]);
|
||||
|
||||
const mapEmpty = !map || isEmpty(map);
|
||||
const mapStateEmpty = !mapState || isEmpty(mapState);
|
||||
@@ -140,7 +145,7 @@ function MapSettings({
|
||||
name="gridX"
|
||||
value={`${(map && map.grid.size.x) || 0}`}
|
||||
onChange={handleGridSizeXChange}
|
||||
disabled={mapEmpty || map.type === "default"}
|
||||
disabled={mapEmpty}
|
||||
min={1}
|
||||
my={1}
|
||||
/>
|
||||
@@ -152,7 +157,7 @@ function MapSettings({
|
||||
name="gridY"
|
||||
value={`${(map && map.grid.size.y) || 0}`}
|
||||
onChange={handleGridSizeYChange}
|
||||
disabled={mapEmpty || map.type === "default"}
|
||||
disabled={mapEmpty}
|
||||
min={1}
|
||||
my={1}
|
||||
/>
|
||||
@@ -164,176 +169,146 @@ function MapSettings({
|
||||
name="name"
|
||||
value={(map && map.name) || ""}
|
||||
onChange={(e) => onSettingsChange("name", e.target.value)}
|
||||
disabled={mapEmpty || map.type === "default"}
|
||||
disabled={mapEmpty}
|
||||
my={1}
|
||||
/>
|
||||
</Box>
|
||||
{showMore && (
|
||||
<>
|
||||
<Flex
|
||||
mt={2}
|
||||
mb={mapEmpty || map.type === "default" ? 2 : 0}
|
||||
sx={{ flexDirection: "column" }}
|
||||
>
|
||||
<Flex sx={{ alignItems: "flex-end" }}>
|
||||
<Box mb={1} sx={{ width: "50%" }}>
|
||||
<Label mb={1}>Grid Type</Label>
|
||||
<Select
|
||||
isDisabled={mapEmpty || map.type === "default"}
|
||||
options={gridTypeSettings}
|
||||
value={
|
||||
!mapEmpty &&
|
||||
gridTypeSettings.find((s) => s.value === map.grid.type)
|
||||
}
|
||||
onChange={handleGridTypeChange}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</Box>
|
||||
<Flex sx={{ width: "50%", flexDirection: "column" }} ml={2}>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={!mapEmpty && map.showGrid}
|
||||
disabled={mapEmpty || map.type === "default"}
|
||||
onChange={(e) =>
|
||||
onSettingsChange("showGrid", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Draw Grid
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={!mapEmpty && map.snapToGrid}
|
||||
disabled={mapEmpty || map.type === "default"}
|
||||
onChange={(e) =>
|
||||
onSettingsChange("snapToGrid", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Snap to Grid
|
||||
</Label>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex sx={{ alignItems: "flex-end" }}>
|
||||
<Box my={2} sx={{ width: "50%" }}>
|
||||
<Label mb={1}>Grid Measurement</Label>
|
||||
<Select
|
||||
isDisabled={mapEmpty || map.type === "default"}
|
||||
options={
|
||||
map && map.grid.type === "square"
|
||||
? gridSquareMeasurementTypeSettings
|
||||
: gridHexMeasurementTypeSettings
|
||||
}
|
||||
value={
|
||||
!mapEmpty &&
|
||||
gridSquareMeasurementTypeSettings.find(
|
||||
(s) => s.value === map.grid.measurement.type
|
||||
)
|
||||
}
|
||||
onChange={handleGridMeasurementTypeChange}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</Box>
|
||||
<Box mb={1} mx={2} sx={{ flexGrow: 1 }}>
|
||||
<Label htmlFor="gridMeasurementScale">Grid Scale</Label>
|
||||
<Input
|
||||
name="gridMeasurementScale"
|
||||
value={`${map && map.grid.measurement.scale}`}
|
||||
onChange={handleGridMeasurementScaleChange}
|
||||
disabled={mapEmpty || map.type === "default"}
|
||||
min={1}
|
||||
my={1}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{!mapEmpty && map.type !== "default" && (
|
||||
<Flex my={2} sx={{ alignItems: "center" }}>
|
||||
<Box mb={1} sx={{ width: "50%" }}>
|
||||
<Label mb={1}>Quality</Label>
|
||||
<Select
|
||||
options={qualitySettings}
|
||||
value={
|
||||
!mapEmpty &&
|
||||
qualitySettings.find((s) => s.value === map.quality)
|
||||
}
|
||||
isDisabled={mapEmpty}
|
||||
onChange={(option) =>
|
||||
onSettingsChange("quality", option.value)
|
||||
}
|
||||
isOptionDisabled={(option) =>
|
||||
mapEmpty ||
|
||||
(option.value !== "original" &&
|
||||
!map.resolutions[option.value])
|
||||
}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</Box>
|
||||
<Label sx={{ width: "50%" }} ml={2}>
|
||||
Size: {getMapSize()}
|
||||
</Label>
|
||||
</Flex>
|
||||
)}
|
||||
<Divider fill />
|
||||
<Box my={2} sx={{ flexGrow: 1 }}>
|
||||
<Label>Allow Others to Edit</Label>
|
||||
<Flex my={1}>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={!mapStateEmpty && mapState.editFlags.includes("fog")}
|
||||
disabled={mapStateEmpty}
|
||||
onChange={(e) => handleFlagChange(e, "fog")}
|
||||
/>
|
||||
Fog
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={
|
||||
!mapStateEmpty && mapState.editFlags.includes("drawing")
|
||||
}
|
||||
disabled={mapStateEmpty}
|
||||
onChange={(e) => handleFlagChange(e, "drawing")}
|
||||
/>
|
||||
Drawings
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={
|
||||
!mapStateEmpty && mapState.editFlags.includes("tokens")
|
||||
}
|
||||
disabled={mapStateEmpty}
|
||||
onChange={(e) => handleFlagChange(e, "tokens")}
|
||||
/>
|
||||
Tokens
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={
|
||||
!mapStateEmpty && mapState.editFlags.includes("notes")
|
||||
}
|
||||
disabled={mapStateEmpty}
|
||||
onChange={(e) => handleFlagChange(e, "notes")}
|
||||
/>
|
||||
Notes
|
||||
</Label>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onShowMoreChange(!showMore);
|
||||
}}
|
||||
sx={{
|
||||
transform: `rotate(${showMore ? "180deg" : "0"})`,
|
||||
alignSelf: "center",
|
||||
}}
|
||||
aria-label={showMore ? "Show Less" : "Show More"}
|
||||
title={showMore ? "Show Less" : "Show More"}
|
||||
<Flex
|
||||
mt={2}
|
||||
mb={mapEmpty || map.type === "default" ? 2 : 0}
|
||||
sx={{ flexDirection: "column" }}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
<Flex sx={{ alignItems: "flex-end" }}>
|
||||
<Box sx={{ width: "50%" }}>
|
||||
<Label>Grid Type</Label>
|
||||
<Select
|
||||
isDisabled={mapEmpty}
|
||||
options={gridTypeSettings}
|
||||
value={
|
||||
!mapEmpty &&
|
||||
gridTypeSettings.find((s) => s.value === map.grid.type)
|
||||
}
|
||||
onChange={handleGridTypeChange}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</Box>
|
||||
<Flex sx={{ flexGrow: 1, flexDirection: "column" }} ml={2}>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={!mapEmpty && map.showGrid}
|
||||
disabled={mapEmpty}
|
||||
onChange={(e) => onSettingsChange("showGrid", e.target.checked)}
|
||||
/>
|
||||
Draw Grid
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={!mapEmpty && map.snapToGrid}
|
||||
disabled={mapEmpty}
|
||||
onChange={(e) =>
|
||||
onSettingsChange("snapToGrid", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Snap to Grid
|
||||
</Label>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex sx={{ alignItems: "flex-end" }}>
|
||||
<Box my={2} sx={{ width: "50%" }}>
|
||||
<Label>Grid Measurement</Label>
|
||||
<Select
|
||||
isDisabled={mapEmpty}
|
||||
options={
|
||||
map && map.grid.type === "square"
|
||||
? gridSquareMeasurementTypeSettings
|
||||
: gridHexMeasurementTypeSettings
|
||||
}
|
||||
value={
|
||||
!mapEmpty &&
|
||||
gridSquareMeasurementTypeSettings.find(
|
||||
(s) => s.value === map.grid.measurement.type
|
||||
)
|
||||
}
|
||||
onChange={handleGridMeasurementTypeChange}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</Box>
|
||||
<Box m={2} mr={0} sx={{ flexGrow: 1 }}>
|
||||
<Label htmlFor="gridMeasurementScale">Grid Scale</Label>
|
||||
<Input
|
||||
name="gridMeasurementScale"
|
||||
value={`${map && map.grid.measurement.scale}`}
|
||||
onChange={handleGridMeasurementScaleChange}
|
||||
disabled={mapEmpty}
|
||||
min={1}
|
||||
my={1}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{!mapEmpty && map.type !== "default" && (
|
||||
<Flex my={2} sx={{ alignItems: "center" }}>
|
||||
<Box mb={1} sx={{ width: "50%" }}>
|
||||
<Label>Quality</Label>
|
||||
<Select
|
||||
options={qualitySettings}
|
||||
value={
|
||||
!mapEmpty &&
|
||||
qualitySettings.find((s) => s.value === map.quality)
|
||||
}
|
||||
isDisabled={mapEmpty}
|
||||
onChange={(option) => onSettingsChange("quality", option.value)}
|
||||
isOptionDisabled={(option) =>
|
||||
mapEmpty ||
|
||||
(option.value !== "original" && !map.resolutions[option.value])
|
||||
}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</Box>
|
||||
<Label sx={{ width: "50%" }} ml={2}>
|
||||
Size: {mapSize > 0 && `${mapSize}MB`}
|
||||
</Label>
|
||||
</Flex>
|
||||
)}
|
||||
<Divider fill />
|
||||
<Box my={2} sx={{ flexGrow: 1 }}>
|
||||
<Label>Allow Others to Edit</Label>
|
||||
<Flex my={1}>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={!mapStateEmpty && mapState.editFlags.includes("fog")}
|
||||
disabled={mapStateEmpty}
|
||||
onChange={(e) => handleFlagChange(e, "fog")}
|
||||
/>
|
||||
Fog
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={!mapStateEmpty && mapState.editFlags.includes("drawing")}
|
||||
disabled={mapStateEmpty}
|
||||
onChange={(e) => handleFlagChange(e, "drawing")}
|
||||
/>
|
||||
Drawings
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={!mapStateEmpty && mapState.editFlags.includes("tokens")}
|
||||
disabled={mapStateEmpty}
|
||||
onChange={(e) => handleFlagChange(e, "tokens")}
|
||||
/>
|
||||
Tokens
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={!mapStateEmpty && mapState.editFlags.includes("notes")}
|
||||
disabled={mapStateEmpty}
|
||||
onChange={(e) => handleFlagChange(e, "notes")}
|
||||
/>
|
||||
Notes
|
||||
</Label>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function MapTest() {}
|
||||
|
||||
export default MapTest;
|
||||
@@ -1,40 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
import Tile from "../Tile";
|
||||
|
||||
import { useImageSource } from "../../contexts/ImageSourceContext";
|
||||
import { mapSources as defaultMapSources, unknownSource } from "../../maps";
|
||||
import Tile from "../tile/Tile";
|
||||
import MapImage from "./MapImage";
|
||||
|
||||
function MapTile({
|
||||
map,
|
||||
isSelected,
|
||||
onMapSelect,
|
||||
onMapEdit,
|
||||
onDone,
|
||||
size,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDoubleClick,
|
||||
canEdit,
|
||||
badges,
|
||||
}) {
|
||||
const mapSource = useImageSource(
|
||||
map,
|
||||
defaultMapSources,
|
||||
unknownSource,
|
||||
map.type === "file"
|
||||
);
|
||||
|
||||
return (
|
||||
<Tile
|
||||
src={mapSource}
|
||||
title={map.name}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => onMapSelect(map)}
|
||||
onEdit={() => onMapEdit(map.id)}
|
||||
onDoubleClick={() => canEdit && onDone()}
|
||||
size={size}
|
||||
onSelect={() => onSelect(map.id)}
|
||||
onEdit={() => onEdit(map.id)}
|
||||
onDoubleClick={() => canEdit && onDoubleClick()}
|
||||
canEdit={canEdit}
|
||||
badges={badges}
|
||||
editTitle="Edit Map"
|
||||
/>
|
||||
>
|
||||
<MapImage map={map} />
|
||||
</Tile>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
41
src/components/map/MapTileGroup.js
Normal file
41
src/components/map/MapTileGroup.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { Grid } from "theme-ui";
|
||||
|
||||
import Tile from "../tile/Tile";
|
||||
import MapImage from "./MapImage";
|
||||
|
||||
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
|
||||
|
||||
function MapTileGroup({ group, maps, isSelected, onSelect, onDoubleClick }) {
|
||||
const layout = useResponsiveLayout();
|
||||
|
||||
return (
|
||||
<Tile
|
||||
title={group.name}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => onSelect(group.id)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<Grid
|
||||
columns={`repeat(${layout.groupGridColumns}, 1fr)`}
|
||||
p={2}
|
||||
sx={{
|
||||
gridGap: 2,
|
||||
gridTemplateRows: `repeat(${layout.groupGridColumns}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{maps
|
||||
.slice(0, layout.groupGridColumns * layout.groupGridColumns)
|
||||
.map((map) => (
|
||||
<MapImage
|
||||
sx={{ borderRadius: "8px" }}
|
||||
map={map}
|
||||
key={`${map.id}-group-tile`}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</Tile>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapTileGroup;
|
||||
@@ -1,179 +1,68 @@
|
||||
import React from "react";
|
||||
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
|
||||
import SimpleBar from "simplebar-react";
|
||||
import Case from "case";
|
||||
|
||||
import RemoveMapIcon from "../../icons/RemoveMapIcon";
|
||||
import ResetMapIcon from "../../icons/ResetMapIcon";
|
||||
import GroupIcon from "../../icons/GroupIcon";
|
||||
|
||||
import MapTile from "./MapTile";
|
||||
import Link from "../Link";
|
||||
import FilterBar from "../FilterBar";
|
||||
import MapTileGroup from "./MapTileGroup";
|
||||
|
||||
import { useDatabase } from "../../contexts/DatabaseContext";
|
||||
import SortableTiles from "../tile/SortableTiles";
|
||||
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
|
||||
|
||||
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
|
||||
import { getGroupItems } from "../../helpers/group";
|
||||
|
||||
function MapTiles({
|
||||
maps,
|
||||
groups,
|
||||
selectedMaps,
|
||||
selectedMapStates,
|
||||
onMapSelect,
|
||||
onMapsRemove,
|
||||
onMapsReset,
|
||||
onMapAdd,
|
||||
onMapEdit,
|
||||
onDone,
|
||||
selectMode,
|
||||
onSelectModeChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
onMapsGroup,
|
||||
}) {
|
||||
const { databaseStatus } = useDatabase();
|
||||
const layout = useResponsiveLayout();
|
||||
import { useGroup } from "../../contexts/GroupContext";
|
||||
|
||||
let hasMapState = false;
|
||||
for (let state of selectedMapStates) {
|
||||
if (
|
||||
Object.values(state.tokens).length > 0 ||
|
||||
Object.values(state.drawShapes).length > 0 ||
|
||||
Object.values(state.fogShapes).length > 0 ||
|
||||
Object.values(state.notes).length > 0
|
||||
) {
|
||||
hasMapState = true;
|
||||
break;
|
||||
function MapTiles({ mapsById, onMapEdit, onMapSelect, subgroup }) {
|
||||
const {
|
||||
selectedGroupIds,
|
||||
selectMode,
|
||||
onGroupOpen,
|
||||
onGroupSelect,
|
||||
} = useGroup();
|
||||
|
||||
function renderTile(group) {
|
||||
if (group.type === "item") {
|
||||
const map = mapsById[group.id];
|
||||
if (map) {
|
||||
const isSelected = selectedGroupIds.includes(group.id);
|
||||
const canEdit =
|
||||
isSelected &&
|
||||
selectMode === "single" &&
|
||||
selectedGroupIds.length === 1;
|
||||
return (
|
||||
<MapTile
|
||||
key={map.id}
|
||||
map={map}
|
||||
isSelected={isSelected}
|
||||
onSelect={onGroupSelect}
|
||||
onEdit={onMapEdit}
|
||||
onDoubleClick={() => canEdit && onMapSelect(group.id)}
|
||||
canEdit={canEdit}
|
||||
badges={[`${map.grid.size.x}x${map.grid.size.y}`]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const isSelected = selectedGroupIds.includes(group.id);
|
||||
const items = getGroupItems(group);
|
||||
const canOpen =
|
||||
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
|
||||
return (
|
||||
<MapTileGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
maps={items.map((item) => mapsById[item.id])}
|
||||
isSelected={isSelected}
|
||||
onSelect={onGroupSelect}
|
||||
onDoubleClick={() => canOpen && onGroupOpen(group.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let hasSelectedDefaultMap = selectedMaps.some(
|
||||
(map) => map.type === "default"
|
||||
);
|
||||
|
||||
function mapToTile(map) {
|
||||
const isSelected = selectedMaps.includes(map);
|
||||
return (
|
||||
<MapTile
|
||||
key={map.id}
|
||||
map={map}
|
||||
isSelected={isSelected}
|
||||
onMapSelect={onMapSelect}
|
||||
onMapEdit={onMapEdit}
|
||||
onDone={onDone}
|
||||
size={layout.tileSize}
|
||||
canEdit={
|
||||
isSelected && selectMode === "single" && selectedMaps.length === 1
|
||||
}
|
||||
badges={[`${map.grid.size.x}x${map.grid.size.y}`]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const multipleSelected = selectedMaps.length > 1;
|
||||
|
||||
return (
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<FilterBar
|
||||
onFocus={() => onMapSelect()}
|
||||
search={search}
|
||||
onSearchChange={onSearchChange}
|
||||
selectMode={selectMode}
|
||||
onSelectModeChange={onSelectModeChange}
|
||||
onAdd={onMapAdd}
|
||||
addTitle="Add Map"
|
||||
/>
|
||||
<SimpleBar
|
||||
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
|
||||
>
|
||||
<Flex
|
||||
p={2}
|
||||
pb={4}
|
||||
pt={databaseStatus === "disabled" ? 4 : 2}
|
||||
bg="muted"
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
borderRadius: "4px",
|
||||
minHeight: layout.screenSize === "large" ? "600px" : "400px",
|
||||
alignContent: "flex-start",
|
||||
}}
|
||||
onClick={() => onMapSelect()}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<React.Fragment key={group}>
|
||||
<Label mx={1} mt={2}>
|
||||
{Case.capital(group)}
|
||||
</Label>
|
||||
{maps[group].map(mapToTile)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Flex>
|
||||
</SimpleBar>
|
||||
{databaseStatus === "disabled" && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "39px",
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
borderRadius: "2px",
|
||||
}}
|
||||
bg="highlight"
|
||||
p={1}
|
||||
>
|
||||
<Text as="p" variant="body2">
|
||||
Map saving is unavailable. See <Link to="/faq#saving">FAQ</Link> for
|
||||
more information.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{selectedMaps.length > 0 && (
|
||||
<Flex
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
bg="overlay"
|
||||
>
|
||||
<Close
|
||||
title="Clear Selection"
|
||||
aria-label="Clear Selection"
|
||||
onClick={() => onMapSelect()}
|
||||
/>
|
||||
<Flex>
|
||||
<IconButton
|
||||
aria-label={multipleSelected ? "Group Maps" : "Group Map"}
|
||||
title={multipleSelected ? "Group Maps" : "Group Map"}
|
||||
onClick={() => onMapsGroup()}
|
||||
disabled={hasSelectedDefaultMap}
|
||||
>
|
||||
<GroupIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={multipleSelected ? "Reset Maps" : "Reset Map"}
|
||||
title={multipleSelected ? "Reset Maps" : "Reset Map"}
|
||||
onClick={() => onMapsReset()}
|
||||
disabled={!hasMapState}
|
||||
>
|
||||
<ResetMapIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={multipleSelected ? "Remove Maps" : "Remove Map"}
|
||||
title={multipleSelected ? "Remove Maps" : "Remove Map"}
|
||||
onClick={() => onMapsRemove()}
|
||||
disabled={hasSelectedDefaultMap}
|
||||
>
|
||||
<RemoveMapIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
<>
|
||||
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
|
||||
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Image as KonvaImage, Group } from "react-konva";
|
||||
import { useSpring, animated } from "react-spring/konva";
|
||||
import useImage from "use-image";
|
||||
import Konva from "konva";
|
||||
|
||||
import useDebounce from "../../hooks/useDebounce";
|
||||
import usePrevious from "../../hooks/usePrevious";
|
||||
import useGridSnapping from "../../hooks/useGridSnapping";
|
||||
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useUserId } from "../../contexts/UserIdContext";
|
||||
import {
|
||||
useSetPreventMapInteraction,
|
||||
useMapWidth,
|
||||
useMapHeight,
|
||||
useDebouncedStageScale,
|
||||
} from "../../contexts/MapInteractionContext";
|
||||
import { useGridCellPixelSize } from "../../contexts/GridContext";
|
||||
import { useImageSource } from "../../contexts/ImageSourceContext";
|
||||
import { useDataURL } from "../../contexts/AssetsContext";
|
||||
|
||||
import TokenStatus from "../token/TokenStatus";
|
||||
import TokenLabel from "../token/TokenLabel";
|
||||
import TokenOutline from "../token/TokenOutline";
|
||||
|
||||
import { tokenSources, unknownSource } from "../../tokens";
|
||||
import { Intersection, getScaledOutline } from "../../helpers/token";
|
||||
|
||||
import { tokenSources } from "../../tokens";
|
||||
|
||||
function MapToken({
|
||||
token,
|
||||
tokenState,
|
||||
onTokenStateChange,
|
||||
onTokenMenuOpen,
|
||||
@@ -34,34 +33,31 @@ function MapToken({
|
||||
fadeOnHover,
|
||||
map,
|
||||
}) {
|
||||
const { userId } = useAuth();
|
||||
const userId = useUserId();
|
||||
|
||||
const stageScale = useDebouncedStageScale();
|
||||
const mapWidth = useMapWidth();
|
||||
const mapHeight = useMapHeight();
|
||||
const setPreventMapInteraction = useSetPreventMapInteraction();
|
||||
|
||||
const gridCellPixelSize = useGridCellPixelSize();
|
||||
|
||||
const tokenSource = useImageSource(token, tokenSources, unknownSource);
|
||||
const [tokenSourceImage, tokenSourceStatus] = useImage(tokenSource);
|
||||
const [tokenAspectRatio, setTokenAspectRatio] = useState(1);
|
||||
const tokenURL = useDataURL(tokenState, tokenSources);
|
||||
const [tokenImage] = useImage(tokenURL);
|
||||
|
||||
useEffect(() => {
|
||||
if (tokenSourceImage) {
|
||||
setTokenAspectRatio(tokenSourceImage.width / tokenSourceImage.height);
|
||||
}
|
||||
}, [tokenSourceImage]);
|
||||
const tokenAspectRatio = tokenState.width / tokenState.height;
|
||||
|
||||
const snapPositionToGrid = useGridSnapping();
|
||||
|
||||
function handleDragStart(event) {
|
||||
const tokenGroup = event.target;
|
||||
const tokenImage = imageRef.current;
|
||||
|
||||
if (token && token.category === "vehicle") {
|
||||
// Enable hit detection for .intersects() function
|
||||
Konva.hitOnDragEnabled = true;
|
||||
if (tokenState.category === "vehicle") {
|
||||
const tokenIntersection = new Intersection(
|
||||
getScaledOutline(tokenState, tokenWidth, tokenHeight),
|
||||
{ x: tokenX - tokenWidth / 2, y: tokenY - tokenHeight / 2 },
|
||||
{ x: tokenX, y: tokenY },
|
||||
tokenState.rotation
|
||||
);
|
||||
|
||||
// Find all other tokens on the map
|
||||
const layer = tokenGroup.getLayer();
|
||||
@@ -70,12 +66,7 @@ function MapToken({
|
||||
if (other === tokenGroup) {
|
||||
continue;
|
||||
}
|
||||
const otherRect = other.getClientRect();
|
||||
const otherCenter = {
|
||||
x: otherRect.x + otherRect.width / 2,
|
||||
y: otherRect.y + otherRect.height / 2,
|
||||
};
|
||||
if (tokenImage.intersects(otherCenter)) {
|
||||
if (tokenIntersection.intersects(other.position())) {
|
||||
// Save and restore token position after moving layer
|
||||
const position = other.absolutePosition();
|
||||
other.moveTo(tokenGroup);
|
||||
@@ -99,9 +90,7 @@ function MapToken({
|
||||
const tokenGroup = event.target;
|
||||
|
||||
const mountChanges = {};
|
||||
if (token && token.category === "vehicle") {
|
||||
Konva.hitOnDragEnabled = false;
|
||||
|
||||
if (tokenState.category === "vehicle") {
|
||||
const parent = tokenGroup.getParent();
|
||||
const mountedTokens = tokenGroup.find(".character");
|
||||
for (let mountedToken of mountedTokens) {
|
||||
@@ -185,33 +174,6 @@ function MapToken({
|
||||
const tokenWidth = minCellSize * tokenState.size;
|
||||
const tokenHeight = (minCellSize / tokenAspectRatio) * tokenState.size;
|
||||
|
||||
const debouncedStageScale = useDebounce(stageScale, 50);
|
||||
const imageRef = useRef();
|
||||
useEffect(() => {
|
||||
const image = imageRef.current;
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = image.getCanvas();
|
||||
const pixelRatio = canvas.pixelRatio || 1;
|
||||
|
||||
if (tokenSourceStatus === "loaded" && tokenWidth > 0 && tokenHeight > 0) {
|
||||
const maxImageSize = token ? Math.max(token.width, token.height) : 512; // Default to 512px
|
||||
const maxTokenSize = Math.max(tokenWidth, tokenHeight);
|
||||
// Constrain image buffer to original image size
|
||||
const maxRatio = maxImageSize / maxTokenSize;
|
||||
|
||||
image.cache({
|
||||
pixelRatio: Math.min(
|
||||
Math.max(debouncedStageScale * pixelRatio, 1),
|
||||
maxRatio
|
||||
),
|
||||
});
|
||||
image.drawHitFromCache();
|
||||
}
|
||||
}, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus, token]);
|
||||
|
||||
// Animate to new token positions if edited by others
|
||||
const tokenX = tokenState.x * mapWidth;
|
||||
const tokenY = tokenState.y * mapHeight;
|
||||
@@ -232,8 +194,8 @@ function MapToken({
|
||||
|
||||
// Token name is used by on click to find whether a token is a vehicle or prop
|
||||
let tokenName = "";
|
||||
if (token) {
|
||||
tokenName = token.category;
|
||||
if (tokenState) {
|
||||
tokenName = tokenState.category;
|
||||
}
|
||||
if (tokenState && tokenState.locked) {
|
||||
tokenName = tokenName + "-locked";
|
||||
@@ -260,28 +222,46 @@ function MapToken({
|
||||
name={tokenName}
|
||||
id={tokenState.id}
|
||||
>
|
||||
<KonvaImage
|
||||
ref={imageRef}
|
||||
<Group
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
x={0}
|
||||
y={0}
|
||||
image={tokenSourceImage}
|
||||
rotation={tokenState.rotation}
|
||||
offsetX={tokenWidth / 2}
|
||||
offsetY={tokenHeight / 2}
|
||||
>
|
||||
<TokenOutline
|
||||
outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)}
|
||||
hidden={!!tokenImage}
|
||||
/>
|
||||
</Group>
|
||||
<KonvaImage
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
x={0}
|
||||
y={0}
|
||||
image={tokenImage}
|
||||
rotation={tokenState.rotation}
|
||||
offsetX={tokenWidth / 2}
|
||||
offsetY={tokenHeight / 2}
|
||||
hitFunc={() => {}}
|
||||
/>
|
||||
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
|
||||
<TokenStatus
|
||||
tokenState={tokenState}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
/>
|
||||
<TokenLabel
|
||||
tokenState={tokenState}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
/>
|
||||
{tokenState.statuses?.length > 0 ? (
|
||||
<TokenStatus
|
||||
tokenState={tokenState}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
/>
|
||||
) : null}
|
||||
{tokenState.label ? (
|
||||
<TokenLabel
|
||||
tokenState={tokenState}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</animated.Group>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { Group } from "react-konva";
|
||||
|
||||
import MapToken from "./MapToken";
|
||||
|
||||
import { useTokenData } from "../../contexts/TokenDataContext";
|
||||
|
||||
function MapTokens({
|
||||
map,
|
||||
mapState,
|
||||
@@ -15,31 +13,6 @@ function MapTokens({
|
||||
selectedToolId,
|
||||
disabledTokens,
|
||||
}) {
|
||||
const { tokensById, loadTokens } = useTokenData();
|
||||
|
||||
// Ensure tokens files have been loaded into the token data
|
||||
useEffect(() => {
|
||||
async function loadFileTokens() {
|
||||
const tokenIds = new Set(
|
||||
Object.values(mapState.tokens).map((state) => state.tokenId)
|
||||
);
|
||||
const tokensToLoad = [];
|
||||
for (let tokenId of tokenIds) {
|
||||
const token = tokensById[tokenId];
|
||||
if (token && token.type === "file" && !token.file) {
|
||||
tokensToLoad.push(tokenId);
|
||||
}
|
||||
}
|
||||
if (tokensToLoad.length > 0) {
|
||||
await loadTokens(tokensToLoad);
|
||||
}
|
||||
}
|
||||
|
||||
if (mapState) {
|
||||
loadFileTokens();
|
||||
}
|
||||
}, [mapState, tokensById, loadTokens]);
|
||||
|
||||
function getMapTokenCategoryWeight(category) {
|
||||
switch (category) {
|
||||
case "character":
|
||||
@@ -55,38 +28,28 @@ function MapTokens({
|
||||
|
||||
// Sort so vehicles render below other tokens
|
||||
function sortMapTokenStates(a, b, tokenDraggingOptions) {
|
||||
const tokenA = tokensById[a.tokenId];
|
||||
const tokenB = tokensById[b.tokenId];
|
||||
if (tokenA && tokenB) {
|
||||
// If categories are different sort in order "prop", "vehicle", "character"
|
||||
if (tokenB.category !== tokenA.category) {
|
||||
const aWeight = getMapTokenCategoryWeight(tokenA.category);
|
||||
const bWeight = getMapTokenCategoryWeight(tokenB.category);
|
||||
return bWeight - aWeight;
|
||||
} else if (
|
||||
tokenDraggingOptions &&
|
||||
tokenDraggingOptions.dragging &&
|
||||
tokenDraggingOptions.tokenState.id === a.id
|
||||
) {
|
||||
// If dragging token a move above
|
||||
return 1;
|
||||
} else if (
|
||||
tokenDraggingOptions &&
|
||||
tokenDraggingOptions.dragging &&
|
||||
tokenDraggingOptions.tokenState.id === b.id
|
||||
) {
|
||||
// If dragging token b move above
|
||||
return -1;
|
||||
} else {
|
||||
// Else sort so last modified is on top
|
||||
return a.lastModified - b.lastModified;
|
||||
}
|
||||
} else if (tokenA) {
|
||||
// If categories are different sort in order "prop", "vehicle", "character"
|
||||
if (b.category !== a.category) {
|
||||
const aWeight = getMapTokenCategoryWeight(a.category);
|
||||
const bWeight = getMapTokenCategoryWeight(b.category);
|
||||
return bWeight - aWeight;
|
||||
} else if (
|
||||
tokenDraggingOptions &&
|
||||
tokenDraggingOptions.dragging &&
|
||||
tokenDraggingOptions.tokenState.id === a.id
|
||||
) {
|
||||
// If dragging token a move above
|
||||
return 1;
|
||||
} else if (tokenB) {
|
||||
} else if (
|
||||
tokenDraggingOptions &&
|
||||
tokenDraggingOptions.dragging &&
|
||||
tokenDraggingOptions.tokenState.id === b.id
|
||||
) {
|
||||
// If dragging token b move above
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
// Else sort so last modified is on top
|
||||
return a.lastModified - b.lastModified;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +60,6 @@ function MapTokens({
|
||||
.map((tokenState) => (
|
||||
<MapToken
|
||||
key={tokenState.id}
|
||||
token={tokensById[tokenState.tokenId]}
|
||||
tokenState={tokenState}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
onTokenMenuOpen={handleTokenMenuOpen}
|
||||
|
||||
@@ -5,7 +5,7 @@ import SelectMapModal from "../../modals/SelectMapModal";
|
||||
import SelectMapIcon from "../../icons/SelectMapIcon";
|
||||
|
||||
import { useMapData } from "../../contexts/MapDataContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useUserId } from "../../contexts/UserIdContext";
|
||||
|
||||
function SelectMapButton({
|
||||
onMapChange,
|
||||
@@ -17,7 +17,7 @@ function SelectMapButton({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const { updateMapState } = useMapData();
|
||||
const { userId } = useAuth();
|
||||
const userId = useUserId();
|
||||
function openModal() {
|
||||
if (currentMapState && currentMap && currentMap.owner === userId) {
|
||||
updateMapState(currentMapState.mapId, currentMapState);
|
||||
|
||||
32
src/components/map/SelectMapSelectButton.js
Normal file
32
src/components/map/SelectMapSelectButton.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { Button } from "theme-ui";
|
||||
|
||||
import { useGroup } from "../../contexts/GroupContext";
|
||||
|
||||
import { findGroup } from "../../helpers/group";
|
||||
|
||||
function SelectMapSelectButton({ onMapSelect, disabled }) {
|
||||
const { activeGroups, selectedGroupIds } = useGroup();
|
||||
|
||||
function handleSelectClick() {
|
||||
if (selectedGroupIds.length === 1) {
|
||||
const group = findGroup(activeGroups, selectedGroupIds[0]);
|
||||
if (group && group.type === "item") {
|
||||
onMapSelect(group.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={disabled || selectedGroupIds.length > 1}
|
||||
onClick={handleSelectClick}
|
||||
mt={2}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectMapSelectButton;
|
||||
Reference in New Issue
Block a user