Move drag and drop to dnd-kit

This commit is contained in:
Mitchell McCaffrey 2021-05-06 15:04:53 +10:00
parent bd522017a7
commit 8aa3bda90d
16 changed files with 322 additions and 359 deletions

View File

@ -5,6 +5,7 @@
"dependencies": {
"@babylonjs/core": "^4.2.0",
"@babylonjs/loaders": "^4.2.0",
"@dnd-kit/core": "3.0.0",
"@mitchemmc/dexie-export-import": "^1.0.1",
"@msgpack/msgpack": "^2.4.1",
"@sentry/react": "^6.2.2",
@ -25,7 +26,6 @@
"file-saver": "^2.0.5",
"fuse.js": "^6.4.6",
"image-outline": "^0.1.0",
"interactjs": "^1.10.8",
"konva": "^7.2.5",
"lodash.clonedeep": "^4.5.0",
"lodash.get": "^4.4.2",

View File

@ -13,12 +13,8 @@ import Donate from "./routes/Donate";
import { AuthProvider } from "./contexts/AuthContext";
import { DatabaseProvider } from "./contexts/DatabaseContext";
import { MapDataProvider } from "./contexts/MapDataContext";
import { TokenDataProvider } from "./contexts/TokenDataContext";
import { MapLoadingProvider } from "./contexts/MapLoadingContext";
import { SettingsProvider } from "./contexts/SettingsContext";
import { KeyboardProvider } from "./contexts/KeyboardContext";
import { AssetsProvider, AssetURLsProvider } from "./contexts/AssetsContext";
import { ToastProvider } from "./components/Toast";
@ -49,17 +45,7 @@ function App() {
<FAQ />
</Route>
<Route path="/game/:id">
<AssetsProvider>
<AssetURLsProvider>
<MapLoadingProvider>
<MapDataProvider>
<TokenDataProvider>
<Game />
</TokenDataProvider>
</MapDataProvider>
</MapLoadingProvider>
</AssetURLsProvider>
</AssetsProvider>
</Route>
<Route path="/">
<Home />

View File

@ -0,0 +1,27 @@
import React from "react";
import { useDraggable } from "@dnd-kit/core";
function Draggable({ id, children, data }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
data,
});
const style = {
border: "none",
background: "transparent",
margin: "0px",
padding: "0px",
cursor: "pointer",
touchAction: "none",
opacity: isDragging ? 0.5 : undefined,
};
return (
<button ref={setNodeRef} style={style} {...listeners} {...attributes}>
{children}
</button>
);
}
export default Draggable;

View File

