Merge branch 'master' into typescript

This commit is contained in:
Mitchell McCaffrey
2021-07-02 15:54:54 +10:00
157 changed files with 8114 additions and 4055 deletions
+72 -67
View File
@@ -19,14 +19,14 @@ import {
useDebouncedStageScale,
} from "../contexts/MapInteractionContext";
import { MapStageProvider, useMapStage } from "../contexts/MapStageContext";
import AuthContext, { useAuth } from "../contexts/AuthContext";
import UserIdContext, { useUserId } from "../contexts/UserIdContext";
import SettingsContext, { useSettings } from "../contexts/SettingsContext";
import KeyboardContext from "../contexts/KeyboardContext";
import TokenDataContext, { useTokenData } from "../contexts/TokenDataContext";
import {
ImageSourcesStateContext,
ImageSourcesUpdaterContext,
} from "../contexts/ImageSourceContext";
import AssetsContext, {
AssetURLsStateContext,
AssetURLsUpdaterContext,
useAssets,
} from "../contexts/AssetsContext";
import {
useGrid,
useGridCellPixelSize,
@@ -43,17 +43,18 @@ import {
GridStrokeWidthContext,
GridCellPixelOffsetContext,
} from "../contexts/GridContext";
import DatabaseContext, { useDatabase } from "../contexts/DatabaseContext";
/**
* Provide a bridge for konva that forwards our contexts
*/
function KonvaBridge({ stageRender, children }: { stageRender: any, children: any}) {
const mapStageRef = useMapStage();
const auth = useAuth();
const userId = useUserId();
const settings = useSettings();
const tokenData = useTokenData();
const imageSources = useContext(ImageSourcesStateContext);
const setImageSources = useContext(ImageSourcesUpdaterContext);
const assets = useAssets();
const assetURLs = useContext(AssetURLsStateContext);
const setAssetURLs = useContext(AssetURLsUpdaterContext);
const keyboardValue = useContext(KeyboardContext);
const stageScale = useStageScale();
@@ -73,70 +74,74 @@ function KonvaBridge({ stageRender, children }: { stageRender: any, children: an
const gridCellPixelOffset = useGridCellPixelOffset();
const gridOffset = useGridOffset();
const database = useDatabase();
return stageRender(
<AuthContext.Provider value={auth}>
<SettingsContext.Provider value={settings}>
<KeyboardContext.Provider value={keyboardValue}>
<MapStageProvider value={mapStageRef}>
<TokenDataContext.Provider value={tokenData}>
<ImageSourcesStateContext.Provider value={imageSources}>
<ImageSourcesUpdaterContext.Provider value={setImageSources}>
<InteractionEmitterContext.Provider
value={interactionEmitter}
>
<SetPreventMapInteractionContext.Provider
value={setPreventMapInteraction}
<DatabaseContext.Provider value={database}>
<UserIdContext.Provider value={userId}>
<SettingsContext.Provider value={settings}>
<KeyboardContext.Provider value={keyboardValue}>
<MapStageProvider value={mapStageRef}>
<AssetsContext.Provider value={assets}>
<AssetURLsStateContext.Provider value={assetURLs}>
<AssetURLsUpdaterContext.Provider value={setAssetURLs}>
<InteractionEmitterContext.Provider
value={interactionEmitter}
>
<StageWidthContext.Provider value={stageWidth}>
<StageHeightContext.Provider value={stageHeight}>
<MapWidthContext.Provider value={mapWidth}>
<MapHeightContext.Provider value={mapHeight}>
<StageScaleContext.Provider value={stageScale}>
<DebouncedStageScaleContext.Provider
value={debouncedStageScale}
>
<GridContext.Provider value={grid}>
<GridPixelSizeContext.Provider
value={gridPixelSize}
>
<GridCellPixelSizeContext.Provider
value={gridCellPixelSize}
<SetPreventMapInteractionContext.Provider
value={setPreventMapInteraction}
>
<StageWidthContext.Provider value={stageWidth}>
<StageHeightContext.Provider value={stageHeight}>
<MapWidthContext.Provider value={mapWidth}>
<MapHeightContext.Provider value={mapHeight}>
<StageScaleContext.Provider value={stageScale}>
<DebouncedStageScaleContext.Provider
value={debouncedStageScale}
>
<GridContext.Provider value={grid}>
<GridPixelSizeContext.Provider
value={gridPixelSize}
>
<GridCellNormalizedSizeContext.Provider
value={gridCellNormalizedSize}
<GridCellPixelSizeContext.Provider
value={gridCellPixelSize}
>
<GridOffsetContext.Provider
value={gridOffset}
<GridCellNormalizedSizeContext.Provider
value={gridCellNormalizedSize}
>
<GridStrokeWidthContext.Provider
value={gridStrokeWidth}
<GridOffsetContext.Provider
value={gridOffset}
>
<GridCellPixelOffsetContext.Provider
value={gridCellPixelOffset}
<GridStrokeWidthContext.Provider
value={gridStrokeWidth}
>
{children}
</GridCellPixelOffsetContext.Provider>
</GridStrokeWidthContext.Provider>
</GridOffsetContext.Provider>
</GridCellNormalizedSizeContext.Provider>
</GridCellPixelSizeContext.Provider>
</GridPixelSizeContext.Provider>
</GridContext.Provider>
</DebouncedStageScaleContext.Provider>
</StageScaleContext.Provider>
</MapHeightContext.Provider>
</MapWidthContext.Provider>
</StageHeightContext.Provider>
</StageWidthContext.Provider>
</SetPreventMapInteractionContext.Provider>
</InteractionEmitterContext.Provider>
</ImageSourcesUpdaterContext.Provider>
</ImageSourcesStateContext.Provider>
</TokenDataContext.Provider>
</MapStageProvider>
</KeyboardContext.Provider>
</SettingsContext.Provider>
</AuthContext.Provider>
<GridCellPixelOffsetContext.Provider
value={gridCellPixelOffset}
>
{children}
</GridCellPixelOffsetContext.Provider>
</GridStrokeWidthContext.Provider>
</GridOffsetContext.Provider>
</GridCellNormalizedSizeContext.Provider>
</GridCellPixelSizeContext.Provider>
</GridPixelSizeContext.Provider>
</GridContext.Provider>
</DebouncedStageScaleContext.Provider>
</StageScaleContext.Provider>
</MapHeightContext.Provider>
</MapWidthContext.Provider>
</StageHeightContext.Provider>
</StageWidthContext.Provider>
</SetPreventMapInteractionContext.Provider>
</InteractionEmitterContext.Provider>
</AssetURLsUpdaterContext.Provider>
</AssetURLsStateContext.Provider>
</AssetsContext.Provider>
</MapStageProvider>
</KeyboardContext.Provider>
</SettingsContext.Provider>
</UserIdContext.Provider>
</DatabaseContext.Provider>
);
}
+60
View File
@@ -520,6 +520,66 @@ class Vector2 {
return { x: -p.y, y: p.x };
}
}
/**
* Returns the centroid of the given points
* @param {Vector2[]} points
* @returns {Vector2}
*/
static centroid(points) {
let center = { x: 0, y: 0 };
for (let point of points) {
center.x += point.x;
center.y += point.y;
}
if (points.length > 0) {
center = { x: center.x / points.length, y: center.y / points.length };
}
return center;
}
/**
* Determine whether given points are rectangular
* @param {Vector2[]} points
* @returns {boolean}
*/
static rectangular(points) {
if (points.length !== 4) {
return false;
}
// Check whether distance to the center is the same for all four points
const centroid = this.centroid(points);
let prevDist;
for (let point of points) {
const dist = this.distance(point, centroid);
if (prevDist && dist !== prevDist) {
return false;
} else {
prevDist = dist;
}
}
return true;
}
/**
* Determine whether given points are circular
* @param {Vector2[]} points
* @returns {boolean}
*/
static circular(points, threshold = 0.1) {
const centroid = this.centroid(points);
let distances = [];
for (let point of points) {
distances.push(this.distance(point, centroid));
}
if (distances.length > 0) {
const maxDistance = Math.max(...distances);
const minDistance = Math.min(...distances);
return maxDistance - minDistance < threshold;
} else {
return false;
}
}
}
export default Vector2;
+8
View File
@@ -42,3 +42,11 @@ export function addPolygonIntersectionToShapes(shape: any, intersection: any, sh
};
}
}
export function shapeToGeometry(shape) {
const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
const shapeHoles = shape.data.holes.map((hole) =>
hole.map(({ x, y }) => [x, y])
);
return [[shapePoints, ...shapeHoles]];
}
+48
View File
@@ -0,0 +1,48 @@
import set from "lodash.set";
import unset from "lodash.unset";
import cloneDeep from "lodash.clonedeep";
/**
* Remove all empty values from an object recursively
* @param {Object} obj
*/
function trimArraysInObject(obj) {
for (let key in obj) {
const value = obj[key];
if (Array.isArray(value)) {
let arr = [];
for (let i = 0; i < value.length; i++) {
const el = value[i];
if (typeof el === "object") {
arr.push(trimArraysInObject(el));
} else if (el !== undefined) {
arr.push(el);
}
}
obj[key] = arr;
} else if (typeof obj[key] === "object") {
obj[key] = trimArraysInObject(obj[key]);
}
}
return obj;
}
export function applyObservableChange(change) {
// Custom application of dexie change to fix issue with array indices being wrong
// https://github.com/dfahlander/Dexie.js/issues/1176
// TODO: Fix dexie observable source
let obj = cloneDeep(change.oldObj);
const changes = Object.entries(change.mods).reverse();
for (let [key, value] of changes) {
if (value === null) {
unset(obj, key);
} else {
obj = set(obj, key, value);
}
}
// Trim empty values from calling unset on arrays
obj = trimArraysInObject(obj);
return obj;
}
+5 -10
View File
@@ -212,7 +212,7 @@ export function getUpdatedShapeData(
y: data.y,
});
const scaled = Vector2.multiply(dif, gridRatio);
const distance = Vector2.setLength(scaled);
const distance = Vector2.magnitude(scaled);
return {
...data,
radius: distance,
@@ -234,7 +234,7 @@ export function getUpdatedShapeData(
const points = data.points;
const startPixel = Vector2.multiply(points[0], mapSize);
const dif = Vector2.subtract(brushPositionPixel, startPixel);
const length = Vector2.setLength(dif);
const length = Vector2.magnitude(dif);
const direction = Vector2.normalize(dif);
// Get the angle for a triangle who's width is the same as it's length
const angle = Math.atan(length / 2 / (length === 0 ? 1 : length));
@@ -257,18 +257,13 @@ export function getUpdatedShapeData(
}
}
const defaultSimplifySize = 1 / 100;
/**
* Simplify points to a grid size
* @param {Vector2[]} points
* @param {Vector2} gridCellSize
* @param {number} scale
* @param {number} tolerance
*/
export function simplifyPoints(points: Vector2[], gridCellSize: Vector2, scale: number): any {
return simplify(
points,
(Vector2.min(gridCellSize) as number * defaultSimplifySize) / scale
);
export function simplifyPoints(points: Vector2[], tolerance: number): Vector2[] {
return simplify(points, tolerance);
}
/**
+10 -7
View File
@@ -65,6 +65,9 @@ export function getGridPixelSize(grid: Required<Grid>, baseWidth: number, baseHe
* @returns {Size}
*/
export function getCellPixelSize(grid: Grid, gridWidth: number, gridHeight: number): Size {
if (grid.size.x === 0 || grid.size.y === 0) {
return new Size(0, 0);
}
switch (grid.type) {
case "square":
return new Size(gridWidth / grid.size.x, gridHeight / grid.size.y);
@@ -226,7 +229,10 @@ export function getGridDefaultInset(grid: Grid, mapWidth: number, mapHeight: num
* @returns {GridInset}
*/
export function getGridUpdatedInset(grid: Required<Grid>, mapWidth: number, mapHeight: number): GridInset {
let inset = grid.inset;
let inset = {
topLeft: { ...grid.inset.topLeft },
bottomRight: { ...grid.inset.bottomRight },
};
// Take current inset width and use it to calculate the new height
if (grid.size.x > 0 && grid.size.x > 0) {
// Convert to px relative to map size
@@ -301,10 +307,7 @@ export function gridDistance(grid: Required<Grid>, a: Vector2, b: Vector2, cellS
const bCoord = getNearestCellCoordinates(grid, b.x, b.y, cellSize);
if (grid.type === "square") {
if (grid.measurement.type === "chebyshev") {
return Math.max(
Math.abs(aCoord.x - bCoord.x),
Math.abs(aCoord.y - bCoord.y)
);
return Vector2.max(Vector2.abs(Vector2.subtract(aCoord, bCoord)));
} else if (grid.measurement.type === "alternating") {
// Alternating diagonal distance like D&D 3.5 and Pathfinder
const delta = Vector2.abs(Vector2.subtract(aCoord, bCoord));
@@ -312,7 +315,7 @@ export function gridDistance(grid: Required<Grid>, a: Vector2, b: Vector2, cellS
const min: any = Vector2.min(delta);
return max - min + Math.floor(1.5 * min);
} else if (grid.measurement.type === "euclidean") {
return Vector2.distance(aCoord, bCoord);
return Vector2.magnitude(Vector2.divide(Vector2.subtract(a, b), cellSize));
} else if (grid.measurement.type === "manhattan") {
return Math.abs(aCoord.x - bCoord.x) + Math.abs(aCoord.y - bCoord.y);
}
@@ -328,7 +331,7 @@ export function gridDistance(grid: Required<Grid>, a: Vector2, b: Vector2, cellS
2
);
} else if (grid.measurement.type === "euclidean") {
return Vector2.distance(aCoord, bCoord);
return Vector2.magnitude(Vector2.divide(Vector2.subtract(a, b), cellSize));
}
}
}
+272
View File
@@ -0,0 +1,272 @@
import { v4 as uuid } from "uuid";
import cloneDeep from "lodash.clonedeep";
import { keyBy } from "./shared";
/**
* @typedef GroupItem
* @property {string} id
* @property {"item"} type
*/
/**
* @typedef GroupContainer
* @property {string} id
* @property {"group"} type
* @property {GroupItem[]} items
* @property {string} name
*/
/**
* @typedef {GroupItem|GroupContainer} Group
*/
/**
* Transform an array of group ids to their groups
* @param {string[]} groupIds
* @param {Group[]} groups
* @return {Group[[]}
*/
export function groupsFromIds(groupIds, groups) {
const groupsByIds = keyBy(groups, "id");
const filteredGroups = [];
for (let groupId of groupIds) {
if (groupId in groupsByIds) {
filteredGroups.push(groupsByIds[groupId]);
}
}
return filteredGroups;
}
/**
* Get all items from a group including all sub groups
* @param {Group} group
* @return {GroupItem[]}
*/
export function getGroupItems(group) {
if (group.type === "group") {
let groups = [];
for (let item of group.items) {
groups.push(...getGroupItems(item));
}
return groups;
} else {
return [group];
}
}
/**
* Transform an array of groups into their assosiated items
* @param {Group[]} groups
* @param {any[]} allItems
* @param {string} itemKey
* @returns {any[]}
*/
export function itemsFromGroups(groups, allItems, itemKey = "id") {
const allItemsById = keyBy(allItems, itemKey);
const groupedItems = [];
for (let group of groups) {
const groupItems = getGroupItems(group);
const items = groupItems.map((item) => allItemsById[item.id]);
groupedItems.push(...items);
}
return groupedItems;
}
/**
* Combine two groups
* @param {Group} a
* @param {Group} b
* @returns {GroupContainer}
*/
export function combineGroups(a, b) {
if (a.type === "item") {
return {
id: uuid(),
type: "group",
items: [a, b],
name: "",
};
}
if (a.type === "group") {
return {
id: a.id,
type: "group",
items: [...a.items, b],
name: a.name,
};
}
}
/**
* 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) {
const newGroups = cloneDeep(groups);
const intoGroup = newGroups[into];
let fromGroups = [];
for (let i of indices) {
fromGroups.push(newGroups[i]);
}
let combined = intoGroup;
for (let fromGroup of fromGroups) {
combined = combineGroups(combined, fromGroup);
}
// Replace and remove old groups
newGroups[into] = combined;
for (let fromGroup of fromGroups) {
const i = newGroups.findIndex((group) => group.id === fromGroup.id);
newGroups.splice(i, 1);
}
return newGroups;
}
/**
* 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) {
const newGroups = cloneDeep(groups);
let fromGroups = [];
for (let i of indices) {
fromGroups.push(newGroups[i]);
}
// Remove old groups
for (let fromGroup of fromGroups) {
const i = newGroups.findIndex((group) => group.id === fromGroup.id);
newGroups.splice(i, 1);
}
// Add back at new index
newGroups.splice(to, 0, ...fromGroups);
return newGroups;
}
/**
* Move items from a sub group to the start of the base group
* @param {Group[]} groups
* @param {string} fromId The id of the group to move from
* @param {number[]} indices The indices of the items in the group
*/
export function ungroup(groups, fromId, indices) {
const newGroups = cloneDeep(groups);
let fromIndex = newGroups.findIndex((group) => group.id === fromId);
let items = [];
for (let i of indices) {
items.push(newGroups[fromIndex].items[i]);
}
// Remove items from previous group
for (let item of items) {
const i = newGroups[fromIndex].items.findIndex((el) => el.id === item.id);
newGroups[fromIndex].items.splice(i, 1);
}
// If we have no more items in the group delete it
if (newGroups[fromIndex].items.length === 0) {
newGroups.splice(fromIndex, 1);
}
// Add to base group
newGroups.splice(0, 0, ...items);
return newGroups;
}
/**
* Recursively find a group within a group array
* @param {Group[]} groups
* @param {string} groupId
* @returns {Group}
*/
export function findGroup(groups, groupId) {
for (let group of groups) {
if (group.id === groupId) {
return group;
}
const items = getGroupItems(group);
for (let item of items) {
if (item.id === groupId) {
return item;
}
}
}
}
/**
* Transform and item array to a record of item ids to item names
* @param {any[]} items
* @param {string=} itemKey
*/
export function getItemNames(items, itemKey = "id") {
let names = {};
for (let item of items) {
names[item[itemKey]] = item.name;
}
return names;
}
/**
* Immutably rename a group
* @param {Group[]} groups
* @param {string} groupId
* @param {string} newName
*/
export function renameGroup(groups, groupId, newName) {
let newGroups = cloneDeep(groups);
const groupIndex = newGroups.findIndex((group) => group.id === groupId);
if (groupIndex >= 0) {
newGroups[groupIndex].name = newName;
}
return newGroups;
}
/**
* Remove items from groups including sub groups
* @param {Group[]} groups
* @param {string[]} itemIds
*/
export function removeGroupsItems(groups, itemIds) {
let newGroups = cloneDeep(groups);
for (let i = newGroups.length - 1; i >= 0; i--) {
const group = newGroups[i];
if (group.type === "item") {
if (itemIds.includes(group.id)) {
newGroups.splice(i, 1);
}
} else {
const items = group.items;
for (let j = items.length - 1; j >= 0; j--) {
const item = items[j];
if (itemIds.includes(item.id)) {
newGroups[i].items.splice(j, 1);
}
}
// Remove group if no items are left
if (newGroups[i].items.length === 0) {
newGroups.splice(i, 1);
}
}
}
return newGroups;
}
+103 -7
View File
@@ -1,4 +1,7 @@
import imageOutline from "image-outline";
import blobToBuffer from "./blobToBuffer";
import Vector2 from "./Vector2";
const lightnessDetectionOffset = 0.1;
@@ -101,12 +104,11 @@ export async function resizeImage(image: HTMLImageElement, size: number, type: s
}
/**
* @typedef ImageFile
* @property {Uint8Array|null} file
* @typedef ImageAsset
* @property {number} width
* @property {number} height
* @property {"file"} type
* @property {string} id
* @property {Uint8Array} file
* @property {string} mime
*/
export type ImageFile = {
@@ -122,7 +124,7 @@ export type ImageFile = {
* @param {string} type the mime type of the image
* @param {number} size the width and height of the thumbnail
* @param {number} quality if image is a jpeg or webp this is the quality setting
* @returns {Promise<ImageFile>}
* @returns {Promise<ImageAsset>}
*/
export async function createThumbnail(image: HTMLImageElement, type: string, size = 300, quality = 0.5): Promise<ImageFile> {
let canvas = document.createElement("canvas");
@@ -174,7 +176,101 @@ export async function createThumbnail(image: HTMLImageElement, type: string, siz
file: thumbnailBuffer,
width: thumbnailImage.width,
height: thumbnailImage.height,
type: "file",
id: "thumbnail",
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
* @param {HTMLImageElement} image
* @returns {Outline}
*/
export function getImageOutline(image, maxPoints = 100) {
// Basic rect outline for fail conditions
const defaultOutline = {
type: "rect",
x: 0,
y: 0,
width: image.width,
height: image.height,
};
try {
let outlinePoints = imageOutline(image, {
opacityThreshold: 1, // Allow everything except full transparency
});
if (outlinePoints) {
if (outlinePoints.length > maxPoints) {
outlinePoints = Vector2.resample(outlinePoints, maxPoints);
}
const bounds = Vector2.getBoundingBox(outlinePoints);
// Reject outline if it's area is less than 5% of the image
const imageArea = image.width * image.height;
const area = bounds.width * bounds.height;
if (area < imageArea * 0.05) {
return defaultOutline;
}
// Detect if the outline is a rectangle or circle
if (Vector2.rectangular(outlinePoints)) {
return {
type: "rect",
x: Math.round(bounds.min.x),
y: Math.round(bounds.min.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height),
};
} else if (
Vector2.circular(
outlinePoints,
Math.max(bounds.width / 10, bounds.height / 10)
)
) {
return {
type: "circle",
x: Math.round(bounds.center.x),
y: Math.round(bounds.center.y),
radius: Math.round(Math.min(bounds.width, bounds.height) / 2),
};
} else {
// Flatten and round outline to save on storage size
const points = outlinePoints
.map(({ x, y }) => [Math.round(x), Math.round(y)])
.flat();
return { type: "path", points };
}
} else {
return defaultOutline;
}
} catch {
return defaultOutline;
}
}
+195
View File
@@ -0,0 +1,195 @@
import { v4 as uuid } from "uuid";
import Case from "case";
import blobToBuffer from "./blobToBuffer";
import { resizeImage, createThumbnail } from "./image";
import {
getGridDefaultInset,
getGridSizeFromImage,
gridSizeVaild,
} from "./grid";
import Vector2 from "./Vector2";
const defaultMapProps = {
showGrid: false,
snapToGrid: true,
quality: "original",
group: "",
};
const mapResolutions = [
{
size: 30, // Pixels per grid
quality: 0.5, // JPEG compression quality
id: "low",
},
{ size: 70, quality: 0.6, id: "medium" },
{ size: 140, quality: 0.7, id: "high" },
{ size: 300, quality: 0.8, id: "ultra" },
];
/**
* Get the asset id of the preview file to send for a map
* @param {any} map
* @returns {undefined|string}
*/
export function getMapPreviewAsset(map) {
const res = map.resolutions;
switch (map.quality) {
case "low":
return;
case "medium":
return res.low;
case "high":
return res.medium;
case "ultra":
return res.medium;
case "original":
if (res.medium) {
return res.medium;
} else if (res.low) {
return res.low;
}
return;
default:
return;
}
}
export async function createMapFromFile(file, userId) {
let image = new Image();
const buffer = await blobToBuffer(file);
// Copy file to avoid permissions issues
const blob = new Blob([buffer]);
// Create and load the image temporarily to get its dimensions
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
image.onload = async function () {
// Find name and grid size
let gridSize;
let name = "Unknown Map";
if (file.name) {
if (file.name.matchAll) {
// Match against a regex to find the grid size in the file name
// e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
for (let match of gridMatches) {
const matchX = parseInt(match[1]);
const matchY = parseInt(match[3]);
if (
!isNaN(matchX) &&
!isNaN(matchY) &&
gridSizeVaild(matchX, matchY)
) {
gridSize = { x: matchX, y: matchY };
}
}
}
if (!gridSize) {
gridSize = await getGridSizeFromImage(image);
}
// Remove file extension
name = file.name.replace(/\.[^/.]+$/, "");
// Removed grid size expression
name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
// Clean string
name = name.replace(/ +/g, " ");
name = name.trim();
// Capitalize and remove underscores
name = Case.capital(name);
}
if (!gridSize) {
gridSize = { x: 22, y: 22 };
}
let assets = [];
// Create resolutions
const resolutions = {};
for (let resolution of mapResolutions) {
const resolutionPixelSize = Vector2.multiply(gridSize, resolution.size);
if (
image.width >= resolutionPixelSize.x &&
image.height >= resolutionPixelSize.y
) {
const resized = await resizeImage(
image,
Vector2.max(resolutionPixelSize),
file.type,
resolution.quality
);
if (resized.blob) {
const assetId = uuid();
resolutions[resolution.id] = assetId;
const resizedBuffer = await blobToBuffer(resized.blob);
const asset = {
file: resizedBuffer,
width: resized.width,
height: resized.height,
id: assetId,
mime: file.type,
owner: userId,
};
assets.push(asset);
}
}
}
// Create thumbnail
const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = {
...thumbnailImage,
id: uuid(),
owner: userId,
};
assets.push(thumbnail);
const fileAsset = {
id: uuid(),
file: buffer,
width: image.width,
height: image.height,
mime: file.type,
owner: userId,
};
assets.push(fileAsset);
const map = {
name,
resolutions,
file: fileAsset.id,
thumbnail: thumbnail.id,
type: "file",
grid: {
size: gridSize,
inset: getGridDefaultInset(
{ size: gridSize, type: "square" },
image.width,
image.height
),
type: "square",
measurement: {
type: "chebyshev",
scale: "5ft",
},
},
width: image.width,
height: image.height,
id: uuid(),
created: Date.now(),
lastModified: Date.now(),
owner: userId,
...defaultMapProps,
};
URL.revokeObjectURL(url);
resolve({ map, assets });
};
image.onerror = reject;
image.src = url;
});
}
+21
View File
@@ -75,3 +75,24 @@ export function groupBy(array: any, key: string) {
}
export const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
export function shuffle(array) {
let temp = [...array];
var currentIndex = temp.length,
randomIndex;
// While there remain elements to shuffle...
while (0 !== currentIndex) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// And swap it with the current element.
[temp[currentIndex], temp[randomIndex]] = [
temp[randomIndex],
temp[currentIndex],
];
}
return temp;
}
+264
View File
@@ -0,0 +1,264 @@
import { v4 as uuid } from "uuid";
import Case from "case";
import blobToBuffer from "./blobToBuffer";
import { createThumbnail, getImageOutline } from "./image";
import Vector2 from "./Vector2";
export function createTokenState(token, position, userId) {
let tokenState = {
id: uuid(),
tokenId: token.id,
owner: userId,
size: token.defaultSize,
category: token.defaultCategory,
label: token.defaultLabel,
statuses: [],
x: position.x,
y: position.y,
lastModifiedBy: userId,
lastModified: Date.now(),
rotation: 0,
locked: false,
visible: true,
type: token.type,
outline: token.outline,
width: token.width,
height: token.height,
};
if (token.type === "file") {
tokenState.file = token.file;
} else if (token.type === "default") {
tokenState.key = token.key;
}
return tokenState;
}
export async function createTokenFromFile(file, userId) {
if (!file) {
return Promise.reject();
}
let name = "Unknown Token";
let defaultSize = 1;
if (file.name) {
if (file.name.matchAll) {
// Match against a regex to find the grid size in the file name
// e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
const sizeMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
for (let match of sizeMatches) {
const matchX = parseInt(match[1]);
const matchY = parseInt(match[3]);
if (
!isNaN(matchX) &&
!isNaN(matchY) &&
matchX < 256 // Add check to test match isn't resolution
) {
defaultSize = matchX;
}
}
}
// Remove file extension
name = file.name.replace(/\.[^/.]+$/, "");
// Removed grid size expression
name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
// Clean string
name = name.replace(/ +/g, " ");
name = name.trim();
// Capitalize and remove underscores
name = Case.capital(name);
}
let image = new Image();
const buffer = await blobToBuffer(file);
// Copy file to avoid permissions issues
const blob = new Blob([buffer]);
// Create and load the image temporarily to get its dimensions
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
image.onload = async function () {
let assets = [];
const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = { ...thumbnailImage, id: uuid(), owner: userId };
assets.push(thumbnail);
const fileAsset = {
id: uuid(),
file: buffer,
width: image.width,
height: image.height,
mime: file.type,
owner: userId,
};
assets.push(fileAsset);
const outline = getImageOutline(image);
const token = {
name,
defaultSize,
thumbnail: thumbnail.id,
file: fileAsset.id,
id: uuid(),
type: "file",
created: Date.now(),
lastModified: Date.now(),
owner: userId,
defaultCategory: "character",
defaultLabel: "",
hideInSidebar: false,
group: "",
width: image.width,
height: image.height,
outline,
};
URL.revokeObjectURL(url);
resolve({ token, assets });
};
image.onerror = reject;
image.src = url;
});
}
export function clientPositionToMapPosition(
mapStage,
clientPosition,
checkMapBounds = true
) {
const mapImage = mapStage.findOne("#mapImage");
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
// Check map bounds
if (
checkMapBounds &&
(clientPosition.x < mapRect.left ||
clientPosition.x > mapRect.right ||
clientPosition.y < mapRect.top ||
clientPosition.y > mapRect.bottom)
) {
return;
}
// Convert relative to map rect
const mapPosition = {
x: clientPosition.x - mapRect.left,
y: clientPosition.y - mapRect.top,
};
// Convert relative to map image
const transform = mapImage.getAbsoluteTransform().copy().invert();
const relativePosition = transform.point(mapPosition);
const normalizedPosition = {
x: relativePosition.x / mapImage.width(),
y: relativePosition.y / mapImage.height(),
};
return normalizedPosition;
}
export function getScaledOutline(tokenState, tokenWidth, tokenHeight) {
let outline = tokenState.outline;
if (outline.type === "rect") {
return {
...outline,
x: (outline.x / tokenState.width) * tokenWidth,
y: (outline.y / tokenState.height) * tokenHeight,
width: (outline.width / tokenState.width) * tokenWidth,
height: (outline.height / tokenState.height) * tokenHeight,
};
} else if (outline.type === "circle") {
return {
...outline,
x: (outline.x / tokenState.width) * tokenWidth,
y: (outline.y / tokenState.height) * tokenHeight,
radius: (outline.radius / tokenState.width) * tokenWidth,
};
} else {
let points = [...outline.points]; // Copy array so we can edit it imutably
for (let i = 0; i < points.length; i += 2) {
// Scale outline to the token
points[i] = (points[i] / tokenState.width) * tokenWidth;
points[i + 1] = (points[i + 1] / tokenState.height) * tokenHeight;
}
return { ...outline, points };
}
}
export class Intersection {
/**
*
* @param {Outline} outline
* @param {Vector2} position - Top left position of the token
* @param {Vector2} center - Center position of the token
* @param {number} rotation - Rotation of the token in degrees
*/
constructor(outline, position, center, rotation) {
this.outline = outline;
this.position = position;
this.center = center;
this.rotation = rotation;
// Save points for intersection
if (outline.type === "rect") {
this.points = [
Vector2.rotate(
Vector2.add(new Vector2(outline.x, outline.y), position),
center,
rotation
),
Vector2.rotate(
Vector2.add(
new Vector2(outline.x + outline.width, outline.y),
position
),
center,
rotation
),
Vector2.rotate(
Vector2.add(
new Vector2(outline.x + outline.width, outline.y + outline.height),
position
),
center,
rotation
),
Vector2.rotate(
Vector2.add(
new Vector2(outline.x, outline.y + outline.height),
position
),
center,
rotation
),
];
} else if (outline.type === "path") {
this.points = [];
for (let i = 0; i < outline.points.length; i += 2) {
this.points.push(
Vector2.rotate(
Vector2.add(
new Vector2(outline.points[i], outline.points[i + 1]),
position
),
center,
rotation
)
);
}
}
}
/**
* @param {Vector2} point
* @returns {boolean}
*/
intersects(point) {
if (this.outline.type === "rect" || this.outline.type === "path") {
return Vector2.pointInPolygon(point, this.points);
} else if (this.outline.type === "circle") {
return Vector2.distance(this.center, point) < this.outline.radius;
}
return false;
}
}