diff --git a/src/components/map/MapEditor.js b/src/components/map/MapEditor.js new file mode 100644 index 0000000..74d977f --- /dev/null +++ b/src/components/map/MapEditor.js @@ -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 ( + + + + + + + + + + ); +} + +export default MapEditor; diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js index f843716..7d0b376 100644 --- a/src/components/map/MapSettings.js +++ b/src/components/map/MapSettings.js @@ -81,18 +81,18 @@ function MapSettings({ /> + + + onSettingsChange("name", e.target.value)} + disabled={mapEmpty || map.type === "default"} + my={1} + /> + {showMore && ( <> - - - onSettingsChange("name", e.target.value)} - disabled={mapEmpty || map.type === "default"} - my={1} - /> - 0 || - mapState.mapDrawActions.length > 0 || - mapState.fogDrawActions.length > 0); - - const expandButton = ( - { - e.preventDefault(); - e.stopPropagation(); - setIsTileMenuOpen(true); - }} - bg="overlay" - sx={{ borderRadius: "50%" }} - m={2} - > - - - ); - - function removeButton(map) { - return ( - { - e.preventDefault(); - e.stopPropagation(); - setIsTileMenuOpen(false); - onMapRemove(map.id); - }} - bg="overlay" - sx={{ borderRadius: "50%" }} - m={2} - > - - - ); - } - - function resetButton(map) { - return ( - { - e.preventDefault(); - e.stopPropagation(); - setIsTileMenuOpen(false); - onMapReset(map.id); - }} - bg="overlay" - sx={{ borderRadius: "50%" }} - m={2} - > - - - ); - } - return ( - {isDefault && hasMapState && resetButton(map)} - {!isDefault && hasMapState && !isMapTileMenuOpen && expandButton} - {!isDefault && !hasMapState && removeButton(map)} + { + e.preventDefault(); + e.stopPropagation(); + onMapEdit(map.id); + }} + bg="overlay" + sx={{ borderRadius: "50%" }} + m={2} + > + + )} - {/* Tile menu for two actions */} - {!isDefault && isMapTileMenuOpen && isSelected && ( - setIsTileMenuOpen(false)} - > - {!isDefault && removeButton(map)} - {hasMapState && resetButton(map)} - - )} ); } diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js index 6bec907..76131fd 100644 --- a/src/components/map/MapTiles.js +++ b/src/components/map/MapTiles.js @@ -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 ( - + @@ -112,6 +118,41 @@ function MapTiles({ )} + {selectedMap && ( + + onMapSelect(null)} + /> + + onMapReset(selectedMap.id)} + disabled={!hasMapState} + > + + + onMapRemove(selectedMap.id)} + > + + + + + )} ); } diff --git a/src/icons/EditMapIcon.js b/src/icons/EditMapIcon.js new file mode 100644 index 0000000..6e43c62 --- /dev/null +++ b/src/icons/EditMapIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function EditMapIcon() { + return ( + + + + + ); +} + +export default EditMapIcon; diff --git a/src/modals/EditMapModal.js b/src/modals/EditMapModal.js new file mode 100644 index 0000000..7341529 --- /dev/null +++ b/src/modals/EditMapModal.js @@ -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 ( + + + + + + + + + ); +} + +export default SelectMapModal; diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index f05d63a..9169a91 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -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 ( setIsEditModalOpen(true)} onMapReset={handleMapReset} + onMapRemove={handleMapRemove} + selectedMap={selectedMap} + selectedMapState={selectedMapState} + onMapSelect={handleMapSelect} onDone={handleDone} /> - {imageLoading && } + setIsEditModalOpen(false)} + map={selectedMap} + mapState={selectedMapState} + /> ); }