@ -0,0 +1,18 @@
import React from "react";
import { useDroppable } from "@dnd-kit/core";
function Droppable({ id, children, disabled }) {
const { setNodeRef } = useDroppable({ id, disabled });
return (
<div style={{ width: "100%", height: "100%" }} ref={setNodeRef}>
{children}
</div>
);
}
Droppable.defaultProps = {
disabled: false,
};
export default Droppable;

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, IconButton } from "theme-ui";
import RemoveTokenIcon from "../icons/RemoveTokenIcon";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
function DragOverlay({ dragging, node, onRemove }) {
const [isRemoveHovered, setIsRemoveHovered] = useState(false);

View File

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { Box } from "theme-ui";
import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction";
@ -18,6 +19,8 @@ import TokenDragOverlay from "../token/TokenDragOverlay";
import NoteMenu from "../note/NoteMenu";
import NoteDragOverlay from "../note/NoteDragOverlay";
import Droppable from "../Droppable";
import {
AddShapeAction,
CutShapeAction,
@ -336,6 +339,8 @@ function Map({
);
return (
<Box sx={{ flexGrow: 1 }}>
<Droppable id="map" disabled={!map}>
<MapInteraction
map={map}
mapState={mapState}
@ -360,6 +365,8 @@ function Map({
{mapPointer}
{mapMeasure}
</MapInteraction>
</Droppable>
</Box>
);
}

View File

@ -181,11 +181,12 @@ function MapInteraction({
<GridProvider grid={map?.grid} width={mapWidth} height={mapHeight}>
<Box
sx={{
flexGrow: 1,
position: "relative",
cursor: getCursorForTool(selectedToolId),
touchAction: "none",
outline: "none",
width: "100%",
height: "100%",
}}
ref={containerRef}
className="map"

View File

@ -1,6 +1,6 @@
import React from "react";
import DragOverlay from "../DragOverlay";
import DragOverlay from "../map/DragOverlay";
function NoteDragOverlay({ onNoteRemove, noteId, noteGroup, dragging }) {
function handleNoteRemove() {

View File

@ -7,7 +7,7 @@ import { useDataURL } from "../../contexts/AssetsContext";
import { tokenSources, unknownSource } from "../../tokens";
function ListToken({ token, className }) {
function ListToken({ token }) {
const tokenURL = useDataURL(
token,
tokenSources,
@ -20,11 +20,10 @@ function ListToken({ token, className }) {
usePreventTouch(imageRef);
return (
<Box my={2} mx={3} sx={{ width: "48px", height: "48px" }}>
<Box py={1} sx={{ width: "48px", height: "56px" }}>
<Image
src={tokenURL}
ref={imageRef}
className={className}
sx={{
userSelect: "none",
touchAction: "none",
@ -32,8 +31,6 @@ function ListToken({ token, className }) {
height: "100%",
objectFit: "cover",
}}
// pass id into the dom element which is then used by the ProxyToken
data-id={token.id}
alt={token.name}
title={token.name}
/>

View File

@ -1,172 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { Image, Box } from "theme-ui";
import interact from "interactjs";
import usePortal from "../../hooks/usePortal";
import { useMapStage } from "../../contexts/MapStageContext";
/**
* @callback onProxyDragEnd
* @param {boolean} isOnMap whether the token was dropped on the map
* @param {Object} token the token that was dropped
*/
/**
*
* @param {string} tokenClassName The class name to attach the interactjs handler to
* @param {onProxyDragEnd} onProxyDragEnd Called when the proxy token is dropped
* @param {Object} tokens An optional mapping of tokens to use as a base when calling OnProxyDragEnd
*/
function ProxyToken({ tokenClassName, onProxyDragEnd, tokens }) {
const proxyContainer = usePortal("root");
const [imageSource, setImageSource] = useState("");
const proxyRef = useRef();
// Store the tokens in a ref and access in the interactjs loop
// This is needed to stop interactjs from creating multiple listeners
const tokensRef = useRef(tokens);
useEffect(() => {
tokensRef.current = tokens;
}, [tokens]);
const proxyOnMap = useRef(false);
const mapStageRef = useMapStage();
useEffect(() => {
interact(`.${tokenClassName}`).draggable({
listeners: {
start: (event) => {
let target = event.target;
// Hide the token and copy it's image to the proxy
target.parentElement.style.opacity = "0.25";
setImageSource(target.src);
let proxy = proxyRef.current;
if (proxy) {
// Find and set the initial offset of the token to the proxy
const proxyRect = proxy.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const xOffset = targetRect.left - proxyRect.left;
const yOffset = targetRect.top - proxyRect.top;
proxy.style.transform = `translate(${xOffset}px, ${yOffset}px)`;
proxy.setAttribute("data-x", xOffset);
proxy.setAttribute("data-y", yOffset);
// Copy width and height of target
proxy.style.width = `${targetRect.width}px`;
proxy.style.height = `${targetRect.height}px`;
}
},
move: (event) => {
let proxy = proxyRef.current;
// Move the proxy based off of the movment of the token
if (proxy) {
// keep the dragged position in the data-x/data-y attributes
const x =
(parseFloat(proxy.getAttribute("data-x")) || 0) + event.dx;
const y =
(parseFloat(proxy.getAttribute("data-y")) || 0) + event.dy;
proxy.style.transform = `translate(${x}px, ${y}px)`;
// Check whether the proxy is on the right or left hand side of the screen
// if not set proxyOnMap to true
const proxyRect = proxy.getBoundingClientRect();
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
proxyOnMap.current =
proxyRect.left > mapRect.left && proxyRect.right < mapRect.right;
// update the posiion attributes
proxy.setAttribute("data-x", x);
proxy.setAttribute("data-y", y);
}
},
end: (event) => {
let target = event.target;
const id = target.dataset.id;
let proxy = proxyRef.current;
if (proxy) {
const mapStage = mapStageRef.current;
if (onProxyDragEnd && mapStage) {
const mapImage = mapStage.findOne("#mapImage");
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
const position = {
x: event.clientX - mapRect.left,
y: event.clientY - mapRect.top,
};
const transform = mapImage.getAbsoluteTransform().copy().invert();
const relativePosition = transform.point(position);
const normalizedPosition = {
x: relativePosition.x / mapImage.width(),
y: relativePosition.y / mapImage.height(),
};
// Get the token from the supplied tokens if it exists
const token = tokensRef.current[id] || {};
onProxyDragEnd(proxyOnMap.current, {
...token,
x: normalizedPosition.x,
y: normalizedPosition.y,
});
}
// Reset the proxy position
proxy.style.transform = "translate(0px, 0px)";
proxy.setAttribute("data-x", 0);
proxy.setAttribute("data-y", 0);
}
// Show the token
target.parentElement.style.opacity = "1";
setImageSource("");
},
},
});
}, [onProxyDragEnd, tokenClassName, proxyContainer, mapStageRef]);
if (!imageSource) {
return null;
}
// Create a portal to allow the proxy to move past the bounds of the token
return ReactDOM.createPortal(
<Box
sx={{
position: "absolute",
overflow: "hidden",
top: 0,
left: 0,
bottom: 0,
right: 0,
}}
>
<Box
sx={{ position: "absolute", display: "flex", flexDirection: "column" }}
ref={proxyRef}
>
<Image
src={imageSource}
sx={{
touchAction: "none",
userSelect: "none",
width: "100%",
}}
/>
</Box>
</Box>,
proxyContainer
);
}
ProxyToken.defaultProps = {
tokens: {},
};
export default ProxyToken;

View File

@ -1,79 +1,52 @@
import React from "react";
import { createPortal } from "react-dom";
import { Box, Flex } from "theme-ui";
import shortid from "shortid";
import SimpleBar from "simplebar-react";
import { DragOverlay } from "@dnd-kit/core";
import ListToken from "./ListToken";
import ProxyToken from "./ProxyToken";
import SelectTokensButton from "./SelectTokensButton";
import { fromEntries } from "../../helpers/shared";
import Draggable from "../Draggable";
import useSetting from "../../hooks/useSetting";
import { useAuth } from "../../contexts/AuthContext";
import { useTokenData } from "../../contexts/TokenDataContext";
import { useDragId } from "../../contexts/DragContext";
const listTokenClassName = "list-token";
function TokenBar({ onMapTokenStateCreate }) {
const { userId } = useAuth();
const { ownedTokens, tokens } = useTokenData();
function TokenBar() {
const { ownedTokens } = useTokenData();
const [fullScreen] = useSetting("map.fullScreen");
function handleProxyDragEnd(isOnMap, token) {
if (isOnMap && onMapTokenStateCreate) {
// Create a token state from the dragged token
let tokenState = {
id: shortid.generate(),
tokenId: token.id,
owner: userId,
size: token.defaultSize,
category: token.defaultCategory,
label: token.defaultLabel,
statuses: [],
x: token.x,
y: token.y,
lastModifiedBy: userId,
lastModified: Date.now(),
rotation: 0,
locked: false,
visible: true,
type: token.type,
outline: token.outline,
width: token.width,
height: token.height,
};
if (token.type === "file") {
tokenState.file = token.file;
} else if (token.type === "default") {
tokenState.key = token.key;
}
onMapTokenStateCreate(tokenState);
}
}
const activeDragId = useDragId();
return (
<>
<Box
sx={{
height: "100%",
width: "80px",
minWidth: "80px",
overflow: "hidden",
overflowY: "scroll",
overflowX: "hidden",
display: fullScreen ? "none" : "block",
}}
>
<SimpleBar style={{ height: "calc(100% - 48px)", overflowX: "hidden" }}>
<SimpleBar
style={{
height: "calc(100% - 48px)",
overflowX: "hidden",
padding: "0 16px",
}}
>
{ownedTokens
.filter((token) => !token.hideInSidebar)
.map((token) => (
<ListToken
<Draggable
id={`sidebar-${token.id}`}
key={token.id}
token={token}
className={listTokenClassName}
/>
data={{ tokenId: token.id }}
>
<ListToken token={token} />
</Draggable>
))}
</SimpleBar>
<Flex
@ -86,13 +59,19 @@ function TokenBar({ onMapTokenStateCreate }) {
>
<SelectTokensButton />
</Flex>
</Box>
<ProxyToken
tokenClassName={listTokenClassName}
onProxyDragEnd={handleProxyDragEnd}
tokens={fromEntries(tokens.map((token) => [token.id, token]))}
{createPortal(
<DragOverlay>
{activeDragId && (
<ListToken
token={ownedTokens.find(
(token) => `sidebar-${token.id}` === activeDragId
)}
/>
</>
)}
</DragOverlay>,
document.body
)}
</Box>
);
}

View File

@ -6,7 +6,7 @@ import {
useMapHeight,
} from "../../contexts/MapInteractionContext";
import DragOverlay from "../DragOverlay";
import DragOverlay from "../map/DragOverlay";
function TokenDragOverlay({
onTokenStateRemove,

View File

@ -0,0 +1,36 @@
import React, { useState, useContext } from "react";
import { DndContext } from "@dnd-kit/core";
/**
* @type {React.Context<string|undefined>}
*/
const DragIdContext = React.createContext();
export function DragProvider({ children, onDragEnd }) {
const [activeDragId, setActiveDragId] = useState(null);
function handleDragStart({ active }) {
setActiveDragId(active.id);
}
function handleDragEnd(event) {
setActiveDragId(null);
onDragEnd && onDragEnd(event);
}
return (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<DragIdContext.Provider value={activeDragId}>
{children}
</DragIdContext.Provider>
</DndContext>
);
}
export function useDragId() {
const context = useContext(DragIdContext);
if (context === undefined) {
throw new Error("useDragId must be used within a DragProvider");
}
return context;
}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from "react";
import { useToasts } from "react-toast-notifications";
import { v4 as uuid } from "uuid";
import { useMapData } from "../contexts/MapDataContext";
import { useMapLoading } from "../contexts/MapLoadingContext";
@ -7,6 +8,9 @@ import { useAuth } from "../contexts/AuthContext";
import { useDatabase } from "../contexts/DatabaseContext";
import { useParty } from "../contexts/PartyContext";
import { useAssets } from "../contexts/AssetsContext";
import { DragProvider } from "../contexts/DragContext";
import { useTokenData } from "../contexts/TokenDataContext";
import { useMapStage } from "../contexts/MapStageContext";
import { omit } from "../helpers/shared";
@ -40,8 +44,10 @@ function NetworkedMapAndTokens({ session }) {
const { userId } = useAuth();
const partyState = useParty();
const { assetLoadStart, assetProgressUpdate, isLoading } = useMapLoading();
const mapStageRef = useMapStage();
const { updateMapState } = useMapData();
const { tokensById } = useTokenData();
const { getAsset, putAsset } = useAssets();
const [currentMap, setCurrentMap] = useState(null);
@ -379,6 +385,53 @@ function NetworkedMapAndTokens({ session }) {
});
}
function handleDragEnd({ active, over }) {
const tokenId = active?.data?.current?.tokenId;
const token = tokensById[tokenId];
const mapStage = mapStageRef.current;
if (over?.id === "map" && tokenId && token && mapStage) {
const mapImage = mapStage.findOne("#mapImage");
// TODO: Get proper pointer position when dnd-kit is updated
// https://github.com/clauderic/dnd-kit/issues/238
const pointerPosition = {
x: over.rect.width / 2,
y: over.rect.height / 2,
};
const transform = mapImage.getAbsoluteTransform().copy().invert();
const relativePosition = transform.point(pointerPosition);
const normalizedPosition = {
x: relativePosition.x / mapImage.width(),
y: relativePosition.y / mapImage.height(),
};
let tokenState = {
id: uuid(),
tokenId: token.id,
owner: userId,
size: token.defaultSize,
category: token.defaultCategory,
label: token.defaultLabel,
statuses: [],
x: normalizedPosition.x,
y: normalizedPosition.y,
lastModifiedBy: userId,
lastModified: Date.now(),
rotation: 0,
locked: false,
visible: true,
type: token.type,
outline: token.outline,
width: token.width,
height: token.height,
};
if (token.type === "file") {
tokenState.file = token.file;
} else if (token.type === "default") {
tokenState.key = token.key;
}
handleMapTokenStateCreate(tokenState);
}
}
useEffect(() => {
async function handlePeerData({ id, data, reply }) {
if (id === "assetRequest") {
@ -451,7 +504,7 @@ function NetworkedMapAndTokens({ session }) {
}
return (
<>
<DragProvider onDragEnd={handleDragEnd}>
<Map
map={currentMap}
mapState={currentMapState}
@ -475,8 +528,8 @@ function NetworkedMapAndTokens({ session }) {
disabledTokens={disabledMapTokens}
session={session}
/>
<TokenBar onMapTokenStateCreate={handleMapTokenStateCreate} />
</>
<TokenBar />
</DragProvider>
);
}

View File

@ -18,6 +18,10 @@ import { MapStageProvider } from "../contexts/MapStageContext";
import { useDatabase } from "../contexts/DatabaseContext";
import { PlayerProvider } from "../contexts/PlayerContext";
import { PartyProvider } from "../contexts/PartyContext";
import { AssetsProvider, AssetURLsProvider } from "../contexts/AssetsContext";
import { MapDataProvider } from "../contexts/MapDataContext";
import { TokenDataProvider } from "../contexts/TokenDataContext";
import { MapLoadingProvider } from "../contexts/MapLoadingContext";
import NetworkedMapAndTokens from "../network/NetworkedMapAndTokens";
import NetworkedParty from "../network/NetworkedParty";
@ -84,7 +88,6 @@ function Game() {
};
}, [session]);
// Join game
useEffect(() => {
if (sessionStatus === "ready" && databaseStatus !== "loading") {
@ -103,6 +106,11 @@ function Game() {
const mapStageRef = useRef();
return (
<AssetsProvider>
<AssetURLsProvider>
<MapLoadingProvider>
<MapDataProvider>
<TokenDataProvider>
<PlayerProvider session={session}>
<PartyProvider session={session}>
<MapStageProvider value={mapStageRef}>
@ -124,13 +132,15 @@ function Game() {
>
<Box p={1}>
<Text as="p" variant="body2">
{peerError} See <Link to="/faq#connection">FAQ</Link> for more
information.
{peerError} See <Link to="/faq#connection">FAQ</Link>{" "}
for more information.
</Text>
</Box>
</Banner>
<OfflineBanner isOpen={sessionStatus === "offline"} />
<ReconnectBanner isOpen={sessionStatus === "reconnecting"} />
<ReconnectBanner
isOpen={sessionStatus === "reconnecting"}
/>
<AuthModal
isOpen={sessionStatus === "auth"}
onSubmit={handleAuthSubmit}
@ -147,6 +157,11 @@ function Game() {
</MapStageProvider>
</PartyProvider>
</PlayerProvider>
</TokenDataProvider>
</MapDataProvider>
</MapLoadingProvider>
</AssetURLsProvider>
</AssetsProvider>
);
}

View File

@ -1796,6 +1796,29 @@
resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18"
integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==
"@dnd-kit/accessibility@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz#b56e3750414fd907b7d6972b3116aa8f96d07fde"
integrity sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow==
dependencies:
tslib "^2.0.0"
"@dnd-kit/core@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-3.0.0.tgz#96dadb6b2dba05ab177e0190b33ae219017bc167"
integrity sha512-QxHLfZHOLkQWK0FPbr5hefWZzsdZfDuluKPwIK1bT2lwp/4hmFXRA6ivqX3FT4g8T0d2de2C1jxYhKM4H3uMQw==
dependencies:
"@dnd-kit/accessibility" "^3.0.0"
"@dnd-kit/utilities" "^2.0.0"
tslib "^2.0.0"
"@dnd-kit/utilities@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-2.0.0.tgz#a8462dff65c6f606ecbe95273c7e263b14a1ab97"
integrity sha512-bjs49yMNzMM+BYRsBUhTqhTk6HEvhuY3leFt6Em6NaYGgygaMbtGbbXof/UXBv7rqyyi0OkmBBnrCCcxqS2t/g==
dependencies:
tslib "^2.0.0"
"@emotion/cache@^10.0.27":
version "10.0.29"
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0"
@ -2008,11 +2031,6 @@
dependencies:
"@hapi/hoek" "^8.3.0"
"@interactjs/types@1.10.8":
version "1.10.8"
resolved "https://registry.yarnpkg.com/@interactjs/types/-/types-1.10.8.tgz#098da479de9c5ac9c8ba97d113746b7dcd9c2204"
integrity sha512-qU2QfnN7r8AU4mSd2W3XmRtR0d35R1PReIT9b5YzpNLX9S0OQgNBLrEEFyXpa9alq/9h6wYNIwPCVAsknF5uZw==
"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
@ -7274,13 +7292,6 @@ ini@^1.3.5:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
interactjs@^1.10.8:
version "1.10.8"
resolved "https://registry.yarnpkg.com/interactjs/-/interactjs-1.10.8.tgz#a85b6e89ebf2ed88ea1678287ffcf0becf0dfb1c"
integrity sha512-hIU82lF9mplmAHVTUmZbHMHKm96AwlD0zWGuf9krKt2dhALHsMOdU+yVilPqIv1VpNAGV66F9B14Rfs4ulS2nA==
dependencies:
"@interactjs/types" "1.10.8"
internal-ip@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-4.3.0.tgz#845452baad9d2ca3b69c635a137acb9a0dad0907"
@ -12816,6 +12827,11 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"
integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==
tsutils@^3.17.1:
version "3.17.1"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"