Browse Source

configuring kitsune token swaps

master
Austin Decker 1 year ago
parent
commit
16ac06d290
76 changed files with 24761 additions and 9 deletions
  1. BIN
      Data/assets/aysun_adara_fox.jpg
  2. BIN
      Data/assets/aysun_adara_fox_token.png
  3. +458
    -0
      Data/modules/token-variants/applications/artSelect.js
  4. +460
    -0
      Data/modules/token-variants/applications/compendiumMap.js
  5. +83
    -0
      Data/modules/token-variants/applications/configJsonEdit.js
  6. +97
    -0
      Data/modules/token-variants/applications/configScriptEdit.js
  7. +798
    -0
      Data/modules/token-variants/applications/configureSettings.js
  8. +363
    -0
      Data/modules/token-variants/applications/dialogs.js
  9. +900
    -0
      Data/modules/token-variants/applications/effectMappingForm.js
  10. +87
    -0
      Data/modules/token-variants/applications/flagsConfig.js
  11. +180
    -0
      Data/modules/token-variants/applications/forgeSearchPaths.js
  12. +68
    -0
      Data/modules/token-variants/applications/importExport.js
  13. +135
    -0
      Data/modules/token-variants/applications/missingImageConfig.js
  14. +1207
    -0
      Data/modules/token-variants/applications/overlayConfig.js
  15. +109
    -0
      Data/modules/token-variants/applications/randomizerConfig.js
  16. +428
    -0
      Data/modules/token-variants/applications/tileHUD.js
  17. +228
    -0
      Data/modules/token-variants/applications/tokenCustomConfig.js
  18. +673
    -0
      Data/modules/token-variants/applications/tokenHUD.js
  19. +32
    -0
      Data/modules/token-variants/applications/tokenHUDClientSettings.js
  20. +81
    -0
      Data/modules/token-variants/applications/userList.js
  21. BIN
      Data/modules/token-variants/img/anchor_diagram.webp
  22. +65
    -0
      Data/modules/token-variants/img/token-images.svg
  23. +212
    -0
      Data/modules/token-variants/lang/en.json
  24. +41
    -0
      Data/modules/token-variants/module.json
  25. +201
    -0
      Data/modules/token-variants/scripts/fuse/LICENSE
  26. +2436
    -0
      Data/modules/token-variants/scripts/fuse/fuse.js
  27. +207
    -0
      Data/modules/token-variants/scripts/hooks/artSelectButtonHooks.js
  28. +31
    -0
      Data/modules/token-variants/scripts/hooks/effectIconHooks.js
  29. +1044
    -0
      Data/modules/token-variants/scripts/hooks/effectMappingHooks.js
  30. +51
    -0
      Data/modules/token-variants/scripts/hooks/hooks.js
  31. +23
    -0
      Data/modules/token-variants/scripts/hooks/hudHooks.js
  32. +85
    -0
      Data/modules/token-variants/scripts/hooks/overlayHooks.js
  33. +272
    -0
      Data/modules/token-variants/scripts/hooks/popUpRandomizeHooks.js
  34. +57
    -0
      Data/modules/token-variants/scripts/hooks/userMappingHooks.js
  35. +82
    -0
      Data/modules/token-variants/scripts/hooks/wildcardHooks.js
  36. +5857
    -0
      Data/modules/token-variants/scripts/mappingTemplates.js
  37. +121
    -0
      Data/modules/token-variants/scripts/models.js
  38. +620
    -0
      Data/modules/token-variants/scripts/search.js
  39. +561
    -0
      Data/modules/token-variants/scripts/settings.js
  40. +699
    -0
      Data/modules/token-variants/scripts/sprite/TVASprite.js
  41. +571
    -0
      Data/modules/token-variants/scripts/token/overlay.js
  42. +1088
    -0
      Data/modules/token-variants/scripts/utils.js
  43. +147
    -0
      Data/modules/token-variants/scripts/wrappers/effectIconWrappers.js
  44. +31
    -0
      Data/modules/token-variants/scripts/wrappers/hudWrappers.js
  45. +150
    -0
      Data/modules/token-variants/scripts/wrappers/userMappingWrappers.js
  46. +30
    -0
      Data/modules/token-variants/scripts/wrappers/wrappers.js
  47. +857
    -0
      Data/modules/token-variants/styles/tva-styles.css
  48. +94
    -0
      Data/modules/token-variants/templates/artSelect.html
  49. +133
    -0
      Data/modules/token-variants/templates/compendiumMap.html
  50. +14
    -0
      Data/modules/token-variants/templates/configJsonEdit.html
  51. +91
    -0
      Data/modules/token-variants/templates/configScriptEdit.html
  52. +813
    -0
      Data/modules/token-variants/templates/configureSettings.html
  53. +124
    -0
      Data/modules/token-variants/templates/effectMappingForm.html
  54. +51
    -0
      Data/modules/token-variants/templates/flagsConfig.html
  55. +60
    -0
      Data/modules/token-variants/templates/forgeSearchPaths.html
  56. +12
    -0
      Data/modules/token-variants/templates/importExport.html
  57. +42
    -0
      Data/modules/token-variants/templates/missingImageConfig.html
  58. +707
    -0
      Data/modules/token-variants/templates/overlayConfig.html
  59. +17
    -0
      Data/modules/token-variants/templates/partials/interpolateColor.html
  60. +60
    -0
      Data/modules/token-variants/templates/partials/repeating.html
  61. +28
    -0
      Data/modules/token-variants/templates/partials/shapeEllipse.html
  62. +28
    -0
      Data/modules/token-variants/templates/partials/shapePolygon.html
  63. +34
    -0
      Data/modules/token-variants/templates/partials/shapeRectangle.html
  64. +40
    -0
      Data/modules/token-variants/templates/partials/shapeTorus.html
  65. +20
    -0
      Data/modules/token-variants/templates/protoTokenElement.html
  66. +79
    -0
      Data/modules/token-variants/templates/randomizerConfig.html
  67. +46
    -0
      Data/modules/token-variants/templates/sideSelect.html
  68. +43
    -0
      Data/modules/token-variants/templates/tokenHUDClientSettings.html
  69. +40
    -0
      Data/modules/token-variants/templates/userList.html
  70. +238
    -0
      Data/modules/token-variants/token-variants.mjs
  71. BIN
      Data/worlds/the-fall-of-plaguestone/data/actors.db
  72. BIN
      Data/worlds/the-fall-of-plaguestone/data/fog.db
  73. BIN
      Data/worlds/the-fall-of-plaguestone/data/folders.db
  74. BIN
      Data/worlds/the-fall-of-plaguestone/data/scenes.db
  75. BIN
      Data/worlds/the-fall-of-plaguestone/data/settings.db
  76. BIN
      Data/worlds/the-fall-of-plaguestone/data/tables.db

BIN
Data/assets/aysun_adara_fox.jpg (Stored with Git LFS) View File

size 54611

BIN
Data/assets/aysun_adara_fox_token.png (Stored with Git LFS) View File

size 101908

+ 458
- 0
Data/modules/token-variants/applications/artSelect.js View File

@ -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 = '<b>>>> ' + label + ' <<<</b>';
}
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: `<style>.dialog .dialog-button {flex: 0 0 auto;}</style>`,
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 = '<mark>', end = '</mark>', 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
class="token-variants-image-select-button"
type="button"
data-type="imagevideo"
data-target="${target}"
title="${game.i18n.localize('token-variants.windows.art-select.select-variant')}">
<i class="fas fa-images"></i>
</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);
}

+ 460
- 0
Data/modules/token-variants/applications/compendiumMap.js View File

@ -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 = $(`<p>CACHING 0/${allItems.length}</p>`);
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: `
<div style="text-align:center;" class="fa-3x"><i class="fas fa-spinner fa-pulse"></i></div>
<div style="text-align:center;" class="counter"></div>
<button style="width:100%;" class="pause"><i class="fas fa-play-circle"></i> Pause/Start</button>`,
buttons: {
cancel: {
icon: '<i class="fas fa-stop-circle"></i>',
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);
}
}
}

+ 83
- 0
Data/modules/token-variants/applications/configJsonEdit.js View File

@ -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);
}
}

+ 97
- 0
Data/modules/token-variants/applications/configScriptEdit.js View File

@ -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);
}
}
}

+ 798
- 0
Data/modules/token-variants/applications/configureSettings.js View File

@ -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 = `<select style="width: 100%;" name="table-name" id="output-tableKey">`;
game.tables.forEach((rollTable) => {
content += `<option value='${rollTable.name}'>${rollTable.name}</option>`;
});
content += `</select>`;
new Dialog({
title: `Select a Rolltable`,
content: content,
buttons: {
yes: {
icon: "<i class='fas fa-check'></i>",
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 = `
<li class="table-row flexrow">
<div class="path-image source-icon">
<a><i class="${this._pathIcon('')}"></i></a>
</div>
<div class="path-source">
<input class="searchSource" type="text" name="searchPaths.source" value="" placeholder="data"/>
</div>
<div class="path-text">
<input class="searchPath" type="text" name="searchPaths.text" value="" placeholder="Path to folder"/>
</div>
<div class="imgur-control">
<a class="convert-imgur" title="Convert to Rolltable"><i class="fas fa-angle-double-left"></i></a>
</div>
<div class="json-control">
<a class="convert-json" title="Convert to Rolltable"><i class="fas fa-angle-double-left"></i></a>
</div>
<div class="path-category">
<a class="select-category" title="Select image categories/filters"><i class="fas fa-swatchbook"></i></a>
<input type="hidden" name="searchPaths.types" value="Portrait,Token,PortraitAndToken">
</div>
<div class="path-config">
<a class="select-config" title="Apply configuration to images under this path."><i class="fas fa-cog fa-lg"></i></a>
<input type="hidden" name="searchPaths.config" value="{}">
</div>
<div class="path-cache">
<input type="checkbox" name="searchPaths.cache" data-dtype="Boolean" checked/>
</div>
<div class="path-controls">
<a class="delete-path" title="Delete path"><i class="fas fa-trash"></i></a>
</div>
</li>
`;
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;
}
}

+ 363
- 0
Data/modules/token-variants/applications/dialogs.js View File

@ -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 = `<div style="height: 300px;" class="form-group stacked command"><textarea style="height: 300px;" class="configJson">${JSON.stringify(
config,
null,
2
)}</textarea></div>`;
new Dialog({
title: `Overlay Configuration`,
content: content,
buttons: {
yes: {
icon: "<i class='fas fa-save'></i>",
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 = '<div class="token-variants-popup-settings">';
// 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 += '<header class="table-header flexrow">';
for (const type of split) {
content += `<label>${type}</label>`;
}
content += '</header><ul class="setting-list"><li class="setting form-group"><div class="form-fields">';
for (const type of split) {
content += `<input class="category" type="checkbox" name="${type}" data-dtype="Boolean" ${
selectedTypes.includes(type) ? 'checked' : ''
}>`;
}
content += '</div></li></ul>';
}
content += '</div>';
new Dialog({
title: `Image Categories/Filters`,
content: content,
buttons: {
yes: {
icon: "<i class='fas fa-save'></i>",
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 = `<form>
<div class="form-group">
<label>Image Name</label>
<input type="text" name="name" value="${token.name}">
</div>
<div class="form-group">
<label>Image Path</label>
<div class="form-fields">
<input type="text" name="path" value="modules/token-variants/">
<button type="button" class="file-picker" data-type="folder" data-target="path" title="Browse Folders" tabindex="-1">
<i class="fas fa-file-import fa-fw"></i>
</button>
</div>
</div>
<div class="form-group slim">
<label>Width <span class="units">(pixels)</span></label>
<div class="form-fields">
<input type="number" step="1" name="width" value="${token.mesh.texture.width}">
</div>
</div>
<div class="form-group slim">
<label>Height <span class="units">(pixels)</span></label>
<div class="form-fields">
<input type="number" step="1" name="height" value="${token.mesh.texture.height}">
</div>
</div>
<div class="form-group slim">
<label>Scale</label>
<div class="form-fields">
<input type="number" step="any" name="scale" value="3">
</div>
</div>
</form>`;
new Dialog({
title: `Save Token/Overlay Image`,
content: content,
buttons: {
yes: {
icon: "<i class='fas fa-save'></i>",
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 = `<form style="overflow-y: scroll; height:400px;"><h2>${title2}</h2>`;
const [_, mappingGroups] = sortMappingsToGroups(mappings);
for (const [group, obj] of Object.entries(mappingGroups)) {
if (obj.list.length) {
content += `<h4 style="text-align:center;"><b>${group}</b></h4>`;
for (const mapping of obj.list) {
content += `
<div class="form-group">
<label>${mapping.label}</label>
<div class="form-fields">
<input type="checkbox" name="${mapping.id}" data-dtype="Boolean">
</div>
</div>
`;
}
}
}
content += `</form><div class="form-group"><button type="button" class="select-all">Select all</div>`;
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 = `
<div class="form-group">
<label>Template Name</label>
<div class="form-fields">
<input type="text" name="templateName" data-dtype="String" value="">
</div>
</div>
<div class="form-group">
<label>Hover Text (optional)</label>
<div class="form-fields">
<input type="text" name="templateHint" data-dtype="String" value="">
</div>
</div>`;
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 = `<tr><th>USER Templates</th></tr>`;
for (const template of TVA_CONFIG.templateMappings) {
if (!template.id) template.id = randomID(8);
user_t += `<tr draggable="true" data-id="${template.id}" title="${template.hint ?? ''}"><td class="template">${
template.name
}</td><td style="text-align:center;"><a class="delete-template"><i class="fa-solid fa-trash"></i></a></td></tr>`;
}
user_t = '<table>' + user_t + '</table>';
user_t += `<button class="create-template" ${mappings.length ? '' : 'disabled'}>Create Template</button>'`;
let core_t = `<tr><th><a href="https://github.com/Aedif/TokenVariants/wiki/Templates">CORE Templates</a></th></tr>`;
for (const template of CORE_TEMPLATES) {
if (!template.id) template.id = randomID(8);
core_t += `<tr draggable="true" data-id="${template.id}" title="${template.hint ?? ''}"><td class="template">${
template.name
}</td></tr>`;
}
core_t = '<table>' + core_t + '</table>';
let content =
'<style>.template:hover {background-color: rgba(39, 245, 101, 0.55);}</style>' + user_t + '<hr>' + 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);
}

+ 900
- 0
Data/modules/token-variants/applications/effectMappingForm.js View File

@ -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: '<i class="fas fa-file-import"></i>',
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: '<i class="fas fa-times"></i>',
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 = '<form style="overflow-y: scroll; height:250x;">';
for (const act of actors) {
content += `
<div class="form-group">
<label>${act.label}</label>
<div class="form-fields">
<input type="checkbox" name="${act.id}" data-dtype="Boolean" ${
act.enabled ? 'checked' : ''
}>
</div>
</div>
`;
}
content += `</form><div class="form-group"><button type="button" class="select-all">Select all</div>`;
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 <span/> around operators
function highlightOperators(text) {
// text = text.replaceAll(' ', '&nbsp;');
const re = new RegExp('([a-zA-Z\\.\\-\\|\\+]+)([><=]+)(".*?"|-?\\d+)(%{0,1})', `gi`);
text = text.replace(re, function replace(match) {
return '<span class="hp-expression">' + match + '</span>';
});
for (const op of ['\\(', '\\)', '&&', '||', '\\!', '\\*', '\\{', '\\}']) {
text = text.replaceAll(op, `<span>${op}</span>`);
}
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];
}

+ 87
- 0
Data/modules/token-variants/applications/flagsConfig.js View File

@ -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);
}
});
}
}

+ 180
- 0
Data/modules/token-variants/applications/forgeSearchPaths.js View File

@ -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);
}
}

+ 68
- 0
Data/modules/token-variants/applications/importExport.js View File

@ -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: '<i class="fas fa-file-import"></i>',
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: '<i class="fas fa-times"></i>',
label: 'Cancel',
callback: (html) => resolve(false),
},
},
default: 'import',
},
{
width: 400,
}
).render(true);
});
this.close();
return await dialog;
}
}

