Merge branch 'test/v1.9.0' into preview/1.9.0

This commit is contained in:
Mitchell McCaffrey 2021-06-20 17:37:40 +10:00
commit ffbded8e9d
53 changed files with 821 additions and 427 deletions

View File

@ -4,6 +4,6 @@ REACT_APP_STRIPE_API_KEY=pk_live_MJjzi5djj524Y7h3fL5PNh4e00a852XD51
REACT_APP_STRIPE_URL=https://payment.owlbear.rodeo
REACT_APP_VERSION=$npm_package_version
REACT_APP_PREVIEW=false
REACT_APP_LOGGING=false
REACT_APP_LOGGING=true
REACT_APP_FATHOM_SITE_ID=VMSHBPKD
REACT_APP_SENTRY_DSN=https://5257021c3a114649baa5e3b8ba775bfe@o467475.ingest.sentry.io/5493956
REACT_APP_SENTRY_DSN=https://d6d22c5233b54c4d91df8fa29d5ffeb0@o467475.ingest.sentry.io/5493956

View File

@ -9,7 +9,8 @@
"@dnd-kit/sortable": "^3.1.0",
"@mitchemmc/dexie-export-import": "^1.0.1",
"@msgpack/msgpack": "^2.4.1",
"@sentry/react": "^6.2.2",
"@sentry/integrations": "^6.3.0",
"@sentry/react": "^6.3.0",
"@stripe/stripe-js": "^1.13.1",
"@tensorflow/tfjs": "^3.3.0",
"@testing-library/jest-dom": "^5.11.9",
@ -27,6 +28,7 @@
"file-saver": "^2.0.5",
"fuse.js": "^6.4.6",
"image-outline": "^0.1.0",
"intersection-observer": "^0.12.0",
"konva": "^7.2.5",
"lodash.clonedeep": "^4.5.0",
"lodash.get": "^4.4.2",
@ -39,6 +41,7 @@
"raw.macro": "^0.4.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-intersection-observer": "^8.32.0",
"react-konva": "^17.0.1-3",
"react-markdown": "4",
"react-media": "^2.0.0-rc.1",
@ -49,6 +52,7 @@
"react-scripts": "^4.0.3",
"react-select": "^4.2.1",
"react-spring": "^8.0.27",
"react-textarea-autosize": "^8.3.3",
"react-toast-notifications": "^2.4.3",
"react-use-gesture": "^9.1.3",
"shortid": "^2.2.15",

View File

@ -67,6 +67,7 @@ function Select({ creatable, ...props }) {
primary25: theme.colors.highlight,
},
})}
captureMenuScroll={false}
{...props}
/>
);

View File

@ -0,0 +1,22 @@
.textarea-auto-size {
box-sizing: border-box;
margin: 0;
min-width: 0;
display: block;
width: 100%;
appearance: none;
font-size: inherit;
line-height: inherit;
border-radius: 4px;
color: inherit;
background-color: transparent;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", sans-serif;
padding: 4px;
border: none;
resize: none;
}
.textarea-auto-size:focus {
outline: none;
}

View File

@ -0,0 +1,8 @@
import TextareaAutosize from "react-textarea-autosize";
import "./TextareaAutoSize.css";
function StyledTextareaAutoSize(props) {
return <TextareaAutosize className="textarea-auto-size" {...props} />;
}
export default StyledTextareaAutoSize;

View File

@ -1,5 +1,5 @@
import React, { useState, useRef } from "react";
import { Flex, Text } from "theme-ui";
import { Box, Flex, Text } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import LoadingOverlay from "../LoadingOverlay";
@ -171,39 +171,61 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
{...overlayListeners}
>
<Flex
bg="overlay"
sx={{
height: "10%",
justifyContent: "center",
alignItems: "center",
color: droppingType === "maps" ? "primary" : "text",
opacity: droppingType === "maps" ? 1 : 0.8,
margin: "4px 16px",
border: "1px dashed",
width: "calc(100% - 32px)",
borderRadius: "12px",
width: "100%",
position: "relative",
}}
onDragEnter={handleMapsOver}
>
<Box
bg="overlay"
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
margin: "4px 16px",
border: "1px dashed",
borderRadius: "12px",
pointerEvents: "none",
}}
/>
<Text sx={{ pointerEvents: "none", userSelect: "none" }}>
Drop as map
</Text>
</Flex>
<Flex
bg="overlay"
sx={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
color: droppingType === "tokens" ? "primary" : "text",
opacity: droppingType === "tokens" ? 1 : 0.8,
margin: "4px 16px",
border: "1px dashed",
width: "calc(100% - 32px)",
borderRadius: "12px",
width: "100%",
position: "relative",
}}
onDragEnter={handleTokensOver}
>
<Box
bg="overlay"
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
margin: "4px 16px",
border: "1px dashed",
borderRadius: "12px",
pointerEvents: "none",
}}
/>
<Text sx={{ pointerEvents: "none", userSelect: "none" }}>
Drop as token
</Text>

View File

