diff --git a/Data/assets/aysun_adara_fox.jpg b/Data/assets/aysun_adara_fox.jpg
new file mode 100644
index 00000000..15796a55
--- /dev/null
+++ b/Data/assets/aysun_adara_fox.jpg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c44268755e947cc6d493add8b53c917ab3470e25ebc5c6e3c593bb4525a043b6
+size 54611
diff --git a/Data/assets/aysun_adara_fox_token.png b/Data/assets/aysun_adara_fox_token.png
new file mode 100644
index 00000000..890d30bf
--- /dev/null
+++ b/Data/assets/aysun_adara_fox_token.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c7b1eab21cfad7787a5c4d8cd2069d4a9c702c6ce583b249a8513caac4a00862
+size 101908
diff --git a/Data/modules/token-variants/applications/artSelect.js b/Data/modules/token-variants/applications/artSelect.js
new file mode 100644
index 00000000..472c176f
--- /dev/null
+++ b/Data/modules/token-variants/applications/artSelect.js
@@ -0,0 +1,458 @@
+import TokenCustomConfig from './tokenCustomConfig.js';
+import { isVideo, isImage, keyPressed, SEARCH_TYPE, BASE_IMAGE_CATEGORIES, getFileName } from '../scripts/utils.js';
+import { showArtSelect } from '../token-variants.mjs';
+import { TVA_CONFIG, getSearchOptions } from '../scripts/settings.js';
+
+const ART_SELECT_QUEUE = {
+ queue: [],
+};
+
+export function addToArtSelectQueue(search, options) {
+ ART_SELECT_QUEUE.queue.push({
+ search: search,
+ options: options,
+ });
+ $('button#token-variant-art-clear-queue').html(`Clear Queue (${ART_SELECT_QUEUE.queue.length})`).show();
+}
+
+export function addToQueue(search, options) {
+ ART_SELECT_QUEUE.queue.push({
+ search: search,
+ options: options,
+ });
+}
+
+export function renderFromQueue(force = false) {
+ if (!force) {
+ const artSelects = Object.values(ui.windows).filter((app) => app instanceof ArtSelect);
+ if (artSelects.length !== 0) {
+ if (ART_SELECT_QUEUE.queue.length !== 0)
+ $('button#token-variant-art-clear-queue').html(`Clear Queue (${ART_SELECT_QUEUE.queue.length})`).show();
+ return;
+ }
+ }
+
+ let callData = ART_SELECT_QUEUE.queue.shift();
+ if (callData?.options.execute) {
+ callData.options.execute();
+ callData = ART_SELECT_QUEUE.queue.shift();
+ }
+ if (callData) {
+ showArtSelect(callData.search, callData.options);
+ }
+}
+
+function delay(fn, ms) {
+ let timer = 0;
+ return function (...args) {
+ clearTimeout(timer);
+ timer = setTimeout(fn.bind(this, ...args), ms || 0);
+ };
+}
+
+export class ArtSelect extends FormApplication {
+ static instance = null;
+
+ static IMAGE_DISPLAY = {
+ NONE: 0,
+ PORTRAIT: 1,
+ TOKEN: 2,
+ PORTRAIT_TOKEN: 3,
+ IMAGE: 4,
+ };
+
+ constructor(
+ search,
+ {
+ preventClose = false,
+ object = null,
+ callback = null,
+ searchType = null,
+ allImages = null,
+ image1 = '',
+ image2 = '',
+ displayMode = ArtSelect.IMAGE_DISPLAY.NONE,
+ multipleSelection = false,
+ searchOptions = {},
+ } = {}
+ ) {
+ let title = game.i18n.localize('token-variants.windows.art-select.select-variant');
+ if (searchType === SEARCH_TYPE.TOKEN)
+ title = game.i18n.localize('token-variants.windows.art-select.select-token-art');
+ else if (searchType === SEARCH_TYPE.PORTRAIT)
+ title = game.i18n.localize('token-variants.windows.art-select.select-portrait-art');
+
+ super(
+ {},
+ {
+ closeOnSubmit: false,
+ width: ArtSelect.WIDTH || 500,
+ height: ArtSelect.HEIGHT || 500,
+ left: ArtSelect.LEFT,
+ top: ArtSelect.TOP,
+ title: title,
+ }
+ );
+ this.search = search;
+ this.allImages = allImages;
+ this.callback = callback;
+ this.doc = object;
+ this.preventClose = preventClose;
+ this.image1 = image1;
+ this.image2 = image2;
+ this.displayMode = displayMode;
+ this.multipleSelection = multipleSelection;
+ this.searchType = searchType;
+ this.searchOptions = mergeObject(searchOptions, getSearchOptions(), {
+ overwrite: false,
+ });
+ ArtSelect.instance = this;
+ }
+
+ static get defaultOptions() {
+ return mergeObject(super.defaultOptions, {
+ id: 'token-variants-art-select',
+ classes: ['sheet'],
+ template: 'modules/token-variants/templates/artSelect.html',
+ resizable: true,
+ minimizable: false,
+ });
+ }
+
+ _getHeaderButtons() {
+ const buttons = super._getHeaderButtons();
+ buttons.unshift({
+ label: 'FilePicker',
+ class: 'file-picker',
+ icon: 'fas fa-file-import fa-fw',
+ onclick: () => {
+ new FilePicker({
+ type: 'imagevideo',
+ callback: (path) => {
+ if (!this.preventClose) {
+ this.close();
+ }
+ if (this.callback) {
+ this.callback(path, getFileName(path));
+ }
+ },
+ }).render();
+ },
+ });
+ buttons.unshift({
+ label: 'Image Category',
+ class: 'type',
+ icon: 'fas fa-swatchbook',
+ onclick: () => {
+ if (ArtSelect.instance) ArtSelect.instance._typeSelect();
+ },
+ });
+ return buttons;
+ }
+
+ _typeSelect() {
+ const categories = BASE_IMAGE_CATEGORIES.concat(TVA_CONFIG.customImageCategories);
+
+ const buttons = {};
+ for (const c of categories) {
+ let label = c;
+ if (c === this.searchType) {
+ label = '>>> ' + label + ' <<< ';
+ }
+ buttons[c] = {
+ label: label,
+ callback: () => {
+ if (this.searchType !== c) {
+ this.searchType = c;
+ this._performSearch(this.search, true);
+ }
+ },
+ };
+ }
+
+ new Dialog({
+ title: `Select Image Category and Filter`,
+ content: ``,
+ buttons: buttons,
+ }).render(true);
+ }
+
+ async getData(options) {
+ const data = super.getData(options);
+ if (this.doc instanceof Item) {
+ data.item = true;
+ data.description = this.doc.system?.description?.value ?? '';
+ }
+ const searchOptions = this.searchOptions;
+ const algorithm = searchOptions.algorithm;
+
+ //
+ // Create buttons
+ //
+ const tokenConfigs = (TVA_CONFIG.tokenConfigs || []).flat();
+ const fuzzySearch = algorithm.fuzzy;
+
+ let allButtons = new Map();
+ let artFound = false;
+
+ const genLabel = function (str, indices, start = '', end = ' ', fillChar = null) {
+ if (!indices) return str;
+ let fillStr = fillChar ? fillChar.repeat(str.length) : str;
+ let label = '';
+ let lastIndex = 0;
+ for (const index of indices) {
+ label += fillStr.slice(lastIndex, index[0]);
+ label += start + str.slice(index[0], index[1] + 1) + end;
+ lastIndex = index[1] + 1;
+ }
+ label += fillStr.slice(lastIndex, fillStr.length);
+
+ return label;
+ };
+
+ const genTitle = function (obj) {
+ if (!fuzzySearch) return obj.path;
+
+ let percent = Math.ceil((1 - obj.score) * 100) + '%';
+ if (searchOptions.runSearchOnPath) {
+ return percent + '\n' + genLabel(obj.path, obj.indices, '', '', '-') + '\n' + obj.path;
+ }
+ return percent;
+ };
+
+ this.allImages.forEach((images, search) => {
+ const buttons = [];
+ images.forEach((imageObj) => {
+ artFound = true;
+ const vid = isVideo(imageObj.path);
+ const img = isImage(imageObj.path);
+ buttons.push({
+ path: imageObj.path,
+ img: img,
+ vid: vid,
+ type: vid || img,
+ name: imageObj.name,
+ label:
+ fuzzySearch && !searchOptions.runSearchOnPath ? genLabel(imageObj.name, imageObj.indices) : imageObj.name,
+ title: genTitle(imageObj),
+ hasConfig:
+ this.searchType === SEARCH_TYPE.TOKEN || this.searchType === SEARCH_TYPE.PORTRAIT_AND_TOKEN
+ ? Boolean(
+ tokenConfigs.find((config) => config.tvImgSrc == imageObj.path && config.tvImgName == imageObj.name)
+ )
+ : false,
+ });
+ });
+ allButtons.set(search, buttons);
+ });
+
+ if (artFound) data.allImages = allButtons;
+
+ data.search = this.search;
+ data.queue = ART_SELECT_QUEUE.queue.length;
+ data.image1 = this.image1;
+ data.image2 = this.image2;
+ data.displayMode = this.displayMode;
+ data.multipleSelection = this.multipleSelection;
+ data.displaySlider = algorithm.fuzzy && algorithm.fuzzyArtSelectPercentSlider;
+ data.fuzzyThreshold = algorithm.fuzzyThreshold;
+ if (data.displaySlider) {
+ data.fuzzyThreshold = 100 - data.fuzzyThreshold * 100;
+ data.fuzzyThreshold = data.fuzzyThreshold.toFixed(0);
+ }
+ data.autoplay = !TVA_CONFIG.playVideoOnHover;
+ return data;
+ }
+
+ /**
+ * @param {JQuery} html
+ */
+ activateListeners(html) {
+ super.activateListeners(html);
+ const callback = this.callback;
+ const close = () => this.close();
+ const object = this.doc;
+ const preventClose = this.preventClose;
+ const multipleSelection = this.multipleSelection;
+
+ const boxes = html.find(`.token-variants-grid-box`);
+ boxes.hover(
+ function () {
+ if (TVA_CONFIG.playVideoOnHover) {
+ const vid = $(this).siblings('video');
+ if (vid.length) {
+ vid[0].play();
+ $(this).siblings('.fa-play').hide();
+ }
+ }
+ },
+ function () {
+ if (TVA_CONFIG.pauseVideoOnHoverOut) {
+ const vid = $(this).siblings('video');
+ if (vid.length) {
+ vid[0].pause();
+ vid[0].currentTime = 0;
+ $(this).siblings('.fa-play').show();
+ }
+ }
+ }
+ );
+ boxes.map((box) => {
+ boxes[box].addEventListener('click', async function (event) {
+ if (keyPressed('config')) {
+ if (object)
+ new TokenCustomConfig(object, {}, event.target.dataset.name, event.target.dataset.filename).render(true);
+ } else {
+ if (!preventClose) {
+ close();
+ }
+ if (callback) {
+ callback(event.target.dataset.name, event.target.dataset.filename);
+ }
+ }
+ });
+ if (multipleSelection) {
+ boxes[box].addEventListener('contextmenu', async function (event) {
+ $(event.target).toggleClass('selected');
+ });
+ }
+ });
+
+ let searchInput = html.find('#custom-art-search');
+ searchInput.focus();
+ searchInput[0].selectionStart = searchInput[0].selectionEnd = 10000;
+
+ searchInput.on(
+ 'input',
+ delay((event) => {
+ this._performSearch(event.target.value);
+ }, 350)
+ );
+
+ html.find(`button#token-variant-art-clear-queue`).on('click', (event) => {
+ ART_SELECT_QUEUE.queue = ART_SELECT_QUEUE.queue.filter((callData) => callData.options.execute);
+ $(event.target).hide();
+ });
+
+ $(html)
+ .find('[name="fuzzyThreshold"]')
+ .change((e) => {
+ $(e.target)
+ .siblings('.token-variants-range-value')
+ .html(`${parseFloat(e.target.value).toFixed(0)}%`);
+ this.searchOptions.algorithm.fuzzyThreshold = (100 - e.target.value) / 100;
+ })
+ .change(
+ delay((event) => {
+ this._performSearch(this.search, true);
+ }, 350)
+ );
+
+ if (multipleSelection) {
+ html.find(`button#token-variant-art-return-selected`).on('click', () => {
+ if (callback) {
+ const images = [];
+ html
+ .find(`.token-variants-grid-box.selected`)
+ .siblings('.token-variants-grid-image')
+ .each(function () {
+ images.push(this.getAttribute('src'));
+ });
+ callback(images);
+ }
+ close();
+ });
+ html.find(`button#token-variant-art-return-all`).on('click', () => {
+ if (callback) {
+ const images = [];
+ html.find(`.token-variants-grid-image`).each(function () {
+ images.push(this.getAttribute('src'));
+ });
+ callback(images);
+ }
+ close();
+ });
+ }
+ }
+
+ _performSearch(search, force = false) {
+ if (!force && this.search.trim() === search.trim()) return;
+ showArtSelect(search, {
+ callback: this.callback,
+ searchType: this.searchType,
+ object: this.doc,
+ force: true,
+ image1: this.image1,
+ image2: this.image2,
+ displayMode: this.displayMode,
+ multipleSelection: this.multipleSelection,
+ searchOptions: this.searchOptions,
+ preventClose: this.preventClose,
+ });
+ }
+
+ /**
+ * @param {Event} event
+ * @param {Object} formData
+ */
+ async _updateObject(event, formData) {
+ if (formData && formData.search != this.search) {
+ this._performSearch(formData.search);
+ } else {
+ this.close();
+ }
+ }
+
+ setPosition(options = {}) {
+ if (options.width) ArtSelect.WIDTH = options.width;
+ if (options.height) ArtSelect.HEIGHT = options.height;
+ if (options.top) ArtSelect.TOP = options.top;
+ if (options.left) ArtSelect.LEFT = options.left;
+ super.setPosition(options);
+ }
+
+ async close(options = {}) {
+ let callData = ART_SELECT_QUEUE.queue.shift();
+ if (callData?.options.execute) {
+ callData.options.execute();
+ callData = ART_SELECT_QUEUE.queue.shift();
+ }
+ if (callData) {
+ callData.options.force = true;
+ showArtSelect(callData.search, callData.options);
+ } else {
+ // For some reason there might be app instances that have not closed themselves by this point
+ // If there are, close them now
+ const artSelects = Object.values(ui.windows)
+ .filter((app) => app instanceof ArtSelect)
+ .filter((app) => app.appId !== this.appId);
+ for (const app of artSelects) {
+ app.close();
+ }
+
+ return super.close(options);
+ }
+ }
+}
+
+export function insertArtSelectButton(html, target, { search = '', searchType = SEARCH_TYPE.TOKEN } = {}) {
+ const button = $(`
+
+ `);
+ button.on('click', () => {
+ showArtSelect(search, {
+ callback: (imgSrc, name) => {
+ button.siblings(`[name="${target}"]`).val(imgSrc);
+ },
+ searchType,
+ });
+ });
+ const input = html.find(`[name="${target}"]`);
+ input.after(button);
+ return Boolean(input.length);
+}
diff --git a/Data/modules/token-variants/applications/compendiumMap.js b/Data/modules/token-variants/applications/compendiumMap.js
new file mode 100644
index 00000000..282f15bf
--- /dev/null
+++ b/Data/modules/token-variants/applications/compendiumMap.js
@@ -0,0 +1,460 @@
+import { showArtSelect } from '../token-variants.mjs';
+import {
+ BASE_IMAGE_CATEGORIES,
+ SEARCH_TYPE,
+ updateActorImage,
+ updateTokenImage,
+ userRequiresImageCache,
+} from '../scripts/utils.js';
+import { addToQueue, ArtSelect, renderFromQueue } from './artSelect.js';
+import { getSearchOptions, TVA_CONFIG, updateSettings } from '../scripts/settings.js';
+import ConfigureSettings from './configureSettings.js';
+import MissingImageConfig from './missingImageConfig.js';
+import { cacheImages, doImageSearch } from '../scripts/search.js';
+
+async function autoApply(actor, image1, image2, formData, typeOverride) {
+ let portraitFound = formData.ignorePortrait;
+ let tokenFound = formData.ignoreToken;
+
+ if (formData.diffImages) {
+ let results = [];
+
+ if (!formData.ignorePortrait) {
+ results = await doImageSearch(actor.name, {
+ searchType: typeOverride ?? SEARCH_TYPE.PORTRAIT,
+ simpleResults: true,
+ searchOptions: formData.searchOptions,
+ });
+
+ if ((results ?? []).length != 0) {
+ portraitFound = true;
+ await updateActorImage(actor, results[0], false, formData.compendium);
+ }
+ }
+
+ if (!formData.ignoreToken) {
+ results = await doImageSearch(actor.prototypeToken.name, {
+ searchType: SEARCH_TYPE.TOKEN,
+ simpleResults: true,
+ searchOptions: formData.searchOptions,
+ });
+
+ if ((results ?? []).length != 0) {
+ tokenFound = true;
+ updateTokenImage(results[0], {
+ actor: actor,
+ pack: formData.compendium,
+ applyDefaultConfig: false,
+ });
+ }
+ }
+ } else {
+ let results = await doImageSearch(actor.name, {
+ searchType: typeOverride ?? SEARCH_TYPE.PORTRAIT_AND_TOKEN,
+ simpleResults: true,
+ searchOptions: formData.searchOptions,
+ });
+
+ if ((results ?? []).length != 0) {
+ portraitFound = tokenFound = true;
+ updateTokenImage(results[0], {
+ actor: actor,
+ actorUpdate: { img: results[0] },
+ pack: formData.compendium,
+ applyDefaultConfig: false,
+ });
+ }
+ }
+
+ if (!(tokenFound && portraitFound) && formData.autoDisplayArtSelect) {
+ addToArtSelectQueue(actor, image1, image2, formData, typeOverride);
+ }
+}
+
+function addToArtSelectQueue(actor, image1, image2, formData, typeOverride) {
+ if (formData.diffImages) {
+ if (!formData.ignorePortrait && !formData.ignoreToken) {
+ addToQueue(actor.name, {
+ searchType: typeOverride ?? SEARCH_TYPE.PORTRAIT,
+ object: actor,
+ preventClose: true,
+ image1: image1,
+ image2: image2,
+ displayMode: ArtSelect.IMAGE_DISPLAY.PORTRAIT,
+ searchOptions: formData.searchOptions,
+ callback: async function (imgSrc, _) {
+ await updateActorImage(actor, imgSrc);
+ showArtSelect(actor.prototypeToken.name, {
+ searchType: typeOverride ?? SEARCH_TYPE.TOKEN,
+ object: actor,
+ force: true,
+ image1: imgSrc,
+ image2: image2,
+ displayMode: ArtSelect.IMAGE_DISPLAY.TOKEN,
+ searchOptions: formData.searchOptions,
+ callback: (imgSrc, name) =>
+ updateTokenImage(imgSrc, {
+ actor: actor,
+ imgName: name,
+ applyDefaultConfig: false,
+ }),
+ });
+ },
+ });
+ } else if (formData.ignorePortrait) {
+ addToQueue(actor.name, {
+ searchType: typeOverride ?? SEARCH_TYPE.TOKEN,
+ object: actor,
+ image1: image1,
+ image2: image2,
+ displayMode: ArtSelect.IMAGE_DISPLAY.TOKEN,
+ searchOptions: formData.searchOptions,
+ callback: async function (imgSrc, name) {
+ updateTokenImage(imgSrc, {
+ actor: actor,
+ imgName: name,
+ applyDefaultConfig: false,
+ });
+ },
+ });
+ } else if (formData.ignoreToken) {
+ addToQueue(actor.name, {
+ searchType: typeOverride ?? SEARCH_TYPE.PORTRAIT,
+ object: actor,
+ image1: image1,
+ image2: image2,
+ displayMode: ArtSelect.IMAGE_DISPLAY.PORTRAIT,
+ searchOptions: formData.searchOptions,
+ callback: async function (imgSrc, name) {
+ await updateActorImage(actor, imgSrc);
+ },
+ });
+ }
+ } else {
+ addToQueue(actor.name, {
+ searchType: typeOverride ?? SEARCH_TYPE.PORTRAIT_AND_TOKEN,
+ object: actor,
+ image1: image1,
+ image2: image2,
+ displayMode: ArtSelect.IMAGE_DISPLAY.PORTRAIT_TOKEN,
+ searchOptions: formData.searchOptions,
+ callback: async function (imgSrc, name) {
+ await updateActorImage(actor, imgSrc);
+ updateTokenImage(imgSrc, {
+ actor: actor,
+ imgName: name,
+ applyDefaultConfig: false,
+ });
+ },
+ });
+ }
+}
+
+export default class CompendiumMapConfig extends FormApplication {
+ constructor() {
+ super({}, {});
+ this.searchOptions = deepClone(getSearchOptions());
+ mergeObject(this.searchOptions, deepClone(TVA_CONFIG.compendiumMapper.searchOptions));
+ this._fixSearchPaths();
+ }
+
+ static get defaultOptions() {
+ return mergeObject(super.defaultOptions, {
+ id: 'token-variants-compendium-map-config',
+ classes: ['sheet'],
+ template: 'modules/token-variants/templates/compendiumMap.html',
+ resizable: false,
+ minimizable: false,
+ title: game.i18n.localize('token-variants.settings.compendium-mapper.Name'),
+ width: 500,
+ });
+ }
+
+ async getData(options) {
+ let data = super.getData(options);
+ data = mergeObject(data, TVA_CONFIG.compendiumMapper);
+
+ const supportedPacks = ['Actor', 'Cards', 'Item', 'Macro', 'RollTable'];
+ data.supportedPacks = supportedPacks.join(', ');
+
+ const packs = [];
+ game.packs.forEach((pack) => {
+ if (!pack.locked && supportedPacks.includes(pack.documentName)) {
+ packs.push({ title: pack.title, id: pack.collection, type: pack.documentName });
+ }
+ });
+ data.compendiums = packs;
+ data.compendium = TVA_CONFIG.compendiumMapper.compendium;
+
+ data.categories = BASE_IMAGE_CATEGORIES.concat(TVA_CONFIG.customImageCategories);
+ data.category = TVA_CONFIG.compendiumMapper.category;
+
+ return data;
+ }
+
+ /**
+ * @param {JQuery} html
+ */
+ activateListeners(html) {
+ super.activateListeners(html);
+ html.find('.token-variants-override-category').change(this._onCategoryOverride).trigger('change');
+ html.find('.token-variants-auto-apply').change(this._onAutoApply);
+ html.find('.token-variants-diff-images').change(this._onDiffImages);
+ html.find(`.token-variants-search-options`).on('click', this._onSearchOptions.bind(this));
+ html.find(`.token-variants-missing-images`).on('click', this._onMissingImages.bind(this));
+
+ $(html).find('[name="compendium"]').change(this._onCompendiumSelect.bind(this)).trigger('change');
+ }
+
+ async _onAutoApply(event) {
+ $(event.target).closest('form').find('.token-variants-auto-art-select').prop('disabled', !event.target.checked);
+ }
+
+ async _onCategoryOverride(event) {
+ $(event.target).closest('form').find('.token-variants-category').prop('disabled', !event.target.checked);
+ }
+
+ async _onDiffImages(event) {
+ $(event.target).closest('form').find('.token-variants-tp-ignore').prop('disabled', !event.target.checked);
+ }
+
+ async _onCompendiumSelect(event) {
+ const compendium = game.packs.get($(event.target).val());
+ if (compendium) {
+ $(event.target)
+ .closest('form')
+ .find('.token-specific')
+ .css('visibility', compendium.documentName === 'Actor' ? 'visible' : 'hidden');
+ }
+ }
+
+ _fixSearchPaths() {
+ if (!this.searchOptions.searchPaths?.length) {
+ this.searchOptions.searchPaths = deepClone(TVA_CONFIG.searchPaths);
+ }
+ }
+
+ async _onSearchOptions(event) {
+ this._fixSearchPaths();
+ new ConfigureSettings(this.searchOptions, {
+ searchPaths: true,
+ searchFilters: true,
+ searchAlgorithm: true,
+ randomizer: false,
+ features: false,
+ popup: false,
+ permissions: false,
+ worldHud: false,
+ misc: false,
+ activeEffects: false,
+ }).render(true);
+ }
+
+ async _onMissingImages(event) {
+ new MissingImageConfig().render(true);
+ }
+
+ async startMapping(formData) {
+ if (formData.diffImages && formData.ignoreToken && formData.ignorePortrait) {
+ return;
+ }
+
+ const originalSearchPaths = TVA_CONFIG.searchPaths;
+ if (formData.searchOptions.searchPaths?.length) {
+ TVA_CONFIG.searchPaths = formData.searchOptions.searchPaths;
+ }
+
+ if (formData.cache || !userRequiresImageCache() || formData.searchOptions.searchPaths?.length) {
+ await cacheImages();
+ }
+
+ const endMapping = function () {
+ if (formData.searchOptions.searchPaths?.length) {
+ TVA_CONFIG.searchPaths = originalSearchPaths;
+ cacheImages();
+ }
+ };
+
+ const compendium = game.packs.get(formData.compendium);
+ let missingImageList = TVA_CONFIG.compendiumMapper.missingImages
+ .filter((mi) => mi.document === 'all' || mi.document === compendium.documentName)
+ .map((mi) => mi.image);
+ const typeOverride = formData.overrideCategory ? formData.category : null;
+ let artSelectDisplayed = false;
+
+ let processItem;
+ if (compendium.documentName === 'Actor') {
+ processItem = async function (item) {
+ const actor = await compendium.getDocument(item._id);
+ if (actor.name === '#[CF_tempEntity]') return; // Compendium Folders module's control entity
+
+ let hasPortrait = actor.img !== CONST.DEFAULT_TOKEN && !missingImageList.includes(actor.img);
+ let hasToken =
+ actor.prototypeToken.texture.src !== CONST.DEFAULT_TOKEN &&
+ !missingImageList.includes(actor.prototypeToken.texture.src);
+ if (formData.syncImages && hasPortrait !== hasToken) {
+ if (hasPortrait) {
+ await updateTokenImage(actor.img, { actor: actor, applyDefaultConfig: false });
+ } else {
+ await updateActorImage(actor, actor.prototypeToken.texture.src);
+ }
+ hasPortrait = hasToken = true;
+ }
+
+ let includeThisActor = !(formData.missingOnly && hasPortrait) && !formData.ignorePortrait;
+ let includeThisToken = !(formData.missingOnly && hasToken) && !formData.ignoreToken;
+
+ const image1 = formData.showImages ? actor.img : '';
+ const image2 = formData.showImages ? actor.prototypeToken.texture.src : '';
+
+ if (includeThisActor || includeThisToken) {
+ if (formData.autoApply) {
+ await autoApply(actor, image1, image2, formData, typeOverride);
+ } else {
+ artSelectDisplayed = true;
+ addToArtSelectQueue(actor, image1, image2, formData, typeOverride);
+ }
+ }
+ };
+ } else {
+ processItem = async function (item) {
+ const doc = await compendium.getDocument(item._id);
+ if (doc.name === '#[CF_tempEntity]') return; // Compendium Folders module's control entity
+
+ let defaultImg = '';
+ if (doc.schema.fields.img || doc.schema.fields.texture) {
+ defaultImg = (doc.schema.fields.img ?? doc.schema.fields.texture).initial();
+ }
+ const hasImage = doc.img != null && doc.img !== defaultImg && !missingImageList.includes(doc.img);
+
+ let imageFound = false;
+ if (formData.missingOnly && hasImage) return;
+ if (formData.autoApply) {
+ let results = await doImageSearch(doc.name, {
+ searchType: typeOverride ?? compendium.documentName,
+ simpleResults: true,
+ searchOptions: formData.searchOptions,
+ });
+
+ if ((results ?? []).length != 0) {
+ imageFound = true;
+ await updateActorImage(doc, results[0], false, formData.compendium);
+ }
+ }
+
+ if (!formData.autoApply || (formData.autoDisplayArtSelect && !imageFound)) {
+ artSelectDisplayed = true;
+ addToQueue(doc.name, {
+ searchType: typeOverride ?? compendium.documentName,
+ object: doc,
+ image1: formData.showImages ? doc.img : '',
+ displayMode: ArtSelect.IMAGE_DISPLAY.IMAGE,
+ searchOptions: formData.searchOptions,
+ callback: async function (imgSrc, name) {
+ await updateActorImage(doc, imgSrc);
+ },
+ });
+ }
+ };
+ }
+
+ const allItems = [];
+ compendium.index.forEach((k) => {
+ allItems.push(k);
+ });
+
+ if (formData.autoApply) {
+ let processing = true;
+ let stopProcessing = false;
+ let processed = 0;
+ let counter = $(`
+
Preset (TMFX)
+
+
+
+
+ `;
+ }
+ return '';
+}
+
+async function promptParamChoice(params) {
+ return new Promise((resolve, reject) => {
+ const buttons = {};
+ for (let i = 0; i < params.length; i++) {
+ const label = params[i].filterType ?? params[i].filterId;
+ buttons[label] = {
+ label,
+ callback: () => {
+ resolve(i);
+ },
+ };
+ }
+
+ const dialog = new Dialog({
+ title: 'Select Filter To Edit',
+ content: '',
+ buttons,
+ close: () => resolve(-1),
+ });
+ dialog.render(true);
+ });
+}
diff --git a/Data/modules/token-variants/applications/randomizerConfig.js b/Data/modules/token-variants/applications/randomizerConfig.js
new file mode 100644
index 00000000..4ffc3cc3
--- /dev/null
+++ b/Data/modules/token-variants/applications/randomizerConfig.js
@@ -0,0 +1,109 @@
+export default class RandomizerConfig extends FormApplication {
+ constructor(obj) {
+ super({}, {});
+ this.actor = game.actors.get(obj.actorId);
+ }
+
+ static get defaultOptions() {
+ return mergeObject(super.defaultOptions, {
+ id: 'token-variants-token-flags',
+ classes: ['sheet'],
+ template: 'modules/token-variants/templates/randomizerConfig.html',
+ resizable: true,
+ minimizable: false,
+ title: 'Randomizer',
+ width: 500,
+ });
+ }
+
+ async getData(options) {
+ const data = super.getData(options);
+ const settings = this.actor.getFlag('token-variants', 'randomizerSettings') || {};
+ data.randomizer = settings;
+ data.hasSettings = !isEmpty(settings);
+ data.nameForgeActive = game.modules.get('nameforge')?.active;
+ if (data.randomizer.nameForge?.models && Array.isArray(data.randomizer.nameForge.models)) {
+ data.randomizer.nameForge.models = data.randomizer.nameForge.models.join(',');
+ }
+ return data;
+ }
+
+ /**
+ * @param {JQuery} html
+ */
+ activateListeners(html) {
+ super.activateListeners(html);
+ html.find('.selectNameForgeModels').click(this._selectNameForgeModels.bind(this));
+
+ // Can't have both tokenName and actorName checkboxes checked at the same time
+ const tokenName = html.find('input[name="randomizer.tokenName"]');
+ const actorName = html.find('input[name="randomizer.actorName"]');
+ tokenName.change(() => {
+ if (tokenName.is(':checked')) actorName.prop('checked', false);
+ });
+ actorName.change(() => {
+ if (actorName.is(':checked')) tokenName.prop('checked', false);
+ });
+ }
+
+ _selectNameForgeModels(event) {
+ const inputSelected = $(event.target).siblings('input');
+ const selected = inputSelected.val().split(',');
+ const genCheckbox = function (name, value) {
+ return `
+
+ `;
+ };
+
+ let content = '
`;
+
+ new Dialog({
+ title: `Name Forge Models`,
+ content: content,
+ buttons: {
+ Ok: {
+ label: `Select`,
+ callback: async (html) => {
+ const selectedModels = [];
+ html.find('input[type="checkbox"]').each(function () {
+ if (this.checked) selectedModels.push(this.value);
+ });
+ inputSelected.val(selectedModels.join(','));
+ },
+ },
+ },
+ }).render(true);
+ }
+
+ /**
+ * @param {Event} event
+ * @param {Object} formData
+ */
+ async _updateObject(event, formData) {
+ if (event.submitter.value === 'remove') {
+ await this.actor.unsetFlag('token-variants', 'randomizerSettings');
+ } else {
+ const expanded = expandObject(formData);
+ if (expanded.randomizer.nameForge?.models) {
+ expanded.randomizer.nameForge.models = expanded.randomizer.nameForge.models.split(',');
+ }
+ this.actor.setFlag('token-variants', 'randomizerSettings', expanded.randomizer);
+ }
+ }
+}
diff --git a/Data/modules/token-variants/applications/tileHUD.js b/Data/modules/token-variants/applications/tileHUD.js
new file mode 100644
index 00000000..d4ec6f7a
--- /dev/null
+++ b/Data/modules/token-variants/applications/tileHUD.js
@@ -0,0 +1,428 @@
+import { getFileName, isImage, isVideo, SEARCH_TYPE, keyPressed } from '../scripts/utils.js';
+import { TVA_CONFIG } from '../scripts/settings.js';
+import FlagsConfig from './flagsConfig.js';
+import { doImageSearch } from '../scripts/search.js';
+import UserList from './userList.js';
+
+export async function renderTileHUD(hud, html, tileData, searchText = '', fp_files = null) {
+ const tile = hud.object;
+ const hudSettings = TVA_CONFIG.hud;
+
+ if (!hudSettings.enableSideMenu || !TVA_CONFIG.tilesEnabled) return;
+
+ const button = $(`
+
+
+
+`);
+
+ html.find('div.right').last().append(button);
+ html.find('div.right').click(_deactivateTokenVariantsSideSelector);
+
+ button.click((event) => _onButtonClick(event, tile));
+ button.contextmenu((event) => _onButtonRightClick(event, tile));
+}
+
+async function _onButtonClick(event, tile) {
+ if (keyPressed('config')) {
+ setNameDialog(tile);
+ return;
+ }
+
+ const button = $(event.target).closest('.control-icon');
+
+ // De-activate 'Status Effects'
+ button.closest('div.right').find('div.control-icon.effects').removeClass('active');
+ button.closest('div.right').find('.status-effects').removeClass('active');
+
+ // Remove contextmenu
+ button.find('.contextmenu').remove();
+
+ // Toggle variants side menu
+ button.toggleClass('active');
+ let variantsWrap = button.find('.token-variants-wrap');
+ if (button.hasClass('active')) {
+ if (!variantsWrap.length) {
+ variantsWrap = await renderSideSelect(tile);
+ if (variantsWrap) button.find('img').after(variantsWrap);
+ else return;
+ }
+ variantsWrap.addClass('active');
+ } else {
+ variantsWrap.removeClass('active');
+ }
+}
+
+function _onButtonRightClick(event, tile) {
+ // Display side menu if button is not active yet
+ const button = $(event.target).closest('.control-icon');
+ if (!button.hasClass('active')) {
+ // button.trigger('click');
+ button.addClass('active');
+ }
+
+ if (button.find('.contextmenu').length) {
+ // Contextmenu already displayed. Remove and activate images
+ button.find('.contextmenu').remove();
+ button.removeClass('active').trigger('click');
+ //button.find('.token-variants-wrap.images').addClass('active');
+ } else {
+ // Contextmenu is not displayed. Hide images, create it and add it
+ button.find('.token-variants-wrap.images').removeClass('active');
+ const contextMenu = $(`
+
+ `);
+ button.append(contextMenu);
+
+ // Register contextmenu listeners
+ contextMenu
+ .find('.token-variants-side-search')
+ .on('keydown', (event) => _onImageSearchKeyUp(event, tile))
+ .on('click', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ contextMenu.find('.flags').click((event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ new FlagsConfig(tile).render(true);
+ });
+ contextMenu.find('.file-picker').click(async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ new FilePicker({
+ type: 'folder',
+ callback: async (path, fp) => {
+ const content = await FilePicker.browse(fp.activeSource, fp.result.target);
+ let files = content.files.filter((f) => isImage(f) || isVideo(f));
+ if (files.length) {
+ button.find('.token-variants-wrap').remove();
+ const sideSelect = await renderSideSelect(tile, null, files);
+ if (sideSelect) {
+ sideSelect.addClass('active');
+ button.append(sideSelect);
+ }
+ }
+ },
+ }).render(true);
+ });
+ }
+}
+
+function _deactivateTokenVariantsSideSelector(event) {
+ const controlIcon = $(event.target).closest('.control-icon');
+ const dataAction = controlIcon.attr('data-action');
+
+ switch (dataAction) {
+ case 'effects':
+ break; // Effects button
+ case 'thwildcard-selector':
+ break; // Token HUD Wildcard module button
+ default:
+ return;
+ }
+
+ $(event.target)
+ .closest('div.right')
+ .find('.control-icon[data-action="token-variants-side-selector"]')
+ .removeClass('active');
+ $(event.target).closest('div.right').find('.token-variants-wrap').removeClass('active');
+}
+
+async function renderSideSelect(tile, searchText = null, fp_files = null) {
+ const hudSettings = TVA_CONFIG.hud;
+ const worldHudSettings = TVA_CONFIG.worldHud;
+ const FULL_ACCESS = TVA_CONFIG.permissions.hudFullAccess[game.user.role];
+ let images = [];
+ let variants = [];
+ let imageDuplicates = new Set();
+ const pushImage = (img) => {
+ if (imageDuplicates.has(img.path)) {
+ if (!images.find((obj) => obj.path === img.path && obj.name === img.name)) {
+ images.push(img);
+ }
+ } else {
+ images.push(img);
+ imageDuplicates.add(img.path);
+ }
+ };
+
+ if (!fp_files) {
+ if (searchText !== null && !searchText) return;
+
+ if (!searchText) {
+ variants = tile.document.getFlag('token-variants', 'variants') || [];
+ variants.forEach((variant) => {
+ for (const name of variant.names) {
+ pushImage({ path: variant.imgSrc, name: name });
+ }
+ });
+
+ // Parse directory flag and include the images
+ const directoryFlag = tile.document.getFlag('token-variants', 'directory');
+ if (directoryFlag) {
+ let dirFlagImages;
+ try {
+ let path = directoryFlag.path;
+ let source = directoryFlag.source;
+ let bucket = '';
+ if (source.startsWith('s3:')) {
+ bucket = source.substring(3, source.length);
+ source = 's3';
+ }
+ const content = await FilePicker.browse(source, path, {
+ type: 'imagevideo',
+ bucket,
+ });
+ dirFlagImages = content.files;
+ } catch (err) {
+ dirFlagImages = [];
+ }
+ dirFlagImages.forEach((f) => {
+ if (isImage(f) || isVideo(f)) pushImage({ path: f, name: getFileName(f) });
+ });
+ }
+ }
+
+ // Perform the search if needed
+ const search = searchText ?? tile.document.getFlag('token-variants', 'tileName');
+ const noSearch = !search || (!searchText && worldHudSettings.displayOnlySharedImages);
+ let artSearch = noSearch
+ ? null
+ : await doImageSearch(search, {
+ searchType: SEARCH_TYPE.TILE,
+ searchOptions: { keywordSearch: worldHudSettings.includeKeywords },
+ });
+
+ if (artSearch) {
+ artSearch.forEach((results) => {
+ images.push(...results);
+ });
+ }
+ } else {
+ images = fp_files.map((f) => {
+ return { path: f, name: getFileName(f) };
+ });
+ }
+
+ // Retrieving the possibly custom name attached as a flag to the token
+ let tileImageName = tile.document.getFlag('token-variants', 'name');
+ if (!tileImageName) {
+ tileImageName = getFileName(tile.document.texture.src);
+ }
+
+ let imagesParsed = [];
+
+ for (const imageObj of images) {
+ const img = isImage(imageObj.path);
+ const vid = isVideo(imageObj.path);
+
+ let shared = false;
+ if (game.user.isGM) {
+ variants.forEach((variant) => {
+ if (variant.imgSrc === imageObj.path && variant.names.includes(imageObj.name)) {
+ shared = true;
+ }
+ });
+ }
+
+ const userMappings = tile.document.getFlag('token-variants', 'userMappings') || {};
+ const [title, style] = genTitleAndStyle(userMappings, imageObj.path, imageObj.name);
+
+ imagesParsed.push({
+ route: imageObj.path,
+ name: imageObj.name,
+ used: imageObj.path === tile.document.texture.src && imageObj.name === tileImageName,
+ img,
+ vid,
+ unknownType: !img && !vid,
+ shared: shared,
+ hasConfig: false, //hasConfig,
+ title: title,
+ style: game.user.isGM && style ? 'box-shadow: ' + style + ';' : null,
+ });
+ }
+
+ //
+ // Render
+ //
+ const imageDisplay = hudSettings.displayAsImage;
+ const imageOpacity = hudSettings.imageOpacity / 100;
+
+ const sideSelect = $(
+ await renderTemplate('modules/token-variants/templates/sideSelect.html', {
+ imagesParsed,
+ imageDisplay,
+ imageOpacity,
+ autoplay: !TVA_CONFIG.playVideoOnHover,
+ })
+ );
+
+ // Activate listeners
+ sideSelect.find('video').hover(
+ function () {
+ if (TVA_CONFIG.playVideoOnHover) {
+ this.play();
+ $(this).siblings('.fa-play').hide();
+ }
+ },
+ function () {
+ if (TVA_CONFIG.pauseVideoOnHoverOut) {
+ this.pause();
+ this.currentTime = 0;
+ $(this).siblings('.fa-play').show();
+ }
+ }
+ );
+ sideSelect.find('.token-variants-button-select').click((event) => _onImageClick(event, tile));
+
+ if (FULL_ACCESS) {
+ sideSelect
+ .find('.token-variants-button-select')
+ .on('contextmenu', (event) => _onImageRightClick(event, tile));
+ }
+
+ return sideSelect;
+}
+
+async function _onImageClick(event, tile) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!tile) return;
+
+ const imgButton = $(event.target).closest('.token-variants-button-select');
+ const imgSrc = imgButton.attr('data-name');
+ const name = imgButton.attr('data-filename');
+ if (imgSrc) {
+ canvas.tiles.hud.clear();
+ await tile.document.update({ img: imgSrc });
+ try {
+ if (getFileName(imgSrc) !== name) await tile.document.setFlag('token-variants', 'name', name);
+ } catch (e) {}
+ }
+}
+
+async function _onImageRightClick(event, tile) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!tile) return;
+
+ const imgButton = $(event.target).closest('.token-variants-button-select');
+ const imgSrc = imgButton.attr('data-name');
+ const name = imgButton.attr('data-filename');
+
+ if (!imgSrc || !name) return;
+
+ if (keyPressed('config') && game.user.isGM) {
+ const regenStyle = (tile, img) => {
+ const mappings = tile.document.getFlag('token-variants', 'userMappings') || {};
+ const name = imgButton.attr('data-filename');
+ const [title, style] = genTitleAndStyle(mappings, img, name);
+ imgButton
+ .closest('.token-variants-wrap')
+ .find(`.token-variants-button-select[data-name='${img}']`)
+ .css('box-shadow', style)
+ .prop('title', title);
+ };
+ new UserList(tile, imgSrc, regenStyle).render(true);
+ return;
+ }
+
+ let variants = tile.document.getFlag('token-variants', 'variants') || [];
+
+ // Remove selected variant if present in the flag, add otherwise
+ let del = false;
+ let updated = false;
+ for (let variant of variants) {
+ if (variant.imgSrc === imgSrc) {
+ let fNames = variant.names.filter((name) => name !== name);
+ if (fNames.length === 0) {
+ del = true;
+ } else if (fNames.length === variant.names.length) {
+ fNames.push(name);
+ }
+ variant.names = fNames;
+ updated = true;
+ break;
+ }
+ }
+ if (del) variants = variants.filter((variant) => variant.imgSrc !== imgSrc);
+ else if (!updated) variants.push({ imgSrc: imgSrc, names: [name] });
+
+ // Set shared variants as a flag
+ tile.document.unsetFlag('token-variants', 'variants');
+ if (variants.length > 0) {
+ tile.document.setFlag('token-variants', 'variants', variants);
+ }
+ imgButton.find('.fa-share').toggleClass('active'); // Display green arrow
+}
+
+async function _onImageSearchKeyUp(event, tile) {
+ if (event.key === 'Enter' || event.keyCode === 13) {
+ event.preventDefault();
+ if (event.target.value.length >= 3) {
+ const button = $(event.target).closest('.control-icon');
+ button.find('.token-variants-wrap').remove();
+ const sideSelect = await renderSideSelect(tile, event.target.value);
+ if (sideSelect) {
+ sideSelect.addClass('active');
+ button.append(sideSelect);
+ }
+ }
+ return false;
+ }
+}
+
+function genTitleAndStyle(mappings, imgSrc, name) {
+ let title = TVA_CONFIG.worldHud.showFullPath ? imgSrc : name;
+ let style = '';
+ let offset = 2;
+ for (const [userId, img] of Object.entries(mappings)) {
+ if (img === imgSrc) {
+ const user = game.users.get(userId);
+ if (!user) continue;
+ if (style.length === 0) {
+ style = `inset 0 0 0 ${offset}px ${user.color}`;
+ } else {
+ style += `, inset 0 0 0 ${offset}px ${user.color}`;
+ }
+ offset += 2;
+ title += `\nDisplayed to: ${user.name}`;
+ }
+ }
+ return [title, style];
+}
+
+function setNameDialog(tile) {
+ const tileName = tile.document.getFlag('token-variants', 'tileName') || tile.id;
+ new Dialog({
+ title: `Assign a name to the Tile (3+ chars)`,
+ content: `
`,
+ buttons: {
+ Ok: {
+ label: `Save`,
+ callback: (html) => {
+ const name = html.find('input').val();
+ if (name) {
+ canvas.tiles.hud.clear();
+ tile.document.setFlag('token-variants', 'tileName', name);
+ }
+ },
+ },
+ },
+ }).render(true);
+}
diff --git a/Data/modules/token-variants/applications/tokenCustomConfig.js b/Data/modules/token-variants/applications/tokenCustomConfig.js
new file mode 100644
index 00000000..b63c6a33
--- /dev/null
+++ b/Data/modules/token-variants/applications/tokenCustomConfig.js
@@ -0,0 +1,228 @@
+import { getTokenConfig, setTokenConfig } from '../scripts/utils.js';
+
+export default class TokenCustomConfig extends TokenConfig {
+ constructor(object, options, imgSrc, imgName, callback, config) {
+ let token;
+ if (object instanceof Actor) {
+ token = new TokenDocument(object.token, {
+ actor: object,
+ });
+ } else {
+ token = new TokenDocument(object.document, {
+ actor: game.actors.get(object.actorId),
+ });
+ }
+ super(token, options);
+ this.imgSrc = imgSrc;
+ this.imgName = imgName;
+ this.callback = callback;
+ this.config = config;
+ if (this.config) {
+ this.flags = this.config.flags;
+ this.tv_script = this.config.tv_script;
+ }
+ }
+
+ _getSubmitData(updateData = {}) {
+ if (!this.form) throw new Error('The FormApplication subclass has no registered form element');
+ const fd = new FormDataExtended(this.form, { editors: this.editors });
+ let data = fd.object;
+ if (updateData) data = foundry.utils.flattenObject(foundry.utils.mergeObject(data, updateData));
+
+ // Clear detection modes array
+ if (!('detectionModes.0.id' in data)) data.detectionModes = [];
+
+ // Treat "None" as null for bar attributes
+ data['bar1.attribute'] ||= null;
+ data['bar2.attribute'] ||= null;
+ return data;
+ }
+
+ async _updateObject(event, formData) {
+ const filtered = {};
+
+ const form = $(event.target).closest('form');
+
+ form.find('.form-group').each(function (_) {
+ const tva_checkbox = $(this).find('.tva-config-checkbox > input');
+ if (tva_checkbox.length && tva_checkbox.is(':checked')) {
+ $(this)
+ .find('[name]')
+ .each(function (_) {
+ const name = $(this).attr('name');
+ filtered[name] = formData[name];
+ });
+ }
+ });
+
+ if (this.tv_script) {
+ filtered.tv_script = this.tv_script;
+ }
+
+ if (this.config) {
+ let config = expandObject(filtered);
+ config.flags = config.flags ? mergeObject(this.flags || {}, config.flags) : this.flags;
+ if (this.callback) this.callback(config);
+ } else {
+ const saved = setTokenConfig(this.imgSrc, this.imgName, filtered);
+ if (this.callback) this.callback(saved);
+ }
+ }
+
+ applyCustomConfig() {
+ const tokenConfig = flattenObject(this.config || getTokenConfig(this.imgSrc, this.imgName));
+ const form = $(this.form);
+ for (const key of Object.keys(tokenConfig)) {
+ const el = form.find(`[name="${key}"]`);
+ if (el.is(':checkbox')) {
+ el.prop('checked', tokenConfig[key]);
+ } else {
+ el.val(tokenConfig[key]);
+ }
+ el.trigger('change');
+ }
+ }
+
+ // *************
+ // consider moving html injection to:
+ // _replaceHTML | _injectHTML
+
+ async activateListeners(html) {
+ await super.activateListeners(html);
+
+ // Disable image path controls
+ $(html).find('.token-variants-image-select-button').prop('disabled', true);
+ $(html).find('.file-picker').prop('disabled', true);
+ $(html).find('.image').prop('disabled', true);
+
+ // Remove 'Assign Token' button
+ $(html).find('.assign-token').remove();
+
+ // Add checkboxes to control inclusion of specific tabs in the custom config
+ const tokenConfig = this.config || getTokenConfig(this.imgSrc, this.imgName);
+ this.tv_script = tokenConfig.tv_script;
+
+ $(html).on('change', '.tva-config-checkbox', this._onCheckboxChange);
+
+ const processFormGroup = function (formGroup) {
+ // Checkbox is not added for the Image Path group
+ if (!$(formGroup).find('[name="img"]').length) {
+ let savedField = false;
+ if (tokenConfig) {
+ const flatConfig = flattenObject(tokenConfig);
+ $(formGroup)
+ .find('[name]')
+ .each(function (_) {
+ const name = $(this).attr('name');
+ if (name in flatConfig) {
+ savedField = true;
+ }
+ });
+ }
+
+ const checkbox = $(
+ `
`
+ );
+ if ($(formGroup).find('p.hint').length) {
+ $(formGroup).find('p.hint').before(checkbox);
+ } else {
+ $(formGroup).append(checkbox);
+ }
+ checkbox.find('input').trigger('change');
+ }
+ };
+ // Add checkboxes to each form-group to control highlighting and which fields will are to be saved
+ $(html)
+ .find('.form-group')
+ .each(function (index) {
+ processFormGroup(this);
+ });
+
+ // Add 'update' and 'remove' config buttons
+ $(html).find('.sheet-footer > button').remove();
+ $(html)
+ .find('.sheet-footer')
+ .append('
Save Config');
+ if (tokenConfig) {
+ $(html)
+ .find('.sheet-footer')
+ .append('
Remove Config');
+ html.find('.remove-config').click(this._onRemoveConfig.bind(this));
+ }
+
+ // Pre-select image or appearance tab
+ $(html).find('.tabs > .item[data-tab="image"] > i').trigger('click');
+ $(html).find('.tabs > .item[data-tab="appearance"] > i').trigger('click');
+
+ document.activeElement.blur(); // Hack fix for key UP/DOWN effects not registering after config has been opened
+
+ // TokenConfig might be changed by some modules after activateListeners is processed
+ // Look out for these updates and add checkboxes for any newly added form-groups
+ const mutate = (mutations) => {
+ mutations.forEach((mutation) => {
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeName === 'DIV' && node.className === 'form-group') {
+ processFormGroup(node);
+ this.applyCustomConfig();
+ }
+ });
+ });
+ };
+
+ const observer = new MutationObserver(mutate);
+ observer.observe(html[0], {
+ characterData: false,
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+
+ // On any field being changed we want to automatically select the form-group to be included in the update
+ $(html).on('change', 'input, select', onInputChange);
+ $(html).on('click', 'button', onInputChange);
+
+ this.applyCustomConfig();
+ }
+
+ async _onCheckboxChange(event) {
+ const checkbox = $(event.target);
+ checkbox.closest('.form-group').css({
+ 'outline-color': checkbox.is(':checked') ? 'green' : '#ffcc6e',
+ 'outline-width': '2px',
+ 'outline-style': 'dotted',
+ 'margin-bottom': '5px',
+ });
+ checkbox.closest('.tva-config-checkbox').css({
+ 'outline-color': checkbox.is(':checked') ? 'green' : '#ffcc6e',
+ 'outline-width': '2px',
+ 'outline-style': 'solid',
+ });
+ }
+
+ async _onRemoveConfig(event) {
+ if (this.config) {
+ if (this.callback) this.callback({});
+ } else {
+ const saved = setTokenConfig(this.imgSrc, this.imgName, null);
+ if (this.callback) this.callback(saved);
+ }
+ this.close();
+ }
+
+ get id() {
+ return `token-custom-config-${this.object.id}`;
+ }
+
+ _getHeaderButtons() {
+ const buttons = super._getHeaderButtons();
+ return buttons;
+ }
+}
+
+// Toggle checkbox if input has been detected inside it's form-group
+async function onInputChange(event) {
+ if (event.target.parentNode.className === 'tva-config-checkbox') return;
+ $(event.target).closest('.form-group').find('.tva-config-checkbox input').prop('checked', true);
+}
diff --git a/Data/modules/token-variants/applications/tokenHUD.js b/Data/modules/token-variants/applications/tokenHUD.js
new file mode 100644
index 00000000..a6d2a251
--- /dev/null
+++ b/Data/modules/token-variants/applications/tokenHUD.js
@@ -0,0 +1,673 @@
+import {
+ getFileName,
+ isImage,
+ isVideo,
+ SEARCH_TYPE,
+ keyPressed,
+ updateActorImage,
+ updateTokenImage,
+} from '../scripts/utils.js';
+import TokenCustomConfig from './tokenCustomConfig.js';
+import EffectMappingForm from './effectMappingForm.js';
+import { TVA_CONFIG } from '../scripts/settings.js';
+import UserList from './userList.js';
+import FlagsConfig from './flagsConfig.js';
+import RandomizerConfig from './randomizerConfig.js';
+import { doImageSearch, findImagesFuzzy } from '../scripts/search.js';
+
+export const TOKEN_HUD_VARIANTS = { variants: null, actor: null };
+
+export async function renderTokenHUD(hud, html, token, searchText = '', fp_files = null) {
+ activateStatusEffectListeners(token);
+
+ const hudSettings = TVA_CONFIG.hud;
+ const FULL_ACCESS = TVA_CONFIG.permissions.hudFullAccess[game.user.role];
+ const PARTIAL_ACCESS = TVA_CONFIG.permissions.hud[game.user.role];
+
+ // Check if the HUD button should be displayed
+ if (
+ !hudSettings.enableSideMenu ||
+ (!PARTIAL_ACCESS && !FULL_ACCESS) ||
+ token.flags['token-variants']?.disableHUDButton
+ )
+ return;
+
+ const tokenActor = game.actors.get(token.actorId);
+
+ // Disable button if Token HUD Wildcard is enabled and appropriate setting is set
+ if (TVA_CONFIG.worldHud.disableIfTHWEnabled && game.modules.get('token-hud-wildcard')?.active) {
+ if (tokenActor && tokenActor.prototypeToken.randomImg) return;
+ }
+
+ const button = $(`
+
+
+
+`);
+
+ html.find('div.right').last().append(button);
+ html.find('div.right').click(_deactivateTokenVariantsSideSelector);
+
+ button.click((event) => _onButtonClick(event, token));
+ if (FULL_ACCESS) {
+ button.contextmenu((event) => _onButtonRightClick(event, hud, html, token));
+ }
+}
+
+async function _onButtonClick(event, token) {
+ const button = $(event.target).closest('.control-icon');
+
+ // De-activate 'Status Effects'
+ button.closest('div.right').find('div.control-icon.effects').removeClass('active');
+ button.closest('div.right').find('.status-effects').removeClass('active');
+
+ // Remove contextmenu
+ button.find('.contextmenu').remove();
+
+ // Toggle variants side menu
+ button.toggleClass('active');
+ let variantsWrap = button.find('.token-variants-wrap');
+ if (button.hasClass('active')) {
+ if (!variantsWrap.length) {
+ variantsWrap = await renderSideSelect(token);
+ if (variantsWrap) button.find('img').after(variantsWrap);
+ else return;
+ }
+ variantsWrap.addClass('active');
+ } else {
+ variantsWrap.removeClass('active');
+ }
+}
+
+function _onButtonRightClick(event, hud, html, token) {
+ // Display side menu if button is not active yet
+ const button = $(event.target).closest('.control-icon');
+ if (!button.hasClass('active')) {
+ // button.trigger('click');
+ button.addClass('active');
+ }
+
+ if (button.find('.contextmenu').length) {
+ // Contextmenu already displayed. Remove and activate images
+ button.find('.contextmenu').remove();
+ button.removeClass('active').trigger('click');
+ //button.find('.token-variants-wrap.images').addClass('active');
+ } else {
+ // Contextmenu is not displayed. Hide images, create it and add it
+ button.find('.token-variants-wrap.images').removeClass('active');
+ const contextMenu = $(`
+
+ `);
+ button.append(contextMenu);
+
+ // Register contextmenu listeners
+ contextMenu
+ .find('.token-variants-side-search')
+ .on('keyup', (event) => _onImageSearchKeyUp(event, token))
+ .on('click', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ contextMenu.find('.flags').click((event) => {
+ const tkn = canvas.tokens.get(token._id);
+ if (tkn) {
+ event.preventDefault();
+ event.stopPropagation();
+ new FlagsConfig(tkn).render(true);
+ }
+ });
+ contextMenu.find('.file-picker').click(async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ new FilePicker({
+ type: 'imagevideo',
+ callback: async (path, fp) => {
+ const content = await FilePicker.browse(fp.activeSource, fp.result.target);
+ let files = content.files.filter((f) => isImage(f) || isVideo(f));
+ if (files.length) {
+ button.find('.token-variants-wrap').remove();
+ const sideSelect = await renderSideSelect(token, '', files);
+ if (sideSelect) {
+ sideSelect.addClass('active');
+ button.append(sideSelect);
+ }
+ }
+ },
+ }).render(true);
+ });
+ contextMenu.find('.effectConfig').click((event) => {
+ new EffectMappingForm(token).render(true);
+ });
+ contextMenu.find('.randomizerConfig').click((event) => {
+ new RandomizerConfig(token).render(true);
+ });
+ }
+}
+
+function _deactivateTokenVariantsSideSelector(event) {
+ const controlIcon = $(event.target).closest('.control-icon');
+ const dataAction = controlIcon.attr('data-action');
+
+ switch (dataAction) {
+ case 'effects':
+ break; // Effects button
+ case 'thwildcard-selector':
+ break; // Token HUD Wildcard module button
+ default:
+ return;
+ }
+
+ $(event.target)
+ .closest('div.right')
+ .find('.control-icon[data-action="token-variants-side-selector"]')
+ .removeClass('active');
+ $(event.target).closest('div.right').find('.token-variants-wrap').removeClass('active');
+}
+
+async function renderSideSelect(token, searchText = '', fp_files = null) {
+ const hudSettings = TVA_CONFIG.hud;
+ const worldHudSettings = TVA_CONFIG.worldHud;
+ const FULL_ACCESS = TVA_CONFIG.permissions.hudFullAccess[game.user.role];
+ const PARTIAL_ACCESS = TVA_CONFIG.permissions.hud[game.user.role];
+
+ const tokenActor = game.actors.get(token.actorId);
+
+ let images = [];
+ let actorVariants = [];
+ let imageDuplicates = new Set();
+ const pushImage = (img) => {
+ if (imageDuplicates.has(img.path)) {
+ if (!images.find((obj) => obj.path === img.path && obj.name === img.name)) {
+ images.push(img);
+ }
+ } else {
+ images.push(img);
+ imageDuplicates.add(img.path);
+ }
+ };
+
+ actorVariants = getVariants(tokenActor);
+
+ if (!fp_files) {
+ if (!searchText) {
+ // Insert current token image
+ if (token.texture?.src && token.texture?.src !== CONST.DEFAULT_TOKEN) {
+ pushImage({
+ path: decodeURI(token.texture.src),
+ name: token.flags?.['token-variants']?.name ?? getFileName(token.texture.src),
+ });
+ }
+
+ if (tokenActor) {
+ // Insert default token image
+ const defaultImg =
+ tokenActor.prototypeToken?.flags['token-variants']?.['randomImgDefault'] ||
+ tokenActor.prototypeToken?.flags['token-hud-wildcard']?.['default'] ||
+ '';
+ if (defaultImg) {
+ pushImage({ path: decodeURI(defaultImg), name: getFileName(defaultImg) });
+ }
+
+ if (FULL_ACCESS || PARTIAL_ACCESS) {
+ actorVariants.forEach((variant) => {
+ for (const name of variant.names) {
+ pushImage({ path: decodeURI(variant.imgSrc), name: name });
+ }
+ });
+ }
+
+ // Parse directory flag and include the images
+ if (FULL_ACCESS || PARTIAL_ACCESS) {
+ const directoryFlag = tokenActor.getFlag('token-variants', 'directory');
+ if (directoryFlag) {
+ let dirFlagImages;
+ try {
+ let path = directoryFlag.path;
+ let source = directoryFlag.source;
+ let bucket = '';
+ if (source.startsWith('s3:')) {
+ bucket = source.substring(3, source.length);
+ source = 's3';
+ }
+ const content = await FilePicker.browse(source, path, {
+ type: 'imagevideo',
+ bucket,
+ });
+ dirFlagImages = content.files;
+ } catch (err) {
+ dirFlagImages = [];
+ }
+ dirFlagImages = dirFlagImages.forEach((f) => {
+ if (isImage(f) || isVideo(f)) pushImage({ path: decodeURI(f), name: getFileName(f) });
+ });
+ }
+ }
+
+ if (
+ (FULL_ACCESS || PARTIAL_ACCESS) &&
+ worldHudSettings.includeWildcard &&
+ !worldHudSettings.displayOnlySharedImages
+ ) {
+ // Merge wildcard images
+ const protoImg = tokenActor.prototypeToken.texture.src;
+ if (tokenActor.prototypeToken.randomImg) {
+ (await tokenActor.getTokenImages())
+ .filter((img) => !img.includes('*'))
+ .forEach((img) => {
+ pushImage({ path: decodeURI(img), name: getFileName(img) });
+ });
+ } else if (protoImg.includes('*') || protoImg.includes('{') || protoImg.includes('}')) {
+ // Modified version of Actor.getTokenImages()
+ const getTokenImages = async () => {
+ if (tokenActor._tokenImages) return tokenActor._tokenImages;
+
+ let source = 'data';
+ let pattern = tokenActor.prototypeToken.texture.src;
+ const browseOptions = { wildcard: true };
+
+ // Support non-user sources
+ if (/\.s3\./.test(pattern)) {
+ source = 's3';
+ const { bucket, keyPrefix } = FilePicker.parseS3URL(pattern);
+ if (bucket) {
+ browseOptions.bucket = bucket;
+ pattern = keyPrefix;
+ }
+ } else if (pattern.startsWith('icons/')) source = 'public';
+
+ // Retrieve wildcard content
+ try {
+ const content = await FilePicker.browse(source, pattern, browseOptions);
+ tokenActor._tokenImages = content.files;
+ } catch (err) {
+ tokenActor._tokenImages = [];
+ }
+ return tokenActor._tokenImages;
+ };
+
+ (await getTokenImages())
+ .filter((img) => !img.includes('*') && (isImage(img) || isVideo(img)))
+ .forEach((variant) => {
+ pushImage({ path: decodeURI(variant), name: getFileName(variant) });
+ });
+ }
+ }
+ }
+ }
+
+ // Perform image search if needed
+ if (FULL_ACCESS) {
+ let search;
+ if (searchText) {
+ search = searchText.length > 2 ? searchText : null;
+ } else {
+ if (
+ worldHudSettings.displayOnlySharedImages ||
+ tokenActor?.getFlag('token-variants', 'disableNameSearch')
+ ) {
+ // No search
+ } else if (token.name.length > 2) {
+ search = token.name;
+ }
+ }
+
+ if (search) {
+ let artSearch = await doImageSearch(search, {
+ searchType: SEARCH_TYPE.TOKEN,
+ searchOptions: { keywordSearch: worldHudSettings.includeKeywords },
+ });
+
+ // Merge full search, and keywords into a single array
+ if (artSearch) {
+ artSearch.forEach((results) => {
+ results.forEach((img) => pushImage(img));
+ });
+ }
+ }
+ }
+ } else {
+ images = fp_files.map((f) => {
+ return { path: decodeURI(f), name: getFileName(f) };
+ });
+ }
+
+ // Retrieving the possibly custom name attached as a flag to the token
+ let tokenImageName = '';
+ if (token.flags['token-variants'] && token.flags['token-variants']['name']) {
+ tokenImageName = token.flags['token-variants']['name'];
+ } else {
+ tokenImageName = getFileName(token.texture.src);
+ }
+
+ let imagesParsed = [];
+ const tokenConfigs = (TVA_CONFIG.tokenConfigs || []).flat();
+ const tkn = canvas.tokens.get(token._id);
+ const userMappings = tkn.document.getFlag('token-variants', 'userMappings') || {};
+
+ for (const imageObj of images) {
+ const img = isImage(imageObj.path);
+ const vid = isVideo(imageObj.path);
+
+ const hasConfig = Boolean(
+ tokenConfigs.find(
+ (config) => config.tvImgSrc === imageObj.path && config.tvImgName === imageObj.name
+ )
+ );
+ let shared = false;
+ if (TVA_CONFIG.permissions.hudFullAccess[game.user.role]) {
+ actorVariants.forEach((variant) => {
+ if (variant.imgSrc === imageObj.path && variant.names.includes(imageObj.name)) {
+ shared = true;
+ }
+ });
+ }
+
+ const [title, style] = genTitleAndStyle(userMappings, imageObj.path, imageObj.name);
+
+ imagesParsed.push({
+ route: imageObj.path,
+ name: imageObj.name,
+ used: imageObj.path === token.texture.src && imageObj.name === tokenImageName,
+ img,
+ vid,
+ unknownType: !img && !vid,
+ shared: shared,
+ hasConfig: hasConfig,
+ title: title,
+ style: game.user.isGM && style ? 'box-shadow: ' + style + ';' : null,
+ });
+ }
+
+ //
+ // Render
+ //
+ const imageDisplay = hudSettings.displayAsImage;
+ const imageOpacity = hudSettings.imageOpacity / 100;
+
+ const sideSelect = $(
+ await renderTemplate('modules/token-variants/templates/sideSelect.html', {
+ imagesParsed,
+ imageDisplay,
+ imageOpacity,
+ tokenHud: true,
+ })
+ );
+
+ // Activate listeners
+ sideSelect.find('video').hover(
+ function () {
+ if (TVA_CONFIG.playVideoOnHover) {
+ this.play();
+ $(this).siblings('.fa-play').hide();
+ }
+ },
+ function () {
+ if (TVA_CONFIG.pauseVideoOnHoverOut) {
+ this.pause();
+ this.currentTime = 0;
+ $(this).siblings('.fa-play').show();
+ }
+ }
+ );
+ sideSelect
+ .find('.token-variants-button-select')
+ .click((event) => _onImageClick(event, token._id));
+
+ if (FULL_ACCESS) {
+ sideSelect
+ .find('.token-variants-button-select')
+ .on('contextmenu', (event) => _onImageRightClick(event, token._id));
+ }
+
+ return sideSelect;
+}
+
+async function _onImageClick(event, tokenId) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const token = canvas.tokens.controlled.find((t) => t.document.id === tokenId);
+ if (!token) return;
+
+ const worldHudSettings = TVA_CONFIG.worldHud;
+
+ const imgButton = $(event.target).closest('.token-variants-button-select');
+ const imgSrc = imgButton.attr('data-name');
+ const name = imgButton.attr('data-filename');
+
+ if (!imgSrc || !name) return;
+
+ if (keyPressed('config') && game.user.isGM) {
+ const toggleCog = (saved) => {
+ const cog = imgButton.find('.fa-cog');
+ if (saved) {
+ cog.addClass('active');
+ } else {
+ cog.removeClass('active');
+ }
+ };
+ new TokenCustomConfig(token, {}, imgSrc, name, toggleCog).render(true);
+ } else if (token.document.texture.src === imgSrc) {
+ let tokenImageName = token.document.getFlag('token-variants', 'name');
+ if (!tokenImageName) tokenImageName = getFileName(token.document.texture.src);
+ if (tokenImageName !== name) {
+ await updateTokenImage(imgSrc, {
+ token: token,
+ imgName: name,
+ animate: worldHudSettings.animate,
+ });
+ if (token.actor && worldHudSettings.updateActorImage) {
+ if (worldHudSettings.useNameSimilarity) {
+ updateActorWithSimilarName(imgSrc, name, token.actor);
+ } else {
+ updateActorImage(token.actor, imgSrc, { imgName: name });
+ }
+ }
+ }
+ } else {
+ await updateTokenImage(imgSrc, {
+ token: token,
+ imgName: name,
+ animate: worldHudSettings.animate,
+ });
+ if (token.actor && worldHudSettings.updateActorImage) {
+ if (worldHudSettings.useNameSimilarity) {
+ updateActorWithSimilarName(imgSrc, name, token.actor);
+ } else {
+ updateActorImage(token.actor, imgSrc, { imgName: name });
+ }
+ }
+ }
+}
+
+async function _onImageRightClick(event, tokenId) {
+ event.preventDefault();
+ event.stopPropagation();
+ let token = canvas.tokens.controlled.find((t) => t.document.id === tokenId);
+ if (!token) return;
+
+ const imgButton = $(event.target).closest('.token-variants-button-select');
+ const imgSrc = imgButton.attr('data-name');
+ const name = imgButton.attr('data-filename');
+
+ if (!imgSrc || !name) return;
+
+ if (keyPressed('config') && game.user.isGM) {
+ const regenStyle = (token, img) => {
+ const mappings = token.document.getFlag('token-variants', 'userMappings') || {};
+ const name = imgButton.attr('data-filename');
+ const [title, style] = genTitleAndStyle(mappings, img, name);
+ imgButton
+ .closest('.token-variants-wrap')
+ .find(`.token-variants-button-select[data-name='${img}']`)
+ .css('box-shadow', style)
+ .prop('title', title);
+ };
+ new UserList(token, imgSrc, regenStyle).render(true);
+ } else if (token.actor) {
+ let tokenActor = game.actors.get(token.actor.id);
+ let variants = getVariants(tokenActor);
+
+ // Remove selected variant if present in the flag, add otherwise
+ let del = false;
+ let updated = false;
+ for (let variant of variants) {
+ if (variant.imgSrc === imgSrc) {
+ let fNames = variant.names.filter((name) => name !== name);
+ if (fNames.length === 0) {
+ del = true;
+ } else if (fNames.length === variant.names.length) {
+ fNames.push(name);
+ }
+ variant.names = fNames;
+ updated = true;
+ break;
+ }
+ }
+ if (del) variants = variants.filter((variant) => variant.imgSrc !== imgSrc);
+ else if (!updated) variants.push({ imgSrc: imgSrc, names: [name] });
+
+ // Set shared variants as an actor flag
+ setVariants(tokenActor, variants);
+ imgButton.find('.fa-share').toggleClass('active'); // Display green arrow
+ }
+}
+
+async function _onImageSearchKeyUp(event, token) {
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.key === 'Enter' || event.keyCode === 13) {
+ if (event.target.value.length >= 3) {
+ const button = $(event.target).closest('.control-icon');
+ button.find('.token-variants-wrap').remove();
+ const sideSelect = await renderSideSelect(token, event.target.value);
+ if (sideSelect) {
+ sideSelect.addClass('active');
+ button.append(sideSelect);
+ }
+ }
+ }
+}
+
+function genTitleAndStyle(mappings, imgSrc, name) {
+ let title = TVA_CONFIG.worldHud.showFullPath ? imgSrc : name;
+ let style = '';
+ let offset = 2;
+ for (const [userId, img] of Object.entries(mappings)) {
+ if (img === imgSrc) {
+ const user = game.users.get(userId);
+ if (!user) continue;
+ if (style.length === 0) {
+ style = `inset 0 0 0 ${offset}px ${user.color}`;
+ } else {
+ style += `, inset 0 0 0 ${offset}px ${user.color}`;
+ }
+ offset += 2;
+ title += `\nDisplayed to: ${user.name}`;
+ }
+ }
+ return [title, style];
+}
+
+async function updateActorWithSimilarName(imgSrc, imgName, actor) {
+ const results = await findImagesFuzzy(
+ imgName,
+ SEARCH_TYPE.PORTRAIT,
+ {
+ algorithm: {
+ fuzzyThreshold: 0.4,
+ fuzzyLimit: 50,
+ },
+ },
+ true
+ );
+
+ if (results && results.length !== 0) {
+ updateActorImage(actor, results[0].path, { imgName: results[0].name });
+ } else {
+ updateActorImage(actor, imgSrc, { imgName: imgName });
+ }
+}
+
+function activateStatusEffectListeners(token) {
+ if (
+ TVA_CONFIG.permissions.statusConfig[game.user.role] &&
+ token.actorId &&
+ game.actors.get(token.actorId)
+ ) {
+ $('.control-icon[data-action="effects"]')
+ .find('img:first')
+ .click((event) => {
+ event.preventDefault();
+ if (keyPressed('config')) {
+ event.stopPropagation();
+ new EffectMappingForm(token).render(true);
+ }
+ });
+
+ $('.control-icon[data-action="visibility"]')
+ .find('img')
+ .click((event) => {
+ event.preventDefault();
+ if (keyPressed('config')) {
+ event.stopPropagation();
+ new EffectMappingForm(token, {
+ createMapping: { label: 'In Combat', expression: 'token-variants-visibility' },
+ }).render(true);
+ }
+ });
+
+ $('.control-icon[data-action="combat"]')
+ .find('img')
+ .click((event) => {
+ event.preventDefault();
+ if (keyPressed('config')) {
+ event.stopPropagation();
+ new EffectMappingForm(token, {
+ createMapping: { label: 'In Combat', expression: 'token-variants-combat' },
+ }).render(true);
+ }
+ });
+
+ $('.status-effects')
+ .find('img')
+ .click((event) => {
+ event.preventDefault();
+ if (keyPressed('config')) {
+ event.stopPropagation();
+
+ let effectName = event.target.getAttribute('title');
+ if (game.system.id === 'pf2e') {
+ effectName = $(event.target).closest('picture').attr('title');
+ }
+ new EffectMappingForm(token, {
+ createMapping: { label: effectName, expression: effectName },
+ }).render(true);
+ }
+ });
+ }
+}
+
+function getVariants(actor) {
+ if (TOKEN_HUD_VARIANTS.variants) return TOKEN_HUD_VARIANTS.variants;
+ else return actor?.getFlag('token-variants', 'variants') || [];
+}
+
+function setVariants(actor, variants) {
+ TOKEN_HUD_VARIANTS.variants = variants;
+ TOKEN_HUD_VARIANTS.actor = actor;
+}
diff --git a/Data/modules/token-variants/applications/tokenHUDClientSettings.js b/Data/modules/token-variants/applications/tokenHUDClientSettings.js
new file mode 100644
index 00000000..a27a0b7d
--- /dev/null
+++ b/Data/modules/token-variants/applications/tokenHUDClientSettings.js
@@ -0,0 +1,32 @@
+import { TVA_CONFIG } from '../scripts/settings.js';
+
+export default class TokenHUDClientSettings extends FormApplication {
+ constructor() {
+ super({}, { title: `Token HUD Settings` });
+ }
+
+ static get defaultOptions() {
+ return mergeObject(super.defaultOptions, {
+ id: 'token-variants-hud-settings',
+ classes: ['sheet'],
+ template: 'modules/token-variants/templates/tokenHUDClientSettings.html',
+ resizable: false,
+ minimizable: false,
+ title: '',
+ width: 500,
+ });
+ }
+
+ async getData(options) {
+ const data = super.getData(options);
+ return mergeObject(data, TVA_CONFIG.hud);
+ }
+
+ /**
+ * @param {Event} event
+ * @param {Object} formData
+ */
+ async _updateObject(event, formData) {
+ game.settings.set('token-variants', 'hudSettings', mergeObject(TVA_CONFIG.hud, formData));
+ }
+}
diff --git a/Data/modules/token-variants/applications/userList.js b/Data/modules/token-variants/applications/userList.js
new file mode 100644
index 00000000..94872160
--- /dev/null
+++ b/Data/modules/token-variants/applications/userList.js
@@ -0,0 +1,81 @@
+import { TVA_CONFIG, updateSettings } from '../scripts/settings.js';
+import { SEARCH_TYPE } from '../scripts/utils.js';
+import { insertArtSelectButton } from './artSelect.js';
+
+export default class UserList extends FormApplication {
+ constructor(object, img, regenStyle) {
+ super({}, {});
+ this.object = object;
+ this.img = img;
+ this.regenStyle = regenStyle;
+ }
+
+ static get defaultOptions() {
+ return mergeObject(super.defaultOptions, {
+ id: 'token-variants-user-list',
+ classes: ['sheet'],
+ template: 'modules/token-variants/templates/userList.html',
+ resizable: false,
+ minimizable: false,
+ title: 'User To Image',
+ width: 300,
+ });
+ }
+
+ async getData(options) {
+ const data = super.getData(options);
+ const mappings = this.object.document.getFlag('token-variants', 'userMappings') || {};
+ let users = [];
+ game.users.forEach((user) => {
+ users.push({
+ avatar: user.avatar,
+ name: user.name,
+ apply: user.id in mappings && mappings[user.id] === this.img,
+ userId: user.id,
+ color: user.color,
+ });
+ });
+ data.users = users;
+ data.invisibleImage = TVA_CONFIG.invisibleImage;
+ return data;
+ }
+
+ /**
+ * @param {JQuery} html
+ */
+ activateListeners(html) {
+ super.activateListeners(html);
+ insertArtSelectButton(html, 'invisibleImage', { search: 'Invisible Image', searchType: SEARCH_TYPE.TOKEN });
+ }
+
+ async _updateObject(event, formData) {
+ const mappings = this.object.document.getFlag('token-variants', 'userMappings') || {};
+
+ if (formData.invisibleImage !== TVA_CONFIG.invisibleImage) {
+ updateSettings({ invisibleImage: decodeURI(formData.invisibleImage) });
+ }
+ delete formData.invisibleImage;
+
+ const affectedImages = [this.img];
+
+ for (const [userId, apply] of Object.entries(formData)) {
+ if (apply) {
+ if (mappings[userId] && mappings[userId] !== this.img) affectedImages.push(mappings[userId]);
+ mappings[userId] = this.img;
+ } else if (mappings[userId] === this.img) {
+ delete mappings[userId];
+ mappings['-=' + userId] = null;
+ }
+ }
+
+ if (Object.keys(mappings).filter((userId) => !userId.startsWith('-=')).length === 0) {
+ await this.object.document.unsetFlag('token-variants', 'userMappings');
+ } else {
+ await this.object.document.setFlag('token-variants', 'userMappings', mappings);
+ }
+
+ for (const img of affectedImages) {
+ this.regenStyle(this.object, img);
+ }
+ }
+}
diff --git a/Data/modules/token-variants/img/anchor_diagram.webp b/Data/modules/token-variants/img/anchor_diagram.webp
new file mode 100644
index 00000000..d6894e16
--- /dev/null
+++ b/Data/modules/token-variants/img/anchor_diagram.webp
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0fb167bdbc92849e00d268a85276941a49c1178629f02db1c7342ca469d8004a
+size 7614
diff --git a/Data/modules/token-variants/img/token-images.svg b/Data/modules/token-variants/img/token-images.svg
new file mode 100644
index 00000000..10eed72d
--- /dev/null
+++ b/Data/modules/token-variants/img/token-images.svg
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Data/modules/token-variants/lang/en.json b/Data/modules/token-variants/lang/en.json
new file mode 100644
index 00000000..196d5329
--- /dev/null
+++ b/Data/modules/token-variants/lang/en.json
@@ -0,0 +1,212 @@
+{
+ "token-variants": {
+ "settings": {
+ "search-paths": {
+ "Name": "Search Paths",
+ "Hint": "Configure folders, Rolltables and s3 buckets to be searched for art here."
+ },
+ "forge-search-paths": {
+ "Name": "Forge Assets Library Paths",
+ "Hint": "Configure your API key and Asset Library paths",
+ "window": {
+ "Hint": "API Key to be used by 'Token Variant Art' to share images with other players. API Key is available in the 'My Account' page."
+ }
+ },
+ "search-filters": {
+ "Name": "Search Filter Settings",
+ "Hint": "Assign filters to Portrait and Token art searches.",
+ "window": {
+ "portrait-filter": {
+ "Name": "Portrait Art Filters",
+ "Hint": "Portrait art will be limited to files that include/exclude these pieces of text or match the provided regular expression."
+ },
+ "token-filter": {
+ "Name": "Token Art Filters",
+ "Hint": "Token art will be limited to files that include/exclude these pieces of text or match the provided regular expression."
+ },
+ "general-filter": {
+ "Name": "General Art Filters",
+ "Hint": "These filters will be used when the search is being performed irrespective of the art type (token/portrait)."
+ }
+ }
+ },
+ "randomizer": {
+ "Name": "Randomizer Settings",
+ "Hint": "Enable randomization of images upon actor and token creation.",
+ "window": {
+ "portrait-image-on-actor-create": "Portrait image on Actor Create",
+ "token-image-on-token-create": "Token image on Token Create",
+ "token-image-on-token-copy-paste": "Token image on Token copy+paste",
+ "search-types-heading": "Searches to be included in the randomizer",
+ "disable-for": "Disable for",
+ "tokens-with-represented-actor": "Tokens with Represented Actor",
+ "tokens-with-linked-actor-data": "Tokens with Linked Actor Data",
+ "pop-up-if-randomization-disabled": "Show Art Select pop-up if randomization is disabled",
+ "token-to-portrait": "Apply Token image to Portrait",
+ "different-images": "Different images for Portrait and Token",
+ "sync-images": "Sync Portrait and Token based on image name similarity"
+ }
+ },
+ "pop-up": {
+ "Name": "Pop-up Settings",
+ "Hint": "Enable/disable pop-up features",
+ "window": {
+ "two-pop-ups": {
+ "Name": "Display separate pop-ups for Portrait and Token art",
+ "Hint": "When enabled 2 separate pop-ups will be displayed upon Actor/Token creation, first to select the Portrait art, and second to select the Token art."
+ },
+ "no-dialog": {
+ "Name": "Disable prompt between Portrait and Token art select",
+ "Hint": "Will disable the prompt displayed upon Token/Actor creation when two separate pop-ups setting is enabled."
+ },
+ "disable-automatic-pop-ups-for": "Disable Automatic Pop-ups for",
+ "on-actor-create": "On Actor Create",
+ "on-token-create": "On Token Create",
+ "on-token-copy-paste": "On Token Copy+Paste"
+ }
+ },
+ "token-hud": {
+ "Name": "Token HUD Client Settings",
+ "Hint": "Settings for the Token HUD Button",
+ "window": {
+ "enable-token-hud": {
+ "Name": "Enable Token HUD Button",
+ "Hint": "Enable extra Token HUD button for selecting token art."
+ },
+ "display-shared-only": {
+ "Name": "Display only shared images",
+ "Hint": "Only shared images will be shown in the side menu. Search can still be performed."
+ },
+ "display-as-image": {
+ "Name": "Display as Image",
+ "Hint": "Disable to display images as a list of their filenames in the HUD."
+ },
+ "image-opacity": {
+ "Name": "Opacity of token preview",
+ "Hint": "The opacity of the token previews in the HUD before hovering over them."
+ },
+ "update-actor-image": {
+ "Name": "Update Actor portrait",
+ "Hint": "When enabled selecting an image from the Token HUD will also apply it to the character sheet."
+ },
+ "disable-if-token-hud-wildcard-active": {
+ "Name": "Disable button",
+ "Hint": "This option will prevent 'Token Variant Art' button being displayed if 'Token HUD Wildcard' is active and 'Randomize Wildcard Images' is checked in the Prototype token."
+ },
+ "include-wildcard": {
+ "Name": "Include wildcard images",
+ "Hint": "If prototype token has been setup with a wildcard image they will be included in the HUD menu."
+ }
+ }
+ },
+ "keywords-search": {
+ "Name": "Search by Keyword",
+ "Hint": "Searches will use both full name and individual words in the name of the actor/token. Words less than 3 characters will be ignored."
+ },
+ "excluded-keywords": {
+ "Name": "Excluded Keywords",
+ "Hint": "This is a list of keywords that will not be included in the search when 'Search by Keyword' is on."
+ },
+ "run-search-on-path": {
+ "Name": "Match name to folder",
+ "Hint": "Whe enabled art searches will check both file names as well as folder names along their path for a match."
+ },
+ "imgur-client-id": {
+ "Name": "Imgur Client ID",
+ "Hint": "Client ID to be used to perform Imgur API calls with."
+ },
+ "disable-notifs": {
+ "Name": "Disable Cache Notifications",
+ "Hint": "Disables notifications shown by the module during caching."
+ },
+ "compendium-mapper": {
+ "Name": "Compendium Mapper",
+ "Hint": "Apply images to compendiums",
+ "window": {
+ "compendium-select": "Select an unlocked compendium to perform mappings on",
+ "missing-only": "Only include documents with missing images",
+ "diff-images": "Apply different images for Portrait and Token",
+ "ignore-token": "Ignore Token",
+ "ignore-portrait": "Ignore Portrait",
+ "show-images": "Show current images in the 'Art Select' window",
+ "include-keywords": "Include keywords in the search",
+ "auto-apply": "Auto-apply the first found image",
+ "auto-art-select": "Display 'Art Select' if no image found",
+ "cache": "Re-cache images before mapping begins",
+ "sync-images": "Sync Portrait and Token if only one image is missing"
+ }
+ },
+ "algorithm": {
+ "Name": "Search Settings",
+ "Hint": "Configure the algorithm and filters used to perform image searches",
+ "window": {
+ "exact-hint": "Token/Actor names need to be fully present in the file/folder name",
+ "fuzzy-hint": "Token/Actor names require only partial matches to the file/folder name",
+ "limit-hint": "Maximum number of results returned per search",
+ "percentage-match": {
+ "Name": "Percentage match",
+ "Hint": "How accurately file/folder name must match for it to be considered a match."
+ },
+ "art-select-slider": {
+ "Name": "Add the percent slider to the Art Select window",
+ "Hint": "Percentage slider will appear in the Art Select pop-ups, allowing you to change the percentage match on the fly"
+ }
+ }
+ },
+ "import-export": {
+ "Hint": "Import/Export module settings",
+ "window": {
+ "import-dialog": "Import Token Variant Art settings",
+ "source-data": "Source Data"
+ }
+ }
+ },
+ "common": {
+ "include": "Include",
+ "exclude": "Exclude",
+ "randomize": "Randomize",
+ "name": "Name",
+ "keywords": "Keywords",
+ "shared": "Shared",
+ "apply": "Apply",
+ "priority": "Priority",
+ "remove": "Remove",
+ "start": "Start",
+ "automation": "Automation",
+ "enable": "Enable",
+ "exact": "Exact",
+ "fuzzy": "Fuzzy",
+ "limit": "Limit",
+ "save": "Save",
+ "import": "Import",
+ "export": "Export"
+ },
+ "windows": {
+ "art-select": {
+ "apply-same-art": "Apply the same art to the token?",
+ "no-art-found": "No art found containing",
+ "select-variant": "Select variant",
+ "select-portrait-art": "Select Portrait Art",
+ "select-token-art": "Select Token Art"
+ },
+ "status-config": {
+ "hint": "Order in which the image will be updated if multiple effects/status are applied. If they have same priority recency will be used instead."
+ }
+ },
+ "notifications": {
+ "warn": {
+ "profile-image-not-found": "Token-Variant-Art: Unable to find profile image to assign right-click listener.",
+ "path-not-found": "Unable to find path:",
+ "invalid-table": "The table \"{rollTableName}\" could not be found",
+ "invalid-json": "The json file \"{jsonName}\" could not be found",
+ "update-image-no-token-actor": "Calling 'updateImage' with no valid Token or Actor as update target.",
+ "imgur-localhost": "Imgur galleries cannot be accessed through clients running on 'localhost'",
+ "json-localhost": "Json file cannot be accessed through clients running on 'localhost'"
+ },
+ "info": {
+ "caching-started": "Token Variant Art: Caching Started",
+ "caching-finished": "Token Variant Art: Caching Finished ({imageCount} images found)"
+ }
+ }
+ }
+}
diff --git a/Data/modules/token-variants/module.json b/Data/modules/token-variants/module.json
new file mode 100644
index 00000000..1d47d359
--- /dev/null
+++ b/Data/modules/token-variants/module.json
@@ -0,0 +1,41 @@
+{
+ "id": "token-variants",
+ "title": "Token Variant Art",
+ "description": "Searches a customizable list of directories and displays art variants for tokens/actors through pop-ups and a new Token HUD button. Variants can be individually shared with players allowing them to switch out their token art on the fly.",
+ "version": "4.52.1",
+ "compatibility": {
+ "minimum": "10",
+ "verified": 11
+ },
+ "download": "https://github.com/Aedif/TokenVariants/releases/download/4.52.1/token-variants.zip",
+ "url": "https://github.com/Aedif/TokenVariants",
+ "manifest": "https://raw.githubusercontent.com/Aedif/TokenVariants/master/module.json",
+ "author": "Aedif",
+ "authors": [
+ {
+ "name": "Aedif",
+ "discord": "Aedif#7268"
+ }
+ ],
+ "bugs": "https://github.com/Aedif/TokenVariants/issues",
+ "allowBugReporter": true,
+ "socket": true,
+ "esmodules": ["token-variants.mjs"],
+ "scripts": [],
+ "styles": ["styles/tva-styles.css"],
+ "languages": [
+ {
+ "lang": "en",
+ "name": "English",
+ "path": "lang/en.json"
+ }
+ ],
+ "relationships": {
+ "requires": [
+ {
+ "id": "lib-wrapper",
+ "type": "module"
+ }
+ ]
+ }
+}
diff --git a/Data/modules/token-variants/scripts/fuse/LICENSE b/Data/modules/token-variants/scripts/fuse/LICENSE
new file mode 100644
index 00000000..7cae2dcc
--- /dev/null
+++ b/Data/modules/token-variants/scripts/fuse/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+Copyright 2017 Kirollos Risk
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/Data/modules/token-variants/scripts/fuse/fuse.js b/Data/modules/token-variants/scripts/fuse/fuse.js
new file mode 100644
index 00000000..9856388c
--- /dev/null
+++ b/Data/modules/token-variants/scripts/fuse/fuse.js
@@ -0,0 +1,2436 @@
+/**
+ * Fuse.js v6.5.3 - Lightweight fuzzy-search (http://fusejs.io)
+ *
+ * Copyright (c) 2021 Kiro Risk (http://kiro.me)
+ * All Rights Reserved. Apache Software License 2.0
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+function ownKeys(object, enumerableOnly) {
+ var keys = Object.keys(object);
+
+ if (Object.getOwnPropertySymbols) {
+ var symbols = Object.getOwnPropertySymbols(object);
+ enumerableOnly &&
+ (symbols = symbols.filter(function (sym) {
+ return Object.getOwnPropertyDescriptor(object, sym).enumerable;
+ })),
+ keys.push.apply(keys, symbols);
+ }
+
+ return keys;
+}
+
+function _objectSpread2(target) {
+ for (var i = 1; i < arguments.length; i++) {
+ var source = null != arguments[i] ? arguments[i] : {};
+ i % 2
+ ? ownKeys(Object(source), !0).forEach(function (key) {
+ _defineProperty(target, key, source[key]);
+ })
+ : Object.getOwnPropertyDescriptors
+ ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source))
+ : ownKeys(Object(source)).forEach(function (key) {
+ Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
+ });
+ }
+
+ return target;
+}
+
+function _typeof(obj) {
+ '@babel/helpers - typeof';
+
+ return (
+ (_typeof =
+ 'function' == typeof Symbol && 'symbol' == typeof Symbol.iterator
+ ? function (obj) {
+ return typeof obj;
+ }
+ : function (obj) {
+ return obj &&
+ 'function' == typeof Symbol &&
+ obj.constructor === Symbol &&
+ obj !== Symbol.prototype
+ ? 'symbol'
+ : typeof obj;
+ }),
+ _typeof(obj)
+ );
+}
+
+function _classCallCheck(instance, Constructor) {
+ if (!(instance instanceof Constructor)) {
+ throw new TypeError('Cannot call a class as a function');
+ }
+}
+
+function _defineProperties(target, props) {
+ for (var i = 0; i < props.length; i++) {
+ var descriptor = props[i];
+ descriptor.enumerable = descriptor.enumerable || false;
+ descriptor.configurable = true;
+ if ('value' in descriptor) descriptor.writable = true;
+ Object.defineProperty(target, descriptor.key, descriptor);
+ }
+}
+
+function _createClass(Constructor, protoProps, staticProps) {
+ if (protoProps) _defineProperties(Constructor.prototype, protoProps);
+ if (staticProps) _defineProperties(Constructor, staticProps);
+ Object.defineProperty(Constructor, 'prototype', {
+ writable: false,
+ });
+ return Constructor;
+}
+
+function _defineProperty(obj, key, value) {
+ if (key in obj) {
+ Object.defineProperty(obj, key, {
+ value: value,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ });
+ } else {
+ obj[key] = value;
+ }
+
+ return obj;
+}
+
+function _inherits(subClass, superClass) {
+ if (typeof superClass !== 'function' && superClass !== null) {
+ throw new TypeError('Super expression must either be null or a function');
+ }
+
+ Object.defineProperty(subClass, 'prototype', {
+ value: Object.create(superClass && superClass.prototype, {
+ constructor: {
+ value: subClass,
+ writable: true,
+ configurable: true,
+ },
+ }),
+ writable: false,
+ });
+ if (superClass) _setPrototypeOf(subClass, superClass);
+}
+
+function _getPrototypeOf(o) {
+ _getPrototypeOf = Object.setPrototypeOf
+ ? Object.getPrototypeOf
+ : function _getPrototypeOf(o) {
+ return o.__proto__ || Object.getPrototypeOf(o);
+ };
+ return _getPrototypeOf(o);
+}
+
+function _setPrototypeOf(o, p) {
+ _setPrototypeOf =
+ Object.setPrototypeOf ||
+ function _setPrototypeOf(o, p) {
+ o.__proto__ = p;
+ return o;
+ };
+
+ return _setPrototypeOf(o, p);
+}
+
+function _isNativeReflectConstruct() {
+ if (typeof Reflect === 'undefined' || !Reflect.construct) return false;
+ if (Reflect.construct.sham) return false;
+ if (typeof Proxy === 'function') return true;
+
+ try {
+ Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {}));
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+function _assertThisInitialized(self) {
+ if (self === void 0) {
+ throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
+ }
+
+ return self;
+}
+
+function _possibleConstructorReturn(self, call) {
+ if (call && (typeof call === 'object' || typeof call === 'function')) {
+ return call;
+ } else if (call !== void 0) {
+ throw new TypeError('Derived constructors may only return object or undefined');
+ }
+
+ return _assertThisInitialized(self);
+}
+
+function _createSuper(Derived) {
+ var hasNativeReflectConstruct = _isNativeReflectConstruct();
+
+ return function _createSuperInternal() {
+ var Super = _getPrototypeOf(Derived),
+ result;
+
+ if (hasNativeReflectConstruct) {
+ var NewTarget = _getPrototypeOf(this).constructor;
+
+ result = Reflect.construct(Super, arguments, NewTarget);
+ } else {
+ result = Super.apply(this, arguments);
+ }
+
+ return _possibleConstructorReturn(this, result);
+ };
+}
+
+function _toConsumableArray(arr) {
+ return (
+ _arrayWithoutHoles(arr) ||
+ _iterableToArray(arr) ||
+ _unsupportedIterableToArray(arr) ||
+ _nonIterableSpread()
+ );
+}
+
+function _arrayWithoutHoles(arr) {
+ if (Array.isArray(arr)) return _arrayLikeToArray(arr);
+}
+
+function _iterableToArray(iter) {
+ if (
+ (typeof Symbol !== 'undefined' && iter[Symbol.iterator] != null) ||
+ iter['@@iterator'] != null
+ )
+ return Array.from(iter);
+}
+
+function _unsupportedIterableToArray(o, minLen) {
+ if (!o) return;
+ if (typeof o === 'string') return _arrayLikeToArray(o, minLen);
+ var n = Object.prototype.toString.call(o).slice(8, -1);
+ if (n === 'Object' && o.constructor) n = o.constructor.name;
+ if (n === 'Map' || n === 'Set') return Array.from(o);
+ if (n === 'Arguments' || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))
+ return _arrayLikeToArray(o, minLen);
+}
+
+function _arrayLikeToArray(arr, len) {
+ if (len == null || len > arr.length) len = arr.length;
+
+ for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i];
+
+ return arr2;
+}
+
+function _nonIterableSpread() {
+ throw new TypeError(
+ 'Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.'
+ );
+}
+
+function isArray(value) {
+ return !Array.isArray ? getTag(value) === '[object Array]' : Array.isArray(value);
+} // Adapted from: https://github.com/lodash/lodash/blob/master/.internal/baseToString.js
+
+var INFINITY = 1 / 0;
+function baseToString(value) {
+ // Exit early for strings to avoid a performance hit in some environments.
+ if (typeof value == 'string') {
+ return value;
+ }
+
+ var result = value + '';
+ return result == '0' && 1 / value == -INFINITY ? '-0' : result;
+}
+function toString(value) {
+ return value == null ? '' : baseToString(value);
+}
+function isString(value) {
+ return typeof value === 'string';
+}
+function isNumber(value) {
+ return typeof value === 'number';
+} // Adapted from: https://github.com/lodash/lodash/blob/master/isBoolean.js
+
+function isBoolean(value) {
+ return (
+ value === true ||
+ value === false ||
+ (isObjectLike(value) && getTag(value) == '[object Boolean]')
+ );
+}
+function isObject(value) {
+ return _typeof(value) === 'object';
+} // Checks if `value` is object-like.
+
+function isObjectLike(value) {
+ return isObject(value) && value !== null;
+}
+function isDefined(value) {
+ return value !== undefined && value !== null;
+}
+function isBlank(value) {
+ return !value.trim().length;
+} // Gets the `toStringTag` of `value`.
+// Adapted from: https://github.com/lodash/lodash/blob/master/.internal/getTag.js
+
+function getTag(value) {
+ return value == null
+ ? value === undefined
+ ? '[object Undefined]'
+ : '[object Null]'
+ : Object.prototype.toString.call(value);
+}
+
+var EXTENDED_SEARCH_UNAVAILABLE = 'Extended search is not available';
+var INCORRECT_INDEX_TYPE = "Incorrect 'index' type";
+var LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY = function LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY(key) {
+ return 'Invalid value for key '.concat(key);
+};
+var PATTERN_LENGTH_TOO_LARGE = function PATTERN_LENGTH_TOO_LARGE(max) {
+ return 'Pattern length exceeds max of '.concat(max, '.');
+};
+var MISSING_KEY_PROPERTY = function MISSING_KEY_PROPERTY(name) {
+ return 'Missing '.concat(name, ' property in key');
+};
+var INVALID_KEY_WEIGHT_VALUE = function INVALID_KEY_WEIGHT_VALUE(key) {
+ return "Property 'weight' in key '".concat(key, "' must be a positive integer");
+};
+
+var hasOwn = Object.prototype.hasOwnProperty;
+
+var KeyStore = /*#__PURE__*/ (function () {
+ function KeyStore(keys) {
+ var _this = this;
+
+ _classCallCheck(this, KeyStore);
+
+ this._keys = [];
+ this._keyMap = {};
+ var totalWeight = 0;
+ keys.forEach(function (key) {
+ var obj = createKey(key);
+ totalWeight += obj.weight;
+
+ _this._keys.push(obj);
+
+ _this._keyMap[obj.id] = obj;
+ totalWeight += obj.weight;
+ }); // Normalize weights so that their sum is equal to 1
+
+ this._keys.forEach(function (key) {
+ key.weight /= totalWeight;
+ });
+ }
+
+ _createClass(KeyStore, [
+ {
+ key: 'get',
+ value: function get(keyId) {
+ return this._keyMap[keyId];
+ },
+ },
+ {
+ key: 'keys',
+ value: function keys() {
+ return this._keys;
+ },
+ },
+ {
+ key: 'toJSON',
+ value: function toJSON() {
+ return JSON.stringify(this._keys);
+ },
+ },
+ ]);
+
+ return KeyStore;
+})();
+function createKey(key) {
+ var path = null;
+ var id = null;
+ var src = null;
+ var weight = 1;
+
+ if (isString(key) || isArray(key)) {
+ src = key;
+ path = createKeyPath(key);
+ id = createKeyId(key);
+ } else {
+ if (!hasOwn.call(key, 'name')) {
+ throw new Error(MISSING_KEY_PROPERTY('name'));
+ }
+
+ var name = key.name;
+ src = name;
+
+ if (hasOwn.call(key, 'weight')) {
+ weight = key.weight;
+
+ if (weight <= 0) {
+ throw new Error(INVALID_KEY_WEIGHT_VALUE(name));
+ }
+ }
+
+ path = createKeyPath(name);
+ id = createKeyId(name);
+ }
+
+ return {
+ path: path,
+ id: id,
+ weight: weight,
+ src: src,
+ };
+}
+function createKeyPath(key) {
+ return isArray(key) ? key : key.split('.');
+}
+function createKeyId(key) {
+ return isArray(key) ? key.join('.') : key;
+}
+
+function get(obj, path) {
+ var list = [];
+ var arr = false;
+
+ var deepGet = function deepGet(obj, path, index) {
+ if (!isDefined(obj)) {
+ return;
+ }
+
+ if (!path[index]) {
+ // If there's no path left, we've arrived at the object we care about.
+ list.push(obj);
+ } else {
+ var key = path[index];
+ var value = obj[key];
+
+ if (!isDefined(value)) {
+ return;
+ } // If we're at the last value in the path, and if it's a string/number/bool,
+ // add it to the list
+
+ if (index === path.length - 1 && (isString(value) || isNumber(value) || isBoolean(value))) {
+ list.push(toString(value));
+ } else if (isArray(value)) {
+ arr = true; // Search each item in the array.
+
+ for (var i = 0, len = value.length; i < len; i += 1) {
+ deepGet(value[i], path, index + 1);
+ }
+ } else if (path.length) {
+ // An object. Recurse further.
+ deepGet(value, path, index + 1);
+ }
+ }
+ }; // Backwards compatibility (since path used to be a string)
+
+ deepGet(obj, isString(path) ? path.split('.') : path, 0);
+ return arr ? list : list[0];
+}
+
+var MatchOptions = {
+ // Whether the matches should be included in the result set. When `true`, each record in the result
+ // set will include the indices of the matched characters.
+ // These can consequently be used for highlighting purposes.
+ includeMatches: false,
+ // When `true`, the matching function will continue to the end of a search pattern even if
+ // a perfect match has already been located in the string.
+ findAllMatches: false,
+ // Minimum number of characters that must be matched before a result is considered a match
+ minMatchCharLength: 1,
+};
+var BasicOptions = {
+ // When `true`, the algorithm continues searching to the end of the input even if a perfect
+ // match is found before the end of the same input.
+ isCaseSensitive: false,
+ // When true, the matching function will continue to the end of a search pattern even if
+ includeScore: false,
+ // List of properties that will be searched. This also supports nested properties.
+ keys: [],
+ // Whether to sort the result list, by score
+ shouldSort: true,
+ // Default sort function: sort by ascending score, ascending index
+ sortFn: function sortFn(a, b) {
+ return a.score === b.score ? (a.idx < b.idx ? -1 : 1) : a.score < b.score ? -1 : 1;
+ },
+};
+var FuzzyOptions = {
+ // Approximately where in the text is the pattern expected to be found?
+ location: 0,
+ // At what point does the match algorithm give up. A threshold of '0.0' requires a perfect match
+ // (of both letters and location), a threshold of '1.0' would match anything.
+ threshold: 0.6,
+ // Determines how close the match must be to the fuzzy location (specified above).
+ // An exact letter match which is 'distance' characters away from the fuzzy location
+ // would score as a complete mismatch. A distance of '0' requires the match be at
+ // the exact location specified, a threshold of '1000' would require a perfect match
+ // to be within 800 characters of the fuzzy location to be found using a 0.8 threshold.
+ distance: 100,
+};
+var AdvancedOptions = {
+ // When `true`, it enables the use of unix-like search commands
+ useExtendedSearch: false,
+ // The get function to use when fetching an object's properties.
+ // The default will search nested paths *ie foo.bar.baz*
+ getFn: get,
+ // When `true`, search will ignore `location` and `distance`, so it won't matter
+ // where in the string the pattern appears.
+ // More info: https://fusejs.io/concepts/scoring-theory.html#fuzziness-score
+ ignoreLocation: false,
+ // When `true`, the calculation for the relevance score (used for sorting) will
+ // ignore the field-length norm.
+ // More info: https://fusejs.io/concepts/scoring-theory.html#field-length-norm
+ ignoreFieldNorm: false,
+ // The weight to determine how much field length norm effects scoring.
+ fieldNormWeight: 1,
+};
+var Config = _objectSpread2(
+ _objectSpread2(_objectSpread2(_objectSpread2({}, BasicOptions), MatchOptions), FuzzyOptions),
+ AdvancedOptions
+);
+
+var SPACE = /[^ ]+/g; // Field-length norm: the shorter the field, the higher the weight.
+// Set to 3 decimals to reduce index size.
+
+function norm() {
+ var weight = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
+ var mantissa = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 3;
+ var cache = new Map();
+ var m = Math.pow(10, mantissa);
+ return {
+ get: function get(value) {
+ var numTokens = value.match(SPACE).length;
+
+ if (cache.has(numTokens)) {
+ return cache.get(numTokens);
+ } // Default function is 1/sqrt(x), weight makes that variable
+
+ var norm = 1 / Math.pow(numTokens, 0.5 * weight); // In place of `toFixed(mantissa)`, for faster computation
+
+ var n = parseFloat(Math.round(norm * m) / m);
+ cache.set(numTokens, n);
+ return n;
+ },
+ clear: function clear() {
+ cache.clear();
+ },
+ };
+}
+
+var FuseIndex = /*#__PURE__*/ (function () {
+ function FuseIndex() {
+ var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
+ _ref$getFn = _ref.getFn,
+ getFn = _ref$getFn === void 0 ? Config.getFn : _ref$getFn,
+ _ref$fieldNormWeight = _ref.fieldNormWeight,
+ fieldNormWeight =
+ _ref$fieldNormWeight === void 0 ? Config.fieldNormWeight : _ref$fieldNormWeight;
+
+ _classCallCheck(this, FuseIndex);
+
+ this.norm = norm(fieldNormWeight, 3);
+ this.getFn = getFn;
+ this.isCreated = false;
+ this.setIndexRecords();
+ }
+
+ _createClass(FuseIndex, [
+ {
+ key: 'setSources',
+ value: function setSources() {
+ var docs = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
+ this.docs = docs;
+ },
+ },
+ {
+ key: 'setIndexRecords',
+ value: function setIndexRecords() {
+ var records = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
+ this.records = records;
+ },
+ },
+ {
+ key: 'setKeys',
+ value: function setKeys() {
+ var _this = this;
+
+ var keys = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
+ this.keys = keys;
+ this._keysMap = {};
+ keys.forEach(function (key, idx) {
+ _this._keysMap[key.id] = idx;
+ });
+ },
+ },
+ {
+ key: 'create',
+ value: function create() {
+ var _this2 = this;
+
+ if (this.isCreated || !this.docs.length) {
+ return;
+ }
+
+ this.isCreated = true; // List is Array
+
+ if (isString(this.docs[0])) {
+ this.docs.forEach(function (doc, docIndex) {
+ _this2._addString(doc, docIndex);
+ });
+ } else {
+ // List is Array
+ this.docs.forEach(function (doc, docIndex) {
+ _this2._addObject(doc, docIndex);
+ });
+ }
+
+ this.norm.clear();
+ }, // Adds a doc to the end of the index
+ },
+ {
+ key: 'add',
+ value: function add(doc) {
+ var idx = this.size();
+
+ if (isString(doc)) {
+ this._addString(doc, idx);
+ } else {
+ this._addObject(doc, idx);
+ }
+ }, // Removes the doc at the specified index of the index
+ },
+ {
+ key: 'removeAt',
+ value: function removeAt(idx) {
+ this.records.splice(idx, 1); // Change ref index of every subsquent doc
+
+ for (var i = idx, len = this.size(); i < len; i += 1) {
+ this.records[i].i -= 1;
+ }
+ },
+ },
+ {
+ key: 'getValueForItemAtKeyId',
+ value: function getValueForItemAtKeyId(item, keyId) {
+ return item[this._keysMap[keyId]];
+ },
+ },
+ {
+ key: 'size',
+ value: function size() {
+ return this.records.length;
+ },
+ },
+ {
+ key: '_addString',
+ value: function _addString(doc, docIndex) {
+ if (!isDefined(doc) || isBlank(doc)) {
+ return;
+ }
+
+ var record = {
+ v: doc,
+ i: docIndex,
+ n: this.norm.get(doc),
+ };
+ this.records.push(record);
+ },
+ },
+ {
+ key: '_addObject',
+ value: function _addObject(doc, docIndex) {
+ var _this3 = this;
+
+ var record = {
+ i: docIndex,
+ $: {},
+ }; // Iterate over every key (i.e, path), and fetch the value at that key
+
+ this.keys.forEach(function (key, keyIndex) {
+ var value = _this3.getFn(doc, key.path);
+
+ if (!isDefined(value)) {
+ return;
+ }
+
+ if (isArray(value)) {
+ (function () {
+ var subRecords = [];
+ var stack = [
+ {
+ nestedArrIndex: -1,
+ value: value,
+ },
+ ];
+
+ while (stack.length) {
+ var _stack$pop = stack.pop(),
+ nestedArrIndex = _stack$pop.nestedArrIndex,
+ _value = _stack$pop.value;
+
+ if (!isDefined(_value)) {
+ continue;
+ }
+
+ if (isString(_value) && !isBlank(_value)) {
+ var subRecord = {
+ v: _value,
+ i: nestedArrIndex,
+ n: _this3.norm.get(_value),
+ };
+ subRecords.push(subRecord);
+ } else if (isArray(_value)) {
+ _value.forEach(function (item, k) {
+ stack.push({
+ nestedArrIndex: k,
+ value: item,
+ });
+ });
+ } else;
+ }
+
+ record.$[keyIndex] = subRecords;
+ })();
+ } else if (!isBlank(value)) {
+ var subRecord = {
+ v: value,
+ n: _this3.norm.get(value),
+ };
+ record.$[keyIndex] = subRecord;
+ }
+ });
+ this.records.push(record);
+ },
+ },
+ {
+ key: 'toJSON',
+ value: function toJSON() {
+ return {
+ keys: this.keys,
+ records: this.records,
+ };
+ },
+ },
+ ]);
+
+ return FuseIndex;
+})();
+function createIndex(keys, docs) {
+ var _ref2 = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {},
+ _ref2$getFn = _ref2.getFn,
+ getFn = _ref2$getFn === void 0 ? Config.getFn : _ref2$getFn,
+ _ref2$fieldNormWeight = _ref2.fieldNormWeight,
+ fieldNormWeight =
+ _ref2$fieldNormWeight === void 0 ? Config.fieldNormWeight : _ref2$fieldNormWeight;
+
+ var myIndex = new FuseIndex({
+ getFn: getFn,
+ fieldNormWeight: fieldNormWeight,
+ });
+ myIndex.setKeys(keys.map(createKey));
+ myIndex.setSources(docs);
+ myIndex.create();
+ return myIndex;
+}
+function parseIndex(data) {
+ var _ref3 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
+ _ref3$getFn = _ref3.getFn,
+ getFn = _ref3$getFn === void 0 ? Config.getFn : _ref3$getFn,
+ _ref3$fieldNormWeight = _ref3.fieldNormWeight,
+ fieldNormWeight =
+ _ref3$fieldNormWeight === void 0 ? Config.fieldNormWeight : _ref3$fieldNormWeight;
+
+ var keys = data.keys,
+ records = data.records;
+ var myIndex = new FuseIndex({
+ getFn: getFn,
+ fieldNormWeight: fieldNormWeight,
+ });
+ myIndex.setKeys(keys);
+ myIndex.setIndexRecords(records);
+ return myIndex;
+}
+
+function computeScore$1(pattern) {
+ var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
+ _ref$errors = _ref.errors,
+ errors = _ref$errors === void 0 ? 0 : _ref$errors,
+ _ref$currentLocation = _ref.currentLocation,
+ currentLocation = _ref$currentLocation === void 0 ? 0 : _ref$currentLocation,
+ _ref$expectedLocation = _ref.expectedLocation,
+ expectedLocation = _ref$expectedLocation === void 0 ? 0 : _ref$expectedLocation,
+ _ref$distance = _ref.distance,
+ distance = _ref$distance === void 0 ? Config.distance : _ref$distance,
+ _ref$ignoreLocation = _ref.ignoreLocation,
+ ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation;
+
+ var accuracy = errors / pattern.length;
+
+ if (ignoreLocation) {
+ return accuracy;
+ }
+
+ var proximity = Math.abs(expectedLocation - currentLocation);
+
+ if (!distance) {
+ // Dodge divide by zero error.
+ return proximity ? 1.0 : accuracy;
+ }
+
+ return accuracy + proximity / distance;
+}
+
+function convertMaskToIndices() {
+ var matchmask = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
+ var minMatchCharLength =
+ arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : Config.minMatchCharLength;
+ var indices = [];
+ var start = -1;
+ var end = -1;
+ var i = 0;
+
+ for (var len = matchmask.length; i < len; i += 1) {
+ var match = matchmask[i];
+
+ if (match && start === -1) {
+ start = i;
+ } else if (!match && start !== -1) {
+ end = i - 1;
+
+ if (end - start + 1 >= minMatchCharLength) {
+ indices.push([start, end]);
+ }
+
+ start = -1;
+ }
+ } // (i-1 - start) + 1 => i - start
+
+ if (matchmask[i - 1] && i - start >= minMatchCharLength) {
+ indices.push([start, i - 1]);
+ }
+
+ return indices;
+}
+
+// Machine word size
+var MAX_BITS = 32;
+
+function search(text, pattern, patternAlphabet) {
+ var _ref = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {},
+ _ref$location = _ref.location,
+ location = _ref$location === void 0 ? Config.location : _ref$location,
+ _ref$distance = _ref.distance,
+ distance = _ref$distance === void 0 ? Config.distance : _ref$distance,
+ _ref$threshold = _ref.threshold,
+ threshold = _ref$threshold === void 0 ? Config.threshold : _ref$threshold,
+ _ref$findAllMatches = _ref.findAllMatches,
+ findAllMatches = _ref$findAllMatches === void 0 ? Config.findAllMatches : _ref$findAllMatches,
+ _ref$minMatchCharLeng = _ref.minMatchCharLength,
+ minMatchCharLength =
+ _ref$minMatchCharLeng === void 0 ? Config.minMatchCharLength : _ref$minMatchCharLeng,
+ _ref$includeMatches = _ref.includeMatches,
+ includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches,
+ _ref$ignoreLocation = _ref.ignoreLocation,
+ ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation;
+
+ if (pattern.length > MAX_BITS) {
+ throw new Error(PATTERN_LENGTH_TOO_LARGE(MAX_BITS));
+ }
+
+ var patternLen = pattern.length; // Set starting location at beginning text and initialize the alphabet.
+
+ var textLen = text.length; // Handle the case when location > text.length
+
+ var expectedLocation = Math.max(0, Math.min(location, textLen)); // Highest score beyond which we give up.
+
+ var currentThreshold = threshold; // Is there a nearby exact match? (speedup)
+
+ var bestLocation = expectedLocation; // Performance: only computer matches when the minMatchCharLength > 1
+ // OR if `includeMatches` is true.
+
+ var computeMatches = minMatchCharLength > 1 || includeMatches; // A mask of the matches, used for building the indices
+
+ var matchMask = computeMatches ? Array(textLen) : [];
+ var index; // Get all exact matches, here for speed up
+
+ while ((index = text.indexOf(pattern, bestLocation)) > -1) {
+ var score = computeScore$1(pattern, {
+ currentLocation: index,
+ expectedLocation: expectedLocation,
+ distance: distance,
+ ignoreLocation: ignoreLocation,
+ });
+ currentThreshold = Math.min(score, currentThreshold);
+ bestLocation = index + patternLen;
+
+ if (computeMatches) {
+ var i = 0;
+
+ while (i < patternLen) {
+ matchMask[index + i] = 1;
+ i += 1;
+ }
+ }
+ } // Reset the best location
+
+ bestLocation = -1;
+ var lastBitArr = [];
+ var finalScore = 1;
+ var binMax = patternLen + textLen;
+ var mask = 1 << (patternLen - 1);
+
+ for (var _i = 0; _i < patternLen; _i += 1) {
+ // Scan for the best match; each iteration allows for one more error.
+ // Run a binary search to determine how far from the match location we can stray
+ // at this error level.
+ var binMin = 0;
+ var binMid = binMax;
+
+ while (binMin < binMid) {
+ var _score2 = computeScore$1(pattern, {
+ errors: _i,
+ currentLocation: expectedLocation + binMid,
+ expectedLocation: expectedLocation,
+ distance: distance,
+ ignoreLocation: ignoreLocation,
+ });
+
+ if (_score2 <= currentThreshold) {
+ binMin = binMid;
+ } else {
+ binMax = binMid;
+ }
+
+ binMid = Math.floor((binMax - binMin) / 2 + binMin);
+ } // Use the result from this iteration as the maximum for the next.
+
+ binMax = binMid;
+ var start = Math.max(1, expectedLocation - binMid + 1);
+ var finish = findAllMatches
+ ? textLen
+ : Math.min(expectedLocation + binMid, textLen) + patternLen; // Initialize the bit array
+
+ var bitArr = Array(finish + 2);
+ bitArr[finish + 1] = (1 << _i) - 1;
+
+ for (var j = finish; j >= start; j -= 1) {
+ var currentLocation = j - 1;
+ var charMatch = patternAlphabet[text.charAt(currentLocation)];
+
+ if (computeMatches) {
+ // Speed up: quick bool to int conversion (i.e, `charMatch ? 1 : 0`)
+ matchMask[currentLocation] = +!!charMatch;
+ } // First pass: exact match
+
+ bitArr[j] = ((bitArr[j + 1] << 1) | 1) & charMatch; // Subsequent passes: fuzzy match
+
+ if (_i) {
+ bitArr[j] |= ((lastBitArr[j + 1] | lastBitArr[j]) << 1) | 1 | lastBitArr[j + 1];
+ }
+
+ if (bitArr[j] & mask) {
+ finalScore = computeScore$1(pattern, {
+ errors: _i,
+ currentLocation: currentLocation,
+ expectedLocation: expectedLocation,
+ distance: distance,
+ ignoreLocation: ignoreLocation,
+ }); // This match will almost certainly be better than any existing match.
+ // But check anyway.
+
+ if (finalScore <= currentThreshold) {
+ // Indeed it is
+ currentThreshold = finalScore;
+ bestLocation = currentLocation; // Already passed `loc`, downhill from here on in.
+
+ if (bestLocation <= expectedLocation) {
+ break;
+ } // When passing `bestLocation`, don't exceed our current distance from `expectedLocation`.
+
+ start = Math.max(1, 2 * expectedLocation - bestLocation);
+ }
+ }
+ } // No hope for a (better) match at greater error levels.
+
+ var _score = computeScore$1(pattern, {
+ errors: _i + 1,
+ currentLocation: expectedLocation,
+ expectedLocation: expectedLocation,
+ distance: distance,
+ ignoreLocation: ignoreLocation,
+ });
+
+ if (_score > currentThreshold) {
+ break;
+ }
+
+ lastBitArr = bitArr;
+ }
+
+ var result = {
+ isMatch: bestLocation >= 0,
+ // Count exact matches (those with a score of 0) to be "almost" exact
+ score: Math.max(0.001, finalScore),
+ };
+
+ if (computeMatches) {
+ var indices = convertMaskToIndices(matchMask, minMatchCharLength);
+
+ if (!indices.length) {
+ result.isMatch = false;
+ } else if (includeMatches) {
+ result.indices = indices;
+ }
+ }
+
+ return result;
+}
+
+function createPatternAlphabet(pattern) {
+ var mask = {};
+
+ for (var i = 0, len = pattern.length; i < len; i += 1) {
+ var _char = pattern.charAt(i);
+
+ mask[_char] = (mask[_char] || 0) | (1 << (len - i - 1));
+ }
+
+ return mask;
+}
+
+var BitapSearch = /*#__PURE__*/ (function () {
+ function BitapSearch(pattern) {
+ var _this = this;
+
+ var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
+ _ref$location = _ref.location,
+ location = _ref$location === void 0 ? Config.location : _ref$location,
+ _ref$threshold = _ref.threshold,
+ threshold = _ref$threshold === void 0 ? Config.threshold : _ref$threshold,
+ _ref$distance = _ref.distance,
+ distance = _ref$distance === void 0 ? Config.distance : _ref$distance,
+ _ref$includeMatches = _ref.includeMatches,
+ includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches,
+ _ref$findAllMatches = _ref.findAllMatches,
+ findAllMatches = _ref$findAllMatches === void 0 ? Config.findAllMatches : _ref$findAllMatches,
+ _ref$minMatchCharLeng = _ref.minMatchCharLength,
+ minMatchCharLength =
+ _ref$minMatchCharLeng === void 0 ? Config.minMatchCharLength : _ref$minMatchCharLeng,
+ _ref$isCaseSensitive = _ref.isCaseSensitive,
+ isCaseSensitive =
+ _ref$isCaseSensitive === void 0 ? Config.isCaseSensitive : _ref$isCaseSensitive,
+ _ref$ignoreLocation = _ref.ignoreLocation,
+ ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation;
+
+ _classCallCheck(this, BitapSearch);
+
+ this.options = {
+ location: location,
+ threshold: threshold,
+ distance: distance,
+ includeMatches: includeMatches,
+ findAllMatches: findAllMatches,
+ minMatchCharLength: minMatchCharLength,
+ isCaseSensitive: isCaseSensitive,
+ ignoreLocation: ignoreLocation,
+ };
+ this.pattern = isCaseSensitive ? pattern : pattern.toLowerCase();
+ this.chunks = [];
+
+ if (!this.pattern.length) {
+ return;
+ }
+
+ var addChunk = function addChunk(pattern, startIndex) {
+ _this.chunks.push({
+ pattern: pattern,
+ alphabet: createPatternAlphabet(pattern),
+ startIndex: startIndex,
+ });
+ };
+
+ var len = this.pattern.length;
+
+ if (len > MAX_BITS) {
+ var i = 0;
+ var remainder = len % MAX_BITS;
+ var end = len - remainder;
+
+ while (i < end) {
+ addChunk(this.pattern.substr(i, MAX_BITS), i);
+ i += MAX_BITS;
+ }
+
+ if (remainder) {
+ var startIndex = len - MAX_BITS;
+ addChunk(this.pattern.substr(startIndex), startIndex);
+ }
+ } else {
+ addChunk(this.pattern, 0);
+ }
+ }
+
+ _createClass(BitapSearch, [
+ {
+ key: 'searchIn',
+ value: function searchIn(text) {
+ var _this$options = this.options,
+ isCaseSensitive = _this$options.isCaseSensitive,
+ includeMatches = _this$options.includeMatches;
+
+ if (!isCaseSensitive) {
+ text = text.toLowerCase();
+ } // Exact match
+
+ if (this.pattern === text) {
+ var _result = {
+ isMatch: true,
+ score: 0,
+ };
+
+ if (includeMatches) {
+ _result.indices = [[0, text.length - 1]];
+ }
+
+ return _result;
+ } // Otherwise, use Bitap algorithm
+
+ var _this$options2 = this.options,
+ location = _this$options2.location,
+ distance = _this$options2.distance,
+ threshold = _this$options2.threshold,
+ findAllMatches = _this$options2.findAllMatches,
+ minMatchCharLength = _this$options2.minMatchCharLength,
+ ignoreLocation = _this$options2.ignoreLocation;
+ var allIndices = [];
+ var totalScore = 0;
+ var hasMatches = false;
+ this.chunks.forEach(function (_ref2) {
+ var pattern = _ref2.pattern,
+ alphabet = _ref2.alphabet,
+ startIndex = _ref2.startIndex;
+
+ var _search = search(text, pattern, alphabet, {
+ location: location + startIndex,
+ distance: distance,
+ threshold: threshold,
+ findAllMatches: findAllMatches,
+ minMatchCharLength: minMatchCharLength,
+ includeMatches: includeMatches,
+ ignoreLocation: ignoreLocation,
+ }),
+ isMatch = _search.isMatch,
+ score = _search.score,
+ indices = _search.indices;
+
+ if (isMatch) {
+ hasMatches = true;
+ }
+
+ totalScore += score;
+
+ if (isMatch && indices) {
+ allIndices = [].concat(_toConsumableArray(allIndices), _toConsumableArray(indices));
+ }
+ });
+ var result = {
+ isMatch: hasMatches,
+ score: hasMatches ? totalScore / this.chunks.length : 1,
+ };
+
+ if (hasMatches && includeMatches) {
+ result.indices = allIndices;
+ }
+
+ return result;
+ },
+ },
+ ]);
+
+ return BitapSearch;
+})();
+
+var BaseMatch = /*#__PURE__*/ (function () {
+ function BaseMatch(pattern) {
+ _classCallCheck(this, BaseMatch);
+
+ this.pattern = pattern;
+ }
+
+ _createClass(
+ BaseMatch,
+ [
+ {
+ key: 'search',
+ value: function /*text*/ search() {},
+ },
+ ],
+ [
+ {
+ key: 'isMultiMatch',
+ value: function isMultiMatch(pattern) {
+ return getMatch(pattern, this.multiRegex);
+ },
+ },
+ {
+ key: 'isSingleMatch',
+ value: function isSingleMatch(pattern) {
+ return getMatch(pattern, this.singleRegex);
+ },
+ },
+ ]
+ );
+
+ return BaseMatch;
+})();
+
+function getMatch(pattern, exp) {
+ var matches = pattern.match(exp);
+ return matches ? matches[1] : null;
+}
+
+var ExactMatch = /*#__PURE__*/ (function (_BaseMatch) {
+ _inherits(ExactMatch, _BaseMatch);
+
+ var _super = _createSuper(ExactMatch);
+
+ function ExactMatch(pattern) {
+ _classCallCheck(this, ExactMatch);
+
+ return _super.call(this, pattern);
+ }
+
+ _createClass(
+ ExactMatch,
+ [
+ {
+ key: 'search',
+ value: function search(text) {
+ var isMatch = text === this.pattern;
+ return {
+ isMatch: isMatch,
+ score: isMatch ? 0 : 1,
+ indices: [0, this.pattern.length - 1],
+ };
+ },
+ },
+ ],
+ [
+ {
+ key: 'type',
+ get: function get() {
+ return 'exact';
+ },
+ },
+ {
+ key: 'multiRegex',
+ get: function get() {
+ return /^="(.*)"$/;
+ },
+ },
+ {
+ key: 'singleRegex',
+ get: function get() {
+ return /^=(.*)$/;
+ },
+ },
+ ]
+ );
+
+ return ExactMatch;
+})(BaseMatch);
+
+var InverseExactMatch = /*#__PURE__*/ (function (_BaseMatch) {
+ _inherits(InverseExactMatch, _BaseMatch);
+
+ var _super = _createSuper(InverseExactMatch);
+
+ function InverseExactMatch(pattern) {
+ _classCallCheck(this, InverseExactMatch);
+
+ return _super.call(this, pattern);
+ }
+
+ _createClass(
+ InverseExactMatch,
+ [
+ {
+ key: 'search',
+ value: function search(text) {
+ var index = text.indexOf(this.pattern);
+ var isMatch = index === -1;
+ return {
+ isMatch: isMatch,
+ score: isMatch ? 0 : 1,
+ indices: [0, text.length - 1],
+ };
+ },
+ },
+ ],
+ [
+ {
+ key: 'type',
+ get: function get() {
+ return 'inverse-exact';
+ },
+ },
+ {
+ key: 'multiRegex',
+ get: function get() {
+ return /^!"(.*)"$/;
+ },
+ },
+ {
+ key: 'singleRegex',
+ get: function get() {
+ return /^!(.*)$/;
+ },
+ },
+ ]
+ );
+
+ return InverseExactMatch;
+})(BaseMatch);
+
+var PrefixExactMatch = /*#__PURE__*/ (function (_BaseMatch) {
+ _inherits(PrefixExactMatch, _BaseMatch);
+
+ var _super = _createSuper(PrefixExactMatch);
+
+ function PrefixExactMatch(pattern) {
+ _classCallCheck(this, PrefixExactMatch);
+
+ return _super.call(this, pattern);
+ }
+
+ _createClass(
+ PrefixExactMatch,
+ [
+ {
+ key: 'search',
+ value: function search(text) {
+ var isMatch = text.startsWith(this.pattern);
+ return {
+ isMatch: isMatch,
+ score: isMatch ? 0 : 1,
+ indices: [0, this.pattern.length - 1],
+ };
+ },
+ },
+ ],
+ [
+ {
+ key: 'type',
+ get: function get() {
+ return 'prefix-exact';
+ },
+ },
+ {
+ key: 'multiRegex',
+ get: function get() {
+ return /^\^"(.*)"$/;
+ },
+ },
+ {
+ key: 'singleRegex',
+ get: function get() {
+ return /^\^(.*)$/;
+ },
+ },
+ ]
+ );
+
+ return PrefixExactMatch;
+})(BaseMatch);
+
+var InversePrefixExactMatch = /*#__PURE__*/ (function (_BaseMatch) {
+ _inherits(InversePrefixExactMatch, _BaseMatch);
+
+ var _super = _createSuper(InversePrefixExactMatch);
+
+ function InversePrefixExactMatch(pattern) {
+ _classCallCheck(this, InversePrefixExactMatch);
+
+ return _super.call(this, pattern);
+ }
+
+ _createClass(
+ InversePrefixExactMatch,
+ [
+ {
+ key: 'search',
+ value: function search(text) {
+ var isMatch = !text.startsWith(this.pattern);
+ return {
+ isMatch: isMatch,
+ score: isMatch ? 0 : 1,
+ indices: [0, text.length - 1],
+ };
+ },
+ },
+ ],
+ [
+ {
+ key: 'type',
+ get: function get() {
+ return 'inverse-prefix-exact';
+ },
+ },
+ {
+ key: 'multiRegex',
+ get: function get() {
+ return /^!\^"(.*)"$/;
+ },
+ },
+ {
+ key: 'singleRegex',
+ get: function get() {
+ return /^!\^(.*)$/;
+ },
+ },
+ ]
+ );
+
+ return InversePrefixExactMatch;
+})(BaseMatch);
+
+var SuffixExactMatch = /*#__PURE__*/ (function (_BaseMatch) {
+ _inherits(SuffixExactMatch, _BaseMatch);
+
+ var _super = _createSuper(SuffixExactMatch);
+
+ function SuffixExactMatch(pattern) {
+ _classCallCheck(this, SuffixExactMatch);
+
+ return _super.call(this, pattern);
+ }
+
+ _createClass(
+ SuffixExactMatch,
+ [
+ {
+ key: 'search',
+ value: function search(text) {
+ var isMatch = text.endsWith(this.pattern);
+ return {
+ isMatch: isMatch,
+ score: isMatch ? 0 : 1,
+ indices: [text.length - this.pattern.length, text.length - 1],
+ };
+ },
+ },
+ ],
+ [
+ {
+ key: 'type',
+ get: function get() {
+ return 'suffix-exact';
+ },
+ },
+ {
+ key: 'multiRegex',
+ get: function get() {
+ return /^"(.*)"\$$/;
+ },
+ },
+ {
+ key: 'singleRegex',
+ get: function get() {
+ return /^(.*)\$$/;
+ },
+ },
+ ]
+ );
+
+ return SuffixExactMatch;
+})(BaseMatch);
+
+var InverseSuffixExactMatch = /*#__PURE__*/ (function (_BaseMatch) {
+ _inherits(InverseSuffixExactMatch, _BaseMatch);
+
+ var _super = _createSuper(InverseSuffixExactMatch);
+
+ function InverseSuffixExactMatch(pattern) {
+ _classCallCheck(this, InverseSuffixExactMatch);
+
+ return _super.call(this, pattern);
+ }
+
+ _createClass(
+ InverseSuffixExactMatch,
+ [
+ {
+ key: 'search',
+ value: function search(text) {
+ var isMatch = !text.endsWith(this.pattern);
+ return {
+ isMatch: isMatch,
+ score: isMatch ? 0 : 1,
+ indices: [0, text.length - 1],
+ };
+ },
+ },
+ ],
+ [
+ {
+ key: 'type',
+ get: function get() {
+ return 'inverse-suffix-exact';
+ },
+ },
+ {
+ key: 'multiRegex',
+ get: function get() {
+ return /^!"(.*)"\$$/;
+ },
+ },
+ {
+ key: 'singleRegex',
+ get: function get() {
+ return /^!(.*)\$$/;
+ },
+ },
+ ]
+ );
+
+ return InverseSuffixExactMatch;
+})(BaseMatch);
+
+var FuzzyMatch = /*#__PURE__*/ (function (_BaseMatch) {
+ _inherits(FuzzyMatch, _BaseMatch);
+
+ var _super = _createSuper(FuzzyMatch);
+
+ function FuzzyMatch(pattern) {
+ var _this;
+
+ var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
+ _ref$location = _ref.location,
+ location = _ref$location === void 0 ? Config.location : _ref$location,
+ _ref$threshold = _ref.threshold,
+ threshold = _ref$threshold === void 0 ? Config.threshold : _ref$threshold,
+ _ref$distance = _ref.distance,
+ distance = _ref$distance === void 0 ? Config.distance : _ref$distance,
+ _ref$includeMatches = _ref.includeMatches,
+ includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches,
+ _ref$findAllMatches = _ref.findAllMatches,
+ findAllMatches = _ref$findAllMatches === void 0 ? Config.findAllMatches : _ref$findAllMatches,
+ _ref$minMatchCharLeng = _ref.minMatchCharLength,
+ minMatchCharLength =
+ _ref$minMatchCharLeng === void 0 ? Config.minMatchCharLength : _ref$minMatchCharLeng,
+ _ref$isCaseSensitive = _ref.isCaseSensitive,
+ isCaseSensitive =
+ _ref$isCaseSensitive === void 0 ? Config.isCaseSensitive : _ref$isCaseSensitive,
+ _ref$ignoreLocation = _ref.ignoreLocation,
+ ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation;
+
+ _classCallCheck(this, FuzzyMatch);
+
+ _this = _super.call(this, pattern);
+ _this._bitapSearch = new BitapSearch(pattern, {
+ location: location,
+ threshold: threshold,
+ distance: distance,
+ includeMatches: includeMatches,
+ findAllMatches: findAllMatches,
+ minMatchCharLength: minMatchCharLength,
+ isCaseSensitive: isCaseSensitive,
+ ignoreLocation: ignoreLocation,
+ });
+ return _this;
+ }
+
+ _createClass(
+ FuzzyMatch,
+ [
+ {
+ key: 'search',
+ value: function search(text) {
+ return this._bitapSearch.searchIn(text);
+ },
+ },
+ ],
+ [
+ {
+ key: 'type',
+ get: function get() {
+ return 'fuzzy';
+ },
+ },
+ {
+ key: 'multiRegex',
+ get: function get() {
+ return /^"(.*)"$/;
+ },
+ },
+ {
+ key: 'singleRegex',
+ get: function get() {
+ return /^(.*)$/;
+ },
+ },
+ ]
+ );
+
+ return FuzzyMatch;
+})(BaseMatch);
+
+var IncludeMatch = /*#__PURE__*/ (function (_BaseMatch) {
+ _inherits(IncludeMatch, _BaseMatch);
+
+ var _super = _createSuper(IncludeMatch);
+
+ function IncludeMatch(pattern) {
+ _classCallCheck(this, IncludeMatch);
+
+ return _super.call(this, pattern);
+ }
+
+ _createClass(
+ IncludeMatch,
+ [
+ {
+ key: 'search',
+ value: function search(text) {
+ var location = 0;
+ var index;
+ var indices = [];
+ var patternLen = this.pattern.length; // Get all exact matches
+
+ while ((index = text.indexOf(this.pattern, location)) > -1) {
+ location = index + patternLen;
+ indices.push([index, location - 1]);
+ }
+
+ var isMatch = !!indices.length;
+ return {
+ isMatch: isMatch,
+ score: isMatch ? 0 : 1,
+ indices: indices,
+ };
+ },
+ },
+ ],
+ [
+ {
+ key: 'type',
+ get: function get() {
+ return 'include';
+ },
+ },
+ {
+ key: 'multiRegex',
+ get: function get() {
+ return /^'"(.*)"$/;
+ },
+ },
+ {
+ key: 'singleRegex',
+ get: function get() {
+ return /^'(.*)$/;
+ },
+ },
+ ]
+ );
+
+ return IncludeMatch;
+})(BaseMatch);
+
+var searchers = [
+ ExactMatch,
+ IncludeMatch,
+ PrefixExactMatch,
+ InversePrefixExactMatch,
+ InverseSuffixExactMatch,
+ SuffixExactMatch,
+ InverseExactMatch,
+ FuzzyMatch,
+];
+var searchersLen = searchers.length; // Regex to split by spaces, but keep anything in quotes together
+
+var SPACE_RE = / +(?=([^\"]*\"[^\"]*\")*[^\"]*$)/;
+var OR_TOKEN = '|'; // Return a 2D array representation of the query, for simpler parsing.
+// Example:
+// "^core go$ | rb$ | py$ xy$" => [["^core", "go$"], ["rb$"], ["py$", "xy$"]]
+
+function parseQuery(pattern) {
+ var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+ return pattern.split(OR_TOKEN).map(function (item) {
+ var query = item
+ .trim()
+ .split(SPACE_RE)
+ .filter(function (item) {
+ return item && !!item.trim();
+ });
+ var results = [];
+
+ for (var i = 0, len = query.length; i < len; i += 1) {
+ var queryItem = query[i]; // 1. Handle multiple query match (i.e, once that are quoted, like `"hello world"`)
+
+ var found = false;
+ var idx = -1;
+
+ while (!found && ++idx < searchersLen) {
+ var searcher = searchers[idx];
+ var token = searcher.isMultiMatch(queryItem);
+
+ if (token) {
+ results.push(new searcher(token, options));
+ found = true;
+ }
+ }
+
+ if (found) {
+ continue;
+ } // 2. Handle single query matches (i.e, once that are *not* quoted)
+
+ idx = -1;
+
+ while (++idx < searchersLen) {
+ var _searcher = searchers[idx];
+
+ var _token = _searcher.isSingleMatch(queryItem);
+
+ if (_token) {
+ results.push(new _searcher(_token, options));
+ break;
+ }
+ }
+ }
+
+ return results;
+ });
+}
+
+// to a singl match
+
+var MultiMatchSet = new Set([FuzzyMatch.type, IncludeMatch.type]);
+/**
+ * Command-like searching
+ * ======================
+ *
+ * Given multiple search terms delimited by spaces.e.g. `^jscript .python$ ruby !java`,
+ * search in a given text.
+ *
+ * Search syntax:
+ *
+ * | Token | Match type | Description |
+ * | ----------- | -------------------------- | -------------------------------------- |
+ * | `jscript` | fuzzy-match | Items that fuzzy match `jscript` |
+ * | `=scheme` | exact-match | Items that are `scheme` |
+ * | `'python` | include-match | Items that include `python` |
+ * | `!ruby` | inverse-exact-match | Items that do not include `ruby` |
+ * | `^java` | prefix-exact-match | Items that start with `java` |
+ * | `!^earlang` | inverse-prefix-exact-match | Items that do not start with `earlang` |
+ * | `.js$` | suffix-exact-match | Items that end with `.js` |
+ * | `!.go$` | inverse-suffix-exact-match | Items that do not end with `.go` |
+ *
+ * A single pipe character acts as an OR operator. For example, the following
+ * query matches entries that start with `core` and end with either`go`, `rb`,
+ * or`py`.
+ *
+ * ```
+ * ^core go$ | rb$ | py$
+ * ```
+ */
+
+var ExtendedSearch = /*#__PURE__*/ (function () {
+ function ExtendedSearch(pattern) {
+ var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
+ _ref$isCaseSensitive = _ref.isCaseSensitive,
+ isCaseSensitive =
+ _ref$isCaseSensitive === void 0 ? Config.isCaseSensitive : _ref$isCaseSensitive,
+ _ref$includeMatches = _ref.includeMatches,
+ includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches,
+ _ref$minMatchCharLeng = _ref.minMatchCharLength,
+ minMatchCharLength =
+ _ref$minMatchCharLeng === void 0 ? Config.minMatchCharLength : _ref$minMatchCharLeng,
+ _ref$ignoreLocation = _ref.ignoreLocation,
+ ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation,
+ _ref$findAllMatches = _ref.findAllMatches,
+ findAllMatches = _ref$findAllMatches === void 0 ? Config.findAllMatches : _ref$findAllMatches,
+ _ref$location = _ref.location,
+ location = _ref$location === void 0 ? Config.location : _ref$location,
+ _ref$threshold = _ref.threshold,
+ threshold = _ref$threshold === void 0 ? Config.threshold : _ref$threshold,
+ _ref$distance = _ref.distance,
+ distance = _ref$distance === void 0 ? Config.distance : _ref$distance;
+
+ _classCallCheck(this, ExtendedSearch);
+
+ this.query = null;
+ this.options = {
+ isCaseSensitive: isCaseSensitive,
+ includeMatches: includeMatches,
+ minMatchCharLength: minMatchCharLength,
+ findAllMatches: findAllMatches,
+ ignoreLocation: ignoreLocation,
+ location: location,
+ threshold: threshold,
+ distance: distance,
+ };
+ this.pattern = isCaseSensitive ? pattern : pattern.toLowerCase();
+ this.query = parseQuery(this.pattern, this.options);
+ }
+
+ _createClass(
+ ExtendedSearch,
+ [
+ {
+ key: 'searchIn',
+ value: function searchIn(text) {
+ var query = this.query;
+
+ if (!query) {
+ return {
+ isMatch: false,
+ score: 1,
+ };
+ }
+
+ var _this$options = this.options,
+ includeMatches = _this$options.includeMatches,
+ isCaseSensitive = _this$options.isCaseSensitive;
+ text = isCaseSensitive ? text : text.toLowerCase();
+ var numMatches = 0;
+ var allIndices = [];
+ var totalScore = 0; // ORs
+
+ for (var i = 0, qLen = query.length; i < qLen; i += 1) {
+ var searchers = query[i]; // Reset indices
+
+ allIndices.length = 0;
+ numMatches = 0; // ANDs
+
+ for (var j = 0, pLen = searchers.length; j < pLen; j += 1) {
+ var searcher = searchers[j];
+
+ var _searcher$search = searcher.search(text),
+ isMatch = _searcher$search.isMatch,
+ indices = _searcher$search.indices,
+ score = _searcher$search.score;
+
+ if (isMatch) {
+ numMatches += 1;
+ totalScore += score;
+
+ if (includeMatches) {
+ var type = searcher.constructor.type;
+
+ if (MultiMatchSet.has(type)) {
+ allIndices = [].concat(
+ _toConsumableArray(allIndices),
+ _toConsumableArray(indices)
+ );
+ } else {
+ allIndices.push(indices);
+ }
+ }
+ } else {
+ totalScore = 0;
+ numMatches = 0;
+ allIndices.length = 0;
+ break;
+ }
+ } // OR condition, so if TRUE, return
+
+ if (numMatches) {
+ var result = {
+ isMatch: true,
+ score: totalScore / numMatches,
+ };
+
+ if (includeMatches) {
+ result.indices = allIndices;
+ }
+
+ return result;
+ }
+ } // Nothing was matched
+
+ return {
+ isMatch: false,
+ score: 1,
+ };
+ },
+ },
+ ],
+ [
+ {
+ key: 'condition',
+ value: function condition(_, options) {
+ return options.useExtendedSearch;
+ },
+ },
+ ]
+ );
+
+ return ExtendedSearch;
+})();
+
+var registeredSearchers = [];
+function register() {
+ registeredSearchers.push.apply(registeredSearchers, arguments);
+}
+function createSearcher(pattern, options) {
+ for (var i = 0, len = registeredSearchers.length; i < len; i += 1) {
+ var searcherClass = registeredSearchers[i];
+
+ if (searcherClass.condition(pattern, options)) {
+ return new searcherClass(pattern, options);
+ }
+ }
+
+ return new BitapSearch(pattern, options);
+}
+
+var LogicalOperator = {
+ AND: '$and',
+ OR: '$or',
+};
+var KeyType = {
+ PATH: '$path',
+ PATTERN: '$val',
+};
+
+var isExpression = function isExpression(query) {
+ return !!(query[LogicalOperator.AND] || query[LogicalOperator.OR]);
+};
+
+var isPath = function isPath(query) {
+ return !!query[KeyType.PATH];
+};
+
+var isLeaf = function isLeaf(query) {
+ return !isArray(query) && isObject(query) && !isExpression(query);
+};
+
+var convertToExplicit = function convertToExplicit(query) {
+ return _defineProperty(
+ {},
+ LogicalOperator.AND,
+ Object.keys(query).map(function (key) {
+ return _defineProperty({}, key, query[key]);
+ })
+ );
+}; // When `auto` is `true`, the parse function will infer and initialize and add
+// the appropriate `Searcher` instance
+
+function parse(query, options) {
+ var _ref3 = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {},
+ _ref3$auto = _ref3.auto,
+ auto = _ref3$auto === void 0 ? true : _ref3$auto;
+
+ var next = function next(query) {
+ var keys = Object.keys(query);
+ var isQueryPath = isPath(query);
+
+ if (!isQueryPath && keys.length > 1 && !isExpression(query)) {
+ return next(convertToExplicit(query));
+ }
+
+ if (isLeaf(query)) {
+ var key = isQueryPath ? query[KeyType.PATH] : keys[0];
+ var pattern = isQueryPath ? query[KeyType.PATTERN] : query[key];
+
+ if (!isString(pattern)) {
+ throw new Error(LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY(key));
+ }
+
+ var obj = {
+ keyId: createKeyId(key),
+ pattern: pattern,
+ };
+
+ if (auto) {
+ obj.searcher = createSearcher(pattern, options);
+ }
+
+ return obj;
+ }
+
+ var node = {
+ children: [],
+ operator: keys[0],
+ };
+ keys.forEach(function (key) {
+ var value = query[key];
+
+ if (isArray(value)) {
+ value.forEach(function (item) {
+ node.children.push(next(item));
+ });
+ }
+ });
+ return node;
+ };
+
+ if (!isExpression(query)) {
+ query = convertToExplicit(query);
+ }
+
+ return next(query);
+}
+
+function computeScore(results, _ref) {
+ var _ref$ignoreFieldNorm = _ref.ignoreFieldNorm,
+ ignoreFieldNorm =
+ _ref$ignoreFieldNorm === void 0 ? Config.ignoreFieldNorm : _ref$ignoreFieldNorm;
+ results.forEach(function (result) {
+ var totalScore = 1;
+ result.matches.forEach(function (_ref2) {
+ var key = _ref2.key,
+ norm = _ref2.norm,
+ score = _ref2.score;
+ var weight = key ? key.weight : null;
+ totalScore *= Math.pow(
+ score === 0 && weight ? Number.EPSILON : score,
+ (weight || 1) * (ignoreFieldNorm ? 1 : norm)
+ );
+ });
+ result.score = totalScore;
+ });
+}
+
+function transformMatches(result, data) {
+ var matches = result.matches;
+ data.matches = [];
+
+ if (!isDefined(matches)) {
+ return;
+ }
+
+ matches.forEach(function (match) {
+ if (!isDefined(match.indices) || !match.indices.length) {
+ return;
+ }
+
+ var indices = match.indices,
+ value = match.value;
+ var obj = {
+ indices: indices,
+ value: value,
+ };
+
+ if (match.key) {
+ obj.key = match.key.src;
+ }
+
+ if (match.idx > -1) {
+ obj.refIndex = match.idx;
+ }
+
+ data.matches.push(obj);
+ });
+}
+
+function transformScore(result, data) {
+ data.score = result.score;
+}
+
+function format(results, docs) {
+ var _ref = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {},
+ _ref$includeMatches = _ref.includeMatches,
+ includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches,
+ _ref$includeScore = _ref.includeScore,
+ includeScore = _ref$includeScore === void 0 ? Config.includeScore : _ref$includeScore;
+
+ var transformers = [];
+ if (includeMatches) transformers.push(transformMatches);
+ if (includeScore) transformers.push(transformScore);
+ return results.map(function (result) {
+ var idx = result.idx;
+ var data = {
+ item: docs[idx],
+ refIndex: idx,
+ };
+
+ if (transformers.length) {
+ transformers.forEach(function (transformer) {
+ transformer(result, data);
+ });
+ }
+
+ return data;
+ });
+}
+
+var Fuse$1 = /*#__PURE__*/ (function () {
+ function Fuse(docs) {
+ var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+ var index = arguments.length > 2 ? arguments[2] : undefined;
+
+ _classCallCheck(this, Fuse);
+
+ this.options = _objectSpread2(_objectSpread2({}, Config), options);
+
+ if (this.options.useExtendedSearch && !true) {
+ throw new Error(EXTENDED_SEARCH_UNAVAILABLE);
+ }
+
+ this._keyStore = new KeyStore(this.options.keys);
+ this.setCollection(docs, index);
+ }
+
+ _createClass(Fuse, [
+ {
+ key: 'setCollection',
+ value: function setCollection(docs, index) {
+ this._docs = docs;
+
+ if (index && !(index instanceof FuseIndex)) {
+ throw new Error(INCORRECT_INDEX_TYPE);
+ }
+
+ this._myIndex =
+ index ||
+ createIndex(this.options.keys, this._docs, {
+ getFn: this.options.getFn,
+ fieldNormWeight: this.options.fieldNormWeight,
+ });
+ },
+ },
+ {
+ key: 'add',
+ value: function add(doc) {
+ if (!isDefined(doc)) {
+ return;
+ }
+
+ this._docs.push(doc);
+
+ this._myIndex.add(doc);
+ },
+ },
+ {
+ key: 'remove',
+ value: function remove() {
+ var predicate =
+ arguments.length > 0 && arguments[0] !== undefined
+ ? arguments[0]
+ : function () /* doc, idx */
+ {
+ return false;
+ };
+ var results = [];
+
+ for (var i = 0, len = this._docs.length; i < len; i += 1) {
+ var doc = this._docs[i];
+
+ if (predicate(doc, i)) {
+ this.removeAt(i);
+ i -= 1;
+ len -= 1;
+ results.push(doc);
+ }
+ }
+
+ return results;
+ },
+ },
+ {
+ key: 'removeAt',
+ value: function removeAt(idx) {
+ this._docs.splice(idx, 1);
+
+ this._myIndex.removeAt(idx);
+ },
+ },
+ {
+ key: 'getIndex',
+ value: function getIndex() {
+ return this._myIndex;
+ },
+ },
+ {
+ key: 'search',
+ value: function search(query) {
+ var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
+ _ref$limit = _ref.limit,
+ limit = _ref$limit === void 0 ? -1 : _ref$limit;
+
+ var _this$options = this.options,
+ includeMatches = _this$options.includeMatches,
+ includeScore = _this$options.includeScore,
+ shouldSort = _this$options.shouldSort,
+ sortFn = _this$options.sortFn,
+ ignoreFieldNorm = _this$options.ignoreFieldNorm;
+ var results = isString(query)
+ ? isString(this._docs[0])
+ ? this._searchStringList(query)
+ : this._searchObjectList(query)
+ : this._searchLogical(query);
+ computeScore(results, {
+ ignoreFieldNorm: ignoreFieldNorm,
+ });
+
+ if (shouldSort) {
+ results.sort(sortFn);
+ }
+
+ if (isNumber(limit) && limit > -1) {
+ results = results.slice(0, limit);
+ }
+
+ return format(results, this._docs, {
+ includeMatches: includeMatches,
+ includeScore: includeScore,
+ });
+ },
+ },
+ {
+ key: '_searchStringList',
+ value: function _searchStringList(query) {
+ var searcher = createSearcher(query, this.options);
+ var records = this._myIndex.records;
+ var results = []; // Iterate over every string in the index
+
+ records.forEach(function (_ref2) {
+ var text = _ref2.v,
+ idx = _ref2.i,
+ norm = _ref2.n;
+
+ if (!isDefined(text)) {
+ return;
+ }
+
+ var _searcher$searchIn = searcher.searchIn(text),
+ isMatch = _searcher$searchIn.isMatch,
+ score = _searcher$searchIn.score,
+ indices = _searcher$searchIn.indices;
+
+ if (isMatch) {
+ results.push({
+ item: text,
+ idx: idx,
+ matches: [
+ {
+ score: score,
+ value: text,
+ norm: norm,
+ indices: indices,
+ },
+ ],
+ });
+ }
+ });
+ return results;
+ },
+ },
+ {
+ key: '_searchLogical',
+ value: function _searchLogical(query) {
+ var _this = this;
+
+ var expression = parse(query, this.options);
+
+ var evaluate = function evaluate(node, item, idx) {
+ if (!node.children) {
+ var keyId = node.keyId,
+ searcher = node.searcher;
+
+ var matches = _this._findMatches({
+ key: _this._keyStore.get(keyId),
+ value: _this._myIndex.getValueForItemAtKeyId(item, keyId),
+ searcher: searcher,
+ });
+
+ if (matches && matches.length) {
+ return [
+ {
+ idx: idx,
+ item: item,
+ matches: matches,
+ },
+ ];
+ }
+
+ return [];
+ }
+
+ var res = [];
+
+ for (var i = 0, len = node.children.length; i < len; i += 1) {
+ var child = node.children[i];
+ var result = evaluate(child, item, idx);
+
+ if (result.length) {
+ res.push.apply(res, _toConsumableArray(result));
+ } else if (node.operator === LogicalOperator.AND) {
+ return [];
+ }
+ }
+
+ return res;
+ };
+
+ var records = this._myIndex.records;
+ var resultMap = {};
+ var results = [];
+ records.forEach(function (_ref3) {
+ var item = _ref3.$,
+ idx = _ref3.i;
+
+ if (isDefined(item)) {
+ var expResults = evaluate(expression, item, idx);
+
+ if (expResults.length) {
+ // Dedupe when adding
+ if (!resultMap[idx]) {
+ resultMap[idx] = {
+ idx: idx,
+ item: item,
+ matches: [],
+ };
+ results.push(resultMap[idx]);
+ }
+
+ expResults.forEach(function (_ref4) {
+ var _resultMap$idx$matche;
+
+ var matches = _ref4.matches;
+
+ (_resultMap$idx$matche = resultMap[idx].matches).push.apply(
+ _resultMap$idx$matche,
+ _toConsumableArray(matches)
+ );
+ });
+ }
+ }
+ });
+ return results;
+ },
+ },
+ {
+ key: '_searchObjectList',
+ value: function _searchObjectList(query) {
+ var _this2 = this;
+
+ var searcher = createSearcher(query, this.options);
+ var _this$_myIndex = this._myIndex,
+ keys = _this$_myIndex.keys,
+ records = _this$_myIndex.records;
+ var results = []; // List is Array
+
+ records.forEach(function (_ref5) {
+ var item = _ref5.$,
+ idx = _ref5.i;
+
+ if (!isDefined(item)) {
+ return;
+ }
+
+ var matches = []; // Iterate over every key (i.e, path), and fetch the value at that key
+
+ keys.forEach(function (key, keyIndex) {
+ matches.push.apply(
+ matches,
+ _toConsumableArray(
+ _this2._findMatches({
+ key: key,
+ value: item[keyIndex],
+ searcher: searcher,
+ })
+ )
+ );
+ });
+
+ if (matches.length) {
+ results.push({
+ idx: idx,
+ item: item,
+ matches: matches,
+ });
+ }
+ });
+ return results;
+ },
+ },
+ {
+ key: '_findMatches',
+ value: function _findMatches(_ref6) {
+ var key = _ref6.key,
+ value = _ref6.value,
+ searcher = _ref6.searcher;
+
+ if (!isDefined(value)) {
+ return [];
+ }
+
+ var matches = [];
+
+ if (isArray(value)) {
+ value.forEach(function (_ref7) {
+ var text = _ref7.v,
+ idx = _ref7.i,
+ norm = _ref7.n;
+
+ if (!isDefined(text)) {
+ return;
+ }
+
+ var _searcher$searchIn2 = searcher.searchIn(text),
+ isMatch = _searcher$searchIn2.isMatch,
+ score = _searcher$searchIn2.score,
+ indices = _searcher$searchIn2.indices;
+
+ if (isMatch) {
+ matches.push({
+ score: score,
+ key: key,
+ value: text,
+ idx: idx,
+ norm: norm,
+ indices: indices,
+ });
+ }
+ });
+ } else {
+ var text = value.v,
+ norm = value.n;
+
+ var _searcher$searchIn3 = searcher.searchIn(text),
+ isMatch = _searcher$searchIn3.isMatch,
+ score = _searcher$searchIn3.score,
+ indices = _searcher$searchIn3.indices;
+
+ if (isMatch) {
+ matches.push({
+ score: score,
+ key: key,
+ value: text,
+ norm: norm,
+ indices: indices,
+ });
+ }
+ }
+
+ return matches;
+ },
+ },
+ ]);
+
+ return Fuse;
+})();
+
+Fuse$1.version = '6.5.3';
+Fuse$1.createIndex = createIndex;
+Fuse$1.parseIndex = parseIndex;
+Fuse$1.config = Config;
+
+{
+ Fuse$1.parseQuery = parse;
+}
+
+{
+ register(ExtendedSearch);
+}
+
+export var Fuse = Fuse$1;
diff --git a/Data/modules/token-variants/scripts/hooks/artSelectButtonHooks.js b/Data/modules/token-variants/scripts/hooks/artSelectButtonHooks.js
new file mode 100644
index 00000000..10e5c9e7
--- /dev/null
+++ b/Data/modules/token-variants/scripts/hooks/artSelectButtonHooks.js
@@ -0,0 +1,207 @@
+import { insertArtSelectButton } from '../../applications/artSelect.js';
+import { showArtSelect } from '../../token-variants.mjs';
+import { TVA_CONFIG } from '../settings.js';
+import { SEARCH_TYPE, updateActorImage } from '../utils.js';
+import { registerHook, unregisterHook } from './hooks.js';
+
+const feature_id = 'ArtSelect';
+
+export function registerArtSelectButtonHooks() {
+ // Insert right-click listeners to open up ArtSelect forms from various contexts
+ if (TVA_CONFIG.permissions.portrait_right_click[game.user.role]) {
+ registerHook(feature_id, 'renderActorSheet', _modActorSheet);
+ registerHook(feature_id, 'renderItemSheet', _modItemSheet);
+ registerHook(feature_id, 'renderItemActionSheet', _modItemSheet);
+ registerHook(feature_id, 'renderJournalSheet', _modJournalSheet);
+ registerHook(feature_id, 'renderRollTableConfig', _modRollTableSheet);
+ } else {
+ [
+ 'renderActorSheet',
+ 'renderItemSheet',
+ 'renderItemActionSheet',
+ 'renderJournalSheet',
+ 'renderRollTableConfig',
+ ].forEach((name) => unregisterHook(feature_id, name));
+ }
+
+ // Insert buttons
+ if (TVA_CONFIG.permissions.image_path_button[game.user.role]) {
+ registerHook(feature_id, 'renderTileConfig', _modTileConfig);
+ registerHook(feature_id, 'renderMeasuredTemplateConfig', _modTemplateConfig);
+ registerHook(feature_id, 'renderTokenConfig', _modTokenConfig);
+ registerHook(feature_id, 'renderDrawingConfig', _modDrawingConfig);
+ registerHook(feature_id, 'renderNoteConfig', _modNoteConfig);
+ registerHook(feature_id, 'renderSceneConfig', _modSceneConfig);
+ registerHook(feature_id, 'renderMacroConfig', _modMacroConfig);
+ registerHook(feature_id, 'renderActiveEffectConfig', _modActiveEffectConfig);
+ } else {
+ [
+ 'renderTileConfig',
+ 'renderMeasuredTemplateConfig',
+ 'renderTokenConfig',
+ 'renderDrawingConfig',
+ 'renderNoteConfig',
+ 'renderSceneConfig',
+ `renderActiveEffectConfig`,
+ ].forEach((name) => unregisterHook(feature_id, name));
+ }
+}
+
+function _modTokenConfig(config, html) {
+ insertArtSelectButton(html, 'texture.src', {
+ search: config.object.name,
+ searchType: SEARCH_TYPE.TOKEN,
+ });
+}
+
+function _modTemplateConfig(config, html) {
+ insertArtSelectButton(html, 'texture', { search: 'Template', searchType: SEARCH_TYPE.TILE });
+}
+
+function _modDrawingConfig(config, html) {
+ insertArtSelectButton(html, 'texture', {
+ search: 'Drawing',
+ searchType: TVA_CONFIG.customImageCategories.includes('Drawing') ? 'Drawing' : SEARCH_TYPE.TILE,
+ });
+}
+
+function _modNoteConfig(config, html) {
+ insertArtSelectButton(html, 'icon.custom', {
+ search: 'Note',
+ searchType: TVA_CONFIG.customImageCategories.includes('Note') ? 'Note' : SEARCH_TYPE.ITEM,
+ });
+}
+
+function _modSceneConfig(config, html) {
+ insertArtSelectButton(html, 'background.src', {
+ search: config.object.name,
+ searchType: TVA_CONFIG.customImageCategories.includes('Scene') ? 'Scene' : SEARCH_TYPE.TILE,
+ });
+ insertArtSelectButton(html, 'foreground', {
+ search: config.object.name,
+ searchType: TVA_CONFIG.customImageCategories.includes('Scene') ? 'Scene' : SEARCH_TYPE.TILE,
+ });
+ insertArtSelectButton(html, 'fogOverlay', {
+ search: config.object.name,
+ searchType: TVA_CONFIG.customImageCategories.includes('Fog') ? 'Fog' : SEARCH_TYPE.TILE,
+ });
+}
+
+function _modTileConfig(tileConfig, html) {
+ insertArtSelectButton(html, 'texture.src', {
+ search: tileConfig.object.getFlag('token-variants', 'tileName') || 'Tile',
+ searchType: SEARCH_TYPE.TILE,
+ });
+}
+
+function _modActiveEffectConfig(effectConfig, html) {
+ const inserted = insertArtSelectButton(html, 'icon', {
+ search: effectConfig.object.name || 'Active Effect',
+ searchType: TVA_CONFIG.customImageCategories.includes('Active Effect') ? 'Active Effect' : SEARCH_TYPE.ITEM,
+ });
+ if (!inserted) {
+ const img = $(html).find('.effect-icon');
+ img.on('contextmenu', () => {
+ showArtSelect(effectConfig.object?.name ?? 'Active Effect', {
+ searchType: SEARCH_TYPE.ITEM,
+ callback: (imgSrc) => img.attr('src', imgSrc),
+ });
+ });
+ }
+}
+
+function _modItemSheet(itemSheet, html, options) {
+ $(html)
+ .find('img.profile, .profile-img, [data-edit="img"]')
+ .on('contextmenu', () => {
+ const item = itemSheet.object;
+ if (!item) return;
+ showArtSelect(item.name, {
+ searchType: SEARCH_TYPE.ITEM,
+ callback: (imgSrc) => item.update({ img: imgSrc }),
+ });
+ });
+}
+
+function _modMacroConfig(macroConfig, html, options) {
+ const img = $(html).find('.sheet-header > img');
+ img.on('contextmenu', () => {
+ showArtSelect(macroConfig.object?.name ?? 'Macro', {
+ searchType: SEARCH_TYPE.MACRO,
+ callback: (imgSrc) => img.attr('src', imgSrc),
+ });
+ });
+}
+
+function _modJournalSheet(journalSheet, html, options) {
+ $(html)
+ .find('.header-button.entry-image')
+ .on('contextmenu', () => {
+ const journal = journalSheet.object;
+ if (!journal) return;
+ showArtSelect(journal.name, {
+ searchType: SEARCH_TYPE.JOURNAL,
+ callback: (imgSrc) => journal.update({ img: imgSrc }),
+ });
+ });
+}
+
+function _modRollTableSheet(sheet, html) {
+ $(html)
+ .find('.result-image')
+ .on('contextmenu', (event) => {
+ const table = sheet.object;
+ if (!table) return;
+ const img = $(event.target).closest('.result-image').find('img');
+ showArtSelect(table.name, {
+ searchType: TVA_CONFIG.customImageCategories.includes('RollTable') ? 'RollTable' : SEARCH_TYPE.ITEM,
+ callback: (imgSrc) => {
+ img.attr('src', imgSrc);
+ sheet._onSubmit(event);
+ },
+ });
+ });
+}
+
+/**
+ * Adds right-click listener to Actor Sheet profile image to open up
+ * the 'Art Select' screen.
+ */
+function _modActorSheet(actorSheet, html, options) {
+ if (options.editable && TVA_CONFIG.permissions.portrait_right_click[game.user.role]) {
+ let profile = null;
+ let profileQueries = {
+ all: ['.profile', '.profile-img', '.profile-image'],
+ pf2e: ['.player-image', '.actor-icon', '.sheet-header img', '.actor-image'],
+ };
+
+ for (let query of profileQueries.all) {
+ profile = html[0].querySelector(query);
+ if (profile) break;
+ }
+
+ if (!profile && game.system.id in profileQueries) {
+ for (let query of profileQueries[game.system.id]) {
+ profile = html[0].querySelector(query);
+ if (profile) break;
+ }
+ }
+
+ if (!profile) {
+ console.warn('TVA |', game.i18n.localize('token-variants.notifications.warn.profile-image-not-found'));
+ return;
+ }
+
+ profile.addEventListener(
+ 'contextmenu',
+ function (ev) {
+ showArtSelect(actorSheet.object.name, {
+ callback: (imgSrc, name) => updateActorImage(actorSheet.object, imgSrc),
+ searchType: SEARCH_TYPE.PORTRAIT,
+ object: actorSheet.object,
+ });
+ },
+ false
+ );
+ }
+}
diff --git a/Data/modules/token-variants/scripts/hooks/effectIconHooks.js b/Data/modules/token-variants/scripts/hooks/effectIconHooks.js
new file mode 100644
index 00000000..1f08d87b
--- /dev/null
+++ b/Data/modules/token-variants/scripts/hooks/effectIconHooks.js
@@ -0,0 +1,31 @@
+import { FEATURE_CONTROL, TVA_CONFIG } from '../settings.js';
+import { registerHook, unregisterHook } from './hooks.js';
+
+const feature_id = 'EffectIcons';
+
+export function registerEffectIconHooks() {
+ // OnHover settings specific hooks
+ if (FEATURE_CONTROL[feature_id] && TVA_CONFIG.displayEffectIconsOnHover) {
+ registerHook(feature_id, 'hoverToken', (token, hoverIn) => {
+ if (token.effects) {
+ token.effects.visible = hoverIn;
+ }
+ });
+ } else {
+ unregisterHook(feature_id, 'hoverToken');
+ }
+
+ if (FEATURE_CONTROL[feature_id] && TVA_CONFIG.displayEffectIconsOnHover) {
+ registerHook(feature_id, 'highlightObjects', (active) => {
+ if (canvas.tokens.active) {
+ for (const tkn of canvas.tokens.placeables) {
+ if (tkn.effects) {
+ tkn.effects.visible = active || tkn.hover;
+ }
+ }
+ }
+ });
+ } else {
+ unregisterHook(feature_id, 'highlightObjects');
+ }
+}
diff --git a/Data/modules/token-variants/scripts/hooks/effectMappingHooks.js b/Data/modules/token-variants/scripts/hooks/effectMappingHooks.js
new file mode 100644
index 00000000..fb3aafcd
--- /dev/null
+++ b/Data/modules/token-variants/scripts/hooks/effectMappingHooks.js
@@ -0,0 +1,1044 @@
+import { FEATURE_CONTROL, TVA_CONFIG, getFlagMappings } from '../settings.js';
+import {
+ applyCEEffect,
+ applyTMFXPreset,
+ determineAddedRemovedEffects,
+ executeMacro,
+ EXPRESSION_OPERATORS,
+ getAllActorTokens,
+ getFileName,
+ tv_executeScript,
+ updateTokenImage,
+} from '../utils.js';
+import { broadcastOverlayRedraw } from '../token/overlay.js';
+import { registerHook, unregisterHook } from './hooks.js';
+
+const EXPRESSION_MATCH_RE = /(\\\()|(\\\))|(\|\|)|(\&\&)|(\\\!)/g;
+const PF2E_ITEM_TYPES = ['condition', 'effect', 'weapon', 'equipment'];
+const ITEM_TYPES = ['equipment', 'weapon'];
+const feature_id = 'EffectMappings';
+
+export function registerEffectMappingHooks() {
+ if (!FEATURE_CONTROL[feature_id]) {
+ [
+ 'canvasReady',
+ 'createActiveEffect',
+ 'deleteActiveEffect',
+ 'preUpdateActiveEffect',
+ 'updateActiveEffect',
+ 'createCombatant',
+ 'deleteCombatant',
+ 'preUpdateCombat',
+ 'updateCombat',
+ 'deleteCombat',
+ 'preUpdateToken',
+ 'preUpdateActor',
+ 'updateActor',
+ 'updateToken',
+ 'createToken',
+ 'preUpdateItem',
+ 'updateItem',
+ 'createItem',
+ 'deleteItem',
+ ].forEach((name) => unregisterHook(feature_id, name));
+ return;
+ }
+
+ if (game.user.isGM) {
+ registerHook(feature_id, 'canvasReady', _refreshTokenMappings);
+ _refreshTokenMappings();
+ }
+
+ registerHook(feature_id, 'createActiveEffect', (activeEffect, options, userId) => {
+ if (!activeEffect.parent || activeEffect.disabled || game.userId !== userId) return;
+ const effectName = activeEffect.name ?? activeEffect.label;
+ _updateImageOnEffectChange(effectName, activeEffect.parent, true);
+ });
+ registerHook(feature_id, 'deleteActiveEffect', (activeEffect, options, userId) => {
+ if (!activeEffect.parent || activeEffect.disabled || game.userId !== userId) return;
+ const effectName = activeEffect.name ?? activeEffect.label;
+ _updateImageOnEffectChange(effectName, activeEffect.parent, false);
+ });
+ registerHook(feature_id, 'preUpdateActiveEffect', _preUpdateActiveEffect);
+ registerHook(feature_id, 'updateActiveEffect', _updateActiveEffect);
+ registerHook(feature_id, 'preUpdateToken', _preUpdateToken);
+ registerHook(feature_id, 'preUpdateActor', _preUpdateActor);
+ registerHook(feature_id, 'updateActor', _updateActor);
+ registerHook(feature_id, 'updateToken', _updateToken);
+ registerHook(feature_id, 'createToken', _createToken);
+ registerHook(feature_id, 'createCombatant', _createCombatant);
+ registerHook(feature_id, 'deleteCombatant', (combatant, options, userId) => {
+ if (game.userId !== userId) return;
+ _deleteCombatant(combatant);
+ });
+ registerHook(feature_id, 'preUpdateCombat', _preUpdateCombat);
+ registerHook(feature_id, 'updateCombat', _updateCombat);
+ registerHook(feature_id, 'deleteCombat', (combat, options, userId) => {
+ if (game.userId !== userId) return;
+ combat.combatants.forEach((combatant) => {
+ _deleteCombatant(combatant);
+ });
+ });
+
+ const applicable_item_types = game.system.id === 'pf2e' ? PF2E_ITEM_TYPES : ITEM_TYPES;
+ // Want to track condition/effect previous name so that the config can be reverted for it
+ registerHook(feature_id, 'preUpdateItem', (item, change, options, userId) => {
+ if (game.user.id === userId && applicable_item_types.includes(item.type)) {
+ options['token-variants-old-name'] = item.name;
+ }
+ _preUpdateAssign(item.parent, change, options);
+ });
+
+ registerHook(feature_id, 'createItem', (item, options, userId) => {
+ if (game.userId !== userId || !applicable_item_types.includes(item.type) || !item.parent)
+ return;
+ _updateImageOnEffectChange(item.name, item.parent, true);
+ });
+
+ registerHook(feature_id, 'deleteItem', (item, options, userId) => {
+ if (
+ game.userId !== userId ||
+ !applicable_item_types.includes(item.type) ||
+ !item.parent ||
+ item.disabled
+ )
+ return;
+ _updateImageOnEffectChange(item.name, item.parent, false);
+ });
+
+ // Status Effects can be applied "stealthily" on item equip/un-equip
+ registerHook(feature_id, 'updateItem', _updateItem);
+}
+
+async function _refreshTokenMappings() {
+ for (const tkn of canvas.tokens.placeables) {
+ await updateWithEffectMapping(tkn);
+ }
+}
+
+function _createCombatant(combatant, options, userId) {
+ if (game.userId !== userId) return;
+ const token = combatant._token || canvas.tokens.get(combatant.tokenId);
+ if (!token || !token.actor) return;
+
+ updateWithEffectMapping(token, {
+ added: ['token-variants-combat'],
+ });
+}
+
+function _preUpdateActiveEffect(activeEffect, change, options, userId) {
+ if (!activeEffect.parent || game.userId !== userId) return;
+
+ if ('label' in change) {
+ options['token-variants-old-name'] = activeEffect.label;
+ }
+}
+
+function _updateActiveEffect(activeEffect, change, options, userId) {
+ if (!activeEffect.parent || game.userId !== userId) return;
+
+ const added = [];
+ const removed = [];
+
+ if ('disabled' in change) {
+ if (change.disabled) removed.push(activeEffect.label);
+ else added.push(activeEffect.label);
+ }
+ if ('label' in change) {
+ removed.push(options['token-variants-old-name']);
+ added.push(change.label);
+ }
+
+ if (added.length || removed.length) {
+ _updateImageOnMultiEffectChange(activeEffect.parent, added, removed);
+ }
+}
+
+function _preUpdateToken(token, change, options, userId) {
+ if (game.user.id !== userId || change.actorId) return;
+
+ const preUpdateEffects = evaluateComparatorEffects(token);
+
+ if (TVA_CONFIG.internalEffects.hpChange.enabled) {
+ getHPChangeEffect(token, preUpdateEffects);
+ }
+
+ if (preUpdateEffects.length) {
+ setProperty(options, 'token-variants.preUpdateEffects', preUpdateEffects);
+ }
+
+ // System specific effects
+ const stateEffects = [];
+ evaluateStateEffects(token, stateEffects);
+ if (stateEffects.length) {
+ setProperty(options, 'token-variants.system', stateEffects);
+ }
+
+ if (game.system.id === 'dnd5e' && token.actor?.isPolymorphed) {
+ setProperty(options, 'token-variants.wasPolymorphed', true);
+ }
+}
+
+async function _updateToken(token, change, options, userId) {
+ if (game.user.id !== userId || change.actorId) return;
+
+ const addedEffects = [];
+ const removedEffects = [];
+ const postUpdateEffects = evaluateComparatorEffects(token);
+ if (TVA_CONFIG.internalEffects.hpChange.enabled) {
+ getHPChangeEffect(token, postUpdateEffects);
+ }
+ const preUpdateEffects = getProperty(options, 'token-variants.preUpdateEffects') || [];
+ determineAddedRemovedEffects(addedEffects, removedEffects, postUpdateEffects, preUpdateEffects);
+
+ const newStateEffects = [];
+ evaluateStateEffects(token, newStateEffects);
+ const oldStateEffects = getProperty(options, 'token-variants.system') || [];
+ determineAddedRemovedEffects(addedEffects, removedEffects, newStateEffects, oldStateEffects);
+
+ if (addedEffects.length || removedEffects.length || 'actorLink' in change) {
+ updateWithEffectMapping(token, { added: addedEffects, removed: removedEffects });
+ } else if (getProperty(options, 'token-variants.wasPolymorphed') && !token.actor?.isPolymorphed) {
+ updateWithEffectMapping(token);
+ }
+
+ if (game.userId === userId && 'hidden' in change) {
+ updateWithEffectMapping(token, {
+ added: change.hidden ? ['token-variants-visibility'] : [],
+ removed: !change.hidden ? ['token-variants-visibility'] : [],
+ });
+ }
+}
+
+function _preUpdateActor(actor, change, options, userId) {
+ if (game.user.id !== userId) return;
+ _preUpdateAssign(actor, change, options);
+}
+
+async function _updateActor(actor, change, options, userId) {
+ if (game.user.id !== userId) return;
+
+ if ('flags' in change && 'token-variants' in change.flags) {
+ const tokenVariantFlags = change.flags['token-variants'];
+ if ('effectMappings' in tokenVariantFlags || '-=effectMappings' in tokenVariantFlags) {
+ const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, true);
+ tokens.forEach((tkn) => updateWithEffectMapping(tkn));
+ for (const tkn of tokens) {
+ if (tkn.object && TVA_CONFIG.filterEffectIcons) {
+ await tkn.object.drawEffects();
+ }
+ }
+ }
+ }
+
+ _preUpdateCheck(actor, options);
+}
+
+function _preUpdateAssign(actor, change, options) {
+ if (!actor) return;
+
+ // Determine which comparators are applicable so that we can compare after the
+ // actor update
+ const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, true);
+ if (TVA_CONFIG.internalEffects.hpChange.enabled && tokens.length) {
+ applyHpChangeEffect(actor, change, tokens);
+ }
+ for (const tkn of tokens) {
+ const preUpdateEffects = getTokenEffects(tkn);
+ //const preUpdateEffects = evaluateComparatorEffects(tkn);
+
+ if (TVA_CONFIG.internalEffects.hpChange.enabled) {
+ getHPChangeEffect(tkn, preUpdateEffects);
+ }
+
+ if (preUpdateEffects.length) {
+ setProperty(options, 'token-variants.' + tkn.id + '.preUpdateEffects', preUpdateEffects);
+ }
+ }
+}
+
+function _preUpdateCheck(actor, options, pAdded = [], pRemoved = []) {
+ if (!actor) return;
+ const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, true);
+ for (const tkn of tokens) {
+ // Check if effects changed by comparing them against the ones calculated in preUpdate*
+ const added = [...pAdded];
+ const removed = [...pRemoved];
+ //const postUpdateEffects = evaluateComparatorEffects(tkn);
+ const postUpdateEffects = getTokenEffects(tkn);
+ if (TVA_CONFIG.internalEffects.hpChange.enabled) {
+ getHPChangeEffect(tkn, postUpdateEffects);
+ }
+
+ const preUpdateEffects =
+ getProperty(options, 'token-variants.' + tkn.id + '.preUpdateEffects') ?? [];
+
+ determineAddedRemovedEffects(added, removed, postUpdateEffects, preUpdateEffects);
+ if (added.length || removed.length) updateWithEffectMapping(tkn, { added, removed });
+ }
+}
+
+function _createToken(token, options, userId) {
+ if (userId && userId === game.user.id) updateWithEffectMapping(token);
+}
+
+function _preUpdateCombat(combat, round, options, userId) {
+ if (game.userId !== userId) return;
+ options['token-variants'] = {
+ combatantId: combat?.combatant?.token?.id,
+ nextCombatantId: combat?.nextCombatant?.token?.id,
+ };
+}
+
+function _updateCombat(combat, round, options, userId) {
+ if (game.userId !== userId) return;
+
+ const previousCombatantId = options['token-variants']?.combatantId;
+ const previousNextCombatantId = options['token-variants']?.nextCombatantId;
+
+ const currentCombatantId = combat?.combatant?.token?.id;
+ const currentNextCombatantId = combat?.nextCombatant?.token?.id;
+
+ const updateCombatant = function (id, added = [], removed = []) {
+ if (game.user.isGM) {
+ const token = canvas.tokens.get(id);
+ if (token) updateWithEffectMapping(token, { added, removed });
+ } else {
+ const message = {
+ handlerName: 'effectMappings',
+ args: { tokenId: id, sceneId: canvas.scene.id, added, removed },
+ type: 'UPDATE',
+ };
+ game.socket?.emit('module.token-variants', message);
+ }
+ };
+
+ if (previousCombatantId !== currentCombatantId) {
+ if (previousCombatantId) updateCombatant(previousCombatantId, [], ['combat-turn']);
+ if (currentCombatantId) updateCombatant(currentCombatantId, ['combat-turn'], []);
+ }
+ if (previousNextCombatantId !== currentNextCombatantId) {
+ if (previousNextCombatantId) updateCombatant(previousNextCombatantId, [], ['combat-turn-next']);
+ if (currentNextCombatantId) updateCombatant(currentNextCombatantId, ['combat-turn-next'], []);
+ }
+}
+
+function _updateItem(item, change, options, userId) {
+ const added = [];
+ const removed = [];
+
+ if (game.user.id === userId) {
+ // Handle condition/effect name change
+ if (options['token-variants-old-name'] !== item.name) {
+ added.push(item.name);
+ removed.push(options['token-variants-old-name']);
+ }
+
+ _preUpdateCheck(item.parent, options, added, removed);
+ }
+}
+
+let EFFECT_M_QUEUES = {};
+let EFFECT_M_TIMER;
+
+export async function updateWithEffectMapping(token, { added = [], removed = [] } = {}) {
+ const callUpdateWithEffectMapping = function () {
+ for (const id of Object.keys(EFFECT_M_QUEUES)) {
+ const m = EFFECT_M_QUEUES[id];
+ _updateWithEffectMapping(m.token, m.opts.added, m.opts.removed);
+ }
+ EFFECT_M_QUEUES = {};
+ };
+
+ clearTimeout(EFFECT_M_TIMER);
+
+ if (token.id in EFFECT_M_QUEUES) {
+ const opts = EFFECT_M_QUEUES[token.id].opts;
+ added.forEach((a) => opts.added.add(a));
+ removed.forEach((a) => opts.removed.add(a));
+ } else {
+ EFFECT_M_QUEUES[token.id] = {
+ token,
+ opts: { added: new Set(added), removed: new Set(removed) },
+ };
+ }
+ EFFECT_M_TIMER = setTimeout(callUpdateWithEffectMapping, 100);
+}
+
+async function _updateWithEffectMapping(token, added, removed) {
+ const placeable = token.object ?? token._object ?? token;
+ token = token.document ?? token;
+
+ const tokenImgName = token.getFlag('token-variants', 'name') || getFileName(token.texture.src);
+ let tokenDefaultImg = token.getFlag('token-variants', 'defaultImg');
+ const animate = !TVA_CONFIG.disableTokenUpdateAnimation;
+ const tokenUpdateObj = {};
+ const hadActiveHUD = token.object?.hasActiveHUD;
+ const toggleStatus =
+ canvas.tokens.hud.object?.id === token.id ? canvas.tokens.hud._statusEffects : false;
+
+ let effects = getTokenEffects(token);
+
+ // If effect is included in `added` or `removed` we need to:
+ // 1. Insert it into `effects` if it's not there in case of 'added' and place it on top of the list
+ // 2. Remove it in case of 'removed'
+ for (const ef of added) {
+ const i = effects.findIndex((s) => s === ef);
+ if (i === -1) {
+ effects.push(ef);
+ } else if (i < effects.length - 1) {
+ effects.splice(i, 1);
+ effects.push(ef);
+ }
+ }
+ for (const ef of removed) {
+ const i = effects.findIndex((s) => s === ef);
+ if (i !== -1) {
+ effects.splice(i, 1);
+ }
+ }
+
+ const mappings = getAllEffectMappings(token);
+
+ // 3. Configurations may contain effect names in a form of a logical expressions
+ // We need to evaluate them and insert them into effects/added/removed if needed
+ for (const mapping of mappings) {
+ evaluateMappingExpression(mapping, effects, added, removed);
+ }
+
+ // Accumulate all scripts that will need to be run after the update
+ const executeOnCallback = [];
+ const deferredUpdateScripts = [];
+ for (const ef of removed) {
+ const script = mappings.find((m) => m.id === ef)?.config?.tv_script;
+ if (script) {
+ if (script.onRemove) {
+ if (script.onRemove.includes('tvaUpdate')) deferredUpdateScripts.push(script.onRemove);
+ else executeOnCallback.push({ script: script.onRemove, token });
+ }
+ if (script.tmfxPreset)
+ executeOnCallback.push({ tmfxPreset: script.tmfxPreset, token, action: 'remove' });
+ if (script.ceEffect?.name)
+ executeOnCallback.push({ ceEffect: script.ceEffect, token, action: 'remove' });
+ if (script.macroOnApply) executeOnCallback.push({ macro: script.macroOnApply, token });
+ }
+ }
+ for (const ef of added) {
+ const script = mappings.find((m) => m.id === ef)?.config?.tv_script;
+ if (script) {
+ if (script.onApply) {
+ if (script.onApply.includes('tvaUpdate')) deferredUpdateScripts.push(script.onApply);
+ else executeOnCallback.push({ script: script.onApply, token });
+ }
+ if (script.tmfxPreset)
+ executeOnCallback.push({ tmfxPreset: script.tmfxPreset, token, action: 'apply' });
+ if (script.ceEffect?.name)
+ executeOnCallback.push({ ceEffect: script.ceEffect, token, action: 'apply' });
+ if (script.macroOnRemove) executeOnCallback.push({ macro: script.macroOnRemove, token });
+ }
+ }
+
+ // Next we're going to determine what configs need to be applied and in what order
+ // Filter effects that do not have a mapping and sort based on priority
+ effects = mappings
+ .filter((m) => effects.includes(m.id))
+ .sort((ef1, ef2) => ef1.priority - ef2.priority);
+
+ // Check if image update should be prevented based on module settings
+ let disableImageUpdate = false;
+ if (TVA_CONFIG.disableImageChangeOnPolymorphed && token.actor?.isPolymorphed) {
+ disableImageUpdate = true;
+ } else if (
+ TVA_CONFIG.disableImageUpdateOnNonPrototype &&
+ token.actor?.prototypeToken?.texture?.src !== token.texture.src
+ ) {
+ disableImageUpdate = true;
+ const tknImg = token.texture.src;
+ for (const m of mappings) {
+ if (m.imgSrc === tknImg) {
+ disableImageUpdate = false;
+ break;
+ }
+ }
+ }
+
+ if (disableImageUpdate) {
+ tokenDefaultImg = '';
+ }
+
+ let updateCall;
+
+ if (effects.length > 0) {
+ // Some effect mappings may not have images, find a mapping with one if it exists
+ const newImg = { imgSrc: '', imgName: '' };
+
+ if (!disableImageUpdate) {
+ for (let i = effects.length - 1; i >= 0; i--) {
+ if (effects[i].imgSrc) {
+ let iSrc = effects[i].imgSrc;
+ if (iSrc.includes('*') || (iSrc.includes('{') && iSrc.includes('}'))) {
+ // wildcard image, if this effect hasn't been newly applied we do not want to randomize the image again
+ if (!added.has(effects[i].overlayConfig?.effect)) {
+ newImg.imgSrc = token.texture.src;
+ newImg.imgName = getFileName(newImg.imgSrc);
+ break;
+ }
+ }
+ newImg.imgSrc = effects[i].imgSrc;
+ newImg.imgName = effects[i].imgName;
+ break;
+ }
+ }
+ }
+
+ // Collect custom configs to be applied to the token
+ let config;
+ if (TVA_CONFIG.stackStatusConfig) {
+ config = {};
+ for (const ef of effects) {
+ config = mergeObject(config, ef.config);
+ }
+ } else {
+ for (let i = effects.length - 1; i >= 0; i--) {
+ if (effects[i].config && Object.keys(effects[i].config).length !== 0) {
+ config = effects[i].config;
+ break;
+ }
+ }
+ }
+
+ // Use or update the default (original) token image
+ if (!newImg.imgSrc && tokenDefaultImg) {
+ delete tokenUpdateObj.flags?.['token-variants']?.defaultImg;
+ setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultImg', null);
+ newImg.imgSrc = tokenDefaultImg.imgSrc;
+ newImg.imgName = tokenDefaultImg.imgName;
+ } else if (!tokenDefaultImg && newImg.imgSrc) {
+ setProperty(tokenUpdateObj, 'flags.token-variants.defaultImg', {
+ imgSrc: token.texture.src,
+ imgName: tokenImgName,
+ });
+ }
+
+ updateCall = () =>
+ updateTokenImage(newImg.imgSrc ?? null, {
+ token,
+ imgName: newImg.imgName ? newImg.imgName : tokenImgName,
+ tokenUpdate: tokenUpdateObj,
+ callback: _postTokenUpdateProcessing.bind(
+ null,
+ token,
+ hadActiveHUD,
+ toggleStatus,
+ executeOnCallback
+ ),
+ config: config,
+ animate,
+ });
+ }
+
+ // If no mapping has been found and the default image (image prior to effect triggered update) is different from current one
+ // reset the token image back to default
+ if (effects.length === 0 && tokenDefaultImg) {
+ delete tokenUpdateObj.flags?.['token-variants']?.defaultImg;
+ setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultImg', null);
+
+ updateCall = () =>
+ updateTokenImage(tokenDefaultImg.imgSrc, {
+ token,
+ imgName: tokenDefaultImg.imgName,
+ tokenUpdate: tokenUpdateObj,
+ callback: _postTokenUpdateProcessing.bind(
+ null,
+ token,
+ hadActiveHUD,
+ toggleStatus,
+ executeOnCallback
+ ),
+ animate,
+ });
+ // If no default image exists but a custom effect is applied, we still want to perform an update to
+ // clear it
+ } else if (effects.length === 0 && token.getFlag('token-variants', 'usingCustomConfig')) {
+ updateCall = () =>
+ updateTokenImage(token.texture.src, {
+ token,
+ imgName: tokenImgName,
+ tokenUpdate: tokenUpdateObj,
+ callback: _postTokenUpdateProcessing.bind(
+ null,
+ token,
+ hadActiveHUD,
+ toggleStatus,
+ executeOnCallback
+ ),
+ animate,
+ });
+ }
+
+ if (updateCall) {
+ if (deferredUpdateScripts.length) {
+ for (let i = 0; i < deferredUpdateScripts.length; i++) {
+ if (i === deferredUpdateScripts.length - 1) {
+ await tv_executeScript(deferredUpdateScripts[i], {
+ token,
+ tvaUpdate: () => {
+ updateCall();
+ },
+ });
+ } else {
+ await tv_executeScript(deferredUpdateScripts[i], {
+ token,
+ tvaUpdate: () => {},
+ });
+ }
+ }
+ } else {
+ updateCall();
+ }
+ }
+ broadcastOverlayRedraw(placeable);
+}
+
+async function _postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, scripts) {
+ if (hadActiveHUD && token.object) {
+ canvas.tokens.hud.bind(token.object);
+ if (toggleStatus) canvas.tokens.hud._toggleStatusEffects(true);
+ }
+ for (const scr of scripts) {
+ if (scr.script) {
+ await tv_executeScript(scr.script, { token: scr.token });
+ } else if (scr.tmfxPreset) {
+ await applyTMFXPreset(scr.token, scr.tmfxPreset, scr.action);
+ } else if (scr.ceEffect) {
+ await applyCEEffect(scr.token, scr.ceEffect, scr.action);
+ } else if (scr.macro) {
+ await executeMacro(scr.macro, token);
+ }
+ }
+}
+
+export function getAllEffectMappings(token = null, includeDisabled = false) {
+ let allMappings = getFlagMappings(token);
+ const unique = new Set();
+
+ // TODO: replace with a setting
+ allMappings.forEach((m) => unique.add(TVA_CONFIG.mergeGroup ? m.group : m.label));
+
+ // Sort out global mappings that do not apply to this actor
+ let applicableGlobal = TVA_CONFIG.globalMappings;
+ if (token?.actor?.type) {
+ const actorType = token.actor.type;
+ applicableGlobal = applicableGlobal.filter((m) => {
+ if (!m.targetActors || m.targetActors.includes(actorType)) {
+ return !unique.has(TVA_CONFIG.mergeGroup ? m.group : m.label);
+ }
+ return false;
+ });
+ }
+ allMappings = allMappings.concat(applicableGlobal);
+
+ if (!includeDisabled) allMappings = allMappings.filter((m) => !m.disabled);
+
+ return allMappings;
+}
+
+function getHPChangeEffect(token, effects) {
+ const internals = token.actor?.getFlag('token-variants', 'internalEffects') || {};
+ const delta = getProperty(
+ token,
+ `${
+ isNewerVersion('11', game.version) ? 'actorData' : 'delta'
+ }.flags.token-variants.internalEffects`
+ );
+ if (delta) mergeObject(internals, delta);
+ if (internals['hp--'] != null) effects.push('hp--');
+ if (internals['hp++'] != null) effects.push('hp++');
+}
+
+function applyHpChangeEffect(actor, change, tokens) {
+ let duration = Number(TVA_CONFIG.internalEffects.hpChange.duration);
+
+ const newHpValue = getProperty(change, `system.${TVA_CONFIG.systemHpPath}.value`);
+ if (newHpValue != null) {
+ const [currentHpVal, _] = getTokenHP(tokens[0]);
+ if (currentHpVal !== newHpValue) {
+ if (currentHpVal < newHpValue) {
+ setProperty(change, 'flags.token-variants.internalEffects.-=hp--', null);
+ setProperty(change, 'flags.token-variants.internalEffects.hp++', newHpValue - currentHpVal);
+ if (duration) {
+ setTimeout(() => {
+ actor.update({
+ 'flags.token-variants.internalEffects.-=hp++': null,
+ });
+ }, duration * 1000);
+ }
+ } else {
+ setProperty(change, 'flags.token-variants.internalEffects.-=hp++', null);
+ setProperty(change, 'flags.token-variants.internalEffects.hp--', newHpValue - currentHpVal);
+ if (duration) {
+ setTimeout(() => {
+ actor.update({
+ 'flags.token-variants.internalEffects.-=hp--': null,
+ });
+ }, duration * 1000);
+ }
+ }
+ }
+ }
+}
+
+export function getTokenEffects(token, includeExpressions = false) {
+ const data = token.document ?? token;
+ let effects = [];
+
+ // Special Effects
+
+ const tokenInCombat = game.combats.some((combat) => {
+ return combat.combatants.some((c) => c.tokenId === token.id);
+ });
+ if (tokenInCombat) {
+ effects.push('token-variants-combat');
+ }
+
+ if (game.combat?.started) {
+ if (game.combat?.combatant?.token?.id === token.id) {
+ effects.push('combat-turn');
+ } else if (game.combat?.nextCombatant?.token?.id === token.id) {
+ effects.push('combat-turn-next');
+ }
+ }
+ if (data.hidden) {
+ effects.push('token-variants-visibility');
+ }
+
+ if (TVA_CONFIG.internalEffects.hpChange.enabled) {
+ getHPChangeEffect(data, effects);
+ }
+
+ if (game.system.id === 'pf2e') {
+ if (data.actorLink) {
+ getEffectsFromActor(token.actor, effects);
+ } else {
+ if (isNewerVersion('11', game.version)) {
+ (data.actorData?.items || []).forEach((item) => {
+ if (PF2E_ITEM_TYPES.includes(item.type)) {
+ if (('active' in item && item.active) || ('isEquipped' in item && item.isEquipped))
+ effects.push(item.name);
+ }
+ });
+ } else {
+ (data.delta?.items || []).forEach((item) => {
+ if (PF2E_ITEM_TYPES.includes(item.type)) {
+ if (('active' in item && item.active) || ('isEquipped' in item && item.isEquipped))
+ effects.push(item.name);
+ }
+ });
+ }
+ }
+ } else {
+ if (data.actorLink && token.actor) {
+ getEffectsFromActor(token.actor, effects);
+ } else {
+ (data.effects || [])
+ .filter((ef) => !ef.disabled && !ef.isSuppressed)
+ .forEach((ef) => effects.push(ef.label));
+ getEffectsFromActor(token.actor, effects);
+ }
+ }
+
+ evaluateComparatorEffects(token, effects);
+ evaluateStateEffects(token, effects);
+
+ // Include mappings marked as always applicable
+ // as well as the ones defined as logical expressions if needed
+ const mappings = getAllEffectMappings(token);
+
+ for (const m of mappings) {
+ if (m.alwaysOn) effects.unshift(m.id);
+ else if (includeExpressions) {
+ const evaluation = evaluateMappingExpression(m, effects);
+ if (evaluation) effects.unshift(m.id);
+ }
+ }
+
+ return effects;
+}
+
+export function getEffectsFromActor(actor, effects = []) {
+ if (!actor) return effects;
+
+ if (game.system.id === 'pf2e') {
+ (actor.items || []).forEach((item, id) => {
+ if (PF2E_ITEM_TYPES.includes(item.type)) {
+ if ('active' in item) {
+ if (item.active) effects.push(item.name);
+ } else if ('isEquipped' in item) {
+ if (item.isEquipped) effects.push(item.name);
+ }
+ }
+ });
+ } else {
+ (actor.effects || []).forEach((activeEffect, id) => {
+ if (!activeEffect.disabled && !activeEffect.isSuppressed)
+ effects.push(activeEffect.name ?? activeEffect.label);
+ });
+ (actor.items || []).forEach((item) => {
+ if (ITEM_TYPES.includes(item.type) && item.system.equipped)
+ effects.push(item.name ?? item.label);
+ });
+ }
+
+ return effects;
+}
+
+export const VALID_EXPRESSION = new RegExp('([a-zA-Z\\-\\.\\+]+)([><=]+)(".*"|-?\\d+)(%{0,1})');
+
+export function evaluateComparator(token, expression) {
+ const match = expression.match(VALID_EXPRESSION);
+ if (match) {
+ const property = match[1];
+
+ let currVal;
+ let maxVal;
+ if (property === 'hp') {
+ [currVal, maxVal] = getTokenHP(token);
+ } else if (property === 'hp++' || property === 'hp--') {
+ [currVal, maxVal] = getTokenHP(token);
+ currVal = getProperty(token, `actor.flags.token-variants.internalEffects.${property}`) ?? 0;
+ } else currVal = getProperty(token, property);
+ if (currVal == null) currVal = 0;
+
+ const sign = match[2];
+ let val = Number(match[3]);
+ if (isNaN(val)) {
+ val = match[3].substring(1, match[3].length - 1);
+ if (val === 'true') val = true;
+ if (val === 'false') val = false;
+ // Convert currVal to a truthy/falsy one if this is a bool check
+ if (val === true || val === false) {
+ if (isEmpty(currVal)) currVal = false;
+ else currVal = Boolean(currVal);
+ }
+ }
+ const isPercentage = Boolean(match[4]);
+
+ if (property === 'rotation') {
+ maxVal = 360;
+ } else if (maxVal == null) {
+ maxVal = 999999;
+ }
+ const toCompare = isPercentage ? (currVal / maxVal) * 100 : currVal;
+
+ let passed = false;
+ if (sign === '=') {
+ passed = toCompare == val;
+ } else if (sign === '>') {
+ passed = toCompare > val;
+ } else if (sign === '<') {
+ passed = toCompare < val;
+ } else if (sign === '>=') {
+ passed = toCompare >= val;
+ } else if (sign === '<=') {
+ passed = toCompare <= val;
+ } else if (sign === '<>') {
+ passed = toCompare < val || toCompare > val;
+ }
+ return passed;
+ }
+ return false;
+}
+
+export function evaluateComparatorEffects(token, effects = []) {
+ token = token.document ?? token;
+
+ const mappings = getAllEffectMappings(token);
+
+ const matched = new Set();
+
+ for (const m of mappings) {
+ const expressions = m.expression
+ .split(EXPRESSION_MATCH_RE)
+ .filter(Boolean)
+ .map((exp) => exp.trim())
+ .filter(Boolean);
+ for (let i = 0; i < expressions.length; i++) {
+ if (evaluateComparator(token, expressions[i])) {
+ matched.add(expressions[i]);
+ if (expressions.length === 1) effects.unshift(m.id);
+ }
+ }
+ }
+
+ // Remove duplicate expressions and insert into effects
+ matched.forEach((exp) => effects.unshift(exp));
+
+ return effects;
+}
+
+export function evaluateStateEffects(token, effects) {
+ if (game.system.id === 'pf2e') {
+ const deathIcon = game.settings.get('pf2e', 'deathIcon');
+ if ((token.document ?? token).overlayEffect === deathIcon) effects.push('Dead');
+ }
+}
+
+/**
+ * Replaces {1,a,5,b} type string in the expressions with (1|a|5|b)
+ * @param {*} exp
+ * @returns
+ */
+function _findReplaceBracketWildcard(exp) {
+ let nExp = '';
+ let lIndex = 0;
+ while (lIndex >= 0) {
+ let i1 = exp.indexOf('\\\\\\{', lIndex);
+ if (i1 !== -1) {
+ let i2 = exp.indexOf('\\\\\\}', i1);
+ if (i2 !== -1) {
+ nExp += exp.substring(lIndex, i1);
+ nExp +=
+ '(' +
+ exp
+ .substring(i1 + 4, i2)
+ .split(',')
+ .join('|') +
+ ')';
+ }
+ lIndex = i2 + 4;
+ } else {
+ return nExp + exp.substring(lIndex, exp.length);
+ }
+ }
+ return nExp ?? exp;
+}
+
+function _testRegExEffect(effect, effects) {
+ let re = effect.replace(/[/\-\\^$+?.()|[\]{}]/g, '\\$&').replaceAll('\\\\*', '.*');
+ re = _findReplaceBracketWildcard(re);
+ re = new RegExp('^' + re + '$');
+ return effects.find((ef) => re.test(ef));
+}
+
+export function evaluateMappingExpression(
+ mapping,
+ effects,
+ added = new Set(),
+ removed = new Set()
+) {
+ let arrExpression = mapping.expression
+ .split(EXPRESSION_MATCH_RE)
+ .filter(Boolean)
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ let temp = '';
+ let hasAdded = false;
+ let hasRemoved = false;
+ for (let exp of arrExpression) {
+ if (EXPRESSION_OPERATORS.includes(exp)) {
+ temp += exp.replace('\\', '');
+ continue;
+ }
+
+ if (/\\\*|\\{.*\\}/g.test(exp)) {
+ let rExp = _testRegExEffect(exp, effects);
+ if (rExp) {
+ temp += 'true';
+ } else {
+ temp += 'false';
+ }
+
+ if (_testRegExEffect(exp, added)) hasAdded = true;
+ else if (_testRegExEffect(exp, removed)) hasRemoved = true;
+ continue;
+ } else if (effects.includes(exp)) {
+ temp += 'true';
+ } else {
+ temp += 'false';
+ }
+
+ if (!hasAdded && added.has(exp)) hasAdded = true;
+ if (!hasRemoved && removed.has(exp)) hasRemoved = true;
+ }
+
+ try {
+ let evaluation = eval(temp);
+ if (evaluation) {
+ if (hasAdded || hasRemoved) {
+ added.add(mapping.id);
+ effects.push(mapping.id);
+ } else effects.unshift(mapping.id);
+ } else if (hasRemoved || hasAdded) {
+ removed.add(mapping.id);
+ }
+ return evaluation;
+ } catch (e) {}
+ return false;
+}
+
+function _getTokenHPv11(token) {
+ let attributes;
+
+ if (token.actorLink) {
+ attributes = getProperty(token.actor?.system, TVA_CONFIG.systemHpPath);
+ } else {
+ attributes = mergeObject(
+ getProperty(token.actor?.system, TVA_CONFIG.systemHpPath) || {},
+ getProperty(token.delta?.system) || {},
+ {
+ inplace: false,
+ }
+ );
+ }
+
+ return [attributes?.value, attributes?.max];
+}
+
+export function getTokenHP(token) {
+ if (!isNewerVersion('11', game.version)) return _getTokenHPv11(token);
+
+ let attributes;
+
+ if (token.actorLink) {
+ attributes = getProperty(token.actor.system, TVA_CONFIG.systemHpPath);
+ } else {
+ attributes = mergeObject(
+ getProperty(token.actor.system, TVA_CONFIG.systemHpPath) || {},
+ getProperty(token.actorData?.system) || {},
+ {
+ inplace: false,
+ }
+ );
+ }
+ return [attributes?.value, attributes?.max];
+}
+
+async function _updateImageOnEffectChange(effectName, actor, added = true) {
+ const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, true);
+ for (const token of tokens) {
+ await updateWithEffectMapping(token, {
+ added: added ? [effectName] : [],
+ removed: !added ? [effectName] : [],
+ });
+ }
+}
+
+async function _updateImageOnMultiEffectChange(actor, added = [], removed = []) {
+ if (!actor) return;
+ const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, true);
+ for (const token of tokens) {
+ await updateWithEffectMapping(token, {
+ added: added,
+ removed: removed,
+ });
+ }
+}
+
+async function _deleteCombatant(combatant) {
+ const token = combatant._token || canvas.tokens.get(combatant.tokenId);
+ if (!token || !token.actor) return;
+ await updateWithEffectMapping(token, {
+ removed: ['token-variants-combat'],
+ });
+}
diff --git a/Data/modules/token-variants/scripts/hooks/hooks.js b/Data/modules/token-variants/scripts/hooks/hooks.js
new file mode 100644
index 00000000..627c3d3c
--- /dev/null
+++ b/Data/modules/token-variants/scripts/hooks/hooks.js
@@ -0,0 +1,51 @@
+import { registerEffectIconHooks } from './effectIconHooks.js';
+import { registerArtSelectButtonHooks } from './artSelectButtonHooks.js';
+import { registerOverlayHooks } from './overlayHooks.js';
+import { registerEffectMappingHooks } from './effectMappingHooks.js';
+import { registerHUDHooks } from './hudHooks.js';
+import { registerUserMappingHooks } from './userMappingHooks.js';
+import { registerWildcardHooks } from './wildcardHooks.js';
+import { registerPopRandomizeHooks } from './popUpRandomizeHooks.js';
+import { TVA_CONFIG } from '../settings.js';
+
+export const REGISTERED_HOOKS = {};
+
+export function registerHook(feature_id, name, fn, { once = false } = {}) {
+ if (!(feature_id in REGISTERED_HOOKS)) REGISTERED_HOOKS[feature_id] = {};
+ if (name in REGISTERED_HOOKS[feature_id]) return;
+ if (TVA_CONFIG.debug) console.info(`TVA | Registering Hook`, { feature_id, name, fn, once });
+ const num = Hooks.on(name, fn, { once });
+ REGISTERED_HOOKS[feature_id][name] = num;
+}
+
+export function unregisterHook(feature_id, name) {
+ if (feature_id in REGISTERED_HOOKS && name in REGISTERED_HOOKS[feature_id]) {
+ if (TVA_CONFIG.debug)
+ console.info(`TVA | Un-Registering Hook`, {
+ feature_id,
+ name,
+ id: REGISTERED_HOOKS[feature_id][name],
+ });
+ Hooks.off(name, REGISTERED_HOOKS[feature_id][name]);
+ delete REGISTERED_HOOKS[feature_id][name];
+ }
+}
+
+export function registerAllHooks() {
+ // Hide effect icons
+ registerEffectIconHooks();
+ // Display overlays
+ registerOverlayHooks();
+ // Insert Art Select buttons and contextmenu listeners
+ registerArtSelectButtonHooks();
+ // Effect Mapping related listening for state changes and applying configurations
+ registerEffectMappingHooks();
+ // Display HUD buttons for Tokens and Tiles
+ registerHUDHooks();
+ // Default Wildcard image controls
+ registerWildcardHooks();
+ // User to Image mappings for Tile and Tokens
+ registerUserMappingHooks();
+ // Handle pop-ups and randomization on token/actor create
+ registerPopRandomizeHooks();
+}
diff --git a/Data/modules/token-variants/scripts/hooks/hudHooks.js b/Data/modules/token-variants/scripts/hooks/hudHooks.js
new file mode 100644
index 00000000..5c3be4ac
--- /dev/null
+++ b/Data/modules/token-variants/scripts/hooks/hudHooks.js
@@ -0,0 +1,23 @@
+import { renderTileHUD } from '../../applications/tileHUD.js';
+import { renderTokenHUD } from '../../applications/tokenHUD.js';
+import { FEATURE_CONTROL, TVA_CONFIG } from '../settings.js';
+import { registerHook, unregisterHook } from './hooks.js';
+
+const feature_id = 'HUD';
+
+export function registerHUDHooks() {
+ if (FEATURE_CONTROL[feature_id] && TVA_CONFIG.tilesEnabled) {
+ registerHook(feature_id, 'renderTileHUD', renderTileHUD);
+ } else {
+ unregisterHook(feature_id, 'renderTileHUD');
+ }
+
+ if (
+ FEATURE_CONTROL[feature_id] &&
+ (TVA_CONFIG.permissions.hudFullAccess[game.user.role] || TVA_CONFIG.permissions.hud[game.user.role])
+ ) {
+ registerHook(feature_id, 'renderTokenHUD', renderTokenHUD);
+ } else {
+ unregisterHook(feature_id, 'renderTokenHUD');
+ }
+}
diff --git a/Data/modules/token-variants/scripts/hooks/overlayHooks.js b/Data/modules/token-variants/scripts/hooks/overlayHooks.js
new file mode 100644
index 00000000..5cfe5d1a
--- /dev/null
+++ b/Data/modules/token-variants/scripts/hooks/overlayHooks.js
@@ -0,0 +1,85 @@
+import { FEATURE_CONTROL } from '../settings.js';
+import { TVASprite } from '../sprite/TVASprite.js';
+import { drawOverlays } from '../token/overlay.js';
+import { registerHook, unregisterHook } from './hooks.js';
+
+const feature_id = 'Overlays';
+
+export function registerOverlayHooks() {
+ if (!FEATURE_CONTROL[feature_id]) {
+ ['refreshToken', 'destroyToken', 'updateActor', 'renderCombatTracker', 'updateToken', 'createToken'].forEach((id) =>
+ unregisterHook(feature_id, id)
+ );
+ return;
+ }
+
+ registerHook(feature_id, 'createToken', async function (token) {
+ if (token.object) drawOverlays(token.object);
+ });
+
+ registerHook(feature_id, 'updateToken', async function (token) {
+ if (token.object) drawOverlays(token.object);
+ });
+
+ registerHook(feature_id, 'refreshToken', (token) => {
+ if (token.tva_sprites)
+ for (const child of token.tva_sprites) {
+ if (child instanceof TVASprite) {
+ child.refresh(null, { preview: false, fullRefresh: false });
+ }
+ }
+ });
+
+ registerHook(feature_id, 'destroyToken', (token) => {
+ if (token.tva_sprites)
+ for (const child of token.tva_sprites) {
+ child.parent?.removeChild(child)?.destroy();
+ }
+ });
+
+ registerHook(feature_id, 'updateActor', async function (actor) {
+ if (actor.getActiveTokens)
+ actor.getActiveTokens(true).forEach((token) => {
+ drawOverlays(token);
+ });
+ });
+
+ registerHook(feature_id, 'renderCombatTracker', function () {
+ for (const tkn of canvas.tokens.placeables) {
+ drawOverlays(tkn);
+ }
+ });
+}
+
+const REFRESH_HOOKS = {};
+
+export function registerOverlayRefreshHook(tvaSprite, hookName) {
+ if (!(hookName in REFRESH_HOOKS)) {
+ registerHook('TVASpriteRefresh', hookName, () => {
+ REFRESH_HOOKS[hookName]?.forEach((s) => s.refresh());
+ });
+ REFRESH_HOOKS[hookName] = [tvaSprite];
+ } else if (!REFRESH_HOOKS[hookName].find((s) => s == tvaSprite)) {
+ REFRESH_HOOKS[hookName].push(tvaSprite);
+ }
+}
+
+export function unregisterOverlayRefreshHooks(tvaSprite, hookName = null) {
+ const unregister = function (hook) {
+ if (REFRESH_HOOKS[hook]) {
+ let index = REFRESH_HOOKS[hook].findIndex((s) => s == tvaSprite);
+ if (index > -1) {
+ REFRESH_HOOKS[hook].splice(index, 1);
+ if (!REFRESH_HOOKS[hook].length) {
+ unregisterHook('TVASpriteRefresh', hook);
+ delete REFRESH_HOOKS[hook];
+ }
+ }
+ }
+ };
+
+ if (hookName) unregister(hookName);
+ else {
+ Object.keys(REFRESH_HOOKS).forEach((k) => unregister(k));
+ }
+}
diff --git a/Data/modules/token-variants/scripts/hooks/popUpRandomizeHooks.js b/Data/modules/token-variants/scripts/hooks/popUpRandomizeHooks.js
new file mode 100644
index 00000000..071b2495
--- /dev/null
+++ b/Data/modules/token-variants/scripts/hooks/popUpRandomizeHooks.js
@@ -0,0 +1,272 @@
+import { showArtSelect } from '../../token-variants.mjs';
+import { doRandomSearch, doSyncSearch } from '../search.js';
+import { FEATURE_CONTROL, TVA_CONFIG } from '../settings.js';
+import { keyPressed, nameForgeRandomize, SEARCH_TYPE, updateActorImage, updateTokenImage } from '../utils.js';
+import { registerHook, unregisterHook } from './hooks.js';
+
+const feature_id = 'PopUpAndRandomize';
+
+export function registerPopRandomizeHooks() {
+ if (FEATURE_CONTROL[feature_id]) {
+ registerHook(feature_id, 'createActor', _createActor);
+ registerHook(feature_id, 'createToken', _createToken);
+ } else {
+ ['createActor', 'createToken'].forEach((name) => unregisterHook(feature_id, name));
+ }
+}
+
+async function _createToken(token, options, userId) {
+ if (userId && game.user.id != userId) return;
+
+ // Check if random search is enabled and if so perform it
+ const actorRandSettings = game.actors.get(token.actorId)?.getFlag('token-variants', 'randomizerSettings');
+ const randSettings = mergeObject(TVA_CONFIG.randomizer, actorRandSettings ?? {}, {
+ inplace: false,
+ recursive: false,
+ });
+
+ let vDown = keyPressed('v');
+ const flagTarget = token.actor ? game.actors.get(token.actor.id) : token.document ?? token;
+ const popupFlag = flagTarget.getFlag('token-variants', 'popups');
+
+ if ((vDown && randSettings.tokenCopyPaste) || (!vDown && randSettings.tokenCreate)) {
+ let performRandomSearch = true;
+ if (!actorRandSettings) {
+ if (randSettings.representedActorDisable && token.actor) performRandomSearch = false;
+ if (randSettings.linkedActorDisable && token.actorLink) performRandomSearch = false;
+ if (_disableRandomSearchForType(randSettings, token.actor)) performRandomSearch = false;
+ } else {
+ performRandomSearch = Boolean(actorRandSettings);
+ }
+
+ if (performRandomSearch) {
+ // Randomize Token Name if need be
+ const randomName = await nameForgeRandomize(randSettings);
+ if (randomName) {
+ token.update({ name: randomName });
+ }
+
+ const img = await doRandomSearch(token.name, {
+ searchType: SEARCH_TYPE.TOKEN,
+ actor: token.actor,
+ randomizerOptions: randSettings,
+ });
+ if (img) {
+ await updateTokenImage(img[0], {
+ token: token,
+ actor: token.actor,
+ imgName: img[1],
+ });
+ }
+
+ if (!img) return;
+
+ if (randSettings.diffImages) {
+ let imgPortrait;
+ if (randSettings.syncImages) {
+ imgPortrait = await doSyncSearch(token.name, img[1], {
+ actor: token.actor,
+ searchType: SEARCH_TYPE.PORTRAIT,
+ randomizerOptions: randSettings,
+ });
+ } else {
+ imgPortrait = await doRandomSearch(token.name, {
+ searchType: SEARCH_TYPE.PORTRAIT,
+ actor: token.actor,
+ randomizerOptions: randSettings,
+ });
+ }
+
+ if (imgPortrait) {
+ await updateActorImage(token.actor, imgPortrait[0]);
+ }
+ } else if (randSettings.tokenToPortrait) {
+ await updateActorImage(token.actor, img[0]);
+ }
+ return;
+ }
+ if (popupFlag == null && !randSettings.popupOnDisable) {
+ return;
+ }
+ } else if (randSettings.tokenCreate || randSettings.tokenCopyPaste) {
+ return;
+ }
+
+ // Check if pop-up is enabled and if so open it
+ if (!TVA_CONFIG.permissions.popups[game.user.role]) {
+ return;
+ }
+
+ let dirKeyDown = keyPressed('popupOverride');
+
+ if (vDown && TVA_CONFIG.popup.disableAutoPopupOnTokenCopyPaste) {
+ return;
+ }
+
+ if (!dirKeyDown || (dirKeyDown && vDown)) {
+ if (TVA_CONFIG.popup.disableAutoPopupOnTokenCreate && !vDown) {
+ return;
+ } else if (popupFlag == null && _disablePopupForType(token.actor)) {
+ return;
+ } else if (popupFlag != null && !popupFlag) {
+ return;
+ }
+ }
+
+ showArtSelect(token.name, {
+ callback: async function (imgSrc, imgName) {
+ if (TVA_CONFIG.popup.twoPopups) {
+ await updateActorImage(token.actor, imgSrc);
+ _twoPopupPrompt(token.actor, imgSrc, imgName, token);
+ } else {
+ updateTokenImage(imgSrc, {
+ actor: token.actor,
+ imgName: imgName,
+ token: token,
+ });
+ }
+ },
+ searchType: TVA_CONFIG.popup.twoPopups ? SEARCH_TYPE.PORTRAIT : SEARCH_TYPE.TOKEN,
+ object: token,
+ preventClose: TVA_CONFIG.popup.twoPopups && TVA_CONFIG.popup.twoPopupsNoDialog,
+ });
+}
+
+async function _createActor(actor, options, userId) {
+ if (userId && game.user.id != userId) return;
+
+ // Check if random search is enabled and if so perform it
+ const randSettings = TVA_CONFIG.randomizer;
+ if (randSettings.actorCreate) {
+ let performRandomSearch = true;
+ if (randSettings.linkedActorDisable && actor.prototypeToken.actorLink) performRandomSearch = false;
+ if (_disableRandomSearchForType(randSettings, actor)) performRandomSearch = false;
+
+ if (performRandomSearch) {
+ const img = await doRandomSearch(actor.name, {
+ searchType: SEARCH_TYPE.PORTRAIT,
+ actor: actor,
+ });
+ if (img) {
+ await updateActorImage(actor, img[0]);
+ }
+
+ if (!img) return;
+
+ if (randSettings.diffImages) {
+ let imgToken;
+ if (randSettings.syncImages) {
+ imgToken = await doSyncSearch(actor.name, img[1], { actor: actor });
+ } else {
+ imgToken = await doRandomSearch(actor.name, {
+ searchType: SEARCH_TYPE.TOKEN,
+ actor: actor,
+ });
+ }
+
+ if (imgToken) {
+ await updateTokenImage(imgToken[0], { actor: actor, imgName: imgToken[1] });
+ }
+ }
+ return;
+ }
+ if (!randSettings.popupOnDisable) {
+ return;
+ }
+ }
+
+ // Check if pop-up is enabled and if so open it
+ if (!TVA_CONFIG.permissions.popups[game.user.role]) {
+ return;
+ }
+
+ if (TVA_CONFIG.popup.disableAutoPopupOnActorCreate && !keyPressed('popupOverride')) {
+ return;
+ } else if (_disablePopupForType(actor)) {
+ return;
+ }
+
+ showArtSelect(actor.name, {
+ callback: async function (imgSrc, name) {
+ const actTokens = actor.getActiveTokens();
+ const token = actTokens.length === 1 ? actTokens[0] : null;
+ await updateActorImage(actor, imgSrc);
+ if (TVA_CONFIG.popup.twoPopups) _twoPopupPrompt(actor, imgSrc, name, token);
+ else {
+ updateTokenImage(imgSrc, {
+ actor: actor,
+ imgName: name,
+ token: token,
+ });
+ }
+ },
+ searchType: TVA_CONFIG.popup.twoPopups ? SEARCH_TYPE.PORTRAIT : SEARCH_TYPE.PORTRAIT_AND_TOKEN,
+ object: actor,
+ preventClose: TVA_CONFIG.popup.twoPopups && TVA_CONFIG.popup.twoPopupsNoDialog,
+ });
+}
+
+function _disableRandomSearchForType(randSettings, actor) {
+ if (!actor) return false;
+ return randSettings[`${actor.type}Disable`] ?? false;
+}
+
+function _disablePopupForType(actor) {
+ if (!actor) return false;
+ return TVA_CONFIG.popup[`${actor.type}Disable`] ?? false;
+}
+
+function _twoPopupPrompt(actor, imgSrc, imgName, token) {
+ if (TVA_CONFIG.popup.twoPopups && TVA_CONFIG.popup.twoPopupsNoDialog) {
+ showArtSelect((token ?? actor.prototypeToken).name, {
+ callback: (imgSrc, name) =>
+ updateTokenImage(imgSrc, {
+ actor: actor,
+ imgName: name,
+ token: token,
+ }),
+ searchType: SEARCH_TYPE.TOKEN,
+ object: token ? token : actor,
+ force: true,
+ });
+ } else if (TVA_CONFIG.popup.twoPopups) {
+ let d = new Dialog({
+ title: 'Portrait -> Token',
+ content: `${game.i18n.localize('token-variants.windows.art-select.apply-same-art')}
`,
+ buttons: {
+ one: {
+ icon: ' ',
+ callback: () => {
+ updateTokenImage(imgSrc, {
+ actor: actor,
+ imgName: imgName,
+ token: token,
+ });
+ const artSelects = Object.values(ui.windows).filter((app) => app instanceof ArtSelect);
+ for (const app of artSelects) {
+ app.close();
+ }
+ },
+ },
+ two: {
+ icon: ' ',
+ callback: () => {
+ showArtSelect((token ?? actor.prototypeToken).name, {
+ callback: (imgSrc, name) =>
+ updateTokenImage(imgSrc, {
+ actor: actor,
+ imgName: name,
+ token: token,
+ }),
+ searchType: SEARCH_TYPE.TOKEN,
+ object: token ? token : actor,
+ force: true,
+ });
+ },
+ },
+ },
+ default: 'one',
+ });
+ d.render(true);
+ }
+}
diff --git a/Data/modules/token-variants/scripts/hooks/userMappingHooks.js b/Data/modules/token-variants/scripts/hooks/userMappingHooks.js
new file mode 100644
index 00000000..dd3ee38b
--- /dev/null
+++ b/Data/modules/token-variants/scripts/hooks/userMappingHooks.js
@@ -0,0 +1,57 @@
+import { FEATURE_CONTROL, TVA_CONFIG } from '../settings.js';
+import { registerHook, unregisterHook } from './hooks.js';
+
+const feature_id = 'UserMappings';
+
+export function registerUserMappingHooks() {
+ if (!FEATURE_CONTROL[feature_id]) {
+ ['updateToken', 'updateTile', 'sightRefresh'].forEach((id) => unregisterHook(feature_id, id));
+ return;
+ }
+
+ registerHook(feature_id, 'updateToken', _updateToken);
+ registerHook(feature_id, 'updateTile', _updateTile);
+ registerHook(feature_id, 'sightRefresh', _sightRefresh);
+}
+
+async function _updateToken(token, change) {
+ // Update User Specific Image
+ if (change.flags?.['token-variants']) {
+ if ('userMappings' in change.flags['token-variants'] || '-=userMappings' in change.flags['token-variants']) {
+ const t = canvas.tokens.get(token.id);
+ if (t) {
+ await t.draw();
+ canvas.effects.visibility.restrictVisibility();
+ }
+ }
+ }
+}
+
+async function _updateTile(tile, change) {
+ // Update User Specific Image
+ if (change.flags?.['token-variants']) {
+ if ('userMappings' in change.flags['token-variants'] || '-=userMappings' in change.flags['token-variants']) {
+ const t = canvas.tiles.get(tile.id);
+ if (t) {
+ await t.draw();
+ canvas.effects.visibility.restrictVisibility();
+ }
+ }
+ }
+}
+
+function _sightRefresh() {
+ if (!game.user.isGM) {
+ for (let t of canvas.tokens.placeables) {
+ if (_isInvisible(t)) t.visible = false;
+ }
+ for (let t of canvas.tiles.placeables) {
+ if (_isInvisible(t)) t.visible = false;
+ }
+ }
+}
+
+function _isInvisible(obj) {
+ const img = (obj.document.getFlag('token-variants', 'userMappings') || {})?.[game.userId];
+ return img === TVA_CONFIG.invisibleImage;
+}
diff --git a/Data/modules/token-variants/scripts/hooks/wildcardHooks.js b/Data/modules/token-variants/scripts/hooks/wildcardHooks.js
new file mode 100644
index 00000000..3bc8b951
--- /dev/null
+++ b/Data/modules/token-variants/scripts/hooks/wildcardHooks.js
@@ -0,0 +1,82 @@
+import { insertArtSelectButton } from '../../applications/artSelect.js';
+import { FEATURE_CONTROL, TVA_CONFIG } from '../settings.js';
+import { SEARCH_TYPE, updateTokenImage } from '../utils.js';
+import { registerHook, unregisterHook } from './hooks.js';
+
+const feature_id = 'Wildcards';
+
+export function registerWildcardHooks() {
+ if (!FEATURE_CONTROL[feature_id]) {
+ ['renderTokenConfig', 'preCreateToken'].forEach((name) => unregisterHook(feature_id, name));
+ return;
+ }
+
+ // Insert default random image field
+ registerHook(feature_id, 'renderTokenConfig', _renderTokenConfig);
+
+ // Set Default Wildcard images if needed
+ registerHook(feature_id, 'preCreateToken', _preCreateToken);
+}
+
+async function _renderTokenConfig(config, html) {
+ const checkboxRandomize = html.find('input[name="randomImg"]');
+ if (checkboxRandomize.length && !html.find('.token-variants-proto').length) {
+ const defaultImg =
+ config.actor?.prototypeToken?.flags['token-variants']?.['randomImgDefault'] ||
+ config.actor?.prototypeToken?.flags['token-hud-wildcard']?.['default'] ||
+ '';
+
+ const field = await renderTemplate('/modules/token-variants/templates/protoTokenElement.html', {
+ defaultImg,
+ disableHUDButton: config.object?.getFlag('token-variants', 'disableHUDButton'),
+ });
+ checkboxRandomize.closest('.form-group').after(field);
+
+ const tvaFieldset = html.find('.token-variants-proto');
+
+ tvaFieldset.find('button').click((event) => {
+ event.preventDefault();
+ const input = tvaFieldset.find('input');
+ new FilePicker({ current: input.val(), field: input[0] }).browse(defaultImg);
+ });
+
+ insertArtSelectButton(tvaFieldset, 'flags.token-variants.randomImgDefault', {
+ search: config.object.name,
+ searchType: SEARCH_TYPE.TOKEN,
+ });
+
+ // Hide/Show Default Img Form Group
+ const rdmImgFormGroup = tvaFieldset.find('.imagevideo').closest('.form-group');
+ const showHideGroup = function (checked) {
+ if (checked) {
+ rdmImgFormGroup.show();
+ } else {
+ rdmImgFormGroup.hide();
+ }
+ config.setPosition();
+ };
+ checkboxRandomize.on('click', (event) => showHideGroup(event.target.checked));
+ showHideGroup(checkboxRandomize.is(':checked'));
+ }
+}
+
+function _preCreateToken(tokenDocument, data, options, userId) {
+ if (game.user.id !== userId) return;
+ const update = {};
+ if (tokenDocument.actor?.prototypeToken?.randomImg) {
+ const defaultImg =
+ tokenDocument.actor?.prototypeToken?.flags['token-variants']?.['randomImgDefault'] ||
+ tokenDocument.actor?.prototypeToken?.flags['token-hud-wildcard']?.['default'] ||
+ '';
+ if (defaultImg) update['texture.src'] = defaultImg;
+ }
+
+ if (TVA_CONFIG.imgNameContainsDimensions || TVA_CONFIG.imgNameContainsFADimensions) {
+ updateTokenImage(update['texture.src'] ?? tokenDocument.texture.src, {
+ token: tokenDocument,
+ update,
+ });
+ }
+
+ if (!isEmpty(update)) tokenDocument.updateSource(update);
+}
diff --git a/Data/modules/token-variants/scripts/mappingTemplates.js b/Data/modules/token-variants/scripts/mappingTemplates.js
new file mode 100644
index 00000000..0f2fafc2
--- /dev/null
+++ b/Data/modules/token-variants/scripts/mappingTemplates.js
@@ -0,0 +1,5857 @@
+export const CORE_TEMPLATES = [
+ {
+ name: 'Tint Red when HP is bellow 10%',
+ hint: 'Tint token red when HP falls bellow 10%',
+ mappings: [
+ {
+ id: 'MmLSOlJx',
+ label: 'Tint Red',
+ expression: 'hp<=10%',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {
+ texture: {
+ tint: '#ff0000',
+ },
+ },
+ overlay: false,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ label: 'Tint Red',
+ },
+ group: 'Low HP',
+ i: 0,
+ },
+ ],
+ id: 'Ob9LP35K',
+ },
+ {
+ name: 'Health State Text Overlay',
+ hint: 'Displays text overlay based on percentage health.',
+ mappings: [
+ {
+ id: 'jqaFdwkQ',
+ label: 'Bloodied',
+ expression: 'hp>25% && hp<=50%',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ parent: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkOpacity: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0.51,
+ scaleX: 0.76,
+ scaleY: 0.76,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 1,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Bloodied',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#ff5900',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: null,
+ },
+ },
+ label: 'Bloodied',
+ id: 'jqaFdwkQ',
+ },
+ group: 'Health State Overlay',
+ i: 0,
+ },
+ {
+ id: 'm4GQVz5O',
+ label: 'Critical',
+ expression: 'hp>0 && hp<=25%',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ parent: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkOpacity: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0.51,
+ scaleX: 0.76,
+ scaleY: 0.76,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 1,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Critical',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#ff0000',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: null,
+ },
+ },
+ label: 'Critical',
+ id: 'm4GQVz5O',
+ },
+ group: 'Health State Overlay',
+ i: 1,
+ },
+ {
+ id: 'H1wrS5N1',
+ label: 'Healthy',
+ expression: 'hp>75%',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ parent: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkOpacity: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0.51,
+ scaleX: 0.76,
+ scaleY: 0.76,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 1,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Healthy',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#2bff00',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: null,
+ },
+ },
+ label: 'Healthy',
+ id: 'H1wrS5N1',
+ },
+ group: 'Health State Overlay',
+ i: 2,
+ },
+ {
+ id: 'IojJZS7v',
+ label: 'Wounded',
+ expression: 'hp>50% && hp<=75%',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ parent: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkOpacity: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0.51,
+ scaleX: 0.76,
+ scaleY: 0.76,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 1,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Wounded',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#ffbb00',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: null,
+ },
+ },
+ label: 'Wounded',
+ id: 'IojJZS7v',
+ },
+ group: 'Health State Overlay',
+ i: 3,
+ },
+ ],
+ id: 'JNClkgGU',
+ },
+ {
+ name: 'Health State Text Overlay - Passed Check',
+ hint: 'Same as Health State Text Overlay except also requiring the Token actor to have Reveal Health active effect applied to it.',
+ mappings: [
+ {
+ id: 'k0XbFE7a',
+ label: 'Bloodied',
+ expression: 'Reveal Health && hp>25% && hp<=50%',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ parent: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkOpacity: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0.51,
+ scaleX: 0.76,
+ scaleY: 0.76,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 1,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Bloodied',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#ff5900',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: null,
+ },
+ },
+ label: 'Bloodied',
+ id: 'k0XbFE7a',
+ },
+ group: 'Health State Overlay - Passed Check',
+ i: 0,
+ },
+ {
+ id: 'a1VxhnWK',
+ label: 'Critical',
+ expression: 'Reveal Health && hp>0 && hp<=25%',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ parent: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkOpacity: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0.51,
+ scaleX: 0.76,
+ scaleY: 0.76,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 1,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Critical',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#ff0000',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: null,
+ },
+ },
+ label: 'Critical',
+ id: 'a1VxhnWK',
+ },
+ group: 'Health State Overlay - Passed Check',
+ i: 1,
+ },
+ {
+ id: 'DNuBTXe8',
+ label: 'Healthy',
+ expression: 'Reveal Health && hp>75%',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ parent: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkOpacity: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0.51,
+ scaleX: 0.76,
+ scaleY: 0.76,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 1,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Healthy',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#2bff00',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: null,
+ },
+ },
+ label: 'Healthy',
+ id: 'DNuBTXe8',
+ },
+ group: 'Health State Overlay - Passed Check',
+ i: 2,
+ },
+ {
+ id: 'ROPjrvLu',
+ label: 'Wounded',
+ expression: 'Reveal Health && hp>50% && hp<=75%',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ parent: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkOpacity: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0.51,
+ scaleX: 0.76,
+ scaleY: 0.76,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 1,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Wounded',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#ffbb00',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: null,
+ },
+ },
+ label: 'Wounded',
+ id: 'ROPjrvLu',
+ },
+ group: 'Health State Overlay - Passed Check',
+ i: 3,
+ },
+ ],
+ id: '0ZJQiOdD',
+ },
+ {
+ name: 'Fancy Nameplate',
+ hint: 'Displays a curved red nameplate underneath the token.',
+ mappings: [
+ {
+ id: 'DTbwvQiG',
+ label: 'Token Nameplate',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ parent: '',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkOpacity: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: -0.51,
+ scaleX: 0.68,
+ scaleY: 0.68,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '{{name}}',
+ fontFamily: 'Modesto Condensed',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#ff0000',
+ dropShadow: 'true',
+ strokeThickness: 3,
+ stroke: '#111111',
+ curve: {
+ radius: 450,
+ invert: true,
+ },
+ },
+ shapes: [
+ {
+ line: {
+ width: 1,
+ color: '#111111',
+ alpha: 1,
+ },
+ fill: {
+ color: '#111111',
+ alpha: 1,
+ },
+ },
+ ],
+ label: 'Token Nameplate',
+ },
+ group: 'Nameplate',
+ i: 0,
+ },
+ ],
+ id: 'Ik1uNcWU',
+ },
+ {
+ name: 'Info Box #1',
+ hint: 'Displays information about the token/actor when hovering over or controlling them. This box will adjust to canvas zoom.',
+ mappings: [
+ {
+ id: 'W8BPK9hv',
+ label: 'Box Background',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'W8BPK9hv',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: true,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.55,
+ offsetY: 0,
+ scaleX: 0.73,
+ scaleY: 0.73,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'DropShadowFilter',
+ filterOptions: {
+ rotation: 45,
+ distance: 16.9,
+ color: '#000000',
+ alpha: 0.52,
+ shadowOnly: false,
+ blur: 2,
+ quality: 3,
+ },
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: true,
+ limitOnHighlight: false,
+ limitOnControl: true,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'rectangle',
+ x: '0',
+ y: '0',
+ width: '450',
+ height: '200',
+ radius: '0',
+ },
+ label: '',
+ line: {
+ width: 2,
+ color: '#ffffff',
+ alpha: 1,
+ },
+ fill: {
+ color: '#508fe2',
+ color2: '',
+ prc: '',
+ alpha: 0.55,
+ },
+ },
+ ],
+ effect: '',
+ label: 'Box Background',
+ },
+ group: 'Info Box',
+ i: 0,
+ },
+ {
+ id: 'bkoP4Qpo',
+ label: 'Legendary Actions',
+ expression: 'actor.system.resources.legact.max>0',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ id: 'bkoP4Qpo',
+ parentID: 'W8BPK9hv',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.47,
+ offsetY: -0.02,
+ scaleX: 0.5,
+ scaleY: 0.5,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Legendary: Action {{actor.system.resources.legact.value}}/{{actor.system.resources.legact.max}} Resistance {{actor.system.resources.legres.value}}/{{actor.system.resources.legres.max}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Legendary Actions',
+ },
+ group: 'Info Box',
+ i: 1,
+ },
+ {
+ id: 'OvcWUW13',
+ label: 'Mods',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'OvcWUW13',
+ parentID: 'W8BPK9hv',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: -0.38,
+ scaleX: 0.57,
+ scaleY: 0.57,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'STR {{actor.system.abilities.str.mod}} DEX {{actor.system.abilities.dex.mod}} CON {{actor.system.abilities.con.mod}} INT {{actor.system.abilities.int.mod}} WIS {{actor.system.abilities.wis.mod}} CHA {{actor.system.abilities.cha.mod}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Mods',
+ },
+ group: 'Info Box',
+ i: 2,
+ },
+ {
+ id: 'jybTYLTB',
+ label: 'Token Name',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 51,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'jybTYLTB',
+ parentID: 'W8BPK9hv',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.47,
+ offsetY: 0.35,
+ scaleX: 0.77,
+ scaleY: 0.77,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Name: {{name}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Token Name',
+ },
+ group: 'Info Box',
+ i: 3,
+ },
+ {
+ id: 'bGbHPbw6',
+ label: 'HP',
+ expression: 'hp>40%',
+ imgName: '',
+ imgSrc: '',
+ priority: 52,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ id: 'bGbHPbw6',
+ parentID: 'W8BPK9hv',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.47,
+ offsetY: 0.23,
+ scaleX: 0.82,
+ scaleY: 0.82,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'HP: {{actor.system.attributes.hp.value}}/{{actor.system.attributes.hp.max}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#6b6b6b',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'HP',
+ },
+ group: 'Info Box',
+ i: 4,
+ },
+ {
+ id: 'S9gXdyGY',
+ label: 'Low HP',
+ expression: 'hp<=40%',
+ imgName: '',
+ imgSrc: '',
+ priority: 52,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ id: 'S9gXdyGY',
+ parentID: 'W8BPK9hv',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.47,
+ offsetY: 0.23,
+ scaleX: 0.82,
+ scaleY: 0.82,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'HP: {{actor.system.attributes.hp.value}}/{{actor.system.attributes.hp.max}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#ff0000',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#6b6b6b',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Low HP',
+ },
+ group: 'Info Box',
+ i: 5,
+ },
+ {
+ id: 'k9Ws74Hc',
+ label: 'Actor AC',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 53,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'k9Ws74Hc',
+ parentID: 'W8BPK9hv',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.25,
+ offsetY: 0.46,
+ scaleX: 1,
+ scaleY: 1,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'AC: {{actor.system.attributes.ac.value}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Actor AC',
+ },
+ group: 'Info Box',
+ i: 6,
+ },
+ {
+ id: 'eIxjLZmy',
+ label: 'Movement Label',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 54,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'eIxjLZmy',
+ parentID: 'W8BPK9hv',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.25,
+ offsetY: 0.2,
+ scaleX: 0.61,
+ scaleY: 0.61,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Movement',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Movement Label',
+ },
+ group: 'Info Box',
+ i: 7,
+ },
+ {
+ id: 'k5xYpZAZ',
+ label: 'Movement Walk',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 55,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'k5xYpZAZ',
+ parentID: 'W8BPK9hv',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.36,
+ offsetY: -0.03,
+ scaleX: 1,
+ scaleY: 1,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '{{actor.system.attributes.movement.walk}}ft',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Movement Walk',
+ },
+ group: 'Info Box',
+ i: 8,
+ },
+ {
+ id: 'dHHZRQXG',
+ label: 'Movement Fly',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 56,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'dHHZRQXG',
+ parentID: 'W8BPK9hv',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.36,
+ offsetY: -0.16,
+ scaleX: 0.33,
+ scaleY: 0.33,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Fly {{actor.system.attributes.movement.fly}}, Swim {{actor.system.attributes.movement.swim}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Movement Fly',
+ },
+ group: 'Info Box',
+ i: 9,
+ },
+ ],
+ id: 'wuMcLy3T',
+ },
+ {
+ name: 'Info Box #2',
+ hint: 'Displays information about the token/actor when hovering over or controlling them. This box will adjust to canvas zoom.',
+ mappings: [
+ {
+ id: 'f0pV6Pnl',
+ label: 'Box Background',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'f0pV6Pnl',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: true,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.55,
+ offsetY: 0,
+ scaleX: 1,
+ scaleY: 1,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: true,
+ limitOnHighlight: false,
+ limitOnControl: true,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'rectangle',
+ x: '0',
+ y: '0',
+ width: '200',
+ height: '300',
+ radius: '0',
+ },
+ label: '',
+ line: {
+ width: 2,
+ color: '#ffffff',
+ alpha: 1,
+ },
+ fill: {
+ color: '#2e5a94',
+ color2: '',
+ prc: '',
+ alpha: 0.9,
+ },
+ },
+ ],
+ effect: '',
+ label: 'Box Background',
+ },
+ group: 'Info Box #2',
+ i: 0,
+ },
+ {
+ id: 'n2Adi1fi',
+ label: 'HP',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 51,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'n2Adi1fi',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.45,
+ offsetY: 0.42,
+ scaleX: 0.84,
+ scaleY: 0.84,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'HP: {{actor.system.attributes.hp.value}}/{{actor.system.attributes.hp.max}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'HP',
+ },
+ group: 'Info Box #2',
+ i: 1,
+ },
+ {
+ id: 'hCKVzw3Z',
+ label: ' AC',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 52,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'hCKVzw3Z',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.45,
+ offsetY: 0.31,
+ scaleX: 0.84,
+ scaleY: 0.84,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'AC: {{actor.system.attributes.ac.value}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: ' AC',
+ },
+ group: 'Info Box #2',
+ i: 2,
+ },
+ {
+ id: '2nYmUTwu',
+ label: ' Speed',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 53,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: '2nYmUTwu',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.45,
+ offsetY: 0.2,
+ scaleX: 0.84,
+ scaleY: 0.84,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Speed: {{actor.system.attributes.movement.walk}}ft',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: ' Speed',
+ },
+ group: 'Info Box #2',
+ i: 3,
+ },
+ {
+ id: 's1NtDiUV',
+ label: 'Perception',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 54,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 's1NtDiUV',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0.15,
+ scaleX: 0.84,
+ scaleY: 0.84,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Perception',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Perception',
+ },
+ group: 'Info Box #2',
+ i: 4,
+ },
+ {
+ id: 'jYeRIoG2',
+ label: 'Passive Perception',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 55,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'jYeRIoG2',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.47,
+ offsetY: 0,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Passive: {{actor.system.skills.prc.passive}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Passive Perception',
+ },
+ group: 'Info Box #2',
+ i: 5,
+ },
+ {
+ id: 'KYMdkTVI',
+ label: 'Active Perception',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 56,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'KYMdkTVI',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.48,
+ offsetY: 0,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 1,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Active: {{actor.system.skills.prc.total}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Active Perception',
+ },
+ group: 'Info Box #2',
+ i: 6,
+ },
+ {
+ id: 'Ewbg54II',
+ label: 'CHA',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'Ewbg54II',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.31,
+ offsetY: -0.3,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'CHA\\n{{actor.system.abilities.cha.mod}} {{actor.system.abilities.cha.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'CHA',
+ },
+ group: 'Info Box #2',
+ i: 7,
+ },
+ {
+ id: '2le1Nagp',
+ label: 'CON',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: '2le1Nagp',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.31,
+ offsetY: -0.11,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'CON\\n{{actor.system.abilities.con.mod}} {{actor.system.abilities.con.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'CON',
+ },
+ group: 'Info Box #2',
+ i: 8,
+ },
+ {
+ id: 'ahKmjzLj',
+ label: 'DEX',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'ahKmjzLj',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: -0.11,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'DEX\\n{{actor.system.abilities.dex.mod}} {{actor.system.abilities.dex.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'DEX',
+ },
+ group: 'Info Box #2',
+ i: 9,
+ },
+ {
+ id: 'gQzyq0zm',
+ label: 'INT',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'gQzyq0zm',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.32,
+ offsetY: -0.3,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'INT\\n{{actor.system.abilities.int.mod}} {{actor.system.abilities.int.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'INT',
+ },
+ group: 'Info Box #2',
+ i: 10,
+ },
+ {
+ id: 'hYGg1oAt',
+ label: 'STR',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'hYGg1oAt',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.32,
+ offsetY: -0.11,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'STR\\n{{actor.system.abilities.str.mod}} {{actor.system.abilities.str.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'STR',
+ },
+ group: 'Info Box #2',
+ i: 11,
+ },
+ {
+ id: 'uQ5zS3K6',
+ label: 'WIS',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'uQ5zS3K6',
+ parentID: 'f0pV6Pnl',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: -0.3,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'WIS\\n{{actor.system.abilities.wis.mod}} {{actor.system.abilities.wis.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'WIS',
+ },
+ group: 'Info Box #2',
+ i: 12,
+ },
+ ],
+ id: 'Wtq9HDsX',
+ },
+ {
+ name: 'Info Box #3',
+ hint: 'Displays information about the token/actor when hovering over or controlling them. This box will adjust to canvas zoom.',
+ mappings: [
+ {
+ id: 'Gt11vjXV',
+ label: 'Box Background',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'Gt11vjXV',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: true,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.55,
+ offsetY: 0,
+ scaleX: 1,
+ scaleY: 1,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: true,
+ limitOnHighlight: false,
+ limitOnControl: true,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'rectangle',
+ x: '0',
+ y: '0',
+ width: '240',
+ height: '300',
+ radius: '0',
+ },
+ label: '',
+ line: {
+ width: 2,
+ color: '#ffffff',
+ alpha: 1,
+ },
+ fill: {
+ color: '#2e5a94',
+ color2: '',
+ prc: '',
+ alpha: 0.9,
+ },
+ },
+ ],
+ effect: '',
+ label: 'Box Background',
+ },
+ group: 'Info Box #3',
+ i: 0,
+ },
+ {
+ id: 'o4XWzdDM',
+ label: 'HP',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 51,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'o4XWzdDM',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.44,
+ offsetY: 0.42,
+ scaleX: 0.84,
+ scaleY: 0.84,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: ' {{actor.system.attributes.hp.value}}/{{actor.system.attributes.hp.max}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'HP',
+ },
+ group: 'Info Box #3',
+ i: 1,
+ },
+ {
+ id: 'eAv2dSV6',
+ label: 'AC',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 52,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'eAv2dSV6',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.43,
+ offsetY: 0.28,
+ scaleX: 0.84,
+ scaleY: 0.84,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: ' {{actor.system.attributes.ac.value}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'AC',
+ },
+ group: 'Info Box #3',
+ i: 2,
+ },
+ {
+ id: 'SCfkWTni',
+ label: 'Fly Speed',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 53,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'SCfkWTni',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.04,
+ offsetY: 0.13,
+ scaleX: 0.84,
+ scaleY: 0.84,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: ' {{actor.system.attributes.movement.fly}}ft',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Fly Speed',
+ },
+ group: 'Info Box #3',
+ i: 3,
+ },
+ {
+ id: 'e5LPzVta',
+ label: 'Walk Speed',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 53,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'e5LPzVta',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.45,
+ offsetY: 0.13,
+ scaleX: 0.84,
+ scaleY: 0.84,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: ' {{actor.system.attributes.movement.walk}}ft',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Walk Speed',
+ },
+ group: 'Info Box #3',
+ i: 4,
+ },
+ {
+ id: 'XlopagaT',
+ label: 'Passive Perception',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 55,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'XlopagaT',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.45,
+ offsetY: -0.02,
+ scaleX: 0.83,
+ scaleY: 0.83,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: ' {{actor.system.skills.prc.passive}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Passive Perception',
+ },
+ group: 'Info Box #3',
+ i: 5,
+ },
+ {
+ id: 'CeXgVxA0',
+ label: 'Active Perception',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 56,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'CeXgVxA0',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.03,
+ offsetY: -0.02,
+ scaleX: 0.83,
+ scaleY: 0.83,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: ' {{actor.system.skills.prc.total}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Active Perception',
+ },
+ group: 'Info Box #3',
+ i: 6,
+ },
+ {
+ id: '0WWf1iGM',
+ label: 'CHA',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: '0WWf1iGM',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.31,
+ offsetY: -0.3,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'CHA\\n{{actor.system.abilities.cha.mod}} {{actor.system.abilities.cha.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'CHA',
+ },
+ group: 'Info Box #3',
+ i: 7,
+ },
+ {
+ id: 'AU8tTXat',
+ label: 'CON',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'AU8tTXat',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.31,
+ offsetY: -0.11,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'CON\\n{{actor.system.abilities.con.mod}} {{actor.system.abilities.con.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'CON',
+ },
+ group: 'Info Box #3',
+ i: 8,
+ },
+ {
+ id: 'JZKNmgvY',
+ label: 'DEX',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'JZKNmgvY',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: -0.11,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'DEX\\n{{actor.system.abilities.dex.mod}} {{actor.system.abilities.dex.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'DEX',
+ },
+ group: 'Info Box #3',
+ i: 9,
+ },
+ {
+ id: 'AUGurJtx',
+ label: 'INT',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'AUGurJtx',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.32,
+ offsetY: -0.3,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'INT\\n{{actor.system.abilities.int.mod}} {{actor.system.abilities.int.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'INT',
+ },
+ group: 'Info Box #3',
+ i: 10,
+ },
+ {
+ id: 'z0PMTFxo',
+ label: 'STR',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'z0PMTFxo',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0.32,
+ offsetY: -0.11,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'STR\\n{{actor.system.abilities.str.mod}} {{actor.system.abilities.str.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'STR',
+ },
+ group: 'Info Box #3',
+ i: 11,
+ },
+ {
+ id: 'YXUiGPBv',
+ label: 'WIS',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 57,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'YXUiGPBv',
+ parentID: 'Gt11vjXV',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: true,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: -0.3,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'WIS\\n{{actor.system.abilities.wis.mod}} {{actor.system.abilities.wis.save}}',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'WIS',
+ },
+ group: 'Info Box #3',
+ i: 12,
+ },
+ ],
+ id: 'vQJ3coCJ',
+ },
+ {
+ name: 'Facing Direction',
+ hint: 'Displays an arrow in the top-right corner of the token showing the direction it is facing.',
+ mappings: [
+ {
+ id: '9UEOkJ1J',
+ label: 'Arrow',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: '9UEOkJ1J',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: true,
+ animation: {
+ relative: true,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.5,
+ offsetY: 0.5,
+ scaleX: 0.51,
+ scaleY: 0.51,
+ angle: 90,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '➢',
+ fontFamily: 'Signika',
+ fontSize: 63,
+ letterSpacing: 0,
+ fill: '#ff0000',
+ dropShadow: null,
+ strokeThickness: 3,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Arrow',
+ },
+ group: 'Facing Direction',
+ i: 0,
+ },
+ ],
+ id: 'Z8CTgmOg',
+ },
+ {
+ name: 'Combat Markers',
+ hint: 'Displays rotating markers for tokens in combat.',
+ mappings: [
+ {
+ id: '9R3glzOK',
+ label: 'Your Turn is Next!',
+ expression: 'combat-turn-next',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ label: 'Your Turn is Next!',
+ parent: '',
+ underlay: true,
+ bottom: true,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: true,
+ duration: 30000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: false,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0,
+ scaleX: 1.6,
+ scaleY: 1.6,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: 'Next Turn = = = = = = Next turn = = = = = ',
+ fontFamily: 'Signika',
+ fontSize: 41,
+ letterSpacing: 0,
+ fill: '#e6a800',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 125,
+ invert: false,
+ },
+ },
+ effect: '',
+ },
+ group: 'Combat',
+ i: 0,
+ },
+ {
+ id: 'qoWG5AD0',
+ label: 'Your Turn!',
+ expression: 'combat-turn',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ label: 'Your Turn!',
+ parent: '',
+ underlay: true,
+ bottom: true,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: true,
+ duration: 30000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: true,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: false,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0,
+ scaleX: 1.6,
+ scaleY: 1.6,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '= Your Turn = = = = = = Your turn = = = = = ',
+ fontFamily: 'Signika',
+ fontSize: 41,
+ letterSpacing: 0,
+ fill: '#00ace6',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 125,
+ invert: false,
+ },
+ },
+ effect: '',
+ },
+ group: 'Combat',
+ i: 1,
+ },
+ ],
+ id: 'YpoX5zxO',
+ },
+ {
+ name: 'Disposition Markers',
+ hint: 'Displays circles underneath tokens coloured based on their disposition.',
+ mappings: [
+ {
+ id: 'TzP6MBC1',
+ label: 'Friendly',
+ expression: 'disposition=1',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ id: 'TzP6MBC1',
+ parentID: '',
+ underlay: true,
+ bottom: true,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '#89fb79',
+ offsetX: 0,
+ offsetY: 0,
+ scaleX: 0.24,
+ scaleY: 0.24,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'ellipse',
+ x: '0',
+ y: '0',
+ width: '{{object.w}} * 2.3',
+ height: '{{object.h}} * 2.3',
+ },
+ label: '',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#ffffff',
+ color2: '',
+ prc: '',
+ alpha: 0.75,
+ },
+ },
+ {
+ shape: {
+ type: 'ellipse',
+ x: '0',
+ y: '0',
+ width: '{{object.w}} * 2.3 - {{object.w}} * 0.3',
+ height: '{{object.h}} * 2.3 - {{object.h}} * 0.3',
+ },
+ label: '',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#b8b8b8',
+ color2: '',
+ prc: '',
+ alpha: 0.7,
+ },
+ },
+ ],
+ effect: '',
+ label: 'Friendly',
+ },
+ group: 'Disposition Markers',
+ i: 0,
+ },
+ {
+ id: 'KvZxcxN1',
+ label: 'Hostile',
+ expression: 'disposition=-1',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ id: 'KvZxcxN1',
+ parentID: '',
+ underlay: true,
+ bottom: true,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '#ff3838',
+ offsetX: 0,
+ offsetY: 0,
+ scaleX: 0.24,
+ scaleY: 0.24,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'ellipse',
+ x: '0',
+ y: '0',
+ width: '{{object.w}} * 2.3',
+ height: '{{object.h}} * 2.3',
+ },
+ label: '',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#ffffff',
+ color2: '',
+ prc: '',
+ alpha: 0.75,
+ },
+ },
+ {
+ shape: {
+ type: 'ellipse',
+ x: '0',
+ y: '0',
+ width: '{{object.w}} * 2.3 - {{object.w}} * 0.3',
+ height: '{{object.h}} * 2.3 - {{object.h}} * 0.3',
+ },
+ label: '',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#b8b8b8',
+ color2: '',
+ prc: '',
+ alpha: 0.7,
+ },
+ },
+ ],
+ effect: '',
+ label: 'Hostile',
+ },
+ group: 'Disposition Markers',
+ i: 1,
+ },
+ {
+ id: 'R5ztWAji',
+ label: 'Neutral',
+ expression: 'disposition=0',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ id: 'R5ztWAji',
+ parentID: '',
+ underlay: true,
+ bottom: true,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0,
+ scaleX: 0.24,
+ scaleY: 0.24,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'ellipse',
+ x: '0',
+ y: '0',
+ width: '{{object.w}} * 2.3',
+ height: '{{object.h}} * 2.3',
+ },
+ label: '',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#ffffff',
+ color2: '',
+ prc: '',
+ alpha: 0.75,
+ },
+ },
+ {
+ shape: {
+ type: 'ellipse',
+ x: '0',
+ y: '0',
+ width: '{{object.w}} * 2.3 - {{object.w}} * 0.3',
+ height: '{{object.h}} * 2.3 - {{object.h}} * 0.3',
+ },
+ label: '',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#b8b8b8',
+ color2: '',
+ prc: '',
+ alpha: 0.7,
+ },
+ },
+ ],
+ effect: '',
+ label: 'Neutral',
+ },
+ group: 'Disposition Markers',
+ i: 2,
+ },
+ {
+ id: 'd6AWv55H',
+ label: 'Secret',
+ expression: 'disposition=-2',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: {
+ id: 'd6AWv55H',
+ parentID: '',
+ underlay: true,
+ bottom: true,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: 0,
+ offsetY: 0,
+ scaleX: 0.24,
+ scaleY: 0.24,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ fontFamily: 'Signika',
+ fontSize: 36,
+ letterSpacing: 0,
+ fill: '#FFFFFF',
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'ellipse',
+ x: '0',
+ y: '0',
+ width: '{{object.w}} * 2.3',
+ height: '{{object.h}} * 2.3',
+ },
+ label: '',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#ffffff',
+ color2: '',
+ prc: '',
+ alpha: 0.75,
+ },
+ },
+ {
+ shape: {
+ type: 'ellipse',
+ x: '0',
+ y: '0',
+ width: '{{object.w}} * 2.3 - {{object.w}} * 0.3',
+ height: '{{object.h}} * 2.3 - {{object.h}} * 0.3',
+ },
+ label: '',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#b8b8b8',
+ color2: '',
+ prc: '',
+ alpha: 0.7,
+ },
+ },
+ ],
+ effect: '',
+ label: 'Secret',
+ },
+ group: 'Disposition Markers',
+ i: 3,
+ },
+ ],
+ id: 'ZKP1htdH',
+ },
+ {
+ name: 'Health Bar',
+ hint: 'A recreation of the standard health bar using Overlays.',
+ mappings: [
+ {
+ id: 'dgIBKbcU',
+ label: 'Health Bar',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'dgIBKbcU',
+ parentID: '',
+ underlay: true,
+ bottom: true,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ repeating: false,
+ alpha: 1,
+ tint: '',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ offsetX: 0,
+ offsetY: -0.5,
+ scaleX: 1,
+ scaleY: 1,
+ angle: 0,
+ anchor: {
+ x: 0.5,
+ y: 1,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ repeating: false,
+ fontFamily: 'Signika',
+ fill: '#FFFFFF',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ fontSize: 36,
+ align: 'left',
+ letterSpacing: 0,
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'rectangle',
+ x: '0',
+ y: '0',
+ width: '{{object.w}}',
+ height: '{{object.h}} * @height',
+ radius: '3',
+ },
+ label: '',
+ line: {
+ width: 1,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#5c5c5c',
+ alpha: 0.65,
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'rectangle',
+ x: '0',
+ y: '0',
+ width: '{{object.w}} * ( {{hp}} / {{hpMax}} )',
+ height: '{{object.h}} * @height',
+ radius: '3',
+ },
+ label: '',
+ line: {
+ width: 1,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#ff0000',
+ alpha: 1,
+ interpolateColor: {
+ color2: '#74da0e',
+ prc: '{{hp}} / {{hpMax}}',
+ },
+ },
+ repeating: false,
+ },
+ ],
+ variables: [
+ {
+ name: 'height',
+ value: '0.079',
+ },
+ ],
+ effect: '',
+ label: 'Health Bar',
+ },
+ group: 'Health Bar',
+ i: 0,
+ },
+ ],
+ id: 'GDyvkEB4',
+ },
+ {
+ name: 'Health Ring',
+ hint: 'A ring shaped health bar.',
+ mappings: [
+ {
+ id: 'erOTHzIc',
+ label: 'Health Ring',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: 'erOTHzIc',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ repeating: false,
+ alpha: 1,
+ tint: '',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ offsetX: 0,
+ offsetY: 0,
+ scaleX: 1.02,
+ scaleY: 1.02,
+ angle: -90,
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ repeating: false,
+ fontFamily: 'Signika',
+ fill: '#ffffff',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ fontSize: 36,
+ align: 'left',
+ letterSpacing: 0,
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '{{object.w}} / @ringScale * @innerRadius',
+ outerRadius: '{{object.w}} / @ringScale * @outerRadius',
+ startAngle: '0',
+ endAngle: '360',
+ },
+ label: 'Background',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#b0b0b0',
+ alpha: 0.6,
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '{{object.w}} / @ringScale * @innerRadius',
+ outerRadius: '{{object.w}} / @ringScale * @outerRadius',
+ startAngle: '0',
+ endAngle: '{{hp}} / {{hpMax}} * 360',
+ },
+ label: 'Health Tracker',
+ line: {
+ width: 0,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#ff0000',
+ alpha: 1,
+ interpolateColor: {
+ color2: '#74da0e',
+ prc: '{{hp}} / {{hpMax}}',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '{{object.w}} / @ringScale * @innerRadius',
+ outerRadius: '{{object.w}} / @ringScale * @outerRadius',
+ startAngle: '0',
+ endAngle: '360',
+ },
+ label: 'Outline',
+ line: {
+ width: 1,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#b0b0b0',
+ alpha: 0,
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ ],
+ variables: [
+ {
+ name: 'ringScale',
+ value: '1.8',
+ },
+ {
+ name: 'innerRadius',
+ value: '0.9',
+ },
+ {
+ name: 'outerRadius',
+ value: '1.0',
+ },
+ ],
+ effect: '',
+ label: 'Health Ring',
+ },
+ group: 'Health Ring',
+ i: 0,
+ },
+ ],
+ id: 'yITi94hC',
+ },
+ {
+ name: 'Health Hearts',
+ hint: 'Displays up to 10 hearts to the right of the token based on their current health. Each heart is representative of 10% of the health.',
+ mappings: [
+ {
+ id: '4uCpbtHY',
+ label: 'Hearts',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: '4uCpbtHY',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: true,
+ linkDimensionsY: true,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ alpha: 1,
+ tint: '',
+ offsetX: -0.53,
+ offsetY: 0.5,
+ scaleX: 0.5,
+ scaleY: 0.5,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: ' ',
+ repeating: true,
+ repeat: {
+ value: '{{hp}}',
+ increment: '10',
+ isPercentage: true,
+ maxValue: '{{hpMax}}',
+ perRow: '2',
+ maxRows: '',
+ },
+ fontFamily: 'Signika',
+ fill: '#ff0000',
+ fontSize: 36,
+ align: 'left',
+ letterSpacing: 0,
+ dropShadow: null,
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ effect: '',
+ label: 'Hearts',
+ },
+ group: 'Health Hearts',
+ i: 0,
+ },
+ ],
+ id: 'T2DrD0Em',
+ },
+ {
+ name: 'Health Circles',
+ hint: 'Displays up to 10 circles to the right of the token based on their current health. Each circle is representative of 10% of the health.',
+ mappings: [
+ {
+ id: '0vETg18v',
+ label: 'Health Circles',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: '0vETg18v',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: true,
+ linkDimensionsY: true,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ repeating: false,
+ alpha: 1,
+ tint: '',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ offsetX: -0.52,
+ offsetY: 0.6,
+ scaleX: 0.46,
+ scaleY: 0.46,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ repeating: false,
+ fontFamily: 'Signika',
+ fill: '#ffffff',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ fontSize: 36,
+ align: 'left',
+ letterSpacing: 0,
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'ellipse',
+ x: '25',
+ y: '25',
+ width: '25',
+ height: '25',
+ },
+ label: '',
+ line: {
+ width: 1,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#ff0000',
+ alpha: 1,
+ interpolateColor: {
+ color2: '#74da0e',
+ prc: '{{hp}} / {{hpMax}}',
+ },
+ },
+ repeating: true,
+ repeat: {
+ value: '{{hp}}',
+ increment: '10',
+ isPercentage: true,
+ maxValue: '{{hpMax}}',
+ perRow: '2',
+ maxRows: '',
+ paddingX: '3',
+ paddingY: '3',
+ },
+ },
+ ],
+ effect: '',
+ label: 'Health Circles',
+ },
+ group: 'Health Circles',
+ i: 0,
+ },
+ ],
+ id: 'kJ0Fi54w',
+ },
+ {
+ name: 'Health Squares',
+ hint: 'Displays up to 10 squares to the right of the token based on their current health. Each square is representative of 10% of the health.',
+ mappings: [
+ {
+ id: '0vETg18v',
+ label: 'Health Squares',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: '0vETg18v',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: false,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: true,
+ linkDimensionsY: true,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ repeating: false,
+ alpha: 1,
+ tint: '',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ offsetX: -0.52,
+ offsetY: 0.6,
+ scaleX: 0.46,
+ scaleY: 0.46,
+ angle: 0,
+ anchor: {
+ x: 0,
+ y: 0,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedUsers: [],
+ limitOnHover: false,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ repeating: false,
+ fontFamily: 'Signika',
+ fill: '#ffffff',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ fontSize: 36,
+ align: 'left',
+ letterSpacing: 0,
+ dropShadow: 'true',
+ strokeThickness: 1,
+ stroke: '#111111',
+ curve: {
+ radius: 0,
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'rectangle',
+ x: '0',
+ y: '0',
+ width: '50',
+ height: '50',
+ radius: '0',
+ },
+ label: '',
+ line: {
+ width: 1,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: {
+ color: '#ff0000',
+ alpha: 1,
+ interpolateColor: {
+ color2: '#74da0e',
+ prc: '{{hp}} / {{hpMax}}',
+ },
+ },
+ repeating: true,
+ repeat: {
+ value: '{{hp}}',
+ increment: '10',
+ isPercentage: true,
+ maxValue: '{{hpMax}}',
+ perRow: '2',
+ maxRows: '',
+ paddingX: '3',
+ paddingY: '3',
+ },
+ },
+ ],
+ effect: '',
+ label: 'Health Squares',
+ },
+ group: 'Health Squares',
+ i: 0,
+ },
+ ],
+ id: 'zzeRhmmk',
+ },
+ {
+ name: 'Spell Slot Ring',
+ hint: 'Remaining spell slots represented as a ring.',
+ mappings: [
+ {
+ id: '1SS3KhwM',
+ label: 'Spell Slot Ring',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: '1SS3KhwM',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: '5000',
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ repeating: false,
+ alpha: '1',
+ tint: '',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ width: '{{object.w}} * @Scale',
+ height: '{{object.w}} * @Scale',
+ scaleX: '1',
+ scaleY: '1',
+ angle: '0',
+ offsetX: '0',
+ offsetY: '0',
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedToOwner: true,
+ limitedUsers: [],
+ limitOnHover: true,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '',
+ repeating: false,
+ fontFamily: 'Signika',
+ fill: '#ffffff',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ fontSize: '36',
+ align: 'left',
+ letterSpacing: '0',
+ dropShadow: 'true',
+ strokeThickness: '1',
+ stroke: '#111111',
+ wordWrap: false,
+ wordWrapWidth: '200',
+ breakWords: false,
+ maxHeight: '0',
+ curve: {
+ angle: '0',
+ radius: '0',
+ invert: false,
+ },
+ },
+ shapes: [
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer ',
+ startAngle: '0',
+ endAngle: '360',
+ },
+ label: 'BaseLayer',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#3b3b3b',
+ alpha: '1',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90',
+ endAngle: '-90 + ( {{actor.system.spells.spell1.value}} * @Tick )',
+ },
+ label: '1st level',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff0000',
+ alpha: '1',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 4 * @Tick )',
+ endAngle: '-90 + ( 4 * @Tick ) + ( {{actor.system.spells.spell2.value}} * @Tick )',
+ },
+ label: '2nd Level',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff8800',
+ alpha: '1',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 7 * @Tick )',
+ endAngle: '-90 + ( 7 * @Tick ) + ( {{actor.system.spells.spell3.value}} * @Tick )',
+ },
+ label: '3rd Level',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ffdd00',
+ alpha: '1',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 10 * @Tick )',
+ endAngle: '-90 + ( 10 * @Tick ) + ( {{actor.system.spells.spell4.value}} * @Tick )',
+ },
+ label: '4th Level',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#37ff00',
+ alpha: '1',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 13 * @Tick )',
+ endAngle: '-90 + ( 13 * @Tick ) + ( {{actor.system.spells.spell5.value}} * @Tick )',
+ },
+ label: '5th Level',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#00ffcc',
+ alpha: '0.95',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 16 * @Tick )',
+ endAngle: '-90 + ( 16 * @Tick ) + ( {{actor.system.spells.spell6.value}} * @Tick )',
+ },
+ label: '6th Level',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#00b3ff',
+ alpha: '1',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 18 * @Tick )',
+ endAngle: '-90 + ( 18 * @Tick ) + ( {{actor.system.spells.spell7.value}} * @Tick )',
+ },
+ label: '7th Level',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#001eff',
+ alpha: '1',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 20 * @Tick )',
+ endAngle: '-90 + ( 20 * @Tick ) + ( {{actor.system.spells.spell8.value}} * @Tick )',
+ },
+ label: '8th Level',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ae00ff',
+ alpha: '1',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 21 * @Tick )',
+ endAngle: '-90 + ( 21 * @Tick ) + ( {{actor.system.spells.spell9.value}} * @Tick )',
+ },
+ label: '9th Level - Copy',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '1',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90',
+ endAngle: '-90 + ( 1 * @Tick )',
+ },
+ label: 'Outline 1',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 1 * @Tick )',
+ endAngle: '-90 + ( 2 * @Tick )',
+ },
+ label: 'Outline 2',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 2 * @Tick )',
+ endAngle: '-90 + ( 3 * @Tick )',
+ },
+ label: 'Outline 3',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 3 * @Tick )',
+ endAngle: '-90 + ( 4 * @Tick )',
+ },
+ label: 'Outline 4',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 4 * @Tick )',
+ endAngle: '-90 + ( 5 * @Tick )',
+ },
+ label: 'Outline 5',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 5 * @Tick )',
+ endAngle: '-90 + ( 6 * @Tick )',
+ },
+ label: 'Outline 6',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 6 * @Tick )',
+ endAngle: '-90 + ( 7 * @Tick )',
+ },
+ label: 'Outline 7',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 7 * @Tick )',
+ endAngle: '-90 + ( 8 * @Tick )',
+ },
+ label: 'Outline 8',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 8 * @Tick )',
+ endAngle: '-90 + ( 9 * @Tick )',
+ },
+ label: 'Outline 9',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 9 * @Tick )',
+ endAngle: '-90 + ( 10 * @Tick )',
+ },
+ label: 'Outline 10',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 10 * @Tick )',
+ endAngle: '-90 + ( 11 * @Tick )',
+ },
+ label: 'Outline 11',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 11 * @Tick )',
+ endAngle: '-90 + ( 12 * @Tick )',
+ },
+ label: 'Outline 12',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 12 * @Tick )',
+ endAngle: '-90 + ( 13 * @Tick )',
+ },
+ label: 'Outline 13',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 13 * @Tick )',
+ endAngle: '-90 + ( 14 * @Tick )',
+ },
+ label: 'Outline 14',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 14 * @Tick )',
+ endAngle: '-90 + ( 15 * @Tick )',
+ },
+ label: 'Outline 15',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 15 * @Tick )',
+ endAngle: '-90 + ( 16 * @Tick )',
+ },
+ label: 'Outline 16',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 16 * @Tick )',
+ endAngle: '-90 + ( 17 * @Tick )',
+ },
+ label: 'Outline 17',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 17 * @Tick )',
+ endAngle: '-90 + ( 18 * @Tick )',
+ },
+ label: 'Outline 18',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 18 * @Tick )',
+ endAngle: '-90 + ( 19 * @Tick )',
+ },
+ label: 'Outline 19',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 19 * @Tick )',
+ endAngle: '-90 + ( 20 * @Tick )',
+ },
+ label: 'Outline 20',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 20 * @Tick )',
+ endAngle: '-90 + ( 21 * @Tick )',
+ },
+ label: 'Outline 21',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ {
+ shape: {
+ type: 'torus',
+ x: '0',
+ y: '0',
+ innerRadius: '@Inner',
+ outerRadius: '@Outer',
+ startAngle: '-90 + ( 21 * @Tick )',
+ endAngle: '-90 + ( 22 * @Tick )',
+ },
+ label: 'Outline 22',
+ line: {
+ width: '1',
+ color: '#000000',
+ alpha: '1',
+ },
+ fill: {
+ color: '#ff00ea',
+ alpha: '0',
+ interpolateColor: {
+ color2: '#ffffff',
+ prc: '',
+ },
+ },
+ repeating: false,
+ },
+ ],
+ variables: [
+ {
+ name: 'Tick',
+ value: '16.3636363636363636',
+ },
+ {
+ name: 'Inner',
+ value: '66',
+ },
+ {
+ name: 'Outer',
+ value: '80',
+ },
+ {
+ name: 'Scale',
+ value: '1.8',
+ },
+ ],
+ effect: '',
+ label: 'Spell Slot Ring',
+ },
+ group: 'Spell Slot Ring',
+ i: 0,
+ },
+ {
+ id: '3IAo8ZUu',
+ label: 'Spell Slot Numbers',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 60,
+ config: {},
+ overlay: true,
+ alwaysOn: true,
+ disabled: false,
+ overlayConfig: {
+ id: '3IAo8ZUu',
+ parentID: '',
+ underlay: false,
+ bottom: false,
+ top: true,
+ inheritTint: false,
+ linkRotation: false,
+ animation: {
+ relative: false,
+ rotate: false,
+ duration: '5000',
+ clockwise: true,
+ },
+ linkMirror: false,
+ linkScale: false,
+ linkDimensionsX: false,
+ linkDimensionsY: false,
+ linkOpacity: false,
+ linkStageScale: false,
+ loop: true,
+ playOnce: false,
+ img: '',
+ repeating: false,
+ alpha: '1',
+ tint: '',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ width: '{{object.w}} * @Scale',
+ height: '{{object.w}} * @Scale',
+ scaleX: '1',
+ scaleY: '1',
+ angle: '1',
+ offsetX: '0',
+ offsetY: '0.009',
+ anchor: {
+ x: 0.5,
+ y: 0.5,
+ },
+ filter: 'NONE',
+ alwaysVisible: false,
+ limitedToOwner: true,
+ limitedUsers: [],
+ limitOnHover: true,
+ limitOnHighlight: false,
+ limitOnControl: false,
+ limitOnEffect: '',
+ limitOnProperty: '',
+ text: {
+ text: '4455566778911112223334',
+ repeating: false,
+ fontFamily: 'Signika',
+ fill: '#ffffff',
+ interpolateColor: {
+ color2: '',
+ prc: '',
+ },
+ fontSize: '45',
+ align: 'left',
+ letterSpacing: '41.5',
+ dropShadow: null,
+ strokeThickness: '4',
+ stroke: '#111111',
+ wordWrap: false,
+ wordWrapWidth: '200',
+ breakWords: false,
+ maxHeight: '0',
+ curve: {
+ angle: '360',
+ radius: '220',
+ invert: false,
+ },
+ },
+ variables: [
+ {
+ name: 'Scale',
+ value: '1.85',
+ },
+ ],
+ effect: '',
+ label: 'Spell Slot Numbers',
+ },
+ group: 'Spell Slot Ring',
+ i: 1,
+ },
+ ],
+ id: 'FeFzTjjE',
+ },
+];
diff --git a/Data/modules/token-variants/scripts/models.js b/Data/modules/token-variants/scripts/models.js
new file mode 100644
index 00000000..7d05a10e
--- /dev/null
+++ b/Data/modules/token-variants/scripts/models.js
@@ -0,0 +1,121 @@
+export const DEFAULT_ACTIVE_EFFECT_CONFIG = {
+ id: '',
+ label: '',
+ expression: '',
+ imgName: '',
+ imgSrc: '',
+ priority: 50,
+ config: null,
+ overlay: false,
+ alwaysOn: false,
+ disabled: false,
+ overlayConfig: null,
+ targetActors: null,
+ group: 'Default',
+};
+
+export const DEFAULT_OVERLAY_CONFIG = {
+ img: '',
+ alpha: 1,
+ scaleX: 1,
+ scaleY: 1,
+ offsetX: 0,
+ offsetY: 0,
+ angle: 0,
+ filter: 'NONE',
+ filterOptions: {},
+ inheritTint: false,
+ top: false,
+ bottom: false,
+ underlay: false,
+ linkRotation: true,
+ linkMirror: true,
+ linkOpacity: false,
+ linkScale: true,
+ linkDimensionX: false,
+ linkDimensionY: false,
+ linkStageScale: false,
+ mirror: false,
+ tint: null,
+ loop: true,
+ playOnce: false,
+ animation: {
+ rotate: false,
+ duration: 5000,
+ clockwise: true,
+ relative: false,
+ },
+ limitedUsers: [],
+ limitedToOwner: false,
+ alwaysVisible: false,
+ text: {
+ text: '',
+ align: CONFIG.canvasTextStyle.align,
+ fontSize: CONFIG.canvasTextStyle.fontSize,
+ fontFamily: CONFIG.canvasTextStyle.fontFamily,
+ fill: CONFIG.canvasTextStyle.fill,
+ dropShadow: CONFIG.canvasTextStyle.dropShadow,
+ strokeThickness: CONFIG.canvasTextStyle.strokeThickness,
+ stroke: CONFIG.canvasTextStyle.stroke,
+ curve: { angle: 0, radius: 0, invert: false },
+ letterSpacing: CONFIG.canvasTextStyle.letterSpacing,
+ repeating: false,
+ wordWrap: false,
+ wordWrapWidth: 200,
+ breakWords: false,
+ maxHeight: 0,
+ },
+ parentID: '',
+ id: null,
+ anchor: { x: 0.5, y: 0.5 },
+ shapes: [],
+ variables: [],
+ interactivity: [],
+};
+
+export const OVERLAY_SHAPES = {
+ Rectangle: {
+ type: 'rectangle',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ radius: 0,
+ repeating: false,
+ },
+ Ellipse: {
+ type: 'ellipse',
+ x: 50,
+ y: 50,
+ width: 50,
+ height: 50,
+ repeating: false,
+ },
+ Polygon: {
+ type: 'polygon',
+ x: 0,
+ y: 0,
+ points: '0,1,0.95,0.31,0.59,-0.81,-0.59,-0.81,-0.95,0.31',
+ scale: 50,
+ repeating: false,
+ },
+ Torus: {
+ type: 'torus',
+ x: 0,
+ y: 0,
+ innerRadius: 50,
+ outerRadius: 100,
+ startAngle: 0,
+ endAngle: 180,
+ repeating: false,
+ },
+};
+
+export const CORE_SHAPE = {
+ line: {
+ width: 1,
+ color: '#000000',
+ alpha: 1,
+ },
+ fill: { color: '#ffffff', color2: '', prc: '', alpha: 1 },
+};
diff --git a/Data/modules/token-variants/scripts/search.js b/Data/modules/token-variants/scripts/search.js
new file mode 100644
index 00000000..e577211f
--- /dev/null
+++ b/Data/modules/token-variants/scripts/search.js
@@ -0,0 +1,620 @@
+import { isInitialized } from '../token-variants.mjs';
+import { Fuse } from './fuse/fuse.js';
+import { getSearchOptions, TVA_CONFIG } from './settings.js';
+import {
+ callForgeVTT,
+ flattenSearchResults,
+ getFileName,
+ getFileNameWithExt,
+ getFilePath,
+ getFilters,
+ isImage,
+ isVideo,
+ parseKeywords,
+ simplifyName,
+ simplifyPath,
+} from './utils.js';
+
+// True if in the middle of caching image paths
+let caching = false;
+export function isCaching() {
+ return caching;
+}
+
+// Cached images
+let CACHED_IMAGES = {};
+
+/**
+ * @param {string} search Text to be used as the search criteria
+ * @param {object} [options={}] Options which customize the search
+ * @param {SEARCH_TYPE|string} [options.searchType] Controls filters applied to the search results
+ * @param {Boolean} [options.simpleResults] Results will be returned as an array of all image paths found
+ * @param {Function[]} [options.callback] Function to be called with the found images
+ * @param {object} [options.searchOptions] Override search settings
+ * @returns {Promise|Array>} All images found split by original criteria and keywords
+ */
+export async function doImageSearch(
+ search,
+ { searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN, simpleResults = false, callback = null, searchOptions = {} } = {}
+) {
+ if (caching) return;
+
+ searchOptions = mergeObject(searchOptions, getSearchOptions(), { overwrite: false });
+
+ search = search.trim();
+
+ if (TVA_CONFIG.debug) console.info('TVA | STARTING: Art Search', search, searchType, searchOptions);
+
+ let searches = [search];
+ let allImages = new Map();
+ const keywords = parseKeywords(searchOptions.excludedKeywords);
+
+ if (searchOptions.keywordSearch) {
+ searches = searches.concat(
+ search
+ .split(/[_\- :,\|\(\)\[\]]/)
+ .filter((word) => word.length > 2 && !keywords.includes(word.toLowerCase()))
+ .reverse()
+ );
+ }
+
+ let usedImages = new Set();
+ for (const search of searches) {
+ if (allImages.get(search) !== undefined) continue;
+
+ let results = await findImages(search, searchType, searchOptions);
+ results = results.filter((pathObj) => !usedImages.has(pathObj));
+
+ allImages.set(search, results);
+ results.forEach(usedImages.add, usedImages);
+ }
+
+ if (TVA_CONFIG.debug) console.info('TVA | ENDING: Art Search');
+
+ if (simpleResults) {
+ allImages = Array.from(usedImages).map((obj) => obj.path);
+ }
+
+ if (callback) callback(allImages);
+ return allImages;
+}
+
+/**
+ * @param {*} search Text to be used as the search criteria
+ * @param {object} [options={}] Options which customize the search
+ * @param {SEARCH_TYPE|string} [options.searchType] Controls filters applied to the search results
+ * @param {Actor} [options.actor] Used to retrieve 'shared' images from if enabled in the Randomizer Settings
+ * @param {Function[]} [options.callback] Function to be called with the random image
+ * @param {object} [options.searchOptions] Override search settings
+ * @param {object} [options.randomizerOptions] Override randomizer settings. These take precedence over searchOptions
+ * @returns Array|null} Image path and name
+ */
+export async function doRandomSearch(
+ search,
+ {
+ searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN,
+ actor = null,
+ callback = null,
+ randomizerOptions = {},
+ searchOptions = {},
+ } = {}
+) {
+ if (caching) return null;
+
+ const results = flattenSearchResults(
+ await _randSearchUtil(search, {
+ searchType: searchType,
+ actor: actor,
+ randomizerOptions: randomizerOptions,
+ searchOptions: searchOptions,
+ })
+ );
+ if (results.length === 0) return null;
+
+ // Pick random image
+ let randImageNum = Math.floor(Math.random() * results.length);
+ if (callback) callback([results[randImageNum].path, results[randImageNum].name]);
+ return [results[randImageNum].path, results[randImageNum].name];
+}
+
+export async function doSyncSearch(
+ search,
+ target,
+ { searchType = SEARCH_TYPE.TOKEN, actor = null, randomizerOptions = {} } = {}
+) {
+ if (caching) return null;
+
+ const results = flattenSearchResults(await _randSearchUtil(search, { searchType, actor, randomizerOptions }));
+
+ // Find the image with the most similar name
+ const fuse = new Fuse(results, {
+ keys: ['name'],
+ minMatchCharLength: 1,
+ ignoreLocation: true,
+ threshold: 0.4,
+ });
+
+ const fResults = fuse.search(target);
+
+ if (fResults && fResults.length !== 0) {
+ return [fResults[0].item.path, fResults[0].item.name];
+ } else {
+ return null;
+ }
+}
+
+async function _randSearchUtil(
+ search,
+ { searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN, actor = null, randomizerOptions = {}, searchOptions = {} } = {}
+) {
+ const randSettings = mergeObject(randomizerOptions, TVA_CONFIG.randomizer, { overwrite: false });
+ if (
+ !(
+ randSettings.tokenName ||
+ randSettings.actorName ||
+ randSettings.keywords ||
+ randSettings.shared ||
+ randSettings.wildcard
+ )
+ )
+ return null;
+
+ // Randomizer settings take precedence
+ searchOptions.keywordSearch = randSettings.keywords;
+
+ // Swap search to the actor name if need be
+ if (randSettings.actorName && actor) {
+ search = actor.name;
+ }
+
+ // Gather all images
+ let results =
+ randSettings.actorName || randSettings.tokenName || randSettings.keywords
+ ? await doImageSearch(search, {
+ searchType: searchType,
+ searchOptions: searchOptions,
+ })
+ : new Map();
+
+ if (!randSettings.tokenName && !randSettings.actorName) {
+ results.delete(search);
+ }
+ if (randSettings.shared && actor) {
+ let sharedVariants = actor.getFlag('token-variants', 'variants') || [];
+ if (sharedVariants.length != 0) {
+ const sv = [];
+ sharedVariants.forEach((variant) => {
+ variant.names.forEach((name) => {
+ sv.push({ path: variant.imgSrc, name: name });
+ });
+ });
+ results.set('variants95436723', sv);
+ }
+ }
+ if (randSettings.wildcard && actor) {
+ let protoImg = actor.prototypeToken.texture.src;
+ if (protoImg.includes('*') || (protoImg.includes('{') && protoImg.includes('}'))) {
+ // Modified version of Actor.getTokenImages()
+ const getTokenImages = async (actor) => {
+ if (actor._tokenImages) return actor._tokenImages;
+ let source = 'data';
+ const browseOptions = { wildcard: true };
+
+ // Support non-user sources
+ if (/\.s3\./.test(protoImg)) {
+ source = 's3';
+ const { bucket, keyPrefix } = FilePicker.parseS3URL(protoImg);
+ if (bucket) {
+ browseOptions.bucket = bucket;
+ protoImg = keyPrefix;
+ }
+ } else if (protoImg.startsWith('icons/')) source = 'public';
+
+ // Retrieve wildcard content
+ try {
+ const content = await FilePicker.browse(source, protoImg, browseOptions);
+ return content.files;
+ } catch (err) {
+ return [];
+ }
+ };
+
+ const wildcardImages = (await getTokenImages(actor))
+ .filter((img) => !img.includes('*') && (isImage(img) || isVideo(img)))
+ .map((variant) => {
+ return { path: variant, name: getFileName(variant) };
+ });
+ results.set('variants95436623', wildcardImages);
+ }
+ }
+
+ return results;
+}
+
+/**
+ * Recursive image search through a directory
+ * @param {*} path starting path
+ * @param {*} options.apiKey ForgeVTT AssetLibrary API key
+ * @param {*} found_images all the images found
+ * @returns void
+ */
+async function walkFindImages(path, { apiKey = '' } = {}, found_images) {
+ let files = {};
+ if (!path.source) {
+ path.source = 'data';
+ }
+ const typeKey = path.types.sort().join(',');
+ try {
+ if (path.source.startsWith('s3:')) {
+ files = await FilePicker.browse('s3', path.text, {
+ bucket: path.source.replace('s3:', ''),
+ });
+ } else if (path.source.startsWith('forgevtt')) {
+ if (apiKey) {
+ const response = await callForgeVTT(path.text, apiKey);
+ files.files = response.files.map((f) => f.url);
+ } else {
+ files = await FilePicker.browse('forgevtt', path.text, { recursive: true });
+ }
+ } else if (path.source.startsWith('forge-bazaar')) {
+ files = await FilePicker.browse('forge-bazaar', path.text, { recursive: true });
+ } else if (path.source.startsWith('imgur')) {
+ await fetch('https://api.imgur.com/3/gallery/album/' + path.text, {
+ headers: {
+ Authorization: 'Client-ID ' + (TVA_CONFIG.imgurClientId ? TVA_CONFIG.imgurClientId : 'df9d991443bb222'),
+ Accept: 'application/json',
+ },
+ })
+ .then((response) => response.json())
+ .then(async function (result) {
+ if (!result.success) {
+ return;
+ }
+ result.data.images.forEach((img) => {
+ const rtName = img.title ?? img.description ?? getFileName(img.link);
+ _addToFound({ path: decodeURI(img.link), name: rtName }, typeKey, found_images);
+ });
+ })
+ .catch((error) => console.warn('TVA |', error));
+ return;
+ } else if (path.source.startsWith('rolltable')) {
+ const table = game.tables.contents.find((t) => t.name === path.text);
+ if (!table) {
+ const rollTableName = path.text;
+ ui.notifications.warn(
+ game.i18n.format('token-variants.notifications.warn.invalid-table', {
+ rollTableName,
+ })
+ );
+ } else {
+ for (let baseTableData of table.results) {
+ const rtPath = baseTableData.img;
+ const rtName = baseTableData.text || getFileName(rtPath);
+ _addToFound({ path: decodeURI(rtPath), name: rtName }, typeKey, found_images);
+ }
+ }
+ return;
+ } else if (path.source.startsWith('json')) {
+ await fetch(path.text, {
+ headers: {
+ Accept: 'application/json',
+ },
+ })
+ .then((response) => response.json())
+ .then(async function (result) {
+ if (!result.length > 0) {
+ return;
+ }
+ result.forEach((img) => {
+ const rtName = img.name ?? getFileName(img.path);
+ _addToFound({ path: decodeURI(img.path), name: rtName, tags: img.tags }, typeKey, found_images);
+ });
+ })
+ .catch((error) => console.warn('TVA |', error));
+ return;
+ } else {
+ files = await FilePicker.browse(path.source, path.text);
+ }
+ } catch (err) {
+ console.warn(
+ `TVA | ${game.i18n.localize('token-variants.notifications.warn.path-not-found')} ${path.source}:${path.text}`
+ );
+ return;
+ }
+
+ if (files.target == '.') return;
+
+ if (files.files) {
+ files.files.forEach((tokenSrc) => {
+ _addToFound({ path: decodeURI(tokenSrc), name: getFileName(tokenSrc) }, typeKey, found_images);
+ });
+ }
+
+ // ForgeVTT requires special treatment
+ // Bazaar paths fail recursive search if one level above root
+ if (path.source.startsWith('forgevtt')) return;
+ else if (
+ path.source.startsWith('forge-bazaar') &&
+ !['modules', 'systems', 'worlds', 'assets'].includes(path.text.replaceAll(/[\/\\]/g, ''))
+ ) {
+ return;
+ }
+
+ for (let f_dir of files.dirs) {
+ await walkFindImages({ text: f_dir, source: path.source, types: path.types }, { apiKey: apiKey }, found_images);
+ }
+}
+
+function _addToFound(img, typeKey, found_images) {
+ if (isImage(img.path) || isVideo(img.path)) {
+ if (found_images[typeKey] == null) {
+ found_images[typeKey] = [img];
+ } else {
+ found_images[typeKey].push(img);
+ }
+ }
+}
+
+/**
+ * Recursive walks through all paths exposed to the module and caches them
+ * @param {*} searchType
+ * @returns
+ */
+async function walkAllPaths(searchType) {
+ const found_images = {};
+ const paths = _filterPathsByType(TVA_CONFIG.searchPaths, searchType);
+
+ for (const path of paths) {
+ if ((path.cache && caching) || (!path.cache && !caching)) await walkFindImages(path, {}, found_images);
+ }
+
+ // ForgeVTT specific path handling
+ const userId = typeof ForgeAPI !== 'undefined' ? await ForgeAPI.getUserId() : '';
+ for (const uid in TVA_CONFIG.forgeSearchPaths) {
+ const apiKey = TVA_CONFIG.forgeSearchPaths[uid].apiKey;
+ const paths = _filterPathsByType(TVA_CONFIG.forgeSearchPaths[uid].paths, searchType);
+ if (uid === userId) {
+ for (const path of paths) {
+ if ((path.cache && caching) || (!path.cache && !caching)) await walkFindImages(path, {}, found_images);
+ }
+ } else if (apiKey) {
+ for (const path of paths) {
+ if ((path.cache && caching) || (!path.cache && !caching)) {
+ if (path.share) await walkFindImages(path, { apiKey: apiKey }, found_images);
+ }
+ }
+ }
+ }
+
+ return found_images;
+}
+
+function _filterPathsByType(paths, searchType) {
+ if (!searchType) return paths;
+ return paths.filter((p) => p.types.includes(searchType));
+}
+
+export async function findImagesFuzzy(name, searchType, searchOptions, forceSearchName = false) {
+ if (TVA_CONFIG.debug)
+ console.info('TVA | STARTING: Fuzzy Image Search', name, searchType, searchOptions, forceSearchName);
+
+ const filters = getFilters(searchType, searchOptions.searchFilters);
+
+ const fuse = new Fuse([], {
+ keys: [!forceSearchName && searchOptions.runSearchOnPath ? 'path' : 'name', 'tags'],
+ includeScore: true,
+ includeMatches: true,
+ minMatchCharLength: 1,
+ ignoreLocation: true,
+ threshold: searchOptions.algorithm.fuzzyThreshold,
+ });
+
+ const found_images = await walkAllPaths(searchType);
+
+ for (const container of [CACHED_IMAGES, found_images]) {
+ for (const typeKey in container) {
+ const types = typeKey.split(',');
+ if (types.includes(searchType)) {
+ for (const imgObj of container[typeKey]) {
+ if (_imagePassesFilter(imgObj.name, imgObj.path, filters, searchOptions.runSearchOnPath)) {
+ fuse.add(imgObj);
+ }
+ }
+ }
+ }
+ }
+
+ let results;
+ if (name === '') {
+ results = fuse.getIndex().docs.slice(0, searchOptions.algorithm.fuzzyLimit);
+ } else {
+ results = fuse.search(name).slice(0, searchOptions.algorithm.fuzzyLimit);
+ results = results.map((r) => {
+ r.item.indices = r.matches[0].indices;
+ r.item.score = r.score;
+ return r.item;
+ });
+ }
+
+ if (TVA_CONFIG.debug) console.info('TVA | ENDING: Fuzzy Image Search', results);
+
+ return results;
+}
+
+async function findImagesExact(name, searchType, searchOptions) {
+ if (TVA_CONFIG.debug) console.info('TVA | STARTING: Exact Image Search', name, searchType, searchOptions);
+
+ const found_images = await walkAllPaths(searchType);
+
+ const simpleName = simplifyName(name);
+ const filters = getFilters(searchType, searchOptions.searchFilters);
+
+ const matchedImages = [];
+
+ for (const container of [CACHED_IMAGES, found_images]) {
+ for (const typeKey in container) {
+ const types = typeKey.split(',');
+ if (types.includes(searchType)) {
+ for (const imgOBj of container[typeKey]) {
+ if (_exactSearchMatchesImage(simpleName, imgOBj.path, imgOBj.name, filters, searchOptions.runSearchOnPath)) {
+ matchedImages.push(imgOBj);
+ }
+ }
+ }
+ }
+ }
+
+ if (TVA_CONFIG.debug) console.info('TVA | ENDING: Exact Image Search', matchedImages);
+ return matchedImages;
+}
+
+async function findImages(name, searchType = '', searchOptions = {}) {
+ const sOptions = mergeObject(searchOptions, getSearchOptions(), { overwrite: false });
+ if (sOptions.algorithm.exact) {
+ return await findImagesExact(name, searchType, sOptions);
+ } else {
+ return await findImagesFuzzy(name, searchType, sOptions);
+ }
+}
+
+/**
+ * Checks if image path and name match the provided search text and filters
+ * @param imagePath image path
+ * @param imageName image name
+ * @param filters filters to be applied
+ * @returns true|false
+ */
+function _exactSearchMatchesImage(simplifiedSearch, imagePath, imageName, filters, runSearchOnPath) {
+ // Is the search text contained in the name/path
+ const simplified = runSearchOnPath ? simplifyPath(imagePath) : simplifyName(imageName);
+ if (!simplified.includes(simplifiedSearch)) {
+ return false;
+ }
+
+ if (!filters) return true;
+ return _imagePassesFilter(imageName, imagePath, filters, runSearchOnPath);
+}
+
+function _imagePassesFilter(imageName, imagePath, filters, runSearchOnPath) {
+ // Filters are applied to path depending on the 'runSearchOnPath' setting, and actual or custom rolltable name
+ let text;
+ if (runSearchOnPath) {
+ text = decodeURIComponent(imagePath);
+ } else if (getFileName(imagePath) === imageName) {
+ text = getFileNameWithExt(imagePath);
+ } else {
+ text = imageName;
+ }
+
+ if (filters.regex) {
+ return filters.regex.test(text);
+ }
+ if (filters.include) {
+ if (!text.includes(filters.include)) return false;
+ }
+ if (filters.exclude) {
+ if (text.includes(filters.exclude)) return false;
+ }
+ return true;
+}
+
+// ===================================
+// ==== CACHING RELATED FUNCTIONS ====
+// ===================================
+
+export async function saveCache(cacheFile) {
+ const data = {};
+
+ const caches = Object.keys(CACHED_IMAGES);
+ for (const c of caches) {
+ if (!(c in data)) data[c] = [];
+ for (const img of CACHED_IMAGES[c]) {
+ if (img.tags) {
+ data[c].push([img.path, img.name, img.tags]);
+ } else if (getFileName(img.path) === img.name) {
+ data[c].push(img.path);
+ } else {
+ data[c].push([img.path, img.name]);
+ }
+ }
+ }
+
+ let file = new File([JSON.stringify(data)], getFileNameWithExt(cacheFile), {
+ type: 'text/plain',
+ });
+ FilePicker.upload('data', getFilePath(cacheFile), file);
+}
+
+/**
+ * Search for and cache all the found token art
+ */
+export async function cacheImages({
+ staticCache = TVA_CONFIG.staticCache,
+ staticCacheFile = TVA_CONFIG.staticCacheFile,
+} = {}) {
+ if (caching) return;
+ caching = true;
+
+ if (!isInitialized() && staticCache) {
+ if (await _readCacheFromFile(staticCacheFile)) {
+ caching = false;
+ return;
+ }
+ }
+
+ if (!TVA_CONFIG.disableNotifs)
+ ui.notifications.info(game.i18n.format('token-variants.notifications.info.caching-started'));
+
+ if (TVA_CONFIG.debug) console.info('TVA | STARTING: Token Caching');
+ const found_images = await walkAllPaths();
+ CACHED_IMAGES = found_images;
+
+ if (TVA_CONFIG.debug) console.info('TVA | ENDING: Token Caching');
+
+ caching = false;
+ if (!TVA_CONFIG.disableNotifs)
+ ui.notifications.info(
+ game.i18n.format('token-variants.notifications.info.caching-finished', {
+ imageCount: Object.keys(CACHED_IMAGES).reduce((count, types) => count + CACHED_IMAGES[types].length, 0),
+ })
+ );
+
+ if (staticCache && game.user.isGM) {
+ saveCache(staticCacheFile);
+ }
+}
+
+async function _readCacheFromFile(fileName) {
+ CACHED_IMAGES = {};
+ try {
+ await jQuery.getJSON(fileName, (json) => {
+ for (let category in json) {
+ CACHED_IMAGES[category] = [];
+
+ for (const img of json[category]) {
+ if (Array.isArray(img)) {
+ if (img.length === 3) {
+ CACHED_IMAGES[category].push({ path: img[0], name: img[1], tags: img[2] });
+ } else {
+ CACHED_IMAGES[category].push({ path: img[0], name: img[1] });
+ }
+ } else {
+ CACHED_IMAGES[category].push({ path: img, name: getFileName(img) });
+ }
+ }
+ }
+ if (!TVA_CONFIG.disableNotifs)
+ ui.notifications.info(
+ `Token Variant Art: Using Static Cache (${Object.keys(CACHED_IMAGES).reduce(
+ (count, c) => count + CACHED_IMAGES[c].length,
+ 0
+ )} images)`
+ );
+ });
+ } catch (error) {
+ ui.notifications.warn(`Token Variant Art: Static Cache not found`);
+ CACHED_IMAGES = {};
+ return false;
+ }
+ return true;
+}
diff --git a/Data/modules/token-variants/scripts/settings.js b/Data/modules/token-variants/scripts/settings.js
new file mode 100644
index 00000000..6130929e
--- /dev/null
+++ b/Data/modules/token-variants/scripts/settings.js
@@ -0,0 +1,561 @@
+import { BASE_IMAGE_CATEGORIES, userRequiresImageCache, waitForTokenTexture } from './utils.js';
+import { ForgeSearchPaths } from '../applications/forgeSearchPaths.js';
+import TokenHUDClientSettings from '../applications/tokenHUDClientSettings.js';
+import CompendiumMapConfig from '../applications/compendiumMap.js';
+import ImportExport from '../applications/importExport.js';
+import ConfigureSettings from '../applications/configureSettings.js';
+import { cacheImages, saveCache } from './search.js';
+import { registerAllHooks } from './hooks/hooks.js';
+import { registerAllWrappers } from './wrappers/wrappers.js';
+
+export const TVA_CONFIG = {
+ debug: false,
+ disableNotifs: false,
+ searchPaths: [
+ {
+ text: 'modules/caeora-maps-tokens-assets/assets/tokens',
+ cache: true,
+ source: typeof ForgeAPI === 'undefined' ? 'data' : 'forge-bazaar',
+ types: ['Portrait', 'Token', 'PortraitAndToken'],
+ },
+ ],
+ forgeSearchPaths: {},
+ worldHud: {
+ displayOnlySharedImages: false,
+ disableIfTHWEnabled: false,
+ includeKeywords: false,
+ updateActorImage: false,
+ useNameSimilarity: false,
+ includeWildcard: true,
+ showFullPath: false,
+ animate: true,
+ },
+ hud: {
+ enableSideMenu: true,
+ displayAsImage: true,
+ imageOpacity: 50,
+ },
+ keywordSearch: true,
+ excludedKeywords: 'and,for',
+ runSearchOnPath: false,
+ searchFilters: {},
+ algorithm: {
+ exact: false,
+ fuzzy: true,
+ fuzzyLimit: 100,
+ fuzzyThreshold: 0.3,
+ fuzzyArtSelectPercentSlider: true,
+ },
+ tokenConfigs: [],
+ randomizer: {
+ actorCreate: false,
+ tokenCreate: false,
+ tokenCopyPaste: false,
+ tokenName: true,
+ keywords: false,
+ shared: false,
+ wildcard: false,
+ representedActorDisable: false,
+ linkedActorDisable: true,
+ popupOnDisable: false,
+ diffImages: false,
+ syncImages: false,
+ },
+ popup: {
+ disableAutoPopupOnActorCreate: true,
+ disableAutoPopupOnTokenCreate: true,
+ disableAutoPopupOnTokenCopyPaste: true,
+ twoPopups: false,
+ twoPopupsNoDialog: false,
+ },
+ imgurClientId: '',
+ stackStatusConfig: true,
+ mergeGroup: false,
+ staticCache: false,
+ staticCacheFile: 'modules/token-variants/token-variants-cache.json',
+ tilesEnabled: true,
+ compendiumMapper: {
+ missingOnly: false,
+ diffImages: false,
+ showImages: true,
+ cache: false,
+ autoDisplayArtSelect: true,
+ syncImages: false,
+ overrideCategory: false,
+ category: 'Token',
+ missingImages: [{ document: 'all', image: CONST.DEFAULT_TOKEN }],
+ searchOptions: {},
+ },
+ permissions: {
+ popups: {
+ 1: false,
+ 2: false,
+ 3: true,
+ 4: true,
+ },
+ portrait_right_click: {
+ 1: false,
+ 2: false,
+ 3: true,
+ 4: true,
+ },
+ image_path_button: {
+ 1: false,
+ 2: false,
+ 3: true,
+ 4: true,
+ },
+ hud: {
+ 1: true,
+ 2: true,
+ 3: true,
+ 4: true,
+ },
+ hudFullAccess: {
+ 1: false,
+ 2: false,
+ 3: true,
+ 4: true,
+ },
+ statusConfig: {
+ 1: false,
+ 2: false,
+ 3: true,
+ 4: true,
+ },
+ },
+ globalMappings: [],
+ templateMappings: [],
+ customImageCategories: [],
+ displayEffectIconsOnHover: false,
+ disableEffectIcons: false,
+ filterEffectIcons: false,
+ filterCustomEffectIcons: true,
+ filterIconList: [],
+ updateTokenProto: false,
+ imgNameContainsDimensions: false,
+ imgNameContainsFADimensions: false,
+ playVideoOnHover: true,
+ pauseVideoOnHoverOut: false,
+ disableImageChangeOnPolymorphed: false,
+ disableImageUpdateOnNonPrototype: false,
+ disableTokenUpdateAnimation: false,
+ invisibleImage: '',
+ systemHpPath: '',
+ internalEffects: {
+ hpChange: { enabled: false, duration: null },
+ },
+};
+
+export const FEATURE_CONTROL = {
+ EffectMappings: true,
+ EffectIcons: true,
+ Overlays: true,
+ UserMappings: true,
+ Wildcards: true,
+ PopUpAndRandomize: true,
+ HUD: true,
+};
+
+export function registerSettings() {
+ game.settings.register('token-variants', 'featureControl', {
+ scope: 'world',
+ config: false,
+ type: Object,
+ default: FEATURE_CONTROL,
+ onChange: async (val) => {
+ mergeObject(FEATURE_CONTROL, val);
+ registerAllHooks();
+ registerAllWrappers();
+ },
+ });
+ mergeObject(FEATURE_CONTROL, game.settings.get('token-variants', 'featureControl'));
+
+ game.settings.registerMenu('token-variants', 'settings', {
+ name: 'Configure Settings',
+ hint: 'Configure Token Variant Art settings',
+ label: 'Settings',
+ scope: 'world',
+ icon: 'fas fa-cog',
+ type: ConfigureSettings,
+ restricted: true,
+ });
+
+ const systemHpPaths = {
+ 'cyberpunk-red-core': 'derivedStats.hp',
+ lfg: 'health',
+ worldbuilding: 'health',
+ twodsix: 'hits',
+ };
+ TVA_CONFIG.systemHpPath = systemHpPaths[game.system.id] ?? 'attributes.hp';
+
+ game.settings.register('token-variants', 'effectMappingToggleGroups', {
+ scope: 'world',
+ config: false,
+ type: Object,
+ default: { Default: true },
+ });
+
+ game.settings.register('token-variants', 'settings', {
+ scope: 'world',
+ config: false,
+ type: Object,
+ default: TVA_CONFIG,
+ onChange: async (val) => {
+ // Generate a diff, it will be required when doing post-processing of the modified settings
+ const diff = _arrayAwareDiffObject(TVA_CONFIG, val);
+
+ // Check image re-cache required due to permission changes
+ let requiresImageCache = false;
+ if ('permissions' in diff) {
+ if (
+ !userRequiresImageCache(TVA_CONFIG.permissions) &&
+ userRequiresImageCache(val.permissions)
+ )
+ requiresImageCache = true;
+ }
+
+ // Update live settings
+ mergeObject(TVA_CONFIG, val);
+
+ if (
+ TVA_CONFIG.filterEffectIcons &&
+ ('filterCustomEffectIcons' in diff || 'filterIconList' in diff)
+ ) {
+ for (const tkn of canvas.tokens.placeables) {
+ waitForTokenTexture(tkn, (token) => {
+ token.drawEffects();
+ });
+ }
+ }
+
+ // Check image re-cache required due to search path changes
+ if ('searchPaths' in diff || 'forgeSearchPaths' in diff) {
+ if (userRequiresImageCache(TVA_CONFIG.permissions)) requiresImageCache = true;
+ }
+
+ // Cache/re-cache images if necessary
+ if (requiresImageCache) {
+ await cacheImages();
+ }
+
+ if (diff.staticCache) {
+ const cacheFile = diff.staticCacheFile ? diff.staticCacheFile : TVA_CONFIG.staticCacheFile;
+ saveCache(cacheFile);
+ }
+
+ TVA_CONFIG.hud = game.settings.get('token-variants', 'hudSettings');
+
+ registerAllHooks();
+ registerAllWrappers();
+
+ if ('displayEffectIconsOnHover' in diff) {
+ for (const tkn of canvas.tokens.placeables) {
+ if (tkn.effects) tkn.effects.visible = !diff.displayEffectIconsOnHover;
+ }
+ }
+
+ if ('filterEffectIcons' in diff || 'disableEffectIcons' in diff) {
+ for (const tkn of canvas.tokens.placeables) {
+ tkn.drawEffects();
+ }
+ }
+ },
+ });
+
+ game.settings.register('token-variants', 'debug', {
+ scope: 'world',
+ config: false,
+ type: Boolean,
+ default: TVA_CONFIG.debug,
+ onChange: (val) => (TVA_CONFIG.debug = val),
+ });
+
+ if (typeof ForgeAPI !== 'undefined') {
+ game.settings.registerMenu('token-variants', 'forgeSearchPaths', {
+ name: game.i18n.localize('token-variants.settings.forge-search-paths.Name'),
+ hint: game.i18n.localize('token-variants.settings.forge-search-paths.Hint'),
+ icon: 'fas fa-search',
+ type: ForgeSearchPaths,
+ scope: 'client',
+ restricted: false,
+ });
+ }
+
+ game.settings.register('token-variants', 'tokenConfigs', {
+ scope: 'world',
+ config: false,
+ type: Array,
+ default: TVA_CONFIG.tokenConfigs,
+ onChange: (val) => (TVA_CONFIG.tokenConfigs = val),
+ });
+
+ game.settings.registerMenu('token-variants', 'tokenHUDSettings', {
+ name: game.i18n.localize('token-variants.settings.token-hud.Name'),
+ hint: game.i18n.localize('token-variants.settings.token-hud.Hint'),
+ scope: 'client',
+ icon: 'fas fa-images',
+ type: TokenHUDClientSettings,
+ restricted: false,
+ });
+
+ game.settings.registerMenu('token-variants', 'compendiumMapper', {
+ name: game.i18n.localize('token-variants.settings.compendium-mapper.Name'),
+ hint: game.i18n.localize('token-variants.settings.compendium-mapper.Hint'),
+ scope: 'world',
+ icon: 'fas fa-cogs',
+ type: CompendiumMapConfig,
+ restricted: true,
+ });
+
+ game.settings.register('token-variants', 'compendiumMapper', {
+ scope: 'world',
+ config: false,
+ type: Object,
+ default: TVA_CONFIG.compendiumMapper,
+ onChange: (val) => (TVA_CONFIG.compendiumMapper = val),
+ });
+
+ game.settings.register('token-variants', 'hudSettings', {
+ scope: 'client',
+ config: false,
+ type: Object,
+ default: TVA_CONFIG.hud,
+ onChange: (val) => (TVA_CONFIG.hud = val),
+ });
+
+ game.settings.registerMenu('token-variants', 'importExport', {
+ name: `Import/Export`,
+ hint: game.i18n.localize('token-variants.settings.import-export.Hint'),
+ scope: 'world',
+ icon: 'fas fa-toolbox',
+ type: ImportExport,
+ restricted: true,
+ });
+
+ // Read settings
+ const settings = game.settings.get('token-variants', 'settings');
+ mergeObject(TVA_CONFIG, settings);
+
+ if (isEmpty(TVA_CONFIG.searchFilters)) {
+ BASE_IMAGE_CATEGORIES.forEach((cat) => {
+ TVA_CONFIG.searchFilters[cat] = {
+ include: '',
+ exclude: '',
+ regex: '',
+ };
+ });
+ }
+
+ for (let uid in TVA_CONFIG.forgeSearchPaths) {
+ TVA_CONFIG.forgeSearchPaths[uid].paths = TVA_CONFIG.forgeSearchPaths[uid].paths.map((p) => {
+ if (!p.source) {
+ p.source = 'forgevtt';
+ }
+ if (!p.types) {
+ if (p.tiles) p.types = ['Tile'];
+ else p.types = ['Portrait', 'Token', 'PortraitAndToken'];
+ }
+ return p;
+ });
+ }
+
+ // 20/07/2023 Convert globalMappings to a new format
+ if (getType(settings.globalMappings) === 'Object') {
+ TVA_CONFIG.globalMappings = migrateMappings(settings.globalMappings);
+ setTimeout(() => updateSettings({ globalMappings: TVA_CONFIG.globalMappings }), 10000);
+ }
+
+ // Read client settings
+ TVA_CONFIG.hud = game.settings.get('token-variants', 'hudSettings');
+}
+
+export function migrateMappings(mappings, globalMappings = []) {
+ if (!mappings) return [];
+ if (getType(mappings) === 'Object') {
+ let nMappings = [];
+ for (const [effect, mapping] of Object.entries(mappings)) {
+ if (!mapping.label) mapping.label = effect.replaceAll('¶', '.');
+ if (!mapping.expression) mapping.expression = effect.replaceAll('¶', '.');
+ if (!mapping.id) mapping.id = randomID(8);
+ delete mapping.effect;
+ if (mapping.overlayConfig) mapping.overlayConfig.id = mapping.id;
+ delete mapping.overlayConfig?.effect;
+ nMappings.push(mapping);
+ }
+ // Convert parents to parentIDs
+ let combMappings = nMappings.concat(globalMappings);
+ for (const mapping of nMappings) {
+ if (mapping.overlayConfig?.parent) {
+ if (mapping.overlayConfig.parent === 'Token (Placeable)') {
+ mapping.overlayConfig.parentID = 'TOKEN';
+ } else {
+ const parent = combMappings.find((m) => m.label === mapping.overlayConfig.parent);
+ if (parent) mapping.overlayConfig.parentID = parent.id;
+ else mapping.overlayConfig.parentID = '';
+ }
+ delete mapping.overlayConfig.parent;
+ }
+ }
+ return nMappings;
+ }
+ return mappings;
+}
+
+export function getFlagMappings(object) {
+ if (!object) return [];
+ let doc = object.document ?? object;
+ const actorId = doc.actor?.id;
+ if (actorId) {
+ doc = game.actors.get(actorId);
+ if (!doc) return [];
+ }
+
+ // 23/07/2023
+ let mappings = doc.getFlag('token-variants', 'effectMappings') ?? [];
+ if (getType(mappings) === 'Object') {
+ mappings = migrateMappings(mappings, TVA_CONFIG.globalMappings);
+ doc.setFlag('token-variants', 'effectMappings', mappings);
+ }
+ return mappings;
+}
+
+export function exportSettingsToJSON() {
+ const settings = deepClone(TVA_CONFIG);
+ const filename = `token-variants-settings.json`;
+ saveDataToFile(JSON.stringify(settings, null, 2), 'text/json', filename);
+}
+
+export async function importSettingsFromJSON(json) {
+ if (typeof json === 'string') json = JSON.parse(json);
+
+ if (json.forgeSearchPaths)
+ for (let uid in json.forgeSearchPaths) {
+ json.forgeSearchPaths[uid].paths = json.forgeSearchPaths[uid].paths.map((p) => {
+ if (!p.source) {
+ p.source = 'forgevtt';
+ }
+ if (!p.types) {
+ if (p.tiles) p.types = ['Tile'];
+ else p.types = ['Portrait', 'Token', 'PortraitAndToken'];
+ }
+ return p;
+ });
+ }
+
+ // 09/07/2022 Convert filters to new format if old one is still in use
+ if (json.searchFilters && json.searchFilters.portraitFilterInclude != null) {
+ const filters = json.searchFilters;
+ json.searchFilters = {
+ Portrait: {
+ include: filters.portraitFilterInclude ?? '',
+ exclude: filters.portraitFilterExclude ?? '',
+ regex: filters.portraitFilterRegex ?? '',
+ },
+ Token: {
+ include: filters.tokenFilterInclude ?? '',
+ exclude: filters.tokenFilterExclude ?? '',
+ regex: filters.tokenFilterRegex ?? '',
+ },
+ PortraitAndToken: {
+ include: filters.generalFilterInclude ?? '',
+ exclude: filters.generalFilterExclude ?? '',
+ regex: filters.generalFilterRegex ?? '',
+ },
+ };
+ if (json.compendiumMapper) delete json.compendiumMapper.searchFilters;
+ }
+
+ // Global Mappings need special merge
+ if (json.globalMappings) {
+ const nMappings = migrateMappings(json.globalMappings);
+ for (const m of nMappings) {
+ const i = TVA_CONFIG.globalMappings.findIndex((mapping) => m.label === mapping.label);
+ if (i === -1) TVA_CONFIG.globalMappings.push(m);
+ else TVA_CONFIG.globalMappings[i] = m;
+ }
+ json.globalMappings = TVA_CONFIG.globalMappings;
+ }
+
+ updateSettings(json);
+}
+
+function _refreshFilters(filters, customCategories, updateTVAConfig = false) {
+ const categories = BASE_IMAGE_CATEGORIES.concat(
+ customCategories ?? TVA_CONFIG.customImageCategories
+ );
+ for (const filter in filters) {
+ if (!categories.includes(filter)) {
+ delete filters[filter];
+ if (updateTVAConfig) delete TVA_CONFIG.searchFilters[filter];
+ }
+ }
+ for (const category of customCategories) {
+ if (filters[category] == null) {
+ filters[category] = {
+ include: '',
+ exclude: '',
+ regex: '',
+ };
+ }
+ }
+}
+
+export async function updateSettings(newSettings) {
+ const settings = mergeObject(deepClone(TVA_CONFIG), newSettings, { insertKeys: false });
+ // Custom image categories might have changed, meaning we may have filters that are no longer relevant
+ // or need to be added
+ if ('customImageCategories' in newSettings) {
+ _refreshFilters(settings.searchFilters, newSettings.customImageCategories, true);
+ if (settings.compendiumMapper?.searchOptions?.searchFilters != null) {
+ _refreshFilters(
+ settings.compendiumMapper.searchOptions.searchFilters,
+ newSettings.customImageCategories
+ );
+ TVA_CONFIG.compendiumMapper.searchOptions.searchFilters =
+ settings.compendiumMapper.searchOptions.searchFilters;
+ }
+ }
+ await game.settings.set('token-variants', 'settings', settings);
+}
+
+export function _arrayAwareDiffObject(original, other, { inner = false } = {}) {
+ function _difference(v0, v1) {
+ let t0 = getType(v0);
+ let t1 = getType(v1);
+ if (t0 !== t1) return [true, v1];
+ if (t0 === 'Array') return [!_arrayEquality(v0, v1), v1];
+ if (t0 === 'Object') {
+ if (isEmpty(v0) !== isEmpty(v1)) return [true, v1];
+ let d = _arrayAwareDiffObject(v0, v1, { inner });
+ return [!isEmpty(d), d];
+ }
+ return [v0 !== v1, v1];
+ }
+
+ // Recursively call the _difference function
+ return Object.keys(other).reduce((obj, key) => {
+ if (inner && !(key in original)) return obj;
+ let [isDifferent, difference] = _difference(original[key], other[key]);
+ if (isDifferent) obj[key] = difference;
+ return obj;
+ }, {});
+}
+
+function _arrayEquality(a1, a2) {
+ if (!(a2 instanceof Array) || a2.length !== a1.length) return false;
+ return a1.every((v, i) => {
+ if (getType(v) === 'Object') return Object.keys(_arrayAwareDiffObject(v, a2[i])).length === 0;
+ return a2[i] === v;
+ });
+}
+
+export function getSearchOptions() {
+ return {
+ keywordSearch: TVA_CONFIG.keywordSearch,
+ excludedKeywords: TVA_CONFIG.excludedKeywords,
+ runSearchOnPath: TVA_CONFIG.runSearchOnPath,
+ algorithm: TVA_CONFIG.algorithm,
+ searchFilters: TVA_CONFIG.searchFilters,
+ };
+}
diff --git a/Data/modules/token-variants/scripts/sprite/TVASprite.js b/Data/modules/token-variants/scripts/sprite/TVASprite.js
new file mode 100644
index 00000000..de9ccd89
--- /dev/null
+++ b/Data/modules/token-variants/scripts/sprite/TVASprite.js
@@ -0,0 +1,699 @@
+import { FILTERS } from '../../applications/overlayConfig.js';
+import { evaluateComparator, getTokenEffects } from '../hooks/effectMappingHooks.js';
+import {
+ registerOverlayRefreshHook,
+ unregisterOverlayRefreshHooks,
+} from '../hooks/overlayHooks.js';
+import { DEFAULT_OVERLAY_CONFIG } from '../models.js';
+import { interpolateColor, removeMarkedOverlays } from '../token/overlay.js';
+import { executeMacro, toggleCEEffect, toggleTMFXPreset, tv_executeScript } from '../utils.js';
+
+class OutlineFilter extends OutlineOverlayFilter {
+ /** @inheritdoc */
+ static createFragmentShader() {
+ return `
+ varying vec2 vTextureCoord;
+ varying vec2 vFilterCoord;
+ uniform sampler2D uSampler;
+
+ uniform vec2 thickness;
+ uniform vec4 outlineColor;
+ uniform vec4 filterClamp;
+ uniform float alphaThreshold;
+ uniform float time;
+ uniform bool knockout;
+ uniform bool wave;
+
+ ${this.CONSTANTS}
+ ${this.WAVE()}
+
+ void main(void) {
+ float dist = distance(vFilterCoord, vec2(0.5)) * 2.0;
+ vec4 ownColor = texture2D(uSampler, vTextureCoord);
+ vec4 wColor = wave ? outlineColor *
+ wcos(0.0, 1.0, dist * 75.0,
+ -time * 0.01 + 3.0 * dot(vec4(1.0), ownColor))
+ * 0.33 * (1.0 - dist) : vec4(0.0);
+ float texAlpha = smoothstep(alphaThreshold, 1.0, ownColor.a);
+ vec4 curColor;
+ float maxAlpha = 0.;
+ vec2 displaced;
+ for ( float angle = 0.0; angle <= TWOPI; angle += ${this.#quality.toFixed(7)} ) {
+ displaced.x = vTextureCoord.x + thickness.x * cos(angle);
+ displaced.y = vTextureCoord.y + thickness.y * sin(angle);
+ curColor = texture2D(uSampler, clamp(displaced, filterClamp.xy, filterClamp.zw));
+ curColor.a = clamp((curColor.a - 0.6) * 2.5, 0.0, 1.0);
+ maxAlpha = max(maxAlpha, curColor.a);
+ }
+ float resultAlpha = max(maxAlpha, texAlpha);
+ vec3 result = (ownColor.rgb + outlineColor.rgb * (1.0 - texAlpha)) * resultAlpha;
+ gl_FragColor = vec4((ownColor.rgb + outlineColor.rgb * (1. - ownColor.a)) * resultAlpha, resultAlpha);
+ }
+ `;
+ }
+
+ static get #quality() {
+ switch (canvas.performance.mode) {
+ case CONST.CANVAS_PERFORMANCE_MODES.LOW:
+ return (Math.PI * 2) / 10;
+ case CONST.CANVAS_PERFORMANCE_MODES.MED:
+ return (Math.PI * 2) / 20;
+ default:
+ return (Math.PI * 2) / 30;
+ }
+ }
+}
+
+export class TVASprite extends TokenMesh {
+ constructor(pTexture, token, config) {
+ super(token);
+ if (pTexture.shapes) pTexture.shapes = this.addChild(pTexture.shapes);
+ this.pseudoTexture = pTexture;
+ this.texture = pTexture.texture;
+ //this.setTexture(pTexture, { refresh: false });
+
+ this.ready = false;
+ this.overlaySort = 0;
+
+ this.overlayConfig = mergeObject(DEFAULT_OVERLAY_CONFIG, config, { inplace: false });
+
+ // linkDimensions has been converted to linkDimensionsX and linkDimensionsY
+ // Make sure we're using the latest fields
+ // 20/07/2023
+ if (!('linkDimensionsX' in this.overlayConfig) && this.overlayConfig.linkDimensions) {
+ this.overlayConfig.linkDimensionsX = true;
+ this.overlayConfig.linkDimensionsY = true;
+ }
+
+ this._registerHooks(this.overlayConfig);
+ this._tvaPlay().then(() => this.refresh());
+
+ // Workaround needed for v11 visible property
+ Object.defineProperty(this, 'visible', {
+ get: this._customVisible,
+ set: function () {},
+ configurable: true,
+ });
+
+ this.enableInteractivity(this.overlayConfig);
+ }
+
+ enableInteractivity() {
+ if (this.mouseInteractionManager && !this.overlayConfig.interactivity?.length) {
+ this.removeAllListeners();
+ this.mouseInteractionManager = null;
+ this.cursor = null;
+ return;
+ } else if (this.mouseInteractionManager || !this.overlayConfig.interactivity?.length) return;
+
+ if (canvas.primary.eventMode === 'none') {
+ canvas.primary.eventMode = 'passive';
+ }
+
+ this.eventMode = 'static';
+ this.cursor = 'pointer';
+ const token = this.object;
+ const sprite = this;
+
+ const runInteraction = function (event, listener) {
+ sprite.overlayConfig.interactivity.forEach((i) => {
+ if (i.listener === listener) {
+ event.preventDefault();
+ event.stopPropagation();
+ if (i.script) tv_executeScript(i.script, { token });
+ if (i.macro) executeMacro(i.macro, token);
+ if (i.ceEffect) toggleCEEffect(token, i.ceEffect);
+ if (i.tmfxPreset) toggleTMFXPreset(token, i.tmfxPreset);
+ }
+ });
+ };
+
+ const permissions = {
+ hoverIn: () => true,
+ hoverOut: () => true,
+ clickLeft: () => true,
+ clickLeft2: () => true,
+ clickRight: () => true,
+ clickRight2: () => true,
+ dragStart: () => false,
+ };
+
+ const callbacks = {
+ hoverIn: (event) => runInteraction(event, 'hoverIn'),
+ hoverOut: (event) => runInteraction(event, 'hoverOut'),
+ clickLeft: (event) => runInteraction(event, 'clickLeft'),
+ clickLeft2: (event) => runInteraction(event, 'clickLeft2'),
+ clickRight: (event) => runInteraction(event, 'clickRight'),
+ clickRight2: (event) => runInteraction(event, 'clickRight2'),
+ dragLeftStart: null,
+ dragLeftMove: null,
+ dragLeftDrop: null,
+ dragLeftCancel: null,
+ dragRightStart: null,
+ dragRightMove: null,
+ dragRightDrop: null,
+ dragRightCancel: null,
+ longPress: null,
+ };
+
+ const options = { target: null };
+
+ // Create the interaction manager
+ const mgr = new MouseInteractionManager(this, canvas.stage, permissions, callbacks, options);
+ this.mouseInteractionManager = mgr.activate();
+ }
+
+ _customVisible() {
+ const ov = this.overlayConfig;
+ if (!this.ready || !(this.object.visible || ov.alwaysVisible)) return false;
+
+ if (ov.limitedToOwner && !this.object.owner) return false;
+ if (ov.limitedUsers?.length && !ov.limitedUsers.includes(game.user.id)) return false;
+
+ if (ov.limitOnEffect || ov.limitOnProperty) {
+ const speaker = ChatMessage.getSpeaker();
+ let token = canvas.ready ? canvas.tokens.get(speaker.token) : null;
+ if (!token) return false;
+ if (ov.limitOnEffect) {
+ if (!getTokenEffects(token).includes(ov.limitOnEffect)) return false;
+ }
+ if (ov.limitOnProperty) {
+ if (!evaluateComparator(token.document, ov.limitOnProperty)) return false;
+ }
+ }
+
+ if (ov.limitOnHover || ov.limitOnControl || ov.limitOnHighlight) {
+ let visible = false;
+ if (
+ ov.limitOnHover &&
+ canvas.controls.ruler._state === Ruler.STATES.INACTIVE &&
+ this.object.hover
+ )
+ visible = true;
+ if (ov.limitOnControl && this.object.controlled) visible = true;
+ if (ov.limitOnHighlight && (canvas.tokens.highlightObjects ?? canvas.tokens._highlight))
+ visible = true;
+ return visible;
+ }
+ return true;
+ }
+
+ // Overlays have the same sort order as the parent
+ get sort() {
+ let sort = this.object.document.sort || 0;
+ if (this.overlayConfig.top) return sort + 1000;
+ else if (this.overlayConfig.bottom) return sort - 1000;
+ return sort;
+ }
+
+ get _lastSortedIndex() {
+ return (this.object.mesh._lastSortedIndex || 0) + this.overlaySort;
+ }
+
+ set _lastSortedIndex(val) {}
+
+ async _tvaPlay() {
+ // Ensure playback state for video
+ const source = foundry.utils.getProperty(this.texture, 'baseTexture.resource.source');
+ if (source && source.tagName === 'VIDEO') {
+ // Detach video from others
+ const s = source.cloneNode();
+
+ if (this.overlayConfig.playOnce) {
+ s.onended = () => {
+ this.alpha = 0;
+ this.tvaVideoEnded = true;
+ };
+ }
+
+ await new Promise((resolve) => (s.oncanplay = resolve));
+ this.texture = PIXI.Texture.from(s, { resourceOptions: { autoPlay: false } });
+
+ const options = {
+ loop: this.overlayConfig.loop && !this.overlayConfig.playOnce,
+ volume: 0,
+ offset: 0,
+ playing: true,
+ };
+ game.video.play(s, options);
+ }
+ }
+
+ addChildAuto(...children) {
+ if (this.pseudoTexture?.shapes) {
+ return this.pseudoTexture.shapes.addChild(...children);
+ } else {
+ return this.addChild(...children);
+ }
+ }
+
+ setTexture(pTexture, { preview = false, refresh = true, configuration = null } = {}) {
+ // Text preview handling
+ if (preview) {
+ this._swapChildren(pTexture);
+ if (this.originalTexture) this._destroyTexture();
+ else {
+ this.originalTexture = this.pseudoTexture;
+ if (this.originalTexture.shapes) this.removeChild(this.originalTexture.shapes);
+ }
+ this.pseudoTexture = pTexture;
+ this.texture = pTexture.texture;
+ if (pTexture.shapes) pTexture.shapes = this.addChild(pTexture.shapes);
+ } else if (this.originalTexture) {
+ this._swapChildren(this.originalTexture);
+ this._destroyTexture();
+ this.pseudoTexture = this.originalTexture;
+ this.texture = this.originalTexture.texture;
+ if (this.originalTexture.shapes)
+ this.pseudoTexture.shapes = this.addChild(this.originalTexture.shapes);
+ delete this.originalTexture;
+ } else {
+ this._swapChildren(pTexture);
+ this._destroyTexture();
+ this.pseudoTexture = pTexture;
+ this.texture = pTexture.texture;
+ if (pTexture.shapes) this.pseudoTexture.shapes = this.addChild(pTexture.shapes);
+ }
+
+ if (refresh) this.refresh(configuration, { fullRefresh: !preview });
+ }
+
+ refresh(configuration, { preview = false, fullRefresh = true, previewTexture = null } = {}) {
+ if (!this.overlayConfig || !this.texture) return;
+
+ // Text preview handling
+ if (previewTexture || this.originalTexture) {
+ this.setTexture(previewTexture, { preview: Boolean(previewTexture), refresh: false });
+ }
+
+ // Register/Unregister hooks that should refresh this overlay
+ if (configuration) {
+ this._registerHooks(configuration);
+ }
+
+ const config = mergeObject(this.overlayConfig, configuration ?? {}, { inplace: !preview });
+
+ this.enableInteractivity(config);
+
+ if (fullRefresh) {
+ const source = foundry.utils.getProperty(this.texture, 'baseTexture.resource.source');
+ if (source && source.tagName === 'VIDEO') {
+ if (!source.loop && config.loop) {
+ game.video.play(source);
+ } else if (source.loop && !config.loop) {
+ game.video.stop(source);
+ }
+ source.loop = config.loop;
+ }
+ }
+
+ const shapes = this.pseudoTexture.shapes;
+
+ // Scale the image using the same logic as the token
+ const dimensions = shapes ?? this.texture;
+ if (config.linkScale && !config.parentID) {
+ const scale = this.scale;
+ const aspect = dimensions.width / dimensions.height;
+ if (aspect >= 1) {
+ scale.x = (this.object.w * this.object.document.texture.scaleX) / dimensions.width;
+ scale.y = Number(scale.x);
+ } else {
+ scale.y = (this.object.h * this.object.document.texture.scaleY) / dimensions.height;
+ scale.x = Number(scale.y);
+ }
+ } else if (config.linkStageScale) {
+ this.scale.x = 1 / canvas.stage.scale.x;
+ this.scale.y = 1 / canvas.stage.scale.y;
+ } else if (config.linkDimensionsX || config.linkDimensionsY) {
+ if (config.linkDimensionsX) {
+ this.scale.x = this.object.document.width;
+ }
+ if (config.linkDimensionsY) {
+ this.scale.y = this.object.document.height;
+ }
+ } else {
+ this.scale.x = config.width ? config.width / dimensions.width : 1;
+ this.scale.y = config.height ? config.height / dimensions.height : 1;
+ }
+
+ // Adjust scale according to config
+ this.scale.x = this.scale.x * config.scaleX;
+ this.scale.y = this.scale.y * config.scaleY;
+
+ // Check if mirroring should be inherited from the token and if so apply it
+ if (config.linkMirror && !config.parentID) {
+ this.scale.x = Math.abs(this.scale.x) * (this.object.document.texture.scaleX < 0 ? -1 : 1);
+ this.scale.y = Math.abs(this.scale.y) * (this.object.document.texture.scaleY < 0 ? -1 : 1);
+ }
+
+ if (this.anchor) {
+ if (!config.anchor) this.anchor.set(0.5, 0.5);
+ else this.anchor.set(config.anchor.x, config.anchor.y);
+ }
+
+ let xOff = 0;
+ let yOff = 0;
+ if (shapes) {
+ shapes.position.x = -this.anchor.x * shapes.width;
+ shapes.position.y = -this.anchor.y * shapes.height;
+ if (config.animation.relative) {
+ this.pivot.set(0, 0);
+ shapes.pivot.set(
+ (0.5 - this.anchor.x) * shapes.width,
+ (0.5 - this.anchor.y) * shapes.height
+ );
+ xOff = shapes.pivot.x * this.scale.x;
+ yOff = shapes.pivot.y * this.scale.y;
+ }
+ } else if (config.animation.relative) {
+ xOff = (0.5 - this.anchor.x) * this.width;
+ yOff = (0.5 - this.anchor.y) * this.height;
+ this.pivot.set(
+ (0.5 - this.anchor.x) * this.texture.width,
+ (0.5 - this.anchor.y) * this.texture.height
+ );
+ }
+
+ // Position
+ if (config.parentID) {
+ const anchor = this.parent.anchor ?? { x: 0, y: 0 };
+ const pWidth = this.parent.width / this.parent.scale.x;
+ const pHeight = this.parent.height / this.parent.scale.y;
+ this.position.set(
+ -config.offsetX * pWidth - anchor.x * pWidth + pWidth / 2,
+ -config.offsetY * pHeight - anchor.y * pHeight + pHeight / 2
+ );
+ } else {
+ if (config.animation.relative) {
+ this.position.set(
+ this.object.document.x + this.object.w / 2 + -config.offsetX * this.object.w + xOff,
+ this.object.document.y + this.object.h / 2 + -config.offsetY * this.object.h + yOff
+ );
+ } else {
+ this.position.set(
+ this.object.document.x + this.object.w / 2,
+ this.object.document.y + this.object.h / 2
+ );
+ this.pivot.set(
+ (config.offsetX * this.object.w) / this.scale.x,
+ (config.offsetY * this.object.h) / this.scale.y
+ );
+ }
+ }
+
+ // Set alpha but only if playOnce is disabled and the video hasn't
+ // finished playing yet. Otherwise we want to keep alpha as 0 to keep the video hidden
+ if (!this.tvaVideoEnded) {
+ this.alpha = config.linkOpacity ? this.object.document.alpha : config.alpha;
+ }
+
+ // Angle in degrees
+ if (fullRefresh) {
+ if (config.linkRotation) this.angle = this.object.document.rotation + config.angle;
+ else this.angle = config.angle;
+ } else if (!config.animation.rotate) {
+ if (config.linkRotation) this.angle = this.object.document.rotation + config.angle;
+ }
+
+ // Apply color tinting
+ const tint = config.inheritTint
+ ? this.object.document.texture.tint
+ : interpolateColor(config.tint, config.interpolateColor, true);
+ this.tint = tint ? Color.from(tint) : 0xffffff;
+
+ if (fullRefresh) {
+ if (config.animation.rotate) {
+ this.animate(config);
+ } else {
+ this.stopAnimation();
+ }
+ }
+
+ // Apply filters
+ if (fullRefresh) this._applyFilters(config);
+ //if (fullRefresh) this.filters = this._getFilters(config);
+
+ if (preview && this.children) {
+ this.children.forEach((ch) => {
+ if (ch instanceof TVASprite) ch.refresh(null, { preview: true });
+ });
+ }
+
+ this.ready = true;
+ }
+
+ _activateTicker() {
+ this._deactivateTicker();
+ canvas.app.ticker.add(this.updatePosition, this, PIXI.UPDATE_PRIORITY.HIGH);
+ }
+
+ _deactivateTicker() {
+ canvas.app.ticker.remove(this.updatePosition, this);
+ }
+
+ updatePosition() {
+ let coord = canvas.canvasCoordinatesFromClient({
+ x: window.innerWidth / 2 + this.overlayConfig.offsetX * window.innerWidth,
+ y: window.innerHeight / 2 + this.overlayConfig.offsetY * window.innerHeight,
+ });
+ this.position.set(coord.x, coord.y);
+ }
+
+ async _applyFilters(config) {
+ const filterName = config.filter;
+ const FilterClass = PIXI.filters[filterName];
+ const options = mergeObject(FILTERS[filterName]?.defaultValues || {}, config.filterOptions);
+ let filter;
+ if (FilterClass) {
+ if (FILTERS[filterName]?.argType === 'args') {
+ let args = [];
+ const controls = FILTERS[filterName]?.controls;
+ if (controls) {
+ controls.forEach((c) => args.push(options[c.name]));
+ }
+ filter = new FilterClass(...args);
+ } else if (FILTERS[filterName]?.argType === 'options') {
+ filter = new FilterClass(options);
+ } else {
+ filter = new FilterClass();
+ }
+ } else if (filterName === 'OutlineOverlayFilter') {
+ filter = OutlineFilter.create(options);
+ filter.thickness = options.trueThickness ?? 1;
+ filter.animate = options.animate ?? false;
+ } else if (filterName === 'Token Magic FX') {
+ this.filters = await constructTMFXFilters(options.params || [], this);
+ return;
+ }
+
+ if (filter) {
+ this.filters = [filter];
+ } else {
+ this.filters = [];
+ }
+ }
+
+ async stopAnimation() {
+ if (this.animationName) {
+ CanvasAnimation.terminateAnimation(this.animationName);
+ }
+ }
+
+ async animate(config) {
+ if (!this.animationName) this.animationName = this.object.sourceId + '.' + randomID(5);
+
+ let newAngle = this.angle + (config.animation.clockwise ? 360 : -360);
+ const rotate = [{ parent: this, attribute: 'angle', to: newAngle }];
+
+ const completed = await CanvasAnimation.animate(rotate, {
+ duration: config.animation.duration,
+ name: this.animationName,
+ });
+ if (completed) {
+ this.animate(config);
+ }
+ }
+
+ _registerHooks(configuration) {
+ if (configuration.linkStageScale) registerOverlayRefreshHook(this, 'canvasPan');
+ else unregisterOverlayRefreshHooks(this, 'canvasPan');
+ }
+
+ _swapChildren(to) {
+ const from = this.pseudoTexture;
+ if (from.shapes) {
+ this.removeChild(this.pseudoTexture.shapes);
+ const children = from.shapes.removeChildren();
+ if (to?.shapes) children.forEach((c) => to.shapes.addChild(c)?.refresh());
+ else children.forEach((c) => this.addChild(c)?.refresh());
+ } else if (to?.shapes) {
+ const children = this.removeChildren();
+ children.forEach((c) => to.shapes.addChild(c)?.refresh());
+ }
+ }
+
+ _destroyTexture() {
+ if (this.texture.textLabel || this.texture.destroyable) {
+ this.texture.destroy(true);
+ }
+ if (this.pseudoTexture?.shapes) {
+ this.removeChild(this.pseudoTexture.shapes);
+ this.pseudoTexture.shapes.destroy();
+ }
+ }
+
+ destroy() {
+ this.stopAnimation();
+ unregisterOverlayRefreshHooks(this);
+
+ if (this.children) {
+ for (const ch of this.children) {
+ if (ch instanceof TVASprite) ch.tvaRemove = true;
+ }
+ removeMarkedOverlays(this.object);
+ if (this.pseudoTexture.shapes) {
+ this.pseudoTexture.shapes.children.forEach((c) => c.destroy());
+ this.removeChild(this.pseudoTexture.shapes)?.destroy();
+ // this.pseudoTexture.shapes.destroy();
+ }
+ }
+
+ if (this.texture.textLabel || this.texture.destroyable) {
+ return super.destroy(true);
+ } else if (this.texture?.baseTexture.resource?.source?.tagName === 'VIDEO') {
+ this.texture.baseTexture.destroy();
+ }
+ super.destroy();
+ }
+
+ // Foundry BUG Fix
+ calculateTrimmedVertices() {
+ return PIXI.Sprite.prototype.calculateTrimmedVertices.call(this);
+ }
+}
+
+async function constructTMFXFilters(paramsArray, sprite) {
+ if (typeof TokenMagic === 'undefined') return [];
+
+ try {
+ paramsArray = eval(paramsArray);
+ } catch (e) {
+ return [];
+ }
+
+ if (!Array.isArray(paramsArray)) {
+ paramsArray = TokenMagic.getPreset(paramsArray);
+ }
+ if (!(paramsArray instanceof Array && paramsArray.length > 0)) return [];
+
+ let filters = [];
+ for (const params of paramsArray) {
+ if (
+ !params.hasOwnProperty('filterType') ||
+ !TMFXFilterTypes.hasOwnProperty(params.filterType)
+ ) {
+ // one invalid ? all rejected.
+ return [];
+ }
+
+ if (!params.hasOwnProperty('rank')) {
+ params.rank = 5000;
+ }
+
+ if (!params.hasOwnProperty('filterId') || params.filterId == null) {
+ params.filterId = randomID();
+ }
+
+ if (!params.hasOwnProperty('enabled') || !(typeof params.enabled === 'boolean')) {
+ params.enabled = true;
+ }
+
+ params.filterInternalId = randomID();
+
+ const gms = game.users.filter((user) => user.isGM);
+ params.filterOwner = gms.length ? gms[0].id : game.data.userId;
+ // params.placeableType = placeable._TMFXgetPlaceableType();
+ params.updateId = randomID();
+
+ const filterClass = await getTMFXFilter(params.filterType);
+ if (filterClass) {
+ filterClass.prototype.assignPlaceable = function () {
+ this.targetPlaceable = sprite.object;
+ this.placeableImg = sprite;
+ };
+
+ filterClass.prototype._TMFXsetAnimeFlag = async function () {};
+
+ const filter = new filterClass(params);
+ if (filter) {
+ // Patch fixes
+ filter.placeableImg = sprite;
+ filter.targetPlaceable = sprite.object;
+ // end of fixes
+ filters.unshift(filter);
+ }
+ }
+ }
+ return filters;
+}
+
+async function getTMFXFilter(id) {
+ if (id in TMFXFilterTypes) {
+ if (id in LOADED_TMFXFilters) return LOADED_TMFXFilters[id];
+ else {
+ try {
+ const className = TMFXFilterTypes[id];
+ let fxModule = await import(`../../../tokenmagic/fx/filters/${className}.js`);
+ if (fxModule && fxModule[className]) {
+ LOADED_TMFXFilters[id] = fxModule[className];
+ return fxModule[className];
+ }
+ } catch (e) {}
+ }
+ }
+ return null;
+}
+
+const LOADED_TMFXFilters = {};
+
+const TMFXFilterTypes = {
+ adjustment: 'FilterAdjustment',
+ distortion: 'FilterDistortion',
+ oldfilm: 'FilterOldFilm',
+ glow: 'FilterGlow',
+ outline: 'FilterOutline',
+ bevel: 'FilterBevel',
+ xbloom: 'FilterXBloom',
+ shadow: 'FilterDropShadow',
+ twist: 'FilterTwist',
+ zoomblur: 'FilterZoomBlur',
+ blur: 'FilterBlur',
+ bulgepinch: 'FilterBulgePinch',
+ zapshadow: 'FilterRemoveShadow',
+ ray: 'FilterRays',
+ fog: 'FilterFog',
+ xfog: 'FilterXFog',
+ electric: 'FilterElectric',
+ wave: 'FilterWaves',
+ shockwave: 'FilterShockwave',
+ fire: 'FilterFire',
+ fumes: 'FilterFumes',
+ smoke: 'FilterSmoke',
+ flood: 'FilterFlood',
+ images: 'FilterMirrorImages',
+ field: 'FilterForceField',
+ xray: 'FilterXRays',
+ liquid: 'FilterLiquid',
+ xglow: 'FilterGleamingGlow',
+ pixel: 'FilterPixelate',
+ web: 'FilterSpiderWeb',
+ ripples: 'FilterSolarRipples',
+ globes: 'FilterGlobes',
+ transform: 'FilterTransform',
+ splash: 'FilterSplash',
+ polymorph: 'FilterPolymorph',
+ xfire: 'FilterXFire',
+ sprite: 'FilterSprite',
+ replaceColor: 'FilterReplaceColor',
+ ddTint: 'FilterDDTint',
+};
diff --git a/Data/modules/token-variants/scripts/token/overlay.js b/Data/modules/token-variants/scripts/token/overlay.js
new file mode 100644
index 00000000..37341950
--- /dev/null
+++ b/Data/modules/token-variants/scripts/token/overlay.js
@@ -0,0 +1,571 @@
+import { TVA_CONFIG } from '../settings.js';
+import { TVASprite } from '../sprite/TVASprite.js';
+import { string2Hex, waitForTokenTexture } from '../utils.js';
+import { getAllEffectMappings, getTokenEffects, getTokenHP } from '../hooks/effectMappingHooks.js';
+
+export const FONT_LOADING = {};
+
+export async function drawOverlays(token) {
+ if (token.tva_drawing_overlays) return;
+ token.tva_drawing_overlays = true;
+
+ const mappings = getAllEffectMappings(token);
+ const effects = getTokenEffects(token, true);
+ let processedMappings = mappings
+ .filter((m) => m.overlay && effects.includes(m.id))
+ .sort(
+ (m1, m2) =>
+ (m1.priority - m1.overlayConfig?.parentID ? 0 : 999) -
+ (m2.priority - m2.overlayConfig?.parentID ? 0 : 999)
+ );
+
+ // See if the whole stack or just top of the stack should be used according to settings
+ if (processedMappings.length) {
+ processedMappings = TVA_CONFIG.stackStatusConfig
+ ? processedMappings
+ : [processedMappings[processedMappings.length - 1]];
+ }
+
+ // Process strings as expressions
+ const overlays = processedMappings.map((m) =>
+ evaluateOverlayExpressions(deepClone(m.overlayConfig), token, m)
+ );
+
+ if (overlays.length) {
+ waitForTokenTexture(token, async (token) => {
+ if (!token.tva_sprites) token.tva_sprites = [];
+ // Temporarily mark every overlay for removal.
+ // We'll only keep overlays that are still applicable to the token
+ _markAllOverlaysForRemoval(token);
+
+ // To keep track of the overlay order
+ let overlaySort = 0;
+ let underlaySort = 0;
+ for (const ov of overlays) {
+ let sprite = _findTVASprite(ov.id, token);
+ if (sprite) {
+ const diff = diffObject(sprite.overlayConfig, ov);
+
+ // Check if we need to create a new texture or simply refresh the overlay
+ if (!isEmpty(diff)) {
+ if (ov.img?.includes('*') || (ov.img?.includes('{') && ov.img?.includes('}'))) {
+ sprite.refresh(ov);
+ } else if (diff.img || diff.text || diff.shapes || diff.repeat) {
+ sprite.setTexture(await genTexture(token, ov), { configuration: ov });
+ } else if (diff.parentID) {
+ sprite.parent?.removeChild(sprite)?.destroy();
+ sprite = null;
+ } else {
+ sprite.refresh(ov);
+ }
+ } else if (diff.text?.text || diff.shapes) {
+ sprite.setTexture(await genTexture(token, ov), { configuration: ov });
+ }
+ }
+ if (!sprite) {
+ if (ov.parentID) {
+ const parent = _findTVASprite(ov.parentID, token);
+ if (parent && !parent.tvaRemove)
+ sprite = parent.addChildAuto(new TVASprite(await genTexture(token, ov), token, ov));
+ } else {
+ sprite = canvas.primary.addChild(new TVASprite(await genTexture(token, ov), token, ov));
+ }
+ if (sprite) token.tva_sprites.push(sprite);
+ }
+
+ // If the sprite has a parent confirm that the parent has not been removed
+ if (sprite?.overlayConfig.parentID) {
+ const parent = _findTVASprite(sprite.overlayConfig.parentID, token);
+ if (!parent || parent.tvaRemove) sprite = null;
+ }
+
+ if (sprite) {
+ sprite.tvaRemove = false; // Sprite in use, do not remove
+
+ // Assign order to the overlay
+ if (sprite.overlayConfig.underlay) {
+ underlaySort -= 0.01;
+ sprite.overlaySort = underlaySort;
+ } else {
+ overlaySort += 0.01;
+ sprite.overlaySort = overlaySort;
+ }
+ }
+ }
+
+ removeMarkedOverlays(token);
+ token.tva_drawing_overlays = false;
+ });
+ } else {
+ _removeAllOverlays(token);
+ token.tva_drawing_overlays = false;
+ }
+}
+
+export async function genTexture(token, conf) {
+ if (conf.img?.trim()) {
+ return await generateImage(token, conf);
+ } else if (conf.text?.text != null) {
+ return await generateTextTexture(token, conf);
+ } else if (conf.shapes?.length) {
+ return await generateShapeTexture(token, conf);
+ } else {
+ return {
+ texture: await loadTexture('modules/token-variants/img/token-images.svg'),
+ };
+ }
+}
+
+async function generateImage(token, conf) {
+ let img = conf.img;
+ if (conf.img.includes('*') || (conf.img.includes('{') && conf.img.includes('}'))) {
+ const images = await wildcardImageSearch(conf.img);
+ if (images.length) {
+ if (images.length) {
+ img = images[Math.floor(Math.random() * images.length)];
+ }
+ }
+ }
+
+ let texture = await loadTexture(img, {
+ fallback: 'modules/token-variants/img/token-images.svg',
+ });
+
+ // Repeat image if needed
+ // Repeating the shape if necessary
+ if (conf.repeating && conf.repeat) {
+ const repeat = conf.repeat;
+ let numRepeats;
+ if (repeat.isPercentage) {
+ numRepeats = Math.ceil(repeat.value / repeat.maxValue / (repeat.increment / 100));
+ } else {
+ numRepeats = Math.ceil(repeat.value / repeat.increment);
+ }
+ let n = 0;
+ let rows = 0;
+ const maxRows = repeat.maxRows ?? Infinity;
+ let xOffset = 0;
+ let yOffset = 0;
+ const paddingX = repeat.paddingX ?? 0;
+ const paddingY = repeat.paddingY ?? 0;
+ let container = new PIXI.Container();
+ while (numRepeats > 0) {
+ let img = new PIXI.Sprite(texture);
+ img.x = xOffset;
+ img.y = yOffset;
+ container.addChild(img);
+ xOffset += texture.width + paddingX;
+ numRepeats--;
+ n++;
+ if (numRepeats != 0 && n >= repeat.perRow) {
+ rows += 1;
+ if (rows >= maxRows) break;
+ yOffset += texture.height + paddingY;
+ xOffset = 0;
+ n = 0;
+ }
+ }
+
+ texture = _renderContainer(container, texture.resolution);
+ }
+
+ return { texture };
+}
+
+function _renderContainer(container, resolution, { width = null, height = null } = {}) {
+ const bounds = container.getLocalBounds();
+ const matrix = new PIXI.Matrix();
+ matrix.tx = -bounds.x;
+ matrix.ty = -bounds.y;
+
+ const renderTexture = PIXI.RenderTexture.create({
+ width: width ?? bounds.width,
+ height: height ?? bounds.height,
+ resolution: resolution,
+ });
+
+ if (isNewerVersion('11', game.version)) {
+ canvas.app.renderer.render(container, renderTexture, true, matrix, false);
+ } else {
+ canvas.app.renderer.render(container, {
+ renderTexture,
+ clear: true,
+ transform: matrix,
+ skipUpdateTransform: false,
+ });
+ }
+ renderTexture.destroyable = true;
+ return renderTexture;
+}
+
+// Return width and height of the drawn shape
+function _drawShape(graphics, shape, xOffset = 0, yOffset = 0) {
+ if (shape.type === 'rectangle') {
+ graphics.drawRoundedRect(
+ shape.x + xOffset,
+ shape.y + yOffset,
+ shape.width,
+ shape.height,
+ shape.radius
+ );
+ return [shape.width, shape.height];
+ } else if (shape.type === 'ellipse') {
+ graphics.drawEllipse(
+ shape.x + xOffset + shape.width,
+ shape.y + yOffset + shape.height,
+ shape.width,
+ shape.height
+ );
+ return [shape.width * 2, shape.height * 2];
+ } else if (shape.type === 'polygon') {
+ graphics.drawPolygon(
+ shape.points
+ .split(',')
+ .map((p, i) => Number(p) * shape.scale + (i % 2 === 0 ? shape.x : shape.y))
+ );
+ } else if (shape.type === 'torus') {
+ drawTorus(
+ graphics,
+ shape.x + xOffset + shape.outerRadius,
+ shape.y + yOffset + shape.outerRadius,
+ shape.innerRadius,
+ shape.outerRadius,
+ Math.toRadians(shape.startAngle),
+ shape.endAngle >= 360 ? Math.PI * 2 : Math.toRadians(shape.endAngle)
+ );
+ return [shape.outerRadius * 2, shape.outerRadius * 2];
+ }
+}
+
+export async function generateShapeTexture(token, conf) {
+ let graphics = new PIXI.Graphics();
+
+ for (const obj of conf.shapes) {
+ graphics.beginFill(interpolateColor(obj.fill.color, obj.fill.interpolateColor), obj.fill.alpha);
+ graphics.lineStyle(obj.line.width, string2Hex(obj.line.color), obj.line.alpha);
+
+ const shape = obj.shape;
+
+ // Repeating the shape if necessary
+ if (obj.repeating && obj.repeat) {
+ const repeat = obj.repeat;
+ let numRepeats;
+ if (repeat.isPercentage) {
+ numRepeats = Math.ceil(repeat.value / repeat.maxValue / (repeat.increment / 100));
+ } else {
+ numRepeats = Math.ceil(repeat.value / repeat.increment);
+ }
+ let n = 0;
+ let rows = 0;
+ const maxRows = repeat.maxRows ?? Infinity;
+ let xOffset = 0;
+ let yOffset = 0;
+ const paddingX = repeat.paddingX ?? 0;
+ const paddingY = repeat.paddingY ?? 0;
+ while (numRepeats > 0) {
+ const [width, height] = _drawShape(graphics, shape, xOffset, yOffset);
+ xOffset += width + paddingX;
+ numRepeats--;
+ n++;
+ if (numRepeats != 0 && n >= repeat.perRow) {
+ rows += 1;
+ if (rows >= maxRows) break;
+ yOffset += height + paddingY;
+ xOffset = 0;
+ n = 0;
+ }
+ }
+ } else {
+ _drawShape(graphics, shape);
+ }
+ }
+
+ // Store original graphics dimensions as these may change when children are added
+ graphics.shapesWidth = Number(graphics.width);
+ graphics.shapesHeight = Number(graphics.height);
+
+ return { texture: PIXI.Texture.EMPTY, shapes: graphics };
+}
+
+function drawTorus(graphics, x, y, innerRadius, outerRadius, startArc = 0, endArc = Math.PI * 2) {
+ if (Math.abs(endArc - startArc) >= Math.PI * 2) {
+ return graphics
+ .drawCircle(x, y, outerRadius)
+ .beginHole()
+ .drawCircle(x, y, innerRadius)
+ .endHole();
+ }
+
+ graphics.finishPoly();
+ graphics
+ .arc(x, y, innerRadius, endArc, startArc, true)
+ .arc(x, y, outerRadius, startArc, endArc, false)
+ .finishPoly();
+}
+
+export function interpolateColor(minColor, interpolate, rString = false) {
+ if (!interpolate || !interpolate.color2 || !interpolate.prc)
+ return rString ? minColor : string2Hex(minColor);
+
+ if (!PIXI.Color) return _interpolateV10(minColor, interpolate, rString);
+
+ const percentage = interpolate.prc;
+ minColor = new PIXI.Color(minColor);
+ const maxColor = new PIXI.Color(interpolate.color2);
+
+ let minHsv = rgb2hsv(minColor.red, minColor.green, minColor.blue);
+ let maxHsv = rgb2hsv(maxColor.red, maxColor.green, maxColor.blue);
+
+ let deltaHue = maxHsv[0] - minHsv[0];
+ let deltaAngle = deltaHue + (Math.abs(deltaHue) > 180 ? (deltaHue < 0 ? 360 : -360) : 0);
+
+ let targetHue = minHsv[0] + deltaAngle * percentage;
+ let targetSaturation = (1 - percentage) * minHsv[1] + percentage * maxHsv[1];
+ let targetValue = (1 - percentage) * minHsv[2] + percentage * maxHsv[2];
+
+ let result = new PIXI.Color({ h: targetHue, s: targetSaturation * 100, v: targetValue * 100 });
+ return rString ? result.toHex() : result.toNumber();
+}
+
+function _interpolateV10(minColor, interpolate, rString = false) {
+ const percentage = interpolate.prc;
+ minColor = PIXI.utils.hex2rgb(string2Hex(minColor));
+ const maxColor = PIXI.utils.hex2rgb(string2Hex(interpolate.color2));
+
+ let minHsv = rgb2hsv(minColor[0], minColor[1], minColor[2]);
+ let maxHsv = rgb2hsv(maxColor[0], maxColor[1], maxColor[2]);
+
+ let deltaHue = maxHsv[0] - minHsv[0];
+ let deltaAngle = deltaHue + (Math.abs(deltaHue) > 180 ? (deltaHue < 0 ? 360 : -360) : 0);
+
+ let targetHue = minHsv[0] + deltaAngle * percentage;
+ let targetSaturation = (1 - percentage) * minHsv[1] + percentage * maxHsv[1];
+ let targetValue = (1 - percentage) * minHsv[2] + percentage * maxHsv[2];
+
+ let result = Color.fromHSV([targetHue / 360, targetSaturation, targetValue]);
+ return rString ? result.toString() : Number(result);
+}
+
+/**
+ * Converts a color from RGB to HSV space.
+ * Source: https://stackoverflow.com/questions/8022885/rgb-to-hsv-color-in-javascript/54070620#54070620
+ */
+function rgb2hsv(r, g, b) {
+ let v = Math.max(r, g, b),
+ c = v - Math.min(r, g, b);
+ let h = c && (v == r ? (g - b) / c : v == g ? 2 + (b - r) / c : 4 + (r - g) / c);
+ return [60 * (h < 0 ? h + 6 : h), v && c / v, v];
+}
+
+const CORE_VARIABLES = {
+ '@hp': (token) => getTokenHP(token)?.[0],
+ '@hpMax': (token) => getTokenHP(token)?.[1],
+ '@gridSize': () => canvas.grid?.size,
+ '@label': (_, conf) => conf.label,
+};
+
+function _evaluateString(str, token, conf) {
+ let variables = conf.overlayConfig?.variables;
+ const re2 = new RegExp('@\\w+', 'gi');
+ str = str.replace(re2, function replace(match) {
+ let name = match.substr(1, match.length);
+ let v = variables?.find((v) => v.name === name);
+ if (v) return v.value;
+ else if (match in CORE_VARIABLES) return CORE_VARIABLES[match](token, conf);
+ return match;
+ });
+
+ const re = new RegExp('{{.*?}}', 'gi');
+ str = str
+ .replace(re, function replace(match) {
+ const property = match.substring(2, match.length - 2);
+ if (conf && property === 'effect') {
+ return conf.expression;
+ }
+ if (token && property === 'hp') return getTokenHP(token)?.[0];
+ else if (token && property === 'hpMax') return getTokenHP(token)?.[1];
+ const val = getProperty(token.document ?? token, property);
+ return val === undefined ? match : val;
+ })
+ .replace('\\n', '\n');
+
+ return str;
+}
+
+function _executeString(evalString, token) {
+ try {
+ const actor = token.actor; // So that actor is easily accessible within eval() scope
+ const result = eval(evalString);
+ if (getType(result) === 'Object') evalString;
+ return result;
+ } catch (e) {}
+ return evalString;
+}
+
+export function evaluateOverlayExpressions(obj, token, conf) {
+ for (const [k, v] of Object.entries(obj)) {
+ if (
+ !['label', 'interactivity', 'variables', 'id', 'parentID', 'limitedUsers', 'filter'].includes(
+ k
+ )
+ ) {
+ obj[k] = _evaluateObjExpressions(v, token, conf);
+ }
+ }
+ return obj;
+}
+
+// Evaluate provided object values substituting in {{path.to.property}} with token properties, and performing eval() on strings
+function _evaluateObjExpressions(obj, token, conf) {
+ const t = getType(obj);
+ if (t === 'string') {
+ const str = _evaluateString(obj, token, conf);
+ return _executeString(str, token);
+ } else if (t === 'Array') {
+ for (let i = 0; i < obj.length; i++) {
+ obj[i] = _evaluateObjExpressions(obj[i], token, conf);
+ }
+ } else if (t === 'Object') {
+ for (const [k, v] of Object.entries(obj)) {
+ // Exception for text overlay
+ if (k === 'text' && getType(v) === 'string' && v) {
+ const evalString = _evaluateString(v, token, conf);
+ const result = _executeString(evalString, token);
+ if (getType(result) !== 'string') obj[k] = evalString;
+ else obj[k] = result;
+ } else obj[k] = _evaluateObjExpressions(v, token, conf);
+ }
+ }
+ return obj;
+}
+
+export async function generateTextTexture(token, conf) {
+ await FONT_LOADING.loading;
+ let label = conf.text.text;
+
+ // Repeating the string if necessary
+ if (conf.text.repeating && conf.text.repeat) {
+ let tmp = '';
+ const repeat = conf.text.repeat;
+ let numRepeats;
+ if (repeat.isPercentage) {
+ numRepeats = Math.ceil(repeat.value / repeat.maxValue / (repeat.increment / 100));
+ } else {
+ numRepeats = Math.ceil(repeat.value / repeat.increment);
+ }
+ let n = 0;
+ let rows = 0;
+ let maxRows = repeat.maxRows ?? Infinity;
+ while (numRepeats > 0) {
+ tmp += label;
+ numRepeats--;
+ n++;
+ if (numRepeats != 0 && n >= repeat.perRow) {
+ rows += 1;
+ if (rows >= maxRows) break;
+ tmp += '\n';
+ n = 0;
+ }
+ }
+ label = tmp;
+ }
+
+ let style = PreciseText.getTextStyle({
+ ...conf.text,
+ fontFamily: [conf.text.fontFamily, 'fontAwesome'].join(','),
+ fill: interpolateColor(conf.text.fill, conf.text.interpolateColor, true),
+ });
+ const text = new PreciseText(label, style);
+ text.updateText(false);
+
+ const texture = text.texture;
+ const height = conf.text.maxHeight ? Math.min(texture.height, conf.text.maxHeight) : null;
+ const curve = conf.text.curve;
+
+ if (!height && !curve?.radius && !curve?.angle) {
+ texture.textLabel = label;
+ return { texture };
+ }
+
+ const container = new PIXI.Container();
+
+ if (curve?.radius || curve?.angle) {
+ // Curve the text
+ const letterSpacing = conf.text.letterSpacing ?? 0;
+ const radius = curve.angle
+ ? (texture.width + letterSpacing) / (Math.PI * 2) / (curve.angle / 360)
+ : curve.radius;
+ const maxRopePoints = 100;
+ const step = Math.PI / maxRopePoints;
+
+ let ropePoints =
+ maxRopePoints - Math.round((texture.width / (radius * Math.PI)) * maxRopePoints);
+ ropePoints /= 2;
+
+ const points = [];
+ for (let i = maxRopePoints - ropePoints; i > ropePoints; i--) {
+ const x = radius * Math.cos(step * i);
+ const y = radius * Math.sin(step * i);
+ points.push(new PIXI.Point(x, curve.invert ? y : -y));
+ }
+ const rope = new PIXI.SimpleRope(texture, points);
+ container.addChild(rope);
+ } else {
+ container.addChild(new PIXI.Sprite(texture));
+ }
+
+ const renderTexture = _renderContainer(container, 2, { height });
+ text.destroy();
+
+ renderTexture.textLabel = label;
+ return { texture: renderTexture };
+}
+
+function _markAllOverlaysForRemoval(token) {
+ for (const child of token.tva_sprites) {
+ if (child instanceof TVASprite) {
+ child.tvaRemove = true;
+ }
+ }
+}
+
+export function removeMarkedOverlays(token) {
+ const sprites = [];
+ for (const child of token.tva_sprites) {
+ if (child.tvaRemove) {
+ child.parent?.removeChild(child)?.destroy();
+ } else {
+ sprites.push(child);
+ }
+ }
+ token.tva_sprites = sprites;
+}
+
+function _findTVASprite(id, token) {
+ for (const child of token.tva_sprites) {
+ if (child.overlayConfig?.id === id) {
+ return child;
+ }
+ }
+ return null;
+}
+
+function _removeAllOverlays(token) {
+ if (token.tva_sprites)
+ for (const child of token.tva_sprites) {
+ child.parent?.removeChild(child)?.destroy();
+ }
+ token.tva_sprites = null;
+}
+
+export function broadcastOverlayRedraw(token) {
+ // Need to broadcast to other users to re-draw the overlay
+ if (token) drawOverlays(token);
+ const actorId = token.document?.actorLink ? token.actor?.id : null;
+ const message = {
+ handlerName: 'drawOverlays',
+ args: { tokenId: token.id, actorId },
+ type: 'UPDATE',
+ };
+ game.socket?.emit('module.token-variants', message);
+}
diff --git a/Data/modules/token-variants/scripts/utils.js b/Data/modules/token-variants/scripts/utils.js
new file mode 100644
index 00000000..2a350b35
--- /dev/null
+++ b/Data/modules/token-variants/scripts/utils.js
@@ -0,0 +1,1088 @@
+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);
+}
diff --git a/Data/modules/token-variants/scripts/wrappers/effectIconWrappers.js b/Data/modules/token-variants/scripts/wrappers/effectIconWrappers.js
new file mode 100644
index 00000000..580e89e9
--- /dev/null
+++ b/Data/modules/token-variants/scripts/wrappers/effectIconWrappers.js
@@ -0,0 +1,147 @@
+import { getAllEffectMappings } from '../hooks/effectMappingHooks.js';
+import { FEATURE_CONTROL, TVA_CONFIG } from '../settings.js';
+import { registerWrapper, unregisterWrapper } from './wrappers.js';
+
+const feature_id = 'EffectIcons';
+
+export function registerEffectIconWrappers() {
+ unregisterWrapper(feature_id, 'Token.prototype.drawEffects');
+ unregisterWrapper(feature_id, 'CombatTracker.prototype.getData');
+ if (!FEATURE_CONTROL[feature_id]) return;
+
+ if (
+ !TVA_CONFIG.disableEffectIcons &&
+ TVA_CONFIG.filterEffectIcons &&
+ !['pf1e', 'pf2e'].includes(game.system.id)
+ ) {
+ registerWrapper(feature_id, 'Token.prototype.drawEffects', _drawEffects, 'OVERRIDE');
+ } else if (TVA_CONFIG.disableEffectIcons) {
+ registerWrapper(
+ feature_id,
+ 'Token.prototype.drawEffects',
+ _drawEffects_fullReplace,
+ 'OVERRIDE'
+ );
+ } else if (TVA_CONFIG.displayEffectIconsOnHover) {
+ registerWrapper(feature_id, 'Token.prototype.drawEffects', _drawEffects_hoverOnly, 'WRAPPER');
+ }
+
+ if (TVA_CONFIG.disableEffectIcons || TVA_CONFIG.filterCustomEffectIcons) {
+ registerWrapper(
+ feature_id,
+ 'CombatTracker.prototype.getData',
+ _combatTrackerGetData,
+ 'WRAPPER'
+ );
+ }
+}
+
+async function _drawEffects_hoverOnly(wrapped, ...args) {
+ let result = await wrapped(...args);
+ this.effects.visible = this.hover;
+ return result;
+}
+
+async function _drawEffects_fullReplace(...args) {
+ this.effects.removeChildren().forEach((c) => c.destroy());
+ this.effects.bg = this.effects.addChild(new PIXI.Graphics());
+ this.effects.overlay = null;
+}
+
+async function _combatTrackerGetData(wrapped, ...args) {
+ let data = await wrapped(...args);
+
+ if (data && data.combat && data.turns) {
+ const combat = data.combat;
+ for (const turn of data.turns) {
+ const combatant = combat.combatants.find((c) => c.id === turn.id);
+ if (combatant) {
+ if (TVA_CONFIG.disableEffectIcons) {
+ turn.effects = new Set();
+ } else if (TVA_CONFIG.filterEffectIcons) {
+ const restrictedEffects = _getRestrictedEffects(combatant.token);
+
+ // Copied from CombatTracker.getData(...)
+ turn.effects = new Set();
+ if (combatant.token) {
+ combatant.token.effects.forEach((e) => turn.effects.add(e));
+ if (combatant.token.overlayEffect) turn.effects.add(combatant.token.overlayEffect);
+ }
+
+ // modified to filter restricted effects
+ if (combatant.actor) {
+ for (const effect of combatant.actor.temporaryEffects) {
+ if (effect.statuses.has(CONFIG.specialStatusEffects.DEFEATED)) {
+ } else if (effect.icon && !restrictedEffects.includes(effect.name ?? effect.label))
+ turn.effects.add(effect.icon);
+ }
+ }
+ // end of copy
+ }
+ }
+ }
+ }
+ return data;
+}
+
+async function _drawEffects(...args) {
+ this.effects.renderable = false;
+ this.effects.removeChildren().forEach((c) => c.destroy());
+ this.effects.bg = this.effects.addChild(new PIXI.Graphics());
+ this.effects.overlay = null;
+
+ // Categorize new effects
+ let tokenEffects = this.document.effects;
+ let actorEffects = this.actor?.temporaryEffects || [];
+ let overlay = {
+ src: this.document.overlayEffect,
+ tint: null,
+ };
+
+ // Modified from the original token.drawEffects
+ if (TVA_CONFIG.displayEffectIconsOnHover) this.effects.visible = this.hover;
+ if (tokenEffects.length || actorEffects.length) {
+ const restrictedEffects = _getRestrictedEffects(this.document);
+ actorEffects = actorEffects.filter((ef) => !restrictedEffects.includes(ef.name ?? ef.label));
+ tokenEffects = tokenEffects.filter(
+ // check if it's a string here
+ // for tokens without representing actors effects are just stored as paths to icons
+ (ef) => typeof ef === 'string' || !restrictedEffects.includes(ef.name ?? ef.label)
+ );
+ }
+ // End of modifications
+
+ // Draw status effects
+ if (tokenEffects.length || actorEffects.length) {
+ const promises = [];
+
+ // Draw actor effects first
+ for (let f of actorEffects) {
+ if (!f.icon) continue;
+ const tint = Color.from(f.tint ?? null);
+ if (f.getFlag('core', 'overlay')) {
+ overlay = { src: f.icon, tint };
+ continue;
+ }
+ promises.push(this._drawEffect(f.icon, tint));
+ }
+
+ // Next draw token effects
+ for (let f of tokenEffects) promises.push(this._drawEffect(f, null));
+ await Promise.all(promises);
+ }
+
+ // Draw overlay effect
+ this.effects.overlay = await this._drawOverlay(overlay.src, overlay.tint);
+ this._refreshEffects();
+ this.effects.renderable = true;
+}
+
+function _getRestrictedEffects(tokenDoc) {
+ let restrictedEffects = TVA_CONFIG.filterIconList;
+ if (TVA_CONFIG.filterCustomEffectIcons) {
+ const mappings = getAllEffectMappings(tokenDoc);
+ if (mappings) restrictedEffects = restrictedEffects.concat(mappings.map((m) => m.expression));
+ }
+ return restrictedEffects;
+}
diff --git a/Data/modules/token-variants/scripts/wrappers/hudWrappers.js b/Data/modules/token-variants/scripts/wrappers/hudWrappers.js
new file mode 100644
index 00000000..5c4897e3
--- /dev/null
+++ b/Data/modules/token-variants/scripts/wrappers/hudWrappers.js
@@ -0,0 +1,31 @@
+import { TOKEN_HUD_VARIANTS } from '../../applications/tokenHUD.js';
+import { FEATURE_CONTROL, TVA_CONFIG } from '../settings.js';
+import { registerWrapper, unregisterWrapper } from './wrappers.js';
+
+const feature_id = 'HUD';
+
+export function registerHUDWrappers() {
+ unregisterWrapper(feature_id, 'TokenHUD.prototype.clear');
+ if (FEATURE_CONTROL[feature_id]) {
+ registerWrapper(feature_id, 'TokenHUD.prototype.clear', _clear, 'WRAPPER');
+ }
+}
+
+function _clear(wrapped, ...args) {
+ let result = wrapped(...args);
+ _applyVariantFlags();
+ return result;
+}
+
+async function _applyVariantFlags() {
+ const { actor, variants } = TOKEN_HUD_VARIANTS;
+ if (actor) {
+ if (!variants?.length) {
+ actor.unsetFlag('token-variants', 'variants');
+ } else {
+ actor.setFlag('token-variants', 'variants', variants);
+ }
+ }
+ TOKEN_HUD_VARIANTS.actor = null;
+ TOKEN_HUD_VARIANTS.variants = null;
+}
diff --git a/Data/modules/token-variants/scripts/wrappers/userMappingWrappers.js b/Data/modules/token-variants/scripts/wrappers/userMappingWrappers.js
new file mode 100644
index 00000000..44172d8f
--- /dev/null
+++ b/Data/modules/token-variants/scripts/wrappers/userMappingWrappers.js
@@ -0,0 +1,150 @@
+import { TVA_CONFIG } from '../settings.js';
+import { registerWrapper } from './wrappers.js';
+
+const feature_id = 'UserMappings';
+
+export function registerUserMappingWrappers() {
+ registerWrapper(feature_id, 'Tile.prototype.draw', _draw);
+ registerWrapper(feature_id, 'Token.prototype.draw', _draw);
+}
+
+async function _draw(wrapped, ...args) {
+ let result;
+
+ // If the Token/Tile has a UserToImage mappings momentarily set document.texture.src to it
+ // so that it's texture gets loaded instead of the actual Token image
+ const mappings = this.document.getFlag('token-variants', 'userMappings') || {};
+ const img = mappings[game.userId];
+ let previous;
+ if (img) {
+ previous = this.document.texture.src;
+ this.document.texture.src = img;
+ this.tva_iconOverride = img;
+ result = await wrapped(...args);
+ this.document.texture.src = previous;
+ overrideVisibility(this, img);
+ } else {
+ overrideVisibility(this);
+ result = await wrapped(...args);
+ }
+
+ return result;
+}
+
+/**
+ * If the img is the same as TVA_CONFIG.invisibleImage then we'll override the isVisible
+ * getter to return false of this client if it's not a GM. Reset it to default if not.
+ * @param {*} obj object whose isVisible is to be overriden
+ * @param {*} img UserToImage mapping
+ */
+function overrideVisibility(obj, img) {
+ if (img && decodeURI(img) === TVA_CONFIG.invisibleImage && !obj.tva_customVisibility) {
+ const originalIsVisible = _getIsVisibleDescriptor(obj).get;
+ Object.defineProperty(obj, 'isVisible', {
+ get: function () {
+ const isVisible = originalIsVisible.call(this);
+ if (isVisible && !game.user.isGM) return false;
+ return isVisible;
+ },
+ configurable: true,
+ });
+ obj.tva_customVisibility = true;
+ } else if (!img && obj.tva_customVisibility) {
+ Object.defineProperty(obj, 'isVisible', _getIsVisibleDescriptor(obj));
+ delete obj.tva_customVisibility;
+ }
+}
+
+function _getIsVisibleDescriptor(obj) {
+ let iObj = Object.getPrototypeOf(obj);
+ let descriptor = null;
+ while (iObj) {
+ descriptor = Object.getOwnPropertyDescriptor(iObj, 'isVisible');
+ if (descriptor) break;
+ iObj = Object.getPrototypeOf(iObj);
+ }
+ return descriptor;
+}
+
+/**
+ * Assign an image to be displayed to only that user.
+ * @param {*} token token the image is to be applied to
+ * @param {*} img image to be displayed, if no image is provided unassignUserSpecificImage(...) will be called
+ * @param {*} opts.userName name of the user that the image is to be displayed to
+ * @param {*} opts.id id of the user that the image is to be displayed to
+ * @returns
+ */
+export function assignUserSpecificImage(token, img, { userName = null, userId = null } = {}) {
+ if (!img) return unassignUserSpecificImage(token, { userName, userId });
+
+ if (userName instanceof Array) {
+ for (const name of userName) assignUserSpecificImage(token, img, { userName: name });
+ return;
+ }
+
+ if (userId instanceof Array) {
+ for (const id of userId) assignUserSpecificImage(token, img, { userId: id });
+ return;
+ }
+
+ let id = userId;
+ if (!id && userName) {
+ id = game.users.find((u) => u.name === userName)?.id;
+ }
+ if (!id) return;
+
+ const doc = token.document ?? token;
+ const mappings = doc.getFlag('token-variants', 'userMappings') || {};
+
+ mappings[id] = img;
+ doc.setFlag('token-variants', 'userMappings', mappings);
+}
+
+/**
+ * Calls assignUserSpecificImage passing in all currently selected tokens.
+ * @param {*} img image to be displayed
+ * @param {*} opts id or name of the user as per assignUserSpecificImage(...)
+ */
+export function assignUserSpecificImageToSelected(img, opts = {}) {
+ const selected = [...canvas.tokens.controlled];
+ for (const t of selected) assignUserSpecificImage(t, img, opts);
+}
+
+/**
+ * Un-assign image if one has been set to be displayed to a user.
+ * @param {*} token token the image is to be removed from
+ * @param {*} opts.userName name of the user that the image is to be removed for
+ * @param {*} opts.id id of the user that the image is to be removed for
+ */
+export function unassignUserSpecificImage(token, { userName = null, userId = null } = {}) {
+ if (userName instanceof Array) {
+ for (const name of userName) unassignUserSpecificImage(token, img, { userName: name });
+ return;
+ }
+
+ if (userId instanceof Array) {
+ for (const id of userId) unassignUserSpecificImage(token, img, { userId: id });
+ return;
+ }
+
+ let id = userId;
+ if (!id && userName) {
+ id = game.users.find((u) => u.name === userName)?.id;
+ }
+ if (!id) {
+ if (!userName && !userId) (token.document ?? token).unsetFlag('token-variants', 'userMappings');
+ } else {
+ const update = {};
+ update['flags.token-variants.userMappings.-=' + id] = null;
+ (token.document ?? token).update(update);
+ }
+}
+
+/**
+ * Calls unassignUserSpecificImage passing in all currently selected tokens.
+ * @param {*} opts id or name of the user as per unassignUserSpecificImage(...)
+ */
+export function unassignUserSpecificImageFromSelected(opts = {}) {
+ const selected = [...canvas.tokens.controlled];
+ for (const t of selected) unassignUserSpecificImage(t, opts);
+}
diff --git a/Data/modules/token-variants/scripts/wrappers/wrappers.js b/Data/modules/token-variants/scripts/wrappers/wrappers.js
new file mode 100644
index 00000000..92eeed06
--- /dev/null
+++ b/Data/modules/token-variants/scripts/wrappers/wrappers.js
@@ -0,0 +1,30 @@
+import { registerEffectIconWrappers } from './effectIconWrappers.js';
+import { registerHUDWrappers } from './hudWrappers.js';
+import { registerUserMappingWrappers } from './userMappingWrappers.js';
+
+export const REGISTERED_WRAPPERS = {};
+
+export function registerWrapper(feature_id, name, fn, method = 'WRAPPER') {
+ if (typeof libWrapper !== 'function') return;
+ if (!(feature_id in REGISTERED_WRAPPERS)) REGISTERED_WRAPPERS[feature_id] = {};
+ if (name in REGISTERED_WRAPPERS[feature_id]) return;
+
+ REGISTERED_WRAPPERS[feature_id][name] = libWrapper.register('token-variants', name, fn, method);
+}
+
+export function unregisterWrapper(feature_id, name) {
+ if (typeof libWrapper !== 'function') return;
+ if (feature_id in REGISTERED_WRAPPERS && name in REGISTERED_WRAPPERS[feature_id]) {
+ libWrapper.unregister('token-variants', REGISTERED_WRAPPERS[feature_id][name]);
+ delete REGISTERED_WRAPPERS[feature_id][name];
+ }
+}
+
+export function registerAllWrappers() {
+ // User to Image mappings for Tile and Tokens
+ registerUserMappingWrappers();
+ // Hide effect icons
+ registerEffectIconWrappers();
+ // Token HUD Variants Management
+ registerHUDWrappers();
+}
diff --git a/Data/modules/token-variants/styles/tva-styles.css b/Data/modules/token-variants/styles/tva-styles.css
new file mode 100644
index 00000000..b0032a7b
--- /dev/null
+++ b/Data/modules/token-variants/styles/tva-styles.css
@@ -0,0 +1,857 @@
+/* ---------------------------------------- */
+/* Pop-up Settings */
+/* ---------------------------------------- */
+.token-variants-popup-settings header.table-header {
+ background: rgba(0, 0, 0, 0.5);
+ padding: 5px;
+ border: 1px solid #191813;
+ text-align: center;
+ color: #f0f0e0;
+ font-weight: bold;
+ text-shadow: 1px 1px #000;
+}
+
+.token-variants-popup-settings li.setting .form-fields {
+ display: flex;
+ justify-content: space-around;
+}
+
+.token-variants-popup-settings ul.setting-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 400px;
+ overflow: hidden auto;
+ scrollbar-width: thin;
+}
+
+.token-variants-config-control {
+ background: rgba(0, 0, 0, 0.4);
+ padding: 2px;
+ border: 1px solid #191813;
+ color: #f0f0e0;
+ font-weight: bold;
+ text-shadow: 1px 1px #000;
+}
+
+/**
+ * Role Permission Configuration Form
+ */
+.token-variants-permissions header.table-header {
+ background: rgba(0, 0, 0, 0.5);
+ padding: 5px;
+ border: 1px solid #191813;
+ text-align: center;
+ color: #f0f0e0;
+ font-weight: bold;
+ text-shadow: 1px 1px #000;
+}
+.token-variants-permissions ul.permissions-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 400px;
+ overflow: hidden auto;
+ scrollbar-width: thin;
+}
+.token-variants-permissions li.permission {
+ padding: 5px;
+ border-bottom: 1px solid #7a7971;
+}
+.token-variants-permissions li.permission .form-fields {
+ justify-content: space-around;
+}
+.token-variants-permissions li.permission input[type='checkbox'] {
+ margin: 0;
+}
+.token-variants-permissions li.permission button {
+ order: 0;
+}
+.token-variants-permissions .index {
+ flex: 0 0 200px;
+ text-align: left;
+ font-weight: bold;
+}
+.token-variants-permissions .hint {
+ flex: 0 0 100%;
+ color: #4b4a44;
+ font-size: 13px;
+ margin: 5px 0 0;
+}
+
+/**
+ * User List Configuration Form
+ */
+#token-variants-user-list header.table-header {
+ background: rgba(0, 0, 0, 0.5);
+ padding: 5px;
+ border: 1px solid #191813;
+ text-align: center;
+ color: #f0f0e0;
+ font-weight: bold;
+ text-shadow: 1px 1px #000;
+}
+#token-variants-user-list ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 400px;
+ overflow: hidden auto;
+ scrollbar-width: thin;
+}
+#token-variants-user-list li {
+ border-bottom: 1px solid #7a7971;
+ text-align: left;
+}
+#token-variants-user-list li .form-fields {
+ justify-content: space-around;
+ text-align: left;
+}
+#token-variants-user-list li input[type='checkbox'] {
+ margin: 0;
+}
+#token-variants-user-list .index {
+ flex: 0 0 40px;
+ text-align: left;
+ font-weight: bold;
+}
+#token-variants-user-list .hint {
+ flex: 0 0 100%;
+ color: #4b4a44;
+ font-size: 13px;
+ margin: 5px 0 0;
+}
+
+/**
+ * Config Settings
+ */
+.tva-setting-nav {
+ display: flex;
+ flex-flow: wrap;
+ height: 64px !important;
+ margin-bottom: 10px !important;
+}
+
+.tva-setting-nav hr {
+ width: 100%;
+ flex-basis: 100%;
+ height: 0;
+ margin: 0;
+ border: 0;
+}
+
+/* ---------------------------------------- */
+/* Art Select Sheet */
+/* ---------------------------------------- */
+.token-variants-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ grid-gap: 0.5rem;
+ text-align: center;
+}
+
+.token-variants-grid > div {
+ display: grid;
+}
+
+.token-variants-grid > div > .token-variants-grid-box,
+.token-variants-grid > div > .token-variants-grid-image {
+ grid-area: 1 / 1 / 2 / 2;
+}
+
+.token-variants-grid > div > .token-variants-grid-box {
+ content: '';
+ padding-bottom: 100%;
+ display: block;
+ border-style: solid;
+ border-width: 1px;
+}
+
+.token-variants-grid > div > .token-variants-grid-box.selected {
+ border-color: lime;
+ border-width: 2px;
+}
+
+.token-variants-grid-image {
+ max-width: 98%;
+ max-height: 113px;
+ border: none;
+ display: block;
+ margin: auto;
+ pointer-events: none;
+}
+
+.token-variants-grid > div > .fa-play {
+ display: inline-block;
+ position: relative;
+ grid-area: 1 / 1 / 2 / 2;
+ left: -38%;
+ top: 80%;
+ pointer-events: none;
+}
+
+.token-variants-grid > div > .fa-cog {
+ display: inline-block;
+ position: relative;
+ grid-area: 1 / 1 / 2 / 2;
+ left: 38%;
+ top: 6px;
+ color: rgb(182, 182, 121);
+ opacity: 0;
+ pointer-events: none;
+}
+
+.token-variants-grid > div > .fa-cog.active {
+ opacity: 1;
+}
+
+.token-variants-grid > div > .token-variants-grid-box:hover {
+ box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
+}
+
+.token-variants-grid > div > p {
+ overflow: auto;
+ white-space: nowrap;
+}
+
+.token-variants-grid > div > p.token-variants-grid-image {
+ white-space: normal;
+ overflow-wrap: break-word;
+}
+
+.token-variants-grid > div > span {
+ white-space: nowrap;
+}
+
+.token-variants-grid > div > p > mark {
+ background-color: initial;
+ font-weight: bold;
+ color: rgba(255, 0, 0, 0.6);
+}
+
+.token-variants-grid .token-variants-unrecognised {
+ border-color: red;
+}
+
+.token-variants-portrait-token > div {
+ max-width: 80px;
+ margin: auto;
+ padding-bottom: 5px;
+ color: green;
+ font-weight: bold;
+}
+
+.token-variants-portrait-token.item {
+ float: left;
+ margin-right: 15px;
+}
+
+.token-variants-portrait-token > div > .image.active {
+ box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
+}
+.token-variants-banner > div > input {
+ text-align: center;
+}
+
+.token-variants-banner > .item-description {
+ overflow: hidden;
+}
+
+.token-variants-banner > .item-description > .item-description-content {
+ overflow: scroll;
+ max-height: 200px;
+}
+
+.token-variants-banner {
+ position: sticky;
+ top: 0;
+ padding-bottom: 10px;
+ padding-top: 10px;
+ background: inherit;
+ top: -8px;
+}
+
+/* ---------------------------------------- */
+/* General */
+/* ---------------------------------------- */
+input[type='range'] + .token-variants-range-value {
+ display: block;
+ flex: 0 1 48px;
+ text-align: center;
+ border: 1px solid #b5b3a4;
+ padding: 2px;
+ margin-left: 10px;
+}
+
+/* ---------------------------------------- */
+/* Search Paths */
+/* ---------------------------------------- */
+
+ol.token-variant-table textarea {
+ resize: none;
+ min-height: 44px;
+}
+ol.token-variant-table {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 600px;
+ overflow-y: auto;
+}
+ol.token-variant-table .table-row {
+ padding: 2px 0;
+ border-top: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+}
+ol.token-variant-table .table-row input[type='text'] {
+ width: 100%;
+ height: 28px;
+}
+ol.token-variant-table .table-row input[type='checkbox'] {
+ width: 100%;
+ margin-top: 7px;
+}
+ol.token-variant-table .table-row > div {
+ line-height: 36px;
+ margin-right: 5px;
+}
+ol.token-variant-table .table-row .path-image {
+ flex: 0 0 36px;
+ width: 36px;
+ height: 36px;
+ text-align: center;
+ margin: 0;
+}
+ol.token-variant-table .table-row .path-image img {
+ border: none;
+ object-fit: cover;
+ object-position: 50% 0;
+}
+ol.token-variant-table .table-row .path-source {
+ flex: 0.2;
+}
+ol.token-variant-table .table-row .path-text {
+ flex: 1;
+}
+ol.token-variant-table .table-row .path-cache,
+ol.token-variant-table .table-row .path-category,
+ol.token-variant-table .table-row .path-config {
+ flex: 0 0 50px;
+ text-align: center;
+}
+ol.token-variant-table .table-row .path-config .select-config.active {
+ color: orange;
+}
+ol.token-variant-table .table-row .path-share {
+ flex: 0 0 50px;
+ text-align: center;
+}
+ol.token-variant-table .table-row .path-controls,
+ol.token-variant-table .table-row .mapping-controls,
+ol.token-variant-table .table-row .imgur-control,
+ol.token-variant-table .table-row .json-control {
+ flex: 0 0 44px;
+ margin: 0;
+ text-align: center;
+}
+ol.token-variant-table .table-row .imgur-control,
+ol.token-variant-table .table-row .json-control {
+ display: none;
+}
+ol.token-variant-table .table-row .imgur-control.active,
+ol.token-variant-table .table-row .json-control.active {
+ display: block;
+}
+ol.token-variant-table .table-row .path-controls i,
+ol.token-variant-table .table-row .mapping-controls i,
+ol.token-variant-table .table-row .imgur-control i,
+ol.token-variant-table .table-row .json-control i {
+ width: 20px;
+}
+ol.token-variant-table .table-header {
+ background: rgba(0, 0, 0, 0.05);
+ border: 1px solid #7a7971;
+ line-height: 24px;
+ font-weight: bold;
+}
+
+ol.token-variant-table .group-title {
+ background: rgba(0, 0, 0, 0.15);
+ border: 1px solid #7a7971;
+ line-height: 11px;
+ font-weight: bold;
+ margin-top: 5px;
+ margin-left: 5px;
+ margin-right: 5px;
+}
+
+ol.token-variant-table .group-title > p {
+ margin-left: 6px;
+}
+
+ol.token-variant-table .group-disable {
+ flex: 0.1;
+ text-align: right;
+ margin-right: 16px;
+ margin-top: 7px;
+ color: red;
+}
+
+ol.token-variant-table .group-disable.active {
+ color: green;
+}
+
+ol.token-variant-table .group-toggle {
+ flex: 0.11;
+ text-align: right;
+ margin-right: 10px;
+ margin-top: 7px;
+}
+
+ol.token-variant-table .group-toggle.global {
+ flex: 0.179;
+ text-align: right;
+ margin-right: 10px;
+ margin-top: 7px;
+}
+
+ol.token-variant-table .group-toggle a.active {
+ transform: rotate(180deg);
+}
+
+/* ---------------------------------------- */
+/* Mapping Config List */
+/* ---------------------------------------- */
+ol.token-variant-table .table-row .mapping-priority {
+ flex: 0.1;
+}
+
+ol.token-variant-table .table-row .mapping-label {
+ flex: 0.4;
+}
+
+ol.token-variant-table .table-row .mapping-target {
+ flex: 0.12;
+ text-align: center;
+}
+
+ol.token-variant-table .table-row .mapping-group {
+ flex: 0.2;
+ text-align: center;
+}
+
+ol.token-variant-table .table-row .mapping-group > input {
+ width: 80px;
+}
+
+ol.token-variant-table .table-row .mapping-image {
+ flex: 0.2;
+ text-align: center;
+}
+
+ol.token-variant-table .table-row .mapping-config {
+ flex: 0.12;
+ text-align: center;
+}
+
+ol.token-variant-table .table-row .mapping-overlay {
+ flex: 0.2;
+ text-align: center;
+}
+
+ol.token-variant-table .table-row .mapping-overlay > input {
+ vertical-align: top;
+ width: 36%;
+}
+
+ol.token-variant-table .table-row .mapping-overlay > a {
+ vertical-align: middle;
+}
+
+ol.token-variant-table .table-row .mapping-overlay > a.child {
+ color: rgb(252, 30, 252);
+}
+
+ol.token-variant-table .table-row .mapping-alwaysOn {
+ flex: 0 0 80px;
+ text-align: center;
+}
+
+ol.token-variant-table .table-row .mapping-disable {
+ flex: 0 0 60px;
+ text-align: center;
+}
+
+ol.token-variant-table .table-row .mapping-config i.active {
+ color: orange;
+}
+
+ol.token-variant-table .table-row .mapping-config.active {
+ color: orange;
+}
+
+ol.token-variant-table .table-row .mapping-config-edit.active {
+ color: orange;
+}
+
+ol.token-variant-table .table-row .mapping-expression {
+ flex: 0.5;
+ margin-top: 5px;
+}
+
+ol.token-variant-table .table-row .mapping-expression .div-input span {
+ color: green;
+}
+
+ol.token-variant-table .table-row .mapping-expression .div-input span.hp-expression {
+ color: blue;
+}
+
+/* Copied from input[type="text"] with some adjustments */
+ol.token-variant-table .table-row .mapping-expression .div-input {
+ white-space: nowrap;
+ overflow: hidden;
+
+ writing-mode: horizontal-tb !important;
+ text-rendering: auto;
+ letter-spacing: normal;
+ word-spacing: normal;
+ text-transform: none;
+ text-indent: 0px;
+ text-shadow: none;
+ appearance: auto;
+ -webkit-rtl-ordering: logical;
+ cursor: text;
+ border-width: 2px;
+ border-style: inset;
+ border-image: initial;
+ border-color: red !important;
+
+ min-width: 215px;
+ max-width: 215px;
+ height: var(--form-field-height);
+ background: rgba(0, 0, 0, 0.05);
+ padding: 3px 3px;
+ margin: 0;
+ color: var(--color-text-dark-primary);
+ line-height: normal;
+ border: 1px solid var(--color-border-light-tertiary);
+ border-radius: 3px;
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ user-select: text;
+}
+
+/* ---------------------------------------- */
+/* Active Effect Config */
+/* ---------------------------------------- */
+
+#token-variants-active-effect-config .mapping-config.active {
+ color: orange;
+}
+
+/* ---------------------------------------- */
+/* Token HUD Settings */
+/* ---------------------------------------- */
+#tile-hud .token-variants-wrap,
+#token-hud .token-variants-wrap {
+ position: absolute;
+ left: 75px;
+ visibility: hidden;
+ top: 50%;
+ transform: translate(0%, -50%);
+ width: max-content;
+ max-width: 350px;
+ max-height: 350px;
+ text-align: start;
+ grid-template-columns: 100px 100px 100px;
+ overflow-y: auto;
+}
+#tile-hud .token-variants-wrap.list,
+#token-hud .token-variants-wrap.list {
+ grid-template-columns: unset !important;
+ max-width: 450px;
+}
+
+#tile-hud .token-variants-wrap.active,
+#token-hud .token-variants-wrap.active {
+ visibility: visible;
+}
+
+#tile-hud .token-variants-button-select,
+#token-hud .token-variants-button-select {
+ max-width: 300px;
+ overflow-wrap: break-word;
+ padding-top: 0;
+ padding-bottom: 0;
+ width: max-content !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ line-height: 0 !important;
+ position: relative;
+ display: inline-block;
+}
+
+#tile-hud .token-variants-button-select.hide,
+#token-hud .token-variants-button-select.hide {
+ display: none;
+}
+
+#tile-hud .token-variants-button-select .fa-share,
+#token-hud .token-variants-button-select .fa-share {
+ position: absolute;
+ left: 0;
+ color: green;
+ opacity: 0;
+ pointer-events: none;
+}
+
+#tile-hud .token-variants-button-select .fa-cog,
+#token-hud .token-variants-button-select .fa-cog {
+ position: absolute;
+ right: 0;
+ color: rgb(182, 182, 121);
+ opacity: 0;
+ pointer-events: none;
+}
+
+#tile-hud .token-variants-button-select .fa-play,
+#token-hud .token-variants-button-select .fa-play {
+ position: absolute;
+ left: 5px;
+ top: 70%;
+ color: dimgray;
+ pointer-events: none;
+}
+
+#tile-hud .token-variants-button-select .fa-cog.active,
+#token-hud .token-variants-button-select .fa-cog.active {
+ opacity: 1;
+}
+
+#tile-hud .token-variants-button-select .fa-share.active,
+#token-hud .token-variants-button-select .fa-share.active {
+ opacity: 1;
+}
+
+#tile-hud .token-variants-context-menu,
+#token-hud .token-variants-context-menu {
+ display: none;
+}
+
+#tile-hud .token-variants-context-menu.active,
+#token-hud .token-variants-context-menu.active {
+ display: block;
+}
+
+#tile-hud .token-variants-context-menu > button,
+#token-hud .token-variants-context-menu > button {
+ width: 48%;
+ background-color: rgb(28, 28, 28, 0.7);
+ color: white;
+ border-color: black;
+}
+
+#tile-hud .token-variants-button-select:hover .token-variants-button-image,
+#token-hud .token-variants-button-select:hover .token-variants-button-image {
+ opacity: 1 !important;
+}
+
+#tile-hud .token-variants-button-select.list,
+#token-hud .token-variants-button-select.list {
+ max-width: 440px;
+ width: 100% !important;
+ margin: 8px 0px !important;
+ line-height: 40px !important;
+}
+
+#tile-hud .token-variants-button-image,
+#token-hud .token-variants-button-image {
+ width: 100px;
+ height: 100px;
+ margin: 2px;
+ object-fit: contain;
+}
+
+#token-hud .token-variants-button-disabled span,
+#token-hud .token-variants-button-disabled img,
+#token-hud .token-variants-button-disabled video {
+ opacity: 1 !important;
+ filter: grayscale(100%);
+}
+
+#tile-hud .token-variants-button-disabled span,
+#tile-hud .token-variants-button-disabled img,
+#tile-hud .token-variants-button-disabled video {
+ opacity: 1 !important;
+ filter: grayscale(100%);
+}
+
+#token-hud .token-variants-button-disabled span,
+#token-hud .token-variants-button-disabled:hover img,
+#token-hud .token-variants-button-disabled:hover video {
+ color: #ccc;
+}
+
+#tile-hud .token-variants-button-disabled span,
+#tile-hud .token-variants-button-disabled:hover img,
+#tile-hud .token-variants-button-disabled:hover video {
+ color: #ccc;
+}
+
+.tile-sheet .token-variants-default,
+.token-sheet .token-variants-default {
+ display: none;
+}
+
+.tile-sheet .token-variants-default.active,
+.token-sheet .token-variants-default.active {
+ display: block;
+}
+
+/* ---------------------------------------- */
+/* Token Custom Config */
+/* ---------------------------------------- */
+
+.tva-config-checkbox {
+ flex: 0 !important;
+}
+
+/* --------------------------------------- */
+/* JSON Edit */
+/* --------------------------------------- */
+/* .token-variants-json-edit {
+ min-width: 360px;
+ min-height: 320px;
+}
+.token-variants-json-edit form > * {
+ flex: 0;
+}
+.token-variants-json-edit .form-group {
+ flex: 1;
+}
+.token-variants-json-edit .form-group label {
+ height: 24px;
+}
+.token-variants-json-edit textarea {
+ height: calc(100% - 24px);
+ height: -moz-calc(100%-100px);
+ height: -webkit-calc(100%-100px);
+ resize: none;
+} */
+
+/* ---------------------------------------- */
+/* Missing Image Form */
+/* ---------------------------------------- */
+
+#token-variants-missing-images header.table-header {
+ background: rgba(0, 0, 0, 0.5);
+ padding: 5px;
+ border: 1px solid #191813;
+ text-align: left;
+ color: #f0f0e0;
+ font-weight: bold;
+ text-shadow: 1px 1px #000;
+}
+#token-variants-missing-images ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 400px;
+ overflow: hidden auto;
+ scrollbar-width: thin;
+}
+#token-variants-missing-images li {
+ border-bottom: 1px solid #7a7971;
+ text-align: left;
+ margin-top: 10px;
+}
+#token-variants-missing-images li .form-fields {
+ justify-content: space-around;
+ text-align: left;
+}
+#token-variants-missing-images li input[type='checkbox'] {
+ margin: 0;
+}
+#token-variants-missing-images .index {
+ flex: 0 0 36px;
+ width: 36px;
+ height: 36px;
+ text-align: center;
+ margin: 0;
+}
+#token-variants-missing-images .hint {
+ flex: 0 0 100%;
+ color: #4b4a44;
+ font-size: 13px;
+ margin: 5px 0 0;
+}
+
+#token-variants-missing-images .missing-document {
+ flex: 0 0 110px;
+}
+
+#token-variants-missing-images .missing-image {
+ flex: 0 0 50px;
+ margin-left: 15px;
+}
+
+#token-variants-missing-images .missing-controls {
+ flex: 0 0 50px;
+ margin-left: 15px;
+}
+
+/* ---------------------------------------- */
+/* Overlay Config */
+/* ---------------------------------------- */
+
+.tva-overlay-form .shape-legend-input {
+ width: 200px !important;
+ margin-left: 15px !important;
+ border: none !important;
+}
+
+.tva-overlay-form .non-empty-variables {
+ background-color: rgb(252, 168, 138);
+}
+
+.tva-overlay-form .repeat-fieldset {
+ border-style: hidden;
+}
+
+.tva-overlay-form .repeat-fieldset.active {
+ border-style: inset;
+}
+
+.tva-overlay-form .text-field {
+ font-family: Signika, 'FontAwesome';
+}
+
+.tva-overlay-form [type='range'] + .range-value {
+ flex: 0 1 200px;
+}
+
+/* ---------------------------------------- */
+/* Active Effect Scripts */
+/* ---------------------------------------- */
+
+.token-variants-macro {
+ flex: 10;
+}
+
+.token-variants-macro > .form-group {
+ height: 50%;
+}
+
+/* ---------------------------------------- */
+/* MISC. */
+/* ---------------------------------------- */
+
+input.tvaValid {
+ border: 2px solid #009b00;
+}
+
+input.tvaInvalid {
+ border: 2px solid #ff0000;
+}
diff --git a/Data/modules/token-variants/templates/artSelect.html b/Data/modules/token-variants/templates/artSelect.html
new file mode 100644
index 00000000..519f545a
--- /dev/null
+++ b/Data/modules/token-variants/templates/artSelect.html
@@ -0,0 +1,94 @@
+
diff --git a/Data/modules/token-variants/templates/compendiumMap.html b/Data/modules/token-variants/templates/compendiumMap.html
new file mode 100644
index 00000000..4d74a2f8
--- /dev/null
+++ b/Data/modules/token-variants/templates/compendiumMap.html
@@ -0,0 +1,133 @@
+
diff --git a/Data/modules/token-variants/templates/configJsonEdit.html b/Data/modules/token-variants/templates/configJsonEdit.html
new file mode 100644
index 00000000..1f9b1655
--- /dev/null
+++ b/Data/modules/token-variants/templates/configJsonEdit.html
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/Data/modules/token-variants/templates/configScriptEdit.html b/Data/modules/token-variants/templates/configScriptEdit.html
new file mode 100644
index 00000000..dd5a0625
--- /dev/null
+++ b/Data/modules/token-variants/templates/configScriptEdit.html
@@ -0,0 +1,91 @@
+
+
+ Scripts
+
+
+ Run when 'Expression' is TRUE
+ {{onApply}}
+
+
+
+ Run when 'Expression' is FALSE
+ {{onRemove}}
+
+
+
+
+
+
+ {{#each macros }}
+
+ {{/each}}
+
+
+
+ Macros
+
+
+
+
+ {{#if tmfxActive}}
+
+
+ Token Magic FX
+
+
+ {{/if}}
+
+ {{#if ceActive}}
+
+
+ DFreds Convenient Effects
+
+
+
+ {{/if}}
+
+
+
\ No newline at end of file
diff --git a/Data/modules/token-variants/templates/configureSettings.html b/Data/modules/token-variants/templates/configureSettings.html
new file mode 100644
index 00000000..7ee608dc
--- /dev/null
+++ b/Data/modules/token-variants/templates/configureSettings.html
@@ -0,0 +1,813 @@
+
+
+
+ {{#if enabledTabs.searchPaths}} Search Paths {{/if}}
+ {{#if enabledTabs.searchFilters}} Search Filters {{/if}}
+ {{#if enabledTabs.searchAlgorithm}} Search Algorithm {{/if}}
+ {{#if enabledTabs.randomizer}} Randomizer {{/if}}
+ {{#if enabledTabs.features}} Features {{/if}}
+
+ {{#if enabledTabs.popup}} Pop-up {{/if}}
+ {{#if enabledTabs.permissions}} Permissions {{/if}}
+ {{#if enabledTabs.worldHud}} Token HUD {{/if}}
+ {{#if enabledTabs.activeEffects}} Effects {{/if}}
+ {{#if enabledTabs.misc}} Misc {{/if}}
+
+
+
+
+
+ {{#if enabledTabs.searchPaths}}
+
+ {{/if}}
+
+ {{#if enabledTabs.searchFilters}}
+
+
Define filters for each image category. Images will be limited to files that include/exclude these pieces of text or match a regular expression.
+
+
+
+ {{#each searchFilters}}
+
{{this.label}}
+
+
+ {{/each}}
+
+ {{/if}}
+
+
+ {{#if enabledTabs.searchAlgorithm}}
+
+
+
+
+
+
+
+
Search Method
+
+
{{localize "token-variants.common.exact"}}
+
+
+
{{localize "token-variants.common.fuzzy"}}
+
+
+
+
+
+
+
+
+ {{/if}}
+
+
+ {{#if enabledTabs.randomizer}}
+
+ {{/if}}
+
+
+ {{#if enabledTabs.features}}
+
+
Fully turn-off module features.
+
+
+
+
+
+ {{/if}}
+
+
+ {{#if enabledTabs.popup}}
+
+ {{/if}}
+
+
+ {{#if enabledTabs.permissions}}
+
+
Configure which User role has permission to access which module features.
+
+
+
+
+
+ {{/if}}
+
+
+ {{#if enabledTabs.worldHud}}
+
+
World Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#if worldHud.tokenHUDWildcardActive}}
+
+
Token HUD Wildcard
+
+
+ {{/if}}
+
+ {{/if}}
+
+
+ {{#if enabledTabs.activeEffects}}
+
+
+
+
+
+
+
+ {{#if dnd5e}}
+
+ DnD5e
+
+
+ {{/if}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#unless pathfinder}}
+
+
+
+
+
+ {{/unless}}
+
+
+
+ Internal Effects
+
+
+
+
+ {{/if}}
+
+
+ {{#if enabledTabs.misc}}
+
+
+
+
+
+
+
+
+
+
+
+ Cache Images
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Image Updates
+
+
+
+
+ Dimensions in Image Names
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{/if}}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Data/modules/token-variants/templates/effectMappingForm.html b/Data/modules/token-variants/templates/effectMappingForm.html
new file mode 100644
index 00000000..9f775038
--- /dev/null
+++ b/Data/modules/token-variants/templates/effectMappingForm.html
@@ -0,0 +1,124 @@
+
+
+
+
diff --git a/Data/modules/token-variants/templates/flagsConfig.html b/Data/modules/token-variants/templates/flagsConfig.html
new file mode 100644
index 00000000..05947d72
--- /dev/null
+++ b/Data/modules/token-variants/templates/flagsConfig.html
@@ -0,0 +1,51 @@
+
+
+
+
+ {{localize "token-variants.common.apply"}}
+
+
diff --git a/Data/modules/token-variants/templates/forgeSearchPaths.html b/Data/modules/token-variants/templates/forgeSearchPaths.html
new file mode 100644
index 00000000..874895b5
--- /dev/null
+++ b/Data/modules/token-variants/templates/forgeSearchPaths.html
@@ -0,0 +1,60 @@
+
+
+
+
diff --git a/Data/modules/token-variants/templates/importExport.html b/Data/modules/token-variants/templates/importExport.html
new file mode 100644
index 00000000..561546d1
--- /dev/null
+++ b/Data/modules/token-variants/templates/importExport.html
@@ -0,0 +1,12 @@
+
+
+
+
+ {{localize "token-variants.common.import"}}
+
+
+
+ {{localize "token-variants.common.export"}}
+
+
+
diff --git a/Data/modules/token-variants/templates/missingImageConfig.html b/Data/modules/token-variants/templates/missingImageConfig.html
new file mode 100644
index 00000000..4593e4af
--- /dev/null
+++ b/Data/modules/token-variants/templates/missingImageConfig.html
@@ -0,0 +1,42 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Data/modules/token-variants/templates/overlayConfig.html b/Data/modules/token-variants/templates/overlayConfig.html
new file mode 100644
index 00000000..76e6b3fe
--- /dev/null
+++ b/Data/modules/token-variants/templates/overlayConfig.html
@@ -0,0 +1,707 @@
+
+
+
+ Misc
+ Image
+ Text
+ Shapes
+ Filter
+ Visibility
+ Animation
+ Triggers
+ Variables
+
+
+
+
+ {{#if tmfxActive}}
+
+ {{#each tmfxPresets }}
+
+ {{/each}}
+
+ {{/if}}
+
+ {{#if ceEffects}}
+
+ {{#each ceEffects }}
+
+ {{/each}}
+
+ {{/if}}
+
+
+ {{#each macros }}
+
+ {{/each}}
+
+
+
+
+
+
+
+ {{#each interactivity as |event|}}
+
+
+ {{event.listener}}
+
+
+
+
+ {{#if ../ceActive}}
+
+ {{/if}}
+
+ {{#if ../tmfxActive}}
+
+ {{/if}}
+
+
+
+
+ {{/each}}
+
+
+
+
+
+
+
+
+ Display Priority
+
+
+
+
+
+
+
+
+
+ Link To Token
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Link To Stage
+
+
+
+
+ Video
+
+
+
+
+
+
+
+
+
+ {{~>modules/token-variants/templates/partials/repeating.html repeating=repeating root="" repeat=repeat padding="true"}}
+
+
+ Appearance
+
+
+
+
+
+ {{~>modules/token-variants/templates/partials/interpolateColor.html root="" interpolateColor=interpolateColor label="Tint Color"}}
+
+
+
+ Dimensions
+
+
+
+
+
+
+
+
+
+
+
+ Positioning
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{{filterOptions}}}
+
+
+
+
+
+
+
+
+
+
+
+ Limit Visibility to Users
+ {{#each users as |user|}}
+
+ {{/each}}
+
+
+
+ Limit Visibility to State
+
+
+
+
+
+
+ Limit Visibility to Token With Effect
+
+
+
+
+ Limit Visibility to Token With Property
+
+
+
+
+
+
+
+ {{~>modules/token-variants/templates/partials/repeating.html repeating=text.repeating root="text." repeat=text.repeat}}
+
+
+
+
+ {{~>modules/token-variants/templates/partials/interpolateColor.html root="text." interpolateColor=text.interpolateColor label="Fill Color"}}
+
+
+
+
+
+
+
+
+
+ Wrapping
+
+
+
+
+
+
+
+
+
+
+
+
Curve
+
+
Curve the text either by defining an angle in should bend by or a radius of an imaginary circle whose edge the text should sit on.
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#each shapes as |shape|}}
+
+
+ {{#if (eq shape.shape.type "rectangle")}}
+ {{~>modules/token-variants/templates/partials/shapeRectangle.html shape}}
+ {{else if (eq shape.shape.type "ellipse")}}
+ {{~>modules/token-variants/templates/partials/shapeEllipse.html shape}}
+ {{else if (eq shape.shape.type "polygon")}}
+ {{~>modules/token-variants/templates/partials/shapePolygon.html shape}}
+ {{else if (eq shape.shape.type "torus")}}
+ {{~>modules/token-variants/templates/partials/shapeTorus.html shape}}
+ {{/if}}
+
+
+ Line Style
+
+
+
+
+
+ Fill
+
+
+
+
+ {{~>modules/token-variants/templates/partials/interpolateColor.html root=(concat "shapes." @index ".fill.") interpolateColor=shape.fill.interpolateColor label="Color"}}
+
+
+
+ {{~>modules/token-variants/templates/partials/repeating.html repeating=shape.repeating root=(concat "shapes." @index ".") repeat=shape.repeat padding="true"}}
+
+
+ {{/each}}
+
+
+
+