From ecfab87aa0883604a25c3a0e1c120caaf4c26456 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Fri, 9 Jul 2021 16:22:35 +1000 Subject: [PATCH] More typescript --- package.json | 5 +- src/components/{Search.js => Search.tsx} | 5 +- .../dice/{DiceButton.js => DiceButton.tsx} | 16 +- ...DiceButtonCount.js => DiceButtonCount.tsx} | 2 +- .../dice/{DiceButtons.js => DiceButtons.tsx} | 35 +++- ...DiceInteraction.js => DiceInteraction.tsx} | 102 ++++++--- .../dice/{DiceResults.js => DiceResults.tsx} | 15 +- .../dice/{DiceTile.js => DiceTile.tsx} | 12 +- .../dice/{DiceTiles.js => DiceTiles.tsx} | 17 +- ...DiceTrayOverlay.js => DiceTrayOverlay.tsx} | 77 ++++--- ...lectDiceButton.js => SelectDiceButton.tsx} | 22 +- .../drag/{Draggable.js => Draggable.tsx} | 9 +- .../drag/{Droppable.js => Droppable.tsx} | 7 +- src/components/map/Map.tsx | 68 ------ .../tile/{LazyTile.js => LazyTile.tsx} | 6 +- .../{SortableTile.js => SortableTile.tsx} | 18 +- .../{SortableTiles.js => SortableTiles.tsx} | 24 ++- ...verlay.js => SortableTilesDragOverlay.tsx} | 13 +- src/components/tile/{Tile.js => Tile.tsx} | 14 +- .../{TileActionBar.js => TileActionBar.tsx} | 10 +- .../{TilesContainer.js => TilesContainer.tsx} | 6 +- .../{TilesOverlay.js => TilesOverlay.tsx} | 49 +++-- .../{AssetsContext.js => AssetsContext.tsx} | 180 +++++++--------- src/contexts/AuthContext.tsx | 6 +- src/contexts/DragContext.js | 75 ------- src/contexts/DragContext.tsx | 68 ++++++ .../{GroupContext.js => GroupContext.tsx} | 84 ++++++-- src/contexts/MapDataContext.tsx | 4 +- ...TileDragContext.js => TileDragContext.tsx} | 59 ++++-- .../{UserIdContext.js => UserIdContext.tsx} | 16 +- src/dice/Dice.ts | 56 ++++- src/dice/index.ts | 6 +- src/dice/walnut/WalnutDice.ts | 5 +- src/helpers/Vector2.ts | 30 ++- src/helpers/dice.ts | 3 +- src/helpers/drawing.ts | 198 +++++------------- src/helpers/grid.ts | 131 ++++++------ src/helpers/{group.js => group.ts} | 166 +++++++-------- src/helpers/image.ts | 125 +++++------ src/helpers/{map.js => map.ts} | 92 ++++---- src/helpers/shared.ts | 34 +-- src/helpers/{token.js => token.ts} | 84 ++++++-- src/hooks/useDebounce.tsx | 4 +- src/modals/AuthModal.tsx | 9 +- src/modals/ChangeNicknameModal.tsx | 20 +- src/modals/ConfirmModal.tsx | 16 +- src/modals/EditGroupModal.tsx | 73 ------- src/modals/EditMapModal.tsx | 5 +- src/modals/EditTokenModal.tsx | 4 +- src/modals/GameExpiredModal.tsx | 7 +- src/modals/GettingStartedModal.tsx | 10 +- .../{GroupNameModal.js => GroupNameModal.tsx} | 22 +- src/modals/ImportExportModal.tsx | 35 ++-- src/modals/JoinModal.tsx | 12 +- src/modals/SelectDataModal.tsx | 94 +++++---- src/modals/SelectDiceModal.tsx | 24 ++- src/modals/SelectMapModal.tsx | 24 ++- src/modals/SelectTokensModal.tsx | 17 +- src/tokens/index.ts | 31 --- src/types/Dice.ts | 13 +- src/types/Drawing.ts | 4 + src/types/Grid.ts | 13 ++ src/types/Map.ts | 1 + src/types/external/image.outline.d.ts | 14 ++ tsconfig.json | 6 +- yarn.lock | 171 ++++++++------- 66 files changed, 1350 insertions(+), 1233 deletions(-) rename src/components/{Search.js => Search.tsx} (88%) rename src/components/dice/{DiceButton.js => DiceButton.tsx} (58%) rename src/components/dice/{DiceButtonCount.js => DiceButtonCount.tsx} (87%) rename src/components/dice/{DiceButtons.js => DiceButtons.tsx} (83%) rename src/components/dice/{DiceInteraction.js => DiceInteraction.tsx} (65%) rename src/components/dice/{DiceResults.js => DiceResults.tsx} (90%) rename src/components/dice/{DiceTile.js => DiceTile.tsx} (57%) rename src/components/dice/{DiceTiles.js => DiceTiles.tsx} (74%) rename src/components/dice/{DiceTrayOverlay.js => DiceTrayOverlay.tsx} (83%) rename src/components/dice/{SelectDiceButton.js => SelectDiceButton.tsx} (66%) rename src/components/drag/{Draggable.js => Draggable.tsx} (69%) rename src/components/drag/{Droppable.js => Droppable.tsx} (62%) rename src/components/tile/{LazyTile.js => LazyTile.tsx} (79%) rename src/components/tile/{SortableTile.js => SortableTile.tsx} (84%) rename src/components/tile/{SortableTiles.js => SortableTiles.tsx} (79%) rename src/components/tile/{SortableTilesDragOverlay.js => SortableTilesDragOverlay.tsx} (90%) rename src/components/tile/{Tile.js => Tile.tsx} (91%) rename src/components/tile/{TileActionBar.js => TileActionBar.tsx} (90%) rename src/components/tile/{TilesContainer.js => TilesContainer.tsx} (87%) rename src/components/tile/{TilesOverlay.js => TilesOverlay.tsx} (81%) rename src/contexts/{AssetsContext.js => AssetsContext.tsx} (67%) delete mode 100644 src/contexts/DragContext.js create mode 100644 src/contexts/DragContext.tsx rename src/contexts/{GroupContext.js => GroupContext.tsx} (70%) rename src/contexts/{TileDragContext.js => TileDragContext.tsx} (78%) rename src/contexts/{UserIdContext.js => UserIdContext.tsx} (64%) rename src/helpers/{group.js => group.ts} (59%) rename src/helpers/{map.js => map.ts} (74%) rename src/helpers/{token.js => token.ts} (81%) delete mode 100644 src/modals/EditGroupModal.tsx rename src/modals/{GroupNameModal.js => GroupNameModal.tsx} (67%) create mode 100644 src/types/external/image.outline.d.ts diff --git a/package.json b/package.json index 28677fc..5e9be27 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "react-markdown": "4", "react-media": "^2.0.0-rc.1", "react-modal": "^3.12.1", - "react-resize-detector": "4.2.3", + "react-resize-detector": "^6.7.4", "react-router-dom": "^5.1.2", "react-router-hash-link": "^2.2.2", "react-scripts": "^4.0.3", @@ -63,7 +63,7 @@ "socket.io-client": "^4.1.2", "socket.io-msgpack-parser": "^3.0.1", "source-map-explorer": "^2.5.2", - "theme-ui": "^0.8.4", + "theme-ui": "^0.10.0", "use-image": "^1.0.7", "uuid": "^8.3.2", "webrtc-adapter": "^7.7.1" @@ -107,6 +107,7 @@ "@types/react-router-dom": "^5.1.7", "@types/shortid": "^0.0.29", "@types/simple-peer": "^9.6.3", + "@types/uuid": "^8.3.1", "typescript": "^4.2.4", "worker-loader": "^3.0.8" } diff --git a/src/components/Search.js b/src/components/Search.tsx similarity index 88% rename from src/components/Search.js rename to src/components/Search.tsx index df00aa1..762de5c 100644 --- a/src/components/Search.js +++ b/src/components/Search.tsx @@ -1,9 +1,8 @@ -import React from "react"; -import { Box, Input } from "theme-ui"; +import { Box, Input, InputProps } from "theme-ui"; import SearchIcon from "../icons/SearchIcon"; -function Search(props) { +function Search(props: InputProps) { return ( ; + disabled: boolean; +}; + +function DiceButton({ + title, + children, + count, + onClick, + disabled, +}: DiceButtonProps) { return ( void; + onDiceLoad: (dice: DefaultDice) => void; + diceTraySize: "single" | "double"; + onDiceTraySizeChange: (newSize: "single" | "double") => void; + shareDice: boolean; + onShareDiceChange: (value: boolean) => void; + loading: boolean; +}; + function DiceButtons({ diceRolls, onDiceAdd, @@ -30,29 +44,32 @@ function DiceButtons({ shareDice, onShareDiceChange, loading, -}) { +}: DiceButtonsProps) { const [currentDiceStyle, setCurrentDiceStyle] = useSetting("dice.style"); - const [currentDice, setCurrentDice] = useState( - dice.find((d) => d.key === currentDiceStyle) + const [currentDice, setCurrentDice] = useState( + dice.find((d) => d.key === currentDiceStyle) || dice[0] ); useEffect(() => { const initialDice = dice.find((d) => d.key === currentDiceStyle); - onDiceLoad(initialDice); - setCurrentDice(initialDice); + if (initialDice) { + onDiceLoad(initialDice); + setCurrentDice(initialDice); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const diceCounts = {}; + const diceCounts: Partial> = {}; for (let dice of diceRolls) { if (dice.type in diceCounts) { - diceCounts[dice.type] += 1; + // TODO: Check type + diceCounts[dice.type]! += 1; } else { diceCounts[dice.type] = 1; } } - async function handleDiceChange(dice) { + async function handleDiceChange(dice: DefaultDice) { await onDiceLoad(dice); setCurrentDice(dice); setCurrentDiceStyle(dice.key); diff --git a/src/components/dice/DiceInteraction.js b/src/components/dice/DiceInteraction.tsx similarity index 65% rename from src/components/dice/DiceInteraction.js rename to src/components/dice/DiceInteraction.tsx index f5d225b..8fd9d50 100644 --- a/src/components/dice/DiceInteraction.js +++ b/src/components/dice/DiceInteraction.tsx @@ -1,9 +1,10 @@ -import React, { useRef, useEffect, useState } from "react"; +import { useRef, useEffect, useState } from "react"; import { Engine } from "@babylonjs/core/Engines/engine"; import { Scene } from "@babylonjs/core/scene"; import { Vector3, Color4, Matrix } from "@babylonjs/core/Maths/math"; import { AmmoJSPlugin } from "@babylonjs/core/Physics/Plugins/ammoJSPlugin"; import { TargetCamera } from "@babylonjs/core/Cameras/targetCamera"; +//@ts-ignore import * as AMMO from "ammo.js"; import "@babylonjs/core/Physics/physicsEngineComponent"; @@ -19,20 +20,44 @@ import ReactResizeDetector from "react-resize-detector"; import usePreventTouch from "../../hooks/usePreventTouch"; import ErrorBanner from "../banner/ErrorBanner"; +import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh"; const diceThrowSpeed = 2; -function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) { - const [error, setError] = useState(); +type DiceInteractionProps = { + onSceneMount?: ({ + scene, + engine, + canvas, + }: { + scene: Scene; + engine: Engine; + canvas: HTMLCanvasElement | WebGLRenderingContext; + }) => void; + onPointerDown: () => void; + onPointerUp: () => any; +}; - const sceneRef = useRef(); - const engineRef = useRef(); - const canvasRef = useRef(); - const containerRef = useRef(); +function DiceInteraction({ + onSceneMount, + onPointerDown, + onPointerUp, +}: DiceInteractionProps) { + const [error, setError] = useState(); + + const sceneRef = useRef(); + const engineRef = useRef(); + const canvasRef = useRef(null); + const containerRef = useRef(null); useEffect(() => { + const canvas = canvasRef.current; + + if (!canvas) { + return; + } + try { - const canvas = canvasRef.current; const engine = new Engine(canvas, true, { preserveDrawingBuffer: true, stencil: true, @@ -67,13 +92,14 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) { const delta = newPosition.subtract(currentPosition); selectedMesh.setAbsolutePosition(newPosition); const velocity = delta.scale(1000 / scene.deltaTime); - selectedMeshVelocityWindowRef.current = selectedMeshVelocityWindowRef.current.slice( - Math.max( - selectedMeshVelocityWindowRef.current.length - - selectedMeshVelocityWindowSize, - 0 - ) - ); + selectedMeshVelocityWindowRef.current = + selectedMeshVelocityWindowRef.current.slice( + Math.max( + selectedMeshVelocityWindowRef.current.length - + selectedMeshVelocityWindowSize, + 0 + ) + ); selectedMeshVelocityWindowRef.current.push(velocity); } }); @@ -82,21 +108,27 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) { } }, [onSceneMount]); - const selectedMeshRef = useRef(); - const selectedMeshVelocityWindowRef = useRef([]); + const selectedMeshRef = useRef(null); + const selectedMeshVelocityWindowRef = useRef([]); const selectedMeshVelocityWindowSize = 4; - const selectedMeshMassRef = useRef(); + const selectedMeshMassRef = useRef(0); function handlePointerDown() { const scene = sceneRef.current; if (scene) { const pickInfo = scene.pick(scene.pointerX, scene.pointerY); - if (pickInfo.hit && pickInfo.pickedMesh.name !== "dice_tray") { - pickInfo.pickedMesh.physicsImpostor.setLinearVelocity(Vector3.Zero()); - pickInfo.pickedMesh.physicsImpostor.setAngularVelocity(Vector3.Zero()); + if ( + pickInfo && + pickInfo.hit && + pickInfo.pickedMesh && + pickInfo.pickedMesh.name !== "dice_tray" + ) { + pickInfo.pickedMesh.physicsImpostor?.setLinearVelocity(Vector3.Zero()); + pickInfo.pickedMesh.physicsImpostor?.setAngularVelocity(Vector3.Zero()); // Save the meshes mass and set it to 0 so we can pick it up - selectedMeshMassRef.current = pickInfo.pickedMesh.physicsImpostor.mass; - pickInfo.pickedMesh.physicsImpostor.setMass(0); + selectedMeshMassRef.current = + pickInfo.pickedMesh.physicsImpostor?.mass || 0; + pickInfo.pickedMesh.physicsImpostor?.setMass(0); selectedMeshRef.current = pickInfo.pickedMesh; } @@ -119,27 +151,29 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) { } // Re-apply the meshes mass - selectedMesh.physicsImpostor.setMass(selectedMeshMassRef.current); - selectedMesh.physicsImpostor.forceUpdate(); + selectedMesh.physicsImpostor?.setMass(selectedMeshMassRef.current); + selectedMesh.physicsImpostor?.forceUpdate(); - selectedMesh.physicsImpostor.applyImpulse( + selectedMesh.physicsImpostor?.applyImpulse( velocity.scale(diceThrowSpeed * selectedMesh.physicsImpostor.mass), selectedMesh.physicsImpostor.getObjectCenter() ); } selectedMeshRef.current = null; selectedMeshVelocityWindowRef.current = []; - selectedMeshMassRef.current = null; + selectedMeshMassRef.current = 0; onPointerUp(); } - function handleResize(width, height) { - const engine = engineRef.current; - if (engine) { - engine.resize(); - canvasRef.current.width = width; - canvasRef.current.height = height; + function handleResize(width?: number, height?: number) { + if (width && height) { + const engine = engineRef.current; + if (engine && canvasRef.current) { + engine.resize(); + canvasRef.current.width = width; + canvasRef.current.height = height; + } } } @@ -165,7 +199,7 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) { style={{ outline: "none" }} /> - setError()} /> + setError(undefined)} /> ); } diff --git a/src/components/dice/DiceResults.js b/src/components/dice/DiceResults.tsx similarity index 90% rename from src/components/dice/DiceResults.js rename to src/components/dice/DiceResults.tsx index c0f1965..0613b93 100644 --- a/src/components/dice/DiceResults.js +++ b/src/components/dice/DiceResults.tsx @@ -5,17 +5,28 @@ import ClearDiceIcon from "../../icons/ClearDiceIcon"; import RerollDiceIcon from "../../icons/RerollDiceIcon"; import { getDiceRollTotal } from "../../helpers/dice"; +import { DiceRoll } from "../../types/Dice"; const maxDiceRollsShown = 6; -function DiceResults({ diceRolls, onDiceClear, onDiceReroll }) { +type DiceResultsProps = { + diceRolls: DiceRoll[]; + onDiceClear: () => void; + onDiceReroll: () => void; +}; + +function DiceResults({ + diceRolls, + onDiceClear, + onDiceReroll, +}: DiceResultsProps) { const [isExpanded, setIsExpanded] = useState(false); if (diceRolls.length === 0) { return null; } - let rolls = []; + let rolls: React.ReactChild[] = []; if (diceRolls.length > 1) { rolls = diceRolls .filter((dice) => dice.roll !== "unknown") diff --git a/src/components/dice/DiceTile.js b/src/components/dice/DiceTile.tsx similarity index 57% rename from src/components/dice/DiceTile.js rename to src/components/dice/DiceTile.tsx index b9e1eb1..1048938 100644 --- a/src/components/dice/DiceTile.js +++ b/src/components/dice/DiceTile.tsx @@ -1,9 +1,17 @@ -import React from "react"; import { Image } from "theme-ui"; import Tile from "../tile/Tile"; -function DiceTile({ dice, isSelected, onDiceSelect, onDone }) { +import { DefaultDice } from "../../types/Dice"; + +type DiceTileProps = { + dice: DefaultDice; + isSelected: boolean; + onDiceSelect: (dice: DefaultDice) => void; + onDone: (dice: DefaultDice) => void; +}; + +function DiceTile({ dice, isSelected, onDiceSelect, onDone }: DiceTileProps) { return (
void; + selectedDice: DefaultDice; + onDone: (dice: DefaultDice) => void; +}; + +function DiceTiles({ + dice, + onDiceSelect, + selectedDice, + onDone, +}: DiceTileProps) { const layout = useResponsiveLayout(); return ( @@ -29,7 +41,6 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) { isSelected={selectedDice && dice.key === selectedDice.key} onDiceSelect={onDiceSelect} onDone={onDone} - size={layout.tileSize} /> ))} diff --git a/src/components/dice/DiceTrayOverlay.js b/src/components/dice/DiceTrayOverlay.tsx similarity index 83% rename from src/components/dice/DiceTrayOverlay.js rename to src/components/dice/DiceTrayOverlay.tsx index 2c19088..4f2a779 100644 --- a/src/components/dice/DiceTrayOverlay.js +++ b/src/components/dice/DiceTrayOverlay.tsx @@ -1,10 +1,11 @@ -import React, { useRef, useCallback, useEffect, useState } from "react"; +import { useRef, useCallback, useEffect, useState } from "react"; import { Vector3 } from "@babylonjs/core/Maths/math"; import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight"; import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator"; import { CubeTexture } from "@babylonjs/core/Materials/Textures/cubeTexture"; import { Box } from "theme-ui"; +// @ts-ignore import environment from "../../dice/environment.dds"; import DiceInteraction from "./DiceInteraction"; @@ -19,6 +20,16 @@ import { useDiceLoading } from "../../contexts/DiceLoadingContext"; import { getDiceRoll } from "../../helpers/dice"; import useSetting from "../../hooks/useSetting"; +import { DefaultDice, DiceMesh, DiceRoll, DiceType } from "../../types/Dice"; +import { Scene } from "@babylonjs/core"; + +type DiceTrayOverlayProps = { + isOpen: boolean; + shareDice: boolean; + onShareDiceChange: () => void; + diceRolls: DiceRoll[]; + onDiceRollsChange: (newRolls: DiceRoll[]) => void; +}; function DiceTrayOverlay({ isOpen, @@ -26,17 +37,18 @@ function DiceTrayOverlay({ onShareDiceChange, diceRolls, onDiceRollsChange, -}) { - const sceneRef = useRef(); - const shadowGeneratorRef = useRef(); - const diceRefs = useRef([]); +}: DiceTrayOverlayProps) { + const sceneRef = useRef(); + const shadowGeneratorRef = useRef(); + const diceRefs = useRef([]); const sceneVisibleRef = useRef(false); const sceneInteractionRef = useRef(false); // Add to the counter to ingore sleep values const sceneKeepAwakeRef = useRef(0); - const diceTrayRef = useRef(); + const diceTrayRef = useRef(); - const [diceTraySize, setDiceTraySize] = useState("single"); + const [diceTraySize, setDiceTraySize] = + useState<"single" | "double">("single"); const { assetLoadStart, assetLoadFinish, isLoading } = useDiceLoading(); const [fullScreen] = useSetting("map.fullScreen"); @@ -50,7 +62,7 @@ function DiceTrayOverlay({ } // Forces rendering for 1 second - function forceRender() { + function forceRender(): () => void { // Force rerender sceneKeepAwakeRef.current++; let triggered = false; @@ -97,7 +109,7 @@ function DiceTrayOverlay({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - async function initializeScene(scene) { + async function initializeScene(scene: Scene) { handleAssetLoadStart(); let light = new DirectionalLight( "DirectionalLight", @@ -124,16 +136,14 @@ function DiceTrayOverlay({ handleAssetLoadFinish(); } - function update(scene) { - function getDiceSpeed(dice) { - const diceSpeed = dice.instance.physicsImpostor - .getLinearVelocity() - .length(); + function update(scene: Scene) { + function getDiceSpeed(dice: DiceMesh) { + const diceSpeed = + dice.instance.physicsImpostor?.getLinearVelocity()?.length() || 0; // If the dice is a d100 check the d10 as well - if (dice.type === "d100") { - const d10Speed = dice.d10Instance.physicsImpostor - .getLinearVelocity() - .length(); + if (dice.d10Instance) { + const d10Speed = + dice.d10Instance.physicsImpostor?.getLinearVelocity()?.length() || 0; return Math.max(diceSpeed, d10Speed); } else { return diceSpeed; @@ -157,14 +167,14 @@ function DiceTrayOverlay({ const dice = die[i]; const speed = getDiceSpeed(dice); // If the speed has been below 0.01 for 1s set dice to sleep - if (speed < 0.01 && !dice.sleepTimout) { - dice.sleepTimout = setTimeout(() => { + if (speed < 0.01 && !dice.sleepTimeout) { + dice.sleepTimeout = setTimeout(() => { dice.asleep = true; }, 1000); - } else if (speed > 0.5 && (dice.asleep || dice.sleepTimout)) { + } else if (speed > 0.5 && (dice.asleep || dice.sleepTimeout)) { dice.asleep = false; - clearTimeout(dice.sleepTimout); - dice.sleepTimout = null; + dice.sleepTimeout && clearTimeout(dice.sleepTimeout); + dice.sleepTimeout = undefined; } } @@ -173,14 +183,14 @@ function DiceTrayOverlay({ } } - function handleDiceAdd(style, type) { + function handleDiceAdd(style: typeof Dice, type: DiceType) { const scene = sceneRef.current; const shadowGenerator = shadowGeneratorRef.current; if (scene && shadowGenerator) { const instance = style.createInstance(type, scene); shadowGenerator.addShadowCaster(instance); - Dice.roll(instance); - let dice = { type, instance, asleep: false }; + style.roll(instance); + let dice: DiceMesh = { type, instance, asleep: false }; // If we have a d100 add a d10 as well if (type === "d100") { const d10Instance = style.createInstance("d10", scene); @@ -196,7 +206,7 @@ function DiceTrayOverlay({ const die = diceRefs.current; for (let dice of die) { dice.instance.dispose(); - if (dice.type === "d100") { + if (dice.d10Instance) { dice.d10Instance.dispose(); } } @@ -208,14 +218,14 @@ function DiceTrayOverlay({ const die = diceRefs.current; for (let dice of die) { Dice.roll(dice.instance); - if (dice.type === "d100") { + if (dice.d10Instance) { Dice.roll(dice.d10Instance); } dice.asleep = false; } } - async function handleDiceLoad(dice) { + async function handleDiceLoad(dice: DefaultDice) { handleAssetLoadStart(); const scene = sceneRef.current; if (scene) { @@ -230,10 +240,13 @@ function DiceTrayOverlay({ }); useEffect(() => { - let renderTimeout; - let renderCleanup; + let renderTimeout: NodeJS.Timeout; + let renderCleanup: () => void; function handleResize() { const map = document.querySelector(".map"); + if (!map) { + return; + } const mapRect = map.getBoundingClientRect(); const availableWidth = mapRect.width - 108; // Subtract padding @@ -283,7 +296,7 @@ function DiceTrayOverlay({ return; } - let newRolls = []; + let newRolls: DiceRoll[] = []; for (let i = 0; i < die.length; i++) { const dice = die[i]; let roll = getDiceRoll(dice); diff --git a/src/components/dice/SelectDiceButton.js b/src/components/dice/SelectDiceButton.tsx similarity index 66% rename from src/components/dice/SelectDiceButton.js rename to src/components/dice/SelectDiceButton.tsx index f5c9fda..02c30fa 100644 --- a/src/components/dice/SelectDiceButton.js +++ b/src/components/dice/SelectDiceButton.tsx @@ -1,10 +1,22 @@ -import React, { useState } from "react"; +import { useState } from "react"; import { IconButton } from "theme-ui"; import SelectDiceIcon from "../../icons/SelectDiceIcon"; import SelectDiceModal from "../../modals/SelectDiceModal"; -function SelectDiceButton({ onDiceChange, currentDice, disabled }) { +import { DefaultDice } from "../../types/Dice"; + +type SelectDiceButtonProps = { + onDiceChange: (dice: DefaultDice) => void; + currentDice: DefaultDice; + disabled: boolean; +}; + +function SelectDiceButton({ + onDiceChange, + currentDice, + disabled, +}: SelectDiceButtonProps) { const [isModalOpen, setIsModalOpen] = useState(false); function openModal() { @@ -14,7 +26,7 @@ function SelectDiceButton({ onDiceChange, currentDice, disabled }) { setIsModalOpen(false); } - function handleDone(dice) { + function handleDone(dice: DefaultDice) { onDiceChange(dice); closeModal(); } @@ -39,4 +51,8 @@ function SelectDiceButton({ onDiceChange, currentDice, disabled }) { ); } +SelectDiceButton.defaultProps = { + disabled: false, +}; + export default SelectDiceButton; diff --git a/src/components/drag/Draggable.js b/src/components/drag/Draggable.tsx similarity index 69% rename from src/components/drag/Draggable.js rename to src/components/drag/Draggable.tsx index e138e94..a0b9268 100644 --- a/src/components/drag/Draggable.js +++ b/src/components/drag/Draggable.tsx @@ -1,7 +1,14 @@ import React from "react"; import { useDraggable } from "@dnd-kit/core"; +import { Data } from "@dnd-kit/core/dist/store/types"; -function Draggable({ id, children, data }) { +type DraggableProps = { + id: string; + children: React.ReactNode; + data: Data; +}; + +function Draggable({ id, children, data }: DraggableProps) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id, data, diff --git a/src/components/drag/Droppable.js b/src/components/drag/Droppable.tsx similarity index 62% rename from src/components/drag/Droppable.js rename to src/components/drag/Droppable.tsx index d11d00f..3213e6e 100644 --- a/src/components/drag/Droppable.js +++ b/src/components/drag/Droppable.tsx @@ -1,7 +1,12 @@ import React from "react"; import { useDroppable } from "@dnd-kit/core"; -function Droppable({ id, children, disabled, ...props }) { +type DroppableProps = React.HTMLAttributes & { + id: string; + disabled: boolean; +}; + +function Droppable({ id, children, disabled, ...props }: DroppableProps) { const { setNodeRef } = useDroppable({ id, disabled }); return ( diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index fe03609..01426e4 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -26,75 +26,7 @@ import { EditShapeAction, RemoveShapeAction, } from "../../actions"; -import { Fog, Path, Shape } from "../../helpers/drawing"; import Session from "../../network/Session"; -import { Grid } from "../../helpers/grid"; -import { ImageFile } from "../../helpers/image"; - -export type Resolutions = Record; -export type Map = { - id: string; - name: string; - owner: string; - file?: Uint8Array; - quality?: string; - resolutions?: Resolutions; - grid: Grid; - group: string; - width: number; - height: number; - type: string; - lastUsed: number; - lastModified: number; - created: number; - showGrid: boolean; - snapToGrid: boolean; - thumbnail?: ImageFile; -}; - -export type Note = { - id: string; - color: string; - lastModified: number; - lastModifiedBy: string; - locked: boolean; - size: number; - text: string; - textOnly: boolean; - visible: boolean; - x: number; - y: number; -}; - -export type TokenState = { - id: string; - tokenId: string; - owner: string; - size: number; - category: string; - label: string; - statuses: string[]; - x: number; - y: number; - lastModifiedBy: string; - lastModified: number; - rotation: number; - locked: boolean; - visible: boolean; - type: "default" | "file"; - outline: any; - width: number; - height: number; -}; - -export type MapState = { - tokens: Record; - drawShapes: Record; - fogShapes: Record; - editFlags: ["drawing", "tokens", "notes", "fog"]; - notes: Record; - mapId: string; -}; function Map({ map, diff --git a/src/components/tile/LazyTile.js b/src/components/tile/LazyTile.tsx similarity index 79% rename from src/components/tile/LazyTile.js rename to src/components/tile/LazyTile.tsx index 54eaa06..d6dca84 100644 --- a/src/components/tile/LazyTile.js +++ b/src/components/tile/LazyTile.tsx @@ -1,11 +1,11 @@ import React from "react"; -import { Box } from "theme-ui"; +import { Box, ThemeUIStyleObject } from "theme-ui"; import { useInView } from "react-intersection-observer"; -function LazyTile({ children }) { +function LazyTile({ children }: { children: React.ReactNode }) { const [ref, inView] = useInView({ triggerOnce: false }); - const sx = inView + const sx: ThemeUIStyleObject = inView ? {} : { width: "100%", height: "0", paddingTop: "100%", position: "relative" }; diff --git a/src/components/tile/SortableTile.js b/src/components/tile/SortableTile.tsx similarity index 84% rename from src/components/tile/SortableTile.js rename to src/components/tile/SortableTile.tsx index cc9c8a0..5cf3905 100644 --- a/src/components/tile/SortableTile.js +++ b/src/components/tile/SortableTile.tsx @@ -6,6 +6,16 @@ import { animated, useSpring } from "react-spring"; import { GROUP_ID_PREFIX } from "../../contexts/TileDragContext"; +type SortableTileProps = { + id: string; + disableGrouping: boolean; + disableSorting: boolean; + hidden: boolean; + children: React.ReactNode; + isDragging: boolean; + cursor: string; +}; + function SortableTile({ id, disableGrouping, @@ -14,7 +24,7 @@ function SortableTile({ children, isDragging, cursor, -}) { +}: SortableTileProps) { const { attributes, listeners, @@ -35,7 +45,7 @@ function SortableTile({ }; // Sort div left aligned - const sortDropStyle = { + const sortDropStyle: React.CSSProperties = { position: "absolute", left: "-5px", top: 0, @@ -46,7 +56,7 @@ function SortableTile({ }; // Group div center aligned - const groupDropStyle = { + const groupDropStyle: React.CSSProperties = { position: "absolute", top: 0, left: 0, @@ -55,7 +65,7 @@ function SortableTile({ borderWidth: "4px", borderRadius: "4px", borderStyle: - over?.id === `${GROUP_ID_PREFIX}${id}` && active.id !== id + over?.id === `${GROUP_ID_PREFIX}${id}` && active?.id !== id ? "solid" : "none", }; diff --git a/src/components/tile/SortableTiles.js b/src/components/tile/SortableTiles.tsx similarity index 79% rename from src/components/tile/SortableTiles.js rename to src/components/tile/SortableTiles.tsx index a42bfbd..9c8b97b 100644 --- a/src/components/tile/SortableTiles.js +++ b/src/components/tile/SortableTiles.tsx @@ -15,8 +15,14 @@ import { GROUP_SORTABLE_ID, } from "../../contexts/TileDragContext"; import { useGroup } from "../../contexts/GroupContext"; +import { Group } from "../../types/Group"; -function SortableTiles({ renderTile, subgroup }) { +type SortableTilesProps = { + renderTile: (group: Group) => React.ReactNode; + subgroup: boolean; +}; + +function SortableTiles({ renderTile, subgroup }: SortableTilesProps) { const dragId = useTileDragId(); const dragCursor = useTileDragCursor(); const overGroupId = useTileOverGroupId(); @@ -38,14 +44,14 @@ function SortableTiles({ renderTile, subgroup }) { const sortableId = subgroup ? GROUP_SORTABLE_ID : BASE_SORTABLE_ID; // Only populate selected groups if needed - let selectedGroupIds = []; + let selectedGroupIds: string[] = []; if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) { selectedGroupIds = allSelectedIds; } - const disableSorting = (openGroupId && !subgroup) || filter; - const disableGrouping = subgroup || disableSorting || filter; + const disableSorting = !!((openGroupId && !subgroup) || filter); + const disableGrouping = !!(subgroup || disableSorting || filter); - function renderSortableGroup(group, selectedGroups) { + function renderSortableGroup(group: Group, selectedGroups: Group[]) { if (overGroupId === group.id && dragId && group.id !== dragId) { // If dragging over a group render a preview of that group const previewGroup = moveGroupsInto( @@ -61,7 +67,7 @@ function SortableTiles({ renderTile, subgroup }) { function renderTiles() { const groupsByIds = keyBy(activeGroups, "id"); const selectedGroupIdsSet = new Set(selectedGroupIds); - let selectedGroups = []; + let selectedGroups: Group[] = []; let hasSelectedContainerGroup = false; for (let groupId of selectedGroupIds) { const group = groupsByIds[groupId]; @@ -72,8 +78,8 @@ function SortableTiles({ renderTile, subgroup }) { } } } - return activeGroups.map((group) => { - const isDragging = dragId && selectedGroupIdsSet.has(group.id); + return activeGroups.map((group: Group) => { + const isDragging = dragId !== null && selectedGroupIdsSet.has(group.id); const disableTileGrouping = disableGrouping || isDragging || hasSelectedContainerGroup; return ( @@ -84,7 +90,7 @@ function SortableTiles({ renderTile, subgroup }) { disableSorting={disableSorting} hidden={group.id === openGroupId} isDragging={isDragging} - cursor={dragCursor} + cursor={dragCursor || ""} > {renderSortableGroup(group, selectedGroups)} diff --git a/src/components/tile/SortableTilesDragOverlay.js b/src/components/tile/SortableTilesDragOverlay.tsx similarity index 90% rename from src/components/tile/SortableTilesDragOverlay.js rename to src/components/tile/SortableTilesDragOverlay.tsx index 848b793..64d6000 100644 --- a/src/components/tile/SortableTilesDragOverlay.js +++ b/src/components/tile/SortableTilesDragOverlay.tsx @@ -8,8 +8,17 @@ import Vector2 from "../../helpers/Vector2"; import { useTileDragId } from "../../contexts/TileDragContext"; import { useGroup } from "../../contexts/GroupContext"; +import { Group } from "../../types/Group"; -function SortableTilesDragOverlay({ renderTile, subgroup }) { +type SortableTilesDragOverlayProps = { + renderTile: (group: Group) => React.ReactNode; + subgroup: boolean; +}; + +function SortableTilesDragOverlay({ + renderTile, + subgroup, +}: SortableTilesDragOverlayProps) { const dragId = useTileDragId(); const { groups, @@ -27,7 +36,7 @@ function SortableTilesDragOverlay({ renderTile, subgroup }) { : groups; // Only populate selected groups if needed - let selectedGroupIds = []; + let selectedGroupIds: string[] = []; if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) { selectedGroupIds = allSelectedIds; } diff --git a/src/components/tile/Tile.js b/src/components/tile/Tile.tsx similarity index 91% rename from src/components/tile/Tile.js rename to src/components/tile/Tile.tsx index c550657..220c483 100644 --- a/src/components/tile/Tile.js +++ b/src/components/tile/Tile.tsx @@ -3,6 +3,18 @@ import { Flex, IconButton, Box, Text, Badge } from "theme-ui"; import EditTileIcon from "../../icons/EditTileIcon"; +type TileProps = { + title: string; + isSelected: boolean; + onSelect: () => void; + onEdit: () => void; + onDoubleClick: () => void; + canEdit: boolean; + badges: React.ReactChild[]; + editTitle: string; + children: React.ReactNode; +}; + function Tile({ title, isSelected, @@ -13,7 +25,7 @@ function Tile({ badges, editTitle, children, -}) { +}: TileProps) { return ( void; + addTitle: string; +}; + +function TileActionBar({ onAdd, addTitle }: TileActionBarProps) { const { selectMode, onSelectModeChange, @@ -33,7 +37,7 @@ function TileActionBar({ onAdd, addTitle }) { outlineOffset: "0px", }, }} - onFocus={() => onGroupSelect()} + onFocus={() => onGroupSelect(undefined)} > onFilterChange(e.target.value)} /> onGroupSelect()} + onClick={() => onGroupSelect(undefined)} > group.id === openGroupId); + const group = groups.find((group: Group) => group.id === openGroupId); if (!openGroupId) { return null; } + const groupName = group && group.type === "group" && group.name; + return ( <> - {group?.name} + {groupName} onGroupSelect()} + onClick={() => onGroupSelect(undefined)} > setIsGroupNameModalOpen(false)} /> diff --git a/src/contexts/AssetsContext.js b/src/contexts/AssetsContext.tsx similarity index 67% rename from src/contexts/AssetsContext.js rename to src/contexts/AssetsContext.tsx index 4da276f..9d64c9c 100644 --- a/src/contexts/AssetsContext.js +++ b/src/contexts/AssetsContext.tsx @@ -8,49 +8,20 @@ import { useDatabase } from "./DatabaseContext"; import useDebounce from "../hooks/useDebounce"; import { omit } from "../helpers/shared"; +import { Asset } from "../types/Asset"; -/** - * @typedef Asset - * @property {string} id - * @property {number} width - * @property {number} height - * @property {Uint8Array} file - * @property {string} mime - * @property {string} owner - */ +type AssetsContext = { + getAsset: (assetId: string) => Promise; + addAssets: (assets: Asset[]) => void; + putAsset: (asset: Asset) => void; +}; -/** - * @callback getAsset - * @param {string} assetId - * @returns {Promise} - */ - -/** - * @callback addAssets - * @param {Asset[]} assets - */ - -/** - * @callback putAsset - * @param {Asset} asset - */ - -/** - * @typedef AssetsContext - * @property {getAsset} getAsset - * @property {addAssets} addAssets - * @property {putAsset} putAsset - */ - -/** - * @type {React.Context} - */ -const AssetsContext = React.createContext(); +const AssetsContext = React.createContext(undefined); // 100 MB max cache size const maxCacheSize = 1e8; -export function AssetsProvider({ children }) { +export function AssetsProvider({ children }: { children: React.ReactNode }) { const { worker, database, databaseStatus } = useDatabase(); useEffect(() => { @@ -61,33 +32,39 @@ export function AssetsProvider({ children }) { const getAsset = useCallback( async (assetId) => { - return await database.table("assets").get(assetId); + if (database) { + return await database.table("assets").get(assetId); + } }, [database] ); const addAssets = useCallback( async (assets) => { - await database.table("assets").bulkAdd(assets); + if (database) { + await database.table("assets").bulkAdd(assets); + } }, [database] ); const putAsset = useCallback( async (asset) => { - // Check for broadcast channel and attempt to use worker to put map to avoid UI lockup - // Safari doesn't support BC so fallback to single thread - if (window.BroadcastChannel) { - const packedAsset = encode(asset); - const success = await worker.putData( - Comlink.transfer(packedAsset, [packedAsset.buffer]), - "assets" - ); - if (!success) { + if (database) { + // Check for broadcast channel and attempt to use worker to put map to avoid UI lockup + // Safari doesn't support BC so fallback to single thread + if (window.BroadcastChannel) { + const packedAsset = encode(asset); + const success = await worker.putData( + Comlink.transfer(packedAsset, [packedAsset.buffer]), + "assets" + ); + if (!success) { + await database.table("assets").put(asset); + } + } else { await database.table("assets").put(asset); } - } else { - await database.table("assets").put(asset); } }, [database, worker] @@ -119,35 +96,38 @@ export function useAssets() { * @property {number} references */ -/** - * @type React.Context> - */ -export const AssetURLsStateContext = React.createContext(); +type AssetURL = { + url: string | null; + id: string; + references: number; +}; -/** - * @type React.Context>> - */ -export const AssetURLsUpdaterContext = React.createContext(); +type AssetURLs = Record; + +export const AssetURLsStateContext = + React.createContext(undefined); + +export const AssetURLsUpdaterContext = + React.createContext< + React.Dispatch> | undefined + >(undefined); /** * Helper to manage sharing of custom image sources between uses of useAssetURL */ -export function AssetURLsProvider({ children }) { - const [assetURLs, setAssetURLs] = useState({}); +export function AssetURLsProvider({ children }: { children: React.ReactNode }) { + const [assetURLs, setAssetURLs] = useState({}); const { database } = useDatabase(); // Keep track of the assets that need to be loaded - const [assetKeys, setAssetKeys] = useState([]); + const [assetKeys, setAssetKeys] = useState([]); // Load assets after 100ms const loadingDebouncedAssetURLs = useDebounce(assetURLs, 100); // Update the asset keys to load when a url is added without an asset attached useEffect(() => { - if (!loadingDebouncedAssetURLs) { - return; - } - let keysToLoad = []; + let keysToLoad: string[] = []; for (let url of Object.values(loadingDebouncedAssetURLs)) { if (url.url === null) { keysToLoad.push(url.id); @@ -159,8 +139,9 @@ export function AssetURLsProvider({ children }) { }, [loadingDebouncedAssetURLs]); // Get the new assets whenever the keys change - const assets = useLiveQuery( - () => database?.table("assets").where("id").anyOf(assetKeys).toArray(), + const assets = useLiveQuery( + () => + database?.table("assets").where("id").anyOf(assetKeys).toArray() || [], [database, assetKeys] ); @@ -197,7 +178,7 @@ export function AssetURLsProvider({ children }) { let urlsToCleanup = []; for (let url of Object.values(prevURLs)) { if (url.references <= 0) { - URL.revokeObjectURL(url.url); + url.url && URL.revokeObjectURL(url.url); urlsToCleanup.push(url.id); } } @@ -220,13 +201,13 @@ export function AssetURLsProvider({ children }) { /** * Helper function to load either file or default asset into a URL - * @param {string} assetId - * @param {"file"|"default"} type - * @param {Object.} defaultSources - * @param {string|undefined} unknownSource - * @returns {string|undefined} */ -export function useAssetURL(assetId, type, defaultSources, unknownSource) { +export function useAssetURL( + assetId: string, + type: "file" | "default", + defaultSources: Record, + unknownSource?: string +) { const assetURLs = useContext(AssetURLsStateContext); if (assetURLs === undefined) { throw new Error("useAssetURL must be used within a AssetURLsProvider"); @@ -242,7 +223,7 @@ export function useAssetURL(assetId, type, defaultSources, unknownSource) { } function updateAssetURL() { - function increaseReferences(prevURLs) { + function increaseReferences(prevURLs: AssetURLs): AssetURLs { return { ...prevURLs, [assetId]: { @@ -252,13 +233,13 @@ export function useAssetURL(assetId, type, defaultSources, unknownSource) { }; } - function createReference(prevURLs) { + function createReference(prevURLs: AssetURLs): AssetURLs { return { ...prevURLs, [assetId]: { url: null, id: assetId, references: 1 }, }; } - setAssetURLs((prevURLs) => { + setAssetURLs?.((prevURLs) => { if (assetId in prevURLs) { // Check if the asset url is already added and increase references return increaseReferences(prevURLs); @@ -303,36 +284,29 @@ export function useAssetURL(assetId, type, defaultSources, unknownSource) { return unknownSource; } -/** - * @typedef FileData - * @property {string} file - * @property {"file"} type - * @property {string} thumbnail - * @property {string=} quality - * @property {Object.=} resolutions - */ +type FileData = { + file: string; + type: "file"; + thumbnail: string; + quality?: string; + resolutions?: Record; +}; -/** - * @typedef DefaultData - * @property {string} key - * @property {"default"} type - */ +type DefaultData = { + key: string; + type: "default"; +}; /** * Load a map or token into a URL taking into account a thumbnail and multiple resolutions - * @param {FileData|DefaultData} data - * @param {Object.} defaultSources - * @param {string|undefined} unknownSource - * @param {boolean} thumbnail - * @returns {string|undefined} */ export function useDataURL( - data, - defaultSources, - unknownSource, + data: FileData | DefaultData, + defaultSources: Record, + unknownSource: string | undefined, thumbnail = false ) { - const [assetId, setAssetId] = useState(); + const [assetId, setAssetId] = useState(); useEffect(() => { if (!data) { @@ -344,7 +318,11 @@ export function useDataURL( } else { if (thumbnail) { setAssetId(data.thumbnail); - } else if (data.resolutions && data.quality !== "original") { + } else if ( + data.resolutions && + data.quality && + data.quality !== "original" + ) { setAssetId(data.resolutions[data.quality]); } else { setAssetId(data.file); @@ -356,7 +334,7 @@ export function useDataURL( }, [data, thumbnail]); const assetURL = useAssetURL( - assetId, + assetId || "", data?.type, defaultSources, unknownSource diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 3ebd255..f5f2979 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -2,9 +2,11 @@ import React, { useState, useEffect, useContext } from "react"; import FakeStorage from "../helpers/FakeStorage"; -type AuthContext = { password: string; setPassword: React.Dispatch }; +type AuthContext = { + password: string; + setPassword: React.Dispatch>; +}; -// TODO: check what default value we want here const AuthContext = React.createContext(undefined); let storage: Storage | FakeStorage; diff --git a/src/contexts/DragContext.js b/src/contexts/DragContext.js deleted file mode 100644 index 2614b0f..0000000 --- a/src/contexts/DragContext.js +++ /dev/null @@ -1,75 +0,0 @@ -// eslint-disable-next-line no-unused-vars -import React, { useRef, ReactNode } from "react"; -import { - DndContext, - useDndContext, - useDndMonitor, - // eslint-disable-next-line no-unused-vars - DragEndEvent, -} from "@dnd-kit/core"; - -/** - * Wrap a dnd-kit DndContext with a position monitor to get the - * active drag element on drag end - * TODO: use look into fixing this upstream - * Related: https://github.com/clauderic/dnd-kit/issues/238 - */ - -/** - * @typedef DragEndOverlayEvent - * @property {DOMRect} overlayNodeClientRect - * - * @typedef {DragEndEvent & DragEndOverlayEvent} DragEndWithOverlayProps - */ - -/** - * @callback DragEndWithOverlayEvent - * @param {DragEndWithOverlayProps} props - */ - -/** - * @typedef CustomDragProps - * @property {DragEndWithOverlayEvent=} onDragEnd - * @property {ReactNode} children - */ - -/** - * @param {CustomDragProps} props - */ -function DragPositionMonitor({ children, onDragEnd }) { - const { overlayNode } = useDndContext(); - - const overlayNodeClientRectRef = useRef(); - function handleDragMove() { - if (overlayNode?.nodeRef?.current) { - overlayNodeClientRectRef.current = overlayNode.nodeRef.current.getBoundingClientRect(); - } - } - - function handleDragEnd(props) { - onDragEnd && - onDragEnd({ - ...props, - overlayNodeClientRect: overlayNodeClientRectRef.current, - }); - } - useDndMonitor({ onDragEnd: handleDragEnd, onDragMove: handleDragMove }); - - return children; -} - -/** - * TODO: Import Props interface from dnd-kit with conversion to Typescript - * @param {CustomDragProps} props - */ -function DragContext({ children, onDragEnd, ...props }) { - return ( - - - {children} - - - ); -} - -export default DragContext; diff --git a/src/contexts/DragContext.tsx b/src/contexts/DragContext.tsx new file mode 100644 index 0000000..2cf8f6a --- /dev/null +++ b/src/contexts/DragContext.tsx @@ -0,0 +1,68 @@ +import { useRef } from "react"; +import { + DndContext, + useDndContext, + useDndMonitor, + DragEndEvent, +} from "@dnd-kit/core"; + +import { Props } from "@dnd-kit/core/dist/components/DndContext/DndContext"; + +/** + * Wrap a dnd-kit DndContext with a position monitor to get the + * active drag element on drag end + * TODO: use look into fixing this upstream + * Related: https://github.com/clauderic/dnd-kit/issues/238 + */ + +type DragEndWithOverlayEvent = { + overlayNodeClientRect?: DOMRect; +}; + +export type CustomDragEndEvent = DragEndWithOverlayEvent & DragEndEvent; + +type CustomDragProps = { + onDragEnd?: (event: CustomDragEndEvent) => void; + ; +}; + +function DragPositionMonitor({ onDragEnd }: CustomDragProps) { + const { overlayNode } = useDndContext(); + + const overlayNodeClientRectRef = useRef(); + function handleDragMove() { + if (overlayNode?.nodeRef?.current) { + overlayNodeClientRectRef.current = + overlayNode.nodeRef.current.getBoundingClientRect(); + } + } + + function handleDragEnd(props: DragEndEvent) { + onDragEnd && + onDragEnd({ + ...props, + overlayNodeClientRect: overlayNodeClientRectRef.current, + }); + } + useDndMonitor({ onDragEnd: handleDragEnd, onDragMove: handleDragMove }); + + return null; +} + +/** + * @param {CustomDragProps} props + */ +function DragContext({ + children, + onDragEnd, + ...props +}: CustomDragProps & Props) { + return ( + + + {children} + + ); +} + +export default DragContext; diff --git a/src/contexts/GroupContext.js b/src/contexts/GroupContext.tsx similarity index 70% rename from src/contexts/GroupContext.js rename to src/contexts/GroupContext.tsx index 3b2138d..fd667fb 100644 --- a/src/contexts/GroupContext.js +++ b/src/contexts/GroupContext.tsx @@ -7,8 +7,40 @@ import { useKeyboard, useBlur } from "./KeyboardContext"; import { getGroupItems, groupsFromIds } from "../helpers/group"; import shortcuts from "../shortcuts"; +import { Group, GroupContainer, GroupItem } from "../types/Group"; -const GroupContext = React.createContext(); +type GroupContext = { + groups: Group[]; + activeGroups: Group[]; + openGroupId: string | undefined; + openGroupItems: Group[]; + filter: string | undefined; + filteredGroupItems: GroupItem[]; + selectedGroupIds: string[]; + selectMode: any; + onSelectModeChange: React.Dispatch< + React.SetStateAction<"single" | "multiple" | "range"> + >; + onGroupOpen: (groupId: string) => void; + onGroupClose: () => void; + onGroupsChange: ( + newGroups: Group[] | GroupItem[], + groupId: string | undefined + ) => void; + onGroupSelect: (groupId: string | undefined) => void; + onFilterChange: React.Dispatch>; +}; + +const GroupContext = React.createContext(undefined); + +type GroupProviderProps = { + groups: Group[]; + itemNames: Record; + onGroupsChange: (groups: Group[]) => void; + onGroupsSelect: (groupIds: string[]) => void; + disabled: boolean; + children: React.ReactNode; +}; export function GroupProvider({ groups, @@ -17,16 +49,17 @@ export function GroupProvider({ onGroupsSelect, disabled, children, -}) { - const [selectedGroupIds, setSelectedGroupIds] = useState([]); +}: GroupProviderProps) { + const [selectedGroupIds, setSelectedGroupIds] = useState([]); // Either single, multiple or range - const [selectMode, setSelectMode] = useState("single"); + const [selectMode, setSelectMode] = + useState<"single" | "multiple" | "range">("single"); /** * Group Open */ - const [openGroupId, setOpenGroupId] = useState(); - const [openGroupItems, setOpenGroupItems] = useState([]); + const [openGroupId, setOpenGroupId] = useState(); + const [openGroupItems, setOpenGroupItems] = useState([]); useEffect(() => { if (openGroupId) { const openGroups = groupsFromIds([openGroupId], groups); @@ -37,29 +70,29 @@ export function GroupProvider({ // Close group if we can't find it // This can happen if it was deleted or all it's items were deleted setOpenGroupItems([]); - setOpenGroupId(); + setOpenGroupId(undefined); } } else { setOpenGroupItems([]); } }, [openGroupId, groups]); - function handleGroupOpen(groupId) { + function handleGroupOpen(groupId: string) { setSelectedGroupIds([]); setOpenGroupId(groupId); } function handleGroupClose() { setSelectedGroupIds([]); - setOpenGroupId(); + setOpenGroupId(undefined); } /** * Search */ - const [filter, setFilter] = useState(); - const [filteredGroupItems, setFilteredGroupItems] = useState([]); - const [fuse, setFuse] = useState(); + const [filter, setFilter] = useState(); + const [filteredGroupItems, setFilteredGroupItems] = useState([]); + const [fuse, setFuse] = useState>(); // Update search index when items change useEffect(() => { let items = []; @@ -76,10 +109,10 @@ export function GroupProvider({ // Perform search when search changes useEffect(() => { - if (filter) { + if (filter && fuse) { const query = fuse.search(filter); setFilteredGroupItems(query.map((result) => result.item)); - setOpenGroupId(); + setOpenGroupId(undefined); } else { setFilteredGroupItems([]); } @@ -96,23 +129,30 @@ export function GroupProvider({ : groups; /** + * @param {Group[] | GroupItem[]} newGroups * @param {string|undefined} groupId The group to apply changes to, leave undefined to replace the full group object */ - function handleGroupsChange(newGroups, groupId) { + function handleGroupsChange( + newGroups: Group[] | GroupItem[], + groupId: string | undefined + ) { if (groupId) { // If a group is specidifed then update that group with the new items const groupIndex = groups.findIndex((group) => group.id === groupId); let updatedGroups = cloneDeep(groups); const group = updatedGroups[groupIndex]; - updatedGroups[groupIndex] = { ...group, items: newGroups }; + updatedGroups[groupIndex] = { + ...group, + items: newGroups, + } as GroupContainer; onGroupsChange(updatedGroups); } else { onGroupsChange(newGroups); } } - function handleGroupSelect(groupId) { - let groupIds = []; + function handleGroupSelect(groupId: string | undefined) { + let groupIds: string[] = []; if (groupId) { switch (selectMode) { case "single": @@ -133,8 +173,8 @@ export function GroupProvider({ const lastIndex = activeGroups.findIndex( (g) => g.id === selectedGroupIds[selectedGroupIds.length - 1] ); - let idsToAdd = []; - let idsToRemove = []; + let idsToAdd: string[] = []; + let idsToRemove: string[] = []; const direction = currentIndex > lastIndex ? 1 : -1; for ( let i = lastIndex + direction; @@ -166,7 +206,7 @@ export function GroupProvider({ /** * Shortcuts */ - function handleKeyDown(event) { + function handleKeyDown(event: React.KeyboardEvent) { if (disabled) { return; } @@ -178,7 +218,7 @@ export function GroupProvider({ } } - function handleKeyUp(event) { + function handleKeyUp(event: React.KeyboardEvent) { if (disabled) { return; } diff --git a/src/contexts/MapDataContext.tsx b/src/contexts/MapDataContext.tsx index b913d00..0114145 100644 --- a/src/contexts/MapDataContext.tsx +++ b/src/contexts/MapDataContext.tsx @@ -9,7 +9,9 @@ import { useLiveQuery } from "dexie-react-hooks"; import { useDatabase } from "./DatabaseContext"; -import { Map, MapState, Note } from "../components/map/Map"; +import { Map } from "../types/Map"; +import { MapState } from "../types/MapState"; +import { Note } from "../types/Note"; import { removeGroupsItems } from "../helpers/group"; diff --git a/src/contexts/TileDragContext.js b/src/contexts/TileDragContext.tsx similarity index 78% rename from src/contexts/TileDragContext.js rename to src/contexts/TileDragContext.tsx index 3a0f52f..2a3844c 100644 --- a/src/contexts/TileDragContext.js +++ b/src/contexts/TileDragContext.tsx @@ -6,19 +6,26 @@ import { useSensor, useSensors, closestCenter, + RectEntry, } from "@dnd-kit/core"; -import DragContext from "./DragContext"; +import DragContext, { CustomDragEndEvent } from "./DragContext"; +import { DragStartEvent, DragOverEvent, ViewRect } from "@dnd-kit/core"; +import { DragCancelEvent } from "@dnd-kit/core/dist/types"; import { useGroup } from "./GroupContext"; import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group"; +import Vector2 from "../helpers/Vector2"; import usePreventSelect from "../hooks/usePreventSelect"; -const TileDragIdContext = React.createContext(); -const TileOverGroupIdContext = React.createContext(); -const TileDragCursorContext = React.createContext(); +const TileDragIdContext = + React.createContext(undefined); +const TileOverGroupIdContext = + React.createContext(undefined); +const TileDragCursorContext = + React.createContext(undefined); export const BASE_SORTABLE_ID = "__base__"; export const GROUP_SORTABLE_ID = "__group__"; @@ -27,7 +34,7 @@ export const UNGROUP_ID = "__ungroup__"; export const ADD_TO_MAP_ID = "__add__"; // Custom rectIntersect that takes a point -function rectIntersection(rects, point) { +function rectIntersection(rects: RectEntry[], point: Vector2) { for (let rect of rects) { const [id, bounds] = rect; if ( @@ -44,13 +51,21 @@ function rectIntersection(rects, point) { return null; } +type TileDragProviderProps = { + onDragAdd?: (selectedGroupIds: string[], rect: DOMRect) => void; + onDragStart?: (event: DragStartEvent) => void; + onDragEnd?: (event: CustomDragEndEvent) => void; + onDragCancel?: (event: DragCancelEvent) => void; + children?: React.ReactNode; +}; + export function TileDragProvider({ onDragAdd, onDragStart, onDragEnd, onDragCancel, children, -}) { +}: TileDragProviderProps) { const { groups, activeGroups, @@ -71,23 +86,23 @@ export function TileDragProvider({ const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor); - const [dragId, setDragId] = useState(null); - const [overId, setOverId] = useState(null); + const [dragId, setDragId] = useState(null); + const [overId, setOverId] = useState(null); const [dragCursor, setDragCursor] = useState("pointer"); const [preventSelect, resumeSelect] = usePreventSelect(); - const [overGroupId, setOverGroupId] = useState(null); + 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; + function handleDragStart(event: DragStartEvent) { + const { active } = event; setDragId(active.id); - setOverId(over?.id || null); + setOverId(null); if (!selectedGroupIds.includes(active.id)) { onGroupSelect(active.id); } @@ -98,7 +113,7 @@ export function TileDragProvider({ preventSelect(); } - function handleDragOver(event) { + function handleDragOver(event: DragOverEvent) { const { over } = event; setOverId(over?.id || null); @@ -116,7 +131,7 @@ export function TileDragProvider({ } } - function handleDragEnd(event) { + function handleDragEnd(event: CustomDragEndEvent) { const { active, over, overlayNodeClientRect } = event; setDragId(null); @@ -130,7 +145,7 @@ export function TileDragProvider({ selectedIndices = selectedIndices.sort((a, b) => a - b); if (over.id.startsWith(GROUP_ID_PREFIX)) { - onGroupSelect(); + onGroupSelect(undefined); // Handle tile group const overId = over.id.slice(9); if (overId !== active.id) { @@ -143,10 +158,12 @@ export function TileDragProvider({ ); } } else if (over.id === UNGROUP_ID) { - onGroupSelect(); - // Handle tile ungroup - const newGroups = ungroup(groups, openGroupId, selectedIndices); - onGroupsChange(newGroups); + if (openGroupId) { + onGroupSelect(undefined); + // Handle tile ungroup + const newGroups = ungroup(groups, openGroupId, selectedIndices); + onGroupsChange(newGroups, undefined); + } } else if (over.id === ADD_TO_MAP_ID) { onDragAdd && overlayNodeClientRect && @@ -168,7 +185,7 @@ export function TileDragProvider({ onDragEnd && onDragEnd(event); } - function handleDragCancel(event) { + function handleDragCancel(event: DragCancelEvent) { setDragId(null); setOverId(null); setDragCursor("pointer"); @@ -178,7 +195,7 @@ export function TileDragProvider({ onDragCancel && onDragCancel(event); } - function customCollisionDetection(rects, rect) { + function customCollisionDetection(rects: RectEntry[], rect: ViewRect) { const rectCenter = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, diff --git a/src/contexts/UserIdContext.js b/src/contexts/UserIdContext.tsx similarity index 64% rename from src/contexts/UserIdContext.js rename to src/contexts/UserIdContext.tsx index 72d3bb8..7630c38 100644 --- a/src/contexts/UserIdContext.js +++ b/src/contexts/UserIdContext.tsx @@ -1,12 +1,10 @@ import React, { useEffect, useState, useContext } from "react"; import { useDatabase } from "./DatabaseContext"; -/** - * @type {React.Context} - */ -const UserIdContext = React.createContext(); -export function UserIdProvider({ children }) { +const UserIdContext = React.createContext(undefined); + +export function UserIdProvider({ children }: { children: React.ReactNode }) { const { database, databaseStatus } = useDatabase(); const [userId, setUserId] = useState(); @@ -15,9 +13,11 @@ export function UserIdProvider({ children }) { return; } async function loadUserId() { - const storedUserId = await database.table("user").get("userId"); - if (storedUserId) { - setUserId(storedUserId.value); + if (database) { + const storedUserId = await database.table("user").get("userId"); + if (storedUserId) { + setUserId(storedUserId.value); + } } } diff --git a/src/dice/Dice.ts b/src/dice/Dice.ts index 785d8a8..275680b 100644 --- a/src/dice/Dice.ts +++ b/src/dice/Dice.ts @@ -1,7 +1,10 @@ import { Vector3 } from "@babylonjs/core/Maths/math"; import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader"; import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial"; -import { PhysicsImpostor, PhysicsImpostorParameters } from "@babylonjs/core/Physics/physicsImpostor"; +import { + PhysicsImpostor, + PhysicsImpostorParameters, +} from "@babylonjs/core/Physics/physicsImpostor"; import d4Source from "./shared/d4.glb"; import d6Source from "./shared/d6.glb"; @@ -13,7 +16,15 @@ import d100Source from "./shared/d100.glb"; import { lerp } from "../helpers/shared"; import { importTextureAsync } from "../helpers/babylon"; -import { BaseTexture, InstancedMesh, Material, Mesh, Scene, Texture } from "@babylonjs/core"; +import { + BaseTexture, + InstancedMesh, + Material, + Mesh, + Scene, + Texture, +} from "@babylonjs/core"; +import { DiceType } from "../types/Dice"; const minDiceRollSpeed = 600; const maxDiceRollSpeed = 800; @@ -21,10 +32,16 @@ const maxDiceRollSpeed = 800; class Dice { static instanceCount = 0; - static async loadMeshes(material: Material, scene: Scene, sourceOverrides?: any): Promise> { + static async loadMeshes( + material: Material, + scene: Scene, + sourceOverrides?: any + ): Promise> { let meshes: any = {}; const addToMeshes = async (type: string | number, defaultSource: any) => { - let source: string = sourceOverrides ? sourceOverrides[type] : defaultSource; + let source: string = sourceOverrides + ? sourceOverrides[type] + : defaultSource; const mesh = await this.loadMesh(source, material, scene); meshes[type] = mesh; }; @@ -54,7 +71,11 @@ class Dice { static async loadMaterial(materialName: string, textures: any, scene: Scene) { let pbr = new PBRMaterial(materialName, scene); - let [albedo, normal, metalRoughness]: [albedo: BaseTexture, normal: Texture, metalRoughness: Texture] = await Promise.all([ + let [albedo, normal, metalRoughness]: [ + albedo: BaseTexture, + normal: Texture, + metalRoughness: Texture + ] = await Promise.all([ importTextureAsync(textures.albedo), importTextureAsync(textures.normal), importTextureAsync(textures.metalRoughness), @@ -69,7 +90,12 @@ class Dice { return pbr; } - static createInstanceFromMesh(mesh: Mesh, name: string, physicalProperties: PhysicsImpostorParameters, scene: Scene) { + static createInstanceFromMesh( + mesh: Mesh, + name: string, + physicalProperties: PhysicsImpostorParameters, + scene: Scene + ) { let instance = mesh.createInstance(name); instance.position = mesh.position; for (let child of mesh.getChildTransformNodes()) { @@ -77,7 +103,7 @@ class Dice { const locator: any = child.clone(child.name, instance); // TODO: handle possible null value if (!locator) { - throw Error + throw Error; } locator.setAbsolutePosition(child.getAbsolutePosition()); locator.name = child.name; @@ -114,7 +140,7 @@ class Dice { } } - static roll(instance: Mesh) { + static roll(instance: InstancedMesh) { instance.physicsImpostor?.setLinearVelocity(Vector3.Zero()); instance.physicsImpostor?.setAngularVelocity(Vector3.Zero()); @@ -156,7 +182,11 @@ class Dice { ); } - static createInstanceMesh(mesh: Mesh, physicalProperties: PhysicsImpostorParameters, scene: Scene): InstancedMesh { + static createInstanceMesh( + mesh: Mesh, + physicalProperties: PhysicsImpostorParameters, + scene: Scene + ): InstancedMesh { this.instanceCount++; return this.createInstanceFromMesh( @@ -166,6 +196,14 @@ class Dice { scene ); } + + static async load(scene: Scene) { + throw new Error(`Unable to load ${scene}`); + } + + static createInstance(diceType: DiceType, scene: Scene): InstancedMesh { + throw new Error(`No instance available for ${diceType} in ${scene}`); + } } export default Dice; diff --git a/src/dice/index.ts b/src/dice/index.ts index 82cf7f0..5c71cbc 100644 --- a/src/dice/index.ts +++ b/src/dice/index.ts @@ -19,7 +19,9 @@ import GlassPreview from "./glass/preview.png"; import GemstonePreview from "./gemstone/preview.png"; import Dice from "./Dice"; -type DiceClasses = Record; +import { DefaultDice } from "../types/Dice"; + +type DiceClasses = Record; export const diceClasses: DiceClasses = { galaxy: GalaxyDice, @@ -45,7 +47,7 @@ export const dicePreviews: DicePreview = { gemstone: GemstonePreview, }; -export const dice = Object.keys(diceClasses).map((key) => ({ +export const dice: DefaultDice[] = Object.keys(diceClasses).map((key) => ({ key, name: Case.capital(key), class: diceClasses[key], diff --git a/src/dice/walnut/WalnutDice.ts b/src/dice/walnut/WalnutDice.ts index 979808e..9813f11 100644 --- a/src/dice/walnut/WalnutDice.ts +++ b/src/dice/walnut/WalnutDice.ts @@ -12,6 +12,7 @@ import d12Source from "./d12.glb"; import d20Source from "./d20.glb"; import d100Source from "./d100.glb"; import { Material, Mesh, Scene } from "@babylonjs/core"; +import { DiceType } from "../../types/Dice"; const sourceOverrides = { d4: d4Source, @@ -24,7 +25,7 @@ const sourceOverrides = { }; class WalnutDice extends Dice { - static meshes: Record; + static meshes: Record; static material: Material; static getDicePhysicalProperties(diceType: string) { @@ -49,7 +50,7 @@ class WalnutDice extends Dice { } } - static createInstance(diceType: string, scene: Scene) { + static createInstance(diceType: DiceType, scene: Scene) { if (!this.material || !this.meshes) { throw Error("Dice not loaded, call load before creating an instance"); } diff --git a/src/helpers/Vector2.ts b/src/helpers/Vector2.ts index 2f9ef6b..8e727db 100644 --- a/src/helpers/Vector2.ts +++ b/src/helpers/Vector2.ts @@ -6,12 +6,12 @@ import { } from "./shared"; export type BoundingBox = { - min: Vector2, - max: Vector2, - width: number, - height: number, - center: Vector2 -} + min: Vector2; + max: Vector2; + width: number; + height: number; + center: Vector2; +}; /** * Vector class with x, y and static helper methods @@ -287,7 +287,12 @@ class Vector2 { * @param {Vector2} C End of the curve * @returns {Object} The distance to and the closest point on the curve */ - static distanceToQuadraticBezier(pos: Vector2, A: Vector2, B: Vector2, C: Vector2): Object { + static distanceToQuadraticBezier( + pos: Vector2, + A: Vector2, + B: Vector2, + C: Vector2 + ): Object { let distance = 0; let point = { x: pos.x, y: pos.y }; @@ -514,7 +519,10 @@ class Vector2 { * @param {("counterClockwise"|"clockwise")=} direction Direction to rotate the vector * @returns {Vector2} */ - static rotate90(p: Vector2, direction: "counterClockwise" | "clockwise" = "clockwise"): Vector2 { + static rotate90( + p: Vector2, + direction: "counterClockwise" | "clockwise" = "clockwise" + ): Vector2 { if (direction === "clockwise") { return { x: p.y, y: -p.x }; } else { @@ -527,7 +535,7 @@ class Vector2 { * @param {Vector2[]} points * @returns {Vector2} */ - static centroid(points) { + static centroid(points: Vector2[]): Vector2 { let center = { x: 0, y: 0 }; for (let point of points) { center.x += point.x; @@ -544,7 +552,7 @@ class Vector2 { * @param {Vector2[]} points * @returns {boolean} */ - static rectangular(points) { + static rectangular(points: Vector2[]): boolean { if (points.length !== 4) { return false; } @@ -567,7 +575,7 @@ class Vector2 { * @param {Vector2[]} points * @returns {boolean} */ - static circular(points, threshold = 0.1) { + static circular(points: Vector2[], threshold = 0.1): boolean { const centroid = this.centroid(points); let distances = []; for (let point of points) { diff --git a/src/helpers/dice.ts b/src/helpers/dice.ts index 2eb661e..8cb086d 100644 --- a/src/helpers/dice.ts +++ b/src/helpers/dice.ts @@ -1,4 +1,5 @@ import { Vector3 } from "@babylonjs/core/Maths/math"; +import { DiceRoll } from "../types/Dice"; /** * Find the number facing up on a mesh instance of a dice @@ -42,7 +43,7 @@ export function getDiceRoll(dice: any) { return { type: dice.type, roll: number }; } -export function getDiceRollTotal(diceRolls: []) { +export function getDiceRollTotal(diceRolls: DiceRoll[]) { return diceRolls.reduce((accumulator: number, dice: any) => { if (dice.roll === "unknown") { return accumulator; diff --git a/src/helpers/drawing.ts b/src/helpers/drawing.ts index 3b18051..92b04db 100644 --- a/src/helpers/drawing.ts +++ b/src/helpers/drawing.ts @@ -2,136 +2,19 @@ import simplify from "simplify-js"; import polygonClipping, { Geom, Polygon, Ring } from "polygon-clipping"; import Vector2, { BoundingBox } from "./Vector2"; -import Size from "./Size" +import Size from "./Size"; import { toDegrees } from "./shared"; -import { Grid, getNearestCellCoordinates, getCellLocation } from "./grid"; +import { getNearestCellCoordinates, getCellLocation } from "./grid"; -/** - * @typedef PointsData - * @property {Vector2[]} points - */ - -type PointsData = { - points: Vector2[] -} - -/** - * @typedef RectData - * @property {number} x - * @property {number} y - * @property {number} width - * @property {number} height - */ - -type RectData = { - x: number, - y: number, - width: number, - height: number -} - -/** - * @typedef CircleData - * @property {number} x - * @property {number} y - * @property {number} radius - */ - -type CircleData = { - x: number, - y: number, - radius: number -} - -/** - * @typedef FogData - * @property {Vector2[]} points - * @property {Vector2[][]} holes - */ - -type FogData = { - points: Vector2[] - holes: Vector2[][] -} - -/** - * @typedef {(PointsData|RectData|CircleData)} ShapeData - */ - -type ShapeData = PointsData | RectData | CircleData - -/** - * @typedef {("line"|"rectangle"|"circle"|"triangle")} ShapeType - */ - -type ShapeType = "line" | "rectangle" | "circle" | "triangle" - -/** - * @typedef {("fill"|"stroke")} PathType - */ - -type PathType = "fill" | "stroke" - -/** - * @typedef Path - * @property {boolean} blend - * @property {string} color - * @property {PointsData} data - * @property {string} id - * @property {PathType} pathType - * @property {number} strokeWidth - * @property {"path"} type - */ - -export type Path = { - blend: boolean, - color: string, - data: PointsData, - id: string, - pathType: PathType, - strokeWidth: number, - type: "path" -} - -/** - * @typedef Shape - * @property {boolean} blend - * @property {string} color - * @property {ShapeData} data - * @property {string} id - * @property {ShapeType} shapeType - * @property {number} strokeWidth - * @property {"shape"} type - */ - -export type Shape = { - blend: boolean, - color: string, - data: ShapeData, - id: string, - shapeType: ShapeType, - strokeWidth: number, - type: "shape" -} - -/** - * @typedef Fog - * @property {string} color - * @property {FogData} data - * @property {string} id - * @property {number} strokeWidth - * @property {"fog"} type - * @property {boolean} visible - */ - -export type Fog = { - color: string, - data: FogData, - id: string, - strokeWidth: number, - type: "fog", - visible: boolean -} +import { + ShapeType, + ShapeData, + PointsData, + RectData, + CircleData, +} from "../types/Drawing"; +import { Fog } from "../types/Fog"; +import { Grid } from "../types/Grid"; /** * @@ -139,24 +22,26 @@ export type Fog = { * @param {Vector2} brushPosition * @returns {ShapeData} */ -export function getDefaultShapeData(type: ShapeType, brushPosition: Vector2): ShapeData | undefined{ - // TODO: handle undefined if no type found +export function getDefaultShapeData( + type: ShapeType, + brushPosition: Vector2 +): ShapeData { if (type === "line") { return { points: [ { x: brushPosition.x, y: brushPosition.y }, { x: brushPosition.x, y: brushPosition.y }, ], - } as PointsData; + }; } else if (type === "circle") { - return { x: brushPosition.x, y: brushPosition.y, radius: 0 } as CircleData; + return { x: brushPosition.x, y: brushPosition.y, radius: 0 }; } else if (type === "rectangle") { return { x: brushPosition.x, y: brushPosition.y, width: 0, height: 0, - } as RectData; + }; } else if (type === "triangle") { return { points: [ @@ -164,7 +49,9 @@ export function getDefaultShapeData(type: ShapeType, brushPosition: Vector2): Sh { x: brushPosition.x, y: brushPosition.y }, { x: brushPosition.x, y: brushPosition.y }, ], - } as PointsData; + }; + } else { + throw new Error("Shape type not implemented"); } } @@ -197,15 +84,14 @@ export function getUpdatedShapeData( gridCellNormalizedSize: Vector2, mapWidth: number, mapHeight: number -): ShapeData | undefined { - // TODO: handle undefined type +): ShapeData { if (type === "line") { data = data as PointsData; return { points: [data.points[0], { x: brushPosition.x, y: brushPosition.y }], } as PointsData; } else if (type === "circle") { - data = data as CircleData; + data = data as CircleData; const gridRatio = getGridCellRatio(gridCellNormalizedSize); const dif = Vector2.subtract(brushPosition, { x: data.x, @@ -254,6 +140,8 @@ export function getUpdatedShapeData( Vector2.add(Vector2.multiply(rightDirNorm, sideLength), points[0]), ], }; + } else { + throw new Error("Shape type not implemented"); } } @@ -262,7 +150,10 @@ export function getUpdatedShapeData( * @param {Vector2[]} points * @param {number} tolerance */ -export function simplifyPoints(points: Vector2[], tolerance: number): Vector2[] { +export function simplifyPoints( + points: Vector2[], + tolerance: number +): Vector2[] { return simplify(points, tolerance); } @@ -272,7 +163,10 @@ export function simplifyPoints(points: Vector2[], tolerance: number): Vector2[] * @param {boolean} ignoreHidden * @returns {Fog[]} */ -export function mergeFogShapes(shapes: Fog[], ignoreHidden: boolean = true): Fog[] { +export function mergeFogShapes( + shapes: Fog[], + ignoreHidden: boolean = true +): Fog[] { if (shapes.length === 0) { return shapes; } @@ -283,7 +177,7 @@ export function mergeFogShapes(shapes: Fog[], ignoreHidden: boolean = true): Fog } const shapePoints: Ring = shape.data.points.map(({ x, y }) => [x, y]); const shapeHoles: Polygon = shape.data.holes.map((hole) => - hole.map(({ x, y }: { x: number, y: number }) => [x, y]) + hole.map(({ x, y }: { x: number; y: number }) => [x, y]) ); let shapeGeom: Geom = [[shapePoints, ...shapeHoles]]; geometries.push(shapeGeom); @@ -315,7 +209,7 @@ export function mergeFogShapes(shapes: Fog[], ignoreHidden: boolean = true): Fog points: union[i][0].map(([x, y]) => ({ x, y })), holes, }, - type: "fog" + type: "fog", }); } return merged; @@ -330,7 +224,10 @@ export function mergeFogShapes(shapes: Fog[], ignoreHidden: boolean = true): Fog * @param {boolean} maxPoints Max amount of points per shape to get bounds for * @returns {Vector2.BoundingBox[]} */ -export function getFogShapesBoundingBoxes(shapes: Fog[], maxPoints = 0): BoundingBox[] { +export function getFogShapesBoundingBoxes( + shapes: Fog[], + maxPoints = 0 +): BoundingBox[] { let boxes = []; for (let shape of shapes) { if (maxPoints > 0 && shape.data.points.length > maxPoints) { @@ -361,11 +258,11 @@ export function getFogShapesBoundingBoxes(shapes: Fog[], maxPoints = 0): Boundin */ type Guide = { - start: Vector2, - end: Vector2, - orientation: "horizontal" | "vertical", - distance: number -} + start: Vector2; + end: Vector2; + orientation: "horizontal" | "vertical"; + distance: number; +}; /** * @param {Vector2} brushPosition Brush position in pixels @@ -382,7 +279,7 @@ export function getGuidesFromGridCell( grid: Grid, gridCellSize: Size, gridOffset: Vector2, - gridCellOffset: Vector2, + gridCellOffset: Vector2, snappingSensitivity: number, mapSize: Vector2 ): Guide[] { @@ -500,7 +397,10 @@ export function getGuidesFromBoundingBoxes( * @param {Guide[]} guides * @returns {Guide[]} */ -export function findBestGuides(brushPosition: Vector2, guides: Guide[]): Guide[] { +export function findBestGuides( + brushPosition: Vector2, + guides: Guide[] +): Guide[] { let bestGuides: Guide[] = []; let verticalGuide = guides .filter((guide) => guide.orientation === "vertical") diff --git a/src/helpers/grid.ts b/src/helpers/grid.ts index fa5d422..52b906b 100644 --- a/src/helpers/grid.ts +++ b/src/helpers/grid.ts @@ -8,42 +8,6 @@ import { logError } from "./logging"; const SQRT3 = 1.73205; const GRID_TYPE_NOT_IMPLEMENTED = new Error("Grid type not implemented"); -/** - * @typedef GridInset - * @property {Vector2} topLeft Top left position of the inset - * @property {Vector2} bottomRight Bottom right position of the inset - */ - -type GridInset = { - topLeft: Vector2, - bottomRight: Vector2 -} - -/** - * @typedef GridMeasurement - * @property {("chebyshev"|"alternating"|"euclidean"|"manhattan")} type - * @property {string} scale - */ - -type GridMeasurement ={ - type: ("chebyshev"|"alternating"|"euclidean"|"manhattan") - scale: string -} - -/** - * @typedef Grid - * @property {GridInset} inset The inset of the grid from the map - * @property {Vector2} size The number of columns and rows of the grid as `x` and `y` - * @property {("square"|"hexVertical"|"hexHorizontal")} type - * @property {GridMeasurement} measurement - */ -export type Grid = { - inset?: GridInset, - size: Vector2, - type: ("square"|"hexVertical"|"hexHorizontal"), - measurement?: GridMeasurement -} - /** * Gets the size of a grid in pixels taking into account the inset * @param {Grid} grid @@ -51,7 +15,11 @@ export type Grid = { * @param {number} baseHeight Height of the grid in pixels before inset * @returns {Size} */ -export function getGridPixelSize(grid: Required, baseWidth: number, baseHeight: number): Size { +export function getGridPixelSize( + grid: Required, + baseWidth: number, + baseHeight: number +): Size { const width = (grid.inset.bottomRight.x - grid.inset.topLeft.x) * baseWidth; const height = (grid.inset.bottomRight.y - grid.inset.topLeft.y) * baseHeight; return new Size(width, height); @@ -64,7 +32,11 @@ export function getGridPixelSize(grid: Required, baseWidth: number, baseHe * @param {number} gridHeight Height of the grid in pixels after inset * @returns {Size} */ -export function getCellPixelSize(grid: Grid, gridWidth: number, gridHeight: number): Size { +export function getCellPixelSize( + grid: Grid, + gridWidth: number, + gridHeight: number +): Size { if (grid.size.x === 0 || grid.size.y === 0) { return new Size(0, 0); } @@ -91,7 +63,12 @@ export function getCellPixelSize(grid: Grid, gridWidth: number, gridHeight: numb * @param {Size} cellSize Cell size in pixels * @returns {Vector2} */ -export function getCellLocation(grid: Grid, col: number, row: number, cellSize: Size): Vector2 { +export function getCellLocation( + grid: Grid, + col: number, + row: number, + cellSize: Size +): Vector2 { switch (grid.type) { case "square": return { @@ -121,7 +98,12 @@ export function getCellLocation(grid: Grid, col: number, row: number, cellSize: * @param {Size} cellSize Cell size in pixels * @returns {Vector2} */ -export function getNearestCellCoordinates(grid: Grid, x: number, y: number, cellSize: Size): Vector2 { +export function getNearestCellCoordinates( + grid: Grid, + x: number, + y: number, + cellSize: Size +): Vector2 { switch (grid.type) { case "square": return Vector2.divide(Vector2.floorTo({ x, y }, cellSize), cellSize); @@ -151,7 +133,12 @@ export function getNearestCellCoordinates(grid: Grid, x: number, y: number, cell * @param {Size} cellSize Cell size in pixels * @returns {Vector2[]} */ -export function getCellCorners(grid: Grid, x: number, y: number, cellSize: Size): Vector2[] { +export function getCellCorners( + grid: Grid, + x: number, + y: number, + cellSize: Size +): Vector2[] { const position = new Vector2(x, y); switch (grid.type) { case "square": @@ -193,7 +180,7 @@ export function getCellCorners(grid: Grid, x: number, y: number, cellSize: Size) * @param {number} gridWidth Width of the grid in pixels after inset * @returns {number} */ -function getGridHeightFromWidth(grid: Grid, gridWidth: number): number{ +function getGridHeightFromWidth(grid: Grid, gridWidth: number): number { switch (grid.type) { case "square": return (grid.size.y * gridWidth) / grid.size.x; @@ -215,7 +202,11 @@ function getGridHeightFromWidth(grid: Grid, gridWidth: number): number{ * @param {number} mapHeight Height of the map in pixels before inset * @returns {GridInset} */ -export function getGridDefaultInset(grid: Grid, mapWidth: number, mapHeight: number): GridInset { +export function getGridDefaultInset( + grid: Grid, + mapWidth: number, + mapHeight: number +): GridInset { // Max the width of the inset and figure out the resulting height value const insetHeightNorm = getGridHeightFromWidth(grid, mapWidth) / mapHeight; return { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: insetHeightNorm } }; @@ -228,7 +219,11 @@ export function getGridDefaultInset(grid: Grid, mapWidth: number, mapHeight: num * @param {number} mapHeight Height of the map in pixels before inset * @returns {GridInset} */ -export function getGridUpdatedInset(grid: Required, mapWidth: number, mapHeight: number): GridInset { +export function getGridUpdatedInset( + grid: Required, + mapWidth: number, + mapHeight: number +): GridInset { let inset = { topLeft: { ...grid.inset.topLeft }, bottomRight: { ...grid.inset.bottomRight }, @@ -263,7 +258,10 @@ export function getGridMaxZoom(grid: Grid): number { * @param {("hexVertical"|"hexHorizontal")} type * @returns {Vector2} */ -export function hexCubeToOffset(cube: Vector3, type: ("hexVertical"|"hexHorizontal")) { +export function hexCubeToOffset( + cube: Vector3, + type: "hexVertical" | "hexHorizontal" +) { if (type === "hexVertical") { const x = cube.x + (cube.z + (cube.z & 1)) / 2; const y = cube.z; @@ -280,7 +278,10 @@ export function hexCubeToOffset(cube: Vector3, type: ("hexVertical"|"hexHorizont * @param {("hexVertical"|"hexHorizontal")} type * @returns {Vector3} */ -export function hexOffsetToCube(offset: Vector2, type: ("hexVertical"|"hexHorizontal")) { +export function hexOffsetToCube( + offset: Vector2, + type: "hexVertical" | "hexHorizontal" +) { if (type === "hexVertical") { const x = offset.x - (offset.y + (offset.y & 1)) / 2; const z = offset.y; @@ -301,7 +302,12 @@ export function hexOffsetToCube(offset: Vector2, type: ("hexVertical"|"hexHorizo * @param {Vector2} b * @param {Size} cellSize */ -export function gridDistance(grid: Required, a: Vector2, b: Vector2, cellSize: Size) { +export function gridDistance( + grid: Required, + a: Vector2, + b: Vector2, + cellSize: Size +) { // Get grid coordinates const aCoord = getNearestCellCoordinates(grid, a.x, a.y, cellSize); const bCoord = getNearestCellCoordinates(grid, b.x, b.y, cellSize); @@ -315,7 +321,9 @@ export function gridDistance(grid: Required, a: Vector2, b: Vector2, cellS const min: any = Vector2.min(delta); return max - min + Math.floor(1.5 * min); } else if (grid.measurement.type === "euclidean") { - return Vector2.magnitude(Vector2.divide(Vector2.subtract(a, b), cellSize)); + return Vector2.magnitude( + 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); } @@ -331,24 +339,13 @@ export function gridDistance(grid: Required, a: Vector2, b: Vector2, cellS 2 ); } else if (grid.measurement.type === "euclidean") { - return Vector2.magnitude(Vector2.divide(Vector2.subtract(a, b), cellSize)); + return Vector2.magnitude( + Vector2.divide(Vector2.subtract(a, b), cellSize) + ); } } } -/** - * @typedef GridScale - * @property {number} multiplier The number multiplier of the scale - * @property {string} unit The unit of the scale - * @property {number} digits The precision of the scale - */ - -type GridScale = { - multiplier: number, - unit: string, - digits: number -} - /** * Parse a string representation of scale e.g. 5ft into a `GridScale` * @param {string} scale @@ -441,7 +438,10 @@ export function gridSizeVaild(x: number, y: number): boolean { * @param {number[]} candidates * @returns {Vector2 | null} */ -function gridSizeHeuristic(image: CanvasImageSource, candidates: number[]): Vector2 | null { +function gridSizeHeuristic( + image: CanvasImageSource, + candidates: number[] +): Vector2 | null { // TODO: check type for Image and CanvasSourceImage const width: any = image.width; const height: any = image.height; @@ -474,7 +474,10 @@ function gridSizeHeuristic(image: CanvasImageSource, candidates: number[]): Vect * @param {number[]} candidates * @returns {Vector2 | null} */ -async function gridSizeML(image: CanvasImageSource, candidates: number[]): Promise { +async function gridSizeML( + image: CanvasImageSource, + candidates: number[] +): Promise { // TODO: check this function because of context and CanvasImageSource -> JSDoc and Typescript do not match const width: any = image.width; const height: any = image.height; diff --git a/src/helpers/group.js b/src/helpers/group.ts similarity index 59% rename from src/helpers/group.js rename to src/helpers/group.ts index dd8571b..17103c3 100644 --- a/src/helpers/group.js +++ b/src/helpers/group.ts @@ -2,34 +2,14 @@ import { v4 as uuid } from "uuid"; import cloneDeep from "lodash.clonedeep"; import { keyBy } from "./shared"; - -/** - * @typedef GroupItem - * @property {string} id - * @property {"item"} type - */ - -/** - * @typedef GroupContainer - * @property {string} id - * @property {"group"} type - * @property {GroupItem[]} items - * @property {string} name - */ - -/** - * @typedef {GroupItem|GroupContainer} Group - */ +import { Group, GroupContainer, GroupItem } from "../types/Group"; /** * Transform an array of group ids to their groups - * @param {string[]} groupIds - * @param {Group[]} groups - * @return {Group[[]} */ -export function groupsFromIds(groupIds, groups) { +export function groupsFromIds(groupIds: string[], groups: Group[]): Group[] { const groupsByIds = keyBy(groups, "id"); - const filteredGroups = []; + const filteredGroups: Group[] = []; for (let groupId of groupIds) { if (groupId in groupsByIds) { filteredGroups.push(groupsByIds[groupId]); @@ -40,10 +20,8 @@ export function groupsFromIds(groupIds, groups) { /** * Get all items from a group including all sub groups - * @param {Group} group - * @return {GroupItem[]} */ -export function getGroupItems(group) { +export function getGroupItems(group: Group): GroupItem[] { if (group.type === "group") { let groups = []; for (let item of group.items) { @@ -57,14 +35,14 @@ export function getGroupItems(group) { /** * Transform an array of groups into their assosiated items - * @param {Group[]} groups - * @param {any[]} allItems - * @param {string} itemKey - * @returns {any[]} */ -export function itemsFromGroups(groups, allItems, itemKey = "id") { +export function itemsFromGroups( + groups: Group[], + allItems: Item[], + itemKey = "id" +): Item[] { const allItemsById = keyBy(allItems, itemKey); - const groupedItems = []; + const groupedItems: Item[] = []; for (let group of groups) { const groupItems = getGroupItems(group); @@ -76,47 +54,52 @@ export function itemsFromGroups(groups, allItems, itemKey = "id") { } /** - * Combine two groups - * @param {Group} a - * @param {Group} b - * @returns {GroupContainer} + * Combine a group and a group item */ -export function combineGroups(a, b) { - if (a.type === "item") { - return { - id: uuid(), - type: "group", - items: [a, b], - name: "", - }; - } - if (a.type === "group") { - return { - id: a.id, - type: "group", - items: [...a.items, b], - name: a.name, - }; +export function combineGroups(a: Group, b: Group): GroupContainer { + switch (a.type) { + case "item": + if (b.type !== "item") { + throw new Error("Unable to combine two GroupContainers"); + } + return { + id: uuid(), + type: "group", + items: [a, b], + name: "", + }; + case "group": + if (b.type !== "item") { + throw new Error("Unable to combine two GroupContainers"); + } + return { + id: a.id, + type: "group", + items: [...a.items, b], + name: a.name, + }; + default: + throw new Error("Group type not implemented"); } } /** * Immutably move group at indices `indices` into group at index `into` - * @param {Group[]} groups - * @param {number} into - * @param {number[]} indices - * @returns {Group[]} */ -export function moveGroupsInto(groups, into, indices) { +export function moveGroupsInto( + groups: Group[], + into: number, + indices: number[] +): Group[] { const newGroups = cloneDeep(groups); const intoGroup = newGroups[into]; - let fromGroups = []; + let fromGroups: Group[] = []; for (let i of indices) { fromGroups.push(newGroups[i]); } - let combined = intoGroup; + let combined: Group = intoGroup; for (let fromGroup of fromGroups) { combined = combineGroups(combined, fromGroup); } @@ -133,12 +116,12 @@ export function moveGroupsInto(groups, into, indices) { /** * Immutably move group at indices `indices` to index `to` - * @param {Group[]} groups - * @param {number} into - * @param {number[]} indices - * @returns {Group[]} */ -export function moveGroups(groups, to, indices) { +export function moveGroups( + groups: Group[], + to: number, + indices: number[] +): Group[] { const newGroups = cloneDeep(groups); let fromGroups = []; @@ -160,28 +143,31 @@ export function moveGroups(groups, to, indices) { /** * Move items from a sub group to the start of the base group - * @param {Group[]} groups - * @param {string} fromId The id of the group to move from - * @param {number[]} indices The indices of the items in the group + * @param fromId The id of the group to move from + * @param indices The indices of the items in the group */ -export function ungroup(groups, fromId, indices) { +export function ungroup(groups: Group[], fromId: string, indices: number[]) { const newGroups = cloneDeep(groups); - let fromIndex = newGroups.findIndex((group) => group.id === fromId); + const fromIndex = newGroups.findIndex((group) => group.id === fromId); + const from = newGroups[fromIndex]; + if (from.type !== "group") { + throw new Error(`Unable to ungroup ${fromId}, not a group`); + } - let items = []; + let items: GroupItem[] = []; for (let i of indices) { - items.push(newGroups[fromIndex].items[i]); + items.push(from.items[i]); } // Remove items from previous group for (let item of items) { - const i = newGroups[fromIndex].items.findIndex((el) => el.id === item.id); - newGroups[fromIndex].items.splice(i, 1); + const i = from.items.findIndex((el) => el.id === item.id); + from.items.splice(i, 1); } // If we have no more items in the group delete it - if (newGroups[fromIndex].items.length === 0) { + if (from.items.length === 0) { newGroups.splice(fromIndex, 1); } @@ -193,11 +179,8 @@ export function ungroup(groups, fromId, indices) { /** * Recursively find a group within a group array - * @param {Group[]} groups - * @param {string} groupId - * @returns {Group} */ -export function findGroup(groups, groupId) { +export function findGroup(groups: Group[], groupId: string): Group | undefined { for (let group of groups) { if (group.id === groupId) { return group; @@ -213,11 +196,9 @@ export function findGroup(groups, groupId) { /** * Transform and item array to a record of item ids to item names - * @param {any[]} items - * @param {string=} itemKey */ -export function getItemNames(items, itemKey = "id") { - let names = {}; +export function getItemNames(items: any[], itemKey: string = "id") { + let names: Record = {}; for (let item of items) { names[item[itemKey]] = item.name; } @@ -226,15 +207,20 @@ export function getItemNames(items, itemKey = "id") { /** * Immutably rename a group - * @param {Group[]} groups - * @param {string} groupId - * @param {string} newName */ -export function renameGroup(groups, groupId, newName) { +export function renameGroup( + groups: Group[], + groupId: string, + newName: string +): Group[] { let newGroups = cloneDeep(groups); const groupIndex = newGroups.findIndex((group) => group.id === groupId); + const group = groups[groupIndex]; + if (group.type !== "group") { + throw new Error(`Unable to rename group ${groupId}, not of type group`); + } if (groupIndex >= 0) { - newGroups[groupIndex].name = newName; + group.name = newName; } return newGroups; } @@ -244,7 +230,7 @@ export function renameGroup(groups, groupId, newName) { * @param {Group[]} groups * @param {string[]} itemIds */ -export function removeGroupsItems(groups, itemIds) { +export function removeGroupsItems(groups: Group[], itemIds: string[]): Group[] { let newGroups = cloneDeep(groups); for (let i = newGroups.length - 1; i >= 0; i--) { @@ -258,11 +244,11 @@ export function removeGroupsItems(groups, itemIds) { for (let j = items.length - 1; j >= 0; j--) { const item = items[j]; if (itemIds.includes(item.id)) { - newGroups[i].items.splice(j, 1); + group.items.splice(j, 1); } } // Remove group if no items are left - if (newGroups[i].items.length === 0) { + if (group.items.length === 0) { newGroups.splice(i, 1); } } diff --git a/src/helpers/image.ts b/src/helpers/image.ts index 6a666f2..605bd83 100644 --- a/src/helpers/image.ts +++ b/src/helpers/image.ts @@ -3,13 +3,15 @@ import imageOutline from "image-outline"; import blobToBuffer from "./blobToBuffer"; import Vector2 from "./Vector2"; +import { Outline } from "../types/Outline"; + const lightnessDetectionOffset = 0.1; /** * @param {HTMLImageElement} image * @returns {boolean} True is the image is light */ -export function getImageLightness(image: HTMLImageElement) { +export function getImageLightness(image: HTMLImageElement): boolean { const width = image.width; const height = image.height; let canvas = document.createElement("canvas"); @@ -17,8 +19,7 @@ export function getImageLightness(image: HTMLImageElement) { canvas.height = height; let context = canvas.getContext("2d"); if (!context) { - // TODO: handle if context is null - return; + return false; } context.drawImage(image, 0, 0); @@ -45,18 +46,12 @@ export function getImageLightness(image: HTMLImageElement) { return norm + lightnessDetectionOffset >= 0; } -/** - * @typedef CanvasImage - * @property {Blob|null} blob The blob of the resized image, `null` if the image was unable to be resized to that dimension - * @property {number} width - * @property {number} height - */ - type CanvasImage = { - blob: Blob | null, - width: number, - height: number -} + file: Uint8Array; + width: number; + height: number; + mime: string; +}; /** * @param {HTMLCanvasElement} canvas @@ -64,11 +59,25 @@ type CanvasImage = { * @param {number} quality * @returns {Promise} */ -export async function canvasToImage(canvas: HTMLCanvasElement, type: string, quality: number): Promise { +export async function canvasToImage( + canvas: HTMLCanvasElement, + type: string, + quality: number +): Promise { return new Promise((resolve) => { canvas.toBlob( - (blob) => { - resolve({ blob, width: canvas.width, height: canvas.height }); + async (blob) => { + if (blob) { + const file = await blobToBuffer(blob); + resolve({ + file, + width: canvas.width, + height: canvas.height, + mime: type, + }); + } else { + resolve(undefined); + } }, type, quality @@ -81,9 +90,14 @@ export async function canvasToImage(canvas: HTMLCanvasElement, type: string, qua * @param {number} size the size of the longest edge of the new image * @param {string} type the mime type of the image * @param {number} quality if image is a jpeg or webp this is the quality setting - * @returns {Promise} + * @returns {Promise} */ -export async function resizeImage(image: HTMLImageElement, size: number, type: string, quality: number): Promise { +export async function resizeImage( + image: HTMLImageElement, + size: number, + type: string, + quality: number +): Promise { const width = image.width; const height = image.height; const ratio = width / height; @@ -96,37 +110,27 @@ export async function resizeImage(image: HTMLImageElement, size: number, type: s canvas.height = size; } let context = canvas.getContext("2d"); - // TODO: Add error if context is empty if (context) { context.drawImage(image, 0, 0, canvas.width, canvas.height); + } else { + return undefined; } return await canvasToImage(canvas, type, quality); } -/** - * @typedef ImageAsset - * @property {number} width - * @property {number} height - * @property {Uint8Array} file - * @property {string} mime - */ - -export type ImageFile = { - file: Uint8Array | null, - width: number, - height: number, - type: "file", - id: string -} /** * Create a image file with resolution `size`x`size` with cover cropping * @param {HTMLImageElement} image the image to resize * @param {string} type the mime type of the image * @param {number} size the width and height of the thumbnail * @param {number} quality if image is a jpeg or webp this is the quality setting - * @returns {Promise} */ -export async function createThumbnail(image: HTMLImageElement, type: string, size = 300, quality = 0.5): Promise { +export async function createThumbnail( + image: HTMLImageElement, + type: string, + size = 300, + quality = 0.5 +): Promise { let canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; @@ -166,55 +170,20 @@ export async function createThumbnail(image: HTMLImageElement, type: string, siz } } - const thumbnailImage = await canvasToImage(canvas, type, quality); - - let thumbnailBuffer = null; - if (thumbnailImage.blob) { - thumbnailBuffer = await blobToBuffer(thumbnailImage.blob); - } - return { - file: thumbnailBuffer, - width: thumbnailImage.width, - height: thumbnailImage.height, - mime: type, - }; + return await canvasToImage(canvas, type, quality); } -/** - * @typedef CircleOutline - * @property {"circle"} type - * @property {number} x - Center X of the circle - * @property {number} y - Center Y of the circle - * @property {number} radius - */ - -/** - * @typedef RectOutline - * @property {"rect"} type - * @property {number} width - * @property {number} height - * @property {number} x - Leftmost X position of the rect - * @property {number} y - Topmost Y position of the rect - */ - -/** - * @typedef PathOutline - * @property {"path"} type - * @property {number[]} points - Alternating x, y coordinates zipped together - */ - -/** - * @typedef {CircleOutline|RectOutline|PathOutline} Outline - */ - /** * Get the outline of an image * @param {HTMLImageElement} image * @returns {Outline} */ -export function getImageOutline(image, maxPoints = 100) { +export function getImageOutline( + image: HTMLImageElement, + maxPoints: number = 100 +): Outline { // Basic rect outline for fail conditions - const defaultOutline = { + const defaultOutline: Outline = { type: "rect", x: 0, y: 0, diff --git a/src/helpers/map.js b/src/helpers/map.ts similarity index 74% rename from src/helpers/map.js rename to src/helpers/map.ts index c493698..17241a7 100644 --- a/src/helpers/map.js +++ b/src/helpers/map.ts @@ -10,14 +10,16 @@ import { } from "./grid"; import Vector2 from "./Vector2"; -const defaultMapProps = { - showGrid: false, - snapToGrid: true, - quality: "original", - group: "", +import { Map, FileMapResolutions, FileMap } from "../types/Map"; +import { Asset } from "../types/Asset"; + +type Resolution = { + size: number; + quality: number; + id: "low" | "medium" | "high" | "ultra"; }; -const mapResolutions = [ +const mapResolutions: Resolution[] = [ { size: 30, // Pixels per grid quality: 0.5, // JPEG compression quality @@ -33,30 +35,35 @@ const mapResolutions = [ * @param {any} map * @returns {undefined|string} */ -export function getMapPreviewAsset(map) { - const res = map.resolutions; - switch (map.quality) { - case "low": - return; - case "medium": - return res.low; - case "high": - return res.medium; - case "ultra": - return res.medium; - case "original": - if (res.medium) { - return res.medium; - } else if (res.low) { +export function getMapPreviewAsset(map: Map): string | undefined { + if (map.type === "file") { + const res = map.resolutions; + switch (map.quality) { + case "low": + return; + case "medium": return res.low; - } - return; - default: - return; + case "high": + return res.medium; + case "ultra": + return res.medium; + case "original": + if (res.medium) { + return res.medium; + } else if (res.low) { + return res.low; + } + return; + default: + return; + } } } -export async function createMapFromFile(file, userId) { +export async function createMapFromFile( + file: File, + userId: string +): Promise<{ map: Map; assets: Asset[] }> { let image = new Image(); const buffer = await blobToBuffer(file); @@ -107,10 +114,10 @@ export async function createMapFromFile(file, userId) { gridSize = { x: 22, y: 22 }; } - let assets = []; + let assets: Asset[] = []; // Create resolutions - const resolutions = {}; + const resolutions: FileMapResolutions = {}; for (let resolution of mapResolutions) { const resolutionPixelSize = Vector2.multiply(gridSize, resolution.size); if ( @@ -119,20 +126,16 @@ export async function createMapFromFile(file, userId) { ) { const resized = await resizeImage( image, - Vector2.max(resolutionPixelSize), + Vector2.max(resolutionPixelSize) as number, file.type, resolution.quality ); - if (resized.blob) { + if (resized) { const assetId = uuid(); resolutions[resolution.id] = assetId; - const resizedBuffer = await blobToBuffer(resized.blob); const asset = { - file: resizedBuffer, - width: resized.width, - height: resized.height, + ...resized, id: assetId, - mime: file.type, owner: userId, }; assets.push(asset); @@ -141,12 +144,11 @@ export async function createMapFromFile(file, userId) { } // Create thumbnail const thumbnailImage = await createThumbnail(image, file.type); - const thumbnail = { - ...thumbnailImage, - id: uuid(), - owner: userId, - }; - assets.push(thumbnail); + const thumbnailId = uuid(); + if (thumbnailImage) { + const thumbnail = { ...thumbnailImage, id: thumbnailId, owner: userId }; + assets.push(thumbnail); + } const fileAsset = { id: uuid(), @@ -158,11 +160,11 @@ export async function createMapFromFile(file, userId) { }; assets.push(fileAsset); - const map = { + const map: FileMap = { name, resolutions, file: fileAsset.id, - thumbnail: thumbnail.id, + thumbnail: thumbnailId, type: "file", grid: { size: gridSize, @@ -183,7 +185,9 @@ export async function createMapFromFile(file, userId) { created: Date.now(), lastModified: Date.now(), owner: userId, - ...defaultMapProps, + showGrid: false, + snapToGrid: true, + quality: "original", }; URL.revokeObjectURL(url); diff --git a/src/helpers/shared.ts b/src/helpers/shared.ts index b63505c..438d166 100644 --- a/src/helpers/shared.ts +++ b/src/helpers/shared.ts @@ -1,5 +1,5 @@ -export function omit(obj:object, keys: string[]) { - let tmp: { [key: string]: any } = {}; +export function omit(obj: Record, keys: string[]) { + let tmp: Record = {}; for (let [key, value] of Object.entries(obj)) { if (keys.includes(key)) { continue; @@ -9,18 +9,21 @@ export function omit(obj:object, keys: string[]) { return tmp; } -export function fromEntries(iterable: any) { +export function fromEntries(iterable: Iterable<[string | number, any]>) { if (Object.fromEntries) { return Object.fromEntries(iterable); } - return [...iterable].reduce((obj, [key, val]) => { - obj[key] = val; - return obj; - }, {}); + return [...iterable].reduce( + (obj: Record, [key, val]) => { + obj[key] = val; + return obj; + }, + {} + ); } // Check to see if all tracks are muted -export function isStreamStopped(stream: any) { +export function isStreamStopped(stream: MediaStream) { return stream.getTracks().reduce((a: any, b: any) => a && b, { mute: true }); } @@ -55,19 +58,22 @@ export function logImage(url: string, width: number, height: number): void { console.log("%c ", style); } -export function isEmpty(obj: any): boolean { +export function isEmpty(obj: Object): boolean { return Object.keys(obj).length === 0 && obj.constructor === Object; } -export function keyBy(array: any, key: any) { +export function keyBy(array: Type[], key: string): Record { return array.reduce( - (prev: any, current: any) => ({ ...prev, [key ? current[key] : current]: current }), + (prev: any, current: any) => ({ + ...prev, + [key ? current[key] : current]: current, + }), {} ); } -export function groupBy(array: any, key: string) { - return array.reduce((prev: any, current: any) => { +export function groupBy(array: Record[], key: string) { + return array.reduce((prev: Record, current) => { const k = current[key]; (prev[k] || (prev[k] = [])).push(current); return prev; @@ -76,7 +82,7 @@ export function groupBy(array: any, key: string) { export const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); -export function shuffle(array) { +export function shuffle(array: Type[]) { let temp = [...array]; var currentIndex = temp.length, randomIndex; diff --git a/src/helpers/token.js b/src/helpers/token.ts similarity index 81% rename from src/helpers/token.js rename to src/helpers/token.ts index d5eefc4..cf941b5 100644 --- a/src/helpers/token.js +++ b/src/helpers/token.ts @@ -1,12 +1,22 @@ import { v4 as uuid } from "uuid"; import Case from "case"; +import { Stage } from "konva/types/Stage"; import blobToBuffer from "./blobToBuffer"; import { createThumbnail, getImageOutline } from "./image"; import Vector2 from "./Vector2"; -export function createTokenState(token, position, userId) { - let tokenState = { +import { Token, FileToken } from "../types/Token"; +import { TokenState, BaseTokenState } from "../types/TokenState"; +import { Asset } from "../types/Asset"; +import { Outline } from "../types/Outline"; + +export function createTokenState( + token: Token, + position: Vector2, + userId: string +): TokenState { + let tokenState: BaseTokenState = { id: uuid(), tokenId: token.id, owner: userId, @@ -21,20 +31,29 @@ export function createTokenState(token, position, userId) { 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; + return { + ...tokenState, + type: "file", + file: token.file, + }; + } else { + return { + ...tokenState, + type: "default", + key: token.key, + }; } - return tokenState; } -export async function createTokenFromFile(file, userId) { +export async function createTokenFromFile( + file: File, + userId: string +): Promise<{ token: Token; assets: Asset[] }> { if (!file) { return Promise.reject(); } @@ -77,10 +96,13 @@ export async function createTokenFromFile(file, userId) { return new Promise((resolve, reject) => { image.onload = async function () { - let assets = []; + let assets: Asset[] = []; const thumbnailImage = await createThumbnail(image, file.type); - const thumbnail = { ...thumbnailImage, id: uuid(), owner: userId }; - assets.push(thumbnail); + const thumbnailId = uuid(); + if (thumbnailImage) { + const thumbnail = { ...thumbnailImage, id: thumbnailId, owner: userId }; + assets.push(thumbnail); + } const fileAsset = { id: uuid(), @@ -94,10 +116,10 @@ export async function createTokenFromFile(file, userId) { const outline = getImageOutline(image); - const token = { + const token: FileToken = { name, defaultSize, - thumbnail: thumbnail.id, + thumbnail: thumbnailId, file: fileAsset.id, id: uuid(), type: "file", @@ -107,7 +129,6 @@ export async function createTokenFromFile(file, userId) { defaultCategory: "character", defaultLabel: "", hideInSidebar: false, - group: "", width: image.width, height: image.height, outline, @@ -122,12 +143,15 @@ export async function createTokenFromFile(file, userId) { } export function clientPositionToMapPosition( - mapStage, - clientPosition, + mapStage: Stage, + clientPosition: Vector2, checkMapBounds = true -) { +): Vector2 | undefined { const mapImage = mapStage.findOne("#mapImage"); const map = document.querySelector(".map"); + if (!map) { + return; + } const mapRect = map.getBoundingClientRect(); // Check map bounds @@ -158,7 +182,11 @@ export function clientPositionToMapPosition( return normalizedPosition; } -export function getScaledOutline(tokenState, tokenWidth, tokenHeight) { +export function getScaledOutline( + tokenState: TokenState, + tokenWidth: number, + tokenHeight: number +): Outline { let outline = tokenState.outline; if (outline.type === "rect") { return { @@ -187,14 +215,23 @@ export function getScaledOutline(tokenState, tokenWidth, tokenHeight) { } export class Intersection { + outline; + position; + center; + rotation; + points: Vector2[] | undefined; /** - * * @param {Outline} outline * @param {Vector2} position - Top left position of the token * @param {Vector2} center - Center position of the token * @param {number} rotation - Rotation of the token in degrees */ - constructor(outline, position, center, rotation) { + constructor( + outline: Outline, + position: Vector2, + center: Vector2, + rotation: number + ) { this.outline = outline; this.position = position; this.center = center; @@ -253,8 +290,11 @@ export class Intersection { * @param {Vector2} point * @returns {boolean} */ - intersects(point) { - if (this.outline.type === "rect" || this.outline.type === "path") { + intersects(point: Vector2) { + if ( + this.points && + (this.outline.type === "rect" || this.outline.type === "path") + ) { return Vector2.pointInPolygon(point, this.points); } else if (this.outline.type === "circle") { return Vector2.distance(this.center, point) < this.outline.radius; diff --git a/src/hooks/useDebounce.tsx b/src/hooks/useDebounce.tsx index 1ab9dea..e5a3242 100644 --- a/src/hooks/useDebounce.tsx +++ b/src/hooks/useDebounce.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; -function useDebounce(value: any, delay: number): any { - const [debouncedValue, setDebouncedValue] = useState(); +function useDebounce(value: Type, delay: number): Type { + const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timeout = setTimeout(() => { diff --git a/src/modals/AuthModal.tsx b/src/modals/AuthModal.tsx index 589d549..ae32f5a 100644 --- a/src/modals/AuthModal.tsx +++ b/src/modals/AuthModal.tsx @@ -5,7 +5,12 @@ import { useAuth } from "../contexts/AuthContext"; import Modal from "../components/Modal"; -function AuthModal({ isOpen, onSubmit }: { isOpen: boolean, onSubmit: (newPassword: string) => void}) { +type AuthModalProps = { + isOpen: boolean; + onSubmit: (newPassword: string) => void; +}; + +function AuthModal({ isOpen, onSubmit }: AuthModalProps) { const { password, setPassword } = useAuth(); const [tmpPassword, setTempPassword] = useState(password); @@ -19,7 +24,7 @@ function AuthModal({ isOpen, onSubmit }: { isOpen: boolean, onSubmit: (newPassw onSubmit(tmpPassword); } - const inputRef = useRef(); + const inputRef = useRef(null); function focusInput(): void { inputRef.current && inputRef.current?.focus(); } diff --git a/src/modals/ChangeNicknameModal.tsx b/src/modals/ChangeNicknameModal.tsx index 2e4353e..b196dec 100644 --- a/src/modals/ChangeNicknameModal.tsx +++ b/src/modals/ChangeNicknameModal.tsx @@ -3,22 +3,24 @@ import { Box, Input, Button, Label, Flex } from "theme-ui"; import Modal from "../components/Modal"; +type ChangeNicknameModalProps = { + isOpen: boolean; + onRequestClose: () => void; + onChangeSubmit: any; + nickname: string; + onChange: any; +}; + function ChangeNicknameModal({ isOpen, onRequestClose, onChangeSubmit, nickname, onChange, -}: { - isOpen: boolean, - onRequestClose: () => void, - onChangeSubmit: any, - nickname: string, - onChange: any, -}) { - const inputRef = useRef(null); +}: ChangeNicknameModalProps) { + const inputRef = useRef(null); function focusInput() { - inputRef.current && inputRef.current?.focus(); + inputRef.current?.focus(); } return ( diff --git a/src/modals/ConfirmModal.tsx b/src/modals/ConfirmModal.tsx index 16e7598..acb6d7f 100644 --- a/src/modals/ConfirmModal.tsx +++ b/src/modals/ConfirmModal.tsx @@ -3,13 +3,13 @@ import { Box, Label, Flex, Button, Text } from "theme-ui"; import Modal from "../components/Modal"; type ConfirmModalProps = { - isOpen: boolean, - onRequestClose: () => void, - onConfirm: () => void, - confirmText: string, - label: string, - description: string, -} + isOpen: boolean; + onRequestClose: () => void; + onConfirm: () => void; + confirmText: string; + label: string; + description: string; +}; function ConfirmModal({ isOpen, @@ -18,7 +18,7 @@ function ConfirmModal({ confirmText, label, description, -}: ConfirmModalProps ) { +}: ConfirmModalProps) { return ( void, - onChange: any, - groups: string[], - defaultGroup: string | undefined | false, -} - -function EditGroupModal({ - isOpen, - onRequestClose, - onChange, - groups, - defaultGroup, -}: EditGroupProps) { - const [value, setValue] = useState<{ value: string; label: string; } | undefined>(); - const [options, setOptions] = useState<{ value: string; label: string; }[]>([]); - - useEffect(() => { - if (defaultGroup) { - setValue({ value: defaultGroup, label: defaultGroup }); - } else { - setValue(undefined); - } - }, [defaultGroup]); - - useEffect(() => { - setOptions(groups.map((group) => ({ value: group, label: group }))); - }, [groups]); - - function handleCreate(group: string) { - const newOption = { value: group, label: group }; - setValue(newOption); - setOptions((prev) => [...prev, newOption]); - } - - function handleChange() { - onChange(value ? value.value : ""); - } - - return ( - - - -