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'; import { toggleTemplateDialog } from '../applications/dialogs.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, }); game.keybindings.register('token-variants', 'toggleTemplate', { name: 'Toggle Template Dialog', hint: 'Brings up a dialog from which you can toggle templates on currently selected tokens.', editable: [], onDown: toggleTemplateDialog, 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 decodeURISafely(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 decodeURISafely(path).split('\\').pop().split('/').pop(); } /** * Extract the directory path excluding the file name. */ export function getFilePath(path) { return decodeURISafely(path).match(/(.*)[\/\\]/)[1] || ''; } /** * Simplify name. */ export function simplifyName(name) { return name.replace(simplifyRegex, '').toLowerCase(); } export function simplifyPath(path) { return decodeURIComponentSafely(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.tvaOverlays) spritesToRender = spritesToRender.concat(token.tvaOverlays); 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); } export function decodeURISafely(uri) { try { return decodeURI(uri); } catch (e) { console.warn('URI Component not decodable: ' + uri); return uri; } } export function decodeURIComponentSafely(uri) { try { return decodeURIComponent(uri); } catch (e) { console.warn('URI Component not decodable: ' + uri); return uri; } } export function mergeMappings(from, to) { const changedIDs = {}; for (const m of from) { const i = to.findIndex((mapping) => mapping.label === m.label && mapping.group === m.group); if (i === -1) to.push(m); else { changedIDs[to.id] = m.id; if (to[i].tokens?.length) { if (!m.tokens) m.tokens = []; to[i].tokens.forEach((id) => { if (!m.tokens.includes(id)) m.tokens.push(id); }); } to[i] = m; } } // If parent's id has been changed we need to update all the children to.forEach((m) => { let pID = m.overlayConfig?.parentID; if (pID && pID in changedIDs) { m.overlayConfig.parentID = changedIDs[pID]; } }); return to; } export function isResponsibleGM() { const isResponsibleGM = !game.users .filter((user) => user.isGM && (user.active || user.isActive)) .some((other) => other.id < game.user.id); return isResponsibleGM; }