size 54611 |
size 101908 |
@ -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); | |||
} |
@ -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); | |||
} | |||
} | |||
} |
@ -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); | |||
} | |||
} |
@ -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); | |||
} | |||
} | |||
} |
@ -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; | |||
} | |||
} |
@ -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); | |||
} |
@ -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(' ', ' '); | |||
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]; | |||
} |
@ -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); | |||
} | |||
}); | |||
} | |||
} |
@ -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); | |||
} | |||
} |
@ -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; | |||
} | |||
} |
@ -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) }, | |||
}); | |||
} | |||
} |
@ -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); | |||
} | |||
} | |||
} |
@ -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); | |||
} |
@ -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); | |||
} |
@ -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
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; | |||
} |
@ -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)); | |||
} | |||
} |
@ -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); | |||
} | |||
} | |||
} |
size 7614 |
@ -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> |
@ -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)" | |||
} | |||
} | |||
} | |||
} |
@ -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" | |||
} | |||
] | |||
} | |||
} |
@ -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. |
@ -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 | |||
); | |||
} | |||
} |
@ -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'); | |||
} | |||
} |
@ -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(); | |||
} |
@ -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'); | |||
} | |||
} |
@ -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)); | |||
} | |||
} |
@ -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); | |||
} | |||
} |
@ -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; | |||
} |
@ -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); | |||
} |
@ -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 }, | |||
}; |
@ -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; | |||
} |
@ -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, | |||
}; | |||
} |
@ -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', | |||
}; |
@ -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); | |||
} |
@ -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; | |||
} |
@ -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; | |||
} |
@ -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); | |||
} |
@ -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(); | |||
} |
@ -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; | |||
} |
@ -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> |
@ -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 | |||
> {{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 | |||
> {{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"> | |||
| |||
<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> |
@ -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> |
@ -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> |
@ -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> | |||
| |||
<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> | |||
| |||
<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> {{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> {{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> {{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> {{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> {{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> |
@ -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> <i class="fas fa-question-circle" title="Accepted Operators:
• && ( logical AND)
• || (logical OR)
• \! (escaped logical NOT)
• \( (escaped open bracket to group expressions)
• \) (escaped closed bracket to group expressions)

Accepted hp and Token property Comparators:
• = (equal)
• < (less than)
• > (greater than)
• <= (less than or equal)
• >= (greater than or equal)
• <> (lesser or greater than)

Accepted wildcards
• \*
• \{ \}

Examples of valid expressions:
• Flying
• Dead && Burning
• Flying && \! \( Prone || Dead \)
• hp<=50%
• name=“Raging Barbarian“
• lockRotation=“true“
• flags.token-variants.test=“true“
• Exhaustion \*
• Exhaustion \{1,2,3\}

Special Effect Names:
• token-variants-combat : Actives when Token is in combat
• combat-turn : Activates when it's Token's turn in combat
• 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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> | |||
@ -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> -- 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.
 *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.
 *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<=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>{{name}}</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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> {{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> {{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> |
@ -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> |
@ -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> |
@ -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> | |||
@ -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; | |||
}); |
size 1337686 |
size 5862251 |
size 5340 |
size 467155 |
size 41354 |
size 2886 |