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_STRIPE_URL=https://payment.owlbear.rodeo
REACT_APP_VERSION=$npm_package_version REACT_APP_VERSION=$npm_package_version
REACT_APP_PREVIEW=false REACT_APP_PREVIEW=false
REACT_APP_LOGGING=false REACT_APP_LOGGING=true
REACT_APP_FATHOM_SITE_ID=VMSHBPKD 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", "@dnd-kit/sortable": "^3.1.0",
"@mitchemmc/dexie-export-import": "^1.0.1", "@mitchemmc/dexie-export-import": "^1.0.1",
"@msgpack/msgpack": "^2.4.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", "@stripe/stripe-js": "^1.13.1",
"@tensorflow/tfjs": "^3.3.0", "@tensorflow/tfjs": "^3.3.0",
"@testing-library/jest-dom": "^5.11.9", "@testing-library/jest-dom": "^5.11.9",
@ -27,6 +28,7 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"fuse.js": "^6.4.6", "fuse.js": "^6.4.6",
"image-outline": "^0.1.0", "image-outline": "^0.1.0",
"intersection-observer": "^0.12.0",
"konva": "^7.2.5", "konva": "^7.2.5",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
@ -39,6 +41,7 @@
"raw.macro": "^0.4.2", "raw.macro": "^0.4.2",
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-intersection-observer": "^8.32.0",
"react-konva": "^17.0.1-3", "react-konva": "^17.0.1-3",
"react-markdown": "4", "react-markdown": "4",
"react-media": "^2.0.0-rc.1", "react-media": "^2.0.0-rc.1",
@ -49,6 +52,7 @@
"react-scripts": "^4.0.3", "react-scripts": "^4.0.3",
"react-select": "^4.2.1", "react-select": "^4.2.1",
"react-spring": "^8.0.27", "react-spring": "^8.0.27",
"react-textarea-autosize": "^8.3.3",
"react-toast-notifications": "^2.4.3", "react-toast-notifications": "^2.4.3",
"react-use-gesture": "^9.1.3", "react-use-gesture": "^9.1.3",
"shortid": "^2.2.15", "shortid": "^2.2.15",

View File

@ -67,6 +67,7 @@ function Select({ creatable, ...props }) {
primary25: theme.colors.highlight, primary25: theme.colors.highlight,
}, },
})} })}
captureMenuScroll={false}
{...props} {...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 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 { useToasts } from "react-toast-notifications";
import LoadingOverlay from "../LoadingOverlay"; import LoadingOverlay from "../LoadingOverlay";
@ -171,39 +171,61 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
{...overlayListeners} {...overlayListeners}
> >
<Flex <Flex
bg="overlay"
sx={{ sx={{
height: "10%", height: "10%",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
color: droppingType === "maps" ? "primary" : "text", color: droppingType === "maps" ? "primary" : "text",
opacity: droppingType === "maps" ? 1 : 0.8, opacity: droppingType === "maps" ? 1 : 0.8,
margin: "4px 16px", width: "100%",
border: "1px dashed", position: "relative",
width: "calc(100% - 32px)",
borderRadius: "12px",
}} }}
onDragEnter={handleMapsOver} 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" }}> <Text sx={{ pointerEvents: "none", userSelect: "none" }}>
Drop as map Drop as map
</Text> </Text>
</Flex> </Flex>
<Flex <Flex
bg="overlay"
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
color: droppingType === "tokens" ? "primary" : "text", color: droppingType === "tokens" ? "primary" : "text",
opacity: droppingType === "tokens" ? 1 : 0.8, opacity: droppingType === "tokens" ? 1 : 0.8,
margin: "4px 16px", width: "100%",
border: "1px dashed", position: "relative",
width: "calc(100% - 32px)",
borderRadius: "12px",
}} }}
onDragEnter={handleTokensOver} 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" }}> <Text sx={{ pointerEvents: "none", userSelect: "none" }}>
Drop as token Drop as token
</Text> </Text>

View File

