import { TVA_CONFIG, updateSettings, _arrayAwareDiffObject } from './settings.js';
|
|
import { showArtSelect } from '../token-variants.mjs';
|
|
import EffectMappingForm from '../applications/effectMappingForm.js';
|
|
import CompendiumMapConfig from '../applications/compendiumMap.js';
|
|
|
|
const simplifyRegex = new RegExp(/[^A-Za-z0-9/\\]/g);
|
|
|
|
export const SUPPORTED_COMP_ATTRIBUTES = ['rotation', 'elevation'];
|
|
export const EXPRESSION_OPERATORS = ['\\(', '\\)', '&&', '||', '\\!'];
|
|
|
|
// Types of searches
|
|
export const SEARCH_TYPE = {
|
|
PORTRAIT: 'Portrait',
|
|
TOKEN: 'Token',
|
|
PORTRAIT_AND_TOKEN: 'PortraitAndToken',
|
|
TILE: 'Tile',
|
|
ITEM: 'Item',
|
|
JOURNAL: 'JournalEntry',
|
|
MACRO: 'Macro',
|
|
};
|
|
|
|
export const BASE_IMAGE_CATEGORIES = [
|
|
'Portrait',
|
|
'Token',
|
|
'PortraitAndToken',
|
|
'Tile',
|
|
'Item',
|
|
'JournalEntry',
|
|
'Macro',
|
|
'RollTable',
|
|
];
|
|
|
|
export const PRESSED_KEYS = {
|
|
popupOverride: false,
|
|
config: false,
|
|
};
|
|
|
|
const BATCH_UPDATES = {
|
|
TOKEN: [],
|
|
TOKEN_CALLBACKS: [],
|
|
TOKEN_CONTEXT: { animate: true },
|
|
ACTOR: [],
|
|
ACTOR_CONTEXT: null,
|
|
};
|
|
|
|
export function startBatchUpdater() {
|
|
canvas.app.ticker.add(() => {
|
|
if (BATCH_UPDATES.TOKEN.length) {
|
|
canvas.scene
|
|
.updateEmbeddedDocuments('Token', BATCH_UPDATES.TOKEN, BATCH_UPDATES.TOKEN_CONTEXT)
|
|
.then(() => {
|
|
for (const cb of BATCH_UPDATES.TOKEN_CALLBACKS) {
|
|
cb();
|
|
}
|
|
BATCH_UPDATES.TOKEN_CALLBACKS = [];
|
|
});
|
|
BATCH_UPDATES.TOKEN = [];
|
|
}
|
|
if (BATCH_UPDATES.ACTOR.length !== 0) {
|
|
if (BATCH_UPDATES.ACTOR_CONTEXT)
|
|
Actor.updateDocuments(BATCH_UPDATES.ACTOR, BATCH_UPDATES.ACTOR_CONTEXT);
|
|
else Actor.updateDocuments(BATCH_UPDATES.ACTOR);
|
|
BATCH_UPDATES.ACTOR = [];
|
|
BATCH_UPDATES.ACTOR_CONTEXT = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
export function queueTokenUpdate(id, update, callback = null, animate = true) {
|
|
update._id = id;
|
|
BATCH_UPDATES.TOKEN.push(update);
|
|
BATCH_UPDATES.TOKEN_CONTEXT = { animate };
|
|
if (callback) BATCH_UPDATES.TOKEN_CALLBACKS.push(callback);
|
|
}
|
|
|
|
export function queueActorUpdate(id, update, context = null) {
|
|
update._id = id;
|
|
BATCH_UPDATES.ACTOR.push(update);
|
|
BATCH_UPDATES.ACTOR_CONTEXT = context;
|
|
}
|
|
|
|
/**
|
|
* Updates Token and/or Proto Token with the new image and custom configuration if one exists.
|
|
* @param {string} imgSrc Image source path/url
|
|
* @param {object} [options={}] Update options
|
|
* @param {Token[]} [options.token] Token to be updated with the new image
|
|
* @param {Actor} [options.actor] Actor with Proto Token to be updated with the new image
|
|
* @param {string} [options.imgName] Image name if it differs from the file name. Relevant for rolltable sourced images.
|
|
* @param {object} [options.tokenUpdate] Token update to be merged and performed at the same time as image update
|
|
* @param {object} [options.actorUpdate] Actor update to be merged and performed at the same time as image update
|
|
* @param {string} [options.pack] Compendium pack of the Actor being updated
|
|
* @param {func} [options.callback] Callback to be executed when a batch update has been performed
|
|
* @param {object} [options.config] Token Configuration settings to be applied to the token
|
|
*/
|
|
export async function updateTokenImage(
|
|
imgSrc,
|
|
{
|
|
token = null,
|
|
actor = null,
|
|
imgName = null,
|
|
tokenUpdate = {},
|
|
actorUpdate = {},
|
|
pack = '',
|
|
callback = null,
|
|
config = undefined,
|
|
animate = true,
|
|
update = null,
|
|
applyDefaultConfig = true,
|
|
} = {}
|
|
) {
|
|
if (!(token || actor)) {
|
|
console.warn(
|
|
game.i18n.localize('token-variants.notifications.warn.update-image-no-token-actor')
|
|
);
|
|
return;
|
|
}
|
|
|
|
token = token?.document ?? token;
|
|
|
|
// Check if it's a wildcard image
|
|
if ((imgSrc && imgSrc.includes('*')) || (imgSrc.includes('{') && imgSrc.includes('}'))) {
|
|
const images = await wildcardImageSearch(imgSrc);
|
|
if (images.length) {
|
|
imgSrc = images[Math.floor(Math.random() * images.length)];
|
|
}
|
|
}
|
|
|
|
if (!actor && token.actor) {
|
|
actor = game.actors.get(token.actor.id);
|
|
}
|
|
|
|
const getDefaultConfig = (token, actor) => {
|
|
let configEntries = [];
|
|
if (token) configEntries = token.getFlag('token-variants', 'defaultConfig') || [];
|
|
else if (actor) {
|
|
const tokenData = actor.prototypeToken;
|
|
if ('token-variants' in tokenData.flags && 'defaultConfig' in tokenData['token-variants'])
|
|
configEntries = tokenData['token-variants']['defaultConfig'];
|
|
}
|
|
return expandObject(Object.fromEntries(configEntries));
|
|
};
|
|
|
|
const constructDefaultConfig = (origData, customConfig) => {
|
|
const flatOrigData = flattenObject(origData);
|
|
TokenDataAdapter.dataToForm(flatOrigData);
|
|
const flatCustomConfig = flattenObject(customConfig);
|
|
let filtered = filterObject(flatOrigData, flatCustomConfig);
|
|
|
|
// Flags need special treatment as once set they are not removed via absence of them in the update
|
|
for (let [k, v] of Object.entries(flatCustomConfig)) {
|
|
if (k.startsWith('flags.')) {
|
|
if (!(k in flatOrigData)) {
|
|
let splitK = k.split('.');
|
|
splitK[splitK.length - 1] = '-=' + splitK[splitK.length - 1];
|
|
filtered[splitK.join('.')] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return Object.entries(filtered);
|
|
};
|
|
|
|
let tokenUpdateObj = tokenUpdate;
|
|
if (imgSrc) {
|
|
setProperty(tokenUpdateObj, 'texture.src', imgSrc);
|
|
if (imgName && getFileName(imgSrc) === imgName)
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.-=name', null);
|
|
else setProperty(tokenUpdateObj, 'flags.token-variants.name', imgName);
|
|
}
|
|
|
|
const tokenCustomConfig = mergeObject(
|
|
getTokenConfigForUpdate(imgSrc || token?.texture.src, imgName, token),
|
|
config ?? {}
|
|
);
|
|
const usingCustomConfig = token?.getFlag('token-variants', 'usingCustomConfig');
|
|
const defaultConfig = getDefaultConfig(token);
|
|
if (!isEmpty(tokenCustomConfig) || usingCustomConfig) {
|
|
tokenUpdateObj = modMergeObject(tokenUpdateObj, defaultConfig);
|
|
}
|
|
|
|
if (!isEmpty(tokenCustomConfig)) {
|
|
if (token) {
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.usingCustomConfig', true);
|
|
let doc = token.document ?? token;
|
|
const tokenData = doc.toObject ? doc.toObject() : deepClone(doc);
|
|
|
|
const defConf = constructDefaultConfig(
|
|
mergeObject(tokenData, defaultConfig),
|
|
tokenCustomConfig
|
|
);
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.defaultConfig', defConf);
|
|
} else if (actor && !token) {
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.usingCustomConfig', true);
|
|
const tokenData =
|
|
actor.prototypeToken instanceof Object
|
|
? actor.prototypeToken
|
|
: actor.prototypeToken.toObject();
|
|
const defConf = constructDefaultConfig(tokenData, tokenCustomConfig);
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.defaultConfig', defConf);
|
|
}
|
|
|
|
// Fix, an empty flag may be passed which would overwrite any current flags in the updateObj
|
|
// Remove it before doing the merge
|
|
if (!tokenCustomConfig.flags) {
|
|
delete tokenCustomConfig.flags;
|
|
}
|
|
|
|
tokenUpdateObj = modMergeObject(tokenUpdateObj, tokenCustomConfig);
|
|
} else if (usingCustomConfig) {
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.-=usingCustomConfig', null);
|
|
delete tokenUpdateObj?.flags?.['token-variants']?.defaultConfig;
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultConfig', null);
|
|
}
|
|
|
|
if (!applyDefaultConfig) {
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.-=usingCustomConfig', null);
|
|
delete tokenUpdateObj?.flags?.['token-variants']?.defaultConfig;
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultConfig', null);
|
|
}
|
|
|
|
if (!isEmpty(tokenUpdateObj)) {
|
|
if (actor && !token) {
|
|
TokenDataAdapter.formToData(actor.prototypeToken, tokenUpdateObj);
|
|
actorUpdate.token = tokenUpdateObj;
|
|
if (pack) {
|
|
queueActorUpdate(actor.id, actorUpdate, { pack: pack });
|
|
} else {
|
|
await (actor.document ?? actor).update(actorUpdate);
|
|
}
|
|
}
|
|
|
|
if (token) {
|
|
TokenDataAdapter.formToData(token, tokenUpdateObj);
|
|
if (TVA_CONFIG.updateTokenProto && token.actor) {
|
|
if (update) {
|
|
mergeObject(update, { token: tokenUpdateObj });
|
|
} else {
|
|
// Timeout to prevent race conditions with other modules namely MidiQOL
|
|
// this is a low priority update so it should be Ok to do
|
|
if (token.actorLink) {
|
|
setTimeout(() => queueActorUpdate(token.actor.id, { token: tokenUpdateObj }), 500);
|
|
} else {
|
|
setTimeout(() => token.actor.update({ token: tokenUpdateObj }), 500);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (update) {
|
|
mergeObject(update, tokenUpdateObj);
|
|
} else {
|
|
if (token.object) queueTokenUpdate(token.id, tokenUpdateObj, callback, animate);
|
|
else {
|
|
await token.update(tokenUpdateObj, { animate });
|
|
callback();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assign new artwork to the actor
|
|
*/
|
|
export async function updateActorImage(actor, imgSrc, directUpdate = true, pack = '') {
|
|
if (!actor) return;
|
|
if (directUpdate) {
|
|
await (actor.document ?? actor).update({
|
|
img: imgSrc,
|
|
});
|
|
} else {
|
|
queueActorUpdate(
|
|
actor.id,
|
|
{
|
|
img: imgSrc,
|
|
},
|
|
pack ? { pack: pack } : null
|
|
);
|
|
}
|
|
}
|
|
|
|
async function showTileArtSelect() {
|
|
for (const tile of canvas.tiles.controlled) {
|
|
const tileName = tile.document.getFlag('token-variants', 'tileName') || tile.id;
|
|
showArtSelect(tileName, {
|
|
callback: async function (imgSrc, name) {
|
|
tile.document.update({ img: imgSrc });
|
|
},
|
|
searchType: SEARCH_TYPE.TILE,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a key is pressed taking into account current game version.
|
|
* @param {string} key v/Ctrl/Shift/Alt
|
|
* @returns
|
|
*/
|
|
export function keyPressed(key) {
|
|
if (key === 'v') return game.keyboard.downKeys.has('KeyV');
|
|
return PRESSED_KEYS[key];
|
|
}
|
|
|
|
export function registerKeybinds() {
|
|
game.keybindings.register('token-variants', 'popupOverride', {
|
|
name: 'Popup Override',
|
|
hint: 'When held will trigger popups even when they are disabled.',
|
|
editable: [
|
|
{
|
|
key: 'ShiftLeft',
|
|
},
|
|
],
|
|
onDown: () => {
|
|
PRESSED_KEYS.popupOverride = true;
|
|
},
|
|
onUp: () => {
|
|
PRESSED_KEYS.popupOverride = false;
|
|
},
|
|
restricted: false,
|
|
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
|
|
});
|
|
game.keybindings.register('token-variants', 'config', {
|
|
name: 'Config',
|
|
hint: 'When held during a mouse Left-Click of an Image or an Active Affect will display a configuration window.',
|
|
editable: [
|
|
{
|
|
key: 'ShiftLeft',
|
|
},
|
|
],
|
|
onDown: () => {
|
|
PRESSED_KEYS.config = true;
|
|
},
|
|
onUp: () => {
|
|
PRESSED_KEYS.config = false;
|
|
},
|
|
restricted: false,
|
|
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
|
|
});
|
|
game.keybindings.register('token-variants', 'showArtSelectPortrait', {
|
|
name: 'Show Art Select: Portrait',
|
|
hint: 'Brings up an Art Select pop-up to change the portrait images of the selected tokens.',
|
|
editable: [
|
|
{
|
|
key: 'Digit1',
|
|
modifiers: ['Shift'],
|
|
},
|
|
],
|
|
onDown: () => {
|
|
for (const token of canvas.tokens.controlled) {
|
|
const actor = token.actor;
|
|
if (!actor) continue;
|
|
showArtSelect(actor.name, {
|
|
callback: async function (imgSrc, name) {
|
|
await updateActorImage(actor, imgSrc);
|
|
},
|
|
searchType: SEARCH_TYPE.PORTRAIT,
|
|
object: actor,
|
|
});
|
|
}
|
|
if (TVA_CONFIG.tilesEnabled && canvas.tokens.controlled.length === 0) showTileArtSelect();
|
|
},
|
|
restricted: true,
|
|
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
|
|
});
|
|
game.keybindings.register('token-variants', 'showArtSelectToken', {
|
|
name: 'Show Art Select: Token',
|
|
hint: 'Brings up an Art Select pop-up to change the token images of the selected tokens.',
|
|
editable: [
|
|
{
|
|
key: 'Digit2',
|
|
modifiers: ['Shift'],
|
|
},
|
|
],
|
|
onDown: () => {
|
|
for (const token of canvas.tokens.controlled) {
|
|
showArtSelect(token.name, {
|
|
callback: async function (imgSrc, imgName) {
|
|
updateTokenImage(imgSrc, {
|
|
actor: token.actor,
|
|
imgName: imgName,
|
|
token: token,
|
|
});
|
|
},
|
|
searchType: SEARCH_TYPE.TOKEN,
|
|
object: token,
|
|
});
|
|
}
|
|
if (TVA_CONFIG.tilesEnabled && canvas.tokens.controlled.length === 0) showTileArtSelect();
|
|
},
|
|
restricted: true,
|
|
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
|
|
});
|
|
game.keybindings.register('token-variants', 'showArtSelectGeneral', {
|
|
name: 'Show Art Select: Portrait+Token',
|
|
hint: 'Brings up an Art Select pop-up to change both Portrait and Token images of the selected tokens.',
|
|
editable: [
|
|
{
|
|
key: 'Digit3',
|
|
modifiers: ['Shift'],
|
|
},
|
|
],
|
|
onDown: () => {
|
|
for (const token of canvas.tokens.controlled) {
|
|
const actor = token.actor;
|
|
showArtSelect(token.name, {
|
|
callback: async function (imgSrc, imgName) {
|
|
if (actor) await updateActorImage(actor, imgSrc);
|
|
updateTokenImage(imgSrc, {
|
|
actor: token.actor,
|
|
imgName: imgName,
|
|
token: token,
|
|
});
|
|
},
|
|
searchType: SEARCH_TYPE.PORTRAIT_AND_TOKEN,
|
|
object: token,
|
|
});
|
|
}
|
|
if (TVA_CONFIG.tilesEnabled && canvas.tokens.controlled.length === 0) showTileArtSelect();
|
|
},
|
|
restricted: true,
|
|
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
|
|
});
|
|
game.keybindings.register('token-variants', 'openGlobalMappings', {
|
|
name: 'Open Global Effect Configurations',
|
|
hint: 'Brings up the settings window for Global Effect Configurations',
|
|
editable: [
|
|
{
|
|
key: 'KeyG',
|
|
modifiers: ['Shift'],
|
|
},
|
|
],
|
|
onDown: () => {
|
|
const setting = game.settings.get('core', DefaultTokenConfig.SETTING);
|
|
const data = new foundry.data.PrototypeToken(setting);
|
|
const token = new TokenDocument(data, { actor: null });
|
|
new EffectMappingForm(token, { globalMappings: true }).render(true);
|
|
},
|
|
restricted: true,
|
|
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
|
|
});
|
|
|
|
game.keybindings.register('token-variants', 'compendiumMapper', {
|
|
name: 'Compendium Mapper',
|
|
hint: 'Opens Compendium Mapper',
|
|
editable: [
|
|
{
|
|
key: 'KeyM',
|
|
modifiers: ['Shift'],
|
|
},
|
|
],
|
|
onDown: () => {
|
|
new CompendiumMapConfig().render(true);
|
|
},
|
|
restricted: true,
|
|
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retrieves a custom token configuration if one exists for the given image
|
|
*/
|
|
export function getTokenConfig(imgSrc, imgName) {
|
|
if (!imgName) imgName = getFileName(imgSrc);
|
|
const tokenConfigs = (TVA_CONFIG.tokenConfigs || []).flat();
|
|
return (
|
|
tokenConfigs.find((config) => config.tvImgSrc == imgSrc && config.tvImgName == imgName) ?? {}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Retrieves a custom token configuration if one exists for the given image and removes control keys
|
|
* returning a clean config that can be used in token update.
|
|
*/
|
|
export function getTokenConfigForUpdate(imgSrc, imgName, token) {
|
|
if (!imgSrc) return {};
|
|
|
|
let tokenConfig = {};
|
|
for (const path of TVA_CONFIG.searchPaths) {
|
|
if (path.config && imgSrc.startsWith(path.text)) {
|
|
mergeObject(tokenConfig, path.config);
|
|
}
|
|
}
|
|
|
|
let imgConfig = getTokenConfig(imgSrc, imgName ?? getFileName(imgSrc));
|
|
if (!isEmpty(imgConfig)) {
|
|
imgConfig = deepClone(imgConfig);
|
|
delete imgConfig.tvImgSrc;
|
|
delete imgConfig.tvImgName;
|
|
if (token) TokenDataAdapter.formToData(token, imgConfig);
|
|
|
|
for (var key in imgConfig) {
|
|
if (!key.startsWith('tvTab_')) {
|
|
tokenConfig[key] = imgConfig[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (TVA_CONFIG.imgNameContainsDimensions || TVA_CONFIG.imgNameContainsFADimensions) {
|
|
extractDimensionsFromImgName(imgSrc, tokenConfig);
|
|
}
|
|
|
|
return tokenConfig;
|
|
}
|
|
|
|
/**
|
|
* Adds or removes a custom token configuration
|
|
*/
|
|
export function setTokenConfig(imgSrc, imgName, tokenConfig) {
|
|
const tokenConfigs = (TVA_CONFIG.tokenConfigs || []).flat();
|
|
const tcIndex = tokenConfigs.findIndex(
|
|
(config) => config.tvImgSrc == imgSrc && config.tvImgName == imgName
|
|
);
|
|
|
|
let deleteConfig = !tokenConfig || Object.keys(tokenConfig).length === 0;
|
|
if (!deleteConfig) {
|
|
tokenConfig['tvImgSrc'] = imgSrc;
|
|
tokenConfig['tvImgName'] = imgName;
|
|
}
|
|
|
|
if (tcIndex != -1 && !deleteConfig) {
|
|
tokenConfigs[tcIndex] = tokenConfig;
|
|
} else if (tcIndex != -1 && deleteConfig) {
|
|
tokenConfigs.splice(tcIndex, 1);
|
|
} else if (!deleteConfig) {
|
|
tokenConfigs.push(tokenConfig);
|
|
}
|
|
updateSettings({ tokenConfigs: tokenConfigs });
|
|
return !deleteConfig;
|
|
}
|
|
|
|
/**
|
|
* Extracts the file name from the given path.
|
|
*/
|
|
export function getFileName(path) {
|
|
if (!path) return '';
|
|
return decodeURI(path).split('\\').pop().split('/').pop().split('.').slice(0, -1).join('.');
|
|
}
|
|
|
|
/**
|
|
* Extracts the file name including the extension from the given path.
|
|
*/
|
|
export function getFileNameWithExt(path) {
|
|
if (!path) return '';
|
|
return decodeURI(path).split('\\').pop().split('/').pop();
|
|
}
|
|
|
|
/**
|
|
* Extract the directory path excluding the file name.
|
|
*/
|
|
export function getFilePath(path) {
|
|
return decodeURI(path).match(/(.*)[\/\\]/)[1] || '';
|
|
}
|
|
|
|
/**
|
|
* Simplify name.
|
|
*/
|
|
export function simplifyName(name) {
|
|
return name.replace(simplifyRegex, '').toLowerCase();
|
|
}
|
|
|
|
export function simplifyPath(path) {
|
|
return decodeURIComponent(path).replace(simplifyRegex, '').toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Parses the 'excludedKeyword' setting (a comma separated string) into a Set
|
|
*/
|
|
export function parseKeywords(keywords) {
|
|
return keywords
|
|
.split(/\W/)
|
|
.map((word) => simplifyName(word))
|
|
.filter((word) => word != '');
|
|
}
|
|
|
|
/**
|
|
* Returns true of provided path points to an image
|
|
*/
|
|
export function isImage(path) {
|
|
var extension = path.split('.');
|
|
extension = extension[extension.length - 1].toLowerCase();
|
|
return ['jpg', 'jpeg', 'png', 'svg', 'webp', 'gif'].includes(extension);
|
|
}
|
|
|
|
/**
|
|
* Returns true of provided path points to a video
|
|
*/
|
|
export function isVideo(path) {
|
|
var extension = path.split('.');
|
|
extension = extension[extension.length - 1].toLowerCase();
|
|
return ['mp4', 'ogg', 'webm', 'm4v'].includes(extension);
|
|
}
|
|
|
|
/**
|
|
* Send a recursive HTTP asset browse request to ForgeVTT
|
|
* @param {string} path Asset Library path
|
|
* @param {string} apiKey Key with read access to the Asset Library
|
|
* @returns
|
|
*/
|
|
export async function callForgeVTT(path, apiKey) {
|
|
return new Promise(async (resolve, reject) => {
|
|
if (typeof ForgeVTT === 'undefined' || !ForgeVTT.usingTheForge) return resolve({});
|
|
|
|
const url = `${ForgeVTT.FORGE_URL}/api/assets/browse`;
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.withCredentials = true;
|
|
xhr.open('POST', url);
|
|
xhr.setRequestHeader('Access-Key', apiKey);
|
|
xhr.setRequestHeader('X-XSRF-TOKEN', await ForgeAPI.getXSRFToken());
|
|
xhr.responseType = 'json';
|
|
|
|
xhr.onreadystatechange = () => {
|
|
if (xhr.readyState !== 4) return;
|
|
resolve(xhr.response);
|
|
};
|
|
xhr.onerror = (err) => {
|
|
resolve({ code: 500, error: err.message });
|
|
};
|
|
let formData = {
|
|
path: path,
|
|
options: {
|
|
recursive: true,
|
|
},
|
|
};
|
|
formData = JSON.stringify(formData);
|
|
xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');
|
|
xhr.send(formData);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retrieves filters based on the type of search.
|
|
* @param {SEARCH_TYPE} searchType
|
|
*/
|
|
export function getFilters(searchType, filters) {
|
|
// Select filters based on type of search
|
|
filters = filters ? filters : TVA_CONFIG.searchFilters;
|
|
|
|
if (filters[searchType]) {
|
|
filters = filters[searchType];
|
|
} else {
|
|
filters = {
|
|
include: '',
|
|
exclude: '',
|
|
regex: '',
|
|
};
|
|
}
|
|
|
|
if (filters.regex) filters.regex = new RegExp(filters.regex);
|
|
return filters;
|
|
}
|
|
|
|
export function userRequiresImageCache(perm) {
|
|
const permissions = perm ? perm : TVA_CONFIG.permissions;
|
|
const role = game.user.role;
|
|
return (
|
|
permissions.popups[role] ||
|
|
permissions.portrait_right_click[role] ||
|
|
permissions.image_path_button[role] ||
|
|
permissions.hudFullAccess[role]
|
|
);
|
|
}
|
|
|
|
export async function waitForTokenTexture(token, callback, checks = 40) {
|
|
// v10/v9 compatibility
|
|
|
|
if (!token.mesh || !token.mesh.texture) {
|
|
checks--;
|
|
if (checks > 1)
|
|
new Promise((resolve) => setTimeout(resolve, 1)).then(() =>
|
|
waitForTokenTexture(token, callback, checks)
|
|
);
|
|
return;
|
|
}
|
|
|
|
callback(token);
|
|
}
|
|
|
|
export function flattenSearchResults(results) {
|
|
let flattened = [];
|
|
if (!results) return flattened;
|
|
results.forEach((images) => {
|
|
flattened = flattened.concat(images);
|
|
});
|
|
return flattened;
|
|
}
|
|
|
|
// Slightly modified version of mergeObject; added an option to ignore -= keys
|
|
export function modMergeObject(
|
|
original,
|
|
other = {},
|
|
{
|
|
insertKeys = true,
|
|
insertValues = true,
|
|
overwrite = true,
|
|
recursive = true,
|
|
inplace = true,
|
|
enforceTypes = false,
|
|
} = {},
|
|
_d = 0
|
|
) {
|
|
other = other || {};
|
|
if (!(original instanceof Object) || !(other instanceof Object)) {
|
|
throw new Error('One of original or other are not Objects!');
|
|
}
|
|
const options = {
|
|
insertKeys,
|
|
insertValues,
|
|
overwrite,
|
|
recursive,
|
|
inplace,
|
|
enforceTypes,
|
|
};
|
|
|
|
// Special handling at depth 0
|
|
if (_d === 0) {
|
|
if (!inplace) original = deepClone(original);
|
|
if (Object.keys(original).some((k) => /\./.test(k))) original = expandObject(original);
|
|
if (Object.keys(other).some((k) => /\./.test(k))) other = expandObject(other);
|
|
}
|
|
|
|
// Iterate over the other object
|
|
for (let k of Object.keys(other)) {
|
|
const v = other[k];
|
|
if (original.hasOwnProperty('-=' + k)) {
|
|
original[k] = original['-=' + k];
|
|
delete original['-=' + k];
|
|
}
|
|
if (original.hasOwnProperty(k)) _modMergeUpdate(original, k, v, options, _d + 1);
|
|
else _modMergeInsert(original, k, v, options, _d + 1);
|
|
}
|
|
return original;
|
|
}
|
|
|
|
/**
|
|
* A helper function for merging objects when the target key does not exist in the original
|
|
* @private
|
|
*/
|
|
function _modMergeInsert(original, k, v, { insertKeys, insertValues } = {}, _d) {
|
|
// Recursively create simple objects
|
|
if (v?.constructor === Object) {
|
|
original[k] = modMergeObject({}, v, {
|
|
insertKeys: true,
|
|
inplace: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Delete a key
|
|
// if (k.startsWith('-=')) {
|
|
// delete original[k.slice(2)];
|
|
// return;
|
|
// }
|
|
|
|
// Insert a key
|
|
const canInsert = (_d <= 1 && insertKeys) || (_d > 1 && insertValues);
|
|
if (canInsert) original[k] = v;
|
|
}
|
|
|
|
/**
|
|
* A helper function for merging objects when the target key exists in the original
|
|
* @private
|
|
*/
|
|
function _modMergeUpdate(
|
|
original,
|
|
k,
|
|
v,
|
|
{ insertKeys, insertValues, enforceTypes, overwrite, recursive } = {},
|
|
_d
|
|
) {
|
|
const x = original[k];
|
|
const tv = getType(v);
|
|
const tx = getType(x);
|
|
|
|
// Recursively merge an inner object
|
|
if (tv === 'Object' && tx === 'Object' && recursive) {
|
|
return modMergeObject(
|
|
x,
|
|
v,
|
|
{
|
|
insertKeys: insertKeys,
|
|
insertValues: insertValues,
|
|
overwrite: overwrite,
|
|
inplace: true,
|
|
enforceTypes: enforceTypes,
|
|
},
|
|
_d
|
|
);
|
|
}
|
|
|
|
// Overwrite an existing value
|
|
if (overwrite) {
|
|
if (tx !== 'undefined' && tv !== tx && enforceTypes) {
|
|
throw new Error(`Mismatched data types encountered during object merge.`);
|
|
}
|
|
original[k] = v;
|
|
}
|
|
}
|
|
|
|
export async function tv_executeScript(script, { actor, token, tvaUpdate } = {}) {
|
|
// Add variables to the evaluation scope
|
|
const speaker = ChatMessage.getSpeaker();
|
|
const character = game.user.character;
|
|
|
|
token = token?.object || token || (canvas.ready ? canvas.tokens.get(speaker.token) : null);
|
|
actor = actor || token?.actor || game.actors.get(speaker.actor);
|
|
|
|
// Attempt script execution
|
|
const AsyncFunction = async function () {}.constructor;
|
|
try {
|
|
const fn = AsyncFunction('speaker', 'actor', 'token', 'character', 'tvaUpdate', `${script}`);
|
|
await fn.call(null, speaker, actor, token, character, tvaUpdate);
|
|
} catch (err) {
|
|
ui.notifications.error(
|
|
`There was an error in your script syntax. See the console (F12) for details`
|
|
);
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
export async function executeMacro(macroName, token) {
|
|
token = token?.object || token;
|
|
game.macros.find((m) => m.name === macroName)?.execute({ token });
|
|
}
|
|
|
|
export async function applyTMFXPreset(token, presetName, action = 'apply') {
|
|
token = token.object ?? token;
|
|
if (game.modules.get('tokenmagic')?.active && token.document) {
|
|
const preset = TokenMagic.getPreset(presetName);
|
|
if (preset) {
|
|
if (action === 'apply') {
|
|
await TokenMagic.addUpdateFilters(token, preset);
|
|
} else if (action === 'remove') {
|
|
await TokenMagic.deleteFilters(token, presetName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function toggleTMFXPreset(token, presetName) {
|
|
token = token.object ?? token;
|
|
if (game.modules.get('tokenmagic')?.active && token.document) {
|
|
if (TokenMagic.hasFilterId(token, presetName)) {
|
|
applyTMFXPreset(token, presetName, 'remove');
|
|
} else {
|
|
applyTMFXPreset(token, presetName, 'apply');
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function applyCEEffect(tokenDoc, ceEffect, action = 'apply') {
|
|
if (game.modules.get('dfreds-convenient-effects')?.active) {
|
|
if (!ceEffect.apply && !ceEffect.remove) return;
|
|
else if (!ceEffect.apply || !ceEffect.remove) {
|
|
if (action === 'apply') {
|
|
if (ceEffect.remove) action = 'remove';
|
|
} else return;
|
|
}
|
|
|
|
let uuid = tokenDoc.actor?.uuid;
|
|
if (uuid) {
|
|
if (action === 'apply') {
|
|
await game.dfreds.effectInterface.addEffect({
|
|
effectName: ceEffect.name,
|
|
uuid,
|
|
origin: 'token-variants',
|
|
overlay: false,
|
|
});
|
|
} else {
|
|
await game.dfreds.effectInterface.removeEffect({ effectName: ceEffect.name, uuid });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function toggleCEEffect(token, effectName) {
|
|
if (game.modules.get('dfreds-convenient-effects')?.active) {
|
|
let uuid = (token.document ?? token).actor?.uuid;
|
|
await game.dfreds.effectInterface.toggleEffect(effectName, {
|
|
uuids: [uuid],
|
|
overlay: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
export class TokenDataAdapter {
|
|
static dataToForm(data) {
|
|
if ('texture.scaleX' in data) {
|
|
data.scale = Math.abs(data['texture.scaleX']);
|
|
data.mirrorX = data['texture.scaleX'] < 0;
|
|
}
|
|
if ('texture.scaleY' in data) {
|
|
data.scale = Math.abs(data['texture.scaleY']);
|
|
data.mirrorY = data['texture.scaleY'] < 0;
|
|
}
|
|
}
|
|
|
|
static formToData(token, formData) {
|
|
// Scale/mirroring
|
|
if ('scale' in formData || 'mirrorX' in formData || 'mirrorY' in formData) {
|
|
const doc = token.document ? token.document : token;
|
|
if (!('scale' in formData)) formData.scale = Math.abs(doc.texture.scaleX);
|
|
if (!('mirrorX' in formData)) formData.mirrorX = doc.texture.scaleX < 0;
|
|
if (!('mirrorY' in formData)) formData.mirrorY = doc.texture.scaleY < 0;
|
|
setProperty(formData, 'texture.scaleX', formData.scale * (formData.mirrorX ? -1 : 1));
|
|
setProperty(formData, 'texture.scaleY', formData.scale * (formData.mirrorY ? -1 : 1));
|
|
['scale', 'mirrorX', 'mirrorY'].forEach((k) => delete formData[k]);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function determineAddedRemovedEffects(addedEffects, removedEffects, newEffects, oldEffects) {
|
|
for (const ef of newEffects) {
|
|
if (!oldEffects.includes(ef)) {
|
|
addedEffects.push(ef);
|
|
}
|
|
}
|
|
for (const ef of oldEffects) {
|
|
if (!newEffects.includes(ef)) {
|
|
removedEffects.push(ef);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function wildcardImageSearch(imgSrc) {
|
|
let source = 'data';
|
|
const browseOptions = { wildcard: true };
|
|
|
|
// Support non-user sources
|
|
if (/\.s3\./.test(imgSrc)) {
|
|
source = 's3';
|
|
const { bucket, keyPrefix } = FilePicker.parseS3URL(imgSrc);
|
|
if (bucket) {
|
|
browseOptions.bucket = bucket;
|
|
imgSrc = keyPrefix;
|
|
}
|
|
} else if (imgSrc.startsWith('icons/')) source = 'public';
|
|
|
|
// Retrieve wildcard content
|
|
try {
|
|
const content = await FilePicker.browse(source, imgSrc, browseOptions);
|
|
return content.files;
|
|
} catch (err) {}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Returns a random name generated using Name Forge module
|
|
* @param {*} randomizerSettings
|
|
* @returns
|
|
*/
|
|
export async function nameForgeRandomize(randomizerSettings) {
|
|
const nameForgeSettings = randomizerSettings.nameForge;
|
|
if (nameForgeSettings?.randomize && nameForgeSettings?.models) {
|
|
const nameForge = game.modules.get('nameforge');
|
|
if (nameForge?.active) {
|
|
const randomNames = [];
|
|
for (const modelKey of nameForgeSettings.models) {
|
|
const modelProp = getProperty(nameForge.models, modelKey);
|
|
if (modelProp) {
|
|
const model = await nameForge.api.createModel(modelProp);
|
|
if (model) {
|
|
randomNames.push(nameForge.api.generateName(model)[0]);
|
|
}
|
|
}
|
|
}
|
|
return randomNames[Math.floor(Math.random() * randomNames.length)];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Upload Token and associated overlays as a single image
|
|
*/
|
|
export async function uploadTokenImage(token, options) {
|
|
let renderTexture = captureToken(token, options);
|
|
if (renderTexture) {
|
|
const b64 = canvas.app.renderer.extract.base64(renderTexture, 'image/webp', 1);
|
|
let res = await fetch(b64);
|
|
let blob = await res.blob();
|
|
const filename = options.name + `.webp`;
|
|
let file = new File([blob], filename, { type: 'image/webp' });
|
|
await FilePicker.upload('data', options.path, file, {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Modified version of 'dev7355608' captureCanvas function. Captures combined Token and Overlay image
|
|
*/
|
|
function captureToken(token, { scale = 3, width = null, height = null } = {}) {
|
|
if (!canvas.ready || !token) {
|
|
return;
|
|
}
|
|
|
|
width = width ?? token.texture.width;
|
|
height = height ?? token.texture.height;
|
|
|
|
scale = scale * Math.min(width / token.texture.width, height / token.texture.height);
|
|
|
|
const renderer = canvas.app.renderer;
|
|
const viewPosition = { ...canvas.scene._viewPosition };
|
|
|
|
renderer.resize(width ?? renderer.screen.width, height ?? renderer.screen.height);
|
|
|
|
width = canvas.screenDimensions[0] = renderer.screen.width;
|
|
height = canvas.screenDimensions[1] = renderer.screen.height;
|
|
|
|
canvas.stage.position.set(width / 2, height / 2);
|
|
|
|
canvas.pan({
|
|
x: token.center.x,
|
|
y: token.center.y,
|
|
scale,
|
|
});
|
|
|
|
const renderTexture = PIXI.RenderTexture.create({
|
|
width,
|
|
height,
|
|
resolution: token.texture.resolution,
|
|
});
|
|
|
|
const cacheParent = canvas.stage.enableTempParent();
|
|
|
|
canvas.stage.updateTransform();
|
|
canvas.stage.disableTempParent(cacheParent);
|
|
|
|
let spritesToRender = [token.mesh];
|
|
if (token.tva_sprites) spritesToRender = spritesToRender.concat(token.tva_sprites);
|
|
spritesToRender.sort((sprite) => sprite.sort);
|
|
|
|
for (const sprite of spritesToRender) {
|
|
renderer.render(sprite, { renderTexture, skipUpdateTransform: true, clear: false });
|
|
}
|
|
|
|
canvas._onResize();
|
|
canvas.pan(viewPosition);
|
|
|
|
return renderTexture;
|
|
}
|
|
|
|
export function getAllActorTokens(actor, linked = false, document = false) {
|
|
if (actor.isToken) {
|
|
if (document) return [actor.token];
|
|
else if (actor.token.object) return [actor.token.object];
|
|
else return [];
|
|
}
|
|
|
|
const tokens = [];
|
|
game.scenes.forEach((scene) =>
|
|
scene.tokens.forEach((token) => {
|
|
if (token.actorId === actor.id) {
|
|
if (linked && token.actorLink) tokens.push(token);
|
|
else if (!linked) tokens.push(token);
|
|
}
|
|
})
|
|
);
|
|
if (document) return tokens;
|
|
else return tokens.map((token) => token.object).filter((token) => token);
|
|
}
|
|
|
|
export function extractDimensionsFromImgName(img, dimensions = {}) {
|
|
const name = getFileName(img);
|
|
|
|
let scale;
|
|
if (TVA_CONFIG.imgNameContainsDimensions) {
|
|
const height = name.match(/_height(.*)_/)?.[1];
|
|
if (height) dimensions.height = parseFloat(height);
|
|
const width = name.match(/_width(.*)_/)?.[1];
|
|
if (width) dimensions.width = parseFloat(width);
|
|
scale = name.match(/_scale(.*)_/)?.[1];
|
|
if (scale) scale = Math.max(parseFloat(scale), 0.2);
|
|
}
|
|
if (TVA_CONFIG.imgNameContainsFADimensions) {
|
|
scale = name.match(/_Scale(\d+)_/)?.[1];
|
|
if (scale) {
|
|
scale = Math.max(parseInt(scale) / 100, 0.2);
|
|
}
|
|
}
|
|
if (scale) {
|
|
dimensions['texture.scaleX'] = scale;
|
|
dimensions['texture.scaleY'] = scale;
|
|
}
|
|
return dimensions;
|
|
}
|
|
|
|
export function string2Hex(hexString) {
|
|
return PIXI.utils.string2hex(hexString);
|
|
}
|