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 = $(`

CACHING 0/${allItems.length}

`); + let d; + + const startProcessing = async function () { + while (processing && processed < allItems.length) { + await new Promise((resolve, reject) => { + setTimeout(async () => { + await processItem(allItems[processed]); + resolve(); + }, 10); + }); + processed++; + counter.html(`${processed}/${allItems.length}`); + } + if (stopProcessing || processed === allItems.length) { + d?.close(true); + addToQueue('DUMMY', { execute: endMapping }); + renderFromQueue(); + } + }; + + d = new Dialog({ + title: `Mapping: ${compendium.title}`, + content: ` +
+
+ `, + buttons: { + cancel: { + icon: '', + label: 'Cancel', + }, + }, + default: 'cancel', + render: (html) => { + html.find('.counter').append(counter); + const spinner = html.find('.fa-spinner'); + html.find('.pause').on('click', () => { + if (processing) { + processing = false; + spinner.removeClass('fa-pulse'); + } else { + processing = true; + startProcessing(); + spinner.addClass('fa-pulse'); + } + }); + setTimeout(async () => startProcessing(), 1000); + }, + close: () => { + if (!stopProcessing) { + stopProcessing = true; + if (!processing) startProcessing(); + else processing = false; + } + }, + }); + d.render(true); + } else { + const tasks = allItems.map(processItem); + Promise.all(tasks).then(() => { + addToQueue('DUMMY', { execute: endMapping }); + renderFromQueue(); + if (formData.missingOnly && !artSelectDisplayed) { + ui.notifications.warn('Token Variant Art: No documents found containing missing images.'); + } + }); + } + } + + /** + * @param {Event} event + * @param {Object} formData + */ + async _updateObject(event, formData) { + // If search paths are the same, remove them from searchOptions + if ( + !this.searchOptions.searchPaths?.length || + isEmpty(diffObject(this.searchOptions.searchPaths, TVA_CONFIG.searchPaths)) + ) { + this.searchOptions.searchPaths = []; + } + + formData.searchOptions = this.searchOptions; + + await updateSettings({ compendiumMapper: formData }); + if (formData.compendium) { + this.startMapping(formData); + } + } +} diff --git a/Data/modules/token-variants/applications/configJsonEdit.js b/Data/modules/token-variants/applications/configJsonEdit.js new file mode 100644 index 00000000..7190bd20 --- /dev/null +++ b/Data/modules/token-variants/applications/configJsonEdit.js @@ -0,0 +1,83 @@ +export default class EditJsonConfig extends FormApplication { + constructor(config, callback) { + super({}, {}); + this.config = config; + this.callback = callback; + } + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: 'token-variants-config-json-edit', + classes: ['sheet'], + template: 'modules/token-variants/templates/configJsonEdit.html', + resizable: true, + minimizable: false, + title: 'Edit Token Configuration', + width: 400, + height: 380, + }); + } + + async getData(options) { + const data = super.getData(options); + + data.hasConfig = this.config != null && Object.keys(this.config).length !== 0; + data.config = JSON.stringify(data.hasConfig ? this.config : {}, null, 2); + + return data; + } + + /** + * @param {JQuery} html + */ + activateListeners(html) { + super.activateListeners(html); + html.on('input', '.command textarea', this._validateJSON.bind(this)); + // Override 'Tab' key to insert spaces + html.on('keydown', '.command textarea', function (e) { + if (e.key === 'Tab' && !e.shiftKey) { + e.preventDefault(); + var start = this.selectionStart; + var end = this.selectionEnd; + this.value = this.value.substring(0, start) + ' ' + this.value.substring(end); + this.selectionStart = this.selectionEnd = start + 2; + return false; + } + }); + + html.find('.remove').click(this._onRemove.bind(this)); + html.find('.format').click(this._onFormat.bind(this)); + } + + async _validateJSON(event) { + const controls = $(event.target).closest('form').find('button[type="submit"], button.format'); + try { + this.config = JSON.parse(event.target.value); + this.config = expandObject(this.config); + this.flag = this.config.flag; + controls.prop('disabled', false); + } catch (e) { + controls.prop('disabled', true); + } + } + + async _onRemove(event) { + this.config = {}; + this.submit(); + } + + async _onFormat(event) { + $(event.target) + .closest('form') + .find('textarea[name="config"]') + .val(JSON.stringify(this.config, null, 2)); + } + + /** + * @param {Event} event + * @param {Object} formData + */ + async _updateObject(event, formData) { + if (this.callback) this.callback(this.config); + } +} diff --git a/Data/modules/token-variants/applications/configScriptEdit.js b/Data/modules/token-variants/applications/configScriptEdit.js new file mode 100644 index 00000000..b60fa33f --- /dev/null +++ b/Data/modules/token-variants/applications/configScriptEdit.js @@ -0,0 +1,97 @@ +export default class EditScriptConfig extends FormApplication { + constructor(script, callback) { + super({}, {}); + this.script = script; + this.callback = callback; + } + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: 'token-variants-config-script-edit', + classes: ['sheet'], + template: 'modules/token-variants/templates/configScriptEdit.html', + resizable: true, + minimizable: false, + title: 'Scripts', + width: 640, + height: 640, + }); + } + + async getData(options) { + const data = super.getData(options); + + const script = this.script ? this.script : {}; + data.hasScript = !isEmpty(script); + data.onApply = script.onApply; + data.onRemove = script.onRemove; + data.macroOnApply = script.macroOnApply; + data.macroOnRemove = script.macroOnRemove; + + data.tmfxPreset = script.tmfxPreset; + data.tmfxActive = game.modules.get('tokenmagic')?.active; + if (data.tmfxActive) { + data.tmfxPresets = TokenMagic.getPresets().map((p) => p.name); + } + + data.ceActive = game.modules.get('dfreds-convenient-effects')?.active; + if (data.ceActive) { + data.ceEffect = script.ceEffect ?? { apply: true, remove: true }; + data.ceEffects = game.dfreds.effects.all.map((ef) => ef.name); + } + + data.macros = game.macros.map((m) => m.name); + + return data; + } + + /** + * @param {JQuery} html + */ + activateListeners(html) { + super.activateListeners(html); + // Override 'Tab' key to insert spaces + html.on('keydown', '.command textarea', function (e) { + if (e.key === 'Tab' && !e.shiftKey) { + e.preventDefault(); + var start = this.selectionStart; + var end = this.selectionEnd; + this.value = this.value.substring(0, start) + ' ' + this.value.substring(end); + this.selectionStart = this.selectionEnd = start + 2; + return false; + } + }); + + html.find('.remove').click(this._onRemove.bind(this)); + } + + async _onRemove(event) { + if (this.callback) this.callback(null); + this.close(); + } + + /** + * @param {Event} event + * @param {Object} formData + */ + async _updateObject(event, formData) { + formData = expandObject(formData); + ['onApply', 'onRemove', 'macroOnApply', 'macroOnRemove'].forEach((k) => { + formData[k] = formData[k].trim(); + }); + if (formData.ceEffect?.name) formData.ceEffect.name = formData.ceEffect.name.trim(); + + if ( + !formData.onApply && + !formData.onRemove && + !formData.tmfxPreset && + !formData.ceEffect.name && + !formData.macroOnApply && + !formData.macroOnRemove + ) { + if (this.callback) this.callback(null); + } else { + if (this.callback) this.callback(formData); + } + } +} diff --git a/Data/modules/token-variants/applications/configureSettings.js b/Data/modules/token-variants/applications/configureSettings.js new file mode 100644 index 00000000..2aa9adad --- /dev/null +++ b/Data/modules/token-variants/applications/configureSettings.js @@ -0,0 +1,798 @@ +import { cacheImages } from '../scripts/search.js'; +import { TVA_CONFIG, updateSettings } from '../scripts/settings.js'; +import { getFileName } from '../scripts/utils.js'; +import EffectMappingForm from './effectMappingForm.js'; +import { showPathSelectCategoryDialog, showPathSelectConfigForm } from './dialogs.js'; + +export default class ConfigureSettings extends FormApplication { + constructor( + dummySettings, + { + searchPaths = true, + searchFilters = true, + searchAlgorithm = true, + randomizer = true, + popup = true, + permissions = true, + worldHud = true, + misc = true, + activeEffects = true, + features = false, + } = {} + ) { + super({}, {}); + this.enabledTabs = { + searchPaths, + searchFilters, + searchAlgorithm, + randomizer, + features, + popup, + permissions, + worldHud, + misc, + activeEffects, + }; + this.settings = foundry.utils.deepClone(TVA_CONFIG); + if (dummySettings) { + this.settings = mergeObject(this.settings, dummySettings, { insertKeys: false }); + this.dummySettings = dummySettings; + } + } + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: 'token-variants-configure-settings', + classes: ['sheet'], + template: 'modules/token-variants/templates/configureSettings.html', + resizable: false, + minimizable: false, + title: 'Configure Settings', + width: 700, + height: 'auto', + tabs: [{ navSelector: '.sheet-tabs', contentSelector: '.content', initial: 'searchPaths' }], + }); + } + + async getData(options) { + const data = super.getData(options); + const settings = this.settings; + + data.enabledTabs = this.enabledTabs; + + // === Search Paths === + const paths = settings.searchPaths.map((path) => { + const r = {}; + r.text = path.text; + r.icon = this._pathIcon(path.source || ''); + r.cache = path.cache; + r.source = path.source || ''; + r.types = path.types.join(','); + r.config = JSON.stringify(path.config ?? {}); + r.hasConfig = path.config && !isEmpty(path.config); + return r; + }); + data.searchPaths = paths; + + // === Search Filters === + data.searchFilters = settings.searchFilters; + for (const filter in data.searchFilters) { + data.searchFilters[filter].label = filter; + } + + // === Algorithm === + data.algorithm = deepClone(settings.algorithm); + data.algorithm.fuzzyThreshold = 100 - data.algorithm.fuzzyThreshold * 100; + + // === Randomizer === + // Get all actor types defined by the game system + data.randomizer = deepClone(settings.randomizer); + const actorTypes = (game.system.entityTypes ?? game.system.documentTypes)['Actor']; + data.randomizer.actorTypes = actorTypes.reduce((obj, t) => { + const label = CONFIG['Actor']?.typeLabels?.[t] ?? t; + obj[t] = { + label: game.i18n.has(label) ? game.i18n.localize(label) : t, + disable: settings.randomizer[`${t}Disable`] ?? false, + }; + return obj; + }, {}); + + data.randomizer.tokenToPortraitDisabled = + !(settings.randomizer.tokenCreate || settings.randomizer.tokenCopyPaste) || + data.randomizer.diffImages; + + // === Pop-up === + data.popup = deepClone(settings.popup); + // Get all actor types defined by the game system + data.popup.actorTypes = actorTypes.reduce((obj, t) => { + const label = CONFIG['Actor']?.typeLabels?.[t] ?? t; + obj[t] = { + type: t, + label: game.i18n.has(label) ? game.i18n.localize(label) : t, + disable: settings.popup[`${t}Disable`] ?? false, + }; + return obj; + }, {}); + + // Split into arrays of max length 3 + let allTypes = []; + let tempTypes = []; + let i = 0; + for (const [key, value] of Object.entries(data.popup.actorTypes)) { + tempTypes.push(value); + i++; + if (i % 3 == 0) { + allTypes.push(tempTypes); + tempTypes = []; + } + } + if (tempTypes.length > 0) allTypes.push(tempTypes); + data.popup.actorTypes = allTypes; + + // === Permissions === + data.permissions = settings.permissions; + + // === Token HUD === + data.worldHud = deepClone(settings.worldHud); + data.worldHud.tokenHUDWildcardActive = game.modules.get('token-hud-wildcard')?.active; + + // === Internal Effects === + data.internalEffects = deepClone(settings.internalEffects); + + // === Misc === + data.keywordSearch = settings.keywordSearch; + data.excludedKeywords = settings.excludedKeywords; + data.systemHpPath = settings.systemHpPath; + data.runSearchOnPath = settings.runSearchOnPath; + data.imgurClientId = settings.imgurClientId; + data.enableStatusConfig = settings.enableStatusConfig; + data.disableNotifs = settings.disableNotifs; + data.staticCache = settings.staticCache; + data.staticCacheFile = settings.staticCacheFile; + data.stackStatusConfig = settings.stackStatusConfig; + data.mergeGroup = settings.mergeGroup; + data.customImageCategories = settings.customImageCategories.join(','); + data.disableEffectIcons = settings.disableEffectIcons; + data.displayEffectIconsOnHover = settings.displayEffectIconsOnHover; + data.filterEffectIcons = settings.filterEffectIcons; + data.filterCustomEffectIcons = settings.filterCustomEffectIcons; + data.filterIconList = settings.filterIconList.join(','); + data.tilesEnabled = settings.tilesEnabled; + data.updateTokenProto = settings.updateTokenProto; + data.imgNameContainsDimensions = settings.imgNameContainsDimensions; + data.imgNameContainsFADimensions = settings.imgNameContainsFADimensions; + data.playVideoOnHover = settings.playVideoOnHover; + data.pauseVideoOnHoverOut = settings.pauseVideoOnHoverOut; + data.disableImageChangeOnPolymorphed = settings.disableImageChangeOnPolymorphed; + data.disableImageUpdateOnNonPrototype = settings.disableImageUpdateOnNonPrototype; + data.disableTokenUpdateAnimation = settings.disableTokenUpdateAnimation; + + // Controls + data.pathfinder = ['pf1e', 'pf2e'].includes(game.system.id); + data.dnd5e = game.system.id === 'dnd5e'; + + return data; + } + + /** + * @param {JQuery} html + */ + activateListeners(html) { + super.activateListeners(html); + + // Search Paths + super.activateListeners(html); + html.find('a.create-path').click(this._onCreatePath.bind(this)); + html.on('input', '.searchSource', this._onSearchSourceTextChange.bind(this)); + $(html).on('click', 'a.delete-path', this._onDeletePath.bind(this)); + $(html).on('click', 'a.convert-imgur', this._onConvertImgurPath.bind(this)); + $(html).on('click', 'a.convert-json', this._onConvertJsonPath.bind(this)); + $(html).on('click', '.path-image.source-icon a', this._onBrowseFolder.bind(this)); + $(html).on('click', 'a.select-category', showPathSelectCategoryDialog.bind(this)); + $(html).on('click', 'a.select-config', showPathSelectConfigForm.bind(this)); + + // Search Filters + html.on('input', 'input.filterRegex', this._validateRegex.bind(this)); + + // Active Effects + const disableEffectIcons = html.find('[name="disableEffectIcons"]'); + const filterEffectIcons = html.find('[name="filterEffectIcons"]'); + disableEffectIcons + .on('change', (e) => { + if (e.target.checked) filterEffectIcons.prop('checked', false); + }) + .trigger('change'); + filterEffectIcons.on('change', (e) => { + if (e.target.checked) disableEffectIcons.prop('checked', false); + }); + + // Algorithm + const algorithmTab = $(html).find('div[data-tab="searchAlgorithm"]'); + algorithmTab.find(`input[name="algorithm.exact"]`).change((e) => { + $(e.target) + .closest('form') + .find('input[name="algorithm.fuzzy"]') + .prop('checked', !e.target.checked); + }); + algorithmTab.find(`input[name="algorithm.fuzzy"]`).change((e) => { + $(e.target) + .closest('form') + .find('input[name="algorithm.exact"]') + .prop('checked', !e.target.checked); + }); + algorithmTab.find('input[name="algorithm.fuzzyThreshold"]').change((e) => { + $(e.target).siblings('.token-variants-range-value').html(`${e.target.value}%`); + }); + + // Randomizer + const tokenCreate = html.find('input[name="randomizer.tokenCreate"]'); + const tokenCopyPaste = html.find('input[name="randomizer.tokenCopyPaste"]'); + const tokenToPortrait = html.find('input[name="randomizer.tokenToPortrait"]'); + const _toggle = () => { + tokenToPortrait.prop( + 'disabled', + !(tokenCreate.is(':checked') || tokenCopyPaste.is(':checked')) + ); + }; + tokenCreate.change(_toggle); + tokenCopyPaste.change(_toggle); + + const diffImages = html.find('input[name="randomizer.diffImages"]'); + const syncImages = html.find('input[name="randomizer.syncImages"]'); + diffImages.change(() => { + syncImages.prop('disabled', !diffImages.is(':checked')); + tokenToPortrait.prop('disabled', diffImages.is(':checked')); + }); + + // Token HUD + html.find('input[name="worldHud.updateActorImage"]').change((event) => { + $(event.target) + .closest('form') + .find('input[name="worldHud.useNameSimilarity"]') + .prop('disabled', !event.target.checked); + }); + + // Static Cache + html.find('button.token-variants-cache-images').click((event) => { + const tab = $(event.target).closest('.tab'); + const staticOn = tab.find('input[name="staticCache"]'); + const staticFile = tab.find('input[name="staticCacheFile"]'); + cacheImages({ staticCache: staticOn.is(':checked'), staticCacheFile: staticFile.val() }); + }); + + // Global Mappings + html.find('button.token-variants-global-mapping').click(() => { + 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); + }); + } + + /** + * Validates regex entered into Search Filter's RegEx input field + */ + async _validateRegex(event) { + if (this._validRegex(event.target.value)) { + event.target.style.backgroundColor = ''; + } else { + event.target.style.backgroundColor = '#ff7066'; + } + } + + _validRegex(val) { + if (val) { + try { + new RegExp(val); + } catch (e) { + return false; + } + } + return true; + } + + /** + * Open a FilePicker so the user can select a local folder to use as an image source + */ + async _onBrowseFolder(event) { + const pathInput = $(event.target).closest('.table-row').find('.path-text input'); + const sourceInput = $(event.target).closest('.table-row').find('.path-source input'); + + let activeSource = sourceInput.val() || 'data'; + let current = pathInput.val(); + + if (activeSource.startsWith('s3:')) { + const bucketName = activeSource.replace('s3:', ''); + current = `${game.data.files.s3?.endpoint.protocol}//${bucketName}.${game.data.files.s3?.endpoint.host}/${current}`; + } else if (activeSource.startsWith('rolltable')) { + let content = ``; + + new Dialog({ + title: `Select a Rolltable`, + content: content, + buttons: { + yes: { + icon: "", + label: 'Select', + callback: (html) => { + pathInput.val(); + const tableName = html.find("select[name='table-name']").val(); + pathInput.val(tableName); + }, + }, + }, + default: 'yes', + }).render(true); + return; + } + + if (activeSource === 'json') { + new FilePicker({ + type: 'text', + activeSource: 'data', + current: current, + callback: (path, fp) => { + pathInput.val(path); + }, + }).render(true); + } else { + new FilePicker({ + type: 'folder', + activeSource: activeSource, + current: current, + callback: (path, fp) => { + pathInput.val(fp.result.target); + if (fp.activeSource === 's3') { + sourceInput.val(`s3:${fp.result.bucket}`); + } else { + sourceInput.val(fp.activeSource); + } + }, + }).render(true); + } + } + + /** + * Converts Imgur path to a rolltable + */ + async _onConvertImgurPath(event) { + event.preventDefault(); + + const pathInput = $(event.target).closest('.table-row').find('.path-text input'); + const sourceInput = $(event.target).closest('.table-row').find('.path-source input'); + + const albumHash = pathInput.val(); + const imgurClientId = + TVA_CONFIG.imgurClientId === '' ? 'df9d991443bb222' : TVA_CONFIG.imgurClientId; + + fetch('https://api.imgur.com/3/gallery/album/' + albumHash, { + headers: { + Authorization: 'Client-ID ' + imgurClientId, + Accept: 'application/json', + }, + }) + .then((response) => response.json()) + .then( + async function (result) { + if (!result.success && location.hostname === 'localhost') { + ui.notifications.warn( + game.i18n.format('token-variants.notifications.warn.imgur-localhost') + ); + return; + } + + const data = result.data; + + let resultsArray = []; + data.images.forEach((img, i) => { + resultsArray.push({ + type: 0, + text: img.title ?? img.description ?? '', + weight: 1, + range: [i + 1, i + 1], + collection: 'Text', + drawn: false, + img: img.link, + }); + }); + + await RollTable.create({ + name: data.title, + description: + 'Token Variant Art auto generated RollTable: https://imgur.com/gallery/' + albumHash, + results: resultsArray, + replacement: true, + displayRoll: true, + img: 'modules/token-variants/img/token-images.svg', + }); + + pathInput.val(data.title); + sourceInput.val('rolltable').trigger('input'); + }.bind(this) + ) + .catch((error) => console.warn('TVA | ', error)); + } + + /** + * Converts Json path to a rolltable + */ + async _onConvertJsonPath(event) { + event.preventDefault(); + + const pathInput = $(event.target).closest('.table-row').find('.path-text input'); + const sourceInput = $(event.target).closest('.table-row').find('.path-source input'); + + const jsonPath = pathInput.val(); + + fetch(jsonPath, { + headers: { + Accept: 'application/json', + }, + }) + .then((response) => response.json()) + .then( + async function (result) { + if (!result.length > 0) { + ui.notifications.warn( + game.i18n.format('token-variants.notifications.warn.json-localhost') + ); + return; + } + + const data = result; + data.title = getFileName(jsonPath); + + let resultsArray = []; + data.forEach((img, i) => { + resultsArray.push({ + type: 0, + text: img.name ?? '', + weight: 1, + range: [i + 1, i + 1], + collection: 'Text', + drawn: false, + img: img.path, + }); + }); + + await RollTable.create({ + name: data.title, + description: 'Token Variant Art auto generated RollTable: ' + jsonPath, + results: resultsArray, + replacement: true, + displayRoll: true, + img: 'modules/token-variants/img/token-images.svg', + }); + + pathInput.val(data.title); + sourceInput.val('rolltable').trigger('input'); + }.bind(this) + ) + .catch((error) => console.warn('TVA | ', error)); + } + + /** + * Generates a new search path row + */ + async _onCreatePath(event) { + event.preventDefault(); + const table = $(event.currentTarget).closest('.token-variant-table'); + let row = ` +
  • +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    +
  • + `; + table.append(row); + + this._reIndexPaths(table); + + this.setPosition(); // Auto-resize window + } + + async _reIndexPaths(table) { + table + .find('.path-source') + .find('input') + .each(function (index) { + $(this).attr('name', `searchPaths.${index}.source`); + }); + + table + .find('.path-text') + .find('input') + .each(function (index) { + $(this).attr('name', `searchPaths.${index}.text`); + }); + + table + .find('.path-cache') + .find('input') + .each(function (index) { + $(this).attr('name', `searchPaths.${index}.cache`); + }); + table + .find('.path-category') + .find('input') + .each(function (index) { + $(this).attr('name', `searchPaths.${index}.types`); + }); + table + .find('.path-config') + .find('input') + .each(function (index) { + $(this).attr('name', `searchPaths.${index}.config`); + }); + } + + async _onDeletePath(event) { + event.preventDefault(); + + const li = event.currentTarget.closest('.table-row'); + li.remove(); + + const table = $(event.currentTarget).closest('.token-variant-table'); + this._reIndexPaths(table); + + this.setPosition(); // Auto-resize window + } + + async _onSearchSourceTextChange(event) { + const image = this._pathIcon(event.target.value); + const imgur = image === 'fas fa-info'; + const json = image === 'fas fa-brackets-curly'; + + const imgurControl = $(event.currentTarget).closest('.table-row').find('.imgur-control'); + if (imgur) imgurControl.addClass('active'); + else imgurControl.removeClass('active'); + + const jsonControl = $(event.currentTarget).closest('.table-row').find('.json-control'); + if (json) jsonControl.addClass('active'); + else jsonControl.removeClass('active'); + + $(event.currentTarget).closest('.table-row').find('.path-image i').attr('class', image); + } + + // Return icon appropriate for the path provided + _pathIcon(source) { + if (source.startsWith('s3')) { + return 'fas fa-database'; + } else if (source.startsWith('rolltable')) { + return 'fas fa-dice'; + } else if (source.startsWith('forgevtt') || source.startsWith('forge-bazaar')) { + return 'fas fa-hammer'; + } else if (source.startsWith('imgur')) { + return 'fas fa-info'; + } else if (source.startsWith('json')) { + return 'fas fa-brackets-curly'; + } + + return 'fas fa-folder'; + } + + /** + * @param {Event} event + * @param {Object} formData + */ + async _updateObject(event, formData) { + const settings = this.settings; + formData = expandObject(formData); + + // Search Paths + settings.searchPaths = formData.hasOwnProperty('searchPaths') + ? Object.values(formData.searchPaths) + : []; + settings.searchPaths.forEach((path) => { + if (!path.source) path.source = 'data'; + if (path.types) path.types = path.types.split(','); + else path.types = []; + if (path.config) { + try { + path.config = JSON.parse(path.config); + } catch (e) { + delete path.config; + } + } else delete path.config; + }); + + // Search Filters + for (const filter in formData.searchFilters) { + if (!this._validRegex(formData.searchFilters[filter].regex)) + formData.searchFilters[filter].regex = ''; + } + mergeObject(settings.searchFilters, formData.searchFilters); + + // Algorithm + formData.algorithm.fuzzyLimit = parseInt(formData.algorithm.fuzzyLimit); + if (isNaN(formData.algorithm.fuzzyLimit) || formData.algorithm.fuzzyLimit < 1) + formData.algorithm.fuzzyLimit = 50; + formData.algorithm.fuzzyThreshold = (100 - formData.algorithm.fuzzyThreshold) / 100; + mergeObject(settings.algorithm, formData.algorithm); + + // Randomizer + mergeObject(settings.randomizer, formData.randomizer); + + // Pop-up + mergeObject(settings.popup, formData.popup); + + // Permissions + mergeObject(settings.permissions, formData.permissions); + + // Token HUD + mergeObject(settings.worldHud, formData.worldHud); + + // Internal Effects + mergeObject(settings.internalEffects, formData.internalEffects); + + // Misc + mergeObject(settings, { + keywordSearch: formData.keywordSearch, + excludedKeywords: formData.excludedKeywords, + systemHpPath: formData.systemHpPath?.trim(), + runSearchOnPath: formData.runSearchOnPath, + imgurClientId: formData.imgurClientId, + enableStatusConfig: formData.enableStatusConfig, + disableNotifs: formData.disableNotifs, + staticCache: formData.staticCache, + staticCacheFile: formData.staticCacheFile, + tilesEnabled: formData.tilesEnabled, + stackStatusConfig: formData.stackStatusConfig, + mergeGroup: formData.mergeGroup, + customImageCategories: (formData.customImageCategories || '') + .split(',') + .map((t) => t.trim()) + .filter((t) => t), + disableEffectIcons: formData.disableEffectIcons, + displayEffectIconsOnHover: formData.displayEffectIconsOnHover, + filterEffectIcons: formData.filterEffectIcons, + filterCustomEffectIcons: formData.filterCustomEffectIcons, + filterIconList: (formData.filterIconList || '') + .split(',') + .map((t) => t.trim()) + .filter((t) => t), + updateTokenProto: formData.updateTokenProto, + imgNameContainsDimensions: formData.imgNameContainsDimensions, + imgNameContainsFADimensions: formData.imgNameContainsFADimensions, + playVideoOnHover: formData.playVideoOnHover, + pauseVideoOnHoverOut: formData.pauseVideoOnHoverOut, + disableImageChangeOnPolymorphed: formData.disableImageChangeOnPolymorphed, + disableImageUpdateOnNonPrototype: formData.disableImageUpdateOnNonPrototype, + disableTokenUpdateAnimation: formData.disableTokenUpdateAnimation, + }); + + // Global Mappings + settings.globalMappings = TVA_CONFIG.globalMappings; + + // Save Settings + if (this.dummySettings) { + mergeObjectFix(this.dummySettings, settings, { insertKeys: false }); + } else { + updateSettings(settings); + } + } +} + +// ======================== +// v8 support, broken merge +// ======================== +export function mergeObjectFix( + 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)) _mergeUpdate(original, k, v, options, _d + 1); + else _mergeInsertFix(original, k, v, options, _d + 1); + } + return original; +} + +function _mergeInsertFix(original, k, v, { insertKeys, insertValues } = {}, _d) { + // Recursively create simple objects + if (v?.constructor === Object && insertKeys) { + original[k] = mergeObjectFix({}, 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; +} + +function _mergeUpdate( + 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 mergeObjectFix( + 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; + } +} diff --git a/Data/modules/token-variants/applications/dialogs.js b/Data/modules/token-variants/applications/dialogs.js new file mode 100644 index 00000000..6a163ae2 --- /dev/null +++ b/Data/modules/token-variants/applications/dialogs.js @@ -0,0 +1,363 @@ +import { CORE_TEMPLATES } from '../scripts/mappingTemplates.js'; +import { TVA_CONFIG, updateSettings } from '../scripts/settings.js'; +import { BASE_IMAGE_CATEGORIES, uploadTokenImage } from '../scripts/utils.js'; +import { sortMappingsToGroups } from './effectMappingForm.js'; +import TokenCustomConfig from './tokenCustomConfig.js'; + +// Edit overlay configuration as a json string +export function showOverlayJsonConfigDialog(overlayConfig, callback) { + const config = deepClone(overlayConfig || {}); + delete config.effect; + let content = `
    `; + + new Dialog({ + title: `Overlay Configuration`, + content: content, + buttons: { + yes: { + icon: "", + label: 'Save', + callback: (html) => { + let json = $(html).find('.configJson').val(); + if (json) { + try { + json = JSON.parse(json); + } catch (e) { + console.warn(`TVA |`, e); + json = {}; + } + } else { + json = {}; + } + callback(json); + }, + }, + }, + default: 'yes', + }).render(true); +} + +// Change categories assigned to a path +export async function showPathSelectCategoryDialog(event) { + event.preventDefault(); + const typesInput = $(event.target).closest('.path-category').find('input'); + const selectedTypes = typesInput.val().split(','); + + const categories = BASE_IMAGE_CATEGORIES.concat(TVA_CONFIG.customImageCategories); + + let content = '
    '; + + // Split into rows of 4 + const splits = []; + let currSplit = []; + for (let i = 0; i < categories.length; i++) { + if (i > 0 && i + 1 != categories.length && i % 4 == 0) { + splits.push(currSplit); + currSplit = []; + } + currSplit.push(categories[i]); + } + if (currSplit.length) splits.push(currSplit); + + for (const split of splits) { + content += '
    '; + for (const type of split) { + content += ``; + } + content += '
    '; + } + content += '
    '; + + new Dialog({ + title: `Image Categories/Filters`, + content: content, + buttons: { + yes: { + icon: "", + label: 'Apply', + callback: (html) => { + const types = []; + $(html) + .find('.category') + .each(function () { + if ($(this).is(':checked')) { + types.push($(this).attr('name')); + } + }); + typesInput.val(types.join(',')); + }, + }, + }, + default: 'yes', + }).render(true); +} + +// Change configs assigned to a path +export async function showPathSelectConfigForm(event) { + event.preventDefault(); + const configInput = $(event.target).closest('.path-config').find('input'); + let config = {}; + try { + config = JSON.parse(configInput.val()); + } catch (e) {} + + const setting = game.settings.get('core', DefaultTokenConfig.SETTING); + const data = new foundry.data.PrototypeToken(setting); + const token = new TokenDocument(data, { actor: null }); + new TokenCustomConfig( + token, + {}, + null, + null, + (conf) => { + if (!conf) conf = {}; + if (conf.flags == null || isEmpty(conf.flags)) delete conf.flags; + configInput.val(JSON.stringify(conf)); + const cog = configInput.siblings('.select-config'); + if (isEmpty(conf)) cog.removeClass('active'); + else cog.addClass('active'); + }, + config + ).render(true); +} + +export async function showTokenCaptureDialog(token) { + if (!token) return; + let content = `
    +
    + + +
    +
    + +
    + + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    `; + + new Dialog({ + title: `Save Token/Overlay Image`, + content: content, + buttons: { + yes: { + icon: "", + label: 'Save', + callback: (html) => { + const options = {}; + $(html) + .find('[name]') + .each(function () { + let val = parseFloat(this.value); + if (isNaN(val)) val = this.value; + options[this.name] = val; + }); + uploadTokenImage(token, options); + }, + }, + }, + render: (html) => { + html.find('.file-picker').click(() => { + new FilePicker({ + type: 'folder', + current: html.find('[name="path"]').val(), + callback: (path) => { + html.find('[name="path"]').val(path); + }, + }).render(); + }); + }, + default: 'yes', + }).render(true); +} + +export function showMappingSelectDialog( + mappings, + { title1 = 'Mappings', title2 = 'Select Mappings', buttonTitle = 'Confirm', callback = null } = {} +) { + if (!mappings || !mappings.length) return; + + let content = `

    ${title2}

    `; + + const [_, mappingGroups] = sortMappingsToGroups(mappings); + for (const [group, obj] of Object.entries(mappingGroups)) { + if (obj.list.length) { + content += `

    ${group}

    `; + for (const mapping of obj.list) { + content += ` +
    + +
    + +
    +
    + `; + } + } + } + + content += `
    `; + + new Dialog({ + title: title1, + content: content, + buttons: { + Ok: { + label: buttonTitle, + callback: async (html) => { + if (!callback) return; + const selectedMappings = []; + html.find('input[type="checkbox"]').each(function () { + if (this.checked) { + const mapping = mappings.find((m) => m.id === this.name); + if (mapping) { + const cMapping = deepClone(mapping); + selectedMappings.push(cMapping); + delete cMapping.targetActors; + } + } + }); + callback(selectedMappings); + }, + }, + }, + render: (html) => { + html.find('.select-all').click(() => { + html.find('input[type="checkbox"]').prop('checked', true); + }); + }, + }).render(true); +} + +function showUserTemplateCreateDialog(mappings) { + let content = ` +
    + +
    + +
    +
    +
    + +
    + +
    +
    `; + + let dialog; + dialog = new Dialog({ + title: 'Mapping Templates', + content, + buttons: { + create: { + label: 'Create Template', + callback: (html) => { + const name = html.find('[name="templateName"]').val(); + const hint = html.find('[name="templateHint"]').val(); + if (name.trim()) { + TVA_CONFIG.templateMappings.push({ name, hint, mappings: deepClone(mappings) }); + updateSettings({ templateMappings: TVA_CONFIG.templateMappings }); + } + }, + }, + cancel: { + label: 'Cancel', + }, + }, + default: 'cancel', + }); + dialog.render(true); +} + +export function showMappingTemplateDialog(mappings, callback) { + let user_t = `USER Templates`; + for (const template of TVA_CONFIG.templateMappings) { + if (!template.id) template.id = randomID(8); + user_t += `${ + template.name + }`; + } + user_t = '' + user_t + '
    '; + + user_t += `'`; + + let core_t = `CORE Templates`; + for (const template of CORE_TEMPLATES) { + if (!template.id) template.id = randomID(8); + core_t += `${ + template.name + }`; + } + core_t = '' + core_t + '
    '; + + let content = + '' + user_t + '
    ' + core_t; + + let dialog; + dialog = new Dialog({ + title: 'Mapping Templates', + content, + buttons: {}, + render: (html) => { + html.find('.template').on('click', (event) => { + let id = $(event.target).closest('tr').data('id'); + if (id) { + let template = + CORE_TEMPLATES.find((t) => t.id === id) || TVA_CONFIG.templateMappings.find((t) => t.id === id); + callback(template); + } + }); + html.find('.delete-template').on('click', async (event) => { + const row = $(event.target).closest('tr'); + const id = row.data('id'); + if (id) { + await updateSettings({ + templateMappings: TVA_CONFIG.templateMappings.filter((m) => m.id !== id), + }); + row.remove(); + } + }); + html.find('.create-template').on('click', () => { + showMappingSelectDialog(mappings, { + title1: 'Create Template', + callback: (selectedMappings) => { + if (selectedMappings.length) showUserTemplateCreateDialog(selectedMappings); + }, + }); + dialog.close(); + }); + }, + }); + dialog.render(true); +} diff --git a/Data/modules/token-variants/applications/effectMappingForm.js b/Data/modules/token-variants/applications/effectMappingForm.js new file mode 100644 index 00000000..95ef9497 --- /dev/null +++ b/Data/modules/token-variants/applications/effectMappingForm.js @@ -0,0 +1,900 @@ +import { showArtSelect } from '../token-variants.mjs'; +import { SEARCH_TYPE, getFileName, isVideo, keyPressed } from '../scripts/utils.js'; +import TokenCustomConfig from './tokenCustomConfig.js'; +import { + TVA_CONFIG, + getFlagMappings, + migrateMappings, + updateSettings, +} from '../scripts/settings.js'; +import EditJsonConfig from './configJsonEdit.js'; +import EditScriptConfig from './configScriptEdit.js'; +import OverlayConfig from './overlayConfig.js'; +import { + showMappingSelectDialog, + showMappingTemplateDialog, + showOverlayJsonConfigDialog, + showTokenCaptureDialog, +} from './dialogs.js'; +import { DEFAULT_ACTIVE_EFFECT_CONFIG } from '../scripts/models.js'; +import { updateWithEffectMapping } from '../scripts/hooks/effectMappingHooks.js'; +import { drawOverlays } from '../scripts/token/overlay.js'; + +// Persist group toggles across forms +let TOGGLED_GROUPS; + +export default class EffectMappingForm extends FormApplication { + constructor(token, { globalMappings = false, callback = null, createMapping = null } = {}) { + super({}, { title: (globalMappings ? 'GLOBAL ' : 'ACTOR ') + 'Mappings' }); + + this.token = token; + if (globalMappings) { + this.globalMappings = deepClone(TVA_CONFIG.globalMappings).filter(Boolean); + } + if (!globalMappings) this.objectToFlag = game.actors.get(token.actorId); + this.callback = callback; + TOGGLED_GROUPS = game.settings.get('token-variants', 'effectMappingToggleGroups') || { + Default: true, + }; + this.createMapping = createMapping; + } + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: 'token-variants-active-effect-config', + classes: ['sheet'], + template: 'modules/token-variants/templates/effectMappingForm.html', + resizable: true, + minimizable: false, + closeOnSubmit: false, + width: 1020, + height: 'auto', + scrollY: ['ol.token-variant-table'], + }); + } + + _processConfig(mapping) { + if (!mapping.config) mapping.config = {}; + let hasTokenConfig = Object.keys(mapping.config).filter((k) => mapping.config[k]).length; + if (mapping.config.flags) hasTokenConfig--; + if (mapping.config.tv_script) hasTokenConfig--; + + return { + id: mapping.id || randomID(8), + label: mapping.label, + expression: mapping.expression, + highlightedExpression: highlightOperators(mapping.expression), + imgName: mapping.imgName, + imgSrc: mapping.imgSrc, + isVideo: mapping.imgSrc ? isVideo(mapping.imgSrc) : false, + priority: mapping.priority, + hasConfig: mapping.config ? !isEmpty(mapping.config) : false, + hasScript: mapping.config && mapping.config.tv_script, + hasTokenConfig: hasTokenConfig > 0, + config: mapping.config, + overlay: mapping.overlay, + alwaysOn: mapping.alwaysOn, + disabled: mapping.disabled, + overlayConfig: mapping.overlayConfig, + targetActors: mapping.targetActors, + group: mapping.group, + parentID: mapping.overlayConfig?.parentID, + }; + } + + async getData(options) { + const data = super.getData(options); + + let mappings = []; + if (this.object.mappings) { + mappings = this.object.mappings.map(this._processConfig); + } else { + const effectMappings = this.globalMappings ?? getFlagMappings(this.objectToFlag); + mappings = effectMappings.map(this._processConfig); + + if ( + this.createMapping && + !effectMappings.find((m) => m.expression === this.createMapping.expression) + ) { + mappings.push(this._processConfig(this._getNewEffectConfig(this.createMapping))); + } + this.createMapping = null; + } + + mappings = mappings.sort((m1, m2) => { + if (!m1.label && m2.label) return -1; + else if (m1.label && !m2.label) return 1; + + if (!m1.overlayConfig?.parentID && m2.overlayConfig?.parentID) return -1; + else if (m1.overlayConfig?.parentID && !m2.overlayConfig?.parentID) return 1; + + let priorityDiff = m1.priority - m2.priority; + if (priorityDiff === 0) return m1.label.localeCompare(m2.label); + return priorityDiff; + }); + + const [sMappings, groupedMappings] = sortMappingsToGroups(mappings); + data.groups = Object.keys(groupedMappings); + + this.object.mappings = sMappings; + data.groupedMappings = groupedMappings; + data.global = Boolean(this.globalMappings); + return data; + } + + /** + * @param {JQuery} html + */ + activateListeners(html) { + super.activateListeners(html); + html.find('.delete-mapping').click(this._onRemove.bind(this)); + html.find('.clone-mapping').click(this._onClone.bind(this)); + html.find('.create-mapping').click(this._onCreate.bind(this)); + html.find('.save-mappings').click(this._onSaveMappings.bind(this)); + if (TVA_CONFIG.permissions.image_path_button[game.user.role]) { + html.find('.mapping-image img').click(this._onImageClick.bind(this)); + html.find('.mapping-image img').mousedown(this._onImageMouseDown.bind(this)); + html.find('.mapping-image video').click(this._onImageClick.bind(this)); + html.find('.mapping-target').click(this._onConfigureApplicableActors.bind(this)); + } + html.find('.mapping-image img').contextmenu(this._onImageRightClick.bind(this)); + html.find('.mapping-image video').contextmenu(this._onImageRightClick.bind(this)); + html.find('.mapping-config i.config').click(this._onConfigClick.bind(this)); + html.find('.mapping-config i.config-edit').click(this._onConfigEditClick.bind(this)); + html.find('.mapping-config i.config-script').click(this._onConfigScriptClick.bind(this)); + html.find('.mapping-overlay i.overlay-config').click(this._onOverlayConfigClick.bind(this)); + html.on( + 'contextmenu', + '.mapping-overlay i.overlay-config', + this._onOverlayConfigRightClick.bind(this) + ); + html.find('.mapping-overlay input').on('change', this._onOverlayChange).trigger('change'); + html.find('.div-input').on('input paste focus click', this._onExpressionChange); + const app = this; + html + .find('.group-toggle > a') + .on('click', this._onGroupToggle.bind(this)) + .each(function () { + const group = $(this).closest('.group-toggle'); + const groupName = group.data('group'); + if (!TOGGLED_GROUPS[groupName]) { + $(this).trigger('click'); + } + }); + this.setPosition({ width: 1020 }); + html.find('.mapping-disable > input').on('change', this._onDisable.bind(this)); + html.find('.group-disable > a').on('click', this._onGroupDisable.bind(this)); + html.find('.group-delete').on('click', this._onGroupDelete.bind(this)); + html.find('.mapping-group > input').on('change', this._onGroupChange.bind(this)); + } + + async _onDisable(event) { + const groupName = $(event.target).closest('.table-row').data('group'); + const disableGroupToggle = $(event.target) + .closest('.token-variant-table') + .find(`.group-disable[data-group="${groupName}"]`); + + const checkboxes = $(event.target) + .closest('.token-variant-table') + .find(`[data-group="${groupName}"] > .mapping-disable`); + const numChecked = checkboxes.find('input:checked').length; + + if (checkboxes.length !== numChecked) { + disableGroupToggle.addClass('active'); + } else disableGroupToggle.removeClass('active'); + } + + async _onGroupDisable(event) { + const group = $(event.target).closest('.group-disable'); + const groupName = group.data('group'); + const chks = $(event.target) + .closest('form') + .find(`[data-group="${groupName}"]`) + .find('.mapping-disable > input'); + + if (group.hasClass('active')) { + group.removeClass('active'); + chks.prop('checked', true); + } else { + group.addClass('active'); + chks.prop('checked', false); + } + } + + async _onGroupDelete(event) { + const group = $(event.target).closest('.group-delete'); + const groupName = group.data('group'); + await this._onSubmit(event); + this.object.mappings = this.object.mappings.filter((m) => m.group !== groupName); + this.render(); + } + + async _onGroupChange(event) { + const input = $(event.target); + let group = input.val().trim(); + if (!group) group = 'Default'; + input.val(group); + + await this._onSubmit(event); + this.render(); + } + + _onGroupToggle(event) { + const group = $(event.target).closest('.group-toggle'); + const groupName = group.data('group'); + const form = $(event.target).closest('form'); + form.find(`li[data-group="${groupName}"]`).toggle(); + if (group.hasClass('active')) { + group.removeClass('active'); + group.find('i').addClass('fa-rotate-180'); + TOGGLED_GROUPS[groupName] = false; + } else { + group.addClass('active'); + group.find('i').removeClass('fa-rotate-180'); + TOGGLED_GROUPS[groupName] = true; + } + game.settings.set('token-variants', 'effectMappingToggleGroups', TOGGLED_GROUPS); + this.setPosition({ height: 'auto' }); + } + + async _onExpressionChange(event) { + var el = event.target; + + // Update the hidden input field so that the text entered in the div will be submitted via the form + $(el).siblings('input').val(event.target.innerText); + + // The rest of the function is to handle operator highlighting and management of the caret position + + if (!el.childNodes.length) return; + + // Calculate the true/total caret offset within the div + const sel = window.getSelection(); + const focusNode = sel.focusNode; + let offset = sel.focusOffset; + + for (const ch of el.childNodes) { + if (ch === focusNode || ch.childNodes[0] === focusNode) break; + offset += ch.nodeName === 'SPAN' ? ch.innerText.length : ch.length; + } + + // Highlight the operators and update the div + let text = highlightOperators(event.target.innerText); + $(event.target).html(text); + + // Set the new caret position with the div + setCaretPosition(el, offset); + } + + async _onOverlayChange(event) { + if (event.target.checked) { + $(event.target).siblings('a').show(); + } else { + $(event.target).siblings('a').hide(); + } + } + + async _onOverlayConfigClick(event) { + const li = event.currentTarget.closest('.table-row'); + const mapping = this.object.mappings[li.dataset.index]; + + new OverlayConfig( + mapping.overlayConfig, + (config) => { + mapping.overlayConfig = config; + const gear = $(li).find('.mapping-overlay > a'); + if (config?.parentID && config.parentID !== 'TOKEN') { + gear.addClass('child'); + gear.attr('title', 'Child Of: ' + config.parentID); + } else { + gear.removeClass('child'); + gear.attr('title', ''); + } + }, + mapping.id, + this.token + ).render(true); + } + + async _onOverlayConfigRightClick(event) { + const li = event.currentTarget.closest('.table-row'); + const mapping = this.object.mappings[li.dataset.index]; + showOverlayJsonConfigDialog( + mapping.overlayConfig, + (config) => (mapping.overlayConfig = config) + ); + } + + async _toggleActiveControls(event) { + const li = event.currentTarget.closest('.table-row'); + const mapping = this.object.mappings[li.dataset.index]; + const tokenConfig = $(event.target).closest('.mapping-config').find('.config'); + const configEdit = $(event.target).closest('.mapping-config').find('.config-edit'); + const scriptEdit = $(event.target).closest('.mapping-config').find('.config-script'); + + let hasTokenConfig = Object.keys(mapping.config).filter((k) => mapping.config[k]).length; + if (mapping.config.flags) hasTokenConfig--; + if (mapping.config.tv_script) hasTokenConfig--; + + if (hasTokenConfig) tokenConfig.addClass('active'); + else tokenConfig.removeClass('active'); + + if (Object.keys(mapping.config).filter((k) => mapping.config[k]).length) + configEdit.addClass('active'); + else configEdit.removeClass('active'); + + if (mapping.config.tv_script) scriptEdit.addClass('active'); + else scriptEdit.removeClass('active'); + } + + async _onConfigScriptClick(event) { + const li = event.currentTarget.closest('.table-row'); + const mapping = this.object.mappings[li.dataset.index]; + + new EditScriptConfig(mapping.config?.tv_script, (script) => { + if (!mapping.config) mapping.config = {}; + if (script) mapping.config.tv_script = script; + else delete mapping.config.tv_script; + this._toggleActiveControls(event); + }).render(true); + } + + async _onConfigEditClick(event) { + const li = event.currentTarget.closest('.table-row'); + const mapping = this.object.mappings[li.dataset.index]; + + new EditJsonConfig(mapping.config, (config) => { + mapping.config = config; + this._toggleActiveControls(event); + }).render(true); + } + + async _onConfigClick(event) { + const li = event.currentTarget.closest('.table-row'); + const mapping = this.object.mappings[li.dataset.index]; + new TokenCustomConfig( + this.token, + {}, + null, + null, + (config) => { + if (!config || isEmpty(config)) { + config = {}; + config.tv_script = mapping.config.tv_script; + config.flags = mapping.config.flags; + } + mapping.config = config; + this._toggleActiveControls(event); + }, + mapping.config ? mapping.config : {} + ).render(true); + } + + _removeImage(event) { + const vid = $(event.target).closest('.mapping-image').find('video'); + const img = $(event.target).closest('.mapping-image').find('img'); + vid.add(img).attr('src', '').attr('title', ''); + vid.hide(); + img.show(); + $(event.target).siblings('.imgSrc').val(''); + $(event.target).siblings('.imgName').val(''); + } + + async _onImageMouseDown(event) { + if (event.which === 2) { + this._removeImage(event); + } + } + + async _onImageClick(event) { + if (keyPressed('config')) { + this._removeImage(event); + return; + } + + let search = this.token.name; + if (search === 'Unknown') { + const li = event.currentTarget.closest('.table-row'); + const mapping = this.object.mappings[li.dataset.index]; + search = mapping.label; + } + + showArtSelect(search, { + searchType: SEARCH_TYPE.TOKEN, + callback: (imgSrc, imgName) => { + const vid = $(event.target).closest('.mapping-image').find('video'); + const img = $(event.target).closest('.mapping-image').find('img'); + vid.add(img).attr('src', imgSrc).attr('title', imgName); + if (isVideo(imgSrc)) { + vid.show(); + img.hide(); + } else { + vid.hide(); + img.show(); + } + $(event.target).siblings('.imgSrc').val(imgSrc); + $(event.target).siblings('.imgName').val(imgName); + }, + }); + } + + async _onImageRightClick(event) { + if (keyPressed('config')) { + this._removeImage(event); + return; + } + + const li = event.currentTarget.closest('.table-row'); + const mapping = this.object.mappings[li.dataset.index]; + + new FilePicker({ + type: 'imagevideo', + current: mapping.imgSrc, + callback: (path) => { + const vid = $(event.target).closest('.mapping-image').find('video'); + const img = $(event.target).closest('.mapping-image').find('img'); + vid.add(img).attr('src', path).attr('title', getFileName(path)); + if (isVideo(path)) { + vid.show(); + img.hide(); + } else { + vid.hide(); + img.show(); + } + $(event.target).siblings('.imgSrc').val(path); + $(event.target).siblings('.imgName').val(getFileName(path)); + }, + }).render(); + } + + async _onRemove(event) { + event.preventDefault(); + await this._onSubmit(event); + const li = event.currentTarget.closest('.table-row'); + this.object.mappings.splice(li.dataset.index, 1); + this.render(); + } + + async _onClone(event) { + event.preventDefault(); + await this._onSubmit(event); + const li = event.currentTarget.closest('.table-row'); + const clone = deepClone(this.object.mappings[li.dataset.index]); + clone.label = clone.label + ' - Copy'; + clone.id = randomID(8); + this.object.mappings.push(clone); + this.render(); + } + + async _onCreate(event) { + event.preventDefault(); + await this._onSubmit(event); + this.object.mappings.push(this._getNewEffectConfig()); + this.render(); + } + + _getNewEffectConfig({ label = '', expression = '' } = {}) { + // if (textOverlay) { + // TOGGLED_GROUPS['Text Overlays'] = true; + // return { + // id: randomID(8), + // label: label, + // expression: label, + // highlightedExpression: highlightOperators(label), + // imgName: '', + // imgSrc: '', + // priority: 50, + // overlay: false, + // alwaysOn: false, + // disabled: false, + // group: 'Text Overlays', + // overlay: true, + // overlayConfig: mergeObject( + // DEFAULT_OVERLAY_CONFIG, + // { + // img: '', + // linkScale: false, + // linkRotation: false, + // linkMirror: false, + // offsetY: 0.5 + Math.round(Math.random() * 0.3 * 100) / 100, + // offsetX: 0, + // scaleX: 0.68, + // scaleY: 0.68, + // text: { + // text: '{{effect}}', + // fontFamily: CONFIG.defaultFontFamily, + // fontSize: 36, + // fill: new Color(Math.round(Math.random() * 16777215)).toString(), + // stroke: '#000000', + // strokeThickness: 2, + // dropShadow: false, + // curve: { + // radius: 160, + // invert: false, + // }, + // }, + // animation: { + // rotate: true, + // duration: 10000 + Math.round(Math.random() * 14000) + 10000, + // clockwise: true, + // }, + // }, + // { inplace: false } + // ), + // }; + // } else { + TOGGLED_GROUPS['Default'] = true; + return mergeObject(deepClone(DEFAULT_ACTIVE_EFFECT_CONFIG), { + label, + expression, + id: randomID(8), + }); + // } + } + + _getHeaderButtons() { + const buttons = super._getHeaderButtons(); + + buttons.unshift({ + label: 'Export', + class: 'token-variants-export', + icon: 'fas fa-file-export', + onclick: (ev) => this._exportConfigs(ev), + }); + buttons.unshift({ + label: 'Import', + class: 'token-variants-import', + icon: 'fas fa-file-import', + onclick: (ev) => this._importConfigs(ev), + }); + + buttons.unshift({ + label: 'Templates', + class: 'token-variants-templates', + icon: 'fa-solid fa-book', + onclick: async (ev) => { + showMappingTemplateDialog( + this.globalMappings ?? getFlagMappings(this.objectToFlag), + (template) => { + this._insertMappings(ev, template.mappings); + } + ); + }, + }); + + if (this.globalMappings) return buttons; + + buttons.unshift({ + label: 'Copy Global Config', + class: 'token-variants-copy-global', + icon: 'fas fa-globe', + onclick: (ev) => this._copyGlobalConfig(ev), + }); + buttons.unshift({ + label: 'Open Global', + class: 'token-variants-open-global', + icon: 'fas fa-globe', + onclick: async (ev) => { + await this.close(); + new EffectMappingForm(this.token, { globalMappings: true }).render(true); + }, + }); + + buttons.unshift({ + label: '', + class: 'token-variants-print-token', + icon: 'fa fa-print', + onclick: () => showTokenCaptureDialog(canvas.tokens.get(this.token._id)), + }); + return buttons; + } + + async _exportConfigs(event) { + let mappings; + let filename = ''; + if (this.globalMappings) { + mappings = { globalMappings: deepClone(TVA_CONFIG.globalMappings) }; + filename = 'token-variants-global-mappings.json'; + } else { + mappings = { + globalMappings: deepClone(getFlagMappings(this.objectToFlag)), + }; + + let actorName = this.objectToFlag.name ?? 'Actor'; + actorName = actorName.replace(/[/\\?%*:|"<>]/g, '-'); + filename = 'token-variants-' + actorName + '.json'; + } + + if (mappings && !isEmpty(mappings)) { + saveDataToFile(JSON.stringify(mappings, null, 2), 'text/json', filename); + } + } + + async _importConfigs(event) { + const content = await renderTemplate('templates/apps/import-data.html', { + entity: 'token-variants', + name: 'settings', + }); + let dialog = new Promise((resolve, reject) => { + new Dialog( + { + title: 'Import Effect Configurations', + content: content, + buttons: { + import: { + icon: '', + label: game.i18n.localize('token-variants.common.import'), + callback: (html) => { + const form = html.find('form')[0]; + if (!form.data.files.length) + return ui.notifications?.error('You did not upload a data file!'); + readTextFromFile(form.data.files[0]).then((json) => { + json = JSON.parse(json); + if (!json || !('globalMappings' in json)) { + return ui.notifications?.error('No mappings found within the file!'); + } + + this._insertMappings(event, migrateMappings(json.globalMappings)); + resolve(true); + }); + }, + }, + no: { + icon: '', + label: 'Cancel', + callback: (html) => resolve(false), + }, + }, + default: 'import', + }, + { + width: 400, + } + ).render(true); + }); + return await dialog; + } + + _copyGlobalConfig(event) { + showMappingSelectDialog(TVA_CONFIG.globalMappings, { + title1: 'Global Mappings', + title2: 'Select Mappings to Copy:', + buttonTitle: 'Copy', + callback: (mappings) => { + this._insertMappings(event, mappings); + }, + }); + } + + async _insertMappings(event, mappings) { + const cMappings = deepClone(mappings).map(this._processConfig); + await this._onSubmit(event); + + const changedIDs = {}; + + for (const m of cMappings) { + const i = this.object.mappings.findIndex( + (mapping) => mapping.label === m.label && mapping.group === m.group + ); + if (i === -1) this.object.mappings.push(m); + else { + changedIDs[this.object.mappings.id] = m.id; + this.object.mappings[i] = m; + } + if (m.group) { + TOGGLED_GROUPS[m.group] = true; + } + } + + // If parent's id has been changed we need to update all the children + this.object.mappings.forEach((m) => { + let pID = m.overlayConfig?.parentID; + if (pID && pID in changedIDs) { + m.overlayConfig.parentID = changedIDs[pID]; + } + }); + this.render(); + } + + _onConfigureApplicableActors(event) { + const li = event.currentTarget.closest('.table-row'); + const mapping = this.object.mappings[li.dataset.index]; + + let actorTypes = (game.system.entityTypes ?? game.system.documentTypes)['Actor']; + let actors = []; + for (const t of actorTypes) { + const label = CONFIG['Actor']?.typeLabels?.[t] ?? t; + actors.push({ + id: t, + label: game.i18n.has(label) ? game.i18n.localize(label) : t, + enabled: !mapping.targetActors || mapping.targetActors.includes(t), + }); + } + + let content = '
    '; + for (const act of actors) { + content += ` +
    + +
    + +
    +
    + `; + } + content += `
    `; + + new Dialog({ + title: `Configure Applicable Actors`, + content: content, + buttons: { + Ok: { + label: `Save`, + callback: async (html) => { + let targetActors = []; + html.find('input[type="checkbox"]').each(function () { + if (this.checked) { + targetActors.push(this.name); + } + }); + mapping.targetActors = targetActors; + }, + }, + }, + render: (html) => { + html.find('.select-all').click(() => { + html.find('input[type="checkbox"]').prop('checked', true); + }); + }, + }).render(true); + } + + // TODO fix this spaghetti code related to globalMappings... + async _onSaveMappings(event) { + await this._onSubmit(event); + if (this.objectToFlag || this.globalMappings) { + // First filter out empty mappings + let mappings = this.object.mappings; + mappings = mappings.filter((m) => Boolean(m.label?.trim()) || Boolean(m.expression?.trim())); + + // Make sure a priority is assigned + for (const mapping of mappings) { + mapping.priority = mapping.priority ? mapping.priority : 50; + mapping.overlayConfig = mapping.overlayConfig ?? {}; + mapping.overlayConfig.label = mapping.label; + } + + if (mappings.length !== 0) { + const effectMappings = mappings.map((m) => + mergeObject(DEFAULT_ACTIVE_EFFECT_CONFIG, m, { + inplace: false, + insertKeys: false, + recursive: false, + }) + ); + if (this.globalMappings) { + updateSettings({ globalMappings: effectMappings }); + } else { + await this.objectToFlag.unsetFlag('token-variants', 'effectMappings'); + await this.objectToFlag.setFlag('token-variants', 'effectMappings', effectMappings); + } + } else if (this.globalMappings) { + updateSettings({ globalMappings: [] }); + } else { + await this.objectToFlag.unsetFlag('token-variants', 'effectMappings'); + } + + const tokens = this.globalMappings + ? canvas.tokens.placeables + : this.objectToFlag.getActiveTokens(); + for (const tkn of tokens) { + if (TVA_CONFIG.filterEffectIcons) { + await tkn.drawEffects(); + } + await updateWithEffectMapping(tkn); + drawOverlays(tkn); + } + + // Instruct users on other scenes to refresh the overlays + const message = { + handlerName: 'drawOverlays', + args: { all: true, sceneId: canvas.scene.id }, + type: 'UPDATE', + }; + game.socket?.emit('module.token-variants', message); + } + if (this.callback) this.callback(); + this.close(); + } + + /** + * @param {Event} event + * @param {Object} formData + */ + async _updateObject(event, formData) { + const mappings = expandObject(formData).mappings ?? {}; + + // Merge form data with internal mappings + for (let i = 0; i < this.object.mappings.length; i++) { + const m1 = mappings[i]; + const m2 = this.object.mappings[i]; + m2.id = m1.id; + m2.label = m1.label.replaceAll(String.fromCharCode(160), ' '); + m2.expression = m1.expression.replaceAll(String.fromCharCode(160), ' '); + m2.imgSrc = m1.imgSrc; + m2.imgName = m1.imgName; + m2.priority = m1.priority; + m2.overlay = m1.overlay; + m2.alwaysOn = m1.alwaysOn; + m2.disabled = m1.disabled; + m2.group = m1.group; + } + } +} + +// Insert around operators +function highlightOperators(text) { + // text = text.replaceAll(' ', ' '); + + const re = new RegExp('([a-zA-Z\\.\\-\\|\\+]+)([><=]+)(".*?"|-?\\d+)(%{0,1})', `gi`); + text = text.replace(re, function replace(match) { + return '' + match + ''; + }); + + for (const op of ['\\(', '\\)', '&&', '||', '\\!', '\\*', '\\{', '\\}']) { + text = text.replaceAll(op, `${op}`); + } + return text; +} + +// Move caret to a specific point in a DOM element +function setCaretPosition(el, pos) { + for (var node of el.childNodes) { + // Check if it's a text node + if (node.nodeType == 3) { + if (node.length >= pos) { + var range = document.createRange(), + sel = window.getSelection(); + range.setStart(node, pos); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + return -1; // We are done + } else { + pos -= node.length; + } + } else { + pos = setCaretPosition(node, pos); + if (pos == -1) { + return -1; // No need to finish the for loop + } + } + } + return pos; +} + +export function sortMappingsToGroups(mappings) { + mappings.sort((m1, m2) => { + if (!m1.label && m2.label) return -1; + else if (m1.label && !m2.label) return 1; + + if (!m1.overlayConfig?.parentID && m2.overlayConfig?.parentID) return -1; + else if (m1.overlayConfig?.parentID && !m2.overlayConfig?.parentID) return 1; + + let priorityDiff = m1.priority - m2.priority; + if (priorityDiff === 0) return m1.label.localeCompare(m2.label); + return priorityDiff; + }); + + let groupedMappings = { Default: { list: [], active: false } }; + mappings.forEach((mapping, index) => { + mapping.i = index; // assign so that we can reference the mapping inside of an array + if (!mapping.group || !mapping.group.trim()) mapping.group = 'Default'; + if (!(mapping.group in groupedMappings)) + groupedMappings[mapping.group] = { list: [], active: false }; + if (!mapping.disabled) groupedMappings[mapping.group].active = true; + groupedMappings[mapping.group].list.push(mapping); + }); + return [mappings, groupedMappings]; +} diff --git a/Data/modules/token-variants/applications/flagsConfig.js b/Data/modules/token-variants/applications/flagsConfig.js new file mode 100644 index 00000000..b0c3a452 --- /dev/null +++ b/Data/modules/token-variants/applications/flagsConfig.js @@ -0,0 +1,87 @@ +export default class FlagsConfig extends FormApplication { + constructor(obj) { + super({}, {}); + if (obj instanceof Tile) { + this.objectToFlag = obj.document; + this.isTile = true; + } else { + this.objectToFlag = game.actors.get(obj.document.actorId) || obj.document; + } + } + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: 'token-variants-token-flags', + classes: ['sheet'], + template: 'modules/token-variants/templates/flagsConfig.html', + resizable: true, + minimizable: false, + title: 'Flags', + width: 500, + }); + } + + async getData(options) { + const data = super.getData(options); + const popups = this.objectToFlag.getFlag('token-variants', 'popups'); + const disableNameSearch = this.objectToFlag.getFlag('token-variants', 'disableNameSearch'); + const directory = this.objectToFlag.getFlag('token-variants', 'directory') || {}; + + return mergeObject(data, { + popups: popups, + popupsSetFlag: popups != null, + disableNameSearch: disableNameSearch, + disableNameSearchSetFlag: disableNameSearch != null, + directory: directory.path, + directorySource: directory.source, + directorySetFlag: !isEmpty(directory), + tile: this.isTile, + }); + } + + /** + * @param {JQuery} html + */ + activateListeners(html) { + super.activateListeners(html); + html.find('.controlFlag').click((e) => { + $(e.target).siblings('.flag').prop('disabled', !e.target.checked); + }); + html.find('.directory-fp').click((event) => { + new FilePicker({ + type: 'folder', + activeSource: 'data', + callback: (path, fp) => { + html.find('[name="directory"]').val(fp.result.target); + $(event.target) + .closest('button') + .attr('title', 'Directory: ' + fp.result.target); + const sourceEl = html.find('[name="directorySource"]'); + if (fp.activeSource === 's3') { + sourceEl.val(`s3:${fp.result.bucket}`); + } else { + sourceEl.val(fp.activeSource); + } + }, + }).render(true); + }); + } + + /** + * @param {Event} event + * @param {Object} formData + */ + async _updateObject(event, formData) { + if ('directory' in formData) { + formData.directory = { path: formData.directory, source: formData.directorySource }; + } + + ['popups', 'disableNameSearch', 'directory'].forEach((flag) => { + if (flag in formData) { + this.objectToFlag.setFlag('token-variants', flag, formData[flag]); + } else { + this.objectToFlag.unsetFlag('token-variants', flag); + } + }); + } +} diff --git a/Data/modules/token-variants/applications/forgeSearchPaths.js b/Data/modules/token-variants/applications/forgeSearchPaths.js new file mode 100644 index 00000000..70e04a0e --- /dev/null +++ b/Data/modules/token-variants/applications/forgeSearchPaths.js @@ -0,0 +1,180 @@ +import { TVA_CONFIG, updateSettings } from '../scripts/settings.js'; +import { showPathSelectCategoryDialog, showPathSelectConfigForm } from './dialogs.js'; + +export class ForgeSearchPaths extends FormApplication { + constructor() { + super({}, {}); + } + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: 'token-variants-search-paths', + classes: ['sheet'], + template: 'modules/token-variants/templates/forgeSearchPaths.html', + resizable: true, + minimizable: false, + closeOnSubmit: false, + title: game.i18n.localize('token-variants.settings.search-paths.Name'), + width: 592, + height: 'auto', + scrollY: ['ol.token-variant-table'], + dragDrop: [{ dragSelector: null, dropSelector: null }], + }); + } + + async getData(options) { + if (!this.object.paths) this.object.paths = await this._getPaths(); + + const paths = this.object.paths.map((path) => { + const r = {}; + r.text = path.text; + r.cache = path.cache; + r.share = path.share; + r.types = path.types.join(','); + r.config = JSON.stringify(path.config ?? {}); + return r; + }); + + const data = super.getData(options); + data.paths = paths; + data.apiKey = this.apiKey; + return data; + } + + async _getPaths() { + const forgePaths = deepClone(TVA_CONFIG.forgeSearchPaths) || {}; + this.userId = typeof ForgeAPI !== 'undefined' ? await ForgeAPI.getUserId() : 'tempUser'; // TODO + this.apiKey = forgePaths[this.userId]?.apiKey; + return forgePaths[this.userId]?.paths || []; + } + + /** + * @param {JQuery} html + */ + activateListeners(html) { + super.activateListeners(html); + html.find('a.create-path').click(this._onCreatePath.bind(this)); + $(html).on('click', 'a.select-category', showPathSelectCategoryDialog.bind(this)); + $(html).on('click', 'a.select-config', showPathSelectConfigForm.bind(this)); + html.find('a.delete-path').click(this._onDeletePath.bind(this)); + html.find('button.reset').click(this._onReset.bind(this)); + html.find('button.update').click(this._onUpdate.bind(this)); + $(html).on('click', '.path-image.source-icon a', this._onBrowseFolder.bind(this)); + } + + /** + * Open a FilePicker so the user can select a local folder to use as an image source + */ + async _onBrowseFolder(event) { + const pathInput = $(event.target).closest('.table-row').find('.path-text input'); + + new FilePicker({ + type: 'folder', + activeSource: 'forgevtt', + current: pathInput.val(), + callback: (path, fp) => { + if (fp.activeSource !== 'forgevtt') { + ui.notifications.warn("Token Variant Art: Only 'Assets Library' paths are supported"); + } else { + pathInput.val(fp.result.target); + } + }, + }).render(true); + } + + async _onCreatePath(event) { + event.preventDefault(); + await this._onSubmit(event); + + this.object.paths.push({ + text: '', + cache: true, + share: true, + types: ['Portrait', 'Token', 'PortraitAndToken'], + }); + this.render(); + } + + async _onDeletePath(event) { + event.preventDefault(); + await this._onSubmit(event); + + const li = event.currentTarget.closest('.table-row'); + this.object.paths.splice(li.dataset.index, 1); + this.render(); + } + + _onReset(event) { + event.preventDefault(); + this.object.paths = this._getPaths(); + this.render(); + } + + async _onUpdate(event) { + event.preventDefault(); + await this._onSubmit(event); + this._updatePaths(); + } + + async _updateObject(event, formData) { + const expanded = expandObject(formData); + expanded.paths = expanded.hasOwnProperty('paths') ? Object.values(expanded.paths) : []; + expanded.paths.forEach((path, index) => { + this.object.paths[index] = { + text: path.text, + cache: path.cache, + share: path.share, + source: path.source, + types: path.types.split(','), + }; + if (path.config) { + try { + path.config = JSON.parse(path.config); + if (!isEmpty(path.config)) { + this.object.paths[index].config = path.config; + } + } catch (e) {} + } + }); + this.apiKey = expanded.apiKey; + } + + _cleanPaths() { + // Cleanup empty and duplicate paths + let uniquePaths = new Set(); + let paths = this.object.paths.filter((path) => { + if (!path.text || uniquePaths.has(path.text)) return false; + uniquePaths.add(path.text); + return true; + }); + return paths; + } + + _updatePaths() { + if (this.userId) { + const forgePaths = deepClone(TVA_CONFIG.forgeSearchPaths) || {}; + forgePaths[this.userId] = { + paths: this._cleanPaths(), + apiKey: this.apiKey, + }; + + if (game.user.isGM) { + updateSettings({ forgeSearchPaths: forgePaths }); + } else { + // Workaround for forgeSearchPaths setting to be updated by non-GM clients + const message = { + handlerName: 'forgeSearchPaths', + args: forgePaths, + type: 'UPDATE', + }; + game.socket?.emit('module.token-variants', message); + } + } + } + + async close(options = {}) { + await this._onSubmit(event); + this._updatePaths(); + return super.close(options); + } +} diff --git a/Data/modules/token-variants/applications/importExport.js b/Data/modules/token-variants/applications/importExport.js new file mode 100644 index 00000000..b278f764 --- /dev/null +++ b/Data/modules/token-variants/applications/importExport.js @@ -0,0 +1,68 @@ +import { importSettingsFromJSON, exportSettingsToJSON } from '../scripts/settings.js'; + +export default class ImportExport extends FormApplication { + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: 'token-variants-import-export', + classes: ['sheet'], + template: 'modules/token-variants/templates/importExport.html', + resizable: false, + minimizable: false, + title: 'Import/Export', + width: 250, + }); + } + + /** + * @param {JQuery} html + */ + activateListeners(html) { + super.activateListeners(html); + html.find('.import').click(this._importFromJSONDialog.bind(this)); + html.find('.export').click(() => { + exportSettingsToJSON(); + this.close(); + }); + } + + async _importFromJSONDialog() { + const content = await renderTemplate('templates/apps/import-data.html', { + entity: 'token-variants', + name: 'settings', + }); + let dialog = new Promise((resolve, reject) => { + new Dialog( + { + title: game.i18n.localize('token-variants.settings.import-export.window.import-dialog'), + content: content, + buttons: { + import: { + icon: '', + label: game.i18n.localize('token-variants.common.import'), + callback: (html) => { + const form = html.find('form')[0]; + if (!form.data.files.length) + return ui.notifications?.error('You did not upload a data file!'); + readTextFromFile(form.data.files[0]).then((json) => { + importSettingsFromJSON(json); + resolve(true); + }); + }, + }, + no: { + icon: '', + label: 'Cancel', + callback: (html) => resolve(false), + }, + }, + default: 'import', + }, + { + width: 400, + } + ).render(true); + }); + this.close(); + return await dialog; + } +} diff --git a/Data/modules/token-variants/applications/missingImageConfig.js b/Data/modules/token-variants/applications/missingImageConfig.js new file mode 100644 index 00000000..079dc655 --- /dev/null +++ b/Data/modules/token-variants/applications/missingImageConfig.js @@ -0,0 +1,135 @@ +import { TVA_CONFIG, updateSettings } from '../scripts/settings.js'; +import { getFileName } from '../scripts/utils.js'; +import { showArtSelect } from '../token-variants.mjs'; + +export default class MissingImageConfig extends FormApplication { + constructor() { + super({}, {}); + } + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: 'token-variants-missing-images', + classes: ['sheet'], + template: 'modules/token-variants/templates/missingImageConfig.html', + resizable: true, + minimizable: false, + title: 'Define Missing Images', + width: 560, + height: 'auto', + }); + } + + async getData(options) { + const data = super.getData(options); + + if (!this.missingImages) + this.missingImages = deepClone(TVA_CONFIG.compendiumMapper.missingImages); + + data.missingImages = this.missingImages; + + data.documents = ['all', 'Actor', 'Cards', 'Item', 'Macro', 'RollTable']; + return data; + } + + _processFormData(formData) { + if (!Array.isArray(formData.document)) { + formData.document = [formData.document]; + formData.image = [formData.image]; + } + + const missingImages = []; + for (let i = 0; i < formData.document.length; i++) { + missingImages.push({ document: formData.document[i], image: formData.image[i] }); + } + return missingImages; + } + + /** + * @param {JQuery} html + */ + activateListeners(html) { + super.activateListeners(html); + + html.on('click', '.add-row', () => { + const formData = this._getSubmitData(); + this.missingImages = this._processFormData(formData); + this.missingImages.push({ document: 'all', image: CONST.DEFAULT_TOKEN }); + this.render(); + }); + + html.on('click', '.delete-row', (event) => { + const formData = this._getSubmitData(); + this.missingImages = this._processFormData(formData); + const index = $(event.target).closest('li')[0].dataset.index; + this.missingImages.splice(index, 1); + this.render(); + }); + + html.on('click', '.file-picker', (event) => { + new FilePicker({ + type: 'imagevideo', + callback: (path) => { + $(event.target).closest('li').find('[name="image"]').val(path); + $(event.target).closest('li').find('img').attr('src', path); + }, + }).render(); + }); + + html.on('click', '.duplicate-picker', (event) => { + let content = ``; + + new Dialog({ + title: `Compendiums`, + content: content, + buttons: { + yes: { + icon: "", + label: 'Search for Duplicates', + callback: (html) => { + const found = new Set(); + const duplicates = new Set(); + const compendium = game.packs.get(html.find("[name='compendium']").val()); + compendium.index.forEach((k) => { + if (found.has(k.img)) { + duplicates.add(k.img); + } + found.add(k.img); + }); + if (!duplicates.size) { + ui.notifications.info('No duplicates found in: ' + compendium.title); + } + + const images = Array.from(duplicates).map((img) => { + return { path: img, name: getFileName(img) }; + }); + const allImages = new Map(); + allImages.set('Duplicates', images); + + showArtSelect('Duplicates', { + allImages, + callback: (img) => { + $(event.target).closest('li').find('[name="image"]').val(img); + $(event.target).closest('li').find('img').attr('src', img); + }, + }); + }, + }, + }, + default: 'yes', + }).render(true); + }); + } + + async _updateObject(event, formData) { + updateSettings({ + compendiumMapper: { missingImages: this._processFormData(formData) }, + }); + } +} diff --git a/Data/modules/token-variants/applications/overlayConfig.js b/Data/modules/token-variants/applications/overlayConfig.js new file mode 100644 index 00000000..56b485b4 --- /dev/null +++ b/Data/modules/token-variants/applications/overlayConfig.js @@ -0,0 +1,1207 @@ +import { CORE_SHAPE, DEFAULT_OVERLAY_CONFIG, OVERLAY_SHAPES } from '../scripts/models.js'; +import { VALID_EXPRESSION, getAllEffectMappings } from '../scripts/hooks/effectMappingHooks.js'; +import { evaluateOverlayExpressions, genTexture } from '../scripts/token/overlay.js'; +import { SEARCH_TYPE } from '../scripts/utils.js'; +import { showArtSelect } from '../token-variants.mjs'; +import { sortMappingsToGroups } from './effectMappingForm.js'; +import { getFlagMappings } from '../scripts/settings.js'; + +export default class OverlayConfig extends FormApplication { + constructor(config, callback, id, token) { + super({}, {}); + this.config = config ?? {}; + this.config.id = id; + this.callback = callback; + this.token = canvas.tokens.get(token._id); + this.previewConfig = deepClone(this.config); + } + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + id: 'token-variants-overlay-config', + classes: ['sheet'], + template: 'modules/token-variants/templates/overlayConfig.html', + resizable: false, + minimizable: false, + title: 'Overlay Settings', + width: 500, + height: 'auto', + tabs: [{ navSelector: '.sheet-tabs', contentSelector: '.content', initial: 'misc' }], + }); + } + + /** + * @param {JQuery} html + */ + activateListeners(html) { + super.activateListeners(html); + + html.find('.repeat').on('change', (event) => { + const fieldset = $(event.target).closest('fieldset'); + const content = fieldset.find('.content'); + if (event.target.checked) { + content.show(); + fieldset.addClass('active'); + } else { + content.hide(); + fieldset.removeClass('active'); + } + this.setPosition(); + }); + + // Insert Controls to the Shape Legend + const shapeLegends = html.find('.shape-legend'); + let config = this.config; + shapeLegends.each(function (i) { + const legend = $(this); + legend.append( + `  +  ` + ); + if (i != 0) { + legend.append( + ` ` + ); + } + if (i != shapeLegends.length - 1) { + legend.append( + ` ` + ); + } + legend.append( + `` + ); + }); + + // Shape listeners + html.find('.addShape').on('click', this._onAddShape.bind(this)); + html.find('.addEvent').on('click', this._onAddEvent.bind(this)); + html.find('.deleteShape').on('click', this._onDeleteShape.bind(this)); + html.find('.deleteEvent').on('click', this._onDeleteEvent.bind(this)); + html.find('.moveShapeUp').on('click', this._onMoveShapeUp.bind(this)); + html.find('.moveShapeDown').on('click', this._onMoveShapeDown.bind(this)); + html.find('.cloneShape').on('click', this._onCloneShape.bind(this)); + + html.find('input,select').on('change', this._onInputChange.bind(this)); + html.find('textarea').on('input', this._onInputChange.bind(this)); + html.find('[name="parentID"]').on('change', (event) => { + if (event.target.value === 'TOKEN') { + html.find('.token-specific-fields').show(); + } else { + html.find('.token-specific-fields').hide(); + } + this.setPosition(); + }); + html.find('[name="parentID"]').trigger('change'); + + html.find('[name="filter"]').on('change', (event) => { + html.find('.filterOptions').empty(); + const filterOptions = $(genFilterOptionControls(event.target.value)); + html.find('.filterOptions').append(filterOptions); + this.setPosition({ height: 'auto' }); + this.activateListeners(filterOptions); + }); + + html.find('.token-variants-image-select-button').click((event) => { + showArtSelect(this.token?.name ?? 'overlay', { + searchType: SEARCH_TYPE.TOKEN, + callback: (imgSrc, imgName) => { + if (imgSrc) + $(event.target).closest('.form-group').find('input').val(imgSrc).trigger('change'); + }, + }); + }); + + html.find('.presetImport').on('click', (event) => { + const presetName = $(event.target).closest('.form-group').find('.tmfxPreset').val(); + if (presetName) { + const preset = TokenMagic.getPreset(presetName); + if (preset) { + $(event.target) + .closest('.form-group') + .find('textarea') + .val(JSON.stringify(preset, null, 2)) + .trigger('input'); + } + } + }); + + // Controls for locking scale sliders together + let scaleState = { locked: true }; + + // Range inputs need to be triggered when slider moves to initiate preview + html + .find('.range-value') + .siblings('[type="range"]') + .on('change', (event) => { + $(event.target).siblings('.range-value').val(event.target.value).trigger('change'); + }); + + const lockButtons = $(html).find('.scaleLock > a'); + const sliderScaleWidth = $(html).find('[name="scaleX"]'); + const sliderScaleHeight = $(html).find('[name="scaleY"]'); + const sliderWidth = html.find('.scaleX'); + const sliderHeight = html.find('.scaleY'); + + lockButtons.on('click', function () { + scaleState.locked = !scaleState.locked; + lockButtons.html( + scaleState.locked ? '' : '' + ); + }); + + sliderScaleWidth.on('change', function () { + if (scaleState.locked && sliderScaleWidth.val() !== sliderScaleHeight.val()) { + sliderScaleHeight.val(sliderScaleWidth.val()).trigger('change'); + sliderHeight.val(sliderScaleWidth.val()); + } + }); + sliderScaleHeight.on('change', function () { + if (scaleState.locked && sliderScaleWidth.val() !== sliderScaleHeight.val()) { + sliderScaleWidth.val(sliderScaleHeight.val()).trigger('change'); + sliderWidth.val(sliderScaleHeight.val()); + } + }); + html.on('change', '.scaleX', () => { + sliderScaleWidth.trigger('change'); + }); + html.on('change', '.scaleY', () => { + sliderScaleHeight.trigger('change'); + }); + + html.find('.me-edit-json').on('click', async (event) => { + const textarea = $(event.target).closest('.form-group').find('textarea'); + let params; + try { + params = eval(textarea.val()); + } catch (e) { + console.warn('TVA |', e); + } + + if (params) { + let param; + if (Array.isArray(params)) { + if (params.length === 1) param = params[0]; + else { + let i = await promptParamChoice(params); + if (i < 0) return; + param = params[i]; + } + } else { + param = params; + } + + if (param) + game.modules + .get('multi-token-edit') + .api.showGenericForm(param, param.filterType ?? 'TMFX', { + inputChangeCallback: (selected) => { + mergeObject(param, selected, { inplace: true }); + textarea.val(JSON.stringify(params, null, 2)).trigger('input'); + }, + }); + } + }); + + const underlay = html.find('[name="underlay"]'); + const top = html.find('[name="top"]'); + const bottom = html.find('[name="bottom"]'); + underlay.change(function () { + if (this.checked) top.prop('checked', false); + else bottom.prop('checked', false); + }); + top.change(function () { + if (this.checked) { + underlay.prop('checked', false); + bottom.prop('checked', false); + } + }); + bottom.change(function () { + if (this.checked) { + underlay.prop('checked', true); + top.prop('checked', false); + } + }); + + const linkScale = html.find('[name="linkScale"]'); + const linkDimensions = html.find('[name="linkDimensionsX"], [name="linkDimensionsY"]'); + const linkStageScale = html.find('[name="linkStageScale"]'); + linkScale.change(function () { + if (this.checked) { + linkDimensions.prop('checked', false); + linkStageScale.prop('checked', false); + } + }); + linkDimensions.change(function () { + if (this.checked) { + linkScale.prop('checked', false); + linkStageScale.prop('checked', false); + } + }); + linkStageScale.change(function () { + if (this.checked) { + linkScale.prop('checked', false); + linkDimensions.prop('checked', false); + } + }); + + // Setting border color for property expression + const limitOnProperty = html.find('[name="limitOnProperty"]'); + limitOnProperty.on('input', (event) => { + const input = $(event.target); + if (input.val() === '') { + input.removeClass('tvaValid'); + input.removeClass('tvaInvalid'); + } else if (input.val().match(VALID_EXPRESSION)) { + input.addClass('tvaValid'); + input.removeClass('tvaInvalid'); + } else { + input.addClass('tvaInvalid'); + input.removeClass('tvaValid'); + } + }); + limitOnProperty.trigger('input'); + + html.find('.create-variable').on('click', this._onCreateVariable.bind(this)); + html.find('.delete-variable').on('click', this._onDeleteVariable.bind(this)); + } + + _onDeleteVariable(event) { + let index = $(event.target).closest('tr').data('index'); + if (index != null) { + this.config = this._getSubmitData(); + if (!this.config.variables) this.config.variables = []; + this.config.variables.splice(index, 1); + this.render(true); + } + } + + _onCreateVariable(event) { + this.config = this._getSubmitData(); + if (!this.config.variables) this.config.variables = []; + this.config.variables.push({ name: '', value: '' }); + this.render(true); + } + + _onAddShape(event) { + let shape = $(event.target).siblings('select').val(); + shape = deepClone(OVERLAY_SHAPES[shape]); + shape = mergeObject(deepClone(CORE_SHAPE), { shape }); + + this.config = this._getSubmitData(); + + if (!this.config.shapes) this.config.shapes = []; + this.config.shapes.push(shape); + + this.render(true); + } + + _onAddEvent(event) { + let listener = $(event.target).siblings('select').val(); + + this.config = this._getSubmitData(); + if (!this.config.interactivity) this.config.interactivity = []; + this.config.interactivity.push({ listener, macro: '', script: '' }); + + this.render(true); + } + + _onDeleteShape(event) { + const index = $(event.target).closest('.deleteShape').data('index'); + if (index == null) return; + + this.config = this._getSubmitData(); + if (!this.config.shapes) this.config.shapes = []; + this.config.shapes.splice(index, 1); + + this.render(true); + } + + _onDeleteEvent(event) { + const index = $(event.target).closest('.deleteEvent').data('index'); + if (index == null) return; + + this.config = this._getSubmitData(); + if (!this.config.interactivity) this.config.interactivity = []; + this.config.interactivity.splice(index, 1); + + this.render(true); + } + + _onCloneShape(event) { + const index = $(event.target).closest('.cloneShape').data('index'); + if (!index && index != 0) return; + + this.config = this._getSubmitData(); + if (!this.config.shapes) return; + const nShape = deepClone(this.config.shapes[index]); + if (nShape.label) { + nShape.label = nShape.label + ' - Copy'; + } + this.config.shapes.push(nShape); + + this.render(true); + } + + _onMoveShapeUp(event) { + const index = $(event.target).closest('.moveShapeUp').data('index'); + if (!index) return; + + this.config = this._getSubmitData(); + if (!this.config.shapes) this.config.shapes = []; + if (this.config.shapes.length >= 2) this._swapShapes(index, index - 1); + + this.render(true); + } + + _onMoveShapeDown(event) { + const index = $(event.target).closest('.moveShapeDown').data('index'); + if (!index && index != 0) return; + + this.config = this._getSubmitData(); + if (!this.config.shapes) this.config.shapes = []; + if (this.config.shapes.length >= 2) this._swapShapes(index, index + 1); + + this.render(true); + } + + _swapShapes(i1, i2) { + let temp = this.config.shapes[i1]; + this.config.shapes[i1] = this.config.shapes[i2]; + this.config.shapes[i2] = temp; + } + + _convertColor(colString) { + try { + const c = Color.fromString(colString); + const rgba = c.rgb; + rgba.push(1); + return rgba; + } catch (e) { + return [1, 1, 1, 1]; + } + } + + async _onInputChange(event) { + this.previewConfig = this._getSubmitData(); + if (event.target.type === 'color') { + const color = $(event.target).siblings('.color'); + color.val(event.target.value).trigger('change'); + return; + } + this._applyPreviews(); + } + + getPreviewIcons() { + if (!this.config.id) return []; + const tokens = this.token ? [this.token] : canvas.tokens.placeables; + const previewIcons = []; + for (const tkn of tokens) { + if (tkn.tva_sprites) { + for (const c of tkn.tva_sprites) { + if (c.overlayConfig && c.overlayConfig.id === this.config.id) { + // Effect icon found, however if we're in global preview then we need to take into account + // a token/actor specific mapping which may override the global one + if (this.token) { + previewIcons.push({ token: tkn, icon: c }); + } else if (!getFlagMappings(tkn).find((m) => m.id === this.config.id)) { + previewIcons.push({ token: tkn, icon: c }); + } + } + } + } + } + return previewIcons; + } + + async _applyPreviews() { + const targets = this.getPreviewIcons(); + for (const target of targets) { + const preview = evaluateOverlayExpressions(deepClone(this.previewConfig), target.token, { + overlayConfig: this.previewConfig, + }); + target.icon.refresh(preview, { + preview: true, + previewTexture: await genTexture(target.token, preview), + }); + } + } + + async _removePreviews() { + const targets = this.getPreviewIcons(); + for (const target of targets) { + target.icon.refresh(); + } + } + + async getData(options) { + const data = super.getData(options); + data.filters = Object.keys(PIXI.filters); + data.filters.push('OutlineOverlayFilter'); + data.filters.sort(); + data.tmfxActive = game.modules.get('tokenmagic')?.active; + if (data.tmfxActive) { + data.tmfxPresets = TokenMagic.getPresets().map((p) => p.name); + data.filters.unshift('Token Magic FX'); + } + data.filters.unshift('NONE'); + const settings = mergeObject(DEFAULT_OVERLAY_CONFIG, this.config, { + inplace: false, + }); + data.ceActive = game.modules.get('dfreds-convenient-effects')?.active; + if (data.ceActive) { + data.ceEffects = game.dfreds.effects.all.map((ef) => ef.name); + } + data.macros = game.macros.map((m) => m.name); + + if (settings.filter !== 'NONE') { + const filterOptions = genFilterOptionControls(settings.filter, settings.filterOptions); + if (filterOptions) { + settings.filterOptions = filterOptions; + } else { + settings.filterOptions = null; + } + } else { + settings.filterOptions = null; + } + + data.users = game.users.map((u) => { + return { id: u.id, name: u.name, selected: settings.limitedUsers.includes(u.id) }; + }); + + data.fonts = Object.keys(CONFIG.fontDefinitions); + + const allMappings = getAllEffectMappings(this.token, true).filter( + (m) => m.id !== this.config.id + ); + const [_, groupedMappings] = sortMappingsToGroups(allMappings); + + data.parents = groupedMappings; + if (!data.parentID) data.parentID = 'TOKEN'; + if (!data.anchor) data.anchor = { x: 0.5, y: 0.5 }; + + // Cache Partials + for (const shapeName of Object.keys(OVERLAY_SHAPES)) { + await getTemplate(`modules/token-variants/templates/partials/shape${shapeName}.html`); + } + await getTemplate('modules/token-variants/templates/partials/repeating.html'); + await getTemplate('modules/token-variants/templates/partials/interpolateColor.html'); + + data.allShapes = Object.keys(OVERLAY_SHAPES); + data.textAlignmentOptions = [ + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' }, + { value: 'justify', label: 'Justify' }, + ]; + + // linkDimensions has been converted to linkDimensionsX and linkDimensionsY + // Make sure we're using the latest fields + // 20/07/2023 + if (!('linkDimensionsX' in settings) && settings.linkDimensions) { + settings.linkDimensionsX = true; + settings.linkDimensionsY = true; + } + + return mergeObject(data, settings); + } + + _getHeaderButtons() { + const buttons = super._getHeaderButtons(); + buttons.unshift({ + label: 'Core Variables', + class: '.core-variables', + icon: 'fas fa-file-import fa-fw', + onclick: () => { + let content = ` + + + + + + +
    VariableDescription
    @hpActor Health
    @hpMaxActor Health (Max)
    @gridSizeGrid Size (Pixels)
    @labelMapping's Label Field
    + `; + + new Dialog({ + title: `Core Variables`, + content, + buttons: {}, + }).render(true); + }, + }); + return buttons; + } + + async close(options = {}) { + super.close(options); + this._removePreviews(); + } + + _getSubmitData() { + let formData = super._getSubmitData(); + formData = expandObject(formData); + + if (!formData.repeating) delete formData.repeat; + if (!formData.text.repeating) delete formData.text.repeat; + + if (formData.shapes) { + formData.shapes = Object.values(formData.shapes); + formData.shapes.forEach((shape) => { + if (!shape.repeating) delete shape.repeat; + }); + } + + if (formData.interactivity) { + formData.interactivity = Object.values(formData.interactivity) + .map((e) => { + e.macro = e.macro.trim(); + e.script = e.script.trim(); + if (e.tmfxPreset) e.tmfxPreset = e.tmfxPreset.trim(); + if (e.ceEffect) e.ceEffect = e.ceEffect.trim(); + return e; + }) + .filter((e) => e.macro || e.script || e.ceEffect || e.tmfxPreset); + } else { + formData.interactivity = []; + } + + if (formData.variables) { + formData.variables = Object.values(formData.variables); + formData.variables = formData.variables.filter((v) => v.name.trim() && v.value.trim()); + } + if (formData.limitedUsers) { + if (getType(formData.limitedUsers) === 'string') + formData.limitedUsers = [formData.limitedUsers]; + formData.limitedUsers = formData.limitedUsers.filter((uid) => uid); + } else { + formData.limitedUsers = []; + } + + formData.limitOnEffect = formData.limitOnEffect.trim(); + formData.limitOnProperty = formData.limitOnProperty.trim(); + if (formData.parentID === 'TOKEN') formData.parentID = ''; + + if (formData.filter === 'OutlineOverlayFilter' && 'filterOptions.outlineColor' in formData) { + formData['filterOptions.outlineColor'] = this._convertColor( + formData['filterOptions.outlineColor'] + ); + } else if (formData.filter === 'BevelFilter') { + if ('filterOptions.lightColor' in formData) + formData['filterOptions.lightColor'] = Number( + Color.fromString(formData['filterOptions.lightColor']) + ); + if ('filterOptions.shadowColor' in formData) + formData['filterOptions.shadowColor'] = Number( + Color.fromString(formData['filterOptions.shadowColor']) + ); + } else if ( + ['DropShadowFilter', 'GlowFilter', 'OutlineFilter', 'FilterFire'].includes(formData.filter) + ) { + if ('filterOptions.color' in formData) + formData['filterOptions.color'] = Number(Color.fromString(formData['filterOptions.color'])); + } + + return formData; + } + + /** + * @param {Event} event + * @param {Object} formData + */ + async _updateObject(event, formData) { + if (this.callback) this.callback(formData); + } +} + +export const FILTERS = { + OutlineOverlayFilter: { + defaultValues: { + outlineColor: [0, 0, 0, 1], + trueThickness: 1, + animate: false, + }, + controls: [ + { + type: 'color', + name: 'outlineColor', + }, + { + type: 'range', + label: 'Thickness', + name: 'trueThickness', + min: 0, + max: 5, + step: 0.01, + }, + { + type: 'boolean', + label: 'Oscillate', + name: 'animate', + }, + ], + argType: 'args', + }, + AlphaFilter: { + defaultValues: { + alpha: 1, + }, + controls: [ + { + type: 'range', + name: 'alpha', + min: 0, + max: 1, + step: 0.01, + }, + ], + argType: 'args', + }, + BlurFilter: { + defaultValues: { + strength: 8, + quality: 4, + }, + controls: [ + { type: 'range', name: 'strength', min: 0, max: 20, step: 1 }, + { type: 'range', name: 'quality', min: 0, max: 20, step: 1 }, + ], + argType: 'args', + }, + BlurFilterPass: { + defaultValues: { + horizontal: false, + strength: 8, + quality: 4, + }, + controls: [ + { + type: 'boolean', + name: 'horizontal', + }, + { type: 'range', name: 'strength', min: 0, max: 20, step: 1 }, + { type: 'range', name: 'quality', min: 0, max: 20, step: 1 }, + ], + argType: 'args', + }, + NoiseFilter: { + defaultValues: { + noise: 0.5, + seed: 4475160954091, + }, + controls: [ + { type: 'range', name: 'noise', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'seed', min: 0, max: 100000, step: 1 }, + ], + argType: 'args', + }, + AdjustmentFilter: { + defaultValues: { + gamma: 1, + saturation: 1, + contrast: 1, + brightness: 1, + red: 1, + green: 1, + blue: 1, + alpha: 1, + }, + controls: [ + { type: 'range', name: 'gamma', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'saturation', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'contrast', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'brightness', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'red', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'green', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'blue', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'alpha', min: 0, max: 1, step: 0.01 }, + ], + argType: 'options', + }, + AdvancedBloomFilter: { + defaultValues: { + threshold: 0.5, + bloomScale: 1, + brightness: 1, + blur: 8, + quality: 4, + }, + controls: [ + { type: 'range', name: 'threshold', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'bloomScale', min: 0, max: 5, step: 0.01 }, + { type: 'range', name: 'brightness', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'blur', min: 0, max: 20, step: 1 }, + { type: 'range', name: 'quality', min: 0, max: 20, step: 1 }, + ], + argType: 'options', + }, + AsciiFilter: { + defaultValues: { + size: 8, + }, + controls: [{ type: 'range', name: 'size', min: 0, max: 20, step: 0.01 }], + argType: 'args', + }, + BevelFilter: { + defaultValues: { + rotation: 45, + thickness: 2, + lightColor: 0xffffff, + lightAlpha: 0.7, + shadowColor: 0x000000, + shadowAlpha: 0.7, + }, + controls: [ + { type: 'range', name: 'rotation', min: 0, max: 360, step: 1 }, + { type: 'range', name: 'thickness', min: 0, max: 20, step: 0.01 }, + { type: 'color', name: 'lightColor' }, + { type: 'range', name: 'lightAlpha', min: 0, max: 1, step: 0.01 }, + { type: 'color', name: 'shadowColor' }, + { type: 'range', name: 'shadowAlpha', min: 0, max: 1, step: 0.01 }, + ], + argType: 'options', + }, + BloomFilter: { + defaultValues: { + blur: 2, + quality: 4, + }, + controls: [ + { type: 'range', name: 'blur', min: 0, max: 20, step: 1 }, + { type: 'range', name: 'quality', min: 0, max: 20, step: 1 }, + ], + argType: 'args', + }, + BulgePinchFilter: { + defaultValues: { + radius: 100, + strength: 1, + }, + controls: [ + { type: 'range', name: 'radius', min: 0, max: 500, step: 1 }, + { type: 'range', name: 'strength', min: -1, max: 1, step: 0.01 }, + ], + argType: 'options', + }, + CRTFilter: { + defaultValues: { + curvature: 1, + lineWidth: 1, + lineContrast: 0.25, + verticalLine: false, + noise: 0.3, + noiseSize: 1, + seed: 0, + vignetting: 0.3, + vignettingAlpha: 1, + vignettingBlur: 0.3, + time: 0, + }, + controls: [ + { type: 'range', name: 'curvature', min: 0, max: 20, step: 0.01 }, + { type: 'range', name: 'lineWidth', min: 0, max: 20, step: 0.01 }, + { type: 'range', name: 'lineContrast', min: 0, max: 5, step: 0.01 }, + { type: 'boolean', name: 'verticalLine' }, + { type: 'range', name: 'noise', min: 0, max: 2, step: 0.01 }, + { type: 'range', name: 'noiseSize', min: 0, max: 20, step: 0.01 }, + { type: 'range', name: 'seed', min: 0, max: 100000, step: 1 }, + { type: 'range', name: 'vignetting', min: 0, max: 20, step: 0.01 }, + { type: 'range', name: 'vignettingAlpha', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'vignettingBlur', min: 0, max: 5, step: 0.01 }, + { type: 'range', name: 'time', min: 0, max: 10000, step: 1 }, + ], + argType: 'options', + }, + DotFilter: { + defaultValues: { + scale: 1, + angle: 5, + }, + controls: [ + { type: 'range', name: 'scale', min: 0, max: 50, step: 1 }, + { type: 'range', name: 'angle', min: 0, max: 360, step: 0.1 }, + ], + argType: 'args', + }, + DropShadowFilter: { + defaultValues: { + rotation: 45, + distance: 5, + color: 0x000000, + alpha: 0.5, + shadowOnly: false, + blur: 2, + quality: 3, + }, + controls: [ + { type: 'range', name: 'rotation', min: 0, max: 360, step: 0.1 }, + { type: 'range', name: 'distance', min: 0, max: 100, step: 0.1 }, + { type: 'color', name: 'color' }, + { type: 'range', name: 'alpha', min: 0, max: 1, step: 0.01 }, + { type: 'boolean', name: 'shadowOnly' }, + { type: 'range', name: 'blur', min: 0, max: 20, step: 0.1 }, + { type: 'range', name: 'quality', min: 0, max: 20, step: 1 }, + ], + argType: 'options', + }, + EmbossFilter: { + defaultValues: { + strength: 5, + }, + controls: [{ type: 'range', name: 'strength', min: 0, max: 20, step: 1 }], + argType: 'args', + }, + GlitchFilter: { + defaultValues: { + slices: 5, + offset: 100, + direction: 0, + fillMode: 0, + seed: 0, + average: false, + minSize: 8, + sampleSize: 512, + }, + controls: [ + { type: 'range', name: 'slices', min: 0, max: 50, step: 1 }, + { type: 'range', name: 'distance', min: 0, max: 1000, step: 1 }, + { type: 'range', name: 'direction', min: 0, max: 360, step: 0.1 }, + { + type: 'select', + name: 'fillMode', + options: [ + { value: 0, label: 'TRANSPARENT' }, + { value: 1, label: 'ORIGINAL' }, + { value: 2, label: 'LOOP' }, + { value: 3, label: 'CLAMP' }, + { value: 4, label: 'MIRROR' }, + ], + }, + { type: 'range', name: 'seed', min: 0, max: 10000, step: 1 }, + { type: 'boolean', name: 'average' }, + { type: 'range', name: 'minSize', min: 0, max: 500, step: 1 }, + { type: 'range', name: 'sampleSize', min: 0, max: 1024, step: 1 }, + ], + argType: 'options', + }, + GlowFilter: { + defaultValues: { + distance: 10, + outerStrength: 4, + innerStrength: 0, + color: 0xffffff, + quality: 0.1, + knockout: false, + }, + controls: [ + { type: 'range', name: 'distance', min: 1, max: 50, step: 1 }, + { type: 'range', name: 'outerStrength', min: 0, max: 20, step: 1 }, + { type: 'range', name: 'innerStrength', min: 0, max: 20, step: 1 }, + { type: 'color', name: 'color' }, + { type: 'range', name: 'quality', min: 0, max: 5, step: 0.1 }, + { type: 'boolean', name: 'knockout' }, + ], + argType: 'options', + }, + GodrayFilter: { + defaultValues: { + angle: 30, + gain: 0.5, + lacunarity: 2.5, + parallel: true, + time: 0, + alpha: 1.0, + }, + controls: [ + { type: 'range', name: 'angle', min: 0, max: 360, step: 0.1 }, + { type: 'range', name: 'gain', min: 0, max: 5, step: 0.01 }, + { type: 'range', name: 'lacunarity', min: 0, max: 5, step: 0.01 }, + { type: 'boolean', name: 'parallel' }, + { type: 'range', name: 'time', min: 0, max: 10000, step: 1 }, + { type: 'range', name: 'alpha', min: 0, max: 1, step: 0.01 }, + ], + argType: 'options', + }, + KawaseBlurFilter: { + defaultValues: { + blur: 4, + quality: 3, + clamp: false, + }, + controls: [ + { type: 'range', name: 'blur', min: 0, max: 20, step: 0.1 }, + { type: 'range', name: 'quality', min: 0, max: 20, step: 1 }, + { type: 'boolean', name: 'clamp' }, + ], + argType: 'args', + }, + OldFilmFilter: { + defaultValues: { + sepia: 0.3, + noise: 0.3, + noiseSize: 1.0, + scratch: 0.5, + scratchDensity: 0.3, + scratchWidth: 1.0, + vignetting: 0.3, + vignettingAlpha: 1.0, + vignettingBlur: 0.3, + }, + controls: [ + { type: 'range', name: 'sepia', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'noise', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'noiseSize', min: 0, max: 5, step: 0.01 }, + { type: 'range', name: 'scratch', min: 0, max: 5, step: 0.01 }, + { type: 'range', name: 'scratchDensity', min: 0, max: 5, step: 0.01 }, + { type: 'range', name: 'scratchWidth', min: 0, max: 20, step: 0.01 }, + { type: 'range', name: 'vignetting', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'vignettingAlpha', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'vignettingBlur', min: 0, max: 5, step: 0.01 }, + ], + argType: 'options', + }, + OutlineFilter: { + defaultValues: { + thickness: 1, + color: 0x000000, + quality: 0.1, + }, + controls: [ + { type: 'range', name: 'thickness', min: 0, max: 20, step: 0.1 }, + { type: 'color', name: 'color' }, + { type: 'range', name: 'quality', min: 0, max: 1, step: 0.01 }, + ], + argType: 'args', + }, + PixelateFilter: { + defaultValues: { + size: 1, + }, + controls: [{ type: 'range', name: 'size', min: 1, max: 100, step: 1 }], + argType: 'args', + }, + RGBSplitFilter: { + defaultValues: { + red: [-10, 0], + green: [0, 10], + blue: [0, 0], + }, + controls: [ + { type: 'point', name: 'red', min: 0, max: 50, step: 1 }, + { type: 'point', name: 'green', min: 0, max: 50, step: 1 }, + { type: 'point', name: 'blue', min: 0, max: 50, step: 1 }, + ], + argType: 'args', + }, + RadialBlurFilter: { + defaultValues: { + angle: 0, + center: [0, 0], + radius: -1, + }, + controls: [ + { type: 'range', name: 'angle', min: 0, max: 360, step: 1 }, + { type: 'point', name: 'center', min: 0, max: 1000, step: 1 }, + { type: 'range', name: 'radius', min: -1, max: 1000, step: 1 }, + ], + argType: 'args', + }, + ReflectionFilter: { + defaultValues: { + mirror: true, + boundary: 0.5, + amplitude: [0, 20], + waveLength: [30, 100], + alpha: [1, 1], + time: 0, + }, + controls: [ + { type: 'boolean', name: 'mirror' }, + { type: 'range', name: 'boundary', min: 0, max: 1, step: 0.01 }, + { type: 'point', name: 'amplitude', min: 0, max: 100, step: 1 }, + { type: 'point', name: 'waveLength', min: 0, max: 500, step: 1 }, + { type: 'point', name: 'alpha', min: 0, max: 1, step: 0.01 }, + { type: 'range', name: 'time', min: 0, max: 10000, step: 1 }, + ], + argType: 'options', + }, + DisplacementFilter: { + defaultValues: { + sprite: '', + textureScale: 1, + displacementScale: 1, + }, + controls: [ + { type: 'text', name: 'sprite' }, + { type: 'range', name: 'textureScale', min: 0, max: 100, step: 0.1 }, + { type: 'range', name: 'displacementScale', min: 0, max: 100, step: 0.1 }, + ], + argType: 'options', + }, + 'Token Magic FX': { + defaultValues: { + params: [], + }, + controls: [ + { type: 'tmfxPreset', name: 'tmfxPreset' }, + { type: 'json', name: 'params' }, + ], + }, +}; + +function genFilterOptionControls(filterName, filterOptions = {}) { + if (!(filterName in FILTERS)) return; + + const options = mergeObject(FILTERS[filterName].defaultValues, filterOptions); + const values = getControlValues(filterName, options); + + const controls = FILTERS[filterName].controls; + let controlsHTML = '
    Options'; + for (const control of controls) { + controlsHTML += genControl(control, values); + } + controlsHTML += '
    '; + + return controlsHTML; +} + +function getControlValues(filterName, options) { + if (filterName === 'OutlineOverlayFilter') { + options.outlineColor = Color.fromRGB(options.outlineColor).toString(); + } else if (filterName === 'BevelFilter') { + options.lightColor = Color.from(options.lightColor).toString(); + options.shadowColor = Color.from(options.shadowColor).toString(); + } else if (['DropShadowFilter', 'GlowFilter', 'OutlineFilter'].includes(filterName)) { + options.color = Color.from(options.color).toString(); + } + return options; +} + +function genControl(control, values) { + const val = values[control.name]; + const name = control.name; + const label = control.label ?? name.charAt(0).toUpperCase() + name.slice(1); + const type = control.type; + if (type === 'color') { + return ` +
    + +
    + + +
    +
    +`; + } else if (type === 'range') { + return ` +
    + +
    + + ${val} +
    +
    +`; + } else if (type === 'boolean') { + return ` +
    + +
    + +
    +
    + `; + } else if (type === 'select') { + let select = ` +
    + +
    +
    `; + + return select; + } else if (type === 'point') { + return ` +
    + +
    + + ${val[0]} +
    +
    + + ${val[1]} +
    +
    +`; + } else if (type === 'json') { + let control = ` +
    + +
    + +
    `; + if (game.modules.get('multi-token-edit')?.api.showGenericForm) { + control += ` +
    + +
    `; + } + control += `
    `; + return control; + } else if (type === 'text') { + return ` +
    + +
    + +
    +
    +`; + } else if (type === 'tmfxPreset' && game.modules.get('tokenmagic')?.active) { + return ` +
    + +
    + + +
    + `; + } + 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 ` +
    + +
    + v === value) ? 'checked' : '' + }> +
    +
    + `; + }; + + let content = '
    '; + + const models = game.modules.get('nameforge').models; + for (const [k, v] of Object.entries(models.defaultModels)) { + content += genCheckbox(v.name, 'defaultModels.' + k); + } + for (const [k, v] of Object.entries(models.userModels)) { + content += genCheckbox(v.name, 'userModels.' + k); + } + 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(''); + if (tokenConfig) { + $(html) + .find('.sheet-footer') + .append(''); + 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 @@ +
    +
    + {{#if displayMode}} + {{#if image1}} +
    + {{#if (eq displayMode 4)}} +
    Current
    + {{else}} +
    Portrait
    +
    Token
    + {{/if}} +
    + {{/if}} + {{#if item}} +
    +

    Description

    +
    {{{description}}}
    +
    + {{/if}} + {{/if}} +
    + + +
    + {{#if displaySlider}} +
    + + {{fuzzyThreshold}}% +
    + {{/if}} + {{#if multipleSelection}} +
    + + +
    + {{/if}} +
    +
    + {{#if allImages}} {{#each allImages as |search index|}} +
    +

    {{search.[0]}}

    +
    +
    +
    + {{#each search.[1] as |image|}} +
    + + + {{#if image.img}} + + {{/if}} {{#if image.vid}} + + {{#unless ../autoplay}} + + {{/unless}} + {{/if}} {{#unless image.type}} +

    {{image.path}}

    + {{/unless}} + +

    {{{image.label}}}

    +
    + {{/each}} +
    + {{/each}} {{else}} + +
    +

    {{localize "token-variants.windows.art-select.no-art-found"}}: {{search}}

    +
    + +
    + + {{/if}} +
    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 @@ +
    +
    +

    Supported compendiums: {{supportedPacks}}

    + +
    + +
    +

    + {{localize "token-variants.settings.compendium-mapper.window.compendium-select"}} +

    +
    + +
    + +
    + + +

    Change the default image category used by the module for this compendium type.

    +
    + +
    + +
    + +
    +
    + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + + +
    + +
    + + +

    + Define additional images that are to be considered as "missing" +

    +
    + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + + +

    + {{localize "token-variants.settings.algorithm.Hint"}} +

    +
    + +

    {{localize "token-variants.common.automation"}}

    + +
    + + +
    + +
    +    + + +
    + +
    + + +
    + +
    + +
    +
    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 @@ +
    +
    + + +
    + +
    + + + {{#if hasConfig}} + + {{/if}} +
    +
    \ 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 + +
    + + +
    + +
    + + +
    +
    + +
    + + + {{#each macros }} + + {{/each}} + + +
    + Macros +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + + {{#if tmfxActive}} +
    +
    + Token Magic FX +
    + +
    + + {{#each tmfxPresets }} + + {{/each}} + + +
    +
    +
    + {{/if}} + + {{#if ceActive}} +
    +
    + DFreds Convenient Effects +
    + +
    + + {{#each ceEffects }} + + {{/each}} + + +
    +
    +
    + +
    + + + + +
    +
    +
    + {{/if}} + +
    + + {{#if hasScript}} + + {{/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}} +
    +
      +
    1. +
      + +
      +
      +
      +
      +
      +
      +
      +
    2. + + {{#each searchPaths as |path index|}} +
    3. +
      + +
      +
      + +
      +
      + +
      +
      + + + +
      +
      + + + +
      +
      + + +
      +
      + + +
      +
      + +
      +
      + +
      +
    4. + {{/each}} +
    + +

    + Formats:
    + Note: the path start from the 'data' folder of Foundry by default
    + data | path/to/folder
    + s3:my_bucket | token/art/folder/
    + rolltable | rolltableName
    + json | path/to/folder/data.json
    + imgur | galleryId +

    +
    + {{/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}} + +
    + +
    + +
    +    + +
    + +
    +    + +
    + +
    +
    +
    + {{/each}} +
    + {{/if}} + + + {{#if enabledTabs.searchAlgorithm}} +
    +
    + +
    + +
    +

    {{localize "token-variants.settings.keywords-search.Hint"}}

    +
    + +
    + +
    + +
    +

    {{localize "token-variants.settings.excluded-keywords.Hint"}}

    +
    + +
    + +
    + +
    +

    {{localize "token-variants.settings.run-search-on-path.Hint"}}

    +
    + +

    Search Method

    +
    +

    {{localize "token-variants.common.exact"}}

    +
    + + +

    {{localize "token-variants.settings.algorithm.window.exact-hint"}}

    +
    + +

    {{localize "token-variants.common.fuzzy"}}

    +
    + + +

    {{localize "token-variants.settings.algorithm.window.fuzzy-hint"}}

    +
    + +
    + + + {{algorithm.fuzzyThreshold}}% +

    + {{localize "token-variants.settings.algorithm.window.percentage-match.Hint"}} +

    +
    + +
    + + +

    + {{localize "token-variants.settings.algorithm.window.art-select-slider.Hint"}} +

    +
    + +
    + + +

    {{localize "token-variants.settings.algorithm.window.limit-hint"}}

    +
    +
    + {{/if}} + + + {{#if enabledTabs.randomizer}} +
    +

    {{localize "token-variants.common.randomize"}}

    + +
    + + +
    + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + + +
    + +
    + + +
    + +

    {{localize "token-variants.settings.randomizer.window.search-types-heading"}}

    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +

    {{localize "token-variants.settings.randomizer.window.disable-for"}}

    + +
    + + +
    + +
    + + +
    + +
    + +

    Actor Types

    + + {{#each randomizer.actorTypes}} +
    + + +
    + {{/each}} + +
    + +
    +

    {{localize "token-variants.settings.randomizer.window.pop-up-if-randomization-disabled"}}

    + +
    +
    + {{/if}} + + + {{#if enabledTabs.features}} +
    +

    Fully turn-off module features.

    + +
    + + +
    + +
      +
    • + +
      + +
      +

      +
    • +
    • + +
      + +
      +

      +
    • +
    +
    + {{/if}} + + + {{#if enabledTabs.popup}} +
    +
    + + +

    {{localize "token-variants.settings.pop-up.window.two-pop-ups.Hint"}}

    +
    + +
    + + +

    {{localize "token-variants.settings.pop-up.window.no-dialog.Hint"}}

    +
    + +
    + +
    + + {{#each popup.actorTypes}} +
    + {{#each this}} + + {{/each}} +
    +
      +
    • +
      + {{#each this}} {{/each}} +
      +
    • +
    + {{/each}} + +
    + + + +
    +
      +
    • +
      + + + +
      +
    • +
    +
    + {{/if}} + + + {{#if enabledTabs.permissions}} +
    +

    Configure which User role has permission to access which module features.

    + +
    + + + + + +
    + +
      +
    • + +
      + + + + +
      +

      Allow players with this role to be shown automatic Art Select pop-ups.

      +
    • + +
    • + +
      + + + + +
      +

      Allow players with this role to open Art Select windows via Right-click of images on various forms.

      +
    • + +
    • + +
      + + + + +
      +

      Allow players with this role to open the Art Select windows via buttons inserted into various forms.

      +
    • + +
    • + +
      + + + + +
      +

      Allow players with this role access to the Token HUD button (Shared and Wildcard art only)

      +
    • + +
    • + +
      + + + + +
      +

      Allow players with this role unrestricted access to all art via the Token HUD button

      +
    • + +
    • + +
      + + + + +
      +

      Allow players with this role to configure image mappings to status effects, visibility and combat states. ('Use File Browser' or 'Token Configuration Art Select' required to select images)

      +
    • +
    +
    + {{/if}} + + + {{#if enabledTabs.worldHud}} +
    +

    World Settings

    + +
    + +
    + +
    +

    + {{localize "token-variants.settings.token-hud.window.display-shared-only.Hint"}} +

    +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +

    + {{localize "token-variants.settings.token-hud.window.include-wildcard.Hint"}} +

    +
    + +
    + +
    + +
    +

    + When hovering over images instead of the file name full file path will be shown. +

    +
    + +
    + +
    + +
    + +
    +

    + {{localize "token-variants.settings.token-hud.window.update-actor-image.Hint"}} +

    +
    + +
    + +
    + +
    +

    + Instead of using the same image for the portrait the module will perform a Portrait image search and attempt to find a similarly named image. +

    +
    + +
    + +
    + +
    +

    Apply core foundry animations on image change.

    +
    + +
    + + {{#if worldHud.tokenHUDWildcardActive}} + +

    Token HUD Wildcard

    + +
    + +
    + +
    +

    + {{localize "token-variants.settings.token-hud.window.disable-if-token-hud-wildcard-active.Hint"}} +

    +
    + {{/if}} +
    + {{/if}} + + + {{#if enabledTabs.activeEffects}} +
    + +
    + +
    + +
    +

    Instead of comparing `Labels` Actor mappings will take precedent over Global ones if they belong to the same group.

    +
    + + +
    + +
    + +
    +

    When multiple Effect Mappings are active Token Configurations and Overlays will accumulate on the token instead of overriding each other.

    +
    + + {{#if dnd5e}} +
    + DnD5e +
    + +
    + +
    +

    Active Effect changes will not update images on tokens with polymorphed or wild shaped actors.

    +
    +
    + {{/if}} + +
    + +
    + +
    +

    Active Effect changes will not update images on tokens that have an image not corresponding to the prototype or any configurations.

    +
    + +
    + +
    + +
    +

    Active Effect changes affecting Token appearance will not trigger core Foundry's Token animation.

    +
    + +
    + + +

    Configurations to be applied on ALL tokens. Will be overridden by token specific configurations.

    +
    + +
    + +
    + +
    +

    Path to the game system's HP min, max, and value properties.

    +
    + +
    + +
    + +
    + +
    +

    Effect icons will only be displayed while hovering over the token.

    +
    + +
    + +
    + +
    + +
    +

    Prevents drawing of temporary effects on the token and combat tracker.

    +
    + +
    + + {{#unless pathfinder}} +
    + +
    + +
    +

    Disable drawing of the following effects on the token and combat tracker:

    +
    + +
    + +
    + +
    +

    Prevents drawing of temporary effects on the token if a mapping exists for it.

    +
    + +
    + +
    + +
    +
    + {{/unless}} +
    + +
    + Internal Effects +
    + +
    + + + + +
    +

    Flags will be stored on tokens to allow the use of `hp--` (decreased) and `hp++` (increased) expressions in effect mappings.

    +
    +
    + +
    + {{/if}} + + + {{#if enabledTabs.misc}} +
    +
    + +
    + +
    +

    {{localize "token-variants.settings.disable-notifs.Hint"}}

    +
    + +
    + +
    + +
    +

    Cached images will be stored in a file and read upon world load. Cache will be refreshed on search path changes or by clicking the button bellow.

    +
    + +
    + +
    + +
    +

    Name and location of the image cache.

    +
    + +
    + + +
    + +
    + +
    + +
    + +
    +

    Enables the Tile HUD button

    +
    + +
    + +
    + +
    +

    {{localize "token-variants.settings.imgur-client-id.Hint"}}

    +
    + +
    + +
    + +
    + +
    +

    Additional types that will be used by the module to group images on.

    +
    + +
    + +
    + Image Updates + +
    + +
    + +
    +

    Token updates using the module will also affect the prototype token.

    +
    + +
    + Dimensions in Image Names +
    + +
    + +
    +

    Module will recognize `_scale#.#_`, `_width#.#_`, and `_height#.#_` in image names and apply them to the token.

    +
    +
    + +
    + +
    +

    Module will recognize `_Scale###_` in image names and apply it to the token.

    +
    +
    + + +
    + +
    + +
    + +
    + +
    +

    When enabled videos will not auto-play, and instead will unpause only when the mouse is hovered over them.

    +
    + +
    + +
    + +
    +

    When enabled videos will pause when the mouse leaves them.

    +
    + +
    + {{/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 @@ +
    +
    + + + {{#each groups}} + + {{/each}} + + +
      +
    1. +
      + +
      +
      +
        
      +
      +
      +
      +
      +
      +
      + {{#if global}} +
      + {{/if}} +
      +
    2. + + {{#each groupedMappings as |mappings group|}} +
      +

      {{group}}

      +
      +
      +
      + {{#each mappings.list as |mapping|}} + +
    3. +
      + + +
      +
      + +
      +
      +
      {{{mapping.highlightedExpression}}}
      + +
      +
      + +
      +
      + + + + +
      +
      + + + + +
      +
      + + +
      +
      + +
      +
      + +
      + {{#if ../../global}} +
      + +
      + {{/if}} +
      + +
      +
    4. + {{/each}} + {{/each}} +
    +
    +
    + +
    +
    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 @@ +
    +
    + +
    + + + +
    + +
      + {{#unless tile}} +
    • + +
      + + +
      +

      Enable or disable pop-ups for this actor/token.

      +
    • + +
    • + +
      + + +
      +

      Disable Token HUD name search

      +
    • + + {{/unless}} + +
    • + +
      + + + + + +
      +

      Assign image directory to be included in the HUD

      +
    • +
    +
    + +
    + +
    +
    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 @@ +
    +
    +

    The Forge

    +
    + +
    + +
    +

    {{localize "token-variants.settings.forge-search-paths.window.Hint"}}

    +
    + +
      +
    1. +
      + +
      +
      +
      +
      +
      +
      +
      +
    2. + + {{#each paths as |path index|}} +
    3. +
      + +
      +
      + + +
      +
      + +
      +
      + + +
      +
      + +
      +
      + +
      +
      + +
      +
    4. + {{/each}} +
    +
    +
    +

    + Format:
    + Assets Library/token_art/dragons -> token_art/dragons +

    +
    +
    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 @@ +
    +
    + + +
    +
    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 @@ +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
      + {{#each missingImages}} +
    • +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
    • + {{/each}} + +
    +
    + +
    +
    + \ 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 @@ +
    + + + + + + {{#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 + +
    + +
    + +
    +

    Place the image, video or text underneath the token.

    +
    + +
    + +
    + +
    +

    Place this underlay bellow all tokens.

    +
    + +
    + +
    + +
    +

    Place this overlay above all tokens.

    +
    +
    + +
    + 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 + +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + + + +
    +

    Set the point on an overlay to be used to anchor it to the center of the parent.

    +
    + +
    + + +
    + +
    +
    + +
    + +
    +
    + +
    + {{{filterOptions}}} +
    +
    + +
    +
    + +
    + +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + +
    +
    +
    + +
    +
    + +
    + +
    +

    Overlay will be visible in explored areas of the map even when the Token is not.

    +
    + +
    + +
    + +
    +
    + +
    + Limit Visibility to Users + {{#each users as |user|}} +
    + +
    + +
    +
    + {{/each}} +
    + +
    + Limit Visibility to State +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + Limit Visibility to Token With Effect +
    + +
    + +
    +

    Overlay will only be visible to Tokens with this effect applied to them.

    +
    +
    + +
    + Limit Visibility to Token With Property +
    + +
    + +
    +

    Overlay will only be visible to Tokens that satisfy this expression.

    +

    e.g. +
    actor.system.attributes.senses.truesight>0 +
    actor.system.skills.prc.passive>=15 +
    hp<=50%

    +
    +
    +
    + +
    +
    + +
    + +
    +

    For this text to show make sure that no image is assigned to this overlay.

    +

    Token attributes can be inserted as so: {{name}}

    +
    + + {{~>modules/token-variants/templates/partials/repeating.html repeating=text.repeating root="text." repeat=text.repeat}} + +
    + +
    + +
    +
    +
    + +
    + {{ colorPicker name="text.fill" value=text.fill }} +
    +
    + + {{~>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 +
    + +
    + + +
    +
    +
    + +
    + {{ colorPicker name=(concat "shapes." @index ".line.color") value=shape.line.color }} +
    +
    +
    + +
    + + +
    +
    +
    +
    + Fill +
    + +
    + {{ colorPicker name=(concat "shapes." @index ".fill.color") value=shape.fill.color }} +
    +
    + +
    + +
    + + +
    +
    + + {{~>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}} +
    + + +
    +

    Define variables that you can insert into any overlay field. Useful when you have a constant value you want to re-use; for example multiple shapes that all share the same width

    +

    e.g. @shapeWidth

    + + + + + + + {{#each variables as |variable|}} + + + + + + + {{/each}} +
    NameValue
    @
    +
    + +
    + + + +
    + +
    +
    \ No newline at end of file diff --git a/Data/modules/token-variants/templates/partials/interpolateColor.html b/Data/modules/token-variants/templates/partials/interpolateColor.html new file mode 100644 index 00000000..bde1db08 --- /dev/null +++ b/Data/modules/token-variants/templates/partials/interpolateColor.html @@ -0,0 +1,17 @@ +
    + Interpolate: {{label}} +
    + +
    + {{ colorPicker name=(concat root "interpolateColor.color2") value=interpolateColor.color2 }} +
    +
    + +
    + +
    + +
    +

    Point between first color and Color 2 as a value between 0.0 and 1.0

    +
    +
    \ No newline at end of file diff --git a/Data/modules/token-variants/templates/partials/repeating.html b/Data/modules/token-variants/templates/partials/repeating.html new file mode 100644 index 00000000..edd6e7ad --- /dev/null +++ b/Data/modules/token-variants/templates/partials/repeating.html @@ -0,0 +1,60 @@ +
    + Repeating + +
    +
    + +
    + +
    +

    Value that will be divided by the increment to determine the number of repeats.

    +
    + +
    + +
    + + + + +
    +
    + +
    + +
    + +
    +

    Max value only required if increment is a percentage.

    +
    + +
    + +
    + +
    +

    How many repeats should be rendered before proceeding to the next row.

    +
    + +
    + +
    + +
    +

    Limit repeats to this number of rows.

    +
    + + {{#if padding}} +
    + +
    + + + + +
    +

    Insert empty pixels in-between the repeating shapes.

    +
    + {{/if}} +
    +
    \ No newline at end of file diff --git a/Data/modules/token-variants/templates/partials/shapeEllipse.html b/Data/modules/token-variants/templates/partials/shapeEllipse.html new file mode 100644 index 00000000..4c56ebad --- /dev/null +++ b/Data/modules/token-variants/templates/partials/shapeEllipse.html @@ -0,0 +1,28 @@ +
    + + ELLIPSE +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    \ No newline at end of file diff --git a/Data/modules/token-variants/templates/partials/shapePolygon.html b/Data/modules/token-variants/templates/partials/shapePolygon.html new file mode 100644 index 00000000..ff1953cf --- /dev/null +++ b/Data/modules/token-variants/templates/partials/shapePolygon.html @@ -0,0 +1,28 @@ +
    + + POLYGON +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    \ No newline at end of file diff --git a/Data/modules/token-variants/templates/partials/shapeRectangle.html b/Data/modules/token-variants/templates/partials/shapeRectangle.html new file mode 100644 index 00000000..83c01a24 --- /dev/null +++ b/Data/modules/token-variants/templates/partials/shapeRectangle.html @@ -0,0 +1,34 @@ +
    + + RECTANGLE +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    \ No newline at end of file diff --git a/Data/modules/token-variants/templates/partials/shapeTorus.html b/Data/modules/token-variants/templates/partials/shapeTorus.html new file mode 100644 index 00000000..b2d78e8f --- /dev/null +++ b/Data/modules/token-variants/templates/partials/shapeTorus.html @@ -0,0 +1,40 @@ +
    + + TORUS +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    \ No newline at end of file diff --git a/Data/modules/token-variants/templates/protoTokenElement.html b/Data/modules/token-variants/templates/protoTokenElement.html new file mode 100644 index 00000000..11a0b7ad --- /dev/null +++ b/Data/modules/token-variants/templates/protoTokenElement.html @@ -0,0 +1,20 @@ +
    + Token Variant Art +
    + +
    + + +
    +

    Rather than randomizing the image upon token creation this default image will be used instead.

    +
    +
    + +
    + +
    +

    Prevent the display of the Token HUD button..

    +
    +
    \ No newline at end of file diff --git a/Data/modules/token-variants/templates/randomizerConfig.html b/Data/modules/token-variants/templates/randomizerConfig.html new file mode 100644 index 00000000..f3085927 --- /dev/null +++ b/Data/modules/token-variants/templates/randomizerConfig.html @@ -0,0 +1,79 @@ +
    +

    {{localize "token-variants.common.randomize"}}

    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + + +
    + +
    + + +
    + +

    Searches to include in image Randomization

    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + {{#if nameForgeActive}} +

    Module: Name Forge

    + +
    + + +
    + +
    + + + +
    + {{/if}} + +
    + + {{#if hasSettings}} + + {{/if}} +
    +
    \ No newline at end of file diff --git a/Data/modules/token-variants/templates/sideSelect.html b/Data/modules/token-variants/templates/sideSelect.html new file mode 100644 index 00000000..60f6e219 --- /dev/null +++ b/Data/modules/token-variants/templates/sideSelect.html @@ -0,0 +1,46 @@ +
    + {{#each imagesParsed as |image|}} +
    + {{#if ../imageDisplay}} + {{#if image.img}} + + {{/if}} + {{#if image.vid}} + + {{#unless ../autoplay}} + + {{/unless}} + {{/if}} + {{#if image.unknownType}} + + {{/if}} + {{else}} + {{image.name}} + {{/if}} + + +
    + {{/each}} +
    diff --git a/Data/modules/token-variants/templates/tokenHUDClientSettings.html b/Data/modules/token-variants/templates/tokenHUDClientSettings.html new file mode 100644 index 00000000..6ab601cf --- /dev/null +++ b/Data/modules/token-variants/templates/tokenHUDClientSettings.html @@ -0,0 +1,43 @@ +
    +
    + +
    + +
    +

    + {{localize "token-variants.settings.token-hud.window.enable-token-hud.Hint"}} +

    +
    + +
    + +
    + +
    +

    + {{localize "token-variants.settings.token-hud.window.display-as-image.Hint"}} +

    +
    + +
    + +
    + +
    +

    {{localize "token-variants.settings.token-hud.window.image-opacity.Hint"}}

    +
    + +
    + +
    +
    diff --git a/Data/modules/token-variants/templates/userList.html b/Data/modules/token-variants/templates/userList.html new file mode 100644 index 00000000..ab130457 --- /dev/null +++ b/Data/modules/token-variants/templates/userList.html @@ -0,0 +1,40 @@ +
    +
    + +
    +
    +
    +
    +
    + +
      + {{#each users}} +
    • +
      + +
      +
      + +
      +
      + +
      +
    • + {{/each}} + +
    +
    + +
    + + +
    +

    Placeables will be rendered invisible for non-gm users that have this image assigned to them.

    +
    + +
    + \ No newline at end of file diff --git a/Data/modules/token-variants/token-variants.mjs b/Data/modules/token-variants/token-variants.mjs new file mode 100644 index 00000000..0eb4b324 --- /dev/null +++ b/Data/modules/token-variants/token-variants.mjs @@ -0,0 +1,238 @@ +import { + registerSettings, + TVA_CONFIG, + exportSettingsToJSON, + updateSettings, +} from './scripts/settings.js'; +import { ArtSelect, addToArtSelectQueue } from './applications/artSelect.js'; +import { + SEARCH_TYPE, + registerKeybinds, + updateTokenImage, + startBatchUpdater, + userRequiresImageCache, + waitForTokenTexture, +} from './scripts/utils.js'; +import { FONT_LOADING, drawOverlays } from './scripts/token/overlay.js'; +import { getTokenEffects, updateWithEffectMapping } from './scripts/hooks/effectMappingHooks.js'; +import { cacheImages, doImageSearch, doRandomSearch, isCaching } from './scripts/search.js'; +import { REGISTERED_HOOKS, registerAllHooks, registerHook } from './scripts/hooks/hooks.js'; +import { REGISTERED_WRAPPERS, registerAllWrappers } from './scripts/wrappers/wrappers.js'; +import { + assignUserSpecificImage, + assignUserSpecificImageToSelected, + unassignUserSpecificImage, + unassignUserSpecificImageFromSelected, +} from './scripts/wrappers/userMappingWrappers.js'; + +// Tracks if module has been initialized +let MODULE_INITIALIZED = false; +export function isInitialized() { + return MODULE_INITIALIZED; +} +let onInit = []; + +// showArtSelect(...) can take a while to fully execute and it is possible for it to be called +// multiple times in very quick succession especially if copy pasting tokens or importing actors. +// This variable set early in the function execution is used to queue additional requests rather +// than continue execution +const showArtSelectExecuting = { inProgress: false }; + +/** + * Initialize the Token Variants module on Foundry VTT init + */ +async function initialize() { + // Initialization should only be performed once + if (MODULE_INITIALIZED) { + return; + } + + // Font Awesome need to be loaded manually on FireFox + FONT_LOADING.loading = FontConfig.loadFont('fontAwesome', { + editor: false, + fonts: [{ urls: ['fonts/fontawesome/webfonts/fa-solid-900.ttf'] }], + }); + + // Want this to be executed once the module has initialized + onInit.push(() => { + // Need to wait for icons do be drawn first however I could not find a way + // to wait until that has occurred. Instead we'll just wait for some static + // amount of time. + new Promise((resolve) => setTimeout(resolve, 500)).then(() => { + for (const tkn of canvas.tokens.placeables) { + drawOverlays(tkn); // Draw Overlays + + // Disable effect icons + if (TVA_CONFIG.disableEffectIcons) { + waitForTokenTexture(tkn, (token) => { + token.effects.removeChildren().forEach((c) => c.destroy()); + token.effects.bg = token.effects.addChild(new PIXI.Graphics()); + token.effects.overlay = null; + }); + } else if (TVA_CONFIG.filterEffectIcons) { + waitForTokenTexture(tkn, (token) => { + token.drawEffects(); + }); + } + } + }); + }); + + if (userRequiresImageCache()) cacheImages(); + + // Register ALL Hooks + registerAllHooks(); + + // Startup ticker that will periodically call 'updateEmbeddedDocuments' with all the accrued updates since the last tick + startBatchUpdater(); + + registerHook('Search', 'renderArtSelect', () => { + showArtSelectExecuting.inProgress = false; + }); + + // Handle broadcasts + game.socket?.on(`module.token-variants`, (message) => { + if (message.handlerName === 'forgeSearchPaths' && message.type === 'UPDATE') { + // Workaround for forgeSearchPaths setting to be updated by non-GM clients + if (!game.user.isGM) return; + const isResponsibleGM = !game.users + .filter((user) => user.isGM && (user.active || user.isActive)) + .some((other) => other.id < game.user.id); + if (!isResponsibleGM) return; + updateSettings({ forgeSearchPaths: message.args }); + } else if (message.handlerName === 'drawOverlays' && message.type === 'UPDATE') { + if (message.args.all) { + if (canvas.scene.id !== message.args.sceneId) { + for (const tkn of canvas.tokens.placeables) { + drawOverlays(tkn); + } + } + } else if (message.args.actorId) { + const actor = game.actors.get(message.args.actorId); + if (actor) actor.getActiveTokens(true)?.forEach((tkn) => drawOverlays(tkn)); + } else if (message.args.tokenId) { + const tkn = canvas.tokens.get(message.args.tokenId); + if (tkn) drawOverlays(tkn); + } + } else if (message.handlerName === 'effectMappings') { + if (!game.user.isGM) return; + const isResponsibleGM = !game.users + .filter((user) => user.isGM && (user.active || user.isActive)) + .some((other) => other.id < game.user.id); + if (!isResponsibleGM) return; + const args = message.args; + const token = game.scenes.get(args.sceneId)?.tokens.get(args.tokenId); + if (token) updateWithEffectMapping(token, { added: args.added, removed: args.removed }); + } + }); + + MODULE_INITIALIZED = true; + for (const cb of onInit) { + cb(); + } + onInit = []; +} + +/** + * Performs searches and displays the Art Select pop-up with the results + * @param {string} search The text to be used as the search criteria + * @param {object} [options={}] Options which customize the search + * @param {Function[]} [options.callback] Function to be called with the user selected image path + * @param {SEARCH_TYPE|string} [options.searchType] Controls filters applied to the search results + * @param {Token|Actor} [options.object] Token/Actor used when displaying Custom Token Config prompt + * @param {boolean} [options.force] If true will always override the current Art Select window if one exists instead of adding it to the queue + * @param {object} [options.searchOptions] Override search and filter settings + */ +export async function showArtSelect( + search, + { + callback = null, + searchType = SEARCH_TYPE.PORTRAIT_AND_TOKEN, + object = null, + force = false, + preventClose = false, + image1 = '', + image2 = '', + displayMode = ArtSelect.IMAGE_DISPLAY.NONE, + multipleSelection = false, + searchOptions = {}, + allImages = null, + } = {} +) { + if (isCaching()) return; + + const artSelects = Object.values(ui.windows).filter((app) => app instanceof ArtSelect); + if (showArtSelectExecuting.inProgress || (!force && artSelects.length !== 0)) { + addToArtSelectQueue(search, { + callback, + searchType, + object, + preventClose, + searchOptions, + allImages, + }); + return; + } + + showArtSelectExecuting.inProgress = true; + if (!allImages) + allImages = await doImageSearch(search, { + searchType: searchType, + searchOptions: searchOptions, + }); + + new ArtSelect(search, { + allImages: allImages, + searchType: searchType, + callback: callback, + object: object, + preventClose: preventClose, + image1: image1, + image2: image2, + displayMode: displayMode, + multipleSelection: multipleSelection, + searchOptions: searchOptions, + }).render(true); +} + +// Initialize module +registerHook('main', 'ready', initialize, { once: true }); + +// Register API and Keybinds +registerHook('main', 'init', function () { + registerSettings(); + registerAllWrappers(); + + registerKeybinds(); + + const api = { + cacheImages, + doImageSearch, + doRandomSearch, + getTokenEffects, + showArtSelect, + updateTokenImage, + exportSettingsToJSON, + assignUserSpecificImage, + assignUserSpecificImageToSelected, + unassignUserSpecificImage, + unassignUserSpecificImageFromSelected, + TVA_CONFIG, + }; + + Object.defineProperty(api, 'hooks', { + get() { + return deepClone(REGISTERED_HOOKS); + }, + configurable: true, + }); + + Object.defineProperty(api, 'wrappers', { + get() { + return deepClone(REGISTERED_WRAPPERS); + }, + configurable: true, + }); + + game.modules.get('token-variants').api = api; +}); diff --git a/Data/worlds/the-fall-of-plaguestone/data/actors.db b/Data/worlds/the-fall-of-plaguestone/data/actors.db index 8ffef1b7..6c2aedfa 100644 --- a/Data/worlds/the-fall-of-plaguestone/data/actors.db +++ b/Data/worlds/the-fall-of-plaguestone/data/actors.db @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c27cfe917fde03434820a9a3c11bd5cee8377b346cc917940a7c3cee48f4ca4d -size 1338587 +oid sha256:67e52f1e3f36e587cd1d09a91a6b29a8f18ab2344523df1f002508971ae9b438 +size 1337686 diff --git a/Data/worlds/the-fall-of-plaguestone/data/fog.db b/Data/worlds/the-fall-of-plaguestone/data/fog.db index 9668542c..a44e43d9 100644 --- a/Data/worlds/the-fall-of-plaguestone/data/fog.db +++ b/Data/worlds/the-fall-of-plaguestone/data/fog.db @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:285b21aa93783f8bbe42a9b418d4179625cd735da2943e261d8b702590dab201 +oid sha256:c65f6191dba88660aaac01cba9c9ecbf7f4abc0f99b3071ce06031eca934ea25 size 5862251 diff --git a/Data/worlds/the-fall-of-plaguestone/data/folders.db b/Data/worlds/the-fall-of-plaguestone/data/folders.db index 90b3f48f..937c91fa 100644 --- a/Data/worlds/the-fall-of-plaguestone/data/folders.db +++ b/Data/worlds/the-fall-of-plaguestone/data/folders.db @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c6c7120695c53fc5d35febb4a2b804aa1a4f83c14f274a29fadb6376c973aa0 -size 5023 +oid sha256:bb6c558f5eeeb7ddb7f95085c8ee50b7641caed32e8c93e3874dcf4efc653f42 +size 5340 diff --git a/Data/worlds/the-fall-of-plaguestone/data/scenes.db b/Data/worlds/the-fall-of-plaguestone/data/scenes.db index 93fd72bd..c85758da 100644 --- a/Data/worlds/the-fall-of-plaguestone/data/scenes.db +++ b/Data/worlds/the-fall-of-plaguestone/data/scenes.db @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c9133cbfaf1f5b086dfc2e766a2256043dc1c67949ba9456169254837989e420 -size 472802 +oid sha256:73c849002f1917ab7d62d91db41aba13e54d8d26a335378b0c004c81dc7dc922 +size 467155 diff --git a/Data/worlds/the-fall-of-plaguestone/data/settings.db b/Data/worlds/the-fall-of-plaguestone/data/settings.db index 822841c8..5139445d 100644 --- a/Data/worlds/the-fall-of-plaguestone/data/settings.db +++ b/Data/worlds/the-fall-of-plaguestone/data/settings.db @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2385f4be56d1f7c11bd7dd2aa079772a391a3adbdcdf74e67914874e13c87e03 -size 36739 +oid sha256:2e2cdf68fe926cbee4c37c406c390fe24ab482bc5ae34f3fe26e4f38e19cd088 +size 41354 diff --git a/Data/worlds/the-fall-of-plaguestone/data/tables.db b/Data/worlds/the-fall-of-plaguestone/data/tables.db index e69de29b..1165b480 100644 --- a/Data/worlds/the-fall-of-plaguestone/data/tables.db +++ b/Data/worlds/the-fall-of-plaguestone/data/tables.db @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e12f822bc102e4bcb57508736e8372ec3cfe16c5fef2f52dc5db322022c33a2f +size 2886