@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Box } from "theme-ui"; import { Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import MapControls from "./MapControls"; import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction"; import MapInteraction from "./MapInteraction";
@ -49,6 +50,8 @@ function Map({
disabledTokens, disabledTokens,
session, session,
}) { }) {
const { addToast } = useToasts();
const { tokensById } = useTokenData(); const { tokensById } = useTokenData();
const [selectedToolId, setSelectedToolId] = useState("move"); const [selectedToolId, setSelectedToolId] = useState("move");
@ -232,6 +235,7 @@ function Map({
onShapesCut={handleFogShapesCut} onShapesCut={handleFogShapesCut}
onShapesRemove={handleFogShapesRemove} onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit} onShapesEdit={handleFogShapesEdit}
onShapeError={addToast}
active={selectedToolId === "fog"} active={selectedToolId === "fog"}
toolSettings={settings.fog} toolSettings={settings.fog}
editable={allowFogDrawing && !settings.fog.preview} editable={allowFogDrawing && !settings.fog.preview}

View File

@ -38,8 +38,10 @@ import {
Tick, Tick,
getRelativePointerPosition, getRelativePointerPosition,
} from "../../helpers/konva"; } from "../../helpers/konva";
import { keyBy } from "../../helpers/shared";
import SubtractShapeAction from "../../actions/SubtractShapeAction"; import SubtractShapeAction from "../../actions/SubtractShapeAction";
import CutShapeAction from "../../actions/CutShapeAction";
import useSetting from "../../hooks/useSetting"; import useSetting from "../../hooks/useSetting";
@ -52,6 +54,7 @@ function MapFog({
onShapesCut, onShapesCut,
onShapesRemove, onShapesRemove,
onShapesEdit, onShapesEdit,
onShapeError,
active, active,
toolSettings, toolSettings,
editable, editable,
@ -214,6 +217,8 @@ function MapFog({
) { ) {
const cut = toolSettings.useFogCut; const cut = toolSettings.useFogCut;
let drawingShapes = [drawingShape]; let drawingShapes = [drawingShape];
// Filter out hidden or visible shapes if single layer enabled
if (!toolSettings.multilayer) { if (!toolSettings.multilayer) {
const shapesToSubtract = shapes.filter((shape) => const shapesToSubtract = shapes.filter((shape) =>
cut ? !shape.visible : shape.visible cut ? !shape.visible : shape.visible
@ -228,22 +233,32 @@ function MapFog({
} }
if (drawingShapes.length > 0) { 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) { 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 { } else {
onShapesAdd(drawingShapes); onShapesAdd(
drawingShapes.map((shape) => ({ ...shape, color: "black" }))
);
}
} else {
if (cut) {
onShapeError("Fog already cut");
} else {
onShapeError("Fog already placed");
} }
} }
setDrawingShape(null); setDrawingShape(null);
@ -373,6 +388,7 @@ function MapFog({
}; };
let polygonShapes = [polygonShape]; let polygonShapes = [polygonShape];
// Filter out hidden or visible shapes if single layer enabled
if (!toolSettings.multilayer) { if (!toolSettings.multilayer) {
const shapesToSubtract = shapes.filter((shape) => const shapesToSubtract = shapes.filter((shape) =>
cut ? !shape.visible : shape.visible cut ? !shape.visible : shape.visible
@ -388,7 +404,15 @@ function MapFog({
if (polygonShapes.length > 0) { if (polygonShapes.length > 0) {
if (cut) { 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 { } else {
onShapesAdd( onShapesAdd(
polygonShapes.map((shape) => ({ polygonShapes.map((shape) => ({
@ -399,10 +423,23 @@ function MapFog({
})) }))
); );
} }
} else {
if (cut) {
onShapeError("Fog already cut");
} else {
onShapeError("Fog already placed");
}
} }
setDrawingShape(null); setDrawingShape(null);
}, [toolSettings, drawingShape, onShapesCut, onShapesAdd, shapes]); }, [
toolSettings,
drawingShape,
onShapesCut,
onShapesAdd,
onShapeError,
shapes,
]);
// Add keyboard shortcuts // Add keyboard shortcuts
function handleKeyDown(event) { function handleKeyDown(event) {

View File

@ -44,7 +44,10 @@ function MapMeasure({ map, active }) {
const gridScale = parseGridScale(active && grid.measurement.scale); const gridScale = parseGridScale(active && grid.measurement.scale);
const snapPositionToGrid = useGridSnapping(); const snapPositionToGrid = useGridSnapping(
grid.measurement.type === "euclidean" ? 0 : 1,
false
);
useEffect(() => { useEffect(() => {
if (!active) { if (!active) {

View File

@ -4,6 +4,7 @@ import MapTile from "./MapTile";
import MapTileGroup from "./MapTileGroup"; import MapTileGroup from "./MapTileGroup";
import SortableTiles from "../tile/SortableTiles"; import SortableTiles from "../tile/SortableTiles";
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
import { getGroupItems } from "../../helpers/group"; import { getGroupItems } from "../../helpers/group";
@ -20,21 +21,25 @@ function MapTiles({ mapsById, onMapEdit, onMapSelect, subgroup }) {
function renderTile(group) { function renderTile(group) {
if (group.type === "item") { if (group.type === "item") {
const map = mapsById[group.id]; const map = mapsById[group.id];
const isSelected = selectedGroupIds.includes(group.id); if (map) {
const canEdit = const isSelected = selectedGroupIds.includes(group.id);
isSelected && selectMode === "single" && selectedGroupIds.length === 1; const canEdit =
return ( isSelected &&
<MapTile selectMode === "single" &&
key={map.id} selectedGroupIds.length === 1;
map={map} return (
isSelected={isSelected} <MapTile
onSelect={onGroupSelect} key={map.id}
onEdit={onMapEdit} map={map}
onDoubleClick={() => canEdit && onMapSelect(group.id)} isSelected={isSelected}
canEdit={canEdit} onSelect={onGroupSelect}
badges={[`${map.grid.size.x}x${map.grid.size.y}`]} onEdit={onMapEdit}
/> onDoubleClick={() => canEdit && onMapSelect(group.id)}
); canEdit={canEdit}
badges={[`${map.grid.size.x}x${map.grid.size.y}`]}
/>
);
}
} else { } else {
const isSelected = selectedGroupIds.includes(group.id); const isSelected = selectedGroupIds.includes(group.id);
const items = getGroupItems(group); 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; export default MapTiles;

View File

@ -248,16 +248,20 @@ function MapToken({
hitFunc={() => {}} hitFunc={() => {}}
/> />
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}> <Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
<TokenStatus {tokenState.statuses?.length > 0 ? (
tokenState={tokenState} <TokenStatus
width={tokenWidth} tokenState={tokenState}
height={tokenHeight} width={tokenWidth}
/> height={tokenHeight}
<TokenLabel />
tokenState={tokenState} ) : null}
width={tokenWidth} {tokenState.label ? (
height={tokenHeight} <TokenLabel
/> tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
</Group> </Group>
</animated.Group> </animated.Group>
); );

View File

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

View File

@ -1,7 +1,8 @@
import React, { useEffect, useState } from "react"; 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 Slider from "../Slider";
import TextareaAutosize from "../TextareaAutoSize";
import MapMenu from "../map/MapMenu"; import MapMenu from "../map/MapMenu";
@ -128,20 +129,12 @@ function NoteMenu({
}} }}
sx={{ alignItems: "center" }} sx={{ alignItems: "center" }}
> >
<Textarea <TextareaAutosize
id="changeNoteText" id="changeNoteText"
onChange={handleTextChange} onChange={handleTextChange}
value={(note && note.text) || ""} value={(note && note.text) || ""}
sx={{
padding: "4px",
border: "none",
":focus": {
outline: "none",
},
resize: "none",
}}
rows={1}
onKeyPress={handleTextKeyPress} onKeyPress={handleTextKeyPress}
maxRows={4}
/> />
</Flex> </Flex>
<Box <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 React from "react";
import { createPortal } from "react-dom";
import { DragOverlay } from "@dnd-kit/core";
import { SortableContext } from "@dnd-kit/sortable"; import { SortableContext } from "@dnd-kit/sortable";
import { animated, useSpring, config } from "react-spring";
import { Badge } from "theme-ui";
import { moveGroupsInto } from "../../helpers/group"; import { moveGroupsInto } from "../../helpers/group";
import { keyBy } from "../../helpers/shared"; import { keyBy } from "../../helpers/shared";
import Vector2 from "../../helpers/Vector2";
import SortableTile from "./SortableTile"; import SortableTile from "./SortableTile";
import LazyTile from "./LazyTile";
import { import {
useTileDrag, useTileDragId,
useTileDragCursor,
useTileOverGroupId,
BASE_SORTABLE_ID, BASE_SORTABLE_ID,
GROUP_SORTABLE_ID, GROUP_SORTABLE_ID,
GROUP_ID_PREFIX,
} from "../../contexts/TileDragContext"; } from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext"; import { useGroup } from "../../contexts/GroupContext";
function SortableTiles({ renderTile, subgroup }) { function SortableTiles({ renderTile, subgroup }) {
const { dragId, overId, dragCursor } = useTileDrag(); const dragId = useTileDragId();
const dragCursor = useTileDragCursor();
const overGroupId = useTileOverGroupId();
const { const {
groups, groups,
selectedGroupIds: allSelectedIds, selectedGroupIds: allSelectedIds,
@ -46,15 +45,6 @@ function SortableTiles({ renderTile, subgroup }) {
const disableSorting = (openGroupId && !subgroup) || filter; const disableSorting = (openGroupId && !subgroup) || filter;
const disableGrouping = subgroup || disableSorting || 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) { function renderSortableGroup(group, selectedGroups) {
if (overGroupId === group.id && dragId && group.id !== dragId) { if (overGroupId === group.id && dragId && group.id !== dragId) {
// If dragging over a group render a preview of that group // If dragging over a group render a preview of that group
@ -68,57 +58,6 @@ function SortableTiles({ renderTile, subgroup }) {
return renderTile(group); 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() { function renderTiles() {
const groupsByIds = keyBy(activeGroups, "id"); const groupsByIds = keyBy(activeGroups, "id");
const selectedGroupIdsSet = new Set(selectedGroupIds); const selectedGroupIdsSet = new Set(selectedGroupIds);
@ -138,17 +77,18 @@ function SortableTiles({ renderTile, subgroup }) {
const disableTileGrouping = const disableTileGrouping =
disableGrouping || isDragging || hasSelectedContainerGroup; disableGrouping || isDragging || hasSelectedContainerGroup;
return ( return (
<SortableTile <LazyTile key={group.id}>
id={group.id} <SortableTile
key={group.id} id={group.id}
disableGrouping={disableTileGrouping} disableGrouping={disableTileGrouping}
disableSorting={disableSorting} disableSorting={disableSorting}
hidden={group.id === openGroupId} hidden={group.id === openGroupId}
isDragging={isDragging} isDragging={isDragging}
cursor={dragCursor} cursor={dragCursor}
> >
{renderSortableGroup(group, selectedGroups)} {renderSortableGroup(group, selectedGroups)}
</SortableTile> </SortableTile>
</LazyTile>
); );
}); });
} }
@ -156,7 +96,6 @@ function SortableTiles({ renderTile, subgroup }) {
return ( return (
<SortableContext items={activeGroups} id={sortableId}> <SortableContext items={activeGroups} id={sortableId}>
{renderTiles()} {renderTiles()}
{createPortal(dragId && renderDragOverlays(), document.body)}
</SortableContext> </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, children,
}) { }) {
return ( return (
<Flex <Box
sx={{ sx={{
position: "relative", position: "relative",
width: "100%", width: "100%",
height: "0", height: "0",
paddingTop: "100%", paddingTop: "100%",
borderRadius: "4px", borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
overflow: "hidden", overflow: "hidden",
userSelect: "none", userSelect: "none",
}} }}
@ -120,7 +118,7 @@ function Tile({
</IconButton> </IconButton>
</Box> </Box>
)} )}
</Flex> </Box>
); );
} }

View File

@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Box, Flex } from "theme-ui"; import { Box, Flex, Grid } from "theme-ui";
import SimpleBar from "simplebar-react"; import SimpleBar from "simplebar-react";
import { import {
DragOverlay, DragOverlay,
@ -165,9 +165,16 @@ function TokenBar({ onMapTokensStateCreate }) {
padding: "0 16px", 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))} {tokenGroups.map((group) => renderToken(group))}
</Flex> </Grid>
</SimpleBar> </SimpleBar>
<Flex <Flex
bg="muted" bg="muted"

View File

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

View File

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

View File

@ -48,7 +48,14 @@ function TokenEditBar({ disabled, onLoad }) {
async function handleTokensHide(hideInSidebar) { async function handleTokensHide(hideInSidebar) {
const selectedTokens = getSelectedTokens(); const selectedTokens = getSelectedTokens();
const selectedTokenIds = selectedTokens.map((token) => token.id); 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"; import useSetting from "../../hooks/useSetting";
const maxTokenSize = 3; const maxTokenSize = 3;
const defaultFontSize = 16;
function TokenLabel({ tokenState, width, height }) { function TokenLabel({ tokenState, width, height }) {
const [labelSize] = useSetting("map.labelSize"); const [labelSize] = useSetting("map.labelSize");
@ -13,7 +14,7 @@ function TokenLabel({ tokenState, width, height }) {
const paddingX = const paddingX =
(height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize); (height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
const [fontSize, setFontSize] = useState(1); const [fontScale, setFontScale] = useState(0);
useEffect(() => { useEffect(() => {
const text = textSizerRef.current; const text = textSizerRef.current;
@ -22,15 +23,14 @@ function TokenLabel({ tokenState, width, height }) {
} }
let fontSizes = []; let fontSizes = [];
for (let size = 10 * labelSize; size >= 6; size--) { for (let size = 20 * labelSize; size >= 6; size--) {
fontSizes.push( const verticalSize = height / size / tokenState.size;
(height / size / tokenState.size) * const tokenSize = Math.min(tokenState.size, maxTokenSize);
Math.min(tokenState.size, maxTokenSize) * const fontSize = verticalSize * tokenSize * labelSize;
labelSize fontSizes.push(fontSize);
);
} }
function findFontSize() { function findFontScale() {
const size = fontSizes.reduce((prev, curr) => { const size = fontSizes.reduce((prev, curr) => {
text.fontSize(curr); text.fontSize(curr);
const textWidth = text.getTextWidth() + paddingX * 2; const textWidth = text.getTextWidth() + paddingX * 2;
@ -39,12 +39,12 @@ function TokenLabel({ tokenState, width, height }) {
} else { } else {
return prev; return prev;
} }
}); }, 1);
setFontSize(size); setFontScale(size / defaultFontSize);
} }
findFontSize(); findFontScale();
}, [ }, [
tokenState.label, tokenState.label,
tokenState.visible, tokenState.visible,
@ -56,44 +56,47 @@ function TokenLabel({ tokenState, width, height }) {
]); ]);
const [rectWidth, setRectWidth] = useState(0); const [rectWidth, setRectWidth] = useState(0);
const [textWidth, setTextWidth] = useState(0);
useEffect(() => { useEffect(() => {
const text = textRef.current; const text = textRef.current;
if (text && tokenState.label) { if (text && tokenState.label) {
setRectWidth(text.getTextWidth() + paddingX * 2); setRectWidth(text.getTextWidth() * fontScale + paddingX * 2);
setTextWidth(text.getTextWidth() * fontScale);
} else { } else {
setRectWidth(0); setRectWidth(0);
setTextWidth(0);
} }
}, [tokenState.label, paddingX, width, fontSize]); }, [tokenState.label, paddingX, width, fontScale]);
const textRef = useRef(); const textRef = useRef();
const textSizerRef = useRef(); const textSizerRef = useRef();
return ( return (
<Group y={height - (fontSize + paddingY) / 2}> <Group y={height - (defaultFontSize * fontScale + paddingY) / 2}>
<Rect <Rect
y={-paddingY / 2} y={-paddingY / 2}
width={rectWidth} width={rectWidth}
offsetX={width / 2} offsetX={width / 2}
x={width - rectWidth / 2} x={width - rectWidth / 2}
height={fontSize + paddingY} height={defaultFontSize * fontScale + paddingY}
fill="hsla(230, 25%, 18%, 0.8)" fill="hsla(230, 25%, 18%, 0.8)"
cornerRadius={(fontSize + paddingY) / 2} cornerRadius={(defaultFontSize * fontScale + 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={() => {}}
/> />
<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 */} {/* Use an invisible text block to work out text sizing */}
<Text <Text
visible={false} visible={false}

View File

@ -50,7 +50,7 @@ function TokenMenu({
}, [isOpen, tokenState, wasOpen, tokenImage]); }, [isOpen, tokenState, wasOpen, tokenImage]);
function handleLabelChange(event) { 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 } }); tokenState && onTokenStateChange({ [tokenState.id]: { label: label } });
} }

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState, useContext } from "react"; import React, { useState, useContext, useEffect } from "react";
import { import {
MouseSensor, MouseSensor,
TouchSensor, TouchSensor,
@ -16,7 +16,9 @@ import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group";
import usePreventSelect from "../hooks/usePreventSelect"; 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 BASE_SORTABLE_ID = "__base__";
export const GROUP_SORTABLE_ID = "__group__"; export const GROUP_SORTABLE_ID = "__group__";
@ -61,7 +63,7 @@ export function TileDragProvider({
} = useGroup(); } = useGroup();
const mouseSensor = useSensor(MouseSensor, { const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 250, tolerance: 5 }, activationConstraint: { distance: 3 },
}); });
const touchSensor = useSensor(TouchSensor, { const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 }, activationConstraint: { delay: 250, tolerance: 5 },
@ -70,16 +72,23 @@ export function TileDragProvider({
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor); const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const [dragId, setDragId] = useState(); const [dragId, setDragId] = useState(null);
const [overId, setOverId] = useState(); const [overId, setOverId] = useState(null);
const [dragCursor, setDragCursor] = useState("pointer"); const [dragCursor, setDragCursor] = useState("pointer");
const [preventSelect, resumeSelect] = usePreventSelect(); 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) { function handleDragStart(event) {
const { active, over } = event; const { active, over } = event;
setDragId(active.id); setDragId(active.id);
setOverId(over?.id); setOverId(over?.id || null);
if (!selectedGroupIds.includes(active.id)) { if (!selectedGroupIds.includes(active.id)) {
onGroupSelect(active.id); onGroupSelect(active.id);
} }
@ -93,7 +102,7 @@ export function TileDragProvider({
function handleDragOver(event) { function handleDragOver(event) {
const { over } = event; const { over } = event;
setOverId(over?.id); setOverId(over?.id || null);
if (over) { if (over) {
if ( if (
over.id.startsWith(UNGROUP_ID) || over.id.startsWith(UNGROUP_ID) ||
@ -111,8 +120,8 @@ export function TileDragProvider({
function handleDragEnd(event) { function handleDragEnd(event) {
const { active, over, overlayNodeClientRect } = event; const { active, over, overlayNodeClientRect } = event;
setDragId(); setDragId(null);
setOverId(); setOverId(null);
setDragCursor("pointer"); setDragCursor("pointer");
if (active && over && active.id !== over.id) { if (active && over && active.id !== over.id) {
let selectedIndices = selectedGroupIds.map((groupId) => let selectedIndices = selectedGroupIds.map((groupId) =>
@ -165,8 +174,8 @@ export function TileDragProvider({
} }
function handleDragCancel(event) { function handleDragCancel(event) {
setDragId(); setDragId(null);
setOverId(); setOverId(null);
setDragCursor("pointer"); setDragCursor("pointer");
resumeSelect(); resumeSelect();
@ -210,8 +219,6 @@ export function TileDragProvider({
return closestCenter(otherRects, rect); return closestCenter(otherRects, rect);
} }
const value = { dragId, overId, dragCursor };
return ( return (
<DragContext <DragContext
onDragStart={handleDragStart} onDragStart={handleDragStart}
@ -221,19 +228,37 @@ export function TileDragProvider({
sensors={sensors} sensors={sensors}
collisionDetection={customCollisionDetection} collisionDetection={customCollisionDetection}
> >
<TileDragContext.Provider value={value}> <TileDragIdContext.Provider value={dragId}>
{children} <TileOverGroupIdContext.Provider value={overGroupId}>
</TileDragContext.Provider> <TileDragCursorContext.Provider value={dragCursor}>
{children}
</TileDragCursorContext.Provider>
</TileOverGroupIdContext.Provider>
</TileDragIdContext.Provider>
</DragContext> </DragContext>
); );
} }
export function useTileDrag() { export function useTileDragId() {
const context = useContext(TileDragContext); const context = useContext(TileDragIdContext);
if (context === undefined) { if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider"); throw new Error("useTileDrag must be used within a TileDragProvider");
} }
return context; 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) { 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) { for (let change of changes) {
if (change.table === "tokens") { if (change.table === "tokens") {
if (change.type === 1) { if (change.type === 1) {
// Created // Created
const token = change.obj; const token = change.obj;
setTokens((prevTokens) => [token, ...prevTokens]); tokensCreated.push(token);
} else if (change.type === 2) { } else if (change.type === 2) {
// Updated // Updated
const token = change.obj; const token = change.obj;
setTokens((prevTokens) => { tokensUpdated[token.id] = token;
const newTokens = [...prevTokens];
const i = newTokens.findIndex((t) => t.id === token.id);
if (i > -1) {
newTokens[i] = token;
}
return newTokens;
});
} else if (change.type === 3) { } else if (change.type === 3) {
// Deleted // Deleted
const id = change.key; const id = change.key;
setTokens((prevTokens) => { tokensDeleted.push(id);
const filtered = prevTokens.filter((token) => token.id !== id);
return filtered;
});
} }
} }
if (change.table === "groups") { 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); 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. 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. 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. - 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. - 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. - Modals have a new transition animation to match the new group UI.
- Cursors now better represent drag and drop actions. - 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. - 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. - 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. - 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. - 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); const bCoord = getNearestCellCoordinates(grid, b.x, b.y, cellSize);
if (grid.type === "square") { if (grid.type === "square") {
if (grid.measurement.type === "chebyshev") { if (grid.measurement.type === "chebyshev") {
return Math.max( return Vector2.max(Vector2.abs(Vector2.subtract(aCoord, bCoord)));
Math.abs(aCoord.x - bCoord.x),
Math.abs(aCoord.y - bCoord.y)
);
} else if (grid.measurement.type === "alternating") { } else if (grid.measurement.type === "alternating") {
// Alternating diagonal distance like D&D 3.5 and Pathfinder // Alternating diagonal distance like D&D 3.5 and Pathfinder
const delta = Vector2.abs(Vector2.subtract(aCoord, bCoord)); const delta = Vector2.abs(Vector2.subtract(aCoord, bCoord));
@ -300,7 +297,7 @@ export function gridDistance(grid, a, b, cellSize) {
const min = Vector2.min(delta); const min = Vector2.min(delta);
return max - min + Math.floor(1.5 * min); return max - min + Math.floor(1.5 * min);
} else if (grid.measurement.type === "euclidean") { } 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") { } else if (grid.measurement.type === "manhattan") {
return Math.abs(aCoord.x - bCoord.x) + Math.abs(aCoord.y - bCoord.y); 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 2
); );
} else if (grid.measurement.type === "euclidean") { } 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} * @returns {Outline}
*/ */
export function getImageOutline(image, maxPoints = 100) { 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 (outlinePoints) {
if (baseOutline.length > maxPoints) { if (outlinePoints.length > maxPoints) {
baseOutline = Vector2.resample(baseOutline, maxPoints); outlinePoints = Vector2.resample(outlinePoints, maxPoints);
} }
const bounds = Vector2.getBoundingBox(baseOutline); const bounds = Vector2.getBoundingBox(outlinePoints);
if (Vector2.rectangular(baseOutline)) {
return { // Reject outline if it's area is less than 5% of the image
type: "rect", const imageArea = image.width * image.height;
x: Math.round(bounds.min.x), const area = bounds.width * bounds.height;
y: Math.round(bounds.min.y), if (area < imageArea * 0.05) {
width: Math.round(bounds.width), return defaultOutline;
height: Math.round(bounds.height), }
};
} else if ( // Detect if the outline is a rectangle or circle
Vector2.circular( if (Vector2.rectangular(outlinePoints)) {
baseOutline, return {
Math.max(bounds.width / 10, bounds.height / 10) type: "rect",
) x: Math.round(bounds.min.x),
) { y: Math.round(bounds.min.y),
return { width: Math.round(bounds.width),
type: "circle", height: Math.round(bounds.height),
x: Math.round(bounds.center.x), };
y: Math.round(bounds.center.y), } else if (
radius: Math.round(Math.min(bounds.width, bounds.height) / 2), 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 { } else {
// Flatten and round outline to save on storage size return defaultOutline;
const points = baseOutline
.map(({ x, y }) => [Math.round(x), Math.round(y)])
.flat();
return { type: "path", points };
} }
} else { } catch {
return { type: "rect", x: 0, y: 0, width: 1, height: 1 }; return defaultOutline;
} }
} }

View File

@ -39,7 +39,24 @@ export async function createTokenFromFile(file, userId) {
return Promise.reject(); return Promise.reject();
} }
let name = "Unknown Token"; let name = "Unknown Token";
let defaultSize = 1;
if (file.name) { 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 // Remove file extension
name = file.name.replace(/\.[^/.]+$/, ""); name = file.name.replace(/\.[^/.]+$/, "");
// Removed grid size expression // Removed grid size expression
@ -79,6 +96,7 @@ export async function createTokenFromFile(file, userId) {
const token = { const token = {
name, name,
defaultSize,
thumbnail: thumbnail.id, thumbnail: thumbnail.id,
file: fileAsset.id, file: fileAsset.id,
id: uuid(), id: uuid(),
@ -86,7 +104,6 @@ export async function createTokenFromFile(file, userId) {
created: Date.now(), created: Date.now(),
lastModified: Date.now(), lastModified: Date.now(),
owner: userId, owner: userId,
defaultSize: 1,
defaultCategory: "character", defaultCategory: "character",
defaultLabel: "", defaultLabel: "",
hideInSidebar: false, hideInSidebar: false,

View File

@ -17,12 +17,16 @@ import {
/** /**
* Returns a function that when called will snap a node to the current grid * 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 {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( const [defaultSnappingSensitivity] = useSetting(
"map.gridSnappingSensitivity" "map.gridSnappingSensitivity"
); );
snappingSensitivity = snappingSensitivity || defaultSnappingSensitivity; snappingSensitivity =
snappingSensitivity === undefined
? defaultSnappingSensitivity
: snappingSensitivity;
const grid = useGrid(); const grid = useGrid();
const gridOffset = useGridOffset(); const gridOffset = useGridOffset();
@ -57,7 +61,10 @@ function useGridSnapping(snappingSensitivity) {
gridCellPixelSize gridCellPixelSize
); );
const snapPoints = [cellPosition, ...cellCorners]; const snapPoints = [cellPosition];
if (useCorners) {
snapPoints.push(...cellCorners);
}
for (let snapPoint of snapPoints) { for (let snapPoint of snapPoints) {
const distanceToSnapPoint = Vector2.distance(offsetPosition, snapPoint); const distanceToSnapPoint = Vector2.distance(offsetPosition, snapPoint);

View File

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

View File

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { Dedupe } from "@sentry/integrations";
import App from "./App"; import App from "./App";
import Modal from "react-modal"; import Modal from "react-modal";
@ -16,10 +17,16 @@ if (!("PointerEvent" in window)) {
import("pepjs"); import("pepjs");
} }
// Intersection observer polyfill
if (!("IntersectionObserver" in window)) {
import("intersection-observer");
}
if (process.env.REACT_APP_LOGGING === "true") { if (process.env.REACT_APP_LOGGING === "true") {
Sentry.init({ Sentry.init({
dsn: process.env.REACT_APP_SENTRY_DSN, dsn: process.env.REACT_APP_SENTRY_DSN,
release: "owlbear-rodeo@" + process.env.REACT_APP_VERSION, 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 resize error as it is triggered by going fullscreen on slower computers
// Ignore quota error // Ignore quota error
// Ignore XDR encoding failure bug in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1678243 // 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 Case from "case";
import blankImage from "./Blank Grid 22x22.jpg"; import blankImage from "./Blank.jpg";
import grassImage from "./Grass Grid 22x22.jpg"; import grassImage from "./Grass.jpg";
import sandImage from "./Sand Grid 22x22.jpg"; import sandImage from "./Sand.jpg";
import stoneImage from "./Stone Grid 22x22.jpg"; import stoneImage from "./Stone.jpg";
import waterImage from "./Water Grid 22x22.jpg"; import waterImage from "./Water.jpg";
import woodImage from "./Wood Grid 22x22.jpg"; import woodImage from "./Wood.jpg";
import unknownImage from "./Unknown Grid 22x22.jpg"; import unknownImage from "./Unknown.jpg";
export const mapSources = { export const mapSources = {
blank: blankImage, blank: blankImage,
@ -42,7 +42,7 @@ export function getDefaultMaps(userId) {
type: "default", type: "default",
created: mapKeys.length - i, created: mapKeys.length - i,
lastModified: Date.now(), lastModified: Date.now(),
showGrid: false, showGrid: key !== "stone",
snapToGrid: true, snapToGrid: true,
group: "", group: "",
}; };

View File

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

View File

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

View File

@ -237,65 +237,79 @@ function SelectDataModal({
function renderMapGroup(group) { function renderMapGroup(group) {
if (group.type === "item") { if (group.type === "item") {
const map = maps[group.id]; const map = maps[group.id];
return ( if (map) {
<Label key={map.id} my={1} pl={4} sx={{ fontFamily: "body2" }}> return (
<Checkbox <Label key={map.id} my={1} pl={4} sx={{ fontFamily: "body2" }}>
checked={map.checked} <Checkbox
onChange={(e) => handleMapsChanged(e, [map])} checked={map.checked}
/> onChange={(e) => handleMapsChanged(e, [map])}
{map.name} />
</Label> {map.name}
); </Label>
);
}
} else { } else {
return renderGroupContainer( if (group.items.some((item) => item.id in maps)) {
group, return renderGroupContainer(
group.items.some((item) => maps[item.id].checked), group,
renderMapGroup, group.items.some((item) => maps[item.id]?.checked),
(e, group) => renderMapGroup,
handleMapsChanged( (e, group) =>
e, handleMapsChanged(
group.items.map((group) => maps[group.id]) e,
) group.items
); .filter((group) => group.id in maps)
.map((group) => maps[group.id])
)
);
}
} }
} }
function renderTokenGroup(group) { function renderTokenGroup(group) {
if (group.type === "item") { if (group.type === "item") {
const token = tokens[group.id]; const token = tokens[group.id];
return ( if (token) {
<Box pl={4} my={1} key={token.id}> return (
<Label sx={{ fontFamily: "body2" }}> <Box pl={4} my={1} key={token.id}>
<Checkbox <Label sx={{ fontFamily: "body2" }}>
checked={token.checked} <Checkbox
onChange={(e) => handleTokensChanged(e, [token])} checked={token.checked}
disabled={token.type !== "default" && token.id in tokenUsedCount} onChange={(e) => handleTokensChanged(e, [token])}
/> disabled={
{token.name} token.type !== "default" && token.id in tokenUsedCount
</Label> }
{token.id in tokenUsedCount && token.type !== "default" && ( />
<Text as="p" variant="caption" ml={4}> {token.name}
Token used in {tokenUsedCount[token.id]} selected map </Label>
{tokenUsedCount[token.id] > 1 && "s"} {token.id in tokenUsedCount && token.type !== "default" && (
</Text> <Text as="p" variant="caption" ml={4}>
)} Token used in {tokenUsedCount[token.id]} selected map
</Box> {tokenUsedCount[token.id] > 1 && "s"}
); </Text>
)}
</Box>
);
}
} else { } else {
const checked = if (group.items.some((item) => item.id in tokens)) {
group.items.some( const checked =
(item) => !(item.id in tokenUsedCount) && tokens[item.id].checked group.items.some(
) || group.items.every((item) => item.id in tokenUsedCount); (item) => !(item.id in tokenUsedCount) && tokens[item.id]?.checked
return renderGroupContainer( ) || group.items.every((item) => item.id in tokenUsedCount);
group, return renderGroupContainer(
checked, group,
renderTokenGroup, checked,
(e, group) => renderTokenGroup,
handleTokensChanged( (e, group) =>
e, handleTokensChanged(
group.items.map((group) => tokens[group.id]) 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%)", background: "hsl(10, 10%, 98%)",
primary: "hsl(260, 100%, 80%)", primary: "hsl(260, 100%, 80%)",
secondary: "hsl(290, 100%, 80%)", secondary: "hsl(290, 100%, 80%)",
highlight: "hsl(260, 20%, 40%)", highlight: "hsl(260, 20%, 70%)",
muted: "hsla(230, 20%, 60%, 20%)", muted: "hsla(230, 20%, 60%, 20%)",
overlay: "hsla(230, 100%, 97%, 80%)", overlay: "hsla(230, 100%, 97%, 80%)",
border: "hsla(10, 20%, 20%, 0.5)", border: "hsla(10, 20%, 20%, 0.5)",
@ -188,6 +188,14 @@ const theme = {
}, },
}, },
textarea: { textarea: {
"&:focus": {
outlineColor: "primary",
},
"&:disabled": {
backgroundColor: "muted",
opacity: 0.5,
borderColor: "text",
},
fontFamily: "body2", fontFamily: "body2",
}, },
}, },

160
yarn.lock
View File

@ -2312,68 +2312,78 @@
estree-walker "^1.0.1" estree-walker "^1.0.1"
picomatch "^2.2.2" picomatch "^2.2.2"
"@sentry/browser@6.2.2": "@sentry/browser@6.3.0":
version "6.2.2" version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.2.2.tgz#4df4ad7026b269d85b63b79a75387ce5370bc705" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.3.0.tgz#523430667bff290220909fb96f35706ff808fed6"
integrity sha512-K5UGyEePtVPZIFMoiRafhd4Ov0M1kdozVsVKIPZrOpJyjQdPNX+fYDNL/h0nVmgOlE2S/uu4fl4mEfe/6aLShw== integrity sha512-Rse9j5XwN9n7GnfW1mNscTS4YQ0oiBNJcaSk3Mw/vQT872Wh60yKyx5wxAw5GujFZI0NgdyPlZwZ/tGQwirRxA==
dependencies: dependencies:
"@sentry/core" "6.2.2" "@sentry/core" "6.3.0"
"@sentry/types" "6.2.2" "@sentry/types" "6.3.0"
"@sentry/utils" "6.2.2" "@sentry/utils" "6.3.0"
tslib "^1.9.3" tslib "^1.9.3"
"@sentry/core@6.2.2": "@sentry/core@6.3.0":
version "6.2.2" version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.2.2.tgz#ec86b5769f8855f43cb58e839f81f87074ec9a3f" resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.3.0.tgz#3b8db24918a00c0b77f1663fc6d9be925f66bb3e"
integrity sha512-qqWbvvXtymfXh7N5eEvk97MCnMURuyFIgqWdVD4MQM6yIfDCy36CyGfuQ3ViHTLZGdIfEOhLL9/f4kzf1RzqBA== integrity sha512-voot/lJ9gRXB6bx6tVqbEbD6jOd4Sx6Rfmm6pzfpom9C0q+fjIZTatTLq8GdXj8DzxaH1MBDSwtaq/eC3NqYpA==
dependencies: dependencies:
"@sentry/hub" "6.2.2" "@sentry/hub" "6.3.0"
"@sentry/minimal" "6.2.2" "@sentry/minimal" "6.3.0"
"@sentry/types" "6.2.2" "@sentry/types" "6.3.0"
"@sentry/utils" "6.2.2" "@sentry/utils" "6.3.0"
tslib "^1.9.3" tslib "^1.9.3"
"@sentry/hub@6.2.2": "@sentry/hub@6.3.0":
version "6.2.2" version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.2.2.tgz#f451d8d3ad207e81556b4846d810226693e0444e" resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.3.0.tgz#4225b3b0f31fe47f24d80753b257a4b57de5d651"
integrity sha512-VR6uQGRYt6RP633FHShlSLj0LUKGVrlTeSlwCoooWM5FR9lmi6akAaweuxpG78/kZvXrAWpjX6/nuYwHKGwzGA== integrity sha512-lAnW3Om66t9IR+t1wya1NpOF9lGbvYG6Ca8wxJJGJ1t2PxKwyxpZKzRx0q8M1QFhlZ5cETCzxmM7lBEZ4QVCBg==
dependencies: dependencies:
"@sentry/types" "6.2.2" "@sentry/types" "6.3.0"
"@sentry/utils" "6.2.2" "@sentry/utils" "6.3.0"
tslib "^1.9.3" tslib "^1.9.3"
"@sentry/minimal@6.2.2": "@sentry/integrations@^6.3.0":
version "6.2.2" version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.2.2.tgz#01f41e0a6a6a2becfc99f6bb6f9c4bddf54f8dae" resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-6.3.0.tgz#2091889d1d4a319d48e84ebce43405c6a7fee5b7"
integrity sha512-l0IgoGQgg1lTd4qDU8bQn25sbZBg8PwIHfuTLbGMlRr1flDXHOM1UXajWK/UKbAPelnU7M2JBSVzgl7PwjprzA== integrity sha512-/bl0wykJr+7zJHmnAulI+/J1kT5AI/019jWSXX7nmfIhp2sRXNUw0jeNVh+xfwrbR6Ik6IleAyzwHNYKzedGVQ==
dependencies: dependencies:
"@sentry/hub" "6.2.2" "@sentry/types" "6.3.0"
"@sentry/types" "6.2.2" "@sentry/utils" "6.3.0"
localforage "^1.8.1"
tslib "^1.9.3" tslib "^1.9.3"
"@sentry/react@^6.2.2": "@sentry/minimal@6.3.0":
version "6.2.2" version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.2.2.tgz#6a93fa1013b2b9e37a8c0bc16cf0cbf4353de4c6" resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.3.0.tgz#e64d87c92a4676a11168672a96589f46985f2b22"
integrity sha512-yDuxPOD4j2WE5nX1p48GIqXwrrmwkjryFjtYvLgzGJkiGWLmGTrxrSqtUKrbqahJpKt3mi24Nkg0cMlsFB178g== integrity sha512-ZdPUwdPQkaKroy67NkwQRqmnfKyd/C1OyouM9IqYKyBjAInjOijwwc/Rd91PMHalvCOGfp1scNZYbZ+YFs/qQQ==
dependencies: dependencies:
"@sentry/browser" "6.2.2" "@sentry/hub" "6.3.0"
"@sentry/minimal" "6.2.2" "@sentry/types" "6.3.0"
"@sentry/types" "6.2.2" tslib "^1.9.3"
"@sentry/utils" "6.2.2"
"@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" hoist-non-react-statics "^3.3.2"
tslib "^1.9.3" tslib "^1.9.3"
"@sentry/types@6.2.2": "@sentry/types@6.3.0":
version "6.2.2" version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.2.2.tgz#9fc7795156680d3da5fc6ecc66702d8f7917f2b1" resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.3.0.tgz#919cc1870f34b7126546c77e3c695052795d3add"
integrity sha512-Y/1sRtw3a5JU4YdNBig8lLSVJ1UdYtuge+QP1CVLcLSAbq07Ok1bvF+Z+BlNcnHqle2Fl8aKuryG5Yu86enOyQ== integrity sha512-xWyCYDmFPjS5ex60kxOOHbHEs4vs00qHbm0iShQfjl4OSg9S2azkcWofDmX8Xbn0FSOUXgdPCjNJW1B0bPVhCA==
"@sentry/utils@6.2.2": "@sentry/utils@6.3.0":
version "6.2.2" version "6.3.0"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.2.2.tgz#69f7151db74e65a010cec062cc9ab3e30bf2c80a" resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.3.0.tgz#e28645b6d4acd03a478e58bfe112ea02f81e94a0"
integrity sha512-qaee6X6VDNZ8HeO83/veaKw0KuhDE7j1R+Yryme3PywFzsoTzutDrEQjb7gvcHAhBaAYX8IHUBHgxcFI9BxI+w== integrity sha512-NZzw4oLelgvCsVBG2e+ZtFtaBvgA7rZYtcGFbZTphhAlYoJ6JMCQUzYk0iwJK79yR1quh510x4UE0jynvvToWg==
dependencies: dependencies:
"@sentry/types" "6.2.2" "@sentry/types" "6.3.0"
tslib "^1.9.3" tslib "^1.9.3"
"@sinonjs/commons@^1.7.0": "@sinonjs/commons@^1.7.0":
@ -7192,6 +7202,11 @@ image-outline@^0.1.0:
marching-squares "^0.2.0" marching-squares "^0.2.0"
minimist "^1.2.0" minimist "^1.2.0"
ndarray "^1.0.18" 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: immer@8.0.1:
version "8.0.1" version "8.0.1"
@ -7317,6 +7332,11 @@ internal-slot@^1.0.2:
has "^1.0.3" has "^1.0.3"
side-channel "^1.0.2" 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: invariant@^2.2.2:
version "2.2.4" version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" 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" prelude-ls "~1.1.2"
type-check "~0.3.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: line-column@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/line-column/-/line-column-1.0.2.tgz#d25af2936b6f4849172b312e4792d1d987bc34a2" 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" emojis-list "^3.0.0"
json5 "^1.0.1" 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: locate-path@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" 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: dependencies:
prop-types "^15.5.8" 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: react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" 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" "@babel/runtime" "^7.3.1"
prop-types "^15.5.8" 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: react-toast-notifications@^2.4.3:
version "2.4.3" version "2.4.3"
resolved "https://registry.yarnpkg.com/react-toast-notifications/-/react-toast-notifications-2.4.3.tgz#ebf2ee776615a97906cef214352cfd9fe800c583" 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" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== 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: ts-pnp@1.2.0, ts-pnp@^1.1.6:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" 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" punycode "1.3.2"
querystring "0.2.0" 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: use-image@^1.0.7:
version "1.0.7" version "1.0.7"
resolved "https://registry.yarnpkg.com/use-image/-/use-image-1.0.7.tgz#b2c42b78d32868762a37631673eb169a244d5ad1" resolved "https://registry.yarnpkg.com/use-image/-/use-image-1.0.7.tgz#b2c42b78d32868762a37631673eb169a244d5ad1"
integrity sha512-Z8hB8+lAGe25iqO3YaHO7bSvBSGErEakYQ6RGyRrPZoMDLKBIuZ67ikzn8f5ydjWorqFzeX+U3vVwnXoE1Q56Q== 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: use@^3.1.0:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"