@ -1,5 +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";
@ -49,6 +50,8 @@ function Map({
disabledTokens,
session,
}) {
const { addToast } = useToasts();
const { tokensById } = useTokenData();
const [selectedToolId, setSelectedToolId] = useState("move");
@ -232,6 +235,7 @@ function Map({
onShapesCut={handleFogShapesCut}
onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit}
onShapeError={addToast}
active={selectedToolId === "fog"}
toolSettings={settings.fog}
editable={allowFogDrawing && !settings.fog.preview}

View File

@ -38,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";
@ -52,6 +54,7 @@ function MapFog({
onShapesCut,
onShapesRemove,
onShapesEdit,
onShapeError,
active,
toolSettings,
editable,
@ -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) {

View File

@ -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) {

View File

@ -4,6 +4,7 @@ import MapTile from "./MapTile";
import MapTileGroup from "./MapTileGroup";
import SortableTiles from "../tile/SortableTiles";
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
import { getGroupItems } from "../../helpers/group";
@ -20,21 +21,25 @@ function MapTiles({ mapsById, onMapEdit, onMapSelect, subgroup }) {
function renderTile(group) {
if (group.type === "item") {
const map = mapsById[group.id];
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}`]}
/>
);
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);
@ -53,7 +58,12 @@ function MapTiles({ mapsById, onMapEdit, onMapSelect, subgroup }) {
}
}
return <SortableTiles renderTile={renderTile} subgroup={subgroup} />;
return (
<>
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
</>
);
}
export default MapTiles;

View File

@ -248,16 +248,20 @@ function MapToken({
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>
);

View File

@ -15,7 +15,7 @@ import colors from "../../helpers/colors";
import usePrevious from "../../hooks/usePrevious";
import useGridSnapping from "../../hooks/useGridSnapping";
const minTextSize = 16;
const defaultFontSize = 16;
function Note({
note,
@ -118,7 +118,7 @@ function Note({
}
}
const [fontSize, setFontSize] = useState(1);
const [fontScale, setFontScale] = useState(1);
useEffect(() => {
const text = textRef.current;
@ -127,10 +127,10 @@ function Note({
}
function findFontSize() {
// Create an array from 1 / minTextSize of the note height to the full note height
const sizes = Array.from(
// Create an array from 1 / defaultFontSize of the note height to the full note height
let sizes = Array.from(
{ length: Math.ceil(noteHeight - notePadding * 2) },
(_, i) => i + Math.ceil(noteHeight / minTextSize)
(_, i) => i + Math.ceil(noteHeight / defaultFontSize)
);
if (sizes.length > 0) {
@ -144,8 +144,7 @@ function Note({
return prev;
}
});
setFontSize(size);
setFontScale(size / defaultFontSize);
}
}
@ -215,11 +214,14 @@ function Note({
}
align="left"
verticalAlign="middle"
padding={notePadding}
fontSize={fontSize}
padding={notePadding / fontScale}
fontSize={defaultFontSize}
// Scale font instead of changing font size to avoid kerning issues with Firefox
scaleX={fontScale}
scaleY={fontScale}
width={noteWidth / fontScale}
height={note.textOnly ? undefined : noteHeight / fontScale}
wrap="word"
width={noteWidth}
height={note.textOnly ? undefined : noteHeight}
/>
{/* Use an invisible text block to work out text sizing */}
<Text visible={false} ref={textRef} text={note.text} wrap="none" />

View File

@ -1,7 +1,8 @@
import React, { useEffect, useState } from "react";
import { Box, Flex, Text, IconButton, Textarea } from "theme-ui";
import { Box, Flex, Text, IconButton } from "theme-ui";
import Slider from "../Slider";
import TextareaAutosize from "../TextareaAutoSize";
import MapMenu from "../map/MapMenu";
@ -128,20 +129,12 @@ function NoteMenu({
}}
sx={{ alignItems: "center" }}
>
<Textarea
<TextareaAutosize
id="changeNoteText"
onChange={handleTextChange}
value={(note && note.text) || ""}
sx={{
padding: "4px",
border: "none",
":focus": {
outline: "none",
},
resize: "none",
}}
rows={1}
onKeyPress={handleTextKeyPress}
maxRows={4}
/>
</Flex>
<Box

View File

@ -0,0 +1,33 @@
import React from "react";
import { Box } from "theme-ui";
import { useInView } from "react-intersection-observer";
function LazyTile({ children }) {
const [ref, inView] = useInView({ triggerOnce: false });
const sx = inView
? {}
: { width: "100%", height: "0", paddingTop: "100%", position: "relative" };
return (
<Box sx={sx} ref={ref}>
{inView ? (
children
) : (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: "4px",
}}
bg="background"
/>
)}
</Box>
);
}
export default LazyTile;

View File

@ -1,26 +1,25 @@
import React from "react";
import { createPortal } from "react-dom";
import { DragOverlay } from "@dnd-kit/core";
import { SortableContext } from "@dnd-kit/sortable";
import { animated, useSpring, config } from "react-spring";
import { Badge } from "theme-ui";
import { moveGroupsInto } from "../../helpers/group";
import { keyBy } from "../../helpers/shared";
import Vector2 from "../../helpers/Vector2";
import SortableTile from "./SortableTile";
import LazyTile from "./LazyTile";
import {
useTileDrag,
useTileDragId,
useTileDragCursor,
useTileOverGroupId,
BASE_SORTABLE_ID,
GROUP_SORTABLE_ID,
GROUP_ID_PREFIX,
} from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext";
function SortableTiles({ renderTile, subgroup }) {
const { dragId, overId, dragCursor } = useTileDrag();
const dragId = useTileDragId();
const dragCursor = useTileDragCursor();
const overGroupId = useTileOverGroupId();
const {
groups,
selectedGroupIds: allSelectedIds,
@ -46,15 +45,6 @@ function SortableTiles({ renderTile, subgroup }) {
const disableSorting = (openGroupId && !subgroup) || filter;
const disableGrouping = subgroup || disableSorting || filter;
const dragBounce = useSpring({
transform: !!dragId ? "scale(0.9)" : "scale(1)",
config: config.wobbly,
position: "relative",
});
const overGroupId =
overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9);
function renderSortableGroup(group, selectedGroups) {
if (overGroupId === group.id && dragId && group.id !== dragId) {
// If dragging over a group render a preview of that group
@ -68,57 +58,6 @@ function SortableTiles({ renderTile, subgroup }) {
return renderTile(group);
}
function renderDragOverlays() {
let selectedIndices = selectedGroupIds.map((groupId) =>
activeGroups.findIndex((group) => group.id === groupId)
);
const activeIndex = activeGroups.findIndex((group) => group.id === dragId);
// Sort so the draging tile is the first element
selectedIndices = selectedIndices.sort((a, b) =>
a === activeIndex ? -1 : b === activeIndex ? 1 : 0
);
selectedIndices = selectedIndices.slice(0, 5);
let coords = selectedIndices.map(
(_, index) => new Vector2(5 * index, 5 * index)
);
// Reverse so the first element is rendered on top
selectedIndices = selectedIndices.reverse();
coords = coords.reverse();
const selectedGroups = selectedIndices.map((index) => activeGroups[index]);
return selectedGroups.map((group, index) => (
<DragOverlay dropAnimation={null} key={group.id}>
<div
style={{
transform: `translate(${coords[index].x}%, ${coords[index].y}%)`,
cursor: dragCursor,
}}
>
<animated.div style={dragBounce}>
{renderTile(group)}
{index === selectedIndices.length - 1 &&
selectedGroupIds.length > 1 && (
<Badge
sx={{
position: "absolute",
top: 0,
right: 0,
transform: "translate(25%, -25%)",
}}
>
{selectedGroupIds.length}
</Badge>
)}
</animated.div>
</div>
</DragOverlay>
));
}
function renderTiles() {
const groupsByIds = keyBy(activeGroups, "id");
const selectedGroupIdsSet = new Set(selectedGroupIds);
@ -138,17 +77,18 @@ function SortableTiles({ renderTile, subgroup }) {
const disableTileGrouping =
disableGrouping || isDragging || hasSelectedContainerGroup;
return (
<SortableTile
id={group.id}
key={group.id}
disableGrouping={disableTileGrouping}
disableSorting={disableSorting}
hidden={group.id === openGroupId}
isDragging={isDragging}
cursor={dragCursor}
>
{renderSortableGroup(group, selectedGroups)}
</SortableTile>
<LazyTile key={group.id}>
<SortableTile
id={group.id}
disableGrouping={disableTileGrouping}
disableSorting={disableSorting}
hidden={group.id === openGroupId}
isDragging={isDragging}
cursor={dragCursor}
>
{renderSortableGroup(group, selectedGroups)}
</SortableTile>
</LazyTile>
);
});
}
@ -156,7 +96,6 @@ function SortableTiles({ renderTile, subgroup }) {
return (
<SortableContext items={activeGroups} id={sortableId}>
{renderTiles()}
{createPortal(dragId && renderDragOverlays(), document.body)}
</SortableContext>
);
}

View File

@ -0,0 +1,93 @@
import React from "react";
import { createPortal } from "react-dom";
import { DragOverlay } from "@dnd-kit/core";
import { animated, useSpring, config } from "react-spring";
import { Badge } from "theme-ui";
import Vector2 from "../../helpers/Vector2";
import { useTileDragId } from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext";
function SortableTilesDragOverlay({ renderTile, subgroup }) {
const dragId = useTileDragId();
const {
groups,
selectedGroupIds: allSelectedIds,
filter,
openGroupId,
openGroupItems,
filteredGroupItems,
} = useGroup();
const activeGroups = subgroup
? openGroupItems
: filter
? filteredGroupItems
: groups;
// Only populate selected groups if needed
let selectedGroupIds = [];
if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) {
selectedGroupIds = allSelectedIds;
}
const dragBounce = useSpring({
transform: !!dragId ? "scale(0.9)" : "scale(1)",
config: config.wobbly,
position: "relative",
});
function renderDragOverlays() {
let selectedIndices = selectedGroupIds.map((groupId) =>
activeGroups.findIndex((group) => group.id === groupId)
);
const activeIndex = activeGroups.findIndex((group) => group.id === dragId);
// Sort so the draging tile is the first element
selectedIndices = selectedIndices.sort((a, b) =>
a === activeIndex ? -1 : b === activeIndex ? 1 : 0
);
selectedIndices = selectedIndices.slice(0, 5);
let coords = selectedIndices.map(
(_, index) => new Vector2(5 * index, 5 * index)
);
// Reverse so the first element is rendered on top
selectedIndices = selectedIndices.reverse();
coords = coords.reverse();
const selectedGroups = selectedIndices.map((index) => activeGroups[index]);
return selectedGroups.map((group, index) => (
<DragOverlay dropAnimation={null} key={group.id}>
<div
style={{
transform: `translate(${coords[index].x}%, ${coords[index].y}%)`,
}}
>
<animated.div style={dragBounce}>
{renderTile(group)}
{index === selectedIndices.length - 1 &&
selectedGroupIds.length > 1 && (
<Badge
sx={{
position: "absolute",
top: 0,
right: 0,
transform: "translate(25%, -25%)",
}}
>
{selectedGroupIds.length}
</Badge>
)}
</animated.div>
</div>
</DragOverlay>
));
}
return createPortal(dragId && renderDragOverlays(), document.body);
}
export default SortableTilesDragOverlay;

View File

@ -15,15 +15,13 @@ function Tile({
children,
}) {
return (
<Flex
<Box
sx={{
position: "relative",
width: "100%",
height: "0",
paddingTop: "100%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
userSelect: "none",
}}
@ -120,7 +118,7 @@ function Tile({
</IconButton>
</Box>
)}
</Flex>
</Box>
);
}

View File

@ -1,6 +1,6 @@
import React, { useState } from "react";
import { createPortal } from "react-dom";
import { Box, Flex } from "theme-ui";
import { Box, Flex, Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import {
DragOverlay,
@ -165,9 +165,16 @@ function TokenBar({ onMapTokensStateCreate }) {
padding: "0 16px",
}}
>
<Flex sx={{ flexDirection: "column" }}>
<Grid
columns="1fr"
gap={2}
py={2}
// Prevent selection on 3D touch for iOS
onTouchStart={preventSelect}
onTouchEnd={resumeSelect}
>
{tokenGroups.map((group) => renderToken(group))}
</Flex>
</Grid>
</SimpleBar>
<Flex
bg="muted"

View File

@ -1,31 +1,29 @@
import React, { useRef } from "react";
import React from "react";
import { Box } from "theme-ui";
import usePreventTouch from "../../hooks/usePreventTouch";
import { useInView } from "react-intersection-observer";
import TokenImage from "./TokenImage";
function TokenBarToken({ token }) {
const imageRef = useRef();
// Stop touch to prevent 3d touch gesutre on iOS
usePreventTouch(imageRef);
const [ref, inView] = useInView({ triggerOnce: true });
return (
<Box py={1} sx={{ width: "48px", height: "56px" }}>
<TokenImage
token={token}
ref={imageRef}
sx={{
userSelect: "none",
touchAction: "none",
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
alt={token.name}
title={token.name}
/>
<Box ref={ref} sx={{ width: "48px", height: "48px" }} title={token.name}>
{inView && (
<TokenImage
token={token}
sx={{
userSelect: "none",
touchAction: "none",
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
alt={token.name}
title={token.name}
/>
)}
</Box>
);
}

View File

@ -40,11 +40,10 @@ function TokenBarTokenGroup({ group, tokens, draggable }) {
return (
<Grid
columns="1fr"
alt={group.name}
title={group.name}
bg="muted"
sx={{ borderRadius: "8px", gridGap: 0 }}
sx={{ borderRadius: "8px" }}
p={0}
gap={2}
>
<Flex
sx={{
@ -57,7 +56,6 @@ function TokenBarTokenGroup({ group, tokens, draggable }) {
}}
onClick={(e) => handleOpenClick(e, false)}
key="group"
alt={group.name}
title={group.name}
{...listeners}
{...attributes}
@ -71,8 +69,6 @@ function TokenBarTokenGroup({ group, tokens, draggable }) {
return (
<Grid
columns="1fr 1fr"
alt={group.name}
title={group.name}
bg="muted"
sx={{
borderRadius: "8px",
@ -81,6 +77,8 @@ function TokenBarTokenGroup({ group, tokens, draggable }) {
gridTemplateRows: "1fr 1fr",
}}
p="2px"
alt={group.name}
title={group.name}
{...listeners}
{...attributes}
>

View File

@ -48,7 +48,14 @@ function TokenEditBar({ disabled, onLoad }) {
async function handleTokensHide(hideInSidebar) {
const selectedTokens = getSelectedTokens();
const selectedTokenIds = selectedTokens.map((token) => token.id);
updateTokensHidden(selectedTokenIds, hideInSidebar);
// Show loading indicator if hiding more than 10 tokens
if (selectedTokenIds.length > 10) {
onLoad(true);
await updateTokensHidden(selectedTokenIds, hideInSidebar);
onLoad(false);
} else {
updateTokensHidden(selectedTokenIds, hideInSidebar);
}
}
/**

View File

@ -4,6 +4,7 @@ import { Rect, Text, Group } from "react-konva";
import useSetting from "../../hooks/useSetting";
const maxTokenSize = 3;
const defaultFontSize = 16;
function TokenLabel({ tokenState, width, height }) {
const [labelSize] = useSetting("map.labelSize");
@ -13,7 +14,7 @@ function TokenLabel({ tokenState, width, height }) {
const paddingX =
(height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
const [fontSize, setFontSize] = useState(1);
const [fontScale, setFontScale] = useState(0);
useEffect(() => {
const text = textSizerRef.current;
@ -22,15 +23,14 @@ function TokenLabel({ tokenState, width, height }) {
}
let fontSizes = [];
for (let size = 10 * labelSize; size >= 6; size--) {
fontSizes.push(
(height / size / tokenState.size) *
Math.min(tokenState.size, maxTokenSize) *
labelSize
);
for (let size = 20 * labelSize; size >= 6; size--) {
const verticalSize = height / size / tokenState.size;
const tokenSize = Math.min(tokenState.size, maxTokenSize);
const fontSize = verticalSize * tokenSize * labelSize;
fontSizes.push(fontSize);
}
function findFontSize() {
function findFontScale() {
const size = fontSizes.reduce((prev, curr) => {
text.fontSize(curr);
const textWidth = text.getTextWidth() + paddingX * 2;
@ -39,12 +39,12 @@ function TokenLabel({ tokenState, width, height }) {
} else {
return prev;
}
});
}, 1);
setFontSize(size);
setFontScale(size / defaultFontSize);
}
findFontSize();
findFontScale();
}, [
tokenState.label,
tokenState.visible,
@ -56,44 +56,47 @@ function TokenLabel({ tokenState, width, height }) {
]);
const [rectWidth, setRectWidth] = useState(0);
const [textWidth, setTextWidth] = useState(0);
useEffect(() => {
const text = textRef.current;
if (text && tokenState.label) {
setRectWidth(text.getTextWidth() + paddingX * 2);
setRectWidth(text.getTextWidth() * fontScale + paddingX * 2);
setTextWidth(text.getTextWidth() * fontScale);
} else {
setRectWidth(0);
setTextWidth(0);
}
}, [tokenState.label, paddingX, width, fontSize]);
}, [tokenState.label, paddingX, width, fontScale]);
const textRef = useRef();
const textSizerRef = useRef();
return (
<Group y={height - (fontSize + paddingY) / 2}>
<Group y={height - (defaultFontSize * fontScale + paddingY) / 2}>
<Rect
y={-paddingY / 2}
width={rectWidth}
offsetX={width / 2}
x={width - rectWidth / 2}
height={fontSize + paddingY}
height={defaultFontSize * fontScale + paddingY}
fill="hsla(230, 25%, 18%, 0.8)"
cornerRadius={(fontSize + paddingY) / 2}
/>
<Text
ref={textRef}
width={width}
text={tokenState.label}
fontSize={fontSize}
lineHeight={1}
align="center"
verticalAlign="bottom"
fill="white"
paddingX={paddingX}
paddingY={paddingY}
wrap="none"
ellipsis={false}
hitFunc={() => {}}
cornerRadius={(defaultFontSize * fontScale + paddingY) / 2}
/>
<Group offsetX={(textWidth - width) / 2}>
<Text
ref={textRef}
text={tokenState.label}
fontSize={defaultFontSize}
lineHeight={1}
// Scale font instead of changing font size to avoid kerning issues with Firefox
scaleX={fontScale}
scaleY={fontScale}
fill="white"
wrap="none"
ellipsis={false}
hitFunc={() => {}}
/>
</Group>
{/* Use an invisible text block to work out text sizing */}
<Text
visible={false}

View File

@ -50,7 +50,7 @@ function TokenMenu({
}, [isOpen, tokenState, wasOpen, tokenImage]);
function handleLabelChange(event) {
const label = event.target.value.substring(0, 144);
const label = event.target.value.substring(0, 48);
tokenState && onTokenStateChange({ [tokenState.id]: { label: label } });
}

View File

@ -25,8 +25,8 @@ function TokenTileGroup({
<Grid
columns={`repeat(${layout.groupGridColumns}, 1fr)`}
p={2}
gap={2}
sx={{
gridGap: 2,
height: "100%",
gridTemplateRows: `repeat(${layout.groupGridColumns}, 1fr)`,
}}

View File

@ -5,6 +5,7 @@ import TokenTileGroup from "./TokenTileGroup";
import TokenHiddenBadge from "./TokenHiddenBadge";
import SortableTiles from "../tile/SortableTiles";
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
import { getGroupItems } from "../../helpers/group";
@ -21,24 +22,28 @@ function TokenTiles({ tokensById, onTokenEdit, subgroup }) {
function renderTile(group) {
if (group.type === "item") {
const token = tokensById[group.id];
const isSelected = selectedGroupIds.includes(group.id);
const canEdit =
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
if (token) {
const isSelected = selectedGroupIds.includes(group.id);
const canEdit =
isSelected &&
selectMode === "single" &&
selectedGroupIds.length === 1;
return (
<TokenTile
key={token.id}
token={token}
isSelected={isSelected}
onSelect={onGroupSelect}
onTokenEdit={onTokenEdit}
canEdit={canEdit}
badges={[
`${token.defaultSize}x`,
<TokenHiddenBadge hidden={token.hideInSidebar} />,
]}
/>
);
return (
<TokenTile
key={token.id}
token={token}
isSelected={isSelected}
onSelect={onGroupSelect}
onTokenEdit={onTokenEdit}
canEdit={canEdit}
badges={[
`${token.defaultSize}x`,
<TokenHiddenBadge hidden={token.hideInSidebar} />,
]}
/>
);
}
} else {
const isSelected = selectedGroupIds.includes(group.id);
const items = getGroupItems(group);
@ -57,7 +62,12 @@ function TokenTiles({ tokensById, onTokenEdit, subgroup }) {
}
}
return <SortableTiles renderTile={renderTile} subgroup={subgroup} />;
return (
<>
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
</>
);
}
export default TokenTiles;

View File

@ -1,4 +1,4 @@
import React, { useState, useContext } from "react";
import React, { useState, useContext, useEffect } from "react";
import {
MouseSensor,
TouchSensor,
@ -16,7 +16,9 @@ import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group";
import usePreventSelect from "../hooks/usePreventSelect";
const TileDragContext = React.createContext();
const TileDragIdContext = React.createContext();
const TileOverGroupIdContext = React.createContext();
const TileDragCursorContext = React.createContext();
export const BASE_SORTABLE_ID = "__base__";
export const GROUP_SORTABLE_ID = "__group__";
@ -61,7 +63,7 @@ export function TileDragProvider({
} = useGroup();
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
activationConstraint: { distance: 3 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
@ -70,16 +72,23 @@ export function TileDragProvider({
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const [dragId, setDragId] = useState();
const [overId, setOverId] = useState();
const [dragId, setDragId] = useState(null);
const [overId, setOverId] = useState(null);
const [dragCursor, setDragCursor] = useState("pointer");
const [preventSelect, resumeSelect] = usePreventSelect();
const [overGroupId, setOverGroupId] = useState(null);
useEffect(() => {
setOverGroupId(
(overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9)) || null
);
}, [overId]);
function handleDragStart(event) {
const { active, over } = event;
setDragId(active.id);
setOverId(over?.id);
setOverId(over?.id || null);
if (!selectedGroupIds.includes(active.id)) {
onGroupSelect(active.id);
}
@ -93,7 +102,7 @@ export function TileDragProvider({
function handleDragOver(event) {
const { over } = event;
setOverId(over?.id);
setOverId(over?.id || null);
if (over) {
if (
over.id.startsWith(UNGROUP_ID) ||
@ -111,8 +120,8 @@ export function TileDragProvider({
function handleDragEnd(event) {
const { active, over, overlayNodeClientRect } = event;
setDragId();
setOverId();
setDragId(null);
setOverId(null);
setDragCursor("pointer");
if (active && over && active.id !== over.id) {
let selectedIndices = selectedGroupIds.map((groupId) =>
@ -165,8 +174,8 @@ export function TileDragProvider({
}
function handleDragCancel(event) {
setDragId();
setOverId();
setDragId(null);
setOverId(null);
setDragCursor("pointer");
resumeSelect();
@ -210,8 +219,6 @@ export function TileDragProvider({
return closestCenter(otherRects, rect);
}
const value = { dragId, overId, dragCursor };
return (
<DragContext
onDragStart={handleDragStart}
@ -221,19 +228,37 @@ export function TileDragProvider({
sensors={sensors}
collisionDetection={customCollisionDetection}
>
<TileDragContext.Provider value={value}>
{children}
</TileDragContext.Provider>
<TileDragIdContext.Provider value={dragId}>
<TileOverGroupIdContext.Provider value={overGroupId}>
<TileDragCursorContext.Provider value={dragCursor}>
{children}
</TileDragCursorContext.Provider>
</TileOverGroupIdContext.Provider>
</TileDragIdContext.Provider>
</DragContext>
);
}
export function useTileDrag() {
const context = useContext(TileDragContext);
export function useTileDragId() {
const context = useContext(TileDragIdContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}
export default TileDragContext;
export function useTileOverGroupId() {
const context = useContext(TileOverGroupIdContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}
export function useTileDragCursor() {
const context = useContext(TileDragCursorContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}

View File

@ -115,30 +115,24 @@ export function TokenDataProvider({ children }) {
}
function handleTokenChanges(changes) {
// Pool token changes together to call a single state update at the end
let tokensCreated = [];
let tokensUpdated = {};
let tokensDeleted = [];
for (let change of changes) {
if (change.table === "tokens") {
if (change.type === 1) {
// Created
const token = change.obj;
setTokens((prevTokens) => [token, ...prevTokens]);
tokensCreated.push(token);
} else if (change.type === 2) {
// Updated
const token = change.obj;
setTokens((prevTokens) => {
const newTokens = [...prevTokens];
const i = newTokens.findIndex((t) => t.id === token.id);
if (i > -1) {
newTokens[i] = token;
}
return newTokens;
});
tokensUpdated[token.id] = token;
} else if (change.type === 3) {
// Deleted
const id = change.key;
setTokens((prevTokens) => {
const filtered = prevTokens.filter((token) => token.id !== id);
return filtered;
});
tokensDeleted.push(id);
}
}
if (change.table === "groups") {
@ -149,6 +143,23 @@ export function TokenDataProvider({ children }) {
}
}
}
const tokensUpdatedArray = Object.values(tokensUpdated);
if (
tokensCreated.length > 0 ||
tokensUpdatedArray.length > 0 ||
tokensDeleted.length > 0
) {
setTokens((prevTokens) => {
let newTokens = [...tokensCreated, ...prevTokens];
for (let token of tokensUpdatedArray) {
const tokenIndex = newTokens.findIndex((t) => t.id === token.id);
if (tokenIndex > -1) {
newTokens[tokenIndex] = token;
}
}
return newTokens.filter((token) => !tokensDeleted.includes(token.id));
});
}
}
database.on("changes", handleTokenChanges);

View File

@ -6,7 +6,7 @@ This release focuses on improving the workflow for managing assets as well as in
All assets (tokens and maps) now offer a lot more flexibility for organisation with a new drag and drop interface for rearrangement, grouping and adding to a game.
To access these features simply long press on any asset you have selected. You can then drag to the edge of another asset to re-order the element, drag over another asset to create a new group or drag out of the select screen to add that asset to the game.
To access these features with mouse and keyboard simply drag any asset you have selected or with touch devices tap and hold to pickup the assets. You can then drag to the edge of another asset to re-order the element, drag over another asset to create a new group or drag out of the select screen to add that asset to the game.
With this feature we now also support the ability to drag and drop images from your computer directly into a game without the need to first add the asset into the appropriate asset import screen. To use this you can simply drag an image file into a game. Two new import boxes will then be shown where you can drag the images into the top box to import them as a map or the bottom box to import them as a token. When importing a single map this way the game will automatically switch to show this map in the shared scene. When importing tokens this way they will automatically be placed onto a shared map if one is shown.
@ -36,6 +36,7 @@ While in edit mode we now render fog much more efficiently. In testing we have s
- The progress loading bar will now pool consecutive assets together to avoid showing each asset separately.
- All default maps and tokens are now fully customisable. This includes adjusting settings, import/export and even deleting.
- Removed grid from default map images to allow changing grid size and type.
- Modals have a new transition animation to match the new group UI.
- Cursors now better represent drag and drop actions.
- Tokens in the select token screen now show an indicator for whether they are hidden in the sidebar.
@ -43,3 +44,10 @@ While in edit mode we now render fog much more efficiently. In testing we have s
- Fixed a bug with the fog brush tool not working properly on maps with smaller grid sizes.
- Added better file type handling for image import screens with more informative error notifications.
- Fixed a bug with vehicle tokens not picking up tokens hidden outside of view.
- Added a notification for when using the cut fog tool on places with no fog.
- Added the ability for the notes text input area to automatically resize to show more lines of text.
- Updated the light theme to be more readable in some cases.
- Added the ability to specify token sizes in input files by using width x height syntax.
- Updated measure tool to snap to grid cell centers to prevent issues with measuring diagonals.
- Updated measure tool euclidean option to use pixel coordinates to be more precise.
- Fixed a bug with note and label kerning on Firefox for Windows.

View File

@ -289,10 +289,7 @@ export function gridDistance(grid, a, b, cellSize) {
const bCoord = getNearestCellCoordinates(grid, b.x, b.y, cellSize);
if (grid.type === "square") {
if (grid.measurement.type === "chebyshev") {
return Math.max(
Math.abs(aCoord.x - bCoord.x),
Math.abs(aCoord.y - bCoord.y)
);
return Vector2.max(Vector2.abs(Vector2.subtract(aCoord, bCoord)));
} else if (grid.measurement.type === "alternating") {
// Alternating diagonal distance like D&D 3.5 and Pathfinder
const delta = Vector2.abs(Vector2.subtract(aCoord, bCoord));
@ -300,7 +297,7 @@ export function gridDistance(grid, a, b, cellSize) {
const min = Vector2.min(delta);
return max - min + Math.floor(1.5 * min);
} else if (grid.measurement.type === "euclidean") {
return Vector2.distance(aCoord, bCoord);
return Vector2.length(Vector2.divide(Vector2.subtract(a, b), cellSize));
} else if (grid.measurement.type === "manhattan") {
return Math.abs(aCoord.x - bCoord.x) + Math.abs(aCoord.y - bCoord.y);
}
@ -316,7 +313,7 @@ export function gridDistance(grid, a, b, cellSize) {
2
);
} else if (grid.measurement.type === "euclidean") {
return Vector2.distance(aCoord, bCoord);
return Vector2.length(Vector2.divide(Vector2.subtract(a, b), cellSize));
}
}
}

View File

@ -189,41 +189,64 @@ export async function createThumbnail(image, type, size = 300, quality = 0.5) {
* @returns {Outline}
*/
export function getImageOutline(image, maxPoints = 100) {
let baseOutline = imageOutline(image);
// Basic rect outline for fail conditions
const defaultOutline = {
type: "rect",
x: 0,
y: 0,
width: image.width,
height: image.height,
};
try {
let outlinePoints = imageOutline(image, {
opacityThreshold: 1, // Allow everything except full transparency
});
if (baseOutline) {
if (baseOutline.length > maxPoints) {
baseOutline = Vector2.resample(baseOutline, maxPoints);
}
const bounds = Vector2.getBoundingBox(baseOutline);
if (Vector2.rectangular(baseOutline)) {
return {
type: "rect",
x: Math.round(bounds.min.x),
y: Math.round(bounds.min.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height),
};
} else if (
Vector2.circular(
baseOutline,
Math.max(bounds.width / 10, bounds.height / 10)
)
) {
return {
type: "circle",
x: Math.round(bounds.center.x),
y: Math.round(bounds.center.y),
radius: Math.round(Math.min(bounds.width, bounds.height) / 2),
};
if (outlinePoints) {
if (outlinePoints.length > maxPoints) {
outlinePoints = Vector2.resample(outlinePoints, maxPoints);
}
const bounds = Vector2.getBoundingBox(outlinePoints);
// Reject outline if it's area is less than 5% of the image
const imageArea = image.width * image.height;
const area = bounds.width * bounds.height;
if (area < imageArea * 0.05) {
return defaultOutline;
}
// Detect if the outline is a rectangle or circle
if (Vector2.rectangular(outlinePoints)) {
return {
type: "rect",
x: Math.round(bounds.min.x),
y: Math.round(bounds.min.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height),
};
} else if (
Vector2.circular(
outlinePoints,
Math.max(bounds.width / 10, bounds.height / 10)
)
) {
return {
type: "circle",
x: Math.round(bounds.center.x),
y: Math.round(bounds.center.y),
radius: Math.round(Math.min(bounds.width, bounds.height) / 2),
};
} else {
// Flatten and round outline to save on storage size
const points = outlinePoints
.map(({ x, y }) => [Math.round(x), Math.round(y)])
.flat();
return { type: "path", points };
}
} else {
// Flatten and round outline to save on storage size
const points = baseOutline
.map(({ x, y }) => [Math.round(x), Math.round(y)])
.flat();
return { type: "path", points };
return defaultOutline;
}
} else {
return { type: "rect", x: 0, y: 0, width: 1, height: 1 };
} catch {
return defaultOutline;
}
}

View File

@ -39,7 +39,24 @@ export async function createTokenFromFile(file, userId) {
return Promise.reject();
}
let name = "Unknown Token";
let defaultSize = 1;
if (file.name) {
if (file.name.matchAll) {
// Match against a regex to find the grid size in the file name
// e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
const sizeMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
for (let match of sizeMatches) {
const matchX = parseInt(match[1]);
const matchY = parseInt(match[3]);
if (
!isNaN(matchX) &&
!isNaN(matchY) &&
matchX < 256 // Add check to test match isn't resolution
) {
defaultSize = matchX;
}
}
}
// Remove file extension
name = file.name.replace(/\.[^/.]+$/, "");
// Removed grid size expression
@ -79,6 +96,7 @@ export async function createTokenFromFile(file, userId) {
const token = {
name,
defaultSize,
thumbnail: thumbnail.id,
file: fileAsset.id,
id: uuid(),
@ -86,7 +104,6 @@ export async function createTokenFromFile(file, userId) {
created: Date.now(),
lastModified: Date.now(),
owner: userId,
defaultSize: 1,
defaultCategory: "character",
defaultLabel: "",
hideInSidebar: false,

View File

@ -17,12 +17,16 @@ import {
/**
* Returns a function that when called will snap a node to the current grid
* @param {number=} snappingSensitivity 1 = Always snap, 0 = never snap if undefined the default user setting will be used
* @param {boolean=} useCorners Snap to grid cell corners
*/
function useGridSnapping(snappingSensitivity) {
function useGridSnapping(snappingSensitivity, useCorners = true) {
const [defaultSnappingSensitivity] = useSetting(
"map.gridSnappingSensitivity"
);
snappingSensitivity = snappingSensitivity || defaultSnappingSensitivity;
snappingSensitivity =
snappingSensitivity === undefined
? defaultSnappingSensitivity
: snappingSensitivity;
const grid = useGrid();
const gridOffset = useGridOffset();
@ -57,7 +61,10 @@ function useGridSnapping(snappingSensitivity) {
gridCellPixelSize
);
const snapPoints = [cellPosition, ...cellCorners];
const snapPoints = [cellPosition];
if (useCorners) {
snapPoints.push(...cellCorners);
}
for (let snapPoint of snapPoints) {
const distanceToSnapPoint = Vector2.distance(offsetPosition, snapPoint);

View File

@ -1,5 +1,14 @@
function usePreventSelect() {
function clearSelection() {
if (window.getSelection) {
window.getSelection().removeAllRanges();
}
if (document.selection) {
document.selection.empty();
}
}
function preventSelect() {
clearSelection();
document.body.classList.add("no-select");
}

View File

@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import * as Sentry from "@sentry/react";
import { Dedupe } from "@sentry/integrations";
import App from "./App";
import Modal from "react-modal";
@ -16,10 +17,16 @@ if (!("PointerEvent" in window)) {
import("pepjs");
}
// Intersection observer polyfill
if (!("IntersectionObserver" in window)) {
import("intersection-observer");
}
if (process.env.REACT_APP_LOGGING === "true") {
Sentry.init({
dsn: process.env.REACT_APP_SENTRY_DSN,
release: "owlbear-rodeo@" + process.env.REACT_APP_VERSION,
integrations: [new Dedupe()],
// Ignore resize error as it is triggered by going fullscreen on slower computers
// Ignore quota error
// Ignore XDR encoding failure bug in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1678243

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

BIN
src/maps/Blank.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

BIN
src/maps/Grass.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

BIN
src/maps/Sand.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

BIN
src/maps/Stone.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

BIN
src/maps/Water.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

BIN
src/maps/Wood.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

View File

@ -1,13 +1,13 @@
import Case from "case";
import blankImage from "./Blank Grid 22x22.jpg";
import grassImage from "./Grass Grid 22x22.jpg";
import sandImage from "./Sand Grid 22x22.jpg";
import stoneImage from "./Stone Grid 22x22.jpg";
import waterImage from "./Water Grid 22x22.jpg";
import woodImage from "./Wood Grid 22x22.jpg";
import blankImage from "./Blank.jpg";
import grassImage from "./Grass.jpg";
import sandImage from "./Sand.jpg";
import stoneImage from "./Stone.jpg";
import waterImage from "./Water.jpg";
import woodImage from "./Wood.jpg";
import unknownImage from "./Unknown Grid 22x22.jpg";
import unknownImage from "./Unknown.jpg";
export const mapSources = {
blank: blankImage,
@ -42,7 +42,7 @@ export function getDefaultMaps(userId) {
type: "default",
created: mapKeys.length - i,
lastModified: Date.now(),
showGrid: false,
showGrid: key !== "stone",
snapToGrid: true,
group: "",
};

View File

@ -132,7 +132,7 @@ function EditMapModal({
<SimpleBar
style={{
minHeight: 0,
padding: "0 20px",
padding: "16px",
backgroundColor: theme.colors.muted,
margin: "0 8px",
height: "100%",

View File

@ -77,7 +77,7 @@ function EditTokenModal({ isOpen, onDone, token, onUpdateToken }) {
<SimpleBar
style={{
minHeight: 0,
padding: "0 20px",
padding: "16px",
backgroundColor: theme.colors.muted,
margin: "0 8px",
height: "100%",

View File

@ -237,65 +237,79 @@ function SelectDataModal({
function renderMapGroup(group) {
if (group.type === "item") {
const map = maps[group.id];
return (
<Label key={map.id} my={1} pl={4} sx={{ fontFamily: "body2" }}>
<Checkbox
checked={map.checked}
onChange={(e) => handleMapsChanged(e, [map])}
/>
{map.name}
</Label>
);
if (map) {
return (
<Label key={map.id} my={1} pl={4} sx={{ fontFamily: "body2" }}>
<Checkbox
checked={map.checked}
onChange={(e) => handleMapsChanged(e, [map])}
/>
{map.name}
</Label>
);
}
} else {
return renderGroupContainer(
group,
group.items.some((item) => maps[item.id].checked),
renderMapGroup,
(e, group) =>
handleMapsChanged(
e,
group.items.map((group) => maps[group.id])
)
);
if (group.items.some((item) => item.id in maps)) {
return renderGroupContainer(
group,
group.items.some((item) => maps[item.id]?.checked),
renderMapGroup,
(e, group) =>
handleMapsChanged(
e,
group.items
.filter((group) => group.id in maps)
.map((group) => maps[group.id])
)
);
}
}
}
function renderTokenGroup(group) {
if (group.type === "item") {
const token = tokens[group.id];
return (
<Box pl={4} my={1} key={token.id}>
<Label sx={{ fontFamily: "body2" }}>
<Checkbox
checked={token.checked}
onChange={(e) => handleTokensChanged(e, [token])}
disabled={token.type !== "default" && token.id in tokenUsedCount}
/>
{token.name}
</Label>
{token.id in tokenUsedCount && token.type !== "default" && (
<Text as="p" variant="caption" ml={4}>
Token used in {tokenUsedCount[token.id]} selected map
{tokenUsedCount[token.id] > 1 && "s"}
</Text>
)}
</Box>
);
if (token) {
return (
<Box pl={4} my={1} key={token.id}>
<Label sx={{ fontFamily: "body2" }}>
<Checkbox
checked={token.checked}
onChange={(e) => handleTokensChanged(e, [token])}
disabled={
token.type !== "default" && token.id in tokenUsedCount
}
/>
{token.name}
</Label>
{token.id in tokenUsedCount && token.type !== "default" && (
<Text as="p" variant="caption" ml={4}>
Token used in {tokenUsedCount[token.id]} selected map
{tokenUsedCount[token.id] > 1 && "s"}
</Text>
)}
</Box>
);
}
} else {
const checked =
group.items.some(
(item) => !(item.id in tokenUsedCount) && tokens[item.id].checked
) || group.items.every((item) => item.id in tokenUsedCount);
return renderGroupContainer(
group,
checked,
renderTokenGroup,
(e, group) =>
handleTokensChanged(
e,
group.items.map((group) => tokens[group.id])
)
);
if (group.items.some((item) => item.id in tokens)) {
const checked =
group.items.some(
(item) => !(item.id in tokenUsedCount) && tokens[item.id]?.checked
) || group.items.every((item) => item.id in tokenUsedCount);
return renderGroupContainer(
group,
checked,
renderTokenGroup,
(e, group) =>
handleTokensChanged(
e,
group.items
.filter((group) => group.id in tokens)
.map((group) => tokens[group.id])
)
);
}
}
}

View File

@ -16,7 +16,7 @@ const theme = {
background: "hsl(10, 10%, 98%)",
primary: "hsl(260, 100%, 80%)",
secondary: "hsl(290, 100%, 80%)",
highlight: "hsl(260, 20%, 40%)",
highlight: "hsl(260, 20%, 70%)",
muted: "hsla(230, 20%, 60%, 20%)",
overlay: "hsla(230, 100%, 97%, 80%)",
border: "hsla(10, 20%, 20%, 0.5)",
@ -188,6 +188,14 @@ const theme = {
},
},
textarea: {
"&:focus": {
outlineColor: "primary",
},
"&:disabled": {
backgroundColor: "muted",
opacity: 0.5,
borderColor: "text",
},
fontFamily: "body2",
},
},

160
yarn.lock
View File

@ -2312,68 +2312,78 @@
estree-walker "^1.0.1"
picomatch "^2.2.2"
"@sentry/browser@6.2.2":
version "6.2.2"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.2.2.tgz#4df4ad7026b269d85b63b79a75387ce5370bc705"
integrity sha512-K5UGyEePtVPZIFMoiRafhd4Ov0M1kdozVsVKIPZrOpJyjQdPNX+fYDNL/h0nVmgOlE2S/uu4fl4mEfe/6aLShw==
"@sentry/browser@6.3.0":
version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.3.0.tgz#523430667bff290220909fb96f35706ff808fed6"
integrity sha512-Rse9j5XwN9n7GnfW1mNscTS4YQ0oiBNJcaSk3Mw/vQT872Wh60yKyx5wxAw5GujFZI0NgdyPlZwZ/tGQwirRxA==
dependencies:
"@sentry/core" "6.2.2"
"@sentry/types" "6.2.2"
"@sentry/utils" "6.2.2"
"@sentry/core" "6.3.0"
"@sentry/types" "6.3.0"
"@sentry/utils" "6.3.0"
tslib "^1.9.3"
"@sentry/core@6.2.2":
version "6.2.2"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.2.2.tgz#ec86b5769f8855f43cb58e839f81f87074ec9a3f"
integrity sha512-qqWbvvXtymfXh7N5eEvk97MCnMURuyFIgqWdVD4MQM6yIfDCy36CyGfuQ3ViHTLZGdIfEOhLL9/f4kzf1RzqBA==
"@sentry/core@6.3.0":
version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.3.0.tgz#3b8db24918a00c0b77f1663fc6d9be925f66bb3e"
integrity sha512-voot/lJ9gRXB6bx6tVqbEbD6jOd4Sx6Rfmm6pzfpom9C0q+fjIZTatTLq8GdXj8DzxaH1MBDSwtaq/eC3NqYpA==
dependencies:
"@sentry/hub" "6.2.2"
"@sentry/minimal" "6.2.2"
"@sentry/types" "6.2.2"
"@sentry/utils" "6.2.2"
"@sentry/hub" "6.3.0"
"@sentry/minimal" "6.3.0"
"@sentry/types" "6.3.0"
"@sentry/utils" "6.3.0"
tslib "^1.9.3"
"@sentry/hub@6.2.2":
version "6.2.2"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.2.2.tgz#f451d8d3ad207e81556b4846d810226693e0444e"
integrity sha512-VR6uQGRYt6RP633FHShlSLj0LUKGVrlTeSlwCoooWM5FR9lmi6akAaweuxpG78/kZvXrAWpjX6/nuYwHKGwzGA==
"@sentry/hub@6.3.0":
version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.3.0.tgz#4225b3b0f31fe47f24d80753b257a4b57de5d651"
integrity sha512-lAnW3Om66t9IR+t1wya1NpOF9lGbvYG6Ca8wxJJGJ1t2PxKwyxpZKzRx0q8M1QFhlZ5cETCzxmM7lBEZ4QVCBg==
dependencies:
"@sentry/types" "6.2.2"
"@sentry/utils" "6.2.2"
"@sentry/types" "6.3.0"
"@sentry/utils" "6.3.0"
tslib "^1.9.3"
"@sentry/minimal@6.2.2":
version "6.2.2"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.2.2.tgz#01f41e0a6a6a2becfc99f6bb6f9c4bddf54f8dae"
integrity sha512-l0IgoGQgg1lTd4qDU8bQn25sbZBg8PwIHfuTLbGMlRr1flDXHOM1UXajWK/UKbAPelnU7M2JBSVzgl7PwjprzA==
"@sentry/integrations@^6.3.0":
version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-6.3.0.tgz#2091889d1d4a319d48e84ebce43405c6a7fee5b7"
integrity sha512-/bl0wykJr+7zJHmnAulI+/J1kT5AI/019jWSXX7nmfIhp2sRXNUw0jeNVh+xfwrbR6Ik6IleAyzwHNYKzedGVQ==
dependencies:
"@sentry/hub" "6.2.2"
"@sentry/types" "6.2.2"
"@sentry/types" "6.3.0"
"@sentry/utils" "6.3.0"
localforage "^1.8.1"
tslib "^1.9.3"
"@sentry/react@^6.2.2":
version "6.2.2"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.2.2.tgz#6a93fa1013b2b9e37a8c0bc16cf0cbf4353de4c6"
integrity sha512-yDuxPOD4j2WE5nX1p48GIqXwrrmwkjryFjtYvLgzGJkiGWLmGTrxrSqtUKrbqahJpKt3mi24Nkg0cMlsFB178g==
"@sentry/minimal@6.3.0":
version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.3.0.tgz#e64d87c92a4676a11168672a96589f46985f2b22"
integrity sha512-ZdPUwdPQkaKroy67NkwQRqmnfKyd/C1OyouM9IqYKyBjAInjOijwwc/Rd91PMHalvCOGfp1scNZYbZ+YFs/qQQ==
dependencies:
"@sentry/browser" "6.2.2"
"@sentry/minimal" "6.2.2"
"@sentry/types" "6.2.2"
"@sentry/utils" "6.2.2"
"@sentry/hub" "6.3.0"
"@sentry/types" "6.3.0"
tslib "^1.9.3"
"@sentry/react@^6.3.0":
version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.3.0.tgz#97763bf006991460126fa7235ebc662cd340f804"
integrity sha512-5+Q2p65WMxslaW96209wUp0kfqT0HTyVV+4TTCIOA6Aj3rnKesQaR44mXHXlQVTQh2/8fk1PTkMEsvWJdSPkjA==
dependencies:
"@sentry/browser" "6.3.0"
"@sentry/minimal" "6.3.0"
"@sentry/types" "6.3.0"
"@sentry/utils" "6.3.0"
hoist-non-react-statics "^3.3.2"
tslib "^1.9.3"
"@sentry/types@6.2.2":
version "6.2.2"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.2.2.tgz#9fc7795156680d3da5fc6ecc66702d8f7917f2b1"
integrity sha512-Y/1sRtw3a5JU4YdNBig8lLSVJ1UdYtuge+QP1CVLcLSAbq07Ok1bvF+Z+BlNcnHqle2Fl8aKuryG5Yu86enOyQ==
"@sentry/types@6.3.0":
version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.3.0.tgz#919cc1870f34b7126546c77e3c695052795d3add"
integrity sha512-xWyCYDmFPjS5ex60kxOOHbHEs4vs00qHbm0iShQfjl4OSg9S2azkcWofDmX8Xbn0FSOUXgdPCjNJW1B0bPVhCA==
"@sentry/utils@6.2.2":
version "6.2.2"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.2.2.tgz#69f7151db74e65a010cec062cc9ab3e30bf2c80a"
integrity sha512-qaee6X6VDNZ8HeO83/veaKw0KuhDE7j1R+Yryme3PywFzsoTzutDrEQjb7gvcHAhBaAYX8IHUBHgxcFI9BxI+w==
"@sentry/utils@6.3.0":
version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.3.0.tgz#e28645b6d4acd03a478e58bfe112ea02f81e94a0"
integrity sha512-NZzw4oLelgvCsVBG2e+ZtFtaBvgA7rZYtcGFbZTphhAlYoJ6JMCQUzYk0iwJK79yR1quh510x4UE0jynvvToWg==
dependencies:
"@sentry/types" "6.2.2"
"@sentry/types" "6.3.0"
tslib "^1.9.3"
"@sinonjs/commons@^1.7.0":
@ -7192,6 +7202,11 @@ image-outline@^0.1.0:
marching-squares "^0.2.0"
minimist "^1.2.0"
ndarray "^1.0.18"
immediate@~3.0.5:
version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
immer@8.0.1:
version "8.0.1"
@ -7317,6 +7332,11 @@ internal-slot@^1.0.2:
has "^1.0.3"
side-channel "^1.0.2"
intersection-observer@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.12.0.tgz#6c84628f67ce8698e5f9ccf857d97718745837aa"
integrity sha512-2Vkz8z46Dv401zTWudDGwO7KiGHNDkMv417T5ItcNYfmvHR/1qCTVBO9vwH8zZmQ0WkA/1ARwpysR9bsnop4NQ==
invariant@^2.2.2:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@ -8465,6 +8485,13 @@ levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
lie@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=
dependencies:
immediate "~3.0.5"
line-column@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/line-column/-/line-column-1.0.2.tgz#d25af2936b6f4849172b312e4792d1d987bc34a2"
@ -8527,6 +8554,13 @@ loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0:
emojis-list "^3.0.0"
json5 "^1.0.1"
localforage@^1.8.1:
version "1.9.0"
resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.9.0.tgz#f3e4d32a8300b362b4634cc4e066d9d00d2f09d1"
integrity sha512-rR1oyNrKulpe+VM9cYmcFn6tsHuokyVHFaCM3+osEmxaHTbEk8oQu6eGDfS6DQLWi/N67XRmB8ECG37OES368g==
dependencies:
lie "3.1.1"
locate-path@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@ -10857,6 +10891,11 @@ react-input-autosize@^3.0.0:
dependencies:
prop-types "^15.5.8"
react-intersection-observer@^8.32.0:
version "8.32.0"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-8.32.0.tgz#47249332e12e8bb99ed35a10bb7dd10446445a7b"
integrity sha512-RlC6FvS3MFShxTn4FHAy904bVjX5Nn4/eTjUkurW0fHK+M/fyQdXuyCy9+L7yjA+YMGogzzSJNc7M4UtfSKvtw==
react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -11062,6 +11101,15 @@ react-spring@^8.0.27:
"@babel/runtime" "^7.3.1"
prop-types "^15.5.8"
react-textarea-autosize@^8.3.3:
version "8.3.3"
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.3.tgz#f70913945369da453fd554c168f6baacd1fa04d8"
integrity sha512-2XlHXK2TDxS6vbQaoPbMOfQ8GK7+irc2fVK6QFIcC8GOnH3zI/v481n+j1L0WaPVvKxwesnY93fEfH++sus2rQ==
dependencies:
"@babel/runtime" "^7.10.2"
use-composed-ref "^1.0.0"
use-latest "^1.0.0"
react-toast-notifications@^2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/react-toast-notifications/-/react-toast-notifications-2.4.3.tgz#ebf2ee776615a97906cef214352cfd9fe800c583"
@ -12816,6 +12864,11 @@ tryer@^1.0.1:
resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==
ts-essentials@^2.0.3:
version "2.0.12"
resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-2.0.12.tgz#c9303f3d74f75fa7528c3d49b80e089ab09d8745"
integrity sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w==
ts-pnp@1.2.0, ts-pnp@^1.1.6:
version "1.2.0"
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92"
@ -13156,11 +13209,30 @@ url@^0.11.0:
punycode "1.3.2"
querystring "0.2.0"
use-composed-ref@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.1.0.tgz#9220e4e94a97b7b02d7d27eaeab0b37034438bbc"
integrity sha512-my1lNHGWsSDAhhVAT4MKs6IjBUtG6ZG11uUqexPH9PptiIZDQOzaF4f5tEbJ2+7qvNbtXNBbU3SfmN+fXlWDhg==
dependencies:
ts-essentials "^2.0.3"
use-image@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/use-image/-/use-image-1.0.7.tgz#b2c42b78d32868762a37631673eb169a244d5ad1"
integrity sha512-Z8hB8+lAGe25iqO3YaHO7bSvBSGErEakYQ6RGyRrPZoMDLKBIuZ67ikzn8f5ydjWorqFzeX+U3vVwnXoE1Q56Q==
use-isomorphic-layout-effect@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.1.tgz#7bb6589170cd2987a152042f9084f9effb75c225"
integrity sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ==
use-latest@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.0.tgz#a44f6572b8288e0972ec411bdd0840ada366f232"
integrity sha512-d2TEuG6nSLKQLAfW3By8mKr8HurOlTkul0sOpxbClIv4SQ4iOd7BYr7VIzdbktUCnv7dua/60xzd8igMU6jmyw==
dependencies:
use-isomorphic-layout-effect "^1.0.0"
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"