Added separate edit map modal and basic map viewer for it

This commit is contained in:
Mitchell McCaffrey 2020-09-24 16:54:33 +10:00
parent 2c0a01b66c
commit 78c86e6194
7 changed files with 401 additions and 198 deletions

View File

@ -0,0 +1,184 @@
import React, { useState, useRef, useEffect } from "react";
import { Box } from "theme-ui";
import { Stage, Layer, Image } from "react-konva";
import ReactResizeDetector from "react-resize-detector";
import useImage from "use-image";
import { useGesture } from "react-use-gesture";
import normalizeWheel from "normalize-wheel";
import useDataSource from "../../helpers/useDataSource";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import { mapSources as defaultMapSources } from "../../maps";
const wheelZoomSpeed = -0.001;
const touchZoomSpeed = 0.005;
const minZoom = 0.1;
const maxZoom = 5;
function MapEditor({ map }) {
const mapSource = useDataSource(map, defaultMapSources);
const [mapSourceImage] = useImage(mapSource);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
const [stageScale, setStageScale] = useState(1);
const stageRatio = stageWidth / stageHeight;
const mapRatio = map ? map.width / map.height : 1;
let mapWidth;
let mapHeight;
if (stageRatio > mapRatio) {
mapWidth = map ? stageHeight / (map.height / map.width) : stageWidth;
mapHeight = stageHeight;
} else {
mapWidth = stageWidth;
mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
}
const stageTranslateRef = useRef({ x: 0, y: 0 });
const isInteractingWithCanvas = useRef(false);
const pinchPreviousDistanceRef = useRef();
const pinchPreviousOriginRef = useRef();
const mapLayerRef = useRef();
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
}
useEffect(() => {
const layer = mapLayerRef.current;
const containerRect = containerRef.current.getBoundingClientRect();
if (map && layer) {
let newTranslate;
if (stageRatio > mapRatio) {
newTranslate = {
x: -(mapWidth - containerRect.width) / 2,
y: 0,
};
} else {
newTranslate = {
x: 0,
y: -(mapHeight - containerRect.height) / 2,
};
}
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
setStageScale(1);
}
}, [map, mapWidth, mapHeight, stageRatio, mapRatio]);
const bind = useGesture({
onWheelStart: ({ event }) => {
isInteractingWithCanvas.current =
event.target === mapLayerRef.current.getCanvas()._canvas;
},
onWheel: ({ event }) => {
event.persist();
const { pixelY } = normalizeWheel(event);
if (!isInteractingWithCanvas.current) {
return;
}
const newScale = Math.min(
Math.max(stageScale + pixelY * wheelZoomSpeed, minZoom),
maxZoom
);
setStageScale(newScale);
},
onPinch: ({ da, origin, first }) => {
const [distance] = da;
const [originX, originY] = origin;
if (first) {
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
}
// Apply scale
const distanceDelta = distance - pinchPreviousDistanceRef.current;
const originXDelta = originX - pinchPreviousOriginRef.current.x;
const originYDelta = originY - pinchPreviousOriginRef.current.y;
const newScale = Math.min(
Math.max(stageScale + distanceDelta * touchZoomSpeed, minZoom),
maxZoom
);
setStageScale(newScale);
// Apply translate
const stageTranslate = stageTranslateRef.current;
const layer = mapLayerRef.current;
const newTranslate = {
x: stageTranslate.x + originXDelta / newScale,
y: stageTranslate.y + originYDelta / newScale,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
},
onDragStart: ({ event }) => {
isInteractingWithCanvas.current =
event.target === mapLayerRef.current.getCanvas()._canvas;
},
onDrag: ({ delta, pinching }) => {
if (pinching || !isInteractingWithCanvas.current) {
return;
}
const [dx, dy] = delta;
const stageTranslate = stageTranslateRef.current;
const layer = mapLayerRef.current;
const newTranslate = {
x: stageTranslate.x + dx / stageScale,
y: stageTranslate.y + dy / stageScale,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
},
});
const containerRef = useRef();
usePreventOverscroll(containerRef);
return (
<Box
sx={{
width: "100%",
height: "300px",
cursor: "pointer",
touchAction: "none",
outline: "none",
}}
bg="muted"
ref={containerRef}
{...bind()}
>
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<Stage
width={stageWidth}
height={stageHeight}
scale={{ x: stageScale, y: stageScale }}
x={stageWidth / 2}
y={stageHeight / 2}
offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
>
<Layer ref={mapLayerRef}>
<Image image={mapSourceImage} width={mapWidth} height={mapHeight} />
</Layer>
</Stage>
</ReactResizeDetector>
</Box>
);
}
export default MapEditor;

View File

@ -81,18 +81,18 @@ function MapSettings({
/>
</Box>
</Flex>
<Box mt={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="name">Name</Label>
<Input
name="name"
value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={mapEmpty || map.type === "default"}
my={1}
/>
</Box>
{showMore && (
<>
<Box mt={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="name">Name</Label>
<Input
name="name"
value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={mapEmpty || map.type === "default"}
my={1}
/>
</Box>
<Flex
mt={2}
mb={mapEmpty || map.type === "default" ? 2 : 0}

View File

@ -1,23 +1,12 @@
import React, { useState } from "react";
import { Flex, Image as UIImage, IconButton, Box, Text } from "theme-ui";
import RemoveMapIcon from "../../icons/RemoveMapIcon";
import ResetMapIcon from "../../icons/ResetMapIcon";
import ExpandMoreDotIcon from "../../icons/ExpandMoreDotIcon";
import EditMapIcon from "../../icons/EditMapIcon";
import useDataSource from "../../helpers/useDataSource";
import { mapSources as defaultMapSources, unknownSource } from "../../maps";
function MapTile({
map,
mapState,
isSelected,
onMapSelect,
onMapRemove,
onMapReset,
onDone,
large,
}) {
function MapTile({ map, isSelected, onMapSelect, onMapEdit, onDone, large }) {
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
const isDefault = map.type === "default";
const mapSource = useDataSource(
@ -30,69 +19,6 @@ function MapTile({
unknownSource
);
const hasMapState =
mapState &&
(Object.values(mapState.tokens).length > 0 ||
mapState.mapDrawActions.length > 0 ||
mapState.fogDrawActions.length > 0);
const expandButton = (
<IconButton
aria-label="Show Map Actions"
title="Show Map Actions"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsTileMenuOpen(true);
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={2}
>
<ExpandMoreDotIcon />
</IconButton>
);
function removeButton(map) {
return (
<IconButton
aria-label="Remove Map"
title="Remove Map"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsTileMenuOpen(false);
onMapRemove(map.id);
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={2}
>
<RemoveMapIcon />
</IconButton>
);
}
function resetButton(map) {
return (
<IconButton
aria-label="Reset Map"
title="Reset Map"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsTileMenuOpen(false);
onMapReset(map.id);
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={2}
>
<ResetMapIcon />
</IconButton>
);
}
return (
<Flex
key={map.id}
@ -174,30 +100,22 @@ function MapTile({
{/* Show expand button only if both reset and remove is available */}
{isSelected && (
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
{isDefault && hasMapState && resetButton(map)}
{!isDefault && hasMapState && !isMapTileMenuOpen && expandButton}
{!isDefault && !hasMapState && removeButton(map)}
<IconButton
aria-label="Edit Map"
title="Edit Map"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onMapEdit(map.id);
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={2}
>
<EditMapIcon />
</IconButton>
</Box>
)}
{/* Tile menu for two actions */}
{!isDefault && isMapTileMenuOpen && isSelected && (
<Flex
sx={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
alignItems: "center",
justifyContent: "center",
}}
bg="muted"
onClick={() => setIsTileMenuOpen(false)}
>
{!isDefault && removeButton(map)}
{hasMapState && resetButton(map)}
</Flex>
)}
</Flex>
);
}

View File

@ -1,9 +1,11 @@
import React, { useContext } from "react";
import { Flex, Box, Text } from "theme-ui";
import { Flex, Box, Text, IconButton, Close } from "theme-ui";
import SimpleBar from "simplebar-react";
import { useMedia } from "react-media";
import AddIcon from "../../icons/AddIcon";
import RemoveMapIcon from "../../icons/RemoveMapIcon";
import ResetMapIcon from "../../icons/ResetMapIcon";
import MapTile from "./MapTile";
import Link from "../Link";
@ -15,19 +17,27 @@ function MapTiles({
selectedMap,
selectedMapState,
onMapSelect,
onMapAdd,
onMapRemove,
onMapReset,
onMapAdd,
onMapEdit,
onDone,
}) {
const { databaseStatus } = useContext(DatabaseContext);
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
const hasMapState =
selectedMapState &&
(Object.values(selectedMapState.tokens).length > 0 ||
selectedMapState.mapDrawActions.length > 0 ||
selectedMapState.fogDrawActions.length > 0);
return (
<Box sx={{ position: "relative" }}>
<SimpleBar style={{ maxHeight: "300px" }}>
<SimpleBar style={{ maxHeight: "400px" }}>
<Flex
p={2}
pb={4}
bg="muted"
sx={{
flexWrap: "wrap",
@ -79,14 +89,10 @@ function MapTiles({
return (
<MapTile
key={map.id}
// TODO: Move to selected map here and fix url error
// when done is clicked
map={map}
mapState={isSelected && selectedMapState}
isSelected={isSelected}
onMapSelect={onMapSelect}
onMapRemove={onMapRemove}
onMapReset={onMapReset}
onMapEdit={onMapEdit}
onDone={onDone}
large={isSmallScreen}
/>
@ -112,6 +118,41 @@ function MapTiles({
</Text>
</Box>
)}
{selectedMap && (
<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(null)}
/>
<Flex>
<IconButton
aria-label="Reset Map"
title="Reset Map"
onClick={() => onMapReset(selectedMap.id)}
disabled={!hasMapState}
>
<ResetMapIcon />
</IconButton>
<IconButton
aria-label="Remove Map"
title="Remove Map"
onClick={() => onMapRemove(selectedMap.id)}
>
<RemoveMapIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
);
}

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

@ -0,0 +1,18 @@
import React from "react";
function EditMapIcon() {
return (
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3 17.46v3.04c0 .28.22.5.5.5h3.04c.13 0 .26-.05.35-.15L17.81 9.94l-3.75-3.75L3.15 17.1c-.1.1-.15.22-.15.36zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
);
}
export default EditMapIcon;

105
src/modals/EditMapModal.js Normal file
View File

@ -0,0 +1,105 @@
import React, { useState, useContext } from "react";
import { Button, Flex, Label } from "theme-ui";
import Modal from "../components/Modal";
import MapSettings from "../components/map/MapSettings";
import MapEditor from "../components/map/MapEditor";
import MapDataContext from "../contexts/MapDataContext";
import { isEmpty } from "../helpers/shared";
function SelectMapModal({ isOpen, onDone, map, mapState }) {
const { updateMap, updateMapState } = useContext(MapDataContext);
function handleClose() {
onDone();
}
async function handleSave() {
await applyMapChanges();
onDone();
}
/**
* Map settings
*/
// Local cache of map setting changes
// Applied when done is clicked or map selection is changed
const [mapSettingChanges, setMapSettingChanges] = useState({});
const [mapStateSettingChanges, setMapStateSettingChanges] = useState({});
function handleMapSettingsChange(key, value) {
setMapSettingChanges((prevChanges) => ({
...prevChanges,
[key]: value,
lastModified: Date.now(),
}));
}
function handleMapStateSettingsChange(key, value) {
setMapStateSettingChanges((prevChanges) => ({
...prevChanges,
[key]: value,
}));
}
async function applyMapChanges() {
if (!isEmpty(mapSettingChanges) || !isEmpty(mapStateSettingChanges)) {
// Ensure grid values are positive
let verifiedChanges = { ...mapSettingChanges };
if ("gridX" in verifiedChanges) {
verifiedChanges.gridX = verifiedChanges.gridX || 1;
}
if ("gridY" in verifiedChanges) {
verifiedChanges.gridY = verifiedChanges.gridY || 1;
}
await updateMap(map.id, verifiedChanges);
await updateMapState(map.id, mapStateSettingChanges);
setMapSettingChanges({});
setMapStateSettingChanges({});
}
}
const selectedMapWithChanges = map && {
...map,
...mapSettingChanges,
};
const selectedMapStateWithChanges = mapState && {
...mapState,
...mapStateSettingChanges,
};
const [showMoreSettings, setShowMoreSettings] = useState(false);
return (
<Modal
isOpen={isOpen}
onRequestClose={handleClose}
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
>
<Flex
sx={{
flexDirection: "column",
}}
>
<Label pt={2} pb={1}>
Edit map
</Label>
<MapEditor map={selectedMapWithChanges} />
<MapSettings
map={selectedMapWithChanges}
mapState={selectedMapStateWithChanges}
onSettingsChange={handleMapSettingsChange}
onStateSettingsChange={handleMapStateSettingsChange}
showMore={showMoreSettings}
onShowMoreChange={setShowMoreSettings}
/>
<Button onClick={handleSave}>Save</Button>
</Flex>
</Modal>
);
}
export default SelectMapModal;

View File

@ -2,9 +2,10 @@ import React, { useRef, useState, useContext } from "react";
import { Button, Flex, Label } from "theme-ui";
import shortid from "shortid";
import EditMapModal from "./EditMapModal";
import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles";
import MapSettings from "../components/map/MapSettings";
import ImageDrop from "../components/ImageDrop";
import LoadingOverlay from "../components/LoadingOverlay";
@ -13,7 +14,6 @@ import blobToBuffer from "../helpers/blobToBuffer";
import MapDataContext from "../contexts/MapDataContext";
import AuthContext from "../contexts/AuthContext";
import { isEmpty } from "../helpers/shared";
import { resizeImage } from "../helpers/image";
const defaultMapSize = 22;
@ -49,7 +49,6 @@ function SelectMapModal({
removeMap,
resetMap,
updateMap,
updateMapState,
} = useContext(MapDataContext);
const [imageLoading, setImageLoading] = useState(false);
@ -62,6 +61,8 @@ function SelectMapModal({
(state) => state.mapId === selectedMapId
);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const fileInputRef = useRef();
async function handleImagesUpload(files) {
@ -175,8 +176,6 @@ function SelectMapModal({
async function handleMapRemove(id) {
await removeMap(id);
setMapSettingChanges({});
setMapStateSettingChanges({});
setSelectedMapId(null);
// Removed the map from the map screen if needed
if (currentMap && currentMap.id === selectedMapId) {
@ -185,7 +184,6 @@ function SelectMapModal({
}
async function handleMapSelect(map) {
await applyMapChanges();
if (map) {
setSelectedMapId(map.id);
} else {
@ -202,9 +200,6 @@ function SelectMapModal({
}
async function handleClose() {
if (selectedMapId) {
await applyMapChanges();
}
onDone();
}
@ -213,74 +208,16 @@ function SelectMapModal({
return;
}
if (selectedMapId) {
await applyMapChanges();
// Update last used for cache invalidation
const lastUsed = Date.now();
await updateMap(selectedMapId, { lastUsed });
onMapChange(
{ ...selectedMapWithChanges, lastUsed },
selectedMapStateWithChanges
);
onMapChange({ ...selectedMap, lastUsed }, selectedMapState);
} else {
onMapChange(null, null);
}
onDone();
}
/**
* Map settings
*/
const [showMoreSettings, setShowMoreSettings] = useState(false);
// Local cache of map setting changes
// Applied when done is clicked or map selection is changed
const [mapSettingChanges, setMapSettingChanges] = useState({});
const [mapStateSettingChanges, setMapStateSettingChanges] = useState({});
function handleMapSettingsChange(key, value) {
setMapSettingChanges((prevChanges) => ({
...prevChanges,
[key]: value,
lastModified: Date.now(),
}));
}
function handleMapStateSettingsChange(key, value) {
setMapStateSettingChanges((prevChanges) => ({
...prevChanges,
[key]: value,
}));
}
async function applyMapChanges() {
if (
selectedMapId &&
(!isEmpty(mapSettingChanges) || !isEmpty(mapStateSettingChanges))
) {
// Ensure grid values are positive
let verifiedChanges = { ...mapSettingChanges };
if ("gridX" in verifiedChanges) {
verifiedChanges.gridX = verifiedChanges.gridX || 1;
}
if ("gridY" in verifiedChanges) {
verifiedChanges.gridY = verifiedChanges.gridY || 1;
}
await updateMap(selectedMapId, verifiedChanges);
await updateMapState(selectedMapId, mapStateSettingChanges);
setMapSettingChanges({});
setMapStateSettingChanges({});
}
}
const selectedMapWithChanges = selectedMap && {
...selectedMap,
...mapSettingChanges,
};
const selectedMapStateWithChanges = selectedMapState && {
...selectedMapState,
...mapStateSettingChanges,
};
return (
<Modal
isOpen={isOpen}
@ -307,31 +244,31 @@ function SelectMapModal({
<MapTiles
maps={ownedMaps}
onMapAdd={openImageDialog}
onMapRemove={handleMapRemove}
selectedMap={selectedMapWithChanges}
selectedMapState={selectedMapStateWithChanges}
onMapSelect={handleMapSelect}
onMapEdit={() => setIsEditModalOpen(true)}
onMapReset={handleMapReset}
onMapRemove={handleMapRemove}
selectedMap={selectedMap}
selectedMapState={selectedMapState}
onMapSelect={handleMapSelect}
onDone={handleDone}
/>
<MapSettings
map={selectedMapWithChanges}
mapState={selectedMapStateWithChanges}
onSettingsChange={handleMapSettingsChange}
onStateSettingsChange={handleMapStateSettingsChange}
showMore={showMoreSettings}
onShowMoreChange={setShowMoreSettings}
/>
<Button
variant="primary"
disabled={imageLoading}
disabled={imageLoading || !selectedMapId}
onClick={handleDone}
mt={2}
>
Done
Select
</Button>
</Flex>
</ImageDrop>
{imageLoading && <LoadingOverlay bg="overlay" />}
<EditMapModal
isOpen={isEditModalOpen}
onDone={() => setIsEditModalOpen(false)}
map={selectedMap}
mapState={selectedMapState}
/>
</Modal>
);
}