+ 135
- 0
Data/modules/token-variants/applications/missingImageConfig.js View File

@ -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 = `<select style="width: 100%;" name="compendium">`;
game.packs.forEach((pack) => {
content += `<option value='${pack.collection}'>${pack.title}</option>`;
});
content += `</select>`;
new Dialog({
title: `Compendiums`,
content: content,
buttons: {
yes: {
icon: "<i class='far fa-search'></i>",
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) },
});
}
}

+ 1207
- 0
Data/modules/token-variants/applications/overlayConfig.js
File diff suppressed because it is too large
View File


+ 109
- 0
Data/modules/token-variants/applications/randomizerConfig.js View File

@ -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 `
<div class="form-group">
<label>${name}</label>
<div class="form-fields">
<input type="checkbox" name="model" value="${value}" data-dtype="Boolean" ${
selected?.find((v) => v === value) ? 'checked' : ''
}>
</div>
</div>
`;
};
let content = '<form style="overflow-y: scroll; height:400px;">';
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 += `</form>`;
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);
}
}
}

+ 428
- 0
Data/modules/token-variants/applications/tileHUD.js View File

@ -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 = $(`
<div class="control-icon" data-action="token-variants-side-selector">
<img
id="token-variants-side-button"
src="modules/token-variants/img/token-images.svg"
width="36"
height="36"
title="${game.i18n.localize('token-variants.windows.art-select.select-variant')}"
/>
</div>
`);
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 = $(`
<div class="token-variants-wrap contextmenu active">
<div class="token-variants-context-menu active">
<input class="token-variants-side-search" type="text" />
<button class="flags" type="button"><i class="fab fa-font-awesome-flag"></i><label>Flags</label></button>
<button class="file-picker" type="button"><i class="fas fa-file-import fa-fw"></i><label>Browse Folders</label></button>
</div>
</div>
`);
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: `<table style="width:100%"><tr><th style="width:50%"><label>Tile Name</label></th><td style="width:50%"><input type="text" name="input" value="${tileName}"/></td></tr></table>`,
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);
}

+ 228
- 0
Data/modules/token-variants/applications/tokenCustomConfig.js View File

@ -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 = $(
`<div class="tva-config-checkbox"><input type="checkbox" data-dtype="Boolean" ${
savedField ? 'checked=""' : ''
}></div>`
);
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('<button type="submit" value="1"><i class="far fa-save"></i> Save Config</button>');
if (tokenConfig) {
$(html)
.find('.sheet-footer')
.append('<button type="button" class="remove-config"><i class="fas fa-trash"></i> Remove Config</button>');
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);
}

+ 673
- 0
Data/modules/token-variants/applications/tokenHUD.js View File

@ -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 = $(`
<div class="control-icon" data-action="token-variants-side-selector">
<img
id="token-variants-side-button"
src="modules/token-variants/img/token-images.svg"
width="36"
height="36"
title="Left-click: Image Menu&#013;Right-click: Search & Additional settings"
/>
</div>
`);
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 = $(`
<div class="token-variants-wrap contextmenu active">
<div class="token-variants-context-menu active">
<input class="token-variants-side-search" type="text" />
<button class="flags" type="button"><i class="fab fa-font-awesome-flag"></i><label>Flags</label></button>
<button class="file-picker" type="button"><i class="fas fa-file-import fa-fw"></i><label>Browse Folders</label></button>
<button class="effectConfig" type="button"><i class="fas fa-sun"></i><label>Mappings</label></button>
<button class="randomizerConfig" type="button"><i class="fas fa-dice"></i><label>Randomizer</label></button>
</div>
</div>
`);
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;
}

+ 32
- 0
Data/modules/token-variants/applications/tokenHUDClientSettings.js View File

@ -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));
}
}

+ 81
- 0
Data/modules/token-variants/applications/userList.js View File

@ -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);
}
}
}

BIN
Data/modules/token-variants/img/anchor_diagram.webp (Stored with Git LFS) View File

size 7614

+ 65
- 0
Data/modules/token-variants/img/token-images.svg View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
aria-hidden="true"
focusable="false"
data-prefix="far"
data-icon="images"
class="svg-inline--fa fa-images fa-w-18"
role="img"
viewBox="0 0 576 512"
version="1.1"
id="svg19"
sodipodi:docname="token-images.svg"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs23" />
<sodipodi:namedview
id="namedview21"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="0.83485557"
inkscape:cx="515.65806"
inkscape:cy="-97.621677"
inkscape:window-width="2498"
inkscape:window-height="1417"
inkscape:window-x="1134"
inkscape:window-y="266"
inkscape:window-maximized="1"
inkscape:current-layer="svg19" />
<path
fill="currentColor"
d="m 480,416 v 16 c 0,26.51 -21.49,48 -48,48 H 48 C 21.49,480 0,458.51 0,432 V 176 c 0,-26.51 21.49,-48 48,-48 h 16 v 48 H 54 c -3.313708,0 -6,2.68629 -6,6 v 244 c 0,3.31371 2.686292,6 6,6 h 372 c 3.31371,0 6,-2.68629 6,-6 V 416 Z M 522,80 H 150 c -3.31371,0 -6,2.686292 -6,6 v 244 c 0,3.31371 2.68629,6 6,6 h 372 c 3.31371,0 6,-2.68629 6,-6 V 86 c 0,-3.313708 -2.68629,-6 -6,-6 z m 6,-48 c 26.51,0 48,21.49 48,48 v 256 c 0,26.51 -21.49,48 -48,48 H 144 C 117.49,384 96,362.51 96,336 V 80 c 0,-26.51 21.49,-48 48,-48 z"
id="path17"
sodipodi:nodetypes="cssssssccssssssccssssssssssssssssss"
style="fill:#ffffff" />
<ellipse
style="fill:#ffffff;stroke-width:0.980196"
id="path2404"
cx="339.00412"
cy="161.56165"
rx="53.571548"
ry="51.877586" />
<g
id="g3974"
transform="matrix(1.0695605,0,0,1.1754194,-28.198969,-43.319962)"
style="fill:#ffffff">
<path
style="fill:#ffffff;stroke:#ffffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 247.31844,301.10174 c 1.34075,-11.36414 5.20924,-21.29324 10.97403,-29.79329 18.10955,-26.7021 54.93264,-39.30172 90.89434,-37.98476 44.69676,1.63684 88.06284,24.77199 92.51377,69.04852"
id="path3429"
sodipodi:nodetypes="cssc" />
<path
style="fill:#ffffff;stroke:#ffffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 247.31844,301.10174 194.38214,1.27047"
id="path3890" />
</g>
</svg>

+ 212
- 0
Data/modules/token-variants/lang/en.json View File

@ -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)"
}
}
}
}

+ 41
- 0
Data/modules/token-variants/module.json View File

@ -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"
}
]
}
}

+ 201
- 0
Data/modules/token-variants/scripts/fuse/LICENSE View File

@ -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.

+ 2436
- 0
Data/modules/token-variants/scripts/fuse/fuse.js
File diff suppressed because it is too large
View File


+ 207
- 0
Data/modules/token-variants/scripts/hooks/artSelectButtonHooks.js View File

@ -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
);
}
}

+ 31
- 0
Data/modules/token-variants/scripts/hooks/effectIconHooks.js View File

@ -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');
}
}

+ 1044
- 0
Data/modules/token-variants/scripts/hooks/effectMappingHooks.js
File diff suppressed because it is too large
View File


+ 51
- 0
Data/modules/token-variants/scripts/hooks/hooks.js View File

@ -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();
}

+ 23
- 0
Data/modules/token-variants/scripts/hooks/hudHooks.js View File

@ -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');
}
}

+ 85
- 0
Data/modules/token-variants/scripts/hooks/overlayHooks.js View File

@ -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));
}
}

+ 272
- 0
Data/modules/token-variants/scripts/hooks/popUpRandomizeHooks.js View File

@ -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: `<p>${game.i18n.localize('token-variants.windows.art-select.apply-same-art')}</p>`,
buttons: {
one: {
icon: '<i class="fas fa-check"></i>',
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: '<i class="fas fa-times"></i>',
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);
}
}

+ 57
- 0
Data/modules/token-variants/scripts/hooks/userMappingHooks.js View File

@ -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;
}

+ 82
- 0
Data/modules/token-variants/scripts/hooks/wildcardHooks.js View File

@ -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);
}

+ 5857
- 0
Data/modules/token-variants/scripts/mappingTemplates.js
File diff suppressed because it is too large
View File


+ 121
- 0
Data/modules/token-variants/scripts/models.js View File

@ -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 },
};

+ 620
- 0
Data/modules/token-variants/scripts/search.js View File

@ -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<Map<string, Array<object>|Array<string>>} 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<string>|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;
}

+ 561
- 0
Data/modules/token-variants/scripts/settings.js View File

@ -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,
};
}

+ 699
- 0
Data/modules/token-variants/scripts/sprite/TVASprite.js View File

@ -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',
};

+ 571
- 0
Data/modules/token-variants/scripts/token/overlay.js View File

@ -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);
}

+ 1088
- 0
Data/modules/token-variants/scripts/utils.js
File diff suppressed because it is too large
View File


+ 147
- 0
Data/modules/token-variants/scripts/wrappers/effectIconWrappers.js View File

@ -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;
}

+ 31
- 0
Data/modules/token-variants/scripts/wrappers/hudWrappers.js View File

@ -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;
}

+ 150
- 0
Data/modules/token-variants/scripts/wrappers/userMappingWrappers.js View File

@ -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);
}

+ 30
- 0
Data/modules/token-variants/scripts/wrappers/wrappers.js View File

@ -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();
}

+ 857
- 0
Data/modules/token-variants/styles/tva-styles.css View File

@ -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;
}

+ 94
- 0
Data/modules/token-variants/templates/artSelect.html View File

@ -0,0 +1,94 @@
<form style="background: inherit;">
<div class="token-variants-banner">
{{#if displayMode}}
{{#if image1}}
<div class="token-variants-portrait-token form-group {{#if item}}item{{/if}}">
{{#if (eq displayMode 4)}}
<div>Current<img class="image active" src="{{image1}}" title="{{image1}}" alt="" width="80" height="80"></img></div>
{{else}}
<div>Portrait<img class="image {{#if (eq displayMode 1)}}active{{else if (eq displayMode 3)}}active{{/if}}" src="{{image1}}" title="{{image1}}" alt="" width="80" height="80"></img></div>
<div>Token<img class="image {{#if (eq displayMode 2)}}active{{else if (eq displayMode 3)}}active{{/if}}" src="{{image2}}" title="{{image2}}" alt="" width="80" height="80"></img></div>
{{/if}}
</div>
{{/if}}
{{#if item}}
<div class="item-description">
<h2>Description</h2>
<div class="item-description-content">{{{description}}}</div>
</div>
{{/if}}
{{/if}}
<div class="form-group">
<input type="text" id="custom-art-search" name="search" value="{{search}}" />
<button type="button" id="token-variant-art-clear-queue" {{#unless queue}}hidden{{/unless}}>Clear Queue ({{queue}})</button>
</div>
{{#if displaySlider}}
<div class="form-group">
<input
type="range"
name="fuzzyThreshold"
data-dtype="Number"
value="{{fuzzyThreshold}}"
min="0"
max="100"
step="1"
/>
<span class="token-variants-range-value">{{fuzzyThreshold}}%</span>
</div>
{{/if}}
{{#if multipleSelection}}
<div class="form-group">
<button type="button" id="token-variant-art-return-selected">Return selected</button>
<button type="button" id="token-variant-art-return-all">Return All</button>
</div>
{{/if}}
</div>
<div class="search-content">
{{#if allImages}} {{#each allImages as |search index|}}
<div>
<h2><b>{{search.[0]}}</b></h2>
</div>
<hr />
<div class="token-variants-grid">
{{#each search.[1] as |image|}}
<div>
<span
class="token-variants-grid-box {{#unless image.type}}token-variants-unrecognised{{/unless}}"
title="{{image.title}}"
data-name="{{image.path}}"
data-filename="{{image.name}}">
</span>
{{#if image.img}}
<img class="token-variants-grid-image" src="{{image.path}}" />
{{/if}} {{#if image.vid}}
<video
class="token-variants-grid-image"
src="{{image.path}}"
alt="{{image.name}}"
{{#if ../autoplay}}
autoplay
{{/if}}
loop
muted>
</video>
{{#unless ../autoplay}}
<i class="fas fa-play fa-lg"></i>
{{/unless}}
{{/if}} {{#unless image.type}}
<p class="token-variants-grid-image" alt="{{image.name}}">{{image.path}}</p>
{{/unless}}
<i class="fas fa-cog fa-lg {{#if image.hasConfig}}active{{/if}}"></i>
<p>{{{image.label}}}</p>
</div>
{{/each}}
</div>
{{/each}} {{else}}
<div>
<p>{{localize "token-variants.windows.art-select.no-art-found"}}: <b>{{search}}</b></p>
</div>
</div>
{{/if}}
</form>

+ 133
- 0
Data/modules/token-variants/templates/compendiumMap.html View File

@ -0,0 +1,133 @@
<form>
<div class="form-group">
<p class="notes">Supported compendiums: <b>{{supportedPacks}}</b></p>
<label>Compendium</label>
<div class="form-fields">
<select name="compendium">
{{#each compendiums as |comp|}}
<option value="{{comp.id}}" {{#if (eq ../compendium comp.id)}}selected="selected"{{/if}}>{{comp.title}}</option>
{{/each}}
</select>
</div>
<p class="notes">
{{localize "token-variants.settings.compendium-mapper.window.compendium-select"}}
</p>
</div>
<hr/>
<div class="form-group">
<label>Override Image Category</label>
<input class="token-variants-override-category" type="checkbox" name="overrideCategory" data-dtype="Boolean" {{#if overrideCategory}}checked{{/if}} />
<p class="notes">Change the default image category used by the module for this compendium type.</p>
</div>
<div class="form-group">
<label>Category</label>
<div class="form-fields">
<select class="token-variants-category" name="category">
{{#each categories as |cat|}}
<option value="{{cat}}" {{#if (eq ../category cat)}}selected="selected"{{/if}}>{{cat}}</option>
{{/each}}
</select>
</div>
</div>
<hr/>
<div class="form-group token-specific">
<label>{{localize "token-variants.settings.compendium-mapper.window.diff-images"}}</label>
<input class="token-variants-diff-images" type="checkbox" name="diffImages" data-dtype="Boolean"
{{#if diffImages}}checked{{/if}} />
</div>
<div class="form-group token-specific">
<label
>&nbsp;&nbsp;&nbsp;&nbsp; {{localize
"token-variants.settings.compendium-mapper.window.ignore-token"}}</label
>
<input class="token-variants-tp-ignore" type="checkbox" name="ignoreToken" data-dtype="Boolean"
{{#if ignoreToken}}checked{{/if}} {{#unless diffImages}}disabled{{/unless}}/>
</div>
<div class="form-group token-specific">
<label
>&nbsp;&nbsp;&nbsp;&nbsp; {{localize
"token-variants.settings.compendium-mapper.window.ignore-portrait"}}</label
>
<input class="token-variants-tp-ignore" type="checkbox" name="ignorePortrait"
data-dtype="Boolean" {{#if ignorePortrait}}checked{{/if}} {{#unless
diffImages}}disabled{{/unless}}/>
</div>
<hr />
<div class="form-group">
<label>{{localize "token-variants.settings.compendium-mapper.window.missing-only"}}</label>
<input type="checkbox" name="missingOnly" data-dtype="Boolean" {{#if missingOnly}}checked{{/if}} />
</div>
<div class="form-group">
<label>Missing Images</label>
<button type="button" class="token-variants-missing-images">
<i class="fas fa-exchange-alt"></i>
</button>
<p class="notes">
Define additional images that are to be considered as "missing"
</p>
</div>
<hr />
<div class="form-group">
<label>{{localize "token-variants.settings.compendium-mapper.window.show-images"}}</label>
<input type="checkbox" name="showImages" data-dtype="Boolean" {{#if showImages}}checked{{/if}} />
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.compendium-mapper.window.cache"}}</label>
<input type="checkbox" name="cache" data-dtype="Boolean" {{#if cache}}checked{{/if}} />
</div>
<hr />
<div class="form-group">
<label>{{localize "token-variants.settings.algorithm.Name"}}</label>
<button type="button" class="token-variants-search-options">
<i class="fas fa-exchange-alt"></i>
</button>
<p class="notes">
{{localize "token-variants.settings.algorithm.Hint"}}
</p>
</div>
<h2>{{localize "token-variants.common.automation"}}</h2>
<div class="form-group">
<label>{{localize "token-variants.settings.compendium-mapper.window.auto-apply"}}</label>
<input
class="token-variants-auto-apply"
type="checkbox"
name="autoApply"
data-dtype="Boolean"
/>
</div>
<div class="form-group">
&nbsp;&nbsp;
<label>{{localize "token-variants.settings.compendium-mapper.window.auto-art-select"}}</label>
<input class="token-variants-auto-art-select" type="checkbox" name="autoDisplayArtSelect"
data-dtype="Boolean" disabled {{#if autoDisplayArtSelect}}checked{{/if}}/>
</div>
<div class="form-group token-specific">
<label>{{localize "token-variants.settings.compendium-mapper.window.sync-images"}}</label>
<input type="checkbox" name="syncImages" data-dtype="Boolean" />
</div>
<footer class="sheet-footer flexrow">
<button type="submit">
<i class="fas fa-cogs"></i>{{localize "token-variants.common.start"}}
</button>
</footer>
</form>

+ 14
- 0
Data/modules/token-variants/templates/configJsonEdit.html View File

@ -0,0 +1,14 @@
<form class="macro-sheet editable flexcol">
<div class="form-group stacked command">
<label>Token Configuration</label>
<textarea name="config">{{config}}</textarea>
</div>
<footer class="sheet-footer flexrow">
<button type="submit"><i class="far fa-save"></i>{{localize "token-variants.common.apply"}}</button>
<button type="button" class="format"><i class="fas fa-sparkles"></i> Format JSON</button>
{{#if hasConfig}}
<button type="button" class="remove"><i class="fas fa-trash"></i> {{localize "token-variants.common.remove"}}</button>
{{/if}}
</footer>
</form>

+ 91
- 0
Data/modules/token-variants/templates/configScriptEdit.html View File

@ -0,0 +1,91 @@
<form class="macro-sheet editable flexcol">
<fieldset class="token-variants-macro">
<legend>Scripts</legend>
<div class="form-group stacked command">
<label>Run when 'Expression' is TRUE</label>
<textarea name="onApply">{{onApply}}</textarea>
</div>
<div class="form-group stacked command">
<label>Run when 'Expression' is FALSE</label>
<textarea name="onRemove">{{onRemove}}</textarea>
</div>
</fieldset>
<br>
<datalist id="macros">
{{#each macros }}
<option value="{{this}}"></option>
{{/each}}
</datalist>
<fieldset>
<legend>Macros</legend>
<div class="form-group">
<label>Run when 'Expression' is TRUE</label>
<div class="form-fields">
<input list="macros" name="macroOnApply" value="{{macroOnApply}}">
</div>
</div>
<div class="form-group">
<label>Run when 'Expression' is FALSE</label>
<div class="form-fields">
<input list="macros" name="macroOnRemove" value="{{macroOnRemove}}">
</div>
</div>
</fieldset>
{{#if tmfxActive}}
<br>
<fieldset>
<legend>Token Magic FX</legend>
<div class="form-group">
<label>Toggle Preset</label>
<div class="form-fields">
<datalist id="tmfxPresets">
{{#each tmfxPresets }}
<option value="{{this}}"></option>
{{/each}}
</datalist>
<input list="tmfxPresets" name="tmfxPreset" value="{{tmfxPreset}}">
</div>
</div>
</fieldset>
{{/if}}
{{#if ceActive}}
<br>
<fieldset>
<legend>DFreds Convenient Effects</legend>
<div class="form-group">
<label>Effect</label>
<div class="form-fields">
<datalist id="ceEffects">
{{#each ceEffects }}
<option value="{{this}}"></option>
{{/each}}
</datalist>
<input list="ceEffects" name="ceEffect.name" value="{{ceEffect.name}}">
</div>
</div>
<div class="form-group slim">
<label>Actions</label>
<div class="form-fields">
<label>Apply</label>
<input type="checkbox" name="ceEffect.apply" {{#if ceEffect.apply}}checked{{/if}}>
<label>Remove</label>
<input type="checkbox" name="ceEffect.remove" {{#if ceEffect.remove}}checked{{/if}}>
</div>
</div>
</fieldset>
{{/if}}
<footer class="sheet-footer flexrow">
<button type="submit"><i class="far fa-save"></i>{{localize "token-variants.common.apply"}}</button>
{{#if hasScript}}
<button type="button" class="remove"><i class="fas fa-trash"></i> {{localize "token-variants.common.remove"}}</button>
{{/if}}
</footer>
</form>

+ 813
- 0
Data/modules/token-variants/templates/configureSettings.html View File

@ -0,0 +1,813 @@
<form>
<!-- Navigation Tabs -->
<nav class="sheet-tabs tabs tva-setting-nav" data-group="primary-tabs">
{{#if enabledTabs.searchPaths}}<a class="item" data-tab="searchPaths"><i class="fas fa-search"></i> Search Paths</a>{{/if}}
{{#if enabledTabs.searchFilters}}<a class="item" data-tab="searchFilters"><i class="fas fa-filter"></i> Search Filters</a>{{/if}}
{{#if enabledTabs.searchAlgorithm}}<a class="item" data-tab="searchAlgorithm"><i class="fas fa-plug"></i> Search Algorithm</a>{{/if}}
{{#if enabledTabs.randomizer}}<a class="item" data-tab="randomizer"><i class="fas fa-dice"></i> Randomizer</a>{{/if}}
{{#if enabledTabs.features}}<a class="item" data-tab="features"><i class="fas fa-power-off"></i> Features</a>{{/if}}
<hr>
{{#if enabledTabs.popup}}<a class="item" data-tab="popup"><i class="fas fa-book-open"></i> Pop-up</a>{{/if}}
{{#if enabledTabs.permissions}}<a class="item" data-tab="permissions"><i class="fas fa-user-lock"></i> Permissions</a>{{/if}}
{{#if enabledTabs.worldHud}}<a class="item" data-tab="worldHud"><i class="fas fa-images"></i> Token HUD</a>{{/if}}
{{#if enabledTabs.activeEffects}}<a class="item" data-tab="activeEffects"><i class="fas fa-sun"></i> Effects</a>{{/if}}
{{#if enabledTabs.misc}}<a class="item" data-tab="misc"><i class="fas fa-bars"></i> Misc</a>{{/if}}
</nav>
<!-- Main Content Section -->
<section class="content">
<!-- SEARCH PATHS -->
{{#if enabledTabs.searchPaths}}
<div class="tab" data-tab="searchPaths" data-group="primary-tabs">
<ol class="token-variant-table">
<li class="table-row table-header flexrow">
<div class="path-image">
<a class="create-path" title="Add new path"><i class="fas fa-plus"></i></a>
</div>
<div class="path-source"><label>Source</label></div>
<div class="path-text"><label>Path</label></div>
<div class="path-category"><label>Category</label></div>
<div class="path-config"><label>Config</label></div>
<div class="path-cache"><label>Cache</label></div>
<div class="path-controls"></div>
</li>
{{#each searchPaths as |path index|}}
<li class="table-row flexrow">
<div class="path-image source-icon">
<a><i class="{{path.icon}}"></i></a>
</div>
<div class="path-source">
<input class="searchSource" type="text" name="searchPaths.{{index}}.source" value="{{path.source}}" placeholder="data"/>
</div>
<div class="path-text">
<input class="searchPath" type="text" name="searchPaths.{{index}}.text" value="{{path.text}}" placeholder="Path to folder"/>
</div>
<div class="imgur-control {{#if (eq path.source 'imgur')}}active{{/if}}">
<a class="convert-imgur" title="Convert to Rolltable">
<i class="fas fa-angle-double-left"></i>
</a>
</div>
<div class="json-control {{#if (eq path.source 'json')}}active{{/if}}">
<a class="convert-json" title="Convert to Rolltable">
<i class="fas fa-angle-double-left"></i>
</a>
</div>
<div class="path-category">
<a class="select-category" title="Select image categories/filters"><i class="fas fa-swatchbook"></i></a>
<input type="hidden" name="searchPaths.{{index}}.types" value="{{path.types}}">
</div>
<div class="path-config">
<a class="select-config {{#if path.hasConfig}}active{{/if}}" title="Apply token configuration to images under this path."><i class="fas fa-cog fa-lg"></i></a>
<input type="hidden" name="searchPaths.{{index}}.config" value="{{path.config}}">
</div>
<div class="path-cache">
<input type="checkbox" name="searchPaths.{{index}}.cache" data-dtype="Boolean" {{#if path.cache}}checked{{/if}}/>
</div>
<div class="path-controls">
<a class="delete-path" title="Delete path"><i class="fas fa-trash"></i></a>
</div>
</li>
{{/each}}
</ol>
<p class="notes">
<b>Formats:</b><br />
<span>Note: the path start from the 'data' folder of Foundry by default<span><br />
data | path/to/folder<br />
s3:my_bucket | token/art/folder/<br />
rolltable | rolltableName<br />
json | path/to/folder/data.json<br />
imgur | galleryId
</p>
</div>
{{/if}}
<!-- SEARCH FILTERS -->
{{#if enabledTabs.searchFilters}}
<div class="tab" data-tab="searchFilters" data-group="primary-tabs">
<p class="notes">Define filters for each image category. Images will be limited to files that include/exclude these pieces of text or match a regular expression.</p>
<hr>
{{#each searchFilters}}
<label><b>{{this.label}}</b></label>
<div class="form-group">
<label>{{localize "token-variants.common.include"}}</label>
<div class="form-fields">
<input type="text" name="searchFilters.{{@key}}.include" value="{{this.include}}" data-dtype="String">
</div>
&nbsp;&nbsp;
<label>{{localize "token-variants.common.exclude"}}</label>
<div class="form-fields">
<input type="text" name="searchFilters.{{@key}}.exclude" value="{{this.exclude}}" data-dtype="String">
</div>
&nbsp;&nbsp;
<label>RegEx</label>
<div class="form-fields">
<input class="filterRegex" type="text" name="searchFilters.{{@key}}.regex" value="{{this.regex}}" data-dtype="String">
</div>
</div>
<hr />
{{/each}}
</div>
{{/if}}
<!-- SEARCH ALGORITHM -->
{{#if enabledTabs.searchAlgorithm}}
<div class="tab" data-tab="searchAlgorithm" data-group="primary-tabs">
<div class="form-group">
<label>{{localize "token-variants.settings.keywords-search.Name"}}</label>
<div class="form-fields">
<input type="checkbox" name="keywordSearch" data-dtype="Boolean" {{#if keywordSearch}}checked{{/if}}>
</div>
<p class="notes">{{localize "token-variants.settings.keywords-search.Hint"}}</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.excluded-keywords.Name"}}</label>
<div class="form-fields">
<input type="text" name="excludedKeywords" data-dtype="String" value="{{excludedKeywords}}">
</div>
<p class="notes">{{localize "token-variants.settings.excluded-keywords.Hint"}}</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.run-search-on-path.Name"}}</label>
<div class="form-fields">
<input type="checkbox" name="runSearchOnPath" data-dtype="Boolean" {{#if runSearchOnPath}}checked{{/if}}>
</div>
<p class="notes">{{localize "token-variants.settings.run-search-on-path.Hint"}}</p>
</div>
<h1>Search Method</h1>
<hr>
<h2>{{localize "token-variants.common.exact"}}</h2>
<div class="form-group">
<label>{{localize "token-variants.common.enable"}}</label>
<input type="checkbox" name="algorithm.exact" data-dtype="Boolean" {{#if algorithm.exact}}checked{{/if}}>
<p class="notes">{{localize "token-variants.settings.algorithm.window.exact-hint"}}</p>
</div>
<h2>{{localize "token-variants.common.fuzzy"}}</h2>
<div class="form-group">
<label>{{localize "token-variants.common.enable"}}</label>
<input type="checkbox" name="algorithm.fuzzy" data-dtype="Boolean" {{#if algorithm.fuzzy}}checked{{/if}}>
<p class="notes">{{localize "token-variants.settings.algorithm.window.fuzzy-hint"}}</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.algorithm.window.percentage-match.Name"}}</label>
<input
type="range"
name="algorithm.fuzzyThreshold"
data-dtype="Number"
value="{{algorithm.fuzzyThreshold}}"
min="0"
max="100"
step="1"
/>
<span class="token-variants-range-value">{{algorithm.fuzzyThreshold}}%</span>
<p class="notes">
{{localize "token-variants.settings.algorithm.window.percentage-match.Hint"}}
</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.algorithm.window.art-select-slider.Name"}}</label>
<input type="checkbox" name="algorithm.fuzzyArtSelectPercentSlider" data-dtype="Boolean" {{#if
algorithm.fuzzyArtSelectPercentSlider}}checked{{/if}}>
<p class="notes">
{{localize "token-variants.settings.algorithm.window.art-select-slider.Hint"}}
</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.common.limit"}}</label>
<input
type="text"
name="algorithm.fuzzyLimit"
value="{{algorithm.fuzzyLimit}}"
placeholder="1"
data-dtype="Number"
/>
<p class="notes">{{localize "token-variants.settings.algorithm.window.limit-hint"}}</p>
</div>
</div>
{{/if}}
<!-- Randomizer -->
{{#if enabledTabs.randomizer}}
<div class="tab" data-tab="randomizer" data-group="primary-tabs">
<h2>{{localize "token-variants.common.randomize"}}</h2>
<div class="form-group">
<label>
{{localize "token-variants.settings.randomizer.window.portrait-image-on-actor-create"}}
</label>
<input type="checkbox" name="randomizer.actorCreate" data-dtype="Boolean" {{#if randomizer.actorCreate}}checked{{/if}}>
</div>
<hr />
<div class="form-group">
<label>
{{localize "token-variants.settings.randomizer.window.token-image-on-token-create"}}
</label>
<input type="checkbox" name="randomizer.tokenCreate" data-dtype="Boolean" {{#if randomizer.tokenCreate}}checked{{/if}}>
</div>
<div class="form-group">
<label>
{{localize "token-variants.settings.randomizer.window.token-image-on-token-copy-paste"}}
</label>
<input type="checkbox" name="randomizer.tokenCopyPaste" data-dtype="Boolean" {{#if randomizer.tokenCopyPaste}}checked{{/if}}>
</div>
<div class="form-group">
<label>&nbsp;&nbsp;&nbsp;&nbsp; {{localize "token-variants.settings.randomizer.window.token-to-portrait"}}</label>
<input type="checkbox" name="randomizer.tokenToPortrait" data-dtype="Boolean" {{#if randomizer.tokenToPortrait}}checked{{/if}} {{#if randomizer.tokenToPortraitDisabled}}disabled{{/if}}>
</div>
<hr />
<div class="form-group">
<label>{{localize "token-variants.settings.randomizer.window.different-images"}}</label>
<input type="checkbox" name="randomizer.diffImages" data-dtype="Boolean" {{#if randomizer.diffImages}}checked{{/if}}>
</div>
<div class="form-group">
<label>&nbsp;&nbsp;&nbsp;&nbsp; {{localize "token-variants.settings.randomizer.window.sync-images"}}</label>
<input type="checkbox" name="randomizer.syncImages" data-dtype="Boolean" {{#if randomizer.syncImages}}checked{{/if}} {{#unless randomizer.diffImages}}disabled{{/unless}}>
</div>
<h2>{{localize "token-variants.settings.randomizer.window.search-types-heading"}}</h2>
<div class="form-group">
<label>{{localize "token-variants.common.name"}}</label>
<input type="checkbox" name="randomizer.tokenName" data-dtype="Boolean" {{#if randomizer.tokenName}}checked{{/if}}>
</div>
<div class="form-group">
<label>{{localize "token-variants.common.keywords"}}</label>
<input type="checkbox" name="randomizer.keywords" data-dtype="Boolean" {{#if randomizer.keywords}}checked{{/if}}>
</div>
<div class="form-group">
<label>{{localize "token-variants.common.shared"}} <i class="fas fa-share"></i></label>
<input type="checkbox" name="randomizer.shared" data-dtype="Boolean" {{#if randomizer.shared}}checked{{/if}}>
</div>
<div class="form-group">
<label>Wildcard *</label>
<input type="checkbox" name="randomizer.wildcard" data-dtype="Boolean" {{#if randomizer.wildcard}}checked{{/if}}>
</div>
<h2>{{localize "token-variants.settings.randomizer.window.disable-for"}}</h2>
<div class="form-group">
<label>&nbsp;&nbsp;&nbsp;&nbsp;{{localize "token-variants.settings.randomizer.window.tokens-with-represented-actor"}}</label>
<input type="checkbox" name="randomizer.representedActorDisable" data-dtype="Boolean" {{#if randomizer.representedActorDisable}}checked{{/if}}>
</div>
<div class="form-group">
<label>&nbsp;&nbsp;&nbsp;&nbsp;{{localize "token-variants.settings.randomizer.window.tokens-with-linked-actor-data"}}</label>
<input type="checkbox" name="randomizer.linkedActorDisable" data-dtype="Boolean" {{#if randomizer.linkedActorDisable}}checked{{/if}}>
</div>
<hr />
<h3>Actor Types</h3>
{{#each randomizer.actorTypes}}
<div class="form-group">
<label>&nbsp;&nbsp;&nbsp;&nbsp;{{this.label}}</label>
<input type="checkbox" name="randomizer.{{@key}}Disable" data-dtype="Boolean" {{#if this.disable}}checked{{/if}}>
</div>
{{/each}}
<hr />
<div class="form-group">
<h4>{{localize "token-variants.settings.randomizer.window.pop-up-if-randomization-disabled"}}</h4>
<input type="checkbox" name="randomizer.popupOnDisable" data-dtype="Boolean" {{#if randomizer.popupOnDisable}}checked{{/if}}>
</div>
</div>
{{/if}}
<!-- Features -->
{{#if enabledTabs.features}}
<div class="tab token-variants-permissions" data-tab="features" data-group="primary-tabs">
<p class="notes">Fully turn-off module features.</p>
<header class="table-header flexrow">
<label class="index">Features</label>
<label>Enabled</label>
</header>
<ul class="permissions-list">
<li class="permission form-group">
<label class="index">Effect Mappings</label>
<div class="form-fields">
<input type="checkbox" name="features.EffectMappings" {{ checked features.EffectMappings}} />
</div>
<p class="hint"></p>
</li>
<li class="permission form-group">
<label class="index">Overlays</label>
<div class="form-fields">
<input type="checkbox" name="features.Overlays" {{ checked features.Overlays}} />
</div>
<p class="hint"></p>
</li>
</ul>
</div>
{{/if}}
<!-- Pop-up -->
{{#if enabledTabs.popup}}
<div class="tab token-variants-popup-settings" data-tab="popup" data-group="primary-tabs">
<div class="form-group">
<label>{{localize "token-variants.settings.pop-up.window.two-pop-ups.Name"}}</label>
<input type="checkbox" name="popup.twoPopups" data-dtype="Boolean" {{#if popup.twoPopups}}checked{{/if}}>
<p class="notes">{{localize "token-variants.settings.pop-up.window.two-pop-ups.Hint"}}</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.pop-up.window.no-dialog.Name"}}</label>
<input type="checkbox" name="popup.twoPopupsNoDialog" data-dtype="Boolean" {{#if popup.twoPopupsNoDialog}}checked{{/if}}>
<p class="notes">{{localize "token-variants.settings.pop-up.window.no-dialog.Hint"}}</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.pop-up.window.disable-automatic-pop-ups-for"}}</label>
</div>
{{#each popup.actorTypes}}
<header class="table-header flexrow">
{{#each this}}
<label>{{this.label}}</label>
{{/each}}
</header>
<ul class="setting-list">
<li class="setting form-group">
<div class="form-fields">
{{#each this}} <input type="checkbox" name="popup.{{this.type}}Disable" data-dtype="Boolean" {{#if
this.disable}}checked{{/if}}> {{/each}}
</div>
</li>
</ul>
{{/each}}
<header class="table-header flexrow">
<label class="index">{{localize "token-variants.settings.pop-up.window.on-actor-create"}}</label>
<label>{{localize "token-variants.settings.pop-up.window.on-token-create"}}</label>
<label>{{localize "token-variants.settings.pop-up.window.on-token-copy-paste"}}</label>
</header>
<ul class="setting-list">
<li class="setting form-group">
<div class="form-fields">
<input type="checkbox" name="popup.disableAutoPopupOnActorCreate" data-dtype="Boolean" {{#if popup.disableAutoPopupOnActorCreate}}checked{{/if}}>
<input type="checkbox" name="popup.disableAutoPopupOnTokenCreate" data-dtype="Boolean" {{#if popup.disableAutoPopupOnTokenCreate}}checked{{/if}}>
<input type="checkbox" name="popup.disableAutoPopupOnTokenCopyPaste" data-dtype="Boolean" {{#if popup.disableAutoPopupOnTokenCopyPaste}}checked{{/if}}>
</div>
</li>
</ul>
</div>
{{/if}}
<!-- Permissions -->
{{#if enabledTabs.permissions}}
<div class="tab token-variants-permissions" data-tab="permissions" data-group="primary-tabs">
<p class="notes">Configure which User role has permission to access which module features.</p>
<header class="table-header flexrow">
<label class="index">Features</label>
<label>Player</label>
<label>Trusted Player</label>
<label>Assistant GM</label>
<label>Game Master</label>
</header>
<ul class="permissions-list">
<li class="permission form-group">
<label class="index">Automatic Pop-ups</label>
<div class="form-fields">
<input type="checkbox" name="permissions.popups.1" {{#if permissions.popups.[1]}}checked{{/if}} />
<input type="checkbox" name="permissions.popups.2" {{#if permissions.popups.[2]}}checked{{/if}} />
<input type="checkbox" name="permissions.popups.3" {{#if permissions.popups.[3]}}checked{{/if}} />
<input type="checkbox" name="permissions.popups.4" {{#if permissions.popups.[4]}}checked{{/if}} />
</div>
<p class="hint">Allow players with this role to be shown automatic Art Select pop-ups.</p>
</li>
<li class="permission form-group">
<label class="index">Portrait/Icon Right-click</label>
<div class="form-fields">
<input type="checkbox" name="permissions.portrait_right_click.1" {{#if permissions.portrait_right_click.[1]}}checked{{/if}} />
<input type="checkbox" name="permissions.portrait_right_click.2" {{#if permissions.portrait_right_click.[2]}}checked{{/if}} />
<input type="checkbox" name="permissions.portrait_right_click.3" {{#if permissions.portrait_right_click.[3]}}checked{{/if}} />
<input type="checkbox" name="permissions.portrait_right_click.4" {{#if permissions.portrait_right_click.[4]}}checked{{/if}} />
</div>
<p class="hint">Allow players with this role to open Art Select windows via Right-click of images on various forms.</p>
</li>
<li class="permission form-group">
<label class="index">Art Select Buttons</label>
<div class="form-fields">
<input type="checkbox" name="permissions.image_path_button.1" {{#if permissions.image_path_button.[1]}}checked{{/if}} />
<input type="checkbox" name="permissions.image_path_button.2" {{#if permissions.image_path_button.[2]}}checked{{/if}} />
<input type="checkbox" name="permissions.image_path_button.3" {{#if permissions.image_path_button.[3]}}checked{{/if}} />
<input type="checkbox" name="permissions.image_path_button.4" {{#if permissions.image_path_button.[4]}}checked{{/if}} />
</div>
<p class="hint">Allow players with this role to open the Art Select windows via buttons inserted into various forms.</p>
</li>
<li class="permission form-group">
<label class="index">Token HUD button</label>
<div class="form-fields">
<input type="checkbox" name="permissions.hud.1" {{#if permissions.hud.[1]}}checked{{/if}} />
<input type="checkbox" name="permissions.hud.2" {{#if permissions.hud.[2]}}checked{{/if}} />
<input type="checkbox" name="permissions.hud.3" {{#if permissions.hud.[3]}}checked{{/if}} />
<input type="checkbox" name="permissions.hud.4" {{#if permissions.hud.[4]}}checked{{/if}} />
</div>
<p class="hint">Allow players with this role access to the Token HUD button (Shared and Wildcard art only)</p>
</li>
<li class="permission form-group">
<label class="index">Token HUD button FULL ACCESS</label>
<div class="form-fields">
<input type="checkbox" name="permissions.hudFullAccess.1" {{#if permissions.hudFullAccess.[1]}}checked{{/if}} />
<input type="checkbox" name="permissions.hudFullAccess.2" {{#if permissions.hudFullAccess.[2]}}checked{{/if}} />
<input type="checkbox" name="permissions.hudFullAccess.3" {{#if permissions.hudFullAccess.[3]}}checked{{/if}} />
<input type="checkbox" name="permissions.hudFullAccess.4" {{#if permissions.hudFullAccess.[4]}}checked{{/if}} />
</div>
<p class="hint">Allow players with this role unrestricted access to all art via the Token HUD button</p>
</li>
<li class="permission form-group">
<label class="index">Status Config</label>
<div class="form-fields">
<input type="checkbox" name="permissions.statusConfig.1" {{#if permissions.statusConfig.[1]}}checked{{/if}} />
<input type="checkbox" name="permissions.statusConfig.2" {{#if permissions.statusConfig.[2]}}checked{{/if}} />
<input type="checkbox" name="permissions.statusConfig.3" {{#if permissions.statusConfig.[3]}}checked{{/if}} />
<input type="checkbox" name="permissions.statusConfig.4" {{#if permissions.statusConfig.[4]}}checked{{/if}} />
</div>
<p class="hint">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)</p>
</li>
</ul>
</div>
{{/if}}
<!-- World HUD -->
{{#if enabledTabs.worldHud}}
<div class="tab" data-tab="worldHud" data-group="primary-tabs">
<h2>World Settings</h2>
<div class="form-group">
<label
>{{localize "token-variants.settings.token-hud.window.display-shared-only.Name"}}
<i class="fas fa-share"></i
></label>
<div class="form-fields">
<input type="checkbox" name="worldHud.displayOnlySharedImages" data-dtype="Boolean" {{#if worldHud.displayOnlySharedImages}}checked{{/if}}>
</div>
<p class="notes">
{{localize "token-variants.settings.token-hud.window.display-shared-only.Hint"}}
</p>
</div>
<div class="form-group">
<label> {{localize "token-variants.settings.compendium-mapper.window.include-keywords"}}</label>
<div class="form-fields">
<input type="checkbox" name="worldHud.includeKeywords" data-dtype="Boolean" {{#if worldHud.includeKeywords}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.token-hud.window.include-wildcard.Name"}}</label>
<div class="form-fields">
<input type="checkbox" name="worldHud.includeWildcard" data-dtype="Boolean" {{#if worldHud.includeWildcard}}checked{{/if}}>
</div>
<p class="notes">
{{localize "token-variants.settings.token-hud.window.include-wildcard.Hint"}}
</p>
</div>
<div class="form-group">
<label>Show full path on hover</label>
<div class="form-fields">
<input type="checkbox" name="worldHud.showFullPath" data-dtype="Boolean" {{#if worldHud.showFullPath}}checked{{/if}}>
</div>
<p class="notes">
When hovering over images instead of the file name full file path will be shown.
</p>
</div>
<hr/>
<div class="form-group">
<label>{{localize "token-variants.settings.token-hud.window.update-actor-image.Name"}}</label>
<div class="form-fields">
<input type="checkbox" name="worldHud.updateActorImage" data-dtype="Boolean" {{#if worldHud.updateActorImage}}checked{{/if}}>
</div>
<p class="notes">
{{localize "token-variants.settings.token-hud.window.update-actor-image.Hint"}}
</p>
</div>
<div class="form-group">
<label>Use a similarly named file</label>
<div class="form-fields">
<input type="checkbox" name="worldHud.useNameSimilarity" data-dtype="Boolean" {{#if worldHud.useNameSimilarity}}checked{{/if}} {{#unless worldHud.updateActorImage}}disabled{{/unless}}>
</div>
<p class="notes">
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.
</p>
</div>
<div class="form-group">
<label>Token Animation</label>
<div class="form-fields">
<input type="checkbox" name="worldHud.animate" data-dtype="Boolean" {{checked worldHud.animate}}>
</div>
<p class="notes">Apply core foundry animations on image change.</p>
</div>
<hr/>
{{#if worldHud.tokenHUDWildcardActive}}
<h2><b>Token HUD Wildcard</b></h2>
<div class="form-group">
<label>{{localize "token-variants.settings.token-hud.window.disable-if-token-hud-wildcard-active.Name"}}</label>
<div class="form-fields">
<input type="checkbox" name="worldHud.disableIfTHWEnabled" data-dtype="Boolean" {{#if worldHud.disableIfTHWEnabled}}checked{{/if}}>
</div>
<p class="notes">
{{localize "token-variants.settings.token-hud.window.disable-if-token-hud-wildcard-active.Hint"}}
</p>
</div>
{{/if}}
</div>
{{/if}}
<!-- Active Effects -->
{{#if enabledTabs.activeEffects}}
<div class="tab" data-tab="activeEffects" data-group="primary-tabs">
<div class="form-group">
<label>Merge Global and Actor mappings based on Groups</label>
<div class="form-fields">
<input type="checkbox" name="mergeGroup" data-dtype="Boolean" {{#if mergeGroup}}checked{{/if}}>
</div>
<p class="notes">Instead of comparing `Labels` Actor mappings will take precedent over Global ones if they belong to the same group.</p>
</div>
<div class="form-group">
<label>Stack Effect Mapping Token Configs and Overlays</label>
<div class="form-fields">
<input type="checkbox" name="stackStatusConfig" data-dtype="Boolean" {{#if stackStatusConfig}}checked{{/if}}>
</div>
<p class="notes">When multiple Effect Mappings are active Token Configurations and Overlays will accumulate on the token instead of overriding each other.</p>
</div>
{{#if dnd5e}}
<fieldset>
<legend>DnD5e</legend>
<div class="form-group">
<label>Disable image updates on Polymorphed</label>
<div class="form-fields">
<input type="checkbox" name="disableImageChangeOnPolymorphed" data-dtype="Boolean" {{#if disableImageChangeOnPolymorphed}}checked{{/if}}>
</div>
<p class="notes">Active Effect changes will not update images on tokens with polymorphed or wild shaped actors.</p>
</div>
</fieldset>
{{/if}}
<div class="form-group">
<label>Disable image updates on manually changed tokens</label>
<div class="form-fields">
<input type="checkbox" name="disableImageUpdateOnNonPrototype" data-dtype="Boolean" {{#if disableImageUpdateOnNonPrototype}}checked{{/if}}>
</div>
<p class="notes">Active Effect changes will not update images on tokens that have an image not corresponding to the prototype or any configurations.</p>
</div>
<div class="form-group">
<label>Disable Token Animation</label>
<div class="form-fields">
<input type="checkbox" name="disableTokenUpdateAnimation" data-dtype="Boolean" {{#if disableTokenUpdateAnimation}}checked{{/if}}>
</div>
<p class="notes">Active Effect changes affecting Token appearance will not trigger core Foundry's Token animation.</p>
</div>
<div class="form-group">
<label>Global Effect Mappings</label>
<button class="token-variants-global-mapping" type="button">
<i class="fas fa-angle-double-right"></i>
<label>Configure</label>
</button>
<p class="notes">Configurations to be applied on ALL tokens. Will be overridden by token specific configurations.</p>
</div>
<div class="form-group">
<label>System's HP Path</label>
<div class="form-fields">
<input type="text" name="systemHpPath" data-dtype="String" value="{{systemHpPath}}">
</div>
<p class="notes">Path to the game system's HP min, max, and value properties.</p>
</div>
<hr>
<div class="form-group">
<label>Display Token Effect Icons on Hover</label>
<div class="form-fields">
<input type="checkbox" name="displayEffectIconsOnHover" data-dtype="Boolean" {{#if displayEffectIconsOnHover}}checked{{/if}}>
</div>
<p class="notes">Effect icons will only be displayed while hovering over the token.</p>
</div>
<hr>
<div class="form-group">
<label>Disable ALL Effect Icons</label>
<div class="form-fields">
<input type="checkbox" name="disableEffectIcons" data-dtype="Boolean" {{#if disableEffectIcons}}checked{{/if}}>
</div>
<p class="notes">Prevents drawing of temporary effects on the token and combat tracker.</p>
</div>
<hr>
{{#unless pathfinder}}
<div class="form-group">
<label>Disable SOME Effect Icons</label>
<div class="form-fields">
<input type="checkbox" name="filterEffectIcons" data-dtype="Boolean" {{#if filterEffectIcons}}checked{{/if}}>
</div>
<p class="notes">Disable drawing of the following effects on the token and combat tracker:</p>
</div>
<div class="form-group">
<label>Effects with mappings</label>
<div class="form-fields">
<input type="checkbox" name="filterCustomEffectIcons" data-dtype="Boolean" {{#if filterCustomEffectIcons}}checked{{/if}}>
</div>
<p class="notes">Prevents drawing of temporary effects on the token if a mapping exists for it.</p>
</div>
<div class="form-group">
<label>Additional Restricted Effects</label>
<div class="form-fields">
<input type="text" name="filterIconList" data-dtype="String" value="{{filterIconList}}" placeholder="e.g. Sharpshooter,Asleep">
</div>
</div>
{{/unless}}
<hr>
<fieldset>
<legend>Internal Effects</legend>
<div class="form-group slim">
<label>HP Change (hp-- hp++)</label>
<div class="form-fields">
<label>Enabled</label>
<input type="checkbox" name="internalEffects.hpChange.enabled" data-dtype="Boolean" {{checked internalEffects.hpChange.enabled}}>
<label>Duration <span class="units">(seconds)</span></label>
<input type="number" name="internalEffects.hpChange.duration" data-dtype="Number" value="{{internalEffects.hpChange.duration}}" min="0.001" max="50" step="any" placeholder="infinite">
</div>
<p class="notes">Flags will be stored on tokens to allow the use of `hp--` (decreased) and `hp++` (increased) expressions in effect mappings.</p>
</div>
</fieldset>
</div>
{{/if}}
<!-- Misc -->
{{#if enabledTabs.misc}}
<div class="tab" data-tab="misc" data-group="primary-tabs">
<div class="form-group">
<label>{{localize "token-variants.settings.disable-notifs.Name"}}</label>
<div class="form-fields">
<input type="checkbox" name="disableNotifs" data-dtype="Boolean" {{#if disableNotifs}}checked{{/if}}>
</div>
<p class="notes">{{localize "token-variants.settings.disable-notifs.Hint"}}</p>
</div>
<div class="form-group">
<label>Static Cache</label>
<div class="form-fields">
<input type="checkbox" name="staticCache" data-dtype="Boolean" {{#if staticCache}}checked{{/if}}>
</div>
<p class="notes">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.</p>
</div>
<div class="form-group">
<label>Cache File</label>
<div class="form-fields">
<input type="text" name="staticCacheFile" data-dtype="String" value="{{staticCacheFile}}">
</div>
<p class="notes">Name and location of the image cache.</p>
</div>
<div class="form-group">
<label></label>
<button class="token-variants-cache-images" type="button">
<i class="fas fa-sync-alt"></i>
<label>Cache Images</label>
</button>
</div>
<hr>
<div class="form-group">
<label>Tile HUD</label>
<div class="form-fields">
<input type="checkbox" name="tilesEnabled" data-dtype="Boolean" {{#if tilesEnabled}}checked{{/if}}>
</div>
<p class="notes">Enables the Tile HUD button</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.imgur-client-id.Name"}}</label>
<div class="form-fields">
<input type="text" name="imgurClientId" data-dtype="String" value="{{imgurClientId}}">
</div>
<p class="notes">{{localize "token-variants.settings.imgur-client-id.Hint"}}</p>
</div>
<hr>
<div class="form-group">
<label>Custom Image Categories</label>
<div class="form-fields">
<input type="text" name="customImageCategories" data-dtype="String" value="{{customImageCategories}}" placeholder="e.g. Dragons,Vampires">
</div>
<p class="notes">Additional types that will be used by the module to group images on.</p>
</div>
<hr>
<fieldset>
<legend>Image Updates</legend>
<div class="form-group">
<label>Transfer Token Updates to Prototype</label>
<div class="form-fields">
<input type="checkbox" name="updateTokenProto" data-dtype="Boolean" {{#if updateTokenProto}}checked{{/if}}>
</div>
<p class="notes">Token updates using the module will also affect the prototype token.</p>
</div>
<fieldset>
<legend>Dimensions in Image Names</legend>
<div class="form-group">
<label>Token HUD Wildcard</label>
<div class="form-fields">
<input type="checkbox" name="imgNameContainsDimensions" data-dtype="Boolean" {{#if imgNameContainsDimensions}}checked{{/if}}>
</div>
<p class="notes">Module will recognize `_scale#.#_`, `_width#.#_`, and `_height#.#_` in image names and apply them to the token.</p>
</div>
<div class="form-group">
<label>Forgotten Adventures</label>
<div class="form-fields">
<input type="checkbox" name="imgNameContainsFADimensions" data-dtype="Boolean" {{#if imgNameContainsFADimensions}}checked{{/if}}>
</div>
<p class="notes">Module will recognize `_Scale###_` in image names and apply it to the token.</p>
</div>
</fieldset>
</fieldset>
<hr>
<div class="form-group">
<label>Play Videos on mouse hover</label>
<div class="form-fields">
<input type="checkbox" name="playVideoOnHover" data-dtype="Boolean" {{#if playVideoOnHover}}checked{{/if}}>
</div>
<p class="notes">When enabled videos will not auto-play, and instead will unpause only when the mouse is hovered over them.</p>
</div>
<div class="form-group">
<label>Pause Videos on mouse hover out</label>
<div class="form-fields">
<input type="checkbox" name="pauseVideoOnHoverOut" data-dtype="Boolean" {{#if pauseVideoOnHoverOut}}checked{{/if}}>
</div>
<p class="notes">When enabled videos will pause when the mouse leaves them.</p>
</div>
</div>
{{/if}}
</section>
<!-- Settings Footer -->
<footer class="sheet-footer flexrow">
<button type="submit" name="submit">
<i class="far fa-save"></i> Save Changes
</button>
</footer>
</form>

+ 124
- 0
Data/modules/token-variants/templates/effectMappingForm.html View File

@ -0,0 +1,124 @@
<form>
<section>
<datalist id="groups">
{{#each groups}}
<option value="{{this}}"></option>
{{/each}}
</datalist>
<ol class="token-variant-table" style="overflow-x: hidden;">
<li class="table-row table-header flexrow">
<div class="mapping-controls">
<a class="create-mapping" title="Add new mapping"> <i class="fas fa-plus"></i></a>
</div>
<div class="mapping-label" title="Optional description for the mapping. Actor mappings will override Global ones if their Labels match."><label>Label</label></div>
<div class="mapping-expression"><label>Expression</label>&nbsp;&nbsp;<i class="fas fa-question-circle" title="Accepted Operators:&#013;• && ( logical AND)&#013;• || (logical OR)&#013;• \! (escaped logical NOT)&#013;• \( (escaped open bracket to group expressions)&#013;• \) (escaped closed bracket to group expressions)&#013;&#013;Accepted hp and Token property Comparators:&#013;• = (equal)&#013;• < (less than)&#013;• > (greater than)&#013;• <= (less than or equal)&#013;• >= (greater than or equal)&#013;• <> (lesser or greater than)&#013;&#013;Accepted wildcards&#013;• \*&#013;• \{ \}&#013;&#013;Examples of valid expressions:&#013;• Flying&#013;• Dead && Burning&#013;• Flying && \! \( Prone || Dead \)&#013;• hp<=50%&#013;• name=&ldquo;Raging Barbarian&ldquo;&#013;• lockRotation=&ldquo;true&ldquo;&#013;• flags.token-variants.test=&ldquo;true&ldquo;&#013;• Exhaustion \*&#013;• Exhaustion \{1,2,3\}&#013;&#013;Special Effect Names:&#013;• token-variants-combat : Actives when Token is in combat&#013;• combat-turn : Activates when it's Token's turn in combat&#013;• combat-turn-next : Actives when Token is next in the initiative order"></i></div>
<div class="mapping-priority" title="The order in which mappings are to be resolved. Which scripts are run first, token configs are prioritized, and overlay display order will all be based off of this value."><label>Priority</label></div>
<div class="mapping-image" title="Image to be applied to the token."><label>Image</label></div>
<div class="mapping-config" title="Token Configuration and Scripts"><label>Config</label></div>
<div class="mapping-overlay" title="Image, text, or shapes to be shown on the token."><label>Overlay</label></div>
<div class="mapping-alwaysOn" title="If checked mapping will always be treated as active, regardless whether the Expression is true or not."><label>Always On</label></div>
<div class="mapping-disable" title="If checked mapping will never be activated regardless of whether the Expression is true or not."><label>Disable</label></div>
{{#if global}}
<div class="mapping-target" title="Actor types that this mapping is applicable to."><i class="fas fa-users"></i></div>
{{/if}}
<div class="mapping-group" title="Mappings sharing Group names will be displayed under the same header."><label>Group</label></div>
</li>
{{#each groupedMappings as |mappings group|}}
<div class="group-title flexrow">
<p><a class="group-delete" data-group="{{group}}"><i class="fas fa-trash fa-xs"></i></a> {{group}}</p>
<div class="group-disable {{#if mappings.active}}active{{/if}}" data-group="{{group}}"><a><i class="fas fa-power-off"></i></a></div>
<div class="group-toggle active {{#if ../global}}global{{/if}}" data-group="{{group}}"><a><i class="fas fa-chevron-double-up"></i></a></div>
</div>
{{#each mappings.list as |mapping|}}
<input type="text" name="mappings.{{mapping.i}}.id" value="{{mapping.id}}" hidden/>
<li class="table-row flexrow" data-group="{{group}}" data-index="{{mapping.i}}">
<div class="mapping-controls">
<a class="clone-mapping" title="Clone mapping"><i class="fas fa-clone"></i></a>
<a class="delete-mapping" title="Delete mapping"><i class="fas fa-trash"></i></a>
</div>
<div class="mapping-label">
<input
type="text"
name="mappings.{{mapping.i}}.label"
value="{{mapping.label}}"
/>
</div>
<div class="mapping-expression">
<div class="div-input" contenteditable="true">{{{mapping.highlightedExpression}}}</div>
<input
type="text"
name="mappings.{{mapping.i}}.expression"
value="{{mapping.expression}}"
hidden/>
</div>
<div class="mapping-priority">
<input
type="number"
name="mappings.{{mapping.i}}.priority"
value="{{mapping.priority}}"
placeholder="priority"
/>
</div>
<div class="mapping-image">
<video
height="32" width="32"
src="{{mapping.imgSrc}}"
title="{{mapping.imgName}}"
autoplay
loop
muted
{{#unless isVideo}}hidden{{/unless}}
>
</video>
<img height="32" width="32" src="{{mapping.imgSrc}}" title="{{mapping.imgName}}" {{#if isVideo}}hidden{{/if}}/>
<input
class="imgSrc"
type="hidden"
name="mappings.{{mapping.i}}.imgSrc"
value="{{mapping.imgSrc}}"
/>
<input
class="imgName"
type="hidden"
name="mappings.{{mapping.i}}.imgName"
value="{{mapping.imgName}}"
/>
</div>
<div class="mapping-config">
<a><i class="fas fa-cog fa-lg config {{#if mapping.hasTokenConfig}}active{{/if}}"></i></a>
<a><i class="fas fa-edit config-edit {{#if mapping.hasConfig}}active{{/if}}"></i></a>
<a><i class="fas fa-play config-script {{#if mapping.hasScript}}active{{/if}}"></i></a>
<input class="config" type="hidden" name="mappings.{{mapping.i}}.config" value="{{mapping.config}}">
</div>
<div class="mapping-overlay">
<input type="checkbox" name="mappings.{{mapping.i}}.overlay" {{#if mapping.overlay}}checked{{/if}}/>
<a {{#if mapping.parentID}}class="child" title="Child Of: {{mapping.parentID}}"{{/if}}><i class="fas fa-cog fa-lg overlay-config"></i></a>
</div>
<div class="mapping-alwaysOn">
<input type="checkbox" name="mappings.{{mapping.i}}.alwaysOn" {{#if mapping.alwaysOn}}checked{{/if}} title="Enabling will not trigger scripts."/>
</div>
<div class="mapping-disable">
<input type="checkbox" name="mappings.{{mapping.i}}.disabled" {{#if mapping.disabled}}checked{{/if}}/>
</div>
{{#if ../../global}}
<div class="mapping-target" title="Configure Applicable Actors">
<a><i class="fas fa-users"></i></a>
</div>
{{/if}}
<div class="mapping-group">
<input list="groups" name="mappings.{{mapping.i}}.group" value="{{mapping.group}}"/>
</div>
</li>
{{/each}}
{{/each}}
</ol>
</section>
<footer class="sheet-footer flexrow">
<button class="save-mappings" type="button">
<i class="far fa-save"></i>{{localize "token-variants.common.apply"}}
</button>
</footer>
</form>

+ 51
- 0
Data/modules/token-variants/templates/flagsConfig.html View File

@ -0,0 +1,51 @@
<form>
<div class="tab token-variants-permissions">
<header class="table-header flexrow">
<label class="index">Flag</label>
<label>Value</label>
<label>Set Flag</label>
</header>
<ul class="permissions-list">
{{#unless tile}}
<li class="permission form-group">
<label class="index">Pop-ups</label>
<div class="form-fields">
<input class="flag" type="checkbox" name="popups" {{#if popups}}checked{{/if}} {{#unless popupsSetFlag}}disabled{{/unless}}/>
<input class="controlFlag" type="checkbox" {{#if popupsSetFlag}}checked{{/if}}/>
</div>
<p class="hint">Enable or disable pop-ups for this actor/token.</p>
</li>
<li class="permission form-group">
<label class="index">Disable Name Search</label>
<div class="form-fields">
<input class="flag" type="checkbox" name="disableNameSearch" {{#if disableNameSearch}}checked{{/if}} {{#unless disableNameSearchSetFlag}}disabled{{/unless}}/>
<input class="controlFlag" type="checkbox" {{#if disableNameSearchSetFlag}}checked{{/if}}/>
</div>
<p class="hint">Disable Token HUD name search</p>
</li>
{{/unless}}
<li class="permission form-group">
<label class="index">Image Directory</label>
<div class="form-fields">
<input class="flag" type="text" name="directory" value="{{directory}}" {{#unless directorySetFlag}}disabled{{/unless}} hidden/>
<input class="flag" type="text" name="directorySource" value="{{directorySource}}" {{#unless directorySetFlag}}disabled{{/unless}} hidden/>
<button type="button" class="directory-fp" title="Directory: {{directory}}">
<i class="fas fa-file-import fa-fw"></i>
</button>
<input class="controlFlag" type="checkbox" {{#if directorySetFlag}}checked{{/if}}/>
</div>
<p class="hint">Assign image directory to be included in the HUD</p>
</li>
</ul>
</div>
<div class="button-container">
<button type="submit"><i class="far fa-save"></i>{{localize "token-variants.common.apply"}}</button>
</div>
</form>

+ 60
- 0
Data/modules/token-variants/templates/forgeSearchPaths.html View File

@ -0,0 +1,60 @@
<form>
<section>
<h2 class="module-header">The Forge</h2>
<div class="form-group">
<label>API Secret Key</label>
<div class="form-fields">
<input type="text" name="apiKey" value="{{apiKey}}" data-dtype="String" />
</div>
<p class="notes">{{localize "token-variants.settings.forge-search-paths.window.Hint"}}</p>
</div>
<ol class="token-variant-table">
<li class="table-row table-header flexrow">
<div class="path-image">
<a class="create-path" title="Add new path"><i class="fas fa-plus"></i></a>
</div>
<div class="path-source"><label>Source</label></div>
<div class="path-text"><label>Path</label></div>
<div class="path-category"><label>Category</label></div>
<div class="path-cache"><label>Cache</label></div>
<div class="path-share"><label>Share</label></div>
<div class="path-controls"></div>
</li>
{{#each paths as |path index|}}
<li class="table-row flexrow" data-index="{{index}}">
<div class="path-image source-icon">
<a><i class="fas fa-hammer"></i></a>
</div>
<div class="path-source">
<input class="searchSource" type="text" value="forgevtt" placeholder="forgevtt" disabled/>
<input type="text" name="paths.{{index}}.source" value="forgevtt" hidden/>
</div>
<div class="path-text">
<input type="text" name="paths.{{index}}.text" value="{{path.text}}" placeholder="Path to folder"/>
</div>
<div class="path-category">
<a class="select-category" title="Select image categories/filters"><i class="fas fa-swatchbook"></i></a>
<input type="hidden" name="paths.{{index}}.types" value="{{path.types}}">
</div>
<div class="path-cache">
<input type="checkbox" name="paths.{{index}}.cache" data-dtype="Boolean" {{#if path.cache}}checked{{/if}}/>
</div>
<div class="path-share">
<input type="checkbox" name="paths.{{index}}.share" data-dtype="Boolean" {{#if path.share}}checked{{/if}}/>
</div>
<div class="path-controls">
<a class="delete-path" title="Delete path"><i class="fas fa-trash"></i></a>
</div>
</li>
{{/each}}
</ol>
</section>
<footer class="sheet-footer flexrow">
<p class="notes">
<b>Format:</b><br />
Assets Library/token_art/dragons <b>-></b> token_art/dragons
</p>
</footer>
</form>

+ 12
- 0
Data/modules/token-variants/templates/importExport.html View File

@ -0,0 +1,12 @@
<form>
<div class="form-group">
<button class="import" type="button">
<i class="fas fa-file-import"></i>
<label>{{localize "token-variants.common.import"}}</label>
</button>
<button class="export" type="button">
<i class="fas fa-file-export"></i>
<label>{{localize "token-variants.common.export"}}</label>
</button>
</div>
</form>

+ 42
- 0
Data/modules/token-variants/templates/missingImageConfig.html View File

@ -0,0 +1,42 @@
<form>
<section>
<header class="missing-header table-header flexrow">
<div class="index add-row"><label><a><i class="fas fa-plus"></i></a></label></div>
<div class="missing-document"><label>Document</label></div>
<div class="missing-path"><label>Image Path</label></div>
<div class="missing-image"><label>Image</label></div>
<div class="missing-controls"></div>
</header>
<ul>
{{#each missingImages}}
<li class="flexrow" data-index="{{@index}}">
<div class="index"><a class="delete-row" title="Delete Image"><i class="fas fa-trash"></i></a></div>
<div class="missing-document">
<select name="document">
{{#each ../documents as |doc|}}
<option value="{{doc}}" {{#if (eq ../this.document doc)}}selected="selected"{{/if}}>{{doc}}</option>
{{/each}}
</select>
</div>
<div class="missing-path">
<input name="image" type="text" value="{{this.image}}" />
</div>
<div class="missing-image">
<img height="32" width="32" src="{{this.image}}"/>
</div>
<div class="missing-controls">
<a class="duplicate-picker"><i class="far fa-search"></i></a>
<a class="file-picker"><i class="fas fa-file-import fa-fw"></i></a></div>
</li>
{{/each}}
</ol>
</section>
<footer class="sheet-footer flexrow">
<button type="submit">
<i class="far fa-save"></i>{{localize "token-variants.common.save"}}
</button>
</footer>
</form>

+ 707
- 0
Data/modules/token-variants/templates/overlayConfig.html View File

@ -0,0 +1,707 @@
<form class="tva-overlay-form">
<nav class="sheet-tabs tabs" data-group="main" aria-role="Form Tab Navigation">
<a class="item active" data-tab="misc"><i class="fas fa-wrench"></i> Misc</a>
<a class="item" data-tab="image"><i class="fas fa-expand"></i> Image</a>
<a class="item" data-tab="text"><i class="fas fa-text-size"></i> Text</a>
<a class="item" data-tab="shapes"><i class="fas fa-shapes"></i> Shapes</a>
<a class="item" data-tab="filter"><i class="fas fa-paint-roller"></i> Filter</a>
<a class="item" data-tab="visibility"><i class="fas fa-eye"></i> Visibility</a>
<a class="item" data-tab="animation"><i class="fas fa-camera-movie"></i> Animation</a>
<a class="item" data-tab="interactivity"><i class="fas fa-bolt"></i> Triggers</a>
<a class="item {{#if variables}}non-empty-variables{{/if}}" data-tab="variables"><i class="fas fa-superscript"></i> Variables</a>
</nav>
<input type="text" name="id" value="{{id}}" hidden>
{{#if tmfxActive}}
<datalist id="tmfxPresets">
{{#each tmfxPresets }}
<option value="{{this}}"></option>
{{/each}}
</datalist>
{{/if}}
{{#if ceEffects}}
<datalist id="ceEffects">
{{#each ceEffects }}
<option value="{{this}}"></option>
{{/each}}
</datalist>
{{/if}}
<datalist id="macros">
{{#each macros }}
<option value="{{this}}"></option>
{{/each}}
</datalist>
<section class = "content">
<div class="tab" data-group="main" data-tab="interactivity">
<div class="form-group">
<label>Event</label>
<div class="form-fields">
<select>
<option value="clickLeft">Click Left</option>
<option value="clickLeft2">Double Click Left</option>
<option value="clickRight">Click Right</option>
<option value="clickRight2">Double Click Right</option>
<option value="hoverIn">Hover In</option>
<option value="hoverOut">Hover Out</option>
</select>
<button class="addEvent" type="button">Add</button>
</div>
</div>
{{#each interactivity as |event|}}
<fieldset>
<legend><b>{{event.listener}}</b> <a class="deleteEvent" data-index="{{@index}}" title="Remove"><i class="fas fa-trash-alt"></i></a></legend>
<input type="hidden" name="interactivity.{{@index}}.listener" value="{{event.listener}}">
<div class="form-group">
<label>Macro</label>
<div class="form-fields">
<input list="macros" name="interactivity.{{@index}}.macro" value="{{event.macro}}">
</div>
</div>
{{#if ../ceActive}}
<div class="form-group">
<label>DFreds Effect</label>
<div class="form-fields">
<input list="ceEffects" name="interactivity.{{@index}}.ceEffect" value="{{event.ceEffect}}">
</div>
</div>
{{/if}}
{{#if ../tmfxActive}}
<div class="form-group">
<label>TMFX (Preset)</label>
<div class="form-fields">
<input list="tmfxPresets" name="interactivity.{{@index}}.tmfxPreset" value="{{event.tmfxPreset}}">
</div>
</div>
{{/if}}
<div class="form-group">
<label>Script</label>
<div class="form-fields">
<textarea name="interactivity.{{@index}}.script">{{event.script}}</textarea>
</div>
</div>
</fieldset>
{{/each}}
</div>
<div class="tab active" data-group="main" data-tab="misc">
<div class="form-group">
<label>Parent</label>
<div class="form-fields">
<select name="parentID">
<option value="TOKEN" {{#if (eq ../../parent "TOKEN")}}selected="selected"{{/if}}>Token (Placeable)</option>
{{#each parents as |parent group|}}
<optgroup label="{{group}}">
{{#each parent.list as |mapping|}}
<option value="{{mapping.id}}" {{#if (eq ../../parentID mapping.id)}}selected="selected"{{/if}}>{{mapping.label}}</option>
{{/each}}
</optgroup>
{{/each}}
</select>
</div>
</div>
<fieldset class="token-specific-fields">
<legend>Display Priority</legend>
<div class="form-group">
<label><i class="fal fa-eclipse"></i> Underlay</label>
<div class="form-fields">
<input type="checkbox" name="underlay" data-dtype="Boolean" value="{{underlay}}" {{#if underlay}}checked{{/if}}>
</div>
<p class="notes">Place the image, video or text underneath the token.</p>
</div>
<div class="form-group">
<label><i class="far fa-arrow-to-bottom"></i> BOTTOM</label>
<div class="form-fields">
<input type="checkbox" name="bottom" data-dtype="Boolean" value="{{bottom}}" {{#if bottom}}checked{{/if}}>
</div>
<p class="notes">Place this underlay bellow all tokens.</p>
</div>
<div class="form-group">
<label><i class="far fa-arrow-to-top"></i> TOP</label>
<div class="form-fields">
<input type="checkbox" name="top" data-dtype="Boolean" value="{{top}}" {{#if top}}checked{{/if}}>
</div>
<p class="notes">Place this overlay above all tokens.</p>
</div>
</fieldset>
<fieldset>
<legend>Link To Token</legend>
<div class="form-group token-specific-fields">
<label>Tint Color</label>
<div class="form-fields">
<input type="checkbox" name="inheritTint" data-dtype="Boolean" value="{{inheritTint}}" {{#if inheritTint}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>Rotation</label>
<div class="form-fields">
<input type="checkbox" name="linkRotation" data-dtype="Boolean" value="{{linkRotation}}" {{#if linkRotation}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>&nbsp;&nbsp; -- Overlay Relative</label>
<div class="form-fields">
<input type="checkbox" name="animation.relative" data-dtype="Boolean" value="{{animation.relative}}" {{#if animation.relative}}checked{{/if}}>
</div>
</div>
<div class="form-group token-specific-fields">
<label>Mirror Image</label>
<div class="form-fields">
<input type="checkbox" name="linkMirror" data-dtype="Boolean" value="{{linkMirror}}" {{#if linkMirror}}checked{{/if}}>
</div>
</div>
<div class="form-group token-specific-fields">
<label>Scale</label>
<div class="form-fields">
<input type="checkbox" name="linkScale" data-dtype="Boolean" value="{{linkScale}}" {{#if linkScale}}checked{{/if}}>
</div>
</div>
<div class="form-group slim token-specific-fields">
<label>Dimensions</label>
<div class="form-fields">
<label>Width</label>
<input type="checkbox" name="linkDimensionsX" data-dtype="Boolean" value="{{linkDimensionsX}}" {{#if linkDimensionsX}}checked{{/if}}>
<label>Height</label>
<input type="checkbox" name="linkDimensionsY" data-dtype="Boolean" value="{{linkDimensionsY}}" {{#if linkDimensionsY}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>Opacity</label>
<div class="form-fields">
<input type="checkbox" name="linkOpacity" data-dtype="Boolean" value="{{linkOpacity}}" {{#if linkOpacity}}checked{{/if}}>
</div>
</div>
</fieldset>
<fieldset>
<legend>Link To Stage</legend>
<div class="form-group token-specific-fields">
<label>Scale</label>
<div class="form-fields">
<input type="checkbox" name="linkStageScale" data-dtype="Boolean" value="{{linkStageScale}}" {{#if linkStageScale}}checked{{/if}}>
</div>
</div>
</fieldset>
<fieldset>
<legend>Video</legend>
<div class="form-group">
<label>Loop Video</label>
<div class="form-fields">
<input type="checkbox" name="loop" data-dtype="Boolean" value="{{loop}}" {{#if loop}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>Play Once and Hide</label>
<div class="form-fields">
<input type="checkbox" name="playOnce" data-dtype="Boolean" value="{{playOnce}}" {{#if playOnce}}checked{{/if}}>
</div>
</div>
</fieldset>
</div>
<div class="tab active" data-group="main" data-tab="image">
<div class="form-group">
<label>Image Path</label>
<div class="form-fields">
<button type="button" class="file-picker" data-type="imagevideo" data-target="img" title="Browse Files" tabindex="-1">
<i class="fas fa-file-import fa-fw"></i>
</button>
<input class="image" type="text" name="img" placeholder="path/image.png" value="{{img}}">
<button type="button" title="Select Image" class="token-variants-image-select-button" tabindex="-1" data-type="imagevideo" data-target="img"><i class="fas fa-images"></i></button></div>
</div>
{{~>modules/token-variants/templates/partials/repeating.html repeating=repeating root="" repeat=repeat padding="true"}}
<fieldset>
<legend>Appearance</legend>
<div class="form-group">
<label>Opacity</label>
<div class="form-fields">
<input type="range" value="{{alpha}}" min="0" max="1" step="0.05">
<input name="alpha" class="range-value" type="text" value="{{alpha}}"></input>
</div>
</div>
<div class="form-group">
<label>Tint Color</label>
<div class="form-fields">
<input class="color" type="text" name="tint" value="{{tint}}">
<input type="color" value="{{tint}}" data-edit="tint">
</div>
</div>
{{~>modules/token-variants/templates/partials/interpolateColor.html root="" interpolateColor=interpolateColor label="Tint Color"}}
</fieldset>
<fieldset>
<legend>Dimensions</legend>
<div class="form-group">
<label>Width <i class="fas fa-question-circle" title="Set exact image width.&#013; *To take effect requires Scale and Dimension linking under Misc to be disabled.*"></i></label>
<div class="form-fields">
<input name="width" type="text" value="{{width}}" min="0"></input>
</div>
</div>
<div class="form-group">
<label>Height <i class="fas fa-question-circle" title="Set exact image height.&#013; *To take effect requires Scale and Dimension linking under Misc to be disabled.*"></i></label>
<div class="form-fields">
<input name="height" type="text" value="{{height}}" min="0"></input>
</div>
</div>
<div>
<div class="form-group">
<label>Scale Width</label>
<div class="form-fields">
<input class="scaleX" type="range" value="{{scaleX}}" min="0.01" max="6" step="0.01">
<input name="scaleX" class="range-value" type="text" value="{{scaleX}}"></input>
</div>
<div class="scaleLock" style="flex: 0 !important;margin-left: 3px;"><a><i class="fas fa-link"></i></a></div>
</div>
<div class="form-group">
<label>Scale Height</label>
<div class="form-fields">
<input class="scaleY" type="range" value="{{scaleY}}" min="0.01" max="6" step="0.01">
<input name="scaleY" class="range-value" type="text" value="{{scaleY}}"></input>
</div>
<div class="scaleLock" style="flex: 0 !important;margin-left: 3px;"><a><i class="fas fa-link"></i></a></div>
</div>
</div>
<div class="form-group">
<label>Rotation</label>
<div class="form-fields">
<input type="range" value="{{angle}}" min="-360" max="360" step="1">
<input name="angle" class="range-value" type="text" value="{{angle}}"></input>
</div>
</div>
</fieldset>
<fieldset>
<legend>Positioning</legend>
<div class="form-group">
<label>Horizontal Offset</label>
<div class="form-fields">
<input class="offsetX" type="range" value="{{offsetX}}" min="-3" max="3" step="0.01">
<input name="offsetX" type="text" class="range-value" value="{{offsetX}}"></input>
</div>
</div>
<div class="form-group">
<label>Vertical Offset</label>
<div class="form-fields">
<input class="offsetY" type="range" value="{{offsetY}}" min="-3" max="3" step="0.01">
<input name="offsetY" type="text" class="range-value" value="{{offsetY}}"></input>
</div>
</div>
<div class="form-group">
<label>Anchor</label>
<div class="form-fields">
<label>X</label>
<input name="anchor.x" type="number" step="any" value="{{anchor.x}}" min="0" max="1"></input>
<label>Y</label>
<input name="anchor.y" type="number" step="any" value="{{anchor.y}}" min="0" max="1"></input>
</div>
<p class="notes">Set the point on an overlay to be used to anchor it to the center of the parent.</p>
</div>
</fieldset>
<img src="modules/token-variants/img/anchor_diagram.webp" width="200" height="200" style="margin: auto;display: block;border: none;"/>
</div>
<div class="tab active" data-group="main" data-tab="filter">
<div class="form-group">
<label>Filter</label>
<div class="form-fields">
<select name="filter">
{{#each filters as |filter|}}
<option value="{{filter}}" {{#if (eq ../filter filter)}}selected="selected"{{/if}}>{{filter}}</option>
{{/each}}
</select>
</div>
</div>
<div class="filterOptions">
{{{filterOptions}}}
</div>
</div>
<div class="tab active" data-group="main" data-tab="animation">
<div class="form-group">
<label>Rotate</label>
<div class="form-fields">
<input type="checkbox" name="animation.rotate" data-dtype="Boolean" value="{{animation.rotate}}" {{#if animation.rotate}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>Duration (ms)</label>
<div class="form-fields">
<input type="range" value="{{animation.duration}}" min="100" max="30000" step="100">
<input name="animation.duration" class="range-value" type="text" value="{{animation.duration}}"></input>
</div>
</div>
<div class="form-group">
<label>Clockwise</label>
<div class="form-fields">
<input type="checkbox" name="animation.clockwise" data-dtype="Boolean" value="{{animation.clockwise}}" {{#if animation.clockwise}}checked{{/if}}>
</div>
</div>
</div>
<div class="tab active" data-group="main" data-tab="visibility">
<div class="form-group">
<label>Always Visible</label>
<div class="form-fields">
<input type="checkbox" name="alwaysVisible" data-dtype="Boolean" value="{{alwaysVisible}}" {{#if alwaysVisible}}checked{{/if}}>
</div>
<p class="notes">Overlay will be visible in explored areas of the map even when the Token is not.</p>
</div>
<div class="form-group">
<label>Limit Visibility to Owner</label>
<div class="form-fields">
<input type="checkbox" name="limitedToOwner" data-dtype="Boolean" value="{{limitedToOwner}}" {{#if limitedToOwner}}checked{{/if}}>
</div>
</div>
<fieldset>
<legend>Limit Visibility to Users</legend>
{{#each users as |user|}}
<div class="form-group">
<label>{{user.name}}</label>
<div class="form-fields">
<input type="checkbox" name="limitedUsers" data-dtype="String" value="{{user.id}}" {{#if user.selected}}checked{{/if}}>
</div>
</div>
{{/each}}
</fieldset>
<fieldset>
<legend>Limit Visibility to State</legend>
<div class="form-group">
<label>Hover</label>
<div class="form-fields">
<input type="checkbox" name="limitOnHover" data-dtype="Boolean" value="{{limitOnHover}}" {{#if limitOnHover}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>Highlight</label>
<div class="form-fields">
<input type="checkbox" name="limitOnHighlight" data-dtype="Boolean" value="{{limitOnHighlight}}" {{#if limitOnHighlight}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>Control</label>
<div class="form-fields">
<input type="checkbox" name="limitOnControl" data-dtype="Boolean" value="{{limitOnControl}}" {{#if limitOnControl}}checked{{/if}}>
</div>
</div>
</fieldset>
<fieldset>
<legend>Limit Visibility to Token With Effect</legend>
<div class="form-group">
<label>Effect Name</label>
<div class="form-fields">
<input type="text" name="limitOnEffect" value="{{limitOnEffect}}" placeholder="Reveal: Overlay">
</div>
<p class="notes">Overlay will only be visible to Tokens with this effect applied to them.</p>
</div>
</fieldset>
<fieldset>
<legend>Limit Visibility to Token With Property</legend>
<div class="form-group">
<label>Expression</label>
<div class="form-fields">
<input type="text" name="limitOnProperty" value="{{limitOnProperty}}" placeholder="actor.system.attributes.senses.truesight>0">
</div>
<p class="notes">Overlay will only be visible to Tokens that satisfy this expression.</p>
<p class="notes"> e.g.
<br>actor.system.attributes.senses.truesight>0
<br> actor.system.skills.prc.passive>=15
<br>hp&lt;=50%</p>
</div>
</fieldset>
</div>
<div class="tab active" data-group="main" data-tab="text">
<div class="form-group">
<label>Text</label>
<div class="form-fields">
<input class="text-field" type="text" name="text.text" value="{{text.text}}">
</div>
<p class="notes">For this text to show make sure that no image is assigned to this overlay.</p>
<p class="notes">Token attributes can be inserted as so: <b>&#123;&#123;name&#125;&#125;</b></p>
</div>
{{~>modules/token-variants/templates/partials/repeating.html repeating=text.repeating root="text." repeat=text.repeat}}
<div class="form-group">
<label>{{localize "DRAWING.FontFamily"}}</label>
<div class="form-fields">
<select name="text.fontFamily">
{{#each fonts as |font|}}
<option value="{{font}}" {{#if (eq ../text.fontFamily font)}}selected="selected"{{/if}}>{{font}}</option>
{{/each}}
</select>
</div>
</div>
<div class="form-group">
<label>{{localize "DRAWING.FillColor"}}</label>
<div class="form-fields">
{{ colorPicker name="text.fill" value=text.fill }}
</div>
</div>
{{~>modules/token-variants/templates/partials/interpolateColor.html root="text." interpolateColor=text.interpolateColor label="Fill Color"}}
<div class="form-group">
<label>Font Size</label>
<div class="form-fields">
<input type="range" value="{{text.fontSize}}" min="24" max="100" step="1">
<input name="text.fontSize" class="range-value" type="text" value="{{text.fontSize}}"></input>
</div>
</div>
<div class="form-group">
<label>Align</label>
<div class="form-fields">
<select name="text.align">
{{#each textAlignmentOptions as |option|}}
<option value="{{option.value}}" {{#if (eq ../text.align option.value)}}selected="selected"{{/if}}>{{option.label}}</option>
{{/each}}
</select>
</div>
</div>
<div class="form-group">
<label>Letter Spacing <span class="units">(Pixels)</span></label>
<div class="form-fields">
<input type="range" value="{{text.letterSpacing}}" min="0" max="25" step="0.1">
<input name="text.letterSpacing" class="range-value" type="text" value="{{text.letterSpacing}}"></input>
</div>
</div>
<div class="form-group">
<label>Shadow</label>
<div class="form-fields">
<input type="checkbox" name="text.dropShadow" data-dtype="String" value="{{text.dropShadow}}" {{#if text.dropShadow}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>Stroke Thickness</label>
<div class="form-fields">
<input type="range" value="{{text.strokeThickness}}" min="0" max="25" step="1">
<input name="text.strokeThickness" class="range-value" type="text" value="{{text.strokeThickness}}"></input>
</div>
</div>
<div class="form-group">
<label>Stroke Color</label>
<div class="form-fields">
<input class="color" type="text" name="text.stroke" value="{{text.stroke}}">
<input type="color" value="{{text.stroke}}" data-edit="text.stroke">
</div>
</div>
<fieldset>
<legend>Wrapping</legend>
<div class="form-group">
<label>Enabled</label>
<div class="form-fields">
<input type="checkbox" name="text.wordWrap" data-dtype="Boolean" {{#if text.wordWrap}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>Word Wrap Width <span class="units">(Pixels)</span></label>
<div class="form-fields">
<input type="range" value="{{text.wordWrapWidth}}" min="0" max="1000" step="5">
<input name="text.wordWrapWidth" class="range-value" type="text" value="{{text.wordWrapWidth}}"></input>
</div>
</div>
<div class="form-group">
<label>Break Words</label>
<div class="form-fields">
<input type="checkbox" name="text.breakWords" data-dtype="Boolean" {{#if text.breakWords}}checked{{/if}}>
</div>
</div>
<div class="form-group">
<label>Max Height <span class="units">(Pixels)</span></label>
<div class="form-fields">
<input type="range" value="{{text.maxHeight}}" min="0" max="1000" step="5">
<input name="text.maxHeight" class="range-value" type="text" value="{{text.maxHeight}}"></input>
</div>
</div>
</fieldset>
<h2>Curve</h2>
<p class="notes">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. </p>
<div class="form-group">
<label>Angle <span class="units">(Degrees)</span></label>
<div class="form-fields">
<input type="range" value="{{text.curve.angle}}" min="0" max="360" step="0.5">
<input name="text.curve.angle" class="range-value" type="text" value="{{text.curve.angle}}"></input>
</div>
</div>
<div class="form-group">
<label>Radius <span class="units">(Pixels)</span></label>
<div class="form-fields">
<input type="range" value="{{text.curve.radius}}" min="0" max="450" step="5">
<input name="text.curve.radius" class="range-value" type="text" value="{{text.curve.radius}}"></input>
</div>
</div>
<div class="form-group">
<label>Invert</label>
<div class="form-fields">
<input type="checkbox" name="text.curve.invert" data-dtype="Boolean" {{#if text.curve.invert}}checked{{/if}}>
</div>
</div>
</div>
<div class="tab" data-group="main" data-tab="shapes">
<div class="form-group">
<label>Shape</label>
<div class="form-fields">
<select>
{{#each allShapes as |shape|}}
<option value="{{shape}}">{{shape}}</option>
{{/each}}
</select>
<button class="addShape" type="button">Add</button>
</div>
</div>
{{#each shapes as |shape|}}
<hr><hr>
{{#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}}
<fieldset>
<legend>Line Style</legend>
<div class="form-group">
<label>Width</label>
<div class="form-fields">
<input type="range" value="{{shape.line.width}}" min="0" max="100" step="1">
<input name="shapes.{{@index}}.line.width" class="range-value" type="text" value="{{shape.line.width}}"></input>
</div>
</div>
<div class="form-group">
<label>Color</label>
<div class="form-fields">
{{ colorPicker name=(concat "shapes." @index ".line.color") value=shape.line.color }}
</div>
</div>
<div class="form-group">
<label>Opacity</label>
<div class="form-fields">
<input type="range" value="{{shape.line.alpha}}" min="0" max="1" step="0.05">
<input name="shapes.{{@index}}.line.alpha" class="range-value" type="text" value="{{shape.line.alpha}}"></input>
</div>
</div>
</fieldset>
<fieldset>
<legend>Fill</legend>
<div class="form-group">
<label>Color</label>
<div class="form-fields">
{{ colorPicker name=(concat "shapes." @index ".fill.color") value=shape.fill.color }}
</div>
</div>
<div class="form-group">
<label>Opacity</label>
<div class="form-fields">
<input type="range" value="{{shape.fill.alpha}}" min="0" max="1" step="0.05">
<input name="shapes.{{@index}}.fill.alpha" class="range-value" type="text" value="{{shape.fill.alpha}}"></input>
</div>
</div>
{{~>modules/token-variants/templates/partials/interpolateColor.html root=(concat "shapes." @index ".fill.") interpolateColor=shape.fill.interpolateColor label="Color"}}
</fieldset>
{{~>modules/token-variants/templates/partials/repeating.html repeating=shape.repeating root=(concat "shapes." @index ".") repeat=shape.repeat padding="true"}}
<hr><hr>
{{/each}}
</div>
</div>
<div class="tab" data-group="main" data-tab="variables">
<p class="notes">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</p>
<p class="notes">e.g. @shapeWidth</p>
<table>
<tr>
<th></th>
<th>Name</th><th>Value</th>
<th><a class="create-variable" title="Add a new variable."><i class="fas fa-plus"></i></a></th>
</tr>
{{#each variables as |variable|}}
<tr data-index="{{@index}}">
<td>@</td>
<td><input type="text" name="variables.{{@index}}.name" value="{{variable.name}}"></td>
<td><input type="text" name="variables.{{@index}}.value" value="{{variable.value}}"></td>
<td> <a class="delete-variable" title="Delete variable."><i class="fa-solid fa-trash"></i></a></td>
</tr>
{{/each}}
</table>
</div>
</section>
<input type="hidden" name="effect" value="{{effect}}">
<footer class="sheet-footer flexrow">
<button type="submit"><i class="far fa-save"></i>{{localize "token-variants.common.apply"}}</button>
</footer>
</form>

+ 17
- 0
Data/modules/token-variants/templates/partials/interpolateColor.html View File

@ -0,0 +1,17 @@
<fieldset>
<legend>Interpolate: {{label}}</legend>
<div class="form-group">
<label>Color 2</label>
<div class="form-fields">
{{ colorPicker name=(concat root "interpolateColor.color2") value=interpolateColor.color2 }}
</div>
</div>
<div class="form-group">
<label>Distance</label>
<div class="form-fields">
<input type="text" name="{{root}}interpolateColor.prc" value="{{interpolateColor.prc}}">
</div>
<p class="notes">Point between first color and Color 2 as a value between 0.0 and 1.0</p>
</div>
</fieldset>

+ 60
- 0
Data/modules/token-variants/templates/partials/repeating.html View File

@ -0,0 +1,60 @@
<fieldset class="repeat-fieldset {{#if repeating}}active{{/if}}">
<legend >Repeating <input class="repeat" type="checkbox" name="{{root}}repeating" {{#if repeating}}checked{{/if}}></legend>
<div class="content" {{#unless repeating}}style="display: none;"{{/unless}}>
<div class="form-group">
<label>Value</label>
<div class="form-fields">
<input name="{{root}}repeat.value" type="text" value="{{repeat.value}}"></input>
</div>
<p class="notes">Value that will be divided by the increment to determine the number of repeats.</p>
</div>
<div class="form-group slim">
<label>Increment</label>
<div class="form-fields">
<label>Value</label>
<input name="{{root}}repeat.increment"type="text" value="{{repeat.increment}}"></input>
<label>Percentage</label>
<input type="checkbox" data-dtype="Boolean" name="{{root}}repeat.isPercentage" {{#if repeat.isPercentage}}checked{{/if}}></input>
</div>
</div>
<div class="form-group">
<label>Max Value (optional)</label>
<div class="form-fields">
<input name="{{root}}repeat.maxValue" type="text" value="{{repeat.maxValue}}"></input>
</div>
<p class="notes">Max value only required if increment is a percentage.</p>
</div>
<div class="form-group">
<label>Repeats per row (optional)</label>
<div class="form-fields">
<input name="{{root}}repeat.perRow" type="text" value="{{repeat.perRow}}"></input>
</div>
<p class="notes">How many repeats should be rendered before proceeding to the next row.</p>
</div>
<div class="form-group">
<label>Max Rows (optional)</label>
<div class="form-fields">
<input name="{{root}}repeat.maxRows" type="text" value="{{repeat.maxRows}}"></input>
</div>
<p class="notes">Limit repeats to this number of rows.</p>
</div>
{{#if padding}}
<div class="form-group slim">
<label>Padding (optional)</label>
<div class="form-fields">
<label>Horizontal</label>
<input name="{{root}}repeat.paddingX" type="text" value="{{repeat.paddingX}}"></input>
<label>Vertical</label>
<input name="{{root}}repeat.paddingY" type="text" value="{{repeat.paddingY}}"></input>
</div>
<p class="notes">Insert empty pixels in-between the repeating shapes.</p>
</div>
{{/if}}
</div>
</fieldset>

+ 28
- 0
Data/modules/token-variants/templates/partials/shapeEllipse.html View File

@ -0,0 +1,28 @@
<fieldset>
<input type="text" name="shapes.{{@index}}.shape.type" value="{{shape.type}}" hidden>
<legend class="shape-legend"><b>ELLIPSE</b></legend>
<div class="form-group">
<label>X</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.x" type="text" value="{{shape.x}}"></input>
</div>
</div>
<div class="form-group">
<label>Y</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.y"type="text" value="{{shape.y}}"></input>
</div>
</div>
<div class="form-group">
<label>Width</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.width" type="text" value="{{shape.width}}"></input>
</div>
</div>
<div class="form-group">
<label>Height</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.height" type="text" value="{{shape.height}}"></input>
</div>
</div>
</fieldset>

+ 28
- 0
Data/modules/token-variants/templates/partials/shapePolygon.html View File

@ -0,0 +1,28 @@
<fieldset>
<input type="text" name="shapes.{{@index}}.shape.type" value="{{shape.type}}" hidden>
<legend class="shape-legend"><b>POLYGON</b></legend>
<div class="form-group">
<label>X</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.x" type="text" value="{{shape.x}}"></input>
</div>
</div>
<div class="form-group">
<label>Y</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.y" type="text" value="{{shape.y}}"></input>
</div>
</div>
<div class="form-group">
<label>Points <span class="units">(x1,y2,x2,y2,...)</span></label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.points" type="text" value="{{shape.points}}"></input>
</div>
</div>
<div class="form-group">
<label>Scale</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.scale" type="text" value="{{shape.scale}}"></input>
</div>
</div>
</fieldset>

+ 34
- 0
Data/modules/token-variants/templates/partials/shapeRectangle.html View File

@ -0,0 +1,34 @@
<fieldset>
<input type="text" name="shapes.{{@index}}.shape.type" value="{{shape.type}}" hidden>
<legend class="shape-legend"><b>RECTANGLE</b></legend>
<div class="form-group">
<label>X</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.x" type="text" value="{{shape.x}}"></input>
</div>
</div>
<div class="form-group">
<label>Y</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.y" type="text" value="{{shape.y}}"></input>
</div>
</div>
<div class="form-group">
<label>Width</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.width" type="text" value="{{shape.width}}"></input>
</div>
</div>
<div class="form-group">
<label>Height</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.height" type="text" value="{{shape.height}}"></input>
</div>
</div>
<div class="form-group">
<label>Corner Radius</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.radius" type="text" value="{{shape.radius}}"></input>
</div>
</div>
</fieldset>

+ 40
- 0
Data/modules/token-variants/templates/partials/shapeTorus.html View File

@ -0,0 +1,40 @@
<fieldset>
<input type="text" name="shapes.{{@index}}.shape.type" value="{{shape.type}}" hidden>
<legend class="shape-legend"><b>TORUS</b></legend>
<div class="form-group">
<label>X</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.x" type="text" value="{{shape.x}}"></input>
</div>
</div>
<div class="form-group">
<label>Y</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.y"type="text" value="{{shape.y}}"></input>
</div>
</div>
<div class="form-group">
<label>Inner Radius</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.innerRadius" type="text" value="{{shape.innerRadius}}"></input>
</div>
</div>
<div class="form-group">
<label>Outer Radius</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.outerRadius" type="text" value="{{shape.outerRadius}}"></input>
</div>
</div>
<div class="form-group">
<label>Start Angle</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.startAngle" type="text" value="{{shape.startAngle}}"></input>
</div>
</div>
<div class="form-group">
<label>End Angle</label>
<div class="form-fields">
<input name="shapes.{{@index}}.shape.endAngle" type="text" value="{{shape.endAngle}}"></input>
</div>
</div>
</fieldset>

+ 20
- 0
Data/modules/token-variants/templates/protoTokenElement.html View File

@ -0,0 +1,20 @@
<fieldset class="token-variants-proto">
<legend>Token Variant Art</legend>
<div class="form-group">
<label for="texture">Default Wildcard Image</label>
<div class="form-fields">
<button type="button" class="file-picker" data-type="imagevideo" data-target="flags.token-variants.randomImgDefault" title="Select Default Image" tabindex="-1">
<i class="fas fa-file-import fa-fw"></i>
</button>
<input class="imagevideo" type="text" name="flags.token-variants.randomImgDefault" placeholder="path/image.png" value="{{defaultImg}}"/>
</div>
<p class="hint">Rather than randomizing the image upon token creation this default image will be used instead.</p>
</div>
<div class="form-group">
<label>Disable HUD Button</label>
<div class="form-fields">
<input type="checkbox" name="flags.token-variants.disableHUDButton" data-dtype="Boolean" {{#if disableHUDButton}}checked{{/if}}>
</div>
<p class="notes">Prevent the display of the Token HUD button..</p>
</div>
</fieldset>

+ 79
- 0
Data/modules/token-variants/templates/randomizerConfig.html View File

@ -0,0 +1,79 @@
<form>
<h2>{{localize "token-variants.common.randomize"}}</h2>
<div class="form-group">
<label>On Token Create</label>
<input type="checkbox" name="randomizer.tokenCreate" data-dtype="Boolean" {{#if randomizer.tokenCreate}}checked{{/if}}>
</div>
<div class="form-group">
<label>On Token Copy+Paste</label>
<input type="checkbox" name="randomizer.tokenCopyPaste" data-dtype="Boolean" {{#if randomizer.tokenCopyPaste}}checked{{/if}}>
</div>
<div class="form-group">
<label>&nbsp;&nbsp;&nbsp;&nbsp; {{localize "token-variants.settings.randomizer.window.token-to-portrait"}}</label>
<input type="checkbox" name="randomizer.tokenToPortrait" data-dtype="Boolean" {{#if randomizer.tokenToPortrait}}checked{{/if}}>
</div>
<hr />
<div class="form-group">
<label>{{localize "token-variants.settings.randomizer.window.different-images"}}</label>
<input type="checkbox" name="randomizer.diffImages" data-dtype="Boolean" {{#if randomizer.diffImages}}checked{{/if}}>
</div>
<div class="form-group">
<label>&nbsp;&nbsp;&nbsp;&nbsp; {{localize "token-variants.settings.randomizer.window.sync-images"}}</label>
<input type="checkbox" name="randomizer.syncImages" data-dtype="Boolean" {{#if randomizer.syncImages}}checked{{/if}}>
</div>
<h2>Searches to include in image Randomization</h2>
<div class="form-group">
<label>Token Name</label>
<input type="checkbox" name="randomizer.tokenName" data-dtype="Boolean" {{#if randomizer.tokenName}}checked{{/if}}>
</div>
<div class="form-group">
<label>Actor Name</label>
<input type="checkbox" name="randomizer.actorName" data-dtype="Boolean" {{#if randomizer.actorName}}checked{{/if}}>
</div>
<div class="form-group">
<label>{{localize "token-variants.common.keywords"}}</label>
<input type="checkbox" name="randomizer.keywords" data-dtype="Boolean" {{#if randomizer.keywords}}checked{{/if}}>
</div>
<div class="form-group">
<label>{{localize "token-variants.common.shared"}} <i class="fas fa-share"></i></label>
<input type="checkbox" name="randomizer.shared" data-dtype="Boolean" {{#if randomizer.shared}}checked{{/if}}>
</div>
<div class="form-group">
<label>Wildcard *</label>
<input type="checkbox" name="randomizer.wildcard" data-dtype="Boolean" {{#if randomizer.wildcard}}checked{{/if}}>
</div>
{{#if nameForgeActive}}
<h2>Module: Name Forge</h2>
<div class="form-group">
<label>Randomize Token Name</label>
<input type="checkbox" name="randomizer.nameForge.randomize" data-dtype="Boolean" {{#if randomizer.nameForge.randomize}}checked{{/if}}>
</div>
<div class="form-group">
<label>Models</label>
<button type="button" class="selectNameForgeModels">Select</button>
<input type="text" name="randomizer.nameForge.models" value="{{randomizer.nameForge.models}}" hidden>
</div>
{{/if}}
<footer class="sheet-footer flexrow">
<button type="submit"><i class="far fa-save"></i>{{localize "token-variants.common.apply"}}</button>
{{#if hasSettings}}
<button type="submit" value="remove"><i class="fas fa-trash"></i>{{localize "token-variants.common.remove"}}</button>
{{/if}}
</footer>
</form>

+ 46
- 0
Data/modules/token-variants/templates/sideSelect.html View File

@ -0,0 +1,46 @@
<div class="token-variants-wrap images {{#unless imageDisplay}}list{{/unless}}">
{{#each imagesParsed as |image|}}
<div
class="token-variants-button-select control-icon {{#unless ../imageDisplay}}list{{/unless}} {{#if image.used}}token-variants-button-disabled active{{/if}}"
data-name="{{image.route}}"
data-filename="{{image.name}}"
title="{{#if image.unknownType}}{{image.route}}{{else}}{{image.title}}{{/if}}"
style="{{image.style}}">
{{#if ../imageDisplay}}
{{#if image.img}}
<img
class="token-variants-button-image"
src="{{image.route}}"
style="opacity:{{../imageOpacity}};"
/>
{{/if}}
{{#if image.vid}}
<video
class="token-variants-button-image"
src="{{image.route}}"
style="opacity:{{../imageOpacity}};"
loop
{{#if ../autoplay}}
autoplay
{{/if}}
muted>
</video>
{{#unless ../autoplay}}
<i class="fas fa-play"></i>
{{/unless}}
{{/if}}
{{#if image.unknownType}}
<img
class="token-variants-button-image"
src="{{image.route}}"
style="opacity:{{../imageOpacity}};"
/>
{{/if}}
{{else}}
<span>{{image.name}}</span>
{{/if}}
<i class="fas fa-share {{#if image.shared}}active{{/if}}"></i>
<i class="fas fa-cog {{#if image.hasConfig}}active{{/if}}"></i>
</div>
{{/each}}
</div>

+ 43
- 0
Data/modules/token-variants/templates/tokenHUDClientSettings.html View File

@ -0,0 +1,43 @@
<form>
<div class="form-group">
<label>{{localize "token-variants.settings.token-hud.window.enable-token-hud.Name"}}</label>
<div class="form-fields">
<input type="checkbox" name="enableSideMenu" data-dtype="Boolean" {{#if enableSideMenu}}checked{{/if}}>
</div>
<p class="notes">
{{localize "token-variants.settings.token-hud.window.enable-token-hud.Hint"}}
</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.token-hud.window.display-as-image.Name"}}</label>
<div class="form-fields">
<input type="checkbox" name="displayAsImage" data-dtype="Boolean" {{#if displayAsImage}}checked{{/if}}>
</div>
<p class="notes">
{{localize "token-variants.settings.token-hud.window.display-as-image.Hint"}}
</p>
</div>
<div class="form-group">
<label>{{localize "token-variants.settings.token-hud.window.image-opacity.Name"}}</label>
<div class="form-fields">
<input
type="range"
name="imageOpacity"
data-dtype="Number"
value="{{imageOpacity}}"
min="0"
max="100"
step="1"
/>
</div>
<p class="notes">{{localize "token-variants.settings.token-hud.window.image-opacity.Hint"}}</p>
</div>
<div class="button-container">
<button type="submit">
<i class="far fa-save"></i>{{localize "token-variants.common.apply"}}
</button>
</div>
</form>

+ 40
- 0
Data/modules/token-variants/templates/userList.html View File

@ -0,0 +1,40 @@
<form>
<section>
<header class="table-header flexrow">
<div class="index"><label></label></div>
<div><label>Username</label></div>
<div><label>Apply</label></div>
</header>
<ul>
{{#each users}}
<li class="form-group">
<div class="index">
<img height="32" width="32" src="{{this.avatar}}" style="border-color: {{this.color}};"/>
</div>
<div class="form-fields">
<label>{{this.name}}</label>
</div>
<div class="form-fields">
<input type="checkbox" name="{{this.userId}}" {{#if this.apply}}checked{{/if}} />
</div>
</li>
{{/each}}
</ol>
</section>
<div class="form-group">
<label>Invisible Image</label>
<div class="form-fields">
<Input type="text" name="invisibleImage" value="{{invisibleImage}}"/>
<button type="button" class="file-picker" data-type="image" data-target="invisibleImage" title="Browse Files" tabindex="-1"><i class="fas fa-file-import fa-fw"></i></button>
</div>
<p class="notes">Placeables will be rendered invisible for non-gm users that have this image assigned to them.</p>
</div>
<footer class="sheet-footer flexrow">
<button type="submit">
<i class="far fa-save"></i>{{localize "token-variants.common.apply"}}
</button>
</footer>
</form>

+ 238
- 0
Data/modules/token-variants/token-variants.mjs View File

@ -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;
});

BIN
Data/worlds/the-fall-of-plaguestone/data/actors.db (Stored with Git LFS) View File

size 1337686

BIN
Data/worlds/the-fall-of-plaguestone/data/fog.db (Stored with Git LFS) View File

size 5862251 size 5862251

BIN
Data/worlds/the-fall-of-plaguestone/data/folders.db (Stored with Git LFS) View File

size 5340

BIN
Data/worlds/the-fall-of-plaguestone/data/scenes.db (Stored with Git LFS) View File

size 467155

BIN
Data/worlds/the-fall-of-plaguestone/data/settings.db (Stored with Git LFS) View File

size 41354

BIN
Data/worlds/the-fall-of-plaguestone/data/tables.db (Stored with Git LFS) View File

size 2886

Loading…
Cancel
Save