diff --git a/package.json b/package.json index 247f710..58e4641 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "owlbear-rodeo", - "version": "1.3.0", + "version": "1.3.1", "private": true, "dependencies": { "@msgpack/msgpack": "^1.12.1", diff --git a/src/components/Divider.js b/src/components/Divider.js index 853aa0c..0d51b0a 100644 --- a/src/components/Divider.js +++ b/src/components/Divider.js @@ -1,15 +1,15 @@ import React from "react"; import { Divider } from "theme-ui"; -function StyledDivider({ vertical, color }) { +function StyledDivider({ vertical, color, fill }) { return ( diff --git a/src/components/ImageDrop.js b/src/components/ImageDrop.js index da41987..8bd71d3 100644 --- a/src/components/ImageDrop.js +++ b/src/components/ImageDrop.js @@ -18,10 +18,14 @@ function ImageDrop({ onDrop, dropText, children }) { function handleImageDrop(event) { event.preventDefault(); event.stopPropagation(); - const file = event.dataTransfer.files[0]; - if (file && file.type.startsWith("image")) { - onDrop(file); + const files = event.dataTransfer.files; + let imageFiles = []; + for (let file of files) { + if (file.type.startsWith("image")) { + imageFiles.push(file); + } } + onDrop(imageFiles); setDragging(false); } diff --git a/src/components/map/Map.js b/src/components/map/Map.js index f44e5e1..38b54dc 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -6,6 +6,7 @@ import MapToken from "./MapToken"; import MapDrawing from "./MapDrawing"; import MapFog from "./MapFog"; import MapDice from "./MapDice"; +import MapGrid from "./MapGrid"; import TokenDataContext from "../../contexts/TokenDataContext"; import MapLoadingContext from "../../contexts/MapLoadingContext"; @@ -97,8 +98,8 @@ function Map({ onMapDraw({ type: "add", shapes: [shape] }); } - function handleMapShapeRemove(shapeId) { - onMapDraw({ type: "remove", shapeIds: [shapeId] }); + function handleMapShapesRemove(shapeIds) { + onMapDraw({ type: "remove", shapeIds }); } const [fogShapes, setFogShapes] = useState([]); @@ -107,12 +108,12 @@ function Map({ onFogDraw({ type: "add", shapes: [shape] }); } - function handleFogShapeRemove(shapeId) { - onFogDraw({ type: "remove", shapeIds: [shapeId] }); + function handleFogShapesRemove(shapeIds) { + onFogDraw({ type: "remove", shapeIds }); } - function handleFogShapeEdit(shape) { - onFogDraw({ type: "edit", shapes: [shape] }); + function handleFogShapesEdit(shapes) { + onFogDraw({ type: "edit", shapes }); } // Replay the draw actions and convert them to shapes for the map drawing @@ -174,7 +175,7 @@ function Map({ disabledSettings.shape.push("redo"); disabledSettings.erase.push("redo"); } - if (fogShapes.length === 0) { + if (!mapState || mapState.fogDrawActionIndex < 0) { disabledSettings.fog.push("undo"); } if ( @@ -275,7 +276,7 @@ function Map({ ); + const mapGrid = map && map.showGrid && ( + + ); + return ( + {mapGrid} {mapDrawing} {mapTokens} {mapFog} diff --git a/src/components/map/MapDrawing.js b/src/components/map/MapDrawing.js index 922b1e4..7901f1a 100644 --- a/src/components/map/MapDrawing.js +++ b/src/components/map/MapDrawing.js @@ -19,13 +19,15 @@ import useMapBrush from "../../helpers/useMapBrush"; function MapDrawing({ shapes, onShapeAdd, - onShapeRemove, + onShapesRemove, selectedToolId, selectedToolSettings, gridSize, }) { const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext); const [drawingShape, setDrawingShape] = useState(null); + const [isBrushDown, setIsBrushDown] = useState(false); + const [erasingShapes, setErasingShapes] = useState([]); const shouldHover = selectedToolId === "erase"; const isEditing = @@ -33,6 +35,14 @@ function MapDrawing({ selectedToolId === "shape" || selectedToolId === "erase"; + const handleBrushUp = useCallback(() => { + setIsBrushDown(false); + if (erasingShapes.length > 0) { + onShapesRemove(erasingShapes.map((shape) => shape.id)); + setErasingShapes([]); + } + }, [erasingShapes, onShapesRemove]); + const handleShapeDraw = useCallback( (brushState, mapBrushPosition) => { function startShape() { @@ -65,6 +75,7 @@ function MapDrawing({ ...commonShapeData, }); } + setIsBrushDown(true); } function continueShape() { @@ -119,6 +130,7 @@ function MapDrawing({ onShapeAdd(drawingShape); } setDrawingShape(null); + handleBrushUp(); } switch (brushState) { @@ -143,48 +155,27 @@ function MapDrawing({ onShapeAdd, shapes, drawingShape, + handleBrushUp, ] ); useMapBrush(isEditing, handleShapeDraw); - function handleShapeClick(_, shape) { - if (selectedToolId === "erase") { - onShapeRemove(shape.id); - } - } - - function handleShapeMouseOver(event, shape) { - if (shouldHover) { - const path = event.target; - const hoverColor = "#BB99FF"; - path.fill(hoverColor); - if (shape.type === "path") { - path.stroke(hoverColor); + function handleShapeOver(shape, isDown) { + if (shouldHover && isDown) { + if (erasingShapes.findIndex((s) => s.id === shape.id) === -1) { + setErasingShapes((prevShapes) => [...prevShapes, shape]); } - path.getLayer().draw(); - } - } - - function handleShapeMouseOut(event, shape) { - if (shouldHover) { - const path = event.target; - const color = colors[shape.color] || shape.color; - path.fill(color); - if (shape.type === "path") { - path.stroke(color); - } - path.getLayer().draw(); } } function renderShape(shape) { const defaultProps = { key: shape.id, - onMouseOver: (e) => handleShapeMouseOver(e, shape), - onMouseOut: (e) => handleShapeMouseOut(e, shape), - onClick: (e) => handleShapeClick(e, shape), - onTap: (e) => handleShapeClick(e, shape), + onMouseMove: () => handleShapeOver(shape, isBrushDown), + onTouchOver: () => handleShapeOver(shape, isBrushDown), + onMouseDown: () => handleShapeOver(shape, true), + onTouchStart: () => handleShapeOver(shape, true), fill: colors[shape.color] || shape.color, opacity: shape.blend ? 0.5 : 1, }; @@ -245,10 +236,19 @@ function MapDrawing({ } } + function renderErasingShape(shape) { + const eraseShape = { + ...shape, + color: "#BB99FF", + }; + return renderShape(eraseShape); + } + return ( {shapes.map(renderShape)} {drawingShape && renderShape(drawingShape)} + {erasingShapes.length > 0 && erasingShapes.map(renderErasingShape)} ); } diff --git a/src/components/map/MapFog.js b/src/components/map/MapFog.js index fbb8782..84dc929 100644 --- a/src/components/map/MapFog.js +++ b/src/components/map/MapFog.js @@ -20,14 +20,16 @@ import useMapBrush from "../../helpers/useMapBrush"; function MapFog({ shapes, onShapeAdd, - onShapeRemove, - onShapeEdit, + onShapesRemove, + onShapesEdit, selectedToolId, selectedToolSettings, gridSize, }) { const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext); const [drawingShape, setDrawingShape] = useState(null); + const [isBrushDown, setIsBrushDown] = useState(false); + const [editingShapes, setEditingShapes] = useState([]); const isEditing = selectedToolId === "fog"; const shouldHover = @@ -37,6 +39,20 @@ function MapFog({ const [patternImage] = useImage(diagonalPattern); + const handleBrushUp = useCallback(() => { + setIsBrushDown(false); + if (editingShapes.length > 0) { + if (selectedToolSettings.type === "remove") { + onShapesRemove(editingShapes.map((shape) => shape.id)); + } else if (selectedToolSettings.type === "toggle") { + onShapesEdit( + editingShapes.map((shape) => ({ ...shape, visible: !shape.visible })) + ); + } + setEditingShapes([]); + } + }, [editingShapes, onShapesRemove, onShapesEdit, selectedToolSettings]); + const handleShapeDraw = useCallback( (brushState, mapBrushPosition) => { function startShape() { @@ -58,6 +74,7 @@ function MapFog({ visible: true, }); } + setIsBrushDown(true); } function continueShape() { @@ -106,6 +123,7 @@ function MapFog({ } } setDrawingShape(null); + handleBrushUp(); } switch (brushState) { @@ -130,46 +148,17 @@ function MapFog({ onShapeAdd, shapes, drawingShape, + handleBrushUp, ] ); useMapBrush(isEditing, handleShapeDraw); - function handleShapeClick(_, shape) { - if (!isEditing) { - return; - } - - if (selectedToolSettings.type === "remove") { - onShapeRemove(shape.id); - } else if (selectedToolSettings.type === "toggle") { - onShapeEdit({ ...shape, visible: !shape.visible }); - } - } - - function handleShapeMouseOver(event, shape) { - if (shouldHover) { - const path = event.target; - if (shape.visible) { - const hoverColor = "#BB99FF"; - path.fill(hoverColor); - } else { - path.opacity(1); + function handleShapeOver(shape, isDown) { + if (shouldHover && isDown) { + if (editingShapes.findIndex((s) => s.id === shape.id) === -1) { + setEditingShapes((prevShapes) => [...prevShapes, shape]); } - path.getLayer().draw(); - } - } - - function handleShapeMouseOut(event, shape) { - if (shouldHover) { - const path = event.target; - if (shape.visible) { - const color = colors[shape.color] || shape.color; - path.fill(color); - } else { - path.opacity(0.5); - } - path.getLayer().draw(); } } @@ -177,10 +166,10 @@ function MapFog({ return ( handleShapeMouseOver(e, shape)} - onMouseOut={(e) => handleShapeMouseOut(e, shape)} - onClick={(e) => handleShapeClick(e, shape)} - onTap={(e) => handleShapeClick(e, shape)} + onMouseMove={() => handleShapeOver(shape, isBrushDown)} + onTouchOver={() => handleShapeOver(shape, isBrushDown)} + onMouseDown={() => handleShapeOver(shape, true)} + onTouchStart={() => handleShapeOver(shape, true)} points={shape.data.points.reduce( (acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight], [] @@ -203,10 +192,19 @@ function MapFog({ ); } + function renderEditingShape(shape) { + const editingShape = { + ...shape, + color: "#BB99FF", + }; + return renderShape(editingShape); + } + return ( {shapes.map(renderShape)} {drawingShape && renderShape(drawingShape)} + {editingShapes.length > 0 && editingShapes.map(renderEditingShape)} ); } diff --git a/src/components/map/MapGrid.js b/src/components/map/MapGrid.js new file mode 100644 index 0000000..06df57a --- /dev/null +++ b/src/components/map/MapGrid.js @@ -0,0 +1,93 @@ +import React, { useContext, useEffect, useState } from "react"; +import { Line, Group } from "react-konva"; +import useImage from "use-image"; + +import MapInteractionContext from "../../contexts/MapInteractionContext"; + +import useDataSource from "../../helpers/useDataSource"; +import { mapSources as defaultMapSources } from "../../maps"; + +import { getStrokeWidth } from "../../helpers/drawing"; + +const lightnessDetectionOffset = 0.1; + +function MapGrid({ map, gridSize }) { + const mapSource = useDataSource(map, defaultMapSources); + const [mapImage, mapLoadingStatus] = useImage(mapSource); + + const gridX = map && map.gridX; + const gridY = map && map.gridY; + + const { mapWidth, mapHeight } = useContext(MapInteractionContext); + + const lineSpacingX = mapWidth / gridX; + const lineSpacingY = mapHeight / gridY; + + const [isImageLight, setIsImageLight] = useState(true); + + // When the map changes find the average lightness of its pixels + useEffect(() => { + if (mapLoadingStatus === "loaded") { + const imageWidth = mapImage.width; + const imageHeight = mapImage.height; + let canvas = document.createElement("canvas"); + canvas.width = imageWidth; + canvas.height = imageHeight; + let context = canvas.getContext("2d"); + context.drawImage(mapImage, 0, 0); + const imageData = context.getImageData(0, 0, imageWidth, imageHeight); + + const data = imageData.data; + let lightPixels = 0; + let darkPixels = 0; + // Loop over every pixels rgba values + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + const max = Math.max(Math.max(r, g), b); + if (max < 128) { + darkPixels++; + } else { + lightPixels++; + } + } + + const norm = (lightPixels - darkPixels) / (imageWidth * imageHeight); + if (norm + lightnessDetectionOffset < 0) { + setIsImageLight(false); + } else { + setIsImageLight(true); + } + } + }, [mapImage, mapLoadingStatus]); + + const lines = []; + for (let x = 1; x < gridX; x++) { + lines.push( + + ); + } + for (let y = 1; y < gridY; y++) { + lines.push( + + ); + } + + return {lines}; +} + +export default MapGrid; diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js index dcefccb..1861bd4 100644 --- a/src/components/map/MapSettings.js +++ b/src/components/map/MapSettings.js @@ -3,6 +3,8 @@ import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui"; import ExpandMoreIcon from "../../icons/ExpandMoreIcon"; +import Divider from "../Divider"; + function MapSettings({ map, mapState, @@ -57,6 +59,27 @@ function MapSettings({ {showMore && ( <> + + onSettingsChange("name", e.target.value)} + disabled={!map || map.type === "default"} + my={1} + /> + + + + + + - - - onSettingsChange("name", e.target.value)} - disabled={!map || map.type === "default"} - my={1} - /> - )} { + return tx + .table("maps") + .toCollection() + .modify((map) => { + map.showGrid = false; + }); + }); } // Get the dexie database used in DatabaseContext diff --git a/src/docs/releaseNotes/v1.3.1.md b/src/docs/releaseNotes/v1.3.1.md new file mode 100644 index 0000000..e678133 --- /dev/null +++ b/src/docs/releaseNotes/v1.3.1.md @@ -0,0 +1,11 @@ +# v1.3.1 + +## Minor Changes + +- Fixed a bug where tokens that were placed on the map then removed from the token select screen could no longer be deleted from the map. +- Fixed a bug where fog drawing couldn't be undone if there the last fog shape was deleted. +- Added the ability to add multiple new maps or tokens at the same time. +- Added a Show Grid option for maps that will overlay a grid on the map. This can be useful for when you have a map with no grid or you want to verify your current grid settings. +- Added the ability to erase multiple shapes at a time by dragging over a shape with the eraser tool. This works for fog erase and toggle as well. + +[Reddit]() diff --git a/src/helpers/shared.js b/src/helpers/shared.js index 7552d7b..8f11569 100644 --- a/src/helpers/shared.js +++ b/src/helpers/shared.js @@ -39,3 +39,14 @@ export function toDegrees(angle) { export function lerp(a, b, alpha) { return a * (1 - alpha) + b * alpha; } + +// Console log an image +export function logImage(url, width, height) { + const style = [ + "font-size: 1px;", + `padding: ${height}px ${width}px;`, + `background: url(${url}) no-repeat;`, + "background-size: contain;", + ].join(" "); + console.log("%c ", style); +} diff --git a/src/index.css b/src/index.css index e679604..fb96b26 100644 --- a/src/index.css +++ b/src/index.css @@ -9,3 +9,7 @@ html { .simplebar-scrollbar:before { background: #fff; } + +input[type="checkbox"]:disabled ~ svg { + opacity: 0.1; +} diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index 91de48a..bebe250 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -17,6 +17,7 @@ const defaultMapProps = { // Grid type // TODO: add support for hex horizontal and hex vertical gridType: "grid", + showGrid: false, }; function SelectMapModal({ @@ -51,9 +52,17 @@ function SelectMapModal({ const fileInputRef = useRef(); - function handleImageUpload(file) { + async function handleImagesUpload(files) { + for (let file of files) { + await handleImageUpload(file); + } + // Set file input to null to allow adding the same image 2 times in a row + fileInputRef.current.value = null; + } + + async function handleImageUpload(file) { if (!file) { - return; + return Promise.reject(); } let fileGridX = defaultMapSize; let fileGridY = defaultMapSize; @@ -86,11 +95,13 @@ function SelectMapModal({ let image = new Image(); setImageLoading(true); - blobToBuffer(file).then((buffer) => { - // Copy file to avoid permissions issues - const blob = new Blob([buffer]); - // Create and load the image temporarily to get its dimensions - const url = URL.createObjectURL(blob); + const buffer = await blobToBuffer(file); + // Copy file to avoid permissions issues + const blob = new Blob([buffer]); + // Create and load the image temporarily to get its dimensions + const url = URL.createObjectURL(blob); + + return new Promise((resolve, reject) => { image.onload = function () { handleMapAdd({ // Save as a buffer to send with msgpack @@ -109,11 +120,10 @@ function SelectMapModal({ }); setImageLoading(false); URL.revokeObjectURL(url); + resolve(); }; + image.onerror = reject; image.src = url; - - // Set file input to null to allow adding the same image 2 times in a row - fileInputRef.current.value = null; }); } @@ -172,12 +182,13 @@ function SelectMapModal({ return ( - + handleImageUpload(event.target.files[0])} + onChange={(event) => handleImagesUpload(event.target.files)} type="file" accept="image/*" style={{ display: "none" }} + multiple ref={fileInputRef} /> { - // Copy file to avoid permissions issues - const blob = new Blob([buffer]); - // Create and load the image temporarily to get its dimensions - const url = URL.createObjectURL(blob); + const buffer = await blobToBuffer(file); + + // Copy file to avoid permissions issues + const blob = new Blob([buffer]); + // Create and load the image temporarily to get its dimensions + const url = URL.createObjectURL(blob); + + return new Promise((resolve, reject) => { image.onload = function () { handleTokenAdd({ file: buffer, @@ -68,11 +79,10 @@ function SelectTokensModal({ isOpen, onRequestClose }) { hideInSidebar: false, }); setImageLoading(false); + resolve(); }; + image.onerror = reject; image.src = url; - - // Set file input to null to allow adding the same image 2 times in a row - fileInputRef.current.value = null; }); } @@ -96,13 +106,14 @@ function SelectTokensModal({ isOpen, onRequestClose }) { return ( - + handleImageUpload(event.target.files[0])} + onChange={(event) => handleImagesUpload(event.target.files)} type="file" accept="image/*" style={{ display: "none" }} ref={fileInputRef} + multiple /> - Beta v1.3.0 + Beta v1.3.1