Merge pull request #9 from mitchemmc/release/v1.3.1

Release/v1.3.1
This commit is contained in:
Mitchell McCaffrey 2020-05-31 19:13:10 +10:00 committed by GitHub
commit bb9e50e659
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 305 additions and 125 deletions

View File

@ -1,6 +1,6 @@
{
"name": "owlbear-rodeo",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"dependencies": {
"@msgpack/msgpack": "^1.12.1",

View File

@ -1,15 +1,15 @@
import React from "react";
import { Divider } from "theme-ui";
function StyledDivider({ vertical, color }) {
function StyledDivider({ vertical, color, fill }) {
return (
<Divider
my={vertical ? 0 : 2}
mx={vertical ? 2 : 0}
bg={color}
sx={{
height: vertical ? "24px" : "2px",
width: vertical ? "2px" : "24px",
height: vertical ? (fill ? "100%" : "24px") : "2px",
width: vertical ? "2px" : fill ? "100%" : "24px",
borderRadius: "2px",
opacity: 0.5,
}}
@ -20,6 +20,7 @@ function StyledDivider({ vertical, color }) {
StyledDivider.defaultProps = {
vertical: false,
color: "text",
fill: false,
};
export default StyledDivider;

View File

@ -12,6 +12,7 @@ function Footer() {
width: "100%",
alignItems: "center",
justifyContent: "center",
paddingBottom: "env(safe-area-inset-bottom)",
}}
>
<Link m={2} to="/about" variant="footer">

View File

@ -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);
}

View File

@ -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({
<MapDrawing
shapes={mapShapes}
onShapeAdd={handleMapShapeAdd}
onShapeRemove={handleMapShapeRemove}
onShapesRemove={handleMapShapesRemove}
selectedToolId={selectedToolId}
selectedToolSettings={toolSettings[selectedToolId]}
gridSize={gridSizeNormalized}
@ -286,14 +287,18 @@ function Map({
<MapFog
shapes={fogShapes}
onShapeAdd={handleFogShapeAdd}
onShapeRemove={handleFogShapeRemove}
onShapeEdit={handleFogShapeEdit}
onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit}
selectedToolId={selectedToolId}
selectedToolSettings={toolSettings[selectedToolId]}
gridSize={gridSizeNormalized}
/>
);
const mapGrid = map && map.showGrid && (
<MapGrid map={map} gridSize={gridSizeNormalized} />
);
return (
<MapInteraction
map={map}
@ -308,6 +313,7 @@ function Map({
}
selectedToolId={selectedToolId}
>
{mapGrid}
{mapDrawing}
{mapTokens}
{mapFog}

View File

@ -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 (
<Group>
{shapes.map(renderShape)}
{drawingShape && renderShape(drawingShape)}
{erasingShapes.length > 0 && erasingShapes.map(renderErasingShape)}
</Group>
);
}

View File

@ -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 (
<Line
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)}
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 (
<Group>
{shapes.map(renderShape)}
{drawingShape && renderShape(drawingShape)}
{editingShapes.length > 0 && editingShapes.map(renderEditingShape)}
</Group>
);
}

View File

@ -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(
<Line
key={`grid_x_${x}`}
points={[x * lineSpacingX, 0, x * lineSpacingX, mapHeight]}
stroke={isImageLight ? "black" : "white"}
strokeWidth={getStrokeWidth(0.1, gridSize, mapWidth, mapHeight)}
opacity={0.8}
/>
);
}
for (let y = 1; y < gridY; y++) {
lines.push(
<Line
key={`grid_y_${y}`}
points={[0, y * lineSpacingY, mapWidth, y * lineSpacingY]}
stroke={isImageLight ? "black" : "white"}
strokeWidth={getStrokeWidth(0.1, gridSize, mapWidth, mapHeight)}
opacity={0.8}
/>
);
}
return <Group>{lines}</Group>;
}
export default MapGrid;

View File

@ -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 && (
<>
<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={!map || map.type === "default"}
my={1}
/>
</Box>
<Box my={2}>
<Label>
<Checkbox
checked={map && map.showGrid}
disabled={!map || map.type === "default"}
onChange={(e) => onSettingsChange("showGrid", e.target.checked)}
/>
Show Grid
</Label>
</Box>
<Divider fill />
<Box my={2} sx={{ flexGrow: 1 }}>
<Label>Allow others to edit</Label>
<Flex my={1}>
<Label>
@ -85,16 +108,6 @@ function MapSettings({
</Label>
</Flex>
</Box>
<Box my={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="name">Name</Label>
<Input
name="name"
value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={!map || map.type === "default"}
my={1}
/>
</Box>
</>
)}
<IconButton

View File

@ -48,7 +48,7 @@ function MapToken({
const tokenImage = event.target;
const tokenImageRect = tokenImage.getClientRect();
if (token.isVehicle) {
if (token && token.isVehicle) {
// Find all other tokens on the map
const layer = tokenImage.getLayer();
const tokens = layer.find(".token");
@ -83,7 +83,7 @@ function MapToken({
const tokenImage = event.target;
const mountChanges = {};
if (token.isVehicle) {
if (token && token.isVehicle) {
const layer = tokenImage.getLayer();
const mountedTokens = tokenImage.find(".token");
for (let mountedToken of mountedTokens) {

View File

@ -66,7 +66,7 @@ function TokenDragOverlay({
function handleTokenDragEnd() {
if (isRemoveHovered) {
// Handle other tokens when a vehicle gets deleted
if (token.isVehicle) {
if (token && token.isVehicle) {
const layer = tokenImage.getLayer();
const mountedTokens = tokenImage.find(".token");
for (let mountedToken of mountedTokens) {

View File

@ -43,6 +43,7 @@ export function MapDataProvider({ children }) {
created: Date.now() + i,
lastModified: Date.now() + i,
gridType: "grid",
showGrid: false,
});
// Add a state for the map if there isn't one already
const state = await database.table("states").get(id);

View File

@ -89,6 +89,17 @@ function loadVersions(db) {
}
});
});
// v1.3.1 - Added show grid option
db.version(4)
.stores({})
.upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.showGrid = false;
});
});
}
// Get the dexie database used in DatabaseContext

View File

@ -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]()

View File

@ -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);
}

View File

@ -9,3 +9,7 @@ html {
.simplebar-scrollbar:before {
background: #fff;
}
input[type="checkbox"]:disabled ~ svg {
opacity: 0.1;
}

View File

@ -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 (
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<ImageDrop onDrop={handleImageUpload} dropText="Drop map to upload">
<ImageDrop onDrop={handleImagesUpload} dropText="Drop map to upload">
<input
onChange={(event) => handleImageUpload(event.target.files[0])}
onChange={(event) => handleImagesUpload(event.target.files)}
type="file"
accept="image/*"
style={{ display: "none" }}
multiple
ref={fileInputRef}
/>
<Flex

View File

@ -36,7 +36,15 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
addToken(token);
}
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) {
let name = "Unknown Map";
if (file.name) {
// Remove file extension
@ -49,11 +57,14 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
}
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 () {
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 (
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<ImageDrop onDrop={handleImageUpload} dropText="Drop token to upload">
<ImageDrop onDrop={handleImagesUpload} dropText="Drop token to upload">
<input
onChange={(event) => handleImageUpload(event.target.files[0])}
onChange={(event) => handleImagesUpload(event.target.files)}
type="file"
accept="image/*"
style={{ display: "none" }}
ref={fileInputRef}
multiple
/>
<Flex
sx={{

View File

@ -51,7 +51,7 @@ function Home() {
Join Game
</Button>
<Text variant="caption" as="p" sx={{ textAlign: "center" }}>
Beta v1.3.0
Beta v1.3.1
</Text>
<Button
m={2}

View File

@ -9,6 +9,7 @@ const v110 = raw("../docs/releaseNotes/v1.1.0.md");
const v120 = raw("../docs/releaseNotes/v1.2.0.md");
const v121 = raw("../docs/releaseNotes/v1.2.1.md");
const v130 = raw("../docs/releaseNotes/v1.3.0.md");
const v131 = raw("../docs/releaseNotes/v1.3.1.md");
function ReleaseNotes() {
return (
@ -31,6 +32,9 @@ function ReleaseNotes() {
<Text mb={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
Release Notes
</Text>
<div id="v131">
<Markdown source={v131} />
</div>
<div id="v130">
<Markdown source={v130} />
</div>