Merge branch 'test/v1.9.0' into preview/1.9.0
@ -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
|
||||
|
@ -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",
|
||||
|
@ -67,6 +67,7 @@ function Select({ creatable, ...props }) {
|
||||
primary25: theme.colors.highlight,
|
||||
},
|
||||
})}
|
||||
captureMenuScroll={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
22
src/components/TextareaAutoSize.css
Normal 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;
|
||||
}
|
8
src/components/TextareaAutoSize.js
Normal 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;
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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" />
|
||||
|
@ -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
|
||||
|
33
src/components/tile/LazyTile.js
Normal 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;
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
93
src/components/tile/SortableTilesDragOverlay.js
Normal 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;
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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}
|
||||
|
@ -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 } });
|
||||
}
|
||||
|
||||
|
@ -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)`,
|
||||
}}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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.
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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");
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
Before Width: | Height: | Size: 65 KiB |
BIN
src/maps/Blank.jpg
Executable file
After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 67 KiB |
BIN
src/maps/Grass.jpg
Executable file
After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 71 KiB |
BIN
src/maps/Sand.jpg
Executable file
After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 91 KiB |
BIN
src/maps/Stone.jpg
Executable file
After Width: | Height: | Size: 180 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 105 KiB |
BIN
src/maps/Water.jpg
Executable file
After Width: | Height: | Size: 132 KiB |
Before Width: | Height: | Size: 109 KiB |
BIN
src/maps/Wood.jpg
Executable file
After Width: | Height: | Size: 262 KiB |
@ -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: "",
|
||||
};
|
||||
|
@ -132,7 +132,7 @@ function EditMapModal({
|
||||
<SimpleBar
|
||||
style={{
|
||||
minHeight: 0,
|
||||
padding: "0 20px",
|
||||
padding: "16px",
|
||||
backgroundColor: theme.colors.muted,
|
||||
margin: "0 8px",
|
||||
height: "100%",
|
||||
|
@ -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%",
|
||||
|
@ -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])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
10
src/theme.js
@ -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
@ -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"
|
||||
|