More typescript

This commit is contained in:
Mitchell McCaffrey 2021-07-09 16:22:35 +10:00
parent 45a4443dd1
commit ecfab87aa0
66 changed files with 1350 additions and 1233 deletions

View File

@ -47,7 +47,7 @@
"react-markdown": "4", "react-markdown": "4",
"react-media": "^2.0.0-rc.1", "react-media": "^2.0.0-rc.1",
"react-modal": "^3.12.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-dom": "^5.1.2",
"react-router-hash-link": "^2.2.2", "react-router-hash-link": "^2.2.2",
"react-scripts": "^4.0.3", "react-scripts": "^4.0.3",
@ -63,7 +63,7 @@
"socket.io-client": "^4.1.2", "socket.io-client": "^4.1.2",
"socket.io-msgpack-parser": "^3.0.1", "socket.io-msgpack-parser": "^3.0.1",
"source-map-explorer": "^2.5.2", "source-map-explorer": "^2.5.2",
"theme-ui": "^0.8.4", "theme-ui": "^0.10.0",
"use-image": "^1.0.7", "use-image": "^1.0.7",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"webrtc-adapter": "^7.7.1" "webrtc-adapter": "^7.7.1"
@ -107,6 +107,7 @@
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"@types/shortid": "^0.0.29", "@types/shortid": "^0.0.29",
"@types/simple-peer": "^9.6.3", "@types/simple-peer": "^9.6.3",
"@types/uuid": "^8.3.1",
"typescript": "^4.2.4", "typescript": "^4.2.4",
"worker-loader": "^3.0.8" "worker-loader": "^3.0.8"
} }

View File

@ -1,9 +1,8 @@
import React from "react"; import { Box, Input, InputProps } from "theme-ui";
import { Box, Input } from "theme-ui";
import SearchIcon from "../icons/SearchIcon"; import SearchIcon from "../icons/SearchIcon";
function Search(props) { function Search(props: InputProps) {
return ( return (
<Box sx={{ position: "relative", flexGrow: 1 }}> <Box sx={{ position: "relative", flexGrow: 1 }}>
<Input <Input

View File

@ -3,7 +3,21 @@ import { IconButton } from "theme-ui";
import Count from "./DiceButtonCount"; import Count from "./DiceButtonCount";
function DiceButton({ title, children, count, onClick, disabled }) { type DiceButtonProps = {
title: string;
children: React.ReactNode;
count?: number;
onClick: React.MouseEventHandler<HTMLButtonElement>;
disabled: boolean;
};
function DiceButton({
title,
children,
count,
onClick,
disabled,
}: DiceButtonProps) {
return ( return (
<IconButton <IconButton
title={title} title={title}

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Box, Text } from "theme-ui"; import { Box, Text } from "theme-ui";
function DiceButtonCount({ children }) { function DiceButtonCount({ children }: { children: React.ReactNode }) {
return ( return (
<Box <Box
sx={{ sx={{

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Flex, IconButton, Box } from "theme-ui"; import { Flex, IconButton, Box } from "theme-ui";
import SimpleBar from "simplebar-react"; import SimpleBar from "simplebar-react";
@ -21,6 +21,20 @@ import Divider from "../Divider";
import { dice } from "../../dice"; import { dice } from "../../dice";
import useSetting from "../../hooks/useSetting"; import useSetting from "../../hooks/useSetting";
import { DefaultDice, DiceRoll, DiceType } from "../../types/Dice";
import Dice from "../../dice/Dice";
type DiceButtonsProps = {
diceRolls: DiceRoll[];
onDiceAdd: (style: typeof Dice, type: DiceType) => void;
onDiceLoad: (dice: DefaultDice) => void;
diceTraySize: "single" | "double";
onDiceTraySizeChange: (newSize: "single" | "double") => void;
shareDice: boolean;
onShareDiceChange: (value: boolean) => void;
loading: boolean;
};
function DiceButtons({ function DiceButtons({
diceRolls, diceRolls,
onDiceAdd, onDiceAdd,
@ -30,29 +44,32 @@ function DiceButtons({
shareDice, shareDice,
onShareDiceChange, onShareDiceChange,
loading, loading,
}) { }: DiceButtonsProps) {
const [currentDiceStyle, setCurrentDiceStyle] = useSetting("dice.style"); const [currentDiceStyle, setCurrentDiceStyle] = useSetting("dice.style");
const [currentDice, setCurrentDice] = useState( const [currentDice, setCurrentDice] = useState<DefaultDice>(
dice.find((d) => d.key === currentDiceStyle) dice.find((d) => d.key === currentDiceStyle) || dice[0]
); );
useEffect(() => { useEffect(() => {
const initialDice = dice.find((d) => d.key === currentDiceStyle); const initialDice = dice.find((d) => d.key === currentDiceStyle);
onDiceLoad(initialDice); if (initialDice) {
setCurrentDice(initialDice); onDiceLoad(initialDice);
setCurrentDice(initialDice);
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const diceCounts = {}; const diceCounts: Partial<Record<DiceType, number>> = {};
for (let dice of diceRolls) { for (let dice of diceRolls) {
if (dice.type in diceCounts) { if (dice.type in diceCounts) {
diceCounts[dice.type] += 1; // TODO: Check type
diceCounts[dice.type]! += 1;
} else { } else {
diceCounts[dice.type] = 1; diceCounts[dice.type] = 1;
} }
} }
async function handleDiceChange(dice) { async function handleDiceChange(dice: DefaultDice) {
await onDiceLoad(dice); await onDiceLoad(dice);
setCurrentDice(dice); setCurrentDice(dice);
setCurrentDiceStyle(dice.key); setCurrentDiceStyle(dice.key);

View File

@ -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 { Engine } from "@babylonjs/core/Engines/engine";
import { Scene } from "@babylonjs/core/scene"; import { Scene } from "@babylonjs/core/scene";
import { Vector3, Color4, Matrix } from "@babylonjs/core/Maths/math"; import { Vector3, Color4, Matrix } from "@babylonjs/core/Maths/math";
import { AmmoJSPlugin } from "@babylonjs/core/Physics/Plugins/ammoJSPlugin"; import { AmmoJSPlugin } from "@babylonjs/core/Physics/Plugins/ammoJSPlugin";
import { TargetCamera } from "@babylonjs/core/Cameras/targetCamera"; import { TargetCamera } from "@babylonjs/core/Cameras/targetCamera";
//@ts-ignore
import * as AMMO from "ammo.js"; import * as AMMO from "ammo.js";
import "@babylonjs/core/Physics/physicsEngineComponent"; import "@babylonjs/core/Physics/physicsEngineComponent";
@ -19,20 +20,44 @@ import ReactResizeDetector from "react-resize-detector";
import usePreventTouch from "../../hooks/usePreventTouch"; import usePreventTouch from "../../hooks/usePreventTouch";
import ErrorBanner from "../banner/ErrorBanner"; import ErrorBanner from "../banner/ErrorBanner";
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
const diceThrowSpeed = 2; const diceThrowSpeed = 2;
function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) { type DiceInteractionProps = {
const [error, setError] = useState(); onSceneMount?: ({
scene,
engine,
canvas,
}: {
scene: Scene;
engine: Engine;
canvas: HTMLCanvasElement | WebGLRenderingContext;
}) => void;
onPointerDown: () => void;
onPointerUp: () => any;
};
const sceneRef = useRef(); function DiceInteraction({
const engineRef = useRef(); onSceneMount,
const canvasRef = useRef(); onPointerDown,
const containerRef = useRef(); onPointerUp,
}: DiceInteractionProps) {
const [error, setError] = useState<Error | undefined>();
const sceneRef = useRef<Scene>();
const engineRef = useRef<Engine>();
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
try { try {
const canvas = canvasRef.current;
const engine = new Engine(canvas, true, { const engine = new Engine(canvas, true, {
preserveDrawingBuffer: true, preserveDrawingBuffer: true,
stencil: true, stencil: true,
@ -67,13 +92,14 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
const delta = newPosition.subtract(currentPosition); const delta = newPosition.subtract(currentPosition);
selectedMesh.setAbsolutePosition(newPosition); selectedMesh.setAbsolutePosition(newPosition);
const velocity = delta.scale(1000 / scene.deltaTime); const velocity = delta.scale(1000 / scene.deltaTime);
selectedMeshVelocityWindowRef.current = selectedMeshVelocityWindowRef.current.slice( selectedMeshVelocityWindowRef.current =
Math.max( selectedMeshVelocityWindowRef.current.slice(
selectedMeshVelocityWindowRef.current.length - Math.max(
selectedMeshVelocityWindowSize, selectedMeshVelocityWindowRef.current.length -
0 selectedMeshVelocityWindowSize,
) 0
); )
);
selectedMeshVelocityWindowRef.current.push(velocity); selectedMeshVelocityWindowRef.current.push(velocity);
} }
}); });
@ -82,21 +108,27 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
} }
}, [onSceneMount]); }, [onSceneMount]);
const selectedMeshRef = useRef(); const selectedMeshRef = useRef<AbstractMesh | null>(null);
const selectedMeshVelocityWindowRef = useRef([]); const selectedMeshVelocityWindowRef = useRef<Vector3[]>([]);
const selectedMeshVelocityWindowSize = 4; const selectedMeshVelocityWindowSize = 4;
const selectedMeshMassRef = useRef(); const selectedMeshMassRef = useRef<number>(0);
function handlePointerDown() { function handlePointerDown() {
const scene = sceneRef.current; const scene = sceneRef.current;
if (scene) { if (scene) {
const pickInfo = scene.pick(scene.pointerX, scene.pointerY); const pickInfo = scene.pick(scene.pointerX, scene.pointerY);
if (pickInfo.hit && pickInfo.pickedMesh.name !== "dice_tray") { if (
pickInfo.pickedMesh.physicsImpostor.setLinearVelocity(Vector3.Zero()); pickInfo &&
pickInfo.pickedMesh.physicsImpostor.setAngularVelocity(Vector3.Zero()); 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 // Save the meshes mass and set it to 0 so we can pick it up
selectedMeshMassRef.current = pickInfo.pickedMesh.physicsImpostor.mass; selectedMeshMassRef.current =
pickInfo.pickedMesh.physicsImpostor.setMass(0); pickInfo.pickedMesh.physicsImpostor?.mass || 0;
pickInfo.pickedMesh.physicsImpostor?.setMass(0);
selectedMeshRef.current = pickInfo.pickedMesh; selectedMeshRef.current = pickInfo.pickedMesh;
} }
@ -119,27 +151,29 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
} }
// Re-apply the meshes mass // Re-apply the meshes mass
selectedMesh.physicsImpostor.setMass(selectedMeshMassRef.current); selectedMesh.physicsImpostor?.setMass(selectedMeshMassRef.current);
selectedMesh.physicsImpostor.forceUpdate(); selectedMesh.physicsImpostor?.forceUpdate();
selectedMesh.physicsImpostor.applyImpulse( selectedMesh.physicsImpostor?.applyImpulse(
velocity.scale(diceThrowSpeed * selectedMesh.physicsImpostor.mass), velocity.scale(diceThrowSpeed * selectedMesh.physicsImpostor.mass),
selectedMesh.physicsImpostor.getObjectCenter() selectedMesh.physicsImpostor.getObjectCenter()
); );
} }
selectedMeshRef.current = null; selectedMeshRef.current = null;
selectedMeshVelocityWindowRef.current = []; selectedMeshVelocityWindowRef.current = [];
selectedMeshMassRef.current = null; selectedMeshMassRef.current = 0;
onPointerUp(); onPointerUp();
} }
function handleResize(width, height) { function handleResize(width?: number, height?: number) {
const engine = engineRef.current; if (width && height) {
if (engine) { const engine = engineRef.current;
engine.resize(); if (engine && canvasRef.current) {
canvasRef.current.width = width; engine.resize();
canvasRef.current.height = height; canvasRef.current.width = width;
canvasRef.current.height = height;
}
} }
} }
@ -165,7 +199,7 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
style={{ outline: "none" }} style={{ outline: "none" }}
/> />
</ReactResizeDetector> </ReactResizeDetector>
<ErrorBanner error={error} onRequestClose={() => setError()} /> <ErrorBanner error={error} onRequestClose={() => setError(undefined)} />
</div> </div>
); );
} }

View File

@ -5,17 +5,28 @@ import ClearDiceIcon from "../../icons/ClearDiceIcon";
import RerollDiceIcon from "../../icons/RerollDiceIcon"; import RerollDiceIcon from "../../icons/RerollDiceIcon";
import { getDiceRollTotal } from "../../helpers/dice"; import { getDiceRollTotal } from "../../helpers/dice";
import { DiceRoll } from "../../types/Dice";
const maxDiceRollsShown = 6; 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); const [isExpanded, setIsExpanded] = useState(false);
if (diceRolls.length === 0) { if (diceRolls.length === 0) {
return null; return null;
} }
let rolls = []; let rolls: React.ReactChild[] = [];
if (diceRolls.length > 1) { if (diceRolls.length > 1) {
rolls = diceRolls rolls = diceRolls
.filter((dice) => dice.roll !== "unknown") .filter((dice) => dice.roll !== "unknown")

View File

@ -1,9 +1,17 @@
import React from "react";
import { Image } from "theme-ui"; import { Image } from "theme-ui";
import Tile from "../tile/Tile"; 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 ( return (
<div style={{ cursor: "pointer" }}> <div style={{ cursor: "pointer" }}>
<Tile <Tile

View File

@ -1,12 +1,24 @@
import React from "react";
import { Grid } from "theme-ui"; import { Grid } from "theme-ui";
import SimpleBar from "simplebar-react"; import SimpleBar from "simplebar-react";
import DiceTile from "./DiceTile"; import DiceTile from "./DiceTile";
import useResponsiveLayout from "../../hooks/useResponsiveLayout"; import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { DefaultDice } from "../../types/Dice";
function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) { type DiceTileProps = {
dice: DefaultDice[];
onDiceSelect: (dice: DefaultDice) => void;
selectedDice: DefaultDice;
onDone: (dice: DefaultDice) => void;
};
function DiceTiles({
dice,
onDiceSelect,
selectedDice,
onDone,
}: DiceTileProps) {
const layout = useResponsiveLayout(); const layout = useResponsiveLayout();
return ( return (
@ -29,7 +41,6 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
isSelected={selectedDice && dice.key === selectedDice.key} isSelected={selectedDice && dice.key === selectedDice.key}
onDiceSelect={onDiceSelect} onDiceSelect={onDiceSelect}
onDone={onDone} onDone={onDone}
size={layout.tileSize}
/> />
))} ))}
</Grid> </Grid>

View File

@ -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 { Vector3 } from "@babylonjs/core/Maths/math";
import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight"; import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight";
import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator"; import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator";
import { CubeTexture } from "@babylonjs/core/Materials/Textures/cubeTexture"; import { CubeTexture } from "@babylonjs/core/Materials/Textures/cubeTexture";
import { Box } from "theme-ui"; import { Box } from "theme-ui";
// @ts-ignore
import environment from "../../dice/environment.dds"; import environment from "../../dice/environment.dds";
import DiceInteraction from "./DiceInteraction"; import DiceInteraction from "./DiceInteraction";
@ -19,6 +20,16 @@ import { useDiceLoading } from "../../contexts/DiceLoadingContext";
import { getDiceRoll } from "../../helpers/dice"; import { getDiceRoll } from "../../helpers/dice";
import useSetting from "../../hooks/useSetting"; 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({ function DiceTrayOverlay({
isOpen, isOpen,
@ -26,17 +37,18 @@ function DiceTrayOverlay({
onShareDiceChange, onShareDiceChange,
diceRolls, diceRolls,
onDiceRollsChange, onDiceRollsChange,
}) { }: DiceTrayOverlayProps) {
const sceneRef = useRef(); const sceneRef = useRef<Scene>();
const shadowGeneratorRef = useRef(); const shadowGeneratorRef = useRef<ShadowGenerator>();
const diceRefs = useRef([]); const diceRefs = useRef<DiceMesh[]>([]);
const sceneVisibleRef = useRef(false); const sceneVisibleRef = useRef(false);
const sceneInteractionRef = useRef(false); const sceneInteractionRef = useRef(false);
// Add to the counter to ingore sleep values // Add to the counter to ingore sleep values
const sceneKeepAwakeRef = useRef(0); const sceneKeepAwakeRef = useRef(0);
const diceTrayRef = useRef(); const diceTrayRef = useRef<DiceTray>();
const [diceTraySize, setDiceTraySize] = useState("single"); const [diceTraySize, setDiceTraySize] =
useState<"single" | "double">("single");
const { assetLoadStart, assetLoadFinish, isLoading } = useDiceLoading(); const { assetLoadStart, assetLoadFinish, isLoading } = useDiceLoading();
const [fullScreen] = useSetting("map.fullScreen"); const [fullScreen] = useSetting("map.fullScreen");
@ -50,7 +62,7 @@ function DiceTrayOverlay({
} }
// Forces rendering for 1 second // Forces rendering for 1 second
function forceRender() { function forceRender(): () => void {
// Force rerender // Force rerender
sceneKeepAwakeRef.current++; sceneKeepAwakeRef.current++;
let triggered = false; let triggered = false;
@ -97,7 +109,7 @@ function DiceTrayOverlay({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
async function initializeScene(scene) { async function initializeScene(scene: Scene) {
handleAssetLoadStart(); handleAssetLoadStart();
let light = new DirectionalLight( let light = new DirectionalLight(
"DirectionalLight", "DirectionalLight",
@ -124,16 +136,14 @@ function DiceTrayOverlay({
handleAssetLoadFinish(); handleAssetLoadFinish();
} }
function update(scene) { function update(scene: Scene) {
function getDiceSpeed(dice) { function getDiceSpeed(dice: DiceMesh) {
const diceSpeed = dice.instance.physicsImpostor const diceSpeed =
.getLinearVelocity() dice.instance.physicsImpostor?.getLinearVelocity()?.length() || 0;
.length();
// If the dice is a d100 check the d10 as well // If the dice is a d100 check the d10 as well
if (dice.type === "d100") { if (dice.d10Instance) {
const d10Speed = dice.d10Instance.physicsImpostor const d10Speed =
.getLinearVelocity() dice.d10Instance.physicsImpostor?.getLinearVelocity()?.length() || 0;
.length();
return Math.max(diceSpeed, d10Speed); return Math.max(diceSpeed, d10Speed);
} else { } else {
return diceSpeed; return diceSpeed;
@ -157,14 +167,14 @@ function DiceTrayOverlay({
const dice = die[i]; const dice = die[i];
const speed = getDiceSpeed(dice); const speed = getDiceSpeed(dice);
// If the speed has been below 0.01 for 1s set dice to sleep // If the speed has been below 0.01 for 1s set dice to sleep
if (speed < 0.01 && !dice.sleepTimout) { if (speed < 0.01 && !dice.sleepTimeout) {
dice.sleepTimout = setTimeout(() => { dice.sleepTimeout = setTimeout(() => {
dice.asleep = true; dice.asleep = true;
}, 1000); }, 1000);
} else if (speed > 0.5 && (dice.asleep || dice.sleepTimout)) { } else if (speed > 0.5 && (dice.asleep || dice.sleepTimeout)) {
dice.asleep = false; dice.asleep = false;
clearTimeout(dice.sleepTimout); dice.sleepTimeout && clearTimeout(dice.sleepTimeout);
dice.sleepTimout = null; 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 scene = sceneRef.current;
const shadowGenerator = shadowGeneratorRef.current; const shadowGenerator = shadowGeneratorRef.current;
if (scene && shadowGenerator) { if (scene && shadowGenerator) {
const instance = style.createInstance(type, scene); const instance = style.createInstance(type, scene);
shadowGenerator.addShadowCaster(instance); shadowGenerator.addShadowCaster(instance);
Dice.roll(instance); style.roll(instance);
let dice = { type, instance, asleep: false }; let dice: DiceMesh = { type, instance, asleep: false };
// If we have a d100 add a d10 as well // If we have a d100 add a d10 as well
if (type === "d100") { if (type === "d100") {
const d10Instance = style.createInstance("d10", scene); const d10Instance = style.createInstance("d10", scene);
@ -196,7 +206,7 @@ function DiceTrayOverlay({
const die = diceRefs.current; const die = diceRefs.current;
for (let dice of die) { for (let dice of die) {
dice.instance.dispose(); dice.instance.dispose();
if (dice.type === "d100") { if (dice.d10Instance) {
dice.d10Instance.dispose(); dice.d10Instance.dispose();
} }
} }
@ -208,14 +218,14 @@ function DiceTrayOverlay({
const die = diceRefs.current; const die = diceRefs.current;
for (let dice of die) { for (let dice of die) {
Dice.roll(dice.instance); Dice.roll(dice.instance);
if (dice.type === "d100") { if (dice.d10Instance) {
Dice.roll(dice.d10Instance); Dice.roll(dice.d10Instance);
} }
dice.asleep = false; dice.asleep = false;
} }
} }
async function handleDiceLoad(dice) { async function handleDiceLoad(dice: DefaultDice) {
handleAssetLoadStart(); handleAssetLoadStart();
const scene = sceneRef.current; const scene = sceneRef.current;
if (scene) { if (scene) {
@ -230,10 +240,13 @@ function DiceTrayOverlay({
}); });
useEffect(() => { useEffect(() => {
let renderTimeout; let renderTimeout: NodeJS.Timeout;
let renderCleanup; let renderCleanup: () => void;
function handleResize() { function handleResize() {
const map = document.querySelector(".map"); const map = document.querySelector(".map");
if (!map) {
return;
}
const mapRect = map.getBoundingClientRect(); const mapRect = map.getBoundingClientRect();
const availableWidth = mapRect.width - 108; // Subtract padding const availableWidth = mapRect.width - 108; // Subtract padding
@ -283,7 +296,7 @@ function DiceTrayOverlay({
return; return;
} }
let newRolls = []; let newRolls: DiceRoll[] = [];
for (let i = 0; i < die.length; i++) { for (let i = 0; i < die.length; i++) {
const dice = die[i]; const dice = die[i];
let roll = getDiceRoll(dice); let roll = getDiceRoll(dice);

View File

@ -1,10 +1,22 @@
import React, { useState } from "react"; import { useState } from "react";
import { IconButton } from "theme-ui"; import { IconButton } from "theme-ui";
import SelectDiceIcon from "../../icons/SelectDiceIcon"; import SelectDiceIcon from "../../icons/SelectDiceIcon";
import SelectDiceModal from "../../modals/SelectDiceModal"; 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); const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() { function openModal() {
@ -14,7 +26,7 @@ function SelectDiceButton({ onDiceChange, currentDice, disabled }) {
setIsModalOpen(false); setIsModalOpen(false);
} }
function handleDone(dice) { function handleDone(dice: DefaultDice) {
onDiceChange(dice); onDiceChange(dice);
closeModal(); closeModal();
} }
@ -39,4 +51,8 @@ function SelectDiceButton({ onDiceChange, currentDice, disabled }) {
); );
} }
SelectDiceButton.defaultProps = {
disabled: false,
};
export default SelectDiceButton; export default SelectDiceButton;

View File

@ -1,7 +1,14 @@
import React from "react"; import React from "react";
import { useDraggable } from "@dnd-kit/core"; 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({ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id, id,
data, data,

View File

@ -1,7 +1,12 @@
import React from "react"; import React from "react";
import { useDroppable } from "@dnd-kit/core"; import { useDroppable } from "@dnd-kit/core";
function Droppable({ id, children, disabled, ...props }) { type DroppableProps = React.HTMLAttributes<HTMLDivElement> & {
id: string;
disabled: boolean;
};
function Droppable({ id, children, disabled, ...props }: DroppableProps) {
const { setNodeRef } = useDroppable({ id, disabled }); const { setNodeRef } = useDroppable({ id, disabled });
return ( return (

View File

@ -26,75 +26,7 @@ import {
EditShapeAction, EditShapeAction,
RemoveShapeAction, RemoveShapeAction,
} from "../../actions"; } from "../../actions";
import { Fog, Path, Shape } from "../../helpers/drawing";
import Session from "../../network/Session"; import Session from "../../network/Session";
import { Grid } from "../../helpers/grid";
import { ImageFile } from "../../helpers/image";
export type Resolutions = Record<string, ImageFile>;
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<string, TokenState>;
drawShapes: Record<string, Path | Shape>;
fogShapes: Record<string, Fog>;
editFlags: ["drawing", "tokens", "notes", "fog"];
notes: Record<string, Note>;
mapId: string;
};
function Map({ function Map({
map, map,

View File

@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { Box } from "theme-ui"; import { Box, ThemeUIStyleObject } from "theme-ui";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
function LazyTile({ children }) { function LazyTile({ children }: { children: React.ReactNode }) {
const [ref, inView] = useInView({ triggerOnce: false }); const [ref, inView] = useInView({ triggerOnce: false });
const sx = inView const sx: ThemeUIStyleObject = inView
? {} ? {}
: { width: "100%", height: "0", paddingTop: "100%", position: "relative" }; : { width: "100%", height: "0", paddingTop: "100%", position: "relative" };

View File

@ -6,6 +6,16 @@ import { animated, useSpring } from "react-spring";
import { GROUP_ID_PREFIX } from "../../contexts/TileDragContext"; 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({ function SortableTile({
id, id,
disableGrouping, disableGrouping,
@ -14,7 +24,7 @@ function SortableTile({
children, children,
isDragging, isDragging,
cursor, cursor,
}) { }: SortableTileProps) {
const { const {
attributes, attributes,
listeners, listeners,
@ -35,7 +45,7 @@ function SortableTile({
}; };
// Sort div left aligned // Sort div left aligned
const sortDropStyle = { const sortDropStyle: React.CSSProperties = {
position: "absolute", position: "absolute",
left: "-5px", left: "-5px",
top: 0, top: 0,
@ -46,7 +56,7 @@ function SortableTile({
}; };
// Group div center aligned // Group div center aligned
const groupDropStyle = { const groupDropStyle: React.CSSProperties = {
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
@ -55,7 +65,7 @@ function SortableTile({
borderWidth: "4px", borderWidth: "4px",
borderRadius: "4px", borderRadius: "4px",
borderStyle: borderStyle:
over?.id === `${GROUP_ID_PREFIX}${id}` && active.id !== id over?.id === `${GROUP_ID_PREFIX}${id}` && active?.id !== id
? "solid" ? "solid"
: "none", : "none",
}; };

View File

@ -15,8 +15,14 @@ import {
GROUP_SORTABLE_ID, GROUP_SORTABLE_ID,
} from "../../contexts/TileDragContext"; } from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext"; 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 dragId = useTileDragId();
const dragCursor = useTileDragCursor(); const dragCursor = useTileDragCursor();
const overGroupId = useTileOverGroupId(); const overGroupId = useTileOverGroupId();
@ -38,14 +44,14 @@ function SortableTiles({ renderTile, subgroup }) {
const sortableId = subgroup ? GROUP_SORTABLE_ID : BASE_SORTABLE_ID; const sortableId = subgroup ? GROUP_SORTABLE_ID : BASE_SORTABLE_ID;
// Only populate selected groups if needed // Only populate selected groups if needed
let selectedGroupIds = []; let selectedGroupIds: string[] = [];
if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) { if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) {
selectedGroupIds = allSelectedIds; selectedGroupIds = allSelectedIds;
} }
const disableSorting = (openGroupId && !subgroup) || filter; const disableSorting = !!((openGroupId && !subgroup) || filter);
const disableGrouping = subgroup || disableSorting || 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 (overGroupId === group.id && dragId && group.id !== dragId) {
// If dragging over a group render a preview of that group // If dragging over a group render a preview of that group
const previewGroup = moveGroupsInto( const previewGroup = moveGroupsInto(
@ -61,7 +67,7 @@ function SortableTiles({ renderTile, subgroup }) {
function renderTiles() { function renderTiles() {
const groupsByIds = keyBy(activeGroups, "id"); const groupsByIds = keyBy(activeGroups, "id");
const selectedGroupIdsSet = new Set(selectedGroupIds); const selectedGroupIdsSet = new Set(selectedGroupIds);
let selectedGroups = []; let selectedGroups: Group[] = [];
let hasSelectedContainerGroup = false; let hasSelectedContainerGroup = false;
for (let groupId of selectedGroupIds) { for (let groupId of selectedGroupIds) {
const group = groupsByIds[groupId]; const group = groupsByIds[groupId];
@ -72,8 +78,8 @@ function SortableTiles({ renderTile, subgroup }) {
} }
} }
} }
return activeGroups.map((group) => { return activeGroups.map((group: Group) => {
const isDragging = dragId && selectedGroupIdsSet.has(group.id); const isDragging = dragId !== null && selectedGroupIdsSet.has(group.id);
const disableTileGrouping = const disableTileGrouping =
disableGrouping || isDragging || hasSelectedContainerGroup; disableGrouping || isDragging || hasSelectedContainerGroup;
return ( return (
@ -84,7 +90,7 @@ function SortableTiles({ renderTile, subgroup }) {
disableSorting={disableSorting} disableSorting={disableSorting}
hidden={group.id === openGroupId} hidden={group.id === openGroupId}
isDragging={isDragging} isDragging={isDragging}
cursor={dragCursor} cursor={dragCursor || ""}
> >
{renderSortableGroup(group, selectedGroups)} {renderSortableGroup(group, selectedGroups)}
</SortableTile> </SortableTile>

View File

@ -8,8 +8,17 @@ import Vector2 from "../../helpers/Vector2";
import { useTileDragId } from "../../contexts/TileDragContext"; import { useTileDragId } from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext"; 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 dragId = useTileDragId();
const { const {
groups, groups,
@ -27,7 +36,7 @@ function SortableTilesDragOverlay({ renderTile, subgroup }) {
: groups; : groups;
// Only populate selected groups if needed // Only populate selected groups if needed
let selectedGroupIds = []; let selectedGroupIds: string[] = [];
if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) { if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) {
selectedGroupIds = allSelectedIds; selectedGroupIds = allSelectedIds;
} }

View File

@ -3,6 +3,18 @@ import { Flex, IconButton, Box, Text, Badge } from "theme-ui";
import EditTileIcon from "../../icons/EditTileIcon"; 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({ function Tile({
title, title,
isSelected, isSelected,
@ -13,7 +25,7 @@ function Tile({
badges, badges,
editTitle, editTitle,
children, children,
}) { }: TileProps) {
return ( return (
<Box <Box
sx={{ sx={{

View File

@ -1,4 +1,3 @@
import React from "react";
import { Flex, IconButton } from "theme-ui"; import { Flex, IconButton } from "theme-ui";
import AddIcon from "../../icons/AddIcon"; import AddIcon from "../../icons/AddIcon";
@ -10,7 +9,12 @@ import RadioIconButton from "../RadioIconButton";
import { useGroup } from "../../contexts/GroupContext"; import { useGroup } from "../../contexts/GroupContext";
function TileActionBar({ onAdd, addTitle }) { type TileActionBarProps = {
onAdd: () => void;
addTitle: string;
};
function TileActionBar({ onAdd, addTitle }: TileActionBarProps) {
const { const {
selectMode, selectMode,
onSelectModeChange, onSelectModeChange,
@ -33,7 +37,7 @@ function TileActionBar({ onAdd, addTitle }) {
outlineOffset: "0px", outlineOffset: "0px",
}, },
}} }}
onFocus={() => onGroupSelect()} onFocus={() => onGroupSelect(undefined)}
> >
<Search value={filter} onChange={(e) => onFilterChange(e.target.value)} /> <Search value={filter} onChange={(e) => onFilterChange(e.target.value)} />
<Flex <Flex

View File

@ -9,7 +9,7 @@ import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import Droppable from "../drag/Droppable"; import Droppable from "../drag/Droppable";
function TilesContainer({ children }) { function TilesContainer({ children }: { children: React.ReactNode }) {
const { onGroupSelect } = useGroup(); const { onGroupSelect } = useGroup();
const { theme } = useThemeUI(); const { theme } = useThemeUI();
@ -21,9 +21,9 @@ function TilesContainer({ children }) {
<SimpleBar <SimpleBar
style={{ style={{
height: layout.tileContainerHeight, height: layout.tileContainerHeight,
backgroundColor: theme.colors.muted, backgroundColor: theme.colors?.muted as string,
}} }}
onClick={() => onGroupSelect()} onClick={() => onGroupSelect(undefined)}
> >
<Grid <Grid
p={3} p={3}

View File

@ -16,15 +16,16 @@ import GroupNameModal from "../../modals/GroupNameModal";
import { renameGroup } from "../../helpers/group"; import { renameGroup } from "../../helpers/group";
import Droppable from "../drag/Droppable"; import Droppable from "../drag/Droppable";
import { Group } from "../../types/Group";
function TilesOverlay({ modalSize, children }) { type TilesOverlayProps = {
const { modalSize: { width: number; height: number };
groups, children: React.ReactNode;
openGroupId, };
onGroupClose,
onGroupSelect, function TilesOverlay({ modalSize, children }: TilesOverlayProps) {
onGroupsChange, const { groups, openGroupId, onGroupClose, onGroupSelect, onGroupsChange } =
} = useGroup(); useGroup();
const { theme } = useThemeUI(); const { theme } = useThemeUI();
@ -37,23 +38,29 @@ function TilesOverlay({ modalSize, children }) {
}); });
const [containerSize, setContinerSize] = useState({ width: 0, height: 0 }); const [containerSize, setContinerSize] = useState({ width: 0, height: 0 });
function handleContainerResize(width, height) { function handleContainerResize(width?: number, height?: number) {
const size = Math.min(width, height) - 16; if (width && height) {
setContinerSize({ width: size, height: size }); const size = Math.min(width, height) - 16;
setContinerSize({ width: size, height: size });
}
} }
const [isGroupNameModalOpen, setIsGroupNameModalOpen] = useState(false); const [isGroupNameModalOpen, setIsGroupNameModalOpen] = useState(false);
function handleGroupNameChange(name) { function handleGroupNameChange(name: string) {
onGroupsChange(renameGroup(groups, openGroupId, name)); if (openGroupId) {
setIsGroupNameModalOpen(false); onGroupsChange(renameGroup(groups, openGroupId, name), undefined);
setIsGroupNameModalOpen(false);
}
} }
const group = groups.find((group) => group.id === openGroupId); const group = groups.find((group: Group) => group.id === openGroupId);
if (!openGroupId) { if (!openGroupId) {
return null; return null;
} }
const groupName = group && group.type === "group" && group.name;
return ( return (
<> <>
<Box <Box
@ -104,14 +111,14 @@ function TilesOverlay({ modalSize, children }) {
> >
<Flex my={1} sx={{ position: "relative" }}> <Flex my={1} sx={{ position: "relative" }}>
<Text as="p" my="2px"> <Text as="p" my="2px">
{group?.name} {groupName}
</Text> </Text>
<IconButton <IconButton
sx={{ sx={{
width: "24px", width: "24px",
height: "24px", height: "24px",
position: group?.name ? "absolute" : "relative", position: groupName ? "absolute" : "relative",
left: group?.name ? "100%" : 0, left: groupName ? "100%" : 0,
}} }}
title="Edit Group" title="Edit Group"
aria-label="Edit Group" aria-label="Edit Group"
@ -125,9 +132,9 @@ function TilesOverlay({ modalSize, children }) {
width: containerSize.width - 16, width: containerSize.width - 16,
height: containerSize.height - 48, height: containerSize.height - 48,
marginBottom: "8px", marginBottom: "8px",
backgroundColor: theme.colors.muted, backgroundColor: theme.colors?.muted as string,
}} }}
onClick={() => onGroupSelect()} onClick={() => onGroupSelect(undefined)}
> >
<Grid <Grid
sx={{ sx={{
@ -179,7 +186,7 @@ function TilesOverlay({ modalSize, children }) {
</ReactResizeDetector> </ReactResizeDetector>
<GroupNameModal <GroupNameModal
isOpen={isGroupNameModalOpen} isOpen={isGroupNameModalOpen}
name={group?.name} name={groupName || ""}
onSubmit={handleGroupNameChange} onSubmit={handleGroupNameChange}
onRequestClose={() => setIsGroupNameModalOpen(false)} onRequestClose={() => setIsGroupNameModalOpen(false)}
/> />

View File

@ -8,49 +8,20 @@ import { useDatabase } from "./DatabaseContext";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
import { omit } from "../helpers/shared"; import { omit } from "../helpers/shared";
import { Asset } from "../types/Asset";
/** type AssetsContext = {
* @typedef Asset getAsset: (assetId: string) => Promise<Asset | undefined>;
* @property {string} id addAssets: (assets: Asset[]) => void;
* @property {number} width putAsset: (asset: Asset) => void;
* @property {number} height };
* @property {Uint8Array} file
* @property {string} mime
* @property {string} owner
*/
/** const AssetsContext = React.createContext<AssetsContext | undefined>(undefined);
* @callback getAsset
* @param {string} assetId
* @returns {Promise<Asset|undefined>}
*/
/**
* @callback addAssets
* @param {Asset[]} assets
*/
/**
* @callback putAsset
* @param {Asset} asset
*/
/**
* @typedef AssetsContext
* @property {getAsset} getAsset
* @property {addAssets} addAssets
* @property {putAsset} putAsset
*/
/**
* @type {React.Context<undefined|AssetsContext>}
*/
const AssetsContext = React.createContext();
// 100 MB max cache size // 100 MB max cache size
const maxCacheSize = 1e8; const maxCacheSize = 1e8;
export function AssetsProvider({ children }) { export function AssetsProvider({ children }: { children: React.ReactNode }) {
const { worker, database, databaseStatus } = useDatabase(); const { worker, database, databaseStatus } = useDatabase();
useEffect(() => { useEffect(() => {
@ -61,33 +32,39 @@ export function AssetsProvider({ children }) {
const getAsset = useCallback( const getAsset = useCallback(
async (assetId) => { async (assetId) => {
return await database.table("assets").get(assetId); if (database) {
return await database.table("assets").get(assetId);
}
}, },
[database] [database]
); );
const addAssets = useCallback( const addAssets = useCallback(
async (assets) => { async (assets) => {
await database.table("assets").bulkAdd(assets); if (database) {
await database.table("assets").bulkAdd(assets);
}
}, },
[database] [database]
); );
const putAsset = useCallback( const putAsset = useCallback(
async (asset) => { async (asset) => {
// Check for broadcast channel and attempt to use worker to put map to avoid UI lockup if (database) {
// Safari doesn't support BC so fallback to single thread // Check for broadcast channel and attempt to use worker to put map to avoid UI lockup
if (window.BroadcastChannel) { // Safari doesn't support BC so fallback to single thread
const packedAsset = encode(asset); if (window.BroadcastChannel) {
const success = await worker.putData( const packedAsset = encode(asset);
Comlink.transfer(packedAsset, [packedAsset.buffer]), const success = await worker.putData(
"assets" Comlink.transfer(packedAsset, [packedAsset.buffer]),
); "assets"
if (!success) { );
if (!success) {
await database.table("assets").put(asset);
}
} else {
await database.table("assets").put(asset); await database.table("assets").put(asset);
} }
} else {
await database.table("assets").put(asset);
} }
}, },
[database, worker] [database, worker]
@ -119,35 +96,38 @@ export function useAssets() {
* @property {number} references * @property {number} references
*/ */
/** type AssetURL = {
* @type React.Context<undefined|Object.<string, AssetURL>> url: string | null;
*/ id: string;
export const AssetURLsStateContext = React.createContext(); references: number;
};
/** type AssetURLs = Record<string, AssetURL>;
* @type React.Context<undefined|React.Dispatch<React.SetStateAction<{}>>>
*/ export const AssetURLsStateContext =
export const AssetURLsUpdaterContext = React.createContext(); React.createContext<AssetURLs | undefined>(undefined);
export const AssetURLsUpdaterContext =
React.createContext<
React.Dispatch<React.SetStateAction<AssetURLs>> | undefined
>(undefined);
/** /**
* Helper to manage sharing of custom image sources between uses of useAssetURL * Helper to manage sharing of custom image sources between uses of useAssetURL
*/ */
export function AssetURLsProvider({ children }) { export function AssetURLsProvider({ children }: { children: React.ReactNode }) {
const [assetURLs, setAssetURLs] = useState({}); const [assetURLs, setAssetURLs] = useState<AssetURLs>({});
const { database } = useDatabase(); const { database } = useDatabase();
// Keep track of the assets that need to be loaded // Keep track of the assets that need to be loaded
const [assetKeys, setAssetKeys] = useState([]); const [assetKeys, setAssetKeys] = useState<string[]>([]);
// Load assets after 100ms // Load assets after 100ms
const loadingDebouncedAssetURLs = useDebounce(assetURLs, 100); const loadingDebouncedAssetURLs = useDebounce(assetURLs, 100);
// Update the asset keys to load when a url is added without an asset attached // Update the asset keys to load when a url is added without an asset attached
useEffect(() => { useEffect(() => {
if (!loadingDebouncedAssetURLs) { let keysToLoad: string[] = [];
return;
}
let keysToLoad = [];
for (let url of Object.values(loadingDebouncedAssetURLs)) { for (let url of Object.values(loadingDebouncedAssetURLs)) {
if (url.url === null) { if (url.url === null) {
keysToLoad.push(url.id); keysToLoad.push(url.id);
@ -159,8 +139,9 @@ export function AssetURLsProvider({ children }) {
}, [loadingDebouncedAssetURLs]); }, [loadingDebouncedAssetURLs]);
// Get the new assets whenever the keys change // Get the new assets whenever the keys change
const assets = useLiveQuery( const assets = useLiveQuery<Asset[]>(
() => database?.table("assets").where("id").anyOf(assetKeys).toArray(), () =>
database?.table("assets").where("id").anyOf(assetKeys).toArray() || [],
[database, assetKeys] [database, assetKeys]
); );
@ -197,7 +178,7 @@ export function AssetURLsProvider({ children }) {
let urlsToCleanup = []; let urlsToCleanup = [];
for (let url of Object.values(prevURLs)) { for (let url of Object.values(prevURLs)) {
if (url.references <= 0) { if (url.references <= 0) {
URL.revokeObjectURL(url.url); url.url && URL.revokeObjectURL(url.url);
urlsToCleanup.push(url.id); urlsToCleanup.push(url.id);
} }
} }
@ -220,13 +201,13 @@ export function AssetURLsProvider({ children }) {
/** /**
* Helper function to load either file or default asset into a URL * Helper function to load either file or default asset into a URL
* @param {string} assetId
* @param {"file"|"default"} type
* @param {Object.<string, string>} 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<string, string>,
unknownSource?: string
) {
const assetURLs = useContext(AssetURLsStateContext); const assetURLs = useContext(AssetURLsStateContext);
if (assetURLs === undefined) { if (assetURLs === undefined) {
throw new Error("useAssetURL must be used within a AssetURLsProvider"); throw new Error("useAssetURL must be used within a AssetURLsProvider");
@ -242,7 +223,7 @@ export function useAssetURL(assetId, type, defaultSources, unknownSource) {
} }
function updateAssetURL() { function updateAssetURL() {
function increaseReferences(prevURLs) { function increaseReferences(prevURLs: AssetURLs): AssetURLs {
return { return {
...prevURLs, ...prevURLs,
[assetId]: { [assetId]: {
@ -252,13 +233,13 @@ export function useAssetURL(assetId, type, defaultSources, unknownSource) {
}; };
} }
function createReference(prevURLs) { function createReference(prevURLs: AssetURLs): AssetURLs {
return { return {
...prevURLs, ...prevURLs,
[assetId]: { url: null, id: assetId, references: 1 }, [assetId]: { url: null, id: assetId, references: 1 },
}; };
} }
setAssetURLs((prevURLs) => { setAssetURLs?.((prevURLs) => {
if (assetId in prevURLs) { if (assetId in prevURLs) {
// Check if the asset url is already added and increase references // Check if the asset url is already added and increase references
return increaseReferences(prevURLs); return increaseReferences(prevURLs);
@ -303,36 +284,29 @@ export function useAssetURL(assetId, type, defaultSources, unknownSource) {
return unknownSource; return unknownSource;
} }
/** type FileData = {
* @typedef FileData file: string;
* @property {string} file type: "file";
* @property {"file"} type thumbnail: string;
* @property {string} thumbnail quality?: string;
* @property {string=} quality resolutions?: Record<string, string>;
* @property {Object.<string, string>=} resolutions };
*/
/** type DefaultData = {
* @typedef DefaultData key: string;
* @property {string} key type: "default";
* @property {"default"} type };
*/
/** /**
* Load a map or token into a URL taking into account a thumbnail and multiple resolutions * Load a map or token into a URL taking into account a thumbnail and multiple resolutions
* @param {FileData|DefaultData} data
* @param {Object.<string, string>} defaultSources
* @param {string|undefined} unknownSource
* @param {boolean} thumbnail
* @returns {string|undefined}
*/ */
export function useDataURL( export function useDataURL(
data, data: FileData | DefaultData,
defaultSources, defaultSources: Record<string, string>,
unknownSource, unknownSource: string | undefined,
thumbnail = false thumbnail = false
) { ) {
const [assetId, setAssetId] = useState(); const [assetId, setAssetId] = useState<string>();
useEffect(() => { useEffect(() => {
if (!data) { if (!data) {
@ -344,7 +318,11 @@ export function useDataURL(
} else { } else {
if (thumbnail) { if (thumbnail) {
setAssetId(data.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]); setAssetId(data.resolutions[data.quality]);
} else { } else {
setAssetId(data.file); setAssetId(data.file);
@ -356,7 +334,7 @@ export function useDataURL(
}, [data, thumbnail]); }, [data, thumbnail]);
const assetURL = useAssetURL( const assetURL = useAssetURL(
assetId, assetId || "",
data?.type, data?.type,
defaultSources, defaultSources,
unknownSource unknownSource

View File

@ -2,9 +2,11 @@ import React, { useState, useEffect, useContext } from "react";
import FakeStorage from "../helpers/FakeStorage"; import FakeStorage from "../helpers/FakeStorage";
type AuthContext = { password: string; setPassword: React.Dispatch<any> }; type AuthContext = {
password: string;
setPassword: React.Dispatch<React.SetStateAction<string>>;
};
// TODO: check what default value we want here
const AuthContext = React.createContext<AuthContext | undefined>(undefined); const AuthContext = React.createContext<AuthContext | undefined>(undefined);
let storage: Storage | FakeStorage; let storage: Storage | FakeStorage;

View File

@ -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 (
<DndContext {...props}>
<DragPositionMonitor onDragEnd={onDragEnd}>
{children}
</DragPositionMonitor>
</DndContext>
);
}
export default DragContext;

View File

@ -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<DOMRect>();
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 (
<DndContext {...props}>
<DragPositionMonitor onDragEnd={onDragEnd} />
{children}
</DndContext>
);
}
export default DragContext;

View File

@ -7,8 +7,40 @@ import { useKeyboard, useBlur } from "./KeyboardContext";
import { getGroupItems, groupsFromIds } from "../helpers/group"; import { getGroupItems, groupsFromIds } from "../helpers/group";
import shortcuts from "../shortcuts"; 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<React.SetStateAction<string | undefined>>;
};
const GroupContext = React.createContext<GroupContext | undefined>(undefined);
type GroupProviderProps = {
groups: Group[];
itemNames: Record<string, string>;
onGroupsChange: (groups: Group[]) => void;
onGroupsSelect: (groupIds: string[]) => void;
disabled: boolean;
children: React.ReactNode;
};
export function GroupProvider({ export function GroupProvider({
groups, groups,
@ -17,16 +49,17 @@ export function GroupProvider({
onGroupsSelect, onGroupsSelect,
disabled, disabled,
children, children,
}) { }: GroupProviderProps) {
const [selectedGroupIds, setSelectedGroupIds] = useState([]); const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
// Either single, multiple or range // Either single, multiple or range
const [selectMode, setSelectMode] = useState("single"); const [selectMode, setSelectMode] =
useState<"single" | "multiple" | "range">("single");
/** /**
* Group Open * Group Open
*/ */
const [openGroupId, setOpenGroupId] = useState(); const [openGroupId, setOpenGroupId] = useState<string>();
const [openGroupItems, setOpenGroupItems] = useState([]); const [openGroupItems, setOpenGroupItems] = useState<Group[]>([]);
useEffect(() => { useEffect(() => {
if (openGroupId) { if (openGroupId) {
const openGroups = groupsFromIds([openGroupId], groups); const openGroups = groupsFromIds([openGroupId], groups);
@ -37,29 +70,29 @@ export function GroupProvider({
// Close group if we can't find it // Close group if we can't find it
// This can happen if it was deleted or all it's items were deleted // This can happen if it was deleted or all it's items were deleted
setOpenGroupItems([]); setOpenGroupItems([]);
setOpenGroupId(); setOpenGroupId(undefined);
} }
} else { } else {
setOpenGroupItems([]); setOpenGroupItems([]);
} }
}, [openGroupId, groups]); }, [openGroupId, groups]);
function handleGroupOpen(groupId) { function handleGroupOpen(groupId: string) {
setSelectedGroupIds([]); setSelectedGroupIds([]);
setOpenGroupId(groupId); setOpenGroupId(groupId);
} }
function handleGroupClose() { function handleGroupClose() {
setSelectedGroupIds([]); setSelectedGroupIds([]);
setOpenGroupId(); setOpenGroupId(undefined);
} }
/** /**
* Search * Search
*/ */
const [filter, setFilter] = useState(); const [filter, setFilter] = useState<string>();
const [filteredGroupItems, setFilteredGroupItems] = useState([]); const [filteredGroupItems, setFilteredGroupItems] = useState<GroupItem[]>([]);
const [fuse, setFuse] = useState(); const [fuse, setFuse] = useState<Fuse<GroupItem & { name: string }>>();
// Update search index when items change // Update search index when items change
useEffect(() => { useEffect(() => {
let items = []; let items = [];
@ -76,10 +109,10 @@ export function GroupProvider({
// Perform search when search changes // Perform search when search changes
useEffect(() => { useEffect(() => {
if (filter) { if (filter && fuse) {
const query = fuse.search(filter); const query = fuse.search(filter);
setFilteredGroupItems(query.map((result) => result.item)); setFilteredGroupItems(query.map((result) => result.item));
setOpenGroupId(); setOpenGroupId(undefined);
} else { } else {
setFilteredGroupItems([]); setFilteredGroupItems([]);
} }
@ -96,23 +129,30 @@ export function GroupProvider({
: groups; : groups;
/** /**
* @param {Group[] | GroupItem[]} newGroups
* @param {string|undefined} groupId The group to apply changes to, leave undefined to replace the full group object * @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 (groupId) {
// If a group is specidifed then update that group with the new items // If a group is specidifed then update that group with the new items
const groupIndex = groups.findIndex((group) => group.id === groupId); const groupIndex = groups.findIndex((group) => group.id === groupId);
let updatedGroups = cloneDeep(groups); let updatedGroups = cloneDeep(groups);
const group = updatedGroups[groupIndex]; const group = updatedGroups[groupIndex];
updatedGroups[groupIndex] = { ...group, items: newGroups }; updatedGroups[groupIndex] = {
...group,
items: newGroups,
} as GroupContainer;
onGroupsChange(updatedGroups); onGroupsChange(updatedGroups);
} else { } else {
onGroupsChange(newGroups); onGroupsChange(newGroups);
} }
} }
function handleGroupSelect(groupId) { function handleGroupSelect(groupId: string | undefined) {
let groupIds = []; let groupIds: string[] = [];
if (groupId) { if (groupId) {
switch (selectMode) { switch (selectMode) {
case "single": case "single":
@ -133,8 +173,8 @@ export function GroupProvider({
const lastIndex = activeGroups.findIndex( const lastIndex = activeGroups.findIndex(
(g) => g.id === selectedGroupIds[selectedGroupIds.length - 1] (g) => g.id === selectedGroupIds[selectedGroupIds.length - 1]
); );
let idsToAdd = []; let idsToAdd: string[] = [];
let idsToRemove = []; let idsToRemove: string[] = [];
const direction = currentIndex > lastIndex ? 1 : -1; const direction = currentIndex > lastIndex ? 1 : -1;
for ( for (
let i = lastIndex + direction; let i = lastIndex + direction;
@ -166,7 +206,7 @@ export function GroupProvider({
/** /**
* Shortcuts * Shortcuts
*/ */
function handleKeyDown(event) { function handleKeyDown(event: React.KeyboardEvent) {
if (disabled) { if (disabled) {
return; return;
} }
@ -178,7 +218,7 @@ export function GroupProvider({
} }
} }
function handleKeyUp(event) { function handleKeyUp(event: React.KeyboardEvent) {
if (disabled) { if (disabled) {
return; return;
} }

View File

@ -9,7 +9,9 @@ import { useLiveQuery } from "dexie-react-hooks";
import { useDatabase } from "./DatabaseContext"; 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"; import { removeGroupsItems } from "../helpers/group";

View File

@ -6,19 +6,26 @@ import {
useSensor, useSensor,
useSensors, useSensors,
closestCenter, closestCenter,
RectEntry,
} from "@dnd-kit/core"; } 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 { useGroup } from "./GroupContext";
import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group"; import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group";
import Vector2 from "../helpers/Vector2";
import usePreventSelect from "../hooks/usePreventSelect"; import usePreventSelect from "../hooks/usePreventSelect";
const TileDragIdContext = React.createContext(); const TileDragIdContext =
const TileOverGroupIdContext = React.createContext(); React.createContext<string | undefined | null>(undefined);
const TileDragCursorContext = React.createContext(); const TileOverGroupIdContext =
React.createContext<string | undefined | null>(undefined);
const TileDragCursorContext =
React.createContext<string | undefined | null>(undefined);
export const BASE_SORTABLE_ID = "__base__"; export const BASE_SORTABLE_ID = "__base__";
export const GROUP_SORTABLE_ID = "__group__"; export const GROUP_SORTABLE_ID = "__group__";
@ -27,7 +34,7 @@ export const UNGROUP_ID = "__ungroup__";
export const ADD_TO_MAP_ID = "__add__"; export const ADD_TO_MAP_ID = "__add__";
// Custom rectIntersect that takes a point // Custom rectIntersect that takes a point
function rectIntersection(rects, point) { function rectIntersection(rects: RectEntry[], point: Vector2) {
for (let rect of rects) { for (let rect of rects) {
const [id, bounds] = rect; const [id, bounds] = rect;
if ( if (
@ -44,13 +51,21 @@ function rectIntersection(rects, point) {
return null; 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({ export function TileDragProvider({
onDragAdd, onDragAdd,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
onDragCancel, onDragCancel,
children, children,
}) { }: TileDragProviderProps) {
const { const {
groups, groups,
activeGroups, activeGroups,
@ -71,23 +86,23 @@ export function TileDragProvider({
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor); const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const [dragId, setDragId] = useState(null); const [dragId, setDragId] = useState<string | null>(null);
const [overId, setOverId] = useState(null); const [overId, setOverId] = useState<string | null>(null);
const [dragCursor, setDragCursor] = useState("pointer"); const [dragCursor, setDragCursor] = useState("pointer");
const [preventSelect, resumeSelect] = usePreventSelect(); const [preventSelect, resumeSelect] = usePreventSelect();
const [overGroupId, setOverGroupId] = useState(null); const [overGroupId, setOverGroupId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setOverGroupId( setOverGroupId(
(overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9)) || null (overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9)) || null
); );
}, [overId]); }, [overId]);
function handleDragStart(event) { function handleDragStart(event: DragStartEvent) {
const { active, over } = event; const { active } = event;
setDragId(active.id); setDragId(active.id);
setOverId(over?.id || null); setOverId(null);
if (!selectedGroupIds.includes(active.id)) { if (!selectedGroupIds.includes(active.id)) {
onGroupSelect(active.id); onGroupSelect(active.id);
} }
@ -98,7 +113,7 @@ export function TileDragProvider({
preventSelect(); preventSelect();
} }
function handleDragOver(event) { function handleDragOver(event: DragOverEvent) {
const { over } = event; const { over } = event;
setOverId(over?.id || null); setOverId(over?.id || null);
@ -116,7 +131,7 @@ export function TileDragProvider({
} }
} }
function handleDragEnd(event) { function handleDragEnd(event: CustomDragEndEvent) {
const { active, over, overlayNodeClientRect } = event; const { active, over, overlayNodeClientRect } = event;
setDragId(null); setDragId(null);
@ -130,7 +145,7 @@ export function TileDragProvider({
selectedIndices = selectedIndices.sort((a, b) => a - b); selectedIndices = selectedIndices.sort((a, b) => a - b);
if (over.id.startsWith(GROUP_ID_PREFIX)) { if (over.id.startsWith(GROUP_ID_PREFIX)) {
onGroupSelect(); onGroupSelect(undefined);
// Handle tile group // Handle tile group
const overId = over.id.slice(9); const overId = over.id.slice(9);
if (overId !== active.id) { if (overId !== active.id) {
@ -143,10 +158,12 @@ export function TileDragProvider({
); );
} }
} else if (over.id === UNGROUP_ID) { } else if (over.id === UNGROUP_ID) {
onGroupSelect(); if (openGroupId) {
// Handle tile ungroup onGroupSelect(undefined);
const newGroups = ungroup(groups, openGroupId, selectedIndices); // Handle tile ungroup
onGroupsChange(newGroups); const newGroups = ungroup(groups, openGroupId, selectedIndices);
onGroupsChange(newGroups, undefined);
}
} else if (over.id === ADD_TO_MAP_ID) { } else if (over.id === ADD_TO_MAP_ID) {
onDragAdd && onDragAdd &&
overlayNodeClientRect && overlayNodeClientRect &&
@ -168,7 +185,7 @@ export function TileDragProvider({
onDragEnd && onDragEnd(event); onDragEnd && onDragEnd(event);
} }
function handleDragCancel(event) { function handleDragCancel(event: DragCancelEvent) {
setDragId(null); setDragId(null);
setOverId(null); setOverId(null);
setDragCursor("pointer"); setDragCursor("pointer");
@ -178,7 +195,7 @@ export function TileDragProvider({
onDragCancel && onDragCancel(event); onDragCancel && onDragCancel(event);
} }
function customCollisionDetection(rects, rect) { function customCollisionDetection(rects: RectEntry[], rect: ViewRect) {
const rectCenter = { const rectCenter = {
x: rect.left + rect.width / 2, x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2, y: rect.top + rect.height / 2,

View File

@ -1,12 +1,10 @@
import React, { useEffect, useState, useContext } from "react"; import React, { useEffect, useState, useContext } from "react";
import { useDatabase } from "./DatabaseContext"; import { useDatabase } from "./DatabaseContext";
/**
* @type {React.Context<string|undefined>}
*/
const UserIdContext = React.createContext();
export function UserIdProvider({ children }) { const UserIdContext = React.createContext<string | undefined>(undefined);
export function UserIdProvider({ children }: { children: React.ReactNode }) {
const { database, databaseStatus } = useDatabase(); const { database, databaseStatus } = useDatabase();
const [userId, setUserId] = useState(); const [userId, setUserId] = useState();
@ -15,9 +13,11 @@ export function UserIdProvider({ children }) {
return; return;
} }
async function loadUserId() { async function loadUserId() {
const storedUserId = await database.table("user").get("userId"); if (database) {
if (storedUserId) { const storedUserId = await database.table("user").get("userId");
setUserId(storedUserId.value); if (storedUserId) {
setUserId(storedUserId.value);
}
} }
} }

View File

@ -1,7 +1,10 @@
import { Vector3 } from "@babylonjs/core/Maths/math"; import { Vector3 } from "@babylonjs/core/Maths/math";
import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader"; import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader";
import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial"; 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 d4Source from "./shared/d4.glb";
import d6Source from "./shared/d6.glb"; import d6Source from "./shared/d6.glb";
@ -13,7 +16,15 @@ import d100Source from "./shared/d100.glb";
import { lerp } from "../helpers/shared"; import { lerp } from "../helpers/shared";
import { importTextureAsync } from "../helpers/babylon"; 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 minDiceRollSpeed = 600;
const maxDiceRollSpeed = 800; const maxDiceRollSpeed = 800;
@ -21,10 +32,16 @@ const maxDiceRollSpeed = 800;
class Dice { class Dice {
static instanceCount = 0; static instanceCount = 0;
static async loadMeshes(material: Material, scene: Scene, sourceOverrides?: any): Promise<Record<string, Mesh>> { static async loadMeshes(
material: Material,
scene: Scene,
sourceOverrides?: any
): Promise<Record<string, Mesh>> {
let meshes: any = {}; let meshes: any = {};
const addToMeshes = async (type: string | number, defaultSource: 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); const mesh = await this.loadMesh(source, material, scene);
meshes[type] = mesh; meshes[type] = mesh;
}; };
@ -54,7 +71,11 @@ class Dice {
static async loadMaterial(materialName: string, textures: any, scene: Scene) { static async loadMaterial(materialName: string, textures: any, scene: Scene) {
let pbr = new PBRMaterial(materialName, 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.albedo),
importTextureAsync(textures.normal), importTextureAsync(textures.normal),
importTextureAsync(textures.metalRoughness), importTextureAsync(textures.metalRoughness),
@ -69,7 +90,12 @@ class Dice {
return pbr; 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); let instance = mesh.createInstance(name);
instance.position = mesh.position; instance.position = mesh.position;
for (let child of mesh.getChildTransformNodes()) { for (let child of mesh.getChildTransformNodes()) {
@ -77,7 +103,7 @@ class Dice {
const locator: any = child.clone(child.name, instance); const locator: any = child.clone(child.name, instance);
// TODO: handle possible null value // TODO: handle possible null value
if (!locator) { if (!locator) {
throw Error throw Error;
} }
locator.setAbsolutePosition(child.getAbsolutePosition()); locator.setAbsolutePosition(child.getAbsolutePosition());
locator.name = child.name; 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?.setLinearVelocity(Vector3.Zero());
instance.physicsImpostor?.setAngularVelocity(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++; this.instanceCount++;
return this.createInstanceFromMesh( return this.createInstanceFromMesh(
@ -166,6 +196,14 @@ class Dice {
scene 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; export default Dice;

View File

@ -19,7 +19,9 @@ import GlassPreview from "./glass/preview.png";
import GemstonePreview from "./gemstone/preview.png"; import GemstonePreview from "./gemstone/preview.png";
import Dice from "./Dice"; import Dice from "./Dice";
type DiceClasses = Record<string, Dice>; import { DefaultDice } from "../types/Dice";
type DiceClasses = Record<string, typeof Dice>;
export const diceClasses: DiceClasses = { export const diceClasses: DiceClasses = {
galaxy: GalaxyDice, galaxy: GalaxyDice,
@ -45,7 +47,7 @@ export const dicePreviews: DicePreview = {
gemstone: GemstonePreview, gemstone: GemstonePreview,
}; };
export const dice = Object.keys(diceClasses).map((key) => ({ export const dice: DefaultDice[] = Object.keys(diceClasses).map((key) => ({
key, key,
name: Case.capital(key), name: Case.capital(key),
class: diceClasses[key], class: diceClasses[key],

View File

@ -12,6 +12,7 @@ import d12Source from "./d12.glb";
import d20Source from "./d20.glb"; import d20Source from "./d20.glb";
import d100Source from "./d100.glb"; import d100Source from "./d100.glb";
import { Material, Mesh, Scene } from "@babylonjs/core"; import { Material, Mesh, Scene } from "@babylonjs/core";
import { DiceType } from "../../types/Dice";
const sourceOverrides = { const sourceOverrides = {
d4: d4Source, d4: d4Source,
@ -24,7 +25,7 @@ const sourceOverrides = {
}; };
class WalnutDice extends Dice { class WalnutDice extends Dice {
static meshes: Record<string, Mesh>; static meshes: Record<DiceType, Mesh>;
static material: Material; static material: Material;
static getDicePhysicalProperties(diceType: string) { 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) { if (!this.material || !this.meshes) {
throw Error("Dice not loaded, call load before creating an instance"); throw Error("Dice not loaded, call load before creating an instance");
} }

View File

@ -6,12 +6,12 @@ import {
} from "./shared"; } from "./shared";
export type BoundingBox = { export type BoundingBox = {
min: Vector2, min: Vector2;
max: Vector2, max: Vector2;
width: number, width: number;
height: number, height: number;
center: Vector2 center: Vector2;
} };
/** /**
* Vector class with x, y and static helper methods * Vector class with x, y and static helper methods
@ -287,7 +287,12 @@ class Vector2 {
* @param {Vector2} C End of the curve * @param {Vector2} C End of the curve
* @returns {Object} The distance to and the closest point on 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 distance = 0;
let point = { x: pos.x, y: pos.y }; let point = { x: pos.x, y: pos.y };
@ -514,7 +519,10 @@ class Vector2 {
* @param {("counterClockwise"|"clockwise")=} direction Direction to rotate the vector * @param {("counterClockwise"|"clockwise")=} direction Direction to rotate the vector
* @returns {Vector2} * @returns {Vector2}
*/ */
static rotate90(p: Vector2, direction: "counterClockwise" | "clockwise" = "clockwise"): Vector2 { static rotate90(
p: Vector2,
direction: "counterClockwise" | "clockwise" = "clockwise"
): Vector2 {
if (direction === "clockwise") { if (direction === "clockwise") {
return { x: p.y, y: -p.x }; return { x: p.y, y: -p.x };
} else { } else {
@ -527,7 +535,7 @@ class Vector2 {
* @param {Vector2[]} points * @param {Vector2[]} points
* @returns {Vector2} * @returns {Vector2}
*/ */
static centroid(points) { static centroid(points: Vector2[]): Vector2 {
let center = { x: 0, y: 0 }; let center = { x: 0, y: 0 };
for (let point of points) { for (let point of points) {
center.x += point.x; center.x += point.x;
@ -544,7 +552,7 @@ class Vector2 {
* @param {Vector2[]} points * @param {Vector2[]} points
* @returns {boolean} * @returns {boolean}
*/ */
static rectangular(points) { static rectangular(points: Vector2[]): boolean {
if (points.length !== 4) { if (points.length !== 4) {
return false; return false;
} }
@ -567,7 +575,7 @@ class Vector2 {
* @param {Vector2[]} points * @param {Vector2[]} points
* @returns {boolean} * @returns {boolean}
*/ */
static circular(points, threshold = 0.1) { static circular(points: Vector2[], threshold = 0.1): boolean {
const centroid = this.centroid(points); const centroid = this.centroid(points);
let distances = []; let distances = [];
for (let point of points) { for (let point of points) {

View File

@ -1,4 +1,5 @@
import { Vector3 } from "@babylonjs/core/Maths/math"; import { Vector3 } from "@babylonjs/core/Maths/math";
import { DiceRoll } from "../types/Dice";
/** /**
* Find the number facing up on a mesh instance of a 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 }; return { type: dice.type, roll: number };
} }
export function getDiceRollTotal(diceRolls: []) { export function getDiceRollTotal(diceRolls: DiceRoll[]) {
return diceRolls.reduce((accumulator: number, dice: any) => { return diceRolls.reduce((accumulator: number, dice: any) => {
if (dice.roll === "unknown") { if (dice.roll === "unknown") {
return accumulator; return accumulator;

View File

@ -2,136 +2,19 @@ import simplify from "simplify-js";
import polygonClipping, { Geom, Polygon, Ring } from "polygon-clipping"; import polygonClipping, { Geom, Polygon, Ring } from "polygon-clipping";
import Vector2, { BoundingBox } from "./Vector2"; import Vector2, { BoundingBox } from "./Vector2";
import Size from "./Size" import Size from "./Size";
import { toDegrees } from "./shared"; import { toDegrees } from "./shared";
import { Grid, getNearestCellCoordinates, getCellLocation } from "./grid"; import { getNearestCellCoordinates, getCellLocation } from "./grid";
/** import {
* @typedef PointsData ShapeType,
* @property {Vector2[]} points ShapeData,
*/ PointsData,
RectData,
type PointsData = { CircleData,
points: Vector2[] } from "../types/Drawing";
} import { Fog } from "../types/Fog";
import { Grid } from "../types/Grid";
/**
* @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
}
/** /**
* *
@ -139,24 +22,26 @@ export type Fog = {
* @param {Vector2} brushPosition * @param {Vector2} brushPosition
* @returns {ShapeData} * @returns {ShapeData}
*/ */
export function getDefaultShapeData(type: ShapeType, brushPosition: Vector2): ShapeData | undefined{ export function getDefaultShapeData(
// TODO: handle undefined if no type found type: ShapeType,
brushPosition: Vector2
): ShapeData {
if (type === "line") { if (type === "line") {
return { return {
points: [ points: [
{ x: brushPosition.x, y: brushPosition.y }, { x: brushPosition.x, y: brushPosition.y },
{ x: brushPosition.x, y: brushPosition.y }, { x: brushPosition.x, y: brushPosition.y },
], ],
} as PointsData; };
} else if (type === "circle") { } 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") { } else if (type === "rectangle") {
return { return {
x: brushPosition.x, x: brushPosition.x,
y: brushPosition.y, y: brushPosition.y,
width: 0, width: 0,
height: 0, height: 0,
} as RectData; };
} else if (type === "triangle") { } else if (type === "triangle") {
return { return {
points: [ 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 },
{ 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, gridCellNormalizedSize: Vector2,
mapWidth: number, mapWidth: number,
mapHeight: number mapHeight: number
): ShapeData | undefined { ): ShapeData {
// TODO: handle undefined type
if (type === "line") { if (type === "line") {
data = data as PointsData; data = data as PointsData;
return { return {
points: [data.points[0], { x: brushPosition.x, y: brushPosition.y }], points: [data.points[0], { x: brushPosition.x, y: brushPosition.y }],
} as PointsData; } as PointsData;
} else if (type === "circle") { } else if (type === "circle") {
data = data as CircleData; data = data as CircleData;
const gridRatio = getGridCellRatio(gridCellNormalizedSize); const gridRatio = getGridCellRatio(gridCellNormalizedSize);
const dif = Vector2.subtract(brushPosition, { const dif = Vector2.subtract(brushPosition, {
x: data.x, x: data.x,
@ -254,6 +140,8 @@ export function getUpdatedShapeData(
Vector2.add(Vector2.multiply(rightDirNorm, sideLength), points[0]), 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 {Vector2[]} points
* @param {number} tolerance * @param {number} tolerance
*/ */
export function simplifyPoints(points: Vector2[], tolerance: number): Vector2[] { export function simplifyPoints(
points: Vector2[],
tolerance: number
): Vector2[] {
return simplify(points, tolerance); return simplify(points, tolerance);
} }
@ -272,7 +163,10 @@ export function simplifyPoints(points: Vector2[], tolerance: number): Vector2[]
* @param {boolean} ignoreHidden * @param {boolean} ignoreHidden
* @returns {Fog[]} * @returns {Fog[]}
*/ */
export function mergeFogShapes(shapes: Fog[], ignoreHidden: boolean = true): Fog[] { export function mergeFogShapes(
shapes: Fog[],
ignoreHidden: boolean = true
): Fog[] {
if (shapes.length === 0) { if (shapes.length === 0) {
return shapes; 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 shapePoints: Ring = shape.data.points.map(({ x, y }) => [x, y]);
const shapeHoles: Polygon = shape.data.holes.map((hole) => 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]]; let shapeGeom: Geom = [[shapePoints, ...shapeHoles]];
geometries.push(shapeGeom); 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 })), points: union[i][0].map(([x, y]) => ({ x, y })),
holes, holes,
}, },
type: "fog" type: "fog",
}); });
} }
return merged; 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 * @param {boolean} maxPoints Max amount of points per shape to get bounds for
* @returns {Vector2.BoundingBox[]} * @returns {Vector2.BoundingBox[]}
*/ */
export function getFogShapesBoundingBoxes(shapes: Fog[], maxPoints = 0): BoundingBox[] { export function getFogShapesBoundingBoxes(
shapes: Fog[],
maxPoints = 0
): BoundingBox[] {
let boxes = []; let boxes = [];
for (let shape of shapes) { for (let shape of shapes) {
if (maxPoints > 0 && shape.data.points.length > maxPoints) { if (maxPoints > 0 && shape.data.points.length > maxPoints) {
@ -361,11 +258,11 @@ export function getFogShapesBoundingBoxes(shapes: Fog[], maxPoints = 0): Boundin
*/ */
type Guide = { type Guide = {
start: Vector2, start: Vector2;
end: Vector2, end: Vector2;
orientation: "horizontal" | "vertical", orientation: "horizontal" | "vertical";
distance: number distance: number;
} };
/** /**
* @param {Vector2} brushPosition Brush position in pixels * @param {Vector2} brushPosition Brush position in pixels
@ -382,7 +279,7 @@ export function getGuidesFromGridCell(
grid: Grid, grid: Grid,
gridCellSize: Size, gridCellSize: Size,
gridOffset: Vector2, gridOffset: Vector2,
gridCellOffset: Vector2, gridCellOffset: Vector2,
snappingSensitivity: number, snappingSensitivity: number,
mapSize: Vector2 mapSize: Vector2
): Guide[] { ): Guide[] {
@ -500,7 +397,10 @@ export function getGuidesFromBoundingBoxes(
* @param {Guide[]} guides * @param {Guide[]} guides
* @returns {Guide[]} * @returns {Guide[]}
*/ */
export function findBestGuides(brushPosition: Vector2, guides: Guide[]): Guide[] { export function findBestGuides(
brushPosition: Vector2,
guides: Guide[]
): Guide[] {
let bestGuides: Guide[] = []; let bestGuides: Guide[] = [];
let verticalGuide = guides let verticalGuide = guides
.filter((guide) => guide.orientation === "vertical") .filter((guide) => guide.orientation === "vertical")

View File

@ -8,42 +8,6 @@ import { logError } from "./logging";
const SQRT3 = 1.73205; const SQRT3 = 1.73205;
const GRID_TYPE_NOT_IMPLEMENTED = new Error("Grid type not implemented"); 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 * Gets the size of a grid in pixels taking into account the inset
* @param {Grid} grid * @param {Grid} grid
@ -51,7 +15,11 @@ export type Grid = {
* @param {number} baseHeight Height of the grid in pixels before inset * @param {number} baseHeight Height of the grid in pixels before inset
* @returns {Size} * @returns {Size}
*/ */
export function getGridPixelSize(grid: Required<Grid>, baseWidth: number, baseHeight: number): Size { export function getGridPixelSize(
grid: Required<Grid>,
baseWidth: number,
baseHeight: number
): Size {
const width = (grid.inset.bottomRight.x - grid.inset.topLeft.x) * baseWidth; const width = (grid.inset.bottomRight.x - grid.inset.topLeft.x) * baseWidth;
const height = (grid.inset.bottomRight.y - grid.inset.topLeft.y) * baseHeight; const height = (grid.inset.bottomRight.y - grid.inset.topLeft.y) * baseHeight;
return new Size(width, height); return new Size(width, height);
@ -64,7 +32,11 @@ export function getGridPixelSize(grid: Required<Grid>, baseWidth: number, baseHe
* @param {number} gridHeight Height of the grid in pixels after inset * @param {number} gridHeight Height of the grid in pixels after inset
* @returns {Size} * @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) { if (grid.size.x === 0 || grid.size.y === 0) {
return new Size(0, 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 * @param {Size} cellSize Cell size in pixels
* @returns {Vector2} * @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) { switch (grid.type) {
case "square": case "square":
return { return {
@ -121,7 +98,12 @@ export function getCellLocation(grid: Grid, col: number, row: number, cellSize:
* @param {Size} cellSize Cell size in pixels * @param {Size} cellSize Cell size in pixels
* @returns {Vector2} * @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) { switch (grid.type) {
case "square": case "square":
return Vector2.divide(Vector2.floorTo({ x, y }, cellSize), cellSize); 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 * @param {Size} cellSize Cell size in pixels
* @returns {Vector2[]} * @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); const position = new Vector2(x, y);
switch (grid.type) { switch (grid.type) {
case "square": 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 * @param {number} gridWidth Width of the grid in pixels after inset
* @returns {number} * @returns {number}
*/ */
function getGridHeightFromWidth(grid: Grid, gridWidth: number): number{ function getGridHeightFromWidth(grid: Grid, gridWidth: number): number {
switch (grid.type) { switch (grid.type) {
case "square": case "square":
return (grid.size.y * gridWidth) / grid.size.x; 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 * @param {number} mapHeight Height of the map in pixels before inset
* @returns {GridInset} * @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 // Max the width of the inset and figure out the resulting height value
const insetHeightNorm = getGridHeightFromWidth(grid, mapWidth) / mapHeight; const insetHeightNorm = getGridHeightFromWidth(grid, mapWidth) / mapHeight;
return { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: insetHeightNorm } }; 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 * @param {number} mapHeight Height of the map in pixels before inset
* @returns {GridInset} * @returns {GridInset}
*/ */
export function getGridUpdatedInset(grid: Required<Grid>, mapWidth: number, mapHeight: number): GridInset { export function getGridUpdatedInset(
grid: Required<Grid>,
mapWidth: number,
mapHeight: number
): GridInset {
let inset = { let inset = {
topLeft: { ...grid.inset.topLeft }, topLeft: { ...grid.inset.topLeft },
bottomRight: { ...grid.inset.bottomRight }, bottomRight: { ...grid.inset.bottomRight },
@ -263,7 +258,10 @@ export function getGridMaxZoom(grid: Grid): number {
* @param {("hexVertical"|"hexHorizontal")} type * @param {("hexVertical"|"hexHorizontal")} type
* @returns {Vector2} * @returns {Vector2}
*/ */
export function hexCubeToOffset(cube: Vector3, type: ("hexVertical"|"hexHorizontal")) { export function hexCubeToOffset(
cube: Vector3,
type: "hexVertical" | "hexHorizontal"
) {
if (type === "hexVertical") { if (type === "hexVertical") {
const x = cube.x + (cube.z + (cube.z & 1)) / 2; const x = cube.x + (cube.z + (cube.z & 1)) / 2;
const y = cube.z; const y = cube.z;
@ -280,7 +278,10 @@ export function hexCubeToOffset(cube: Vector3, type: ("hexVertical"|"hexHorizont
* @param {("hexVertical"|"hexHorizontal")} type * @param {("hexVertical"|"hexHorizontal")} type
* @returns {Vector3} * @returns {Vector3}
*/ */
export function hexOffsetToCube(offset: Vector2, type: ("hexVertical"|"hexHorizontal")) { export function hexOffsetToCube(
offset: Vector2,
type: "hexVertical" | "hexHorizontal"
) {
if (type === "hexVertical") { if (type === "hexVertical") {
const x = offset.x - (offset.y + (offset.y & 1)) / 2; const x = offset.x - (offset.y + (offset.y & 1)) / 2;
const z = offset.y; const z = offset.y;
@ -301,7 +302,12 @@ export function hexOffsetToCube(offset: Vector2, type: ("hexVertical"|"hexHorizo
* @param {Vector2} b * @param {Vector2} b
* @param {Size} cellSize * @param {Size} cellSize
*/ */
export function gridDistance(grid: Required<Grid>, a: Vector2, b: Vector2, cellSize: Size) { export function gridDistance(
grid: Required<Grid>,
a: Vector2,
b: Vector2,
cellSize: Size
) {
// Get grid coordinates // Get grid coordinates
const aCoord = getNearestCellCoordinates(grid, a.x, a.y, cellSize); const aCoord = getNearestCellCoordinates(grid, a.x, a.y, cellSize);
const bCoord = getNearestCellCoordinates(grid, b.x, b.y, cellSize); const bCoord = getNearestCellCoordinates(grid, b.x, b.y, cellSize);
@ -315,7 +321,9 @@ export function gridDistance(grid: Required<Grid>, a: Vector2, b: Vector2, cellS
const min: any = Vector2.min(delta); const min: any = Vector2.min(delta);
return max - min + Math.floor(1.5 * min); return max - min + Math.floor(1.5 * min);
} else if (grid.measurement.type === "euclidean") { } else if (grid.measurement.type === "euclidean") {
return Vector2.magnitude(Vector2.divide(Vector2.subtract(a, b), cellSize)); return Vector2.magnitude(
Vector2.divide(Vector2.subtract(a, b), cellSize)
);
} else if (grid.measurement.type === "manhattan") { } else if (grid.measurement.type === "manhattan") {
return Math.abs(aCoord.x - bCoord.x) + Math.abs(aCoord.y - bCoord.y); return Math.abs(aCoord.x - bCoord.x) + Math.abs(aCoord.y - bCoord.y);
} }
@ -331,24 +339,13 @@ export function gridDistance(grid: Required<Grid>, a: Vector2, b: Vector2, cellS
2 2
); );
} else if (grid.measurement.type === "euclidean") { } 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` * Parse a string representation of scale e.g. 5ft into a `GridScale`
* @param {string} scale * @param {string} scale
@ -441,7 +438,10 @@ export function gridSizeVaild(x: number, y: number): boolean {
* @param {number[]} candidates * @param {number[]} candidates
* @returns {Vector2 | null} * @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 // TODO: check type for Image and CanvasSourceImage
const width: any = image.width; const width: any = image.width;
const height: any = image.height; const height: any = image.height;
@ -474,7 +474,10 @@ function gridSizeHeuristic(image: CanvasImageSource, candidates: number[]): Vect
* @param {number[]} candidates * @param {number[]} candidates
* @returns {Vector2 | null} * @returns {Vector2 | null}
*/ */
async function gridSizeML(image: CanvasImageSource, candidates: number[]): Promise<Vector2 | null> { async function gridSizeML(
image: CanvasImageSource,
candidates: number[]
): Promise<Vector2 | null> {
// TODO: check this function because of context and CanvasImageSource -> JSDoc and Typescript do not match // TODO: check this function because of context and CanvasImageSource -> JSDoc and Typescript do not match
const width: any = image.width; const width: any = image.width;
const height: any = image.height; const height: any = image.height;

View File

@ -2,34 +2,14 @@ import { v4 as uuid } from "uuid";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { keyBy } from "./shared"; import { keyBy } from "./shared";
import { Group, GroupContainer, GroupItem } from "../types/Group";
/**
* @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
*/
/** /**
* Transform an array of group ids to their groups * 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 groupsByIds = keyBy(groups, "id");
const filteredGroups = []; const filteredGroups: Group[] = [];
for (let groupId of groupIds) { for (let groupId of groupIds) {
if (groupId in groupsByIds) { if (groupId in groupsByIds) {
filteredGroups.push(groupsByIds[groupId]); filteredGroups.push(groupsByIds[groupId]);
@ -40,10 +20,8 @@ export function groupsFromIds(groupIds, groups) {
/** /**
* Get all items from a group including all sub 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") { if (group.type === "group") {
let groups = []; let groups = [];
for (let item of group.items) { for (let item of group.items) {
@ -57,14 +35,14 @@ export function getGroupItems(group) {
/** /**
* Transform an array of groups into their assosiated items * 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<Item>(
groups: Group[],
allItems: Item[],
itemKey = "id"
): Item[] {
const allItemsById = keyBy(allItems, itemKey); const allItemsById = keyBy(allItems, itemKey);
const groupedItems = []; const groupedItems: Item[] = [];
for (let group of groups) { for (let group of groups) {
const groupItems = getGroupItems(group); const groupItems = getGroupItems(group);
@ -76,47 +54,52 @@ export function itemsFromGroups(groups, allItems, itemKey = "id") {
} }
/** /**
* Combine two groups * Combine a group and a group item
* @param {Group} a
* @param {Group} b
* @returns {GroupContainer}
*/ */
export function combineGroups(a, b) { export function combineGroups(a: Group, b: Group): GroupContainer {
if (a.type === "item") { switch (a.type) {
return { case "item":
id: uuid(), if (b.type !== "item") {
type: "group", throw new Error("Unable to combine two GroupContainers");
items: [a, b], }
name: "", return {
}; id: uuid(),
} type: "group",
if (a.type === "group") { items: [a, b],
return { name: "",
id: a.id, };
type: "group", case "group":
items: [...a.items, b], if (b.type !== "item") {
name: a.name, 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` * 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 newGroups = cloneDeep(groups);
const intoGroup = newGroups[into]; const intoGroup = newGroups[into];
let fromGroups = []; let fromGroups: Group[] = [];
for (let i of indices) { for (let i of indices) {
fromGroups.push(newGroups[i]); fromGroups.push(newGroups[i]);
} }
let combined = intoGroup; let combined: Group = intoGroup;
for (let fromGroup of fromGroups) { for (let fromGroup of fromGroups) {
combined = combineGroups(combined, fromGroup); combined = combineGroups(combined, fromGroup);
} }
@ -133,12 +116,12 @@ export function moveGroupsInto(groups, into, indices) {
/** /**
* Immutably move group at indices `indices` to index `to` * 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); const newGroups = cloneDeep(groups);
let fromGroups = []; 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 * Move items from a sub group to the start of the base group
* @param {Group[]} groups * @param fromId The id of the group to move from
* @param {string} fromId The id of the group to move from * @param indices The indices of the items in the group
* @param {number[]} 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); 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) { for (let i of indices) {
items.push(newGroups[fromIndex].items[i]); items.push(from.items[i]);
} }
// Remove items from previous group // Remove items from previous group
for (let item of items) { for (let item of items) {
const i = newGroups[fromIndex].items.findIndex((el) => el.id === item.id); const i = from.items.findIndex((el) => el.id === item.id);
newGroups[fromIndex].items.splice(i, 1); from.items.splice(i, 1);
} }
// If we have no more items in the group delete it // 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); newGroups.splice(fromIndex, 1);
} }
@ -193,11 +179,8 @@ export function ungroup(groups, fromId, indices) {
/** /**
* Recursively find a group within a group array * 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) { for (let group of groups) {
if (group.id === groupId) { if (group.id === groupId) {
return group; return group;
@ -213,11 +196,9 @@ export function findGroup(groups, groupId) {
/** /**
* Transform and item array to a record of item ids to item names * 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") { export function getItemNames(items: any[], itemKey: string = "id") {
let names = {}; let names: Record<string, string> = {};
for (let item of items) { for (let item of items) {
names[item[itemKey]] = item.name; names[item[itemKey]] = item.name;
} }
@ -226,15 +207,20 @@ export function getItemNames(items, itemKey = "id") {
/** /**
* Immutably rename a group * 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); let newGroups = cloneDeep(groups);
const groupIndex = newGroups.findIndex((group) => group.id === groupId); 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) { if (groupIndex >= 0) {
newGroups[groupIndex].name = newName; group.name = newName;
} }
return newGroups; return newGroups;
} }
@ -244,7 +230,7 @@ export function renameGroup(groups, groupId, newName) {
* @param {Group[]} groups * @param {Group[]} groups
* @param {string[]} itemIds * @param {string[]} itemIds
*/ */
export function removeGroupsItems(groups, itemIds) { export function removeGroupsItems(groups: Group[], itemIds: string[]): Group[] {
let newGroups = cloneDeep(groups); let newGroups = cloneDeep(groups);
for (let i = newGroups.length - 1; i >= 0; i--) { 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--) { for (let j = items.length - 1; j >= 0; j--) {
const item = items[j]; const item = items[j];
if (itemIds.includes(item.id)) { if (itemIds.includes(item.id)) {
newGroups[i].items.splice(j, 1); group.items.splice(j, 1);
} }
} }
// Remove group if no items are left // Remove group if no items are left
if (newGroups[i].items.length === 0) { if (group.items.length === 0) {
newGroups.splice(i, 1); newGroups.splice(i, 1);
} }
} }

View File

@ -3,13 +3,15 @@ import imageOutline from "image-outline";
import blobToBuffer from "./blobToBuffer"; import blobToBuffer from "./blobToBuffer";
import Vector2 from "./Vector2"; import Vector2 from "./Vector2";
import { Outline } from "../types/Outline";
const lightnessDetectionOffset = 0.1; const lightnessDetectionOffset = 0.1;
/** /**
* @param {HTMLImageElement} image * @param {HTMLImageElement} image
* @returns {boolean} True is the image is light * @returns {boolean} True is the image is light
*/ */
export function getImageLightness(image: HTMLImageElement) { export function getImageLightness(image: HTMLImageElement): boolean {
const width = image.width; const width = image.width;
const height = image.height; const height = image.height;
let canvas = document.createElement("canvas"); let canvas = document.createElement("canvas");
@ -17,8 +19,7 @@ export function getImageLightness(image: HTMLImageElement) {
canvas.height = height; canvas.height = height;
let context = canvas.getContext("2d"); let context = canvas.getContext("2d");
if (!context) { if (!context) {
// TODO: handle if context is null return false;
return;
} }
context.drawImage(image, 0, 0); context.drawImage(image, 0, 0);
@ -45,18 +46,12 @@ export function getImageLightness(image: HTMLImageElement) {
return norm + lightnessDetectionOffset >= 0; 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 = { type CanvasImage = {
blob: Blob | null, file: Uint8Array;
width: number, width: number;
height: number height: number;
} mime: string;
};
/** /**
* @param {HTMLCanvasElement} canvas * @param {HTMLCanvasElement} canvas
@ -64,11 +59,25 @@ type CanvasImage = {
* @param {number} quality * @param {number} quality
* @returns {Promise<CanvasImage>} * @returns {Promise<CanvasImage>}
*/ */
export async function canvasToImage(canvas: HTMLCanvasElement, type: string, quality: number): Promise<CanvasImage> { export async function canvasToImage(
canvas: HTMLCanvasElement,
type: string,
quality: number
): Promise<CanvasImage | undefined> {
return new Promise((resolve) => { return new Promise((resolve) => {
canvas.toBlob( canvas.toBlob(
(blob) => { async (blob) => {
resolve({ blob, width: canvas.width, height: canvas.height }); if (blob) {
const file = await blobToBuffer(blob);
resolve({
file,
width: canvas.width,
height: canvas.height,
mime: type,
});
} else {
resolve(undefined);
}
}, },
type, type,
quality 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 {number} size the size of the longest edge of the new image
* @param {string} type the mime type of the 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 * @param {number} quality if image is a jpeg or webp this is the quality setting
* @returns {Promise<CanvasImage>} * @returns {Promise<CanvasImage | undefined>}
*/ */
export async function resizeImage(image: HTMLImageElement, size: number, type: string, quality: number): Promise<CanvasImage> { export async function resizeImage(
image: HTMLImageElement,
size: number,
type: string,
quality: number
): Promise<CanvasImage | undefined> {
const width = image.width; const width = image.width;
const height = image.height; const height = image.height;
const ratio = width / height; const ratio = width / height;
@ -96,37 +110,27 @@ export async function resizeImage(image: HTMLImageElement, size: number, type: s
canvas.height = size; canvas.height = size;
} }
let context = canvas.getContext("2d"); let context = canvas.getContext("2d");
// TODO: Add error if context is empty
if (context) { if (context) {
context.drawImage(image, 0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height);
} else {
return undefined;
} }
return await canvasToImage(canvas, type, quality); 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 * Create a image file with resolution `size`x`size` with cover cropping
* @param {HTMLImageElement} image the image to resize * @param {HTMLImageElement} image the image to resize
* @param {string} type the mime type of the image * @param {string} type the mime type of the image
* @param {number} size the width and height of the thumbnail * @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 * @param {number} quality if image is a jpeg or webp this is the quality setting
* @returns {Promise<ImageAsset>}
*/ */
export async function createThumbnail(image: HTMLImageElement, type: string, size = 300, quality = 0.5): Promise<ImageFile> { export async function createThumbnail(
image: HTMLImageElement,
type: string,
size = 300,
quality = 0.5
): Promise<CanvasImage | undefined> {
let canvas = document.createElement("canvas"); let canvas = document.createElement("canvas");
canvas.width = size; canvas.width = size;
canvas.height = size; canvas.height = size;
@ -166,55 +170,20 @@ export async function createThumbnail(image: HTMLImageElement, type: string, siz
} }
} }
const thumbnailImage = await canvasToImage(canvas, type, quality); return 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,
};
} }
/**
* @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 * Get the outline of an image
* @param {HTMLImageElement} image * @param {HTMLImageElement} image
* @returns {Outline} * @returns {Outline}
*/ */
export function getImageOutline(image, maxPoints = 100) { export function getImageOutline(
image: HTMLImageElement,
maxPoints: number = 100
): Outline {
// Basic rect outline for fail conditions // Basic rect outline for fail conditions
const defaultOutline = { const defaultOutline: Outline = {
type: "rect", type: "rect",
x: 0, x: 0,
y: 0, y: 0,

View File

@ -10,14 +10,16 @@ import {
} from "./grid"; } from "./grid";
import Vector2 from "./Vector2"; import Vector2 from "./Vector2";
const defaultMapProps = { import { Map, FileMapResolutions, FileMap } from "../types/Map";
showGrid: false, import { Asset } from "../types/Asset";
snapToGrid: true,
quality: "original", type Resolution = {
group: "", size: number;
quality: number;
id: "low" | "medium" | "high" | "ultra";
}; };
const mapResolutions = [ const mapResolutions: Resolution[] = [
{ {
size: 30, // Pixels per grid size: 30, // Pixels per grid
quality: 0.5, // JPEG compression quality quality: 0.5, // JPEG compression quality
@ -33,30 +35,35 @@ const mapResolutions = [
* @param {any} map * @param {any} map
* @returns {undefined|string} * @returns {undefined|string}
*/ */
export function getMapPreviewAsset(map) { export function getMapPreviewAsset(map: Map): string | undefined {
const res = map.resolutions; if (map.type === "file") {
switch (map.quality) { const res = map.resolutions;
case "low": switch (map.quality) {
return; case "low":
case "medium": return;
return res.low; case "medium":
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 res.low;
} case "high":
return; return res.medium;
default: case "ultra":
return; 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(); let image = new Image();
const buffer = await blobToBuffer(file); const buffer = await blobToBuffer(file);
@ -107,10 +114,10 @@ export async function createMapFromFile(file, userId) {
gridSize = { x: 22, y: 22 }; gridSize = { x: 22, y: 22 };
} }
let assets = []; let assets: Asset[] = [];
// Create resolutions // Create resolutions
const resolutions = {}; const resolutions: FileMapResolutions = {};
for (let resolution of mapResolutions) { for (let resolution of mapResolutions) {
const resolutionPixelSize = Vector2.multiply(gridSize, resolution.size); const resolutionPixelSize = Vector2.multiply(gridSize, resolution.size);
if ( if (
@ -119,20 +126,16 @@ export async function createMapFromFile(file, userId) {
) { ) {
const resized = await resizeImage( const resized = await resizeImage(
image, image,
Vector2.max(resolutionPixelSize), Vector2.max(resolutionPixelSize) as number,
file.type, file.type,
resolution.quality resolution.quality
); );
if (resized.blob) { if (resized) {
const assetId = uuid(); const assetId = uuid();
resolutions[resolution.id] = assetId; resolutions[resolution.id] = assetId;
const resizedBuffer = await blobToBuffer(resized.blob);
const asset = { const asset = {
file: resizedBuffer, ...resized,
width: resized.width,
height: resized.height,
id: assetId, id: assetId,
mime: file.type,
owner: userId, owner: userId,
}; };
assets.push(asset); assets.push(asset);
@ -141,12 +144,11 @@ export async function createMapFromFile(file, userId) {
} }
// Create thumbnail // Create thumbnail
const thumbnailImage = await createThumbnail(image, file.type); const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = { const thumbnailId = uuid();
...thumbnailImage, if (thumbnailImage) {
id: uuid(), const thumbnail = { ...thumbnailImage, id: thumbnailId, owner: userId };
owner: userId, assets.push(thumbnail);
}; }
assets.push(thumbnail);
const fileAsset = { const fileAsset = {
id: uuid(), id: uuid(),
@ -158,11 +160,11 @@ export async function createMapFromFile(file, userId) {
}; };
assets.push(fileAsset); assets.push(fileAsset);
const map = { const map: FileMap = {
name, name,
resolutions, resolutions,
file: fileAsset.id, file: fileAsset.id,
thumbnail: thumbnail.id, thumbnail: thumbnailId,
type: "file", type: "file",
grid: { grid: {
size: gridSize, size: gridSize,
@ -183,7 +185,9 @@ export async function createMapFromFile(file, userId) {
created: Date.now(), created: Date.now(),
lastModified: Date.now(), lastModified: Date.now(),
owner: userId, owner: userId,
...defaultMapProps, showGrid: false,
snapToGrid: true,
quality: "original",
}; };
URL.revokeObjectURL(url); URL.revokeObjectURL(url);

View File

@ -1,5 +1,5 @@
export function omit(obj:object, keys: string[]) { export function omit(obj: Record<PropertyKey, any>, keys: string[]) {
let tmp: { [key: string]: any } = {}; let tmp: Record<PropertyKey, any> = {};
for (let [key, value] of Object.entries(obj)) { for (let [key, value] of Object.entries(obj)) {
if (keys.includes(key)) { if (keys.includes(key)) {
continue; continue;
@ -9,18 +9,21 @@ export function omit(obj:object, keys: string[]) {
return tmp; return tmp;
} }
export function fromEntries(iterable: any) { export function fromEntries(iterable: Iterable<[string | number, any]>) {
if (Object.fromEntries) { if (Object.fromEntries) {
return Object.fromEntries(iterable); return Object.fromEntries(iterable);
} }
return [...iterable].reduce((obj, [key, val]) => { return [...iterable].reduce(
obj[key] = val; (obj: Record<string | number, any>, [key, val]) => {
return obj; obj[key] = val;
}, {}); return obj;
},
{}
);
} }
// Check to see if all tracks are muted // 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 }); 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); 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; return Object.keys(obj).length === 0 && obj.constructor === Object;
} }
export function keyBy(array: any, key: any) { export function keyBy<Type>(array: Type[], key: string): Record<string, Type> {
return array.reduce( 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) { export function groupBy(array: Record<PropertyKey, any>[], key: string) {
return array.reduce((prev: any, current: any) => { return array.reduce((prev: Record<string, any[]>, current) => {
const k = current[key]; const k = current[key];
(prev[k] || (prev[k] = [])).push(current); (prev[k] || (prev[k] = [])).push(current);
return prev; 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 const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
export function shuffle(array) { export function shuffle<Type>(array: Type[]) {
let temp = [...array]; let temp = [...array];
var currentIndex = temp.length, var currentIndex = temp.length,
randomIndex; randomIndex;

View File

@ -1,12 +1,22 @@
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import Case from "case"; import Case from "case";
import { Stage } from "konva/types/Stage";
import blobToBuffer from "./blobToBuffer"; import blobToBuffer from "./blobToBuffer";
import { createThumbnail, getImageOutline } from "./image"; import { createThumbnail, getImageOutline } from "./image";
import Vector2 from "./Vector2"; import Vector2 from "./Vector2";
export function createTokenState(token, position, userId) { import { Token, FileToken } from "../types/Token";
let tokenState = { 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(), id: uuid(),
tokenId: token.id, tokenId: token.id,
owner: userId, owner: userId,
@ -21,20 +31,29 @@ export function createTokenState(token, position, userId) {
rotation: 0, rotation: 0,
locked: false, locked: false,
visible: true, visible: true,
type: token.type,
outline: token.outline, outline: token.outline,
width: token.width, width: token.width,
height: token.height, height: token.height,
}; };
if (token.type === "file") { if (token.type === "file") {
tokenState.file = token.file; return {
} else if (token.type === "default") { ...tokenState,
tokenState.key = token.key; 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) { if (!file) {
return Promise.reject(); return Promise.reject();
} }
@ -77,10 +96,13 @@ export async function createTokenFromFile(file, userId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
image.onload = async function () { image.onload = async function () {
let assets = []; let assets: Asset[] = [];
const thumbnailImage = await createThumbnail(image, file.type); const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = { ...thumbnailImage, id: uuid(), owner: userId }; const thumbnailId = uuid();
assets.push(thumbnail); if (thumbnailImage) {
const thumbnail = { ...thumbnailImage, id: thumbnailId, owner: userId };
assets.push(thumbnail);
}
const fileAsset = { const fileAsset = {
id: uuid(), id: uuid(),
@ -94,10 +116,10 @@ export async function createTokenFromFile(file, userId) {
const outline = getImageOutline(image); const outline = getImageOutline(image);
const token = { const token: FileToken = {
name, name,
defaultSize, defaultSize,
thumbnail: thumbnail.id, thumbnail: thumbnailId,
file: fileAsset.id, file: fileAsset.id,
id: uuid(), id: uuid(),
type: "file", type: "file",
@ -107,7 +129,6 @@ export async function createTokenFromFile(file, userId) {
defaultCategory: "character", defaultCategory: "character",
defaultLabel: "", defaultLabel: "",
hideInSidebar: false, hideInSidebar: false,
group: "",
width: image.width, width: image.width,
height: image.height, height: image.height,
outline, outline,
@ -122,12 +143,15 @@ export async function createTokenFromFile(file, userId) {
} }
export function clientPositionToMapPosition( export function clientPositionToMapPosition(
mapStage, mapStage: Stage,
clientPosition, clientPosition: Vector2,
checkMapBounds = true checkMapBounds = true
) { ): Vector2 | undefined {
const mapImage = mapStage.findOne("#mapImage"); const mapImage = mapStage.findOne("#mapImage");
const map = document.querySelector(".map"); const map = document.querySelector(".map");
if (!map) {
return;
}
const mapRect = map.getBoundingClientRect(); const mapRect = map.getBoundingClientRect();
// Check map bounds // Check map bounds
@ -158,7 +182,11 @@ export function clientPositionToMapPosition(
return normalizedPosition; return normalizedPosition;
} }
export function getScaledOutline(tokenState, tokenWidth, tokenHeight) { export function getScaledOutline(
tokenState: TokenState,
tokenWidth: number,
tokenHeight: number
): Outline {
let outline = tokenState.outline; let outline = tokenState.outline;
if (outline.type === "rect") { if (outline.type === "rect") {
return { return {
@ -187,14 +215,23 @@ export function getScaledOutline(tokenState, tokenWidth, tokenHeight) {
} }
export class Intersection { export class Intersection {
outline;
position;
center;
rotation;
points: Vector2[] | undefined;
/** /**
*
* @param {Outline} outline * @param {Outline} outline
* @param {Vector2} position - Top left position of the token * @param {Vector2} position - Top left position of the token
* @param {Vector2} center - Center position of the token * @param {Vector2} center - Center position of the token
* @param {number} rotation - Rotation of the token in degrees * @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.outline = outline;
this.position = position; this.position = position;
this.center = center; this.center = center;
@ -253,8 +290,11 @@ export class Intersection {
* @param {Vector2} point * @param {Vector2} point
* @returns {boolean} * @returns {boolean}
*/ */
intersects(point) { intersects(point: Vector2) {
if (this.outline.type === "rect" || this.outline.type === "path") { if (
this.points &&
(this.outline.type === "rect" || this.outline.type === "path")
) {
return Vector2.pointInPolygon(point, this.points); return Vector2.pointInPolygon(point, this.points);
} else if (this.outline.type === "circle") { } else if (this.outline.type === "circle") {
return Vector2.distance(this.center, point) < this.outline.radius; return Vector2.distance(this.center, point) < this.outline.radius;

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
function useDebounce(value: any, delay: number): any { function useDebounce<Type>(value: Type, delay: number): Type {
const [debouncedValue, setDebouncedValue] = useState(); const [debouncedValue, setDebouncedValue] = useState<Type>(value);
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {

View File

@ -5,7 +5,12 @@ import { useAuth } from "../contexts/AuthContext";
import Modal from "../components/Modal"; 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 { password, setPassword } = useAuth();
const [tmpPassword, setTempPassword] = useState<string>(password); const [tmpPassword, setTempPassword] = useState<string>(password);
@ -19,7 +24,7 @@ function AuthModal({ isOpen, onSubmit }: { isOpen: boolean, onSubmit: (newPassw
onSubmit(tmpPassword); onSubmit(tmpPassword);
} }
const inputRef = useRef<any>(); const inputRef = useRef<HTMLInputElement>(null);
function focusInput(): void { function focusInput(): void {
inputRef.current && inputRef.current?.focus(); inputRef.current && inputRef.current?.focus();
} }

View File

@ -3,22 +3,24 @@ import { Box, Input, Button, Label, Flex } from "theme-ui";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
type ChangeNicknameModalProps = {
isOpen: boolean;
onRequestClose: () => void;
onChangeSubmit: any;
nickname: string;
onChange: any;
};
function ChangeNicknameModal({ function ChangeNicknameModal({
isOpen, isOpen,
onRequestClose, onRequestClose,
onChangeSubmit, onChangeSubmit,
nickname, nickname,
onChange, onChange,
}: { }: ChangeNicknameModalProps) {
isOpen: boolean, const inputRef = useRef<HTMLInputElement>(null);
onRequestClose: () => void,
onChangeSubmit: any,
nickname: string,
onChange: any,
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
function focusInput() { function focusInput() {
inputRef.current && inputRef.current?.focus(); inputRef.current?.focus();
} }
return ( return (

View File

@ -3,13 +3,13 @@ import { Box, Label, Flex, Button, Text } from "theme-ui";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
type ConfirmModalProps = { type ConfirmModalProps = {
isOpen: boolean, isOpen: boolean;
onRequestClose: () => void, onRequestClose: () => void;
onConfirm: () => void, onConfirm: () => void;
confirmText: string, confirmText: string;
label: string, label: string;
description: string, description: string;
} };
function ConfirmModal({ function ConfirmModal({
isOpen, isOpen,
@ -18,7 +18,7 @@ function ConfirmModal({
confirmText, confirmText,
label, label,
description, description,
}: ConfirmModalProps ) { }: ConfirmModalProps) {
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}

View File

@ -1,73 +0,0 @@
import { useEffect, useState } from "react";
import { Box, Button, Label, Flex } from "theme-ui";
import Modal from "../components/Modal";
import Select from "../components/Select";
type EditGroupProps = {
isOpen: boolean,
onRequestClose: () => 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 (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{ content: { overflow: "visible" } }}
>
<Box onSubmit={handleChange} sx={{ width: "300px" }}>
<Label py={2}>Select or add a group</Label>
<Select
creatable
options={options}
value={value}
onChange={setValue}
onCreateOption={handleCreate}
placeholder=""
/>
<Flex py={2}>
<Button sx={{ flexGrow: 1 }} onClick={handleChange}>
Save
</Button>
</Flex>
</Box>
</Modal>
);
}
export default EditGroupModal;

View File

@ -10,7 +10,8 @@ import { isEmpty } from "../helpers/shared";
import { getGridDefaultInset } from "../helpers/grid"; import { getGridDefaultInset } from "../helpers/grid";
import useResponsiveLayout from "../hooks/useResponsiveLayout"; import useResponsiveLayout from "../hooks/useResponsiveLayout";
import { Map, MapState } from "../components/map/Map"; import { Map } from "../types/Map";
import { MapState } from "../types/MapState";
type EditMapProps = { type EditMapProps = {
isOpen: boolean; isOpen: boolean;
@ -145,7 +146,7 @@ function EditMapModal({
style={{ style={{
minHeight: 0, minHeight: 0,
padding: "16px", padding: "16px",
backgroundColor: theme.colors.muted, backgroundColor: theme.colors?.muted as string,
margin: "0 8px", margin: "0 8px",
height: "100%", height: "100%",
}} }}

View File

@ -9,7 +9,7 @@ import TokenPreview from "../components/token/TokenPreview";
import { isEmpty } from "../helpers/shared"; import { isEmpty } from "../helpers/shared";
import useResponsiveLayout from "../hooks/useResponsiveLayout"; import useResponsiveLayout from "../hooks/useResponsiveLayout";
import { Token } from "../tokens"; import { Token } from "../types/Token";
type EditModalProps = { type EditModalProps = {
isOpen: boolean; isOpen: boolean;
@ -98,7 +98,7 @@ function EditTokenModal({
style={{ style={{
minHeight: 0, minHeight: 0,
padding: "16px", padding: "16px",
backgroundColor: theme.colors.muted, backgroundColor: theme.colors?.muted as string,
margin: "0 8px", margin: "0 8px",
height: "100%", height: "100%",
}} }}

View File

@ -2,7 +2,12 @@ import { Box, Label, Flex, Button, Text } from "theme-ui";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
function GameExpiredModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestClose: () => void }) { type GameExpiredModalProps = {
isOpen: boolean;
onRequestClose: () => void;
};
function GameExpiredModal({ isOpen, onRequestClose }: GameExpiredModalProps) {
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}

View File

@ -7,7 +7,15 @@ import Link from "../components/Link";
const gettingStarted = raw("../docs/howTo/gettingStarted.md"); const gettingStarted = raw("../docs/howTo/gettingStarted.md");
function GettingStartedModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestClose: () => void } ) { type GettingStartedModalProps = {
isOpen: boolean;
onRequestClose: () => void;
};
function GettingStartedModal({
isOpen,
onRequestClose,
}: GettingStartedModalProps) {
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}

View File

@ -3,25 +3,37 @@ import { Box, Input, Button, Label, Flex } from "theme-ui";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
function GroupNameModal({ isOpen, name, onSubmit, onRequestClose }) { type GroupNameModalProps = {
isOpen: boolean;
onRequestClose: () => void;
name: string;
onSubmit: (name: string) => void;
};
function GroupNameModal({
isOpen,
name,
onSubmit,
onRequestClose,
}: GroupNameModalProps) {
const [tmpName, setTempName] = useState(name); const [tmpName, setTempName] = useState(name);
useEffect(() => { useEffect(() => {
setTempName(name); setTempName(name);
}, [name]); }, [name]);
function handleChange(event) { function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setTempName(event.target.value); setTempName(event.target.value);
} }
function handleSubmit(event) { function handleSubmit(event: React.FormEvent<HTMLDivElement>) {
event.preventDefault(); event.preventDefault();
onSubmit(tmpName); onSubmit(tmpName);
} }
const inputRef = useRef(); const inputRef = useRef<HTMLInputElement>(null);
function focusInput() { function focusInput() {
inputRef.current && inputRef.current.focus(); inputRef.current?.focus();
} }
return ( return (

View File

@ -14,11 +14,13 @@ import ErrorBanner from "../components/banner/ErrorBanner";
import { useUserId } from "../contexts/UserIdContext"; import { useUserId } from "../contexts/UserIdContext";
import { useDatabase } from "../contexts/DatabaseContext"; import { useDatabase } from "../contexts/DatabaseContext";
import SelectDataModal from "./SelectDataModal"; import SelectDataModal, { SelectData } from "./SelectDataModal";
import { getDatabase } from "../database"; import { getDatabase } from "../database";
import { Map, MapState, TokenState } from "../components/map/Map"; import { Map } from "../types/Map";
import { Token } from "../tokens"; import { MapState } from "../types/MapState";
import { Token } from "../types/Token";
import { Group } from "../types/Group";
const importDBName = "OwlbearRodeoImportDB"; const importDBName = "OwlbearRodeoImportDB";
@ -49,7 +51,11 @@ function ImportExportModal({
const [showExportSelector, setShowExportSelector] = useState(false); const [showExportSelector, setShowExportSelector] = useState(false);
const { addToast } = useToasts(); const { addToast } = useToasts();
function addSuccessToast(message: string, maps: Map[], tokens: Token[]) { function addSuccessToast(
message: string,
maps: SelectData[],
tokens: SelectData[]
) {
const mapText = `${maps.length} map${maps.length > 1 ? "s" : ""}`; const mapText = `${maps.length} map${maps.length > 1 ? "s" : ""}`;
const tokenText = `${tokens.length} token${tokens.length > 1 ? "s" : ""}`; const tokenText = `${tokens.length} token${tokens.length > 1 ? "s" : ""}`;
if (maps.length > 0 && tokens.length > 0) { if (maps.length > 0 && tokens.length > 0) {
@ -145,10 +151,10 @@ function ImportExportModal({
} }
async function handleImportSelectorConfirm( async function handleImportSelectorConfirm(
checkedMaps: Map[], checkedMaps: SelectData[],
checkedTokens: Token[], checkedTokens: SelectData[],
checkedMapGroups: any[], checkedMapGroups: Group[],
checkedTokenGroups: any[] checkedTokenGroups: Group[]
) { ) {
setIsLoading(true); setIsLoading(true);
backgroundTaskRunningRef.current = true; backgroundTaskRunningRef.current = true;
@ -204,16 +210,15 @@ function ImportExportModal({
// Apply new token ids to imported state // Apply new token ids to imported state
for (let tokenState of Object.values(state.tokens)) { for (let tokenState of Object.values(state.tokens)) {
if (tokenState.tokenId in newTokenIds) { if (tokenState.tokenId in newTokenIds) {
state.tokens[tokenState.id].tokenId = tokenState.tokenId = newTokenIds[tokenState.tokenId];
newTokenIds[tokenState.tokenId];
} }
// Change token state file asset id // Change token state file asset id
if (tokenState.type === "file" && tokenState.file in newAssetIds) { if (tokenState.type === "file" && tokenState.file in newAssetIds) {
state.tokens[tokenState.id].file = newAssetIds[tokenState.file]; tokenState.file = newAssetIds[tokenState.file];
} }
// Change token state owner if owned by the user of the map // Change token state owner if owned by the user of the map
if (tokenState.owner === map.owner) { if (tokenState.owner === map.owner && userId) {
state.tokens[tokenState.id].owner = userId; tokenState.owner = userId;
} }
} }
// Generate new ids // Generate new ids
@ -368,8 +373,8 @@ function ImportExportModal({
} }
async function handleExportSelectorConfirm( async function handleExportSelectorConfirm(
checkedMaps: Map[], checkedMaps: SelectData[],
checkedTokens: TokenState[] checkedTokens: SelectData[]
) { ) {
setShowExportSelector(false); setShowExportSelector(false);
setIsLoading(true); setIsLoading(true);

View File

@ -4,7 +4,12 @@ import { useHistory } from "react-router-dom";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
function JoinModal({ isOpen, onRequestClose }: any) { type JoinModalProps = {
isOpen: boolean;
onRequestClose: () => void;
};
function JoinModal({ isOpen, onRequestClose }: JoinModalProps) {
let history = useHistory(); let history = useHistory();
const [gameId, setGameId] = useState(""); const [gameId, setGameId] = useState("");
@ -17,9 +22,9 @@ function JoinModal({ isOpen, onRequestClose }: any) {
history.push(`/game/${gameId}`); history.push(`/game/${gameId}`);
} }
const inputRef = useRef<any>(); const inputRef = useRef<HTMLInputElement>(null);
function focusInput() { function focusInput() {
inputRef.current && inputRef.current.focus(); inputRef.current?.focus();
} }
return ( return (
@ -27,7 +32,6 @@ function JoinModal({ isOpen, onRequestClose }: any) {
isOpen={isOpen} isOpen={isOpen}
onRequestClose={onRequestClose} onRequestClose={onRequestClose}
onAfterOpen={focusInput} onAfterOpen={focusInput}
> >
<Flex <Flex
sx={{ sx={{

View File

@ -8,23 +8,35 @@ import Divider from "../components/Divider";
import { getDatabase } from "../database"; import { getDatabase } from "../database";
import { Map } from "../types/Map";
import { Group, GroupContainer } from "../types/Group";
import { MapState } from "../types/MapState";
import { Token } from "../types/Token";
type SelectDataProps = { type SelectDataProps = {
isOpen: boolean; isOpen: boolean;
onRequestClose: () => void; onRequestClose: () => void;
onConfirm: any; onConfirm: (
checkedMaps: SelectData[],
checkedTokens: SelectData[],
checkedMapGroups: Group[],
checkedTokenGroups: Group[]
) => void;
confirmText: string; confirmText: string;
label: string; label: string;
databaseName: string; databaseName: string;
filter: any; filter: (table: string, data: Map | MapState | Token, id: string) => boolean;
}; };
type SelectData = { export type SelectData = {
name: string; name: string;
id: string; id: string;
type: "default" | "file"; type: "default" | "file";
checked: boolean; checked: boolean;
}; };
type DataRecord = Record<string, SelectData>;
function SelectDataModal({ function SelectDataModal({
isOpen, isOpen,
onRequestClose, onRequestClose,
@ -34,13 +46,13 @@ function SelectDataModal({
databaseName, databaseName,
filter, filter,
}: SelectDataProps) { }: SelectDataProps) {
const [maps, setMaps] = useState<Record<string, SelectData>>({}); const [maps, setMaps] = useState<DataRecord>({});
const [mapGroups, setMapGroups] = useState<any[]>([]); const [mapGroups, setMapGroups] = useState<Group[]>([]);
const [tokensByMap, setTokensByMap] = useState<Record<string, Set<string>>>( const [tokensByMap, setTokensByMap] = useState<Record<string, Set<string>>>(
{} {}
); );
const [tokens, setTokens] = useState<Record<string, SelectData>>({}); const [tokens, setTokens] = useState<DataRecord>({});
const [tokenGroups, setTokenGroups] = useState<any[]>([]); const [tokenGroups, setTokenGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const hasMaps = Object.values(maps).length > 0; const hasMaps = Object.values(maps).length > 0;
@ -51,13 +63,13 @@ function SelectDataModal({
if (isOpen && databaseName) { if (isOpen && databaseName) {
setIsLoading(true); setIsLoading(true);
const db = getDatabase({ addons: [] }, databaseName); const db = getDatabase({ addons: [] }, databaseName);
let loadedMaps: Record<string, SelectData> = {}; let loadedMaps: DataRecord = {};
let loadedTokensByMap: Record<string, Set<string>> = {}; let loadedTokensByMap: Record<string, Set<string>> = {};
let loadedTokens: Record<string, SelectData> = {}; let loadedTokens: DataRecord = {};
await db await db
.table("maps") .table("maps")
.filter((map) => filter("maps", map, map.id)) .filter((map: Map) => filter("maps", map, map.id))
.each((map) => { .each((map: Map) => {
loadedMaps[map.id] = { loadedMaps[map.id] = {
name: map.name, name: map.name,
id: map.id, id: map.id,
@ -67,19 +79,19 @@ function SelectDataModal({
}); });
await db await db
.table("states") .table("states")
.filter((state) => filter("states", state, state.mapId)) .filter((state: MapState) => filter("states", state, state.mapId))
.each((state) => { .each((state: MapState) => {
loadedTokensByMap[state.mapId] = new Set( loadedTokensByMap[state.mapId] = new Set(
Object.values(state.tokens).map( Object.values(state.tokens).map(
(tokenState: any) => tokenState.tokenId (tokenState) => tokenState.tokenId
) )
); );
}); });
await db await db
.table("tokens") .table("tokens")
.filter((token) => filter("tokens", token, token.id)) .filter((token: Token) => filter("tokens", token, token.id))
.each((token) => { .each((token: Token) => {
loadedTokens[token.id] = { loadedTokens[token.id] = {
name: token.name, name: token.name,
id: token.id, id: token.id,
@ -110,9 +122,11 @@ function SelectDataModal({
}, [isOpen, databaseName, filter]); }, [isOpen, databaseName, filter]);
// An object mapping a tokenId to how many checked maps it is currently used in // An object mapping a tokenId to how many checked maps it is currently used in
const [tokenUsedCount, setTokenUsedCount] = useState<any>({}); const [tokenUsedCount, setTokenUsedCount] = useState<Record<string, number>>(
{}
);
useEffect(() => { useEffect(() => {
let tokensUsed: any = {}; let tokensUsed: Record<string, number> = {};
for (let mapId in maps) { for (let mapId in maps) {
if (maps[mapId].checked && mapId in tokensByMap) { if (maps[mapId].checked && mapId in tokensByMap) {
for (let tokenId of tokensByMap[mapId]) { for (let tokenId of tokensByMap[mapId]) {
@ -126,7 +140,7 @@ function SelectDataModal({
} }
setTokenUsedCount(tokensUsed); setTokenUsedCount(tokensUsed);
// Update tokens to ensure used tokens are checked // Update tokens to ensure used tokens are checked
setTokens((prevTokens: any) => { setTokens((prevTokens) => {
let newTokens = { ...prevTokens }; let newTokens = { ...prevTokens };
for (let id in newTokens) { for (let id in newTokens) {
if (id in tokensUsed && newTokens[id].type !== "default") { if (id in tokensUsed && newTokens[id].type !== "default") {
@ -137,7 +151,7 @@ function SelectDataModal({
}); });
}, [maps, tokensByMap]); }, [maps, tokensByMap]);
function getCheckedGroups(groups: any[], data: Record<string, SelectData>) { function getCheckedGroups(groups: Group[], data: DataRecord) {
let checkedGroups = []; let checkedGroups = [];
for (let group of groups) { for (let group of groups) {
if (group.type === "item") { if (group.type === "item") {
@ -181,7 +195,7 @@ function SelectDataModal({
}); });
// If all token select is unchecked then ensure all tokens are unchecked // If all token select is unchecked then ensure all tokens are unchecked
if (!event.target.checked && !tokensSelectChecked) { if (!event.target.checked && !tokensSelectChecked) {
setTokens((prevTokens: any) => { setTokens((prevTokens) => {
let newTokens = { ...prevTokens }; let newTokens = { ...prevTokens };
let tempUsedCount = { ...tokenUsedCount }; let tempUsedCount = { ...tokenUsedCount };
for (let id in newTokens) { for (let id in newTokens) {
@ -219,17 +233,16 @@ function SelectDataModal({
// Some tokens are checked not by maps or all tokens are checked by maps // Some tokens are checked not by maps or all tokens are checked by maps
const tokensSelectChecked = const tokensSelectChecked =
Object.values(tokens).some( Object.values(tokens).some(
(token: any) => !(token.id in tokenUsedCount) && token.checked (token) => !(token.id in tokenUsedCount) && token.checked
) || ) || Object.values(tokens).every((token) => token.id in tokenUsedCount);
Object.values(tokens).every((token: any) => token.id in tokenUsedCount);
function renderGroupContainer( function renderGroupContainer(
group: any, group: GroupContainer,
checked: boolean, checked: boolean,
renderItem: (group: any) => React.ReactNode, renderItem: (group: Group) => React.ReactNode,
onGroupChange: ( onGroupChange: (
event: React.ChangeEvent<HTMLInputElement>, event: React.ChangeEvent<HTMLInputElement>,
group: any group: GroupContainer
) => void ) => void
) { ) {
return ( return (
@ -270,7 +283,7 @@ function SelectDataModal({
); );
} }
function renderMapGroup(group: any) { function renderMapGroup(group: Group) {
if (group.type === "item") { if (group.type === "item") {
const map = maps[group.id]; const map = maps[group.id];
if (map) { if (map) {
@ -287,24 +300,24 @@ function SelectDataModal({
); );
} }
} else { } else {
if (group.items.some((item: any) => item.id in maps)) { if (group.items.some((item) => item.id in maps)) {
return renderGroupContainer( return renderGroupContainer(
group, group,
group.items.some((item: any) => maps[item.id]?.checked), group.items.some((item) => maps[item.id]?.checked),
renderMapGroup, renderMapGroup,
(e, group) => (e, group) =>
handleMapsChanged( handleMapsChanged(
e, e,
group.items group.items
.filter((group: any) => group.id in maps) .filter((group) => group.id in maps)
.map((group: any) => maps[group.id]) .map((group) => maps[group.id])
) )
); );
} }
} }
} }
function renderTokenGroup(group: any) { function renderTokenGroup(group: Group) {
if (group.type === "item") { if (group.type === "item") {
const token = tokens[group.id]; const token = tokens[group.id];
if (token) { if (token) {
@ -332,12 +345,11 @@ function SelectDataModal({
); );
} }
} else { } else {
if (group.items.some((item: any) => item.id in tokens)) { if (group.items.some((item) => item.id in tokens)) {
const checked = const checked =
group.items.some( group.items.some(
(item: any) => (item) => !(item.id in tokenUsedCount) && tokens[item.id]?.checked
!(item.id in tokenUsedCount) && tokens[item.id]?.checked ) || group.items.every((item) => item.id in tokenUsedCount);
) || group.items.every((item: any) => item.id in tokenUsedCount);
return renderGroupContainer( return renderGroupContainer(
group, group,
checked, checked,
@ -346,8 +358,8 @@ function SelectDataModal({
handleTokensChanged( handleTokensChanged(
e, e,
group.items group.items
.filter((group: any) => group.id in tokens) .filter((group) => group.id in tokens)
.map((group: any) => tokens[group.id]) .map((group) => tokens[group.id])
) )
); );
} }
@ -423,8 +435,8 @@ function SelectDataModal({
</Button> </Button>
<Button <Button
disabled={ disabled={
!Object.values(maps).some((map: any) => map.checked) && !Object.values(maps).some((map) => map.checked) &&
!Object.values(tokens).some((token: any) => token.checked) !Object.values(tokens).some((token) => token.checked)
} }
sx={{ flexGrow: 1 }} sx={{ flexGrow: 1 }}
m={1} m={1}

View File

@ -7,16 +7,22 @@ import DiceTiles from "../components/dice/DiceTiles";
import { dice } from "../dice"; import { dice } from "../dice";
import useResponsiveLayout from "../hooks/useResponsiveLayout"; import useResponsiveLayout from "../hooks/useResponsiveLayout";
import Dice from "../dice/Dice";
import { DefaultDice } from "../types/Dice";
type SelectDiceProps = { type SelectDiceProps = {
isOpen: boolean, isOpen: boolean;
onRequestClose: () => void, onRequestClose: () => void;
onDone: any, onDone: (dice: DefaultDice) => void;
defaultDice: Dice defaultDice: DefaultDice;
} };
function SelectDiceModal({ isOpen, onRequestClose, onDone, defaultDice }: SelectDiceProps) { function SelectDiceModal({
isOpen,
onRequestClose,
onDone,
defaultDice,
}: SelectDiceProps) {
const [selectedDice, setSelectedDice] = useState(defaultDice); const [selectedDice, setSelectedDice] = useState(defaultDice);
const layout = useResponsiveLayout(); const layout = useResponsiveLayout();
@ -24,7 +30,9 @@ function SelectDiceModal({ isOpen, onRequestClose, onDone, defaultDice }: Select
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onRequestClose={onRequestClose} onRequestClose={onRequestClose}
style={{ content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" } }} style={{
content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" },
}}
> >
<Flex <Flex
sx={{ sx={{

View File

@ -140,11 +140,13 @@ function SelectMapModal({
} }
async function handleImageUpload(file: File) { async function handleImageUpload(file: File) {
setIsLoading(true); if (userId) {
const { map, assets } = await createMapFromFile(file, userId); setIsLoading(true);
await addMap(map); const { map, assets } = await createMapFromFile(file, userId);
await addAssets(assets); await addMap(map);
setIsLoading(false); await addAssets(assets);
setIsLoading(false);
}
} }
/** /**
@ -183,7 +185,7 @@ function SelectMapModal({
if (groupIds.length === 1) { if (groupIds.length === 1) {
// Only allow adding a map from dragging if there is a single group item selected // Only allow adding a map from dragging if there is a single group item selected
const group = findGroup(mapGroups, groupIds[0]); const group = findGroup(mapGroups, groupIds[0]);
setCanAddDraggedMap(group && group.type === "item"); setCanAddDraggedMap(group !== undefined && group.type === "item");
} else { } else {
setCanAddDraggedMap(false); setCanAddDraggedMap(false);
} }
@ -198,8 +200,10 @@ function SelectMapModal({
const layout = useResponsiveLayout(); const layout = useResponsiveLayout();
const [modalSize, setModalSize] = useState({ width: 0, height: 0 }); const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
function handleModalResize(width: number, height: number) { function handleModalResize(width?: number, height?: number) {
setModalSize({ width, height }); if (width && height) {
setModalSize({ width, height });
}
} }
const editingMap = const editingMap =
@ -249,7 +253,7 @@ function SelectMapModal({
<TileActionBar onAdd={openImageDialog} addTitle="Import Map(s)" /> <TileActionBar onAdd={openImageDialog} addTitle="Import Map(s)" />
<Box sx={{ position: "relative" }}> <Box sx={{ position: "relative" }}>
<TileDragProvider <TileDragProvider
onDragAdd={canAddDraggedMap && handleDragAdd} onDragAdd={(canAddDraggedMap && handleDragAdd) || undefined}
onDragStart={() => setIsDraggingMap(true)} onDragStart={() => setIsDraggingMap(true)}
onDragEnd={() => setIsDraggingMap(false)} onDragEnd={() => setIsDraggingMap(false)}
onDragCancel={() => setIsDraggingMap(false)} onDragCancel={() => setIsDraggingMap(false)}
@ -263,7 +267,7 @@ function SelectMapModal({
</TilesContainer> </TilesContainer>
</TileDragProvider> </TileDragProvider>
<TileDragProvider <TileDragProvider
onDragAdd={canAddDraggedMap && handleDragAdd} onDragAdd={(canAddDraggedMap && handleDragAdd) || undefined}
onDragStart={() => setIsDraggingMap(true)} onDragStart={() => setIsDraggingMap(true)}
onDragEnd={() => setIsDraggingMap(false)} onDragEnd={() => setIsDraggingMap(false)}
onDragCancel={() => setIsDraggingMap(false)} onDragCancel={() => setIsDraggingMap(false)}

View File

@ -35,7 +35,7 @@ import { GroupProvider } from "../contexts/GroupContext";
import { TileDragProvider } from "../contexts/TileDragContext"; import { TileDragProvider } from "../contexts/TileDragContext";
import { useMapStage } from "../contexts/MapStageContext"; import { useMapStage } from "../contexts/MapStageContext";
import { TokenState } from "../components/map/Map"; import { TokenState } from "../types/TokenState";
type SelectTokensModalProps = { type SelectTokensModalProps = {
isOpen: boolean; isOpen: boolean;
@ -146,6 +146,9 @@ function SelectTokensModal({
} }
async function handleImageUpload(file: File) { async function handleImageUpload(file: File) {
if (!userId) {
return;
}
setIsLoading(true); setIsLoading(true);
const { token, assets } = await createTokenFromFile(file, userId); const { token, assets } = await createTokenFromFile(file, userId);
await addToken(token); await addToken(token);
@ -161,7 +164,7 @@ function SelectTokensModal({
const [isDraggingToken, setIsDraggingToken] = useState(false); const [isDraggingToken, setIsDraggingToken] = useState(false);
const mapStageRef = useMapStage(); const mapStageRef = useMapStage();
function handleTokensAddToMap(groupIds: string[], rect: any) { function handleTokensAddToMap(groupIds: string[], rect: DOMRect) {
let clientPosition = new Vector2( let clientPosition = new Vector2(
rect.width / 2 + rect.left, rect.width / 2 + rect.left,
rect.height / 2 + rect.top rect.height / 2 + rect.top
@ -172,11 +175,11 @@ function SelectTokensModal({
} }
let position = clientPositionToMapPosition(mapStage, clientPosition, false); let position = clientPositionToMapPosition(mapStage, clientPosition, false);
if (!position) { if (!position || !userId) {
return; return;
} }
let newTokenStates = []; let newTokenStates: TokenState[] = [];
for (let id of groupIds) { for (let id of groupIds) {
if (id in tokensById) { if (id in tokensById) {
@ -210,8 +213,10 @@ function SelectTokensModal({
const layout = useResponsiveLayout(); const layout = useResponsiveLayout();
const [modalSize, setModalSize] = useState({ width: 0, height: 0 }); const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
function handleModalResize(width: number, height: number) { function handleModalResize(width?: number, height?: number) {
setModalSize({ width, height }); if (width && height) {
setModalSize({ width, height });
}
} }
const editingToken = const editingToken =

View File

@ -32,7 +32,6 @@ import undead from "./Undead.png";
import warlock from "./Warlock.png"; import warlock from "./Warlock.png";
import wizard from "./Wizard.png"; import wizard from "./Wizard.png";
import unknown from "./Unknown.png"; import unknown from "./Unknown.png";
import { ImageFile } from "../helpers/image";
export const tokenSources = { export const tokenSources = {
barbarian, barbarian,
@ -81,36 +80,6 @@ function getDefaultTokenSize(key: string) {
} }
} }
type TokenCategory = "character" | "vehicle" | "prop"
export type BaseToken = {
id: string,
name: string,
defaultSize: number,
defaultCategory: TokenCategory,
defaultLabel: string,
hideInSidebar: boolean,
width: number,
height: number,
owner: string,
created: number,
lastModified: number,
lastUsed: number,
}
export interface DefaultToken extends BaseToken {
key: string,
type: "default",
}
export interface FileToken extends BaseToken {
file: Uint8Array,
thumbnail: ImageFile,
type: "file",
}
export type Token = DefaultToken | FileToken;
export function getDefaultTokens(userId: string) { export function getDefaultTokens(userId: string) {
const tokenKeys = Object.keys(tokenSources); const tokenKeys = Object.keys(tokenSources);
let tokens = []; let tokens = [];

View File

@ -1,16 +1,18 @@
import { InstancedMesh } from "@babylonjs/core"; import { InstancedMesh } from "@babylonjs/core";
import Dice from "../dice/Dice";
export type DiceType = "d4" | "d6" | "d8" | "d10" | "d12" | "d20"; export type DiceType = "d4" | "d6" | "d8" | "d10" | "d12" | "d20" | "d100";
export type DiceRoll = { export type DiceRoll = {
type: DiceType; type: DiceType;
roll: number | "unknown"; roll: number | "unknown";
}; };
export type Dice = { export type DiceMesh = {
type: DiceType; type: DiceType;
instance: InstancedMesh; instance: InstancedMesh;
asleep: boolean; asleep: boolean;
sleepTimeout?: NodeJS.Timeout;
d10Instance?: InstancedMesh; d10Instance?: InstancedMesh;
}; };
@ -18,3 +20,10 @@ export type DiceState = {
share: boolean; share: boolean;
rolls: DiceRoll[]; rolls: DiceRoll[];
}; };
export type DefaultDice = {
key: string;
name: string;
class: typeof Dice;
preview: string;
};

View File

@ -32,6 +32,8 @@ export type CircleData = {
radius: number; radius: number;
}; };
export type ShapeData = PointsData | RectData | CircleData;
export type BaseDrawing = { export type BaseDrawing = {
blend: boolean; blend: boolean;
color: string; color: string;
@ -43,6 +45,8 @@ export type BaseShape = BaseDrawing & {
type: "shape"; type: "shape";
}; };
export type ShapeType = "line" | "rectangle" | "circle" | "triangle";
export type Line = BaseShape & { export type Line = BaseShape & {
shapeType: "line"; shapeType: "line";
data: PointsData; data: PointsData;

View File

@ -1,7 +1,9 @@
import Vector2 from "../helpers/Vector2"; import Vector2 from "../helpers/Vector2";
export type GridInset = { export type GridInset = {
/** Top left position of the inset */
topLeft: Vector2; topLeft: Vector2;
/** Bottom right position of the inset */
bottomRight: Vector2; bottomRight: Vector2;
}; };
@ -19,8 +21,19 @@ export type GridMeasurement = {
export type GridType = "square" | "hexVertical" | "hexHorizontal"; export type GridType = "square" | "hexVertical" | "hexHorizontal";
export type Grid = { export type Grid = {
/** The inset of the grid from the map */
inset: GridInset; inset: GridInset;
/** The number of columns and rows of the grid as `x` and `y` */
size: Vector2; size: Vector2;
type: GridType; type: GridType;
measurement: GridMeasurement; measurement: GridMeasurement;
}; };
export type GridScale = {
/** The number multiplier of the scale */
multiplier: number;
/** The unit of the scale */
unit: string;
/** The precision of the scale */
digits: number;
};

View File

@ -31,6 +31,7 @@ export type FileMap = BaseMap & {
file: string; file: string;
resolutions: FileMapResolutions; resolutions: FileMapResolutions;
thumbnail: string; thumbnail: string;
quality: "low" | "medium" | "high" | "ultra" | "original";
}; };
export type Map = DefaultMap | FileMap; export type Map = DefaultMap | FileMap;

14
src/types/external/image.outline.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
declare module "image-outline" {
type ImageOutlineOptions = {
opacityThreshold?: number;
simplifyThreshold?: number;
pixelFn?: "opaque" | "not-white" | "not-black";
};
declare function imageOutline(
imageElement: HTMLImageElement,
options: ImageOutlineOptions
): { x: number; y: number }[];
export default imageOutline;
}

View File

@ -24,7 +24,11 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"incremental": true, "incremental": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"typeRoots": [
"src/types/external",
"node_modules/@types"
]
}, },
"include": [ "include": [
"src/**/*" "src/**/*"

171
yarn.lock
View File

@ -1803,24 +1803,6 @@
resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18" resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18"
integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg== integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==
"@emotion/babel-plugin@^11.3.0":
version "11.3.0"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.3.0.tgz#3a16850ba04d8d9651f07f3fb674b3436a4fb9d7"
integrity sha512-UZKwBV2rADuhRp+ZOGgNWg2eYgbzKzQXfQPtJbu/PLy8onurxlNCLvxMQEvlr1/GudguPI5IU9qIY1+2z1M5bA==
dependencies:
"@babel/helper-module-imports" "^7.12.13"
"@babel/plugin-syntax-jsx" "^7.12.13"
"@babel/runtime" "^7.13.10"
"@emotion/hash" "^0.8.0"
"@emotion/memoize" "^0.7.5"
"@emotion/serialize" "^1.0.2"
babel-plugin-macros "^2.6.1"
convert-source-map "^1.5.0"
escape-string-regexp "^4.0.0"
find-root "^1.1.0"
source-map "^0.5.7"
stylis "^4.0.3"
"@dnd-kit/accessibility@^3.0.0": "@dnd-kit/accessibility@^3.0.0":
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz#b56e3750414fd907b7d6972b3116aa8f96d07fde" resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz#b56e3750414fd907b7d6972b3116aa8f96d07fde"
@ -1852,6 +1834,24 @@
dependencies: dependencies:
tslib "^2.0.0" tslib "^2.0.0"
"@emotion/babel-plugin@^11.3.0":
version "11.3.0"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.3.0.tgz#3a16850ba04d8d9651f07f3fb674b3436a4fb9d7"
integrity sha512-UZKwBV2rADuhRp+ZOGgNWg2eYgbzKzQXfQPtJbu/PLy8onurxlNCLvxMQEvlr1/GudguPI5IU9qIY1+2z1M5bA==
dependencies:
"@babel/helper-module-imports" "^7.12.13"
"@babel/plugin-syntax-jsx" "^7.12.13"
"@babel/runtime" "^7.13.10"
"@emotion/hash" "^0.8.0"
"@emotion/memoize" "^0.7.5"
"@emotion/serialize" "^1.0.2"
babel-plugin-macros "^2.6.1"
convert-source-map "^1.5.0"
escape-string-regexp "^4.0.0"
find-root "^1.1.0"
source-map "^0.5.7"
stylis "^4.0.3"
"@emotion/cache@^10.0.27": "@emotion/cache@^10.0.27":
version "10.0.29" version "10.0.29"
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0" resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0"
@ -2748,76 +2748,76 @@
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
"@theme-ui/color-modes@0.8.4": "@theme-ui/color-modes@0.10.0":
version "0.8.4" version "0.10.0"
resolved "https://registry.yarnpkg.com/@theme-ui/color-modes/-/color-modes-0.8.4.tgz#d09bff439e990bc8751677b1d06adf73534a5571" resolved "https://registry.yarnpkg.com/@theme-ui/color-modes/-/color-modes-0.10.0.tgz#85071f16d7d4458f3dab5a7af8b9ea459da4dcd0"
integrity sha512-3Ueb6yKBFkyHsLEFlLH3Igl6ZHbVamJHn6YoAwIut0QQrAOAfTSG1vZr/4LJUCILc/U0y9kPvTw7MBpUKi1hWg== integrity sha512-6sZaagCFK48p2YjecLljFwPkiB3/R9dMNKUQC3+fnaH3N9FcsflNWpjKAYhtQ5QLKvYacFdqczT4YaMtGwKb/Q==
dependencies: dependencies:
"@emotion/react" "^11.1.1" "@emotion/react" "^11.1.1"
"@theme-ui/core" "0.8.4" "@theme-ui/core" "0.10.0"
"@theme-ui/css" "0.8.4" "@theme-ui/css" "0.10.0"
deepmerge "^4.2.2" deepmerge "^4.2.2"
"@theme-ui/components@0.8.4": "@theme-ui/components@0.10.0":
version "0.8.4" version "0.10.0"
resolved "https://registry.yarnpkg.com/@theme-ui/components/-/components-0.8.4.tgz#a362d0625f6a6efacc35a2db29fb6c2368257fd2" resolved "https://registry.yarnpkg.com/@theme-ui/components/-/components-0.10.0.tgz#c1aa9cade71e5a6cf7c19f9e0ade900122ef23f9"
integrity sha512-JOP/rABNS2Bu/hWA68Tdt6pUyCtCU+nMMWZAyvj2qDIn2mBrLwBKvvxyrwaGT5tHniIX4oVG57GH1Sb94Rw+mg== integrity sha512-zPA+16fP+R140kns+3FBhybsPzNjcCWHgXcwIPjww1dfDnlXRa7al9Nz4Y8zyWvk1wNiGqUa09Y1sabK6EYspQ==
dependencies: dependencies:
"@emotion/react" "^11.1.1" "@emotion/react" "^11.1.1"
"@emotion/styled" "^11.0.0" "@emotion/styled" "^11.0.0"
"@styled-system/color" "^5.1.2" "@styled-system/color" "^5.1.2"
"@styled-system/should-forward-prop" "^5.1.2" "@styled-system/should-forward-prop" "^5.1.2"
"@styled-system/space" "^5.1.2" "@styled-system/space" "^5.1.2"
"@theme-ui/css" "0.8.4" "@theme-ui/css" "0.10.0"
"@types/styled-system" "^5.1.10" "@types/styled-system" "^5.1.10"
"@theme-ui/core@0.8.4": "@theme-ui/core@0.10.0":
version "0.8.4" version "0.10.0"
resolved "https://registry.yarnpkg.com/@theme-ui/core/-/core-0.8.4.tgz#64ce2db0b2d50768cb8726e61f9d391cabae0448" resolved "https://registry.yarnpkg.com/@theme-ui/core/-/core-0.10.0.tgz#2373d53368da5fa561915414ade070a9de0e9678"
integrity sha512-zHOLJ/Zw024SlQEl7+mpIk2wuNaWRFCr9brYtV+2kyLwQeETIIBXEdmQ6yJ6wc1nSJNs0VOHk/sLRPvreb+5uQ== integrity sha512-3DeTHGqyqIi05JCsJ+G+fqW6YsX/oGJiaAvMgLfd86tGdJOnDseEBQG41oDFHSWtQSJDpBcoFgAWMGITmYdH+g==
dependencies: dependencies:
"@emotion/react" "^11.1.1" "@emotion/react" "^11.1.1"
"@theme-ui/css" "0.8.4" "@theme-ui/css" "0.10.0"
"@theme-ui/parse-props" "0.8.4" "@theme-ui/parse-props" "0.10.0"
deepmerge "^4.2.2" deepmerge "^4.2.2"
"@theme-ui/css@0.8.4": "@theme-ui/css@0.10.0":
version "0.8.4" version "0.10.0"
resolved "https://registry.yarnpkg.com/@theme-ui/css/-/css-0.8.4.tgz#2539c8ccb52054d54593786e5f1e89f118e908c0" resolved "https://registry.yarnpkg.com/@theme-ui/css/-/css-0.10.0.tgz#871f63334fb138491406c32f8347d53b19846a9b"
integrity sha512-ZubYp4glaDpsJSd2z38FlwkItvXk8+t0i0323ZWNO7Liqg4t/hJT+7RmtWj1NQ2IPVqTUEmsH/hVdz5SIPu2LA== integrity sha512-Up3HqXoy2ERn/9gVxApCSl2n9vwtHBwPrYlMyEjX0YPs/rxmo+Aqe3kAxO+SG9idMw08mtdaDfMIFaPsBE5ovA==
dependencies: dependencies:
"@emotion/react" "^11.1.1" "@emotion/react" "^11.1.1"
csstype "^3.0.5" csstype "^3.0.5"
"@theme-ui/mdx@0.8.4": "@theme-ui/mdx@0.10.0":
version "0.8.4" version "0.10.0"
resolved "https://registry.yarnpkg.com/@theme-ui/mdx/-/mdx-0.8.4.tgz#86bee495402216f65bd3244303f2248d91ca90d0" resolved "https://registry.yarnpkg.com/@theme-ui/mdx/-/mdx-0.10.0.tgz#14124cb194df8023f7253391900d8d17a6011a92"
integrity sha512-pI2XalkIV+Ky2q/y+RBuHi9fBxzCECXZDSMYH98FExO3C6X5sdPDJ1u9kDKgbqJxUxTBQlZKSvU+fG9hjN3oQQ== integrity sha512-IcDrQONVrOFQFCFdyrlNoTTKmhw7ELtrLktRYmmWtCEz+KHpBiEVdxNo2yvz/05zF2BPGKOqu4wkMpUR13wNSQ==
dependencies: dependencies:
"@emotion/react" "^11.1.1" "@emotion/react" "^11.1.1"
"@emotion/styled" "^11.0.0" "@emotion/styled" "^11.0.0"
"@mdx-js/react" "^1.6.22" "@mdx-js/react" "^1.6.22"
"@theme-ui/core" "0.8.4" "@theme-ui/core" "0.10.0"
"@theme-ui/css" "0.8.4" "@theme-ui/css" "0.10.0"
"@theme-ui/parse-props@0.8.4": "@theme-ui/parse-props@0.10.0":
version "0.8.4" version "0.10.0"
resolved "https://registry.yarnpkg.com/@theme-ui/parse-props/-/parse-props-0.8.4.tgz#398e54e11768248938c2c505c04bc44042b66409" resolved "https://registry.yarnpkg.com/@theme-ui/parse-props/-/parse-props-0.10.0.tgz#8d2f4f3b3edafd925c3872ddd559e2b62006f43f"
integrity sha512-2CgPKsApLVRu9wYzaaC/+ZhmKwohQ5uZylbf0HzVA3X4rGIfs7aktsM16FvCeiTyAn+7Z8MTShgSOSsD0S8l3Q== integrity sha512-UfcLyThXYsB9azc8qbsZVgbF7xf+GLF2Hhy+suyjwQ3XSVOx97B5ZsuzCNUGbggtBw4dXayJgRmMz0FHyp0L8Q==
dependencies: dependencies:
"@emotion/react" "^11.1.1" "@emotion/react" "^11.1.1"
"@theme-ui/css" "0.8.4" "@theme-ui/css" "0.10.0"
"@theme-ui/theme-provider@0.8.4": "@theme-ui/theme-provider@0.10.0":
version "0.8.4" version "0.10.0"
resolved "https://registry.yarnpkg.com/@theme-ui/theme-provider/-/theme-provider-0.8.4.tgz#87ba378f2bd4d07af5c04a215db193eb78843e89" resolved "https://registry.yarnpkg.com/@theme-ui/theme-provider/-/theme-provider-0.10.0.tgz#16586af579bef804f1e4997a131134d2465bfd5d"
integrity sha512-xYXqs2y9hkZmqSTA76X2dfrAfUw8LDHnBa1Xp6J41Zb4iXcxbLp3Aq1yRT1xGBAAVRGyhfMkYqxSXfNxN8R1ig== integrity sha512-1AVsegjEAw7uidr0/qJMoKktKbdXuXRjfukI9712GZleft3dzoHhkQUO7IefXjbafyu/plzo/WTXkbz0A4uhmA==
dependencies: dependencies:
"@emotion/react" "^11.1.1" "@emotion/react" "^11.1.1"
"@theme-ui/color-modes" "0.8.4" "@theme-ui/color-modes" "0.10.0"
"@theme-ui/core" "0.8.4" "@theme-ui/core" "0.10.0"
"@theme-ui/css" "0.8.4" "@theme-ui/css" "0.10.0"
"@theme-ui/mdx" "0.8.4" "@theme-ui/mdx" "0.10.0"
"@types/anymatch@*": "@types/anymatch@*":
version "1.3.1" version "1.3.1"
@ -3096,6 +3096,11 @@
"@types/scheduler" "*" "@types/scheduler" "*"
csstype "^3.0.2" csstype "^3.0.2"
"@types/resize-observer-browser@^0.1.5":
version "0.1.6"
resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.6.tgz#d8e6c2f830e2650dc06fe74464472ff64b54a302"
integrity sha512-61IfTac0s9jvNtBCpyo86QeaN8qqpMGHdK0uGKCCIy2dt5/Yk84VduHIdWAcmkC5QvdkPL0p5eWYgUZtHKKUVg==
"@types/resolve@0.0.8": "@types/resolve@0.0.8":
version "0.0.8" version "0.0.8"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194"
@ -3161,6 +3166,11 @@
dependencies: dependencies:
source-map "^0.6.1" source-map "^0.6.1"
"@types/uuid@^8.3.1":
version "8.3.1"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.1.tgz#1a32969cf8f0364b3d8c8af9cc3555b7805df14f"
integrity sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==
"@types/webgl-ext@0.0.30": "@types/webgl-ext@0.0.30":
version "0.0.30" version "0.0.30"
resolved "https://registry.yarnpkg.com/@types/webgl-ext/-/webgl-ext-0.0.30.tgz#0ce498c16a41a23d15289e0b844d945b25f0fb9d" resolved "https://registry.yarnpkg.com/@types/webgl-ext/-/webgl-ext-0.0.30.tgz#0ce498c16a41a23d15289e0b844d945b25f0fb9d"
@ -8774,11 +8784,6 @@ locate-path@^5.0.0:
dependencies: dependencies:
p-locate "^4.1.0" p-locate "^4.1.0"
lodash-es@^4.17.15:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==
lodash._reinterpolate@^3.0.0: lodash._reinterpolate@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
@ -10969,11 +10974,6 @@ queue-microtask@^1.1.0:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.2.tgz#abf64491e6ecf0f38a6502403d4cda04f372dfd3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.2.tgz#abf64491e6ecf0f38a6502403d4cda04f372dfd3"
integrity sha512-dB15eXv3p2jDlbOiNLyMabYg1/sXvppd8DP2J3EOCQ0AkuSXCW2tP7mnVouVLJKgUMY6yP0kcQDVpLCN13h4Xg== integrity sha512-dB15eXv3p2jDlbOiNLyMabYg1/sXvppd8DP2J3EOCQ0AkuSXCW2tP7mnVouVLJKgUMY6yP0kcQDVpLCN13h4Xg==
raf-schd@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0"
integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ==
raf@^3.4.1: raf@^3.4.1:
version "3.4.1" version "3.4.1"
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
@ -11162,15 +11162,14 @@ react-refresh@^0.8.3:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==
react-resize-detector@4.2.3: react-resize-detector@^6.7.4:
version "4.2.3" version "6.7.4"
resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-4.2.3.tgz#7df258668a30bdfd88e655bbdb27db7fd7b23127" resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-6.7.4.tgz#594cc026115af05484e8011157b5dc2137492680"
integrity sha512-4AeS6lxdz2KOgDZaOVt1duoDHrbYwSrUX32KeM9j6t9ISyRphoJbTRCMS1aPFxZHFqcCGLT1gMl3lEcSWZNW0A== integrity sha512-wzvGmUdEDMhiUHVZGnl4kuyj/TEQhvbB5LyAGkbYXetwJ2O+u/zftmPvU+kxiO1h+d9aUqQBKcNLS7TvB3ytqA==
dependencies: dependencies:
lodash "^4.17.15" "@types/resize-observer-browser" "^0.1.5"
lodash-es "^4.17.15" lodash.debounce "^4.0.8"
prop-types "^15.7.2" lodash.throttle "^4.1.1"
raf-schd "^4.0.2"
resize-observer-polyfill "^1.5.1" resize-observer-polyfill "^1.5.1"
react-router-dom@^5.1.2: react-router-dom@^5.1.2:
@ -12899,17 +12898,17 @@ text-table@0.2.0, text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
theme-ui@^0.8.4: theme-ui@^0.10.0:
version "0.8.4" version "0.10.0"
resolved "https://registry.yarnpkg.com/theme-ui/-/theme-ui-0.8.4.tgz#c2e2aebd6266d71a9b6be70cfff018fa8337866e" resolved "https://registry.yarnpkg.com/theme-ui/-/theme-ui-0.10.0.tgz#4fce8fbe7ad008ec07b383eaf5f468b0317fcfa1"
integrity sha512-vBIixheMRmFf3YuqqonYeWg0dhbshWqUzTIx4ROYLSlkbsGwcCAxSwZBtwxwnTvieaInwNUBvQ+KOZ3HpWbT6A== integrity sha512-6uj9/4n6gZrlhrfQKt+QoLdtVc46ETJZv42iqedCatXaaTA5tHN1j7TJDmvYD9ooD/CT0+hsvOrx2d2etb/kYg==
dependencies: dependencies:
"@theme-ui/color-modes" "0.8.4" "@theme-ui/color-modes" "0.10.0"
"@theme-ui/components" "0.8.4" "@theme-ui/components" "0.10.0"
"@theme-ui/core" "0.8.4" "@theme-ui/core" "0.10.0"
"@theme-ui/css" "0.8.4" "@theme-ui/css" "0.10.0"
"@theme-ui/mdx" "0.8.4" "@theme-ui/mdx" "0.10.0"
"@theme-ui/theme-provider" "0.8.4" "@theme-ui/theme-provider" "0.10.0"
throat@^5.0.0: throat@^5.0.0:
version "5.0.0" version "5.0.0"