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 5862251 |
size 5340 |
size 467155 |
size 41354 |
size 2886 |