diff --git a/package.json b/package.json index a7698031..0fe42007 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "ramda": "^0.26.1", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-dropzone": "^11.4.2", "react-masonry-css": "^1.0.16", "react-popper": "^2.2.3", "react-redux": "^7.2.2", @@ -69,6 +70,7 @@ ] }, "devDependencies": { + "@types/throttle-debounce": "^2.1.0", "@craco/craco": "5.8.0", "@types/classnames": "^2.2.7", "@types/marked": "^1.2.2", diff --git a/src/components/comment/CommentForm/index.tsx b/src/components/comment/CommentForm/index.tsx index 7fbddc87..119ad897 100644 --- a/src/components/comment/CommentForm/index.tsx +++ b/src/components/comment/CommentForm/index.tsx @@ -11,7 +11,7 @@ import { CommentFormAttaches } from '~/components/comment/CommentFormAttaches'; import { LoaderCircle } from '~/components/input/LoaderCircle'; import { IComment, INode } from '~/redux/types'; import { EMPTY_COMMENT } from '~/redux/node/constants'; -import { CommentFormDropzone } from '~/components/comment/CommentFormDropzone'; +import { UploadDropzone } from '~/components/upload/UploadDropzone'; import styles from './styles.module.scss'; import { ERROR_LITERAL } from '~/constants/errors'; import { useInputPasteUpload } from '~/utils/hooks/useInputPasteUpload'; @@ -50,7 +50,7 @@ const CommentForm: FC = ({ comment, nodeId, onCancelEdit }) => { useInputPasteUpload(textarea, uploader.uploadFiles); return ( - +
@@ -103,7 +103,7 @@ const CommentForm: FC = ({ comment, nodeId, onCancelEdit }) => {
-
+ ); }; diff --git a/src/components/comment/CommentFormAttaches/index.tsx b/src/components/comment/CommentFormAttaches/index.tsx index c0d2b0af..1523a578 100644 --- a/src/components/comment/CommentFormAttaches/index.tsx +++ b/src/components/comment/CommentFormAttaches/index.tsx @@ -5,7 +5,7 @@ import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid'; import { IFile } from '~/redux/types'; import { SortEnd } from 'react-sortable-hoc'; import { moveArrItem } from '~/utils/fn'; -import { useDropZone } from '~/utils/hooks'; +import { useFileDropZone } from '~/utils/hooks'; import { COMMENT_FILE_TYPES, UPLOAD_TYPES } from '~/redux/uploads/constants'; import { useFileUploaderContext } from '~/utils/hooks/useFileUploader'; @@ -29,7 +29,7 @@ const CommentFormAttaches: FC = () => { pending, ]); - const onDrop = useDropZone(uploadFiles, COMMENT_FILE_TYPES); + const onDrop = useFileDropZone(uploadFiles, COMMENT_FILE_TYPES); const hasImageAttaches = images.length > 0 || pendingImages.length > 0; const hasAudioAttaches = audios.length > 0 || pendingAudios.length > 0; diff --git a/src/components/comment/CommentFormDropzone/index.tsx b/src/components/comment/CommentFormDropzone/index.tsx deleted file mode 100644 index 5c06fb67..00000000 --- a/src/components/comment/CommentFormDropzone/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { FC } from 'react'; -import { COMMENT_FILE_TYPES } from '~/redux/uploads/constants'; -import { useDropZone } from '~/utils/hooks'; - -interface IProps { - onUpload: (files: File[]) => void; -} - -const CommentFormDropzone: FC = ({ children, onUpload }) => { - const onDrop = useDropZone(onUpload, COMMENT_FILE_TYPES); - return
{children}
; -}; - -export { CommentFormDropzone }; diff --git a/src/components/editors/AudioEditor/index.tsx b/src/components/editors/AudioEditor/index.tsx index c817a16a..81c92931 100644 --- a/src/components/editors/AudioEditor/index.tsx +++ b/src/components/editors/AudioEditor/index.tsx @@ -12,12 +12,13 @@ import { useNodeImages } from '~/utils/hooks/node/useNodeImages'; import { useNodeAudios } from '~/utils/hooks/node/useNodeAudios'; import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik'; import { useFileUploaderContext } from '~/utils/hooks/useFileUploader'; +import { UploadDropzone } from '~/components/upload/UploadDropzone'; type IProps = NodeEditorProps; const AudioEditor: FC = () => { const { values } = useNodeFormContext(); - const { pending, setFiles } = useFileUploaderContext()!; + const { pending, setFiles, uploadFiles } = useFileUploaderContext()!; const images = useNodeImages(values); const audios = useNodeAudios(values); @@ -35,10 +36,12 @@ const AudioEditor: FC = () => { const setAudios = useCallback(values => setFiles([...values, ...images]), [setFiles, images]); return ( -
- - -
+ +
+ + +
+
); }; diff --git a/src/components/editors/EditorActionsPanel/styles.module.scss b/src/components/editors/EditorActionsPanel/styles.module.scss index 5bda9666..e272b93a 100644 --- a/src/components/editors/EditorActionsPanel/styles.module.scss +++ b/src/components/editors/EditorActionsPanel/styles.module.scss @@ -11,9 +11,13 @@ z-index: 10; display: flex; flex-direction: row; + pointer-events: none; + touch-action: none; & > * { margin: 0 $gap / 2; + pointer-events: all; + touch-action: auto; &:first-child { margin-left: 0; diff --git a/src/components/editors/EditorFiller/index.tsx b/src/components/editors/EditorFiller/index.tsx index d2de7b7b..d49d750b 100644 --- a/src/components/editors/EditorFiller/index.tsx +++ b/src/components/editors/EditorFiller/index.tsx @@ -1,9 +1,10 @@ import React, { FC } from 'react'; import { Filler } from '~/components/containers/Filler'; import { IEditorComponentProps } from '~/redux/node/types'; +import styles from './styles.module.scss'; type IProps = IEditorComponentProps & {}; -const EditorFiller: FC = () => ; +const EditorFiller: FC = () => ; export { EditorFiller }; diff --git a/src/components/editors/EditorFiller/styles.module.scss b/src/components/editors/EditorFiller/styles.module.scss new file mode 100644 index 00000000..6f0ce1d0 --- /dev/null +++ b/src/components/editors/EditorFiller/styles.module.scss @@ -0,0 +1,4 @@ +.filler { + touch-action: none; + pointer-events: none; +} diff --git a/src/components/editors/ImageEditor/index.tsx b/src/components/editors/ImageEditor/index.tsx index d401bbb6..8dc2a0f0 100644 --- a/src/components/editors/ImageEditor/index.tsx +++ b/src/components/editors/ImageEditor/index.tsx @@ -1,22 +1,21 @@ -import React, { FC, useMemo, useCallback } from 'react'; -import { connect } from 'react-redux'; -import { INode, IFile } from '~/redux/types'; -import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; -import { selectUploads } from '~/redux/uploads/selectors'; +import React, { FC } from 'react'; import { ImageGrid } from '~/components/editors/ImageGrid'; import styles from './styles.module.scss'; import { NodeEditorProps } from '~/redux/node/types'; import { useFileUploaderContext } from '~/utils/hooks/useFileUploader'; +import { UploadDropzone } from '~/components/upload/UploadDropzone'; type IProps = NodeEditorProps; const ImageEditor: FC = () => { - const { pending, files, setFiles } = useFileUploaderContext()!; + const { pending, files, setFiles, uploadFiles } = useFileUploaderContext()!; return ( -
- -
+ +
+ +
+
); }; diff --git a/src/components/editors/ImageEditor/styles.module.scss b/src/components/editors/ImageEditor/styles.module.scss index 10286e1a..c8fc297b 100644 --- a/src/components/editors/ImageEditor/styles.module.scss +++ b/src/components/editors/ImageEditor/styles.module.scss @@ -1,6 +1,9 @@ -@import "src/styles/variables"; +@import 'src/styles/variables'; .wrap { min-height: 200px; padding-bottom: $upload_button_height + $gap; } + +div.dropzone { +} diff --git a/src/components/input/DropHereIcon/index.tsx b/src/components/input/DropHereIcon/index.tsx new file mode 100644 index 00000000..8711d3ca --- /dev/null +++ b/src/components/input/DropHereIcon/index.tsx @@ -0,0 +1,18 @@ +import React, { FC } from 'react'; +import styles from './styles.module.scss'; +import { SVGProps } from '~/utils/types'; + +interface Props extends SVGProps {} + +const DropHereIcon: FC = ({ ...rest }) => ( + + + + + +); + +export { DropHereIcon }; diff --git a/src/components/input/DropHereIcon/styles.module.scss b/src/components/input/DropHereIcon/styles.module.scss new file mode 100644 index 00000000..6af4e092 --- /dev/null +++ b/src/components/input/DropHereIcon/styles.module.scss @@ -0,0 +1,8 @@ +@keyframes bounce { + 0% { transform: translate(0, -5%); } + 100% { transform: translate(0, 5%); } +} + +.arrow { + animation: bounce alternate infinite 0.25s; +} diff --git a/src/components/upload/UploadDropzone/index.tsx b/src/components/upload/UploadDropzone/index.tsx new file mode 100644 index 00000000..508f9062 --- /dev/null +++ b/src/components/upload/UploadDropzone/index.tsx @@ -0,0 +1,48 @@ +import React, { FC, useCallback } from 'react'; +import Dropzone from 'react-dropzone'; +import classnames from 'classnames'; +import classNames from 'classnames'; +import styles from './styles.module.scss'; +import { DivProps } from '~/utils/types'; +import { DropHereIcon } from '~/components/input/DropHereIcon'; +import { useDragDetector } from '~/utils/hooks/useDragDetector'; + +interface IProps extends DivProps { + onUpload: (files: File[]) => void; + helperClassName?: string; +} + +const UploadDropzone: FC = ({ children, onUpload, helperClassName, ...rest }) => { + const { isDragging: isDraggingOnBody, onStopDragging } = useDragDetector(); + const onDrop = useCallback( + (files: File[]) => { + onStopDragging(); + onUpload(files); + }, + [onUpload] + ); + + return ( + + {({ getRootProps, isDragActive }) => ( +
+ {children} +
+ +
+
+ )} +
+ ); +}; + +export { UploadDropzone }; diff --git a/src/components/upload/UploadDropzone/styles.module.scss b/src/components/upload/UploadDropzone/styles.module.scss new file mode 100644 index 00000000..8bda07d1 --- /dev/null +++ b/src/components/upload/UploadDropzone/styles.module.scss @@ -0,0 +1,34 @@ +@import '~/styles/variables'; + +.zone { + position: relative; + z-index: 1; + outline: none; +} + +.helper { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: transparentize($wisegreen, 0.7); + border-radius: $radius; + z-index: 10; + box-shadow: inset $wisegreen 0 0 0 2px; + display: none; + align-items: center; + justify-content: center; + pointer-events: none; + touch-action: none; + + &.active, :global(.dragging) & { + display: flex; + } +} + +svg.icon { + width: auto; + height: 72px; + fill: $wisegreen; +} diff --git a/src/containers/App.tsx b/src/containers/App.tsx index af312b6e..d0393e26 100644 --- a/src/containers/App.tsx +++ b/src/containers/App.tsx @@ -10,6 +10,7 @@ import { BlurWrapper } from '~/components/containers/BlurWrapper'; import { PageCover } from '~/components/containers/PageCover'; import { BottomContainer } from '~/containers/main/BottomContainer'; import { MainRouter } from '~/containers/main/MainRouter'; +import { DragDetectorProvider } from '~/utils/hooks/useDragDetector'; const mapStateToProps = state => ({ modal: selectModal(state), @@ -21,7 +22,7 @@ type IProps = typeof mapDispatchToProps & ReturnType & { const Component: FC = ({ modal: { is_shown } }) => { return ( -
+ @@ -32,9 +33,8 @@ const Component: FC = ({ modal: { is_shown } }) => { - -
+
); }; diff --git a/src/containers/dialogs/EditorDialog/index.tsx b/src/containers/dialogs/EditorDialog/index.tsx index 988bf793..3ad3d045 100644 --- a/src/containers/dialogs/EditorDialog/index.tsx +++ b/src/containers/dialogs/EditorDialog/index.tsx @@ -15,6 +15,7 @@ import { ModalWrapper } from '~/components/dialogs/ModalWrapper'; import { useTranslatedError } from '~/utils/hooks/useTranslatedError'; import { useCloseOnEscape } from '~/utils/hooks'; import { EditorConfirmClose } from '~/components/editors/EditorConfirmClose'; +import { UploadDropzone } from '~/components/upload/UploadDropzone'; interface Props extends IDialogProps { node: INode; diff --git a/src/redux/uploads/constants.ts b/src/redux/uploads/constants.ts index 8d8cf134..f5280bd4 100644 --- a/src/redux/uploads/constants.ts +++ b/src/redux/uploads/constants.ts @@ -74,3 +74,5 @@ export const COMMENT_FILE_TYPES = [ ...FILE_MIMES[UPLOAD_TYPES.IMAGE], ...FILE_MIMES[UPLOAD_TYPES.AUDIO], ]; + +export const IMAGE_FILE_TYPES = [...FILE_MIMES[UPLOAD_TYPES.IMAGE]]; diff --git a/src/sprites/Sprites.tsx b/src/sprites/Sprites.tsx index e48cde68..ba0e79d6 100644 --- a/src/sprites/Sprites.tsx +++ b/src/sprites/Sprites.tsx @@ -334,6 +334,14 @@ const Sprites: FC = () => ( d="M409.132,114.573c-19.608-33.596-46.205-60.194-79.798-79.8C295.736,15.166,259.057,5.365,219.271,5.365 c-39.781,0-76.472,9.804-110.063,29.408c-33.596,19.605-60.192,46.204-79.8,79.8C9.803,148.168,0,184.854,0,224.63 c0,47.78,13.94,90.745,41.827,128.906c27.884,38.164,63.906,64.572,108.063,79.227c5.14,0.954,8.945,0.283,11.419-1.996 c2.475-2.282,3.711-5.14,3.711-8.562c0-0.571-0.049-5.708-0.144-15.417c-0.098-9.709-0.144-18.179-0.144-25.406l-6.567,1.136 c-4.187,0.767-9.469,1.092-15.846,1c-6.374-0.089-12.991-0.757-19.842-1.999c-6.854-1.231-13.229-4.086-19.13-8.559 c-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559 c-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-0.951-2.568-2.098-3.711-3.429c-1.142-1.331-1.997-2.663-2.568-3.997 c-0.572-1.335-0.098-2.43,1.427-3.289c1.525-0.859,4.281-1.276,8.28-1.276l5.708,0.853c3.807,0.763,8.516,3.042,14.133,6.851 c5.614,3.806,10.229,8.754,13.846,14.842c4.38,7.806,9.657,13.754,15.846,17.847c6.184,4.093,12.419,6.136,18.699,6.136 c6.28,0,11.704-0.476,16.274-1.423c4.565-0.952,8.848-2.383,12.847-4.285c1.713-12.758,6.377-22.559,13.988-29.41 c-10.848-1.14-20.601-2.857-29.264-5.14c-8.658-2.286-17.605-5.996-26.835-11.14c-9.235-5.137-16.896-11.516-22.985-19.126 c-6.09-7.614-11.088-17.61-14.987-29.979c-3.901-12.374-5.852-26.648-5.852-42.826c0-23.035,7.52-42.637,22.557-58.817 c-7.044-17.318-6.379-36.732,1.997-58.24c5.52-1.715,13.706-0.428,24.554,3.853c10.85,4.283,18.794,7.952,23.84,10.994 c5.046,3.041,9.089,5.618,12.135,7.708c17.705-4.947,35.976-7.421,54.818-7.421s37.117,2.474,54.823,7.421l10.849-6.849 c7.419-4.57,16.18-8.758,26.262-12.565c10.088-3.805,17.802-4.853,23.134-3.138c8.562,21.509,9.325,40.922,2.279,58.24 c15.036,16.18,22.559,35.787,22.559,58.817c0,16.178-1.958,30.497-5.853,42.966c-3.9,12.471-8.941,22.457-15.125,29.979 c-6.191,7.521-13.901,13.85-23.131,18.986c-9.232,5.14-18.182,8.85-26.84,11.136c-8.662,2.286-18.415,4.004-29.263,5.146 c9.894,8.562,14.842,22.077,14.842,40.539v60.237c0,3.422,1.19,6.279,3.572,8.562c2.379,2.279,6.136,2.95,11.276,1.995 c44.163-14.653,80.185-41.062,108.068-79.226c27.88-38.161,41.825-81.126,41.825-128.906 C438.536,184.851,428.728,148.168,409.132,114.573z" /> + + + + + ); diff --git a/src/sprites/boris_lab.svg b/src/sprites/boris_lab.svg index 446867d8..b47a54e8 100644 --- a/src/sprites/boris_lab.svg +++ b/src/sprites/boris_lab.svg @@ -16,7 +16,7 @@ xmlns:svg="http://www.w3.org/2000/svg"> void, delay: number * @param onUpload -- upload callback * @param allowedTypes -- list of allowed types */ -export const useDropZone = (onUpload: (file: File[]) => void, allowedTypes?: string[]) => { +export const useFileDropZone = (onUpload: (file: File[]) => void, allowedTypes?: string[]) => { return useCallback( event => { event.preventDefault(); + event.stopPropagation(); + const files: File[] = Array.from((event.dataTransfer?.files as File[]) || []).filter( (file: File) => file?.type && (!allowedTypes || allowedTypes.includes(file.type)) ); diff --git a/src/utils/hooks/useDragDetector.tsx b/src/utils/hooks/useDragDetector.tsx new file mode 100644 index 00000000..80760d68 --- /dev/null +++ b/src/utils/hooks/useDragDetector.tsx @@ -0,0 +1,50 @@ +import React, { FC, useContext } from 'react'; +import { createContext, useCallback, useEffect, useState } from 'react'; + +const DragContext = createContext({ + isDragging: false, + setIsDragging: (val: boolean) => {}, +}); + +export const DragDetectorProvider: FC = ({ children }) => { + const [isDragging, setIsDragging] = useState(false); + + return ( + {children} + ); +}; + +export const useDragDetector = () => { + const { isDragging, setIsDragging } = useContext(DragContext); + + const onStopDragging = useCallback(() => setIsDragging(false), [setIsDragging]); + + useEffect(() => { + const addClass = () => setIsDragging(true); + + const removeClass = event => { + // Small hack to ignore intersection with child elements + if (event.pageX !== 0 && event.pageY !== 0) { + return; + } + + setIsDragging(false); + }; + + document.addEventListener('dragenter', addClass); + document.addEventListener('dragover', addClass); + document.addEventListener('dragleave', removeClass); + document.addEventListener('blur', removeClass); + document.addEventListener('drop', onStopDragging); + + return () => { + document.removeEventListener('dragenter', addClass); + document.removeEventListener('dragover', addClass); + document.removeEventListener('dragleave', removeClass); + document.removeEventListener('blur', removeClass); + document.removeEventListener('drop', onStopDragging); + }; + }, [setIsDragging]); + + return { isDragging, onStopDragging }; +}; diff --git a/src/utils/types.ts b/src/utils/types.ts index 7f43acfb..a7d786e1 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -4,3 +4,5 @@ export type DivProps = React.DetailedHTMLProps< React.HTMLAttributes, HTMLDivElement >; + +export type SVGProps = React.SVGProps; diff --git a/yarn.lock b/yarn.lock index e5a6393e..f3b8b5b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1805,6 +1805,11 @@ dependencies: "@types/jest" "*" +"@types/throttle-debounce@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/throttle-debounce/-/throttle-debounce-2.1.0.tgz#1c3df624bfc4b62f992d3012b84c56d41eab3776" + integrity sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ== + "@types/yargs-parser@*": version "15.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" @@ -2410,6 +2415,11 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +attr-accept@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b" + integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg== + autoprefixer@^9.6.1: version "9.8.6" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.6.tgz#3b73594ca1bf9266320c5acf1588d74dea74210f" @@ -4885,6 +4895,13 @@ file-loader@4.3.0: loader-utils "^1.2.3" schema-utils "^2.5.0" +file-selector@^0.2.2: + version "0.2.4" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.2.4.tgz#7b98286f9dbb9925f420130ea5ed0a69238d4d80" + integrity sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA== + dependencies: + tslib "^2.0.3" + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -9361,6 +9378,15 @@ react-dom@^17.0.1: object-assign "^4.1.1" scheduler "^0.20.1" +react-dropzone@^11.4.2: + version "11.4.2" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.4.2.tgz#1eb99e9def4cc7520f4f58e85c853ce52c483d56" + integrity sha512-ocYzYn7Qgp0tFc1gQtUTOaHHSzVTwhWHxxY+r7cj2jJTPfMTZB5GWSJHdIVoxsl+EQENpjJ/6Zvcw0BqKZQ+Eg== + dependencies: + attr-accept "^2.2.1" + file-selector "^0.2.2" + prop-types "^15.7.2" + react-error-overlay@^6.0.7: version "6.0.8" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de" @@ -11137,6 +11163,11 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.3: + version "2.3.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" + integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + tsutils@^3.17.1: version "3.17.1" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"