All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

661 lines
22 KiB

  1. import {
  2. getFileName,
  3. isImage,
  4. isVideo,
  5. SEARCH_TYPE,
  6. keyPressed,
  7. updateActorImage,
  8. updateTokenImage,
  9. decodeURISafely,
  10. } from '../scripts/utils.js';
  11. import TokenCustomConfig from './tokenCustomConfig.js';
  12. import EffectMappingForm from './effectMappingForm.js';
  13. import { TVA_CONFIG } from '../scripts/settings.js';
  14. import UserList from './userList.js';
  15. import FlagsConfig from './flagsConfig.js';
  16. import RandomizerConfig from './randomizerConfig.js';
  17. import { doImageSearch, findImagesFuzzy } from '../scripts/search.js';
  18. export const TOKEN_HUD_VARIANTS = { variants: null, actor: null };
  19. export async function renderTokenHUD(hud, html, token, searchText = '', fp_files = null) {
  20. activateStatusEffectListeners(token);
  21. const hudSettings = TVA_CONFIG.hud;
  22. const FULL_ACCESS = TVA_CONFIG.permissions.hudFullAccess[game.user.role];
  23. const PARTIAL_ACCESS = TVA_CONFIG.permissions.hud[game.user.role];
  24. // Check if the HUD button should be displayed
  25. if (
  26. !hudSettings.enableSideMenu ||
  27. (!PARTIAL_ACCESS && !FULL_ACCESS) ||
  28. token.flags['token-variants']?.disableHUDButton
  29. )
  30. return;
  31. const tokenActor = game.actors.get(token.actorId);
  32. // Disable button if Token HUD Wildcard is enabled and appropriate setting is set
  33. if (TVA_CONFIG.worldHud.disableIfTHWEnabled && game.modules.get('token-hud-wildcard')?.active) {
  34. if (tokenActor && tokenActor.prototypeToken.randomImg) return;
  35. }
  36. const button = $(`
  37. <div class="control-icon" data-action="token-variants-side-selector">
  38. <img
  39. id="token-variants-side-button"
  40. src="modules/token-variants/img/token-images.svg"
  41. width="36"
  42. height="36"
  43. title="Left-click: Image Menu&#013;Right-click: Search & Additional settings"
  44. />
  45. </div>
  46. `);
  47. html.find('div.right').last().append(button);
  48. html.find('div.right').click(_deactivateTokenVariantsSideSelector);
  49. button.click((event) => _onButtonClick(event, token));
  50. if (FULL_ACCESS) {
  51. button.contextmenu((event) => _onButtonRightClick(event, hud, html, token));
  52. }
  53. }
  54. async function _onButtonClick(event, token) {
  55. const button = $(event.target).closest('.control-icon');
  56. // De-activate 'Status Effects'
  57. button.closest('div.right').find('div.control-icon.effects').removeClass('active');
  58. button.closest('div.right').find('.status-effects').removeClass('active');
  59. // Remove contextmenu
  60. button.find('.contextmenu').remove();
  61. // Toggle variants side menu
  62. button.toggleClass('active');
  63. let variantsWrap = button.find('.token-variants-wrap');
  64. if (button.hasClass('active')) {
  65. if (!variantsWrap.length) {
  66. variantsWrap = await renderSideSelect(token);
  67. if (variantsWrap) button.find('img').after(variantsWrap);
  68. else return;
  69. }
  70. variantsWrap.addClass('active');
  71. } else {
  72. variantsWrap.removeClass('active');
  73. }
  74. }
  75. function _onButtonRightClick(event, hud, html, token) {
  76. // Display side menu if button is not active yet
  77. const button = $(event.target).closest('.control-icon');
  78. if (!button.hasClass('active')) {
  79. // button.trigger('click');
  80. button.addClass('active');
  81. }
  82. if (button.find('.contextmenu').length) {
  83. // Contextmenu already displayed. Remove and activate images
  84. button.find('.contextmenu').remove();
  85. button.removeClass('active').trigger('click');
  86. //button.find('.token-variants-wrap.images').addClass('active');
  87. } else {
  88. // Contextmenu is not displayed. Hide images, create it and add it
  89. button.find('.token-variants-wrap.images').removeClass('active');
  90. const contextMenu = $(`
  91. <div class="token-variants-wrap contextmenu active">
  92. <div class="token-variants-context-menu active">
  93. <input class="token-variants-side-search" type="text" />
  94. <button class="flags" type="button"><i class="fab fa-font-awesome-flag"></i><label>Flags</label></button>
  95. <button class="file-picker" type="button"><i class="fas fa-file-import fa-fw"></i><label>Browse Folders</label></button>
  96. <button class="effectConfig" type="button"><i class="fas fa-sun"></i><label>Mappings</label></button>
  97. <button class="randomizerConfig" type="button"><i class="fas fa-dice"></i><label>Randomizer</label></button>
  98. </div>
  99. </div>
  100. `);
  101. button.append(contextMenu);
  102. // Register contextmenu listeners
  103. contextMenu
  104. .find('.token-variants-side-search')
  105. .on('keyup', (event) => _onImageSearchKeyUp(event, token))
  106. .on('click', (event) => {
  107. event.preventDefault();
  108. event.stopPropagation();
  109. });
  110. contextMenu.find('.flags').click((event) => {
  111. const tkn = canvas.tokens.get(token._id);
  112. if (tkn) {
  113. event.preventDefault();
  114. event.stopPropagation();
  115. new FlagsConfig(tkn).render(true);
  116. }
  117. });
  118. contextMenu.find('.file-picker').click(async (event) => {
  119. event.preventDefault();
  120. event.stopPropagation();
  121. new FilePicker({
  122. type: 'imagevideo',
  123. callback: async (path, fp) => {
  124. const content = await FilePicker.browse(fp.activeSource, fp.result.target);
  125. let files = content.files.filter((f) => isImage(f) || isVideo(f));
  126. if (files.length) {
  127. button.find('.token-variants-wrap').remove();
  128. const sideSelect = await renderSideSelect(token, '', files);
  129. if (sideSelect) {
  130. sideSelect.addClass('active');
  131. button.append(sideSelect);
  132. }
  133. }
  134. },
  135. }).render(true);
  136. });
  137. contextMenu.find('.effectConfig').click((event) => {
  138. new EffectMappingForm(token).render(true);
  139. });
  140. contextMenu.find('.randomizerConfig').click((event) => {
  141. new RandomizerConfig(token).render(true);
  142. });
  143. }
  144. }
  145. function _deactivateTokenVariantsSideSelector(event) {
  146. const controlIcon = $(event.target).closest('.control-icon');
  147. const dataAction = controlIcon.attr('data-action');
  148. switch (dataAction) {
  149. case 'effects':
  150. break; // Effects button
  151. case 'thwildcard-selector':
  152. break; // Token HUD Wildcard module button
  153. default:
  154. return;
  155. }
  156. $(event.target)
  157. .closest('div.right')
  158. .find('.control-icon[data-action="token-variants-side-selector"]')
  159. .removeClass('active');
  160. $(event.target).closest('div.right').find('.token-variants-wrap').removeClass('active');
  161. }
  162. async function renderSideSelect(token, searchText = '', fp_files = null) {
  163. const hudSettings = TVA_CONFIG.hud;
  164. const worldHudSettings = TVA_CONFIG.worldHud;
  165. const FULL_ACCESS = TVA_CONFIG.permissions.hudFullAccess[game.user.role];
  166. const PARTIAL_ACCESS = TVA_CONFIG.permissions.hud[game.user.role];
  167. const tokenActor = game.actors.get(token.actorId);
  168. let images = [];
  169. let actorVariants = [];
  170. let imageDuplicates = new Set();
  171. const pushImage = (img) => {
  172. if (imageDuplicates.has(img.path)) {
  173. if (!images.find((obj) => obj.path === img.path && obj.name === img.name)) {
  174. images.push(img);
  175. }
  176. } else {
  177. images.push(img);
  178. imageDuplicates.add(img.path);
  179. }
  180. };
  181. actorVariants = getVariants(tokenActor);
  182. if (!fp_files) {
  183. if (!searchText) {
  184. // Insert current token image
  185. if (token.texture?.src && token.texture?.src !== CONST.DEFAULT_TOKEN) {
  186. pushImage({
  187. path: decodeURISafely(token.texture.src),
  188. name: token.flags?.['token-variants']?.name ?? getFileName(token.texture.src),
  189. });
  190. }
  191. if (tokenActor) {
  192. // Insert default token image
  193. const defaultImg =
  194. tokenActor.prototypeToken?.flags['token-variants']?.['randomImgDefault'] ||
  195. tokenActor.prototypeToken?.flags['token-hud-wildcard']?.['default'] ||
  196. '';
  197. if (defaultImg) {
  198. pushImage({ path: decodeURISafely(defaultImg), name: getFileName(defaultImg) });
  199. }
  200. if (FULL_ACCESS || PARTIAL_ACCESS) {
  201. actorVariants.forEach((variant) => {
  202. for (const name of variant.names) {
  203. pushImage({ path: decodeURISafely(variant.imgSrc), name: name });
  204. }
  205. });
  206. }
  207. // Parse directory flag and include the images
  208. if (FULL_ACCESS || PARTIAL_ACCESS) {
  209. const directoryFlag = tokenActor.getFlag('token-variants', 'directory');
  210. if (directoryFlag) {
  211. let dirFlagImages;
  212. try {
  213. let path = directoryFlag.path;
  214. let source = directoryFlag.source;
  215. let bucket = '';
  216. if (source.startsWith('s3:')) {
  217. bucket = source.substring(3, source.length);
  218. source = 's3';
  219. }
  220. const content = await FilePicker.browse(source, path, {
  221. type: 'imagevideo',
  222. bucket,
  223. });
  224. dirFlagImages = content.files;
  225. } catch (err) {
  226. dirFlagImages = [];
  227. }
  228. dirFlagImages = dirFlagImages.forEach((f) => {
  229. if (isImage(f) || isVideo(f)) pushImage({ path: decodeURISafely(f), name: getFileName(f) });
  230. });
  231. }
  232. }
  233. if (
  234. (FULL_ACCESS || PARTIAL_ACCESS) &&
  235. worldHudSettings.includeWildcard &&
  236. !worldHudSettings.displayOnlySharedImages
  237. ) {
  238. // Merge wildcard images
  239. const protoImg = tokenActor.prototypeToken.texture.src;
  240. if (tokenActor.prototypeToken.randomImg) {
  241. (await tokenActor.getTokenImages())
  242. .filter((img) => !img.includes('*'))
  243. .forEach((img) => {
  244. pushImage({ path: decodeURISafely(img), name: getFileName(img) });
  245. });
  246. } else if (protoImg.includes('*') || protoImg.includes('{') || protoImg.includes('}')) {
  247. // Modified version of Actor.getTokenImages()
  248. const getTokenImages = async () => {
  249. if (tokenActor._tokenImages) return tokenActor._tokenImages;
  250. let source = 'data';
  251. let pattern = tokenActor.prototypeToken.texture.src;
  252. const browseOptions = { wildcard: true };
  253. // Support non-user sources
  254. if (/\.s3\./.test(pattern)) {
  255. source = 's3';
  256. const { bucket, keyPrefix } = FilePicker.parseS3URL(pattern);
  257. if (bucket) {
  258. browseOptions.bucket = bucket;
  259. pattern = keyPrefix;
  260. }
  261. } else if (pattern.startsWith('icons/')) source = 'public';
  262. // Retrieve wildcard content
  263. try {
  264. const content = await FilePicker.browse(source, pattern, browseOptions);
  265. tokenActor._tokenImages = content.files;
  266. } catch (err) {
  267. tokenActor._tokenImages = [];
  268. }
  269. return tokenActor._tokenImages;
  270. };
  271. (await getTokenImages())
  272. .filter((img) => !img.includes('*') && (isImage(img) || isVideo(img)))
  273. .forEach((variant) => {
  274. pushImage({ path: decodeURISafely(variant), name: getFileName(variant) });
  275. });
  276. }
  277. }
  278. }
  279. }
  280. // Perform image search if needed
  281. if (FULL_ACCESS) {
  282. let search;
  283. if (searchText) {
  284. search = searchText.length > 2 ? searchText : null;
  285. } else {
  286. if (worldHudSettings.displayOnlySharedImages || tokenActor?.getFlag('token-variants', 'disableNameSearch')) {
  287. // No search
  288. } else if (token.name.length > 2) {
  289. search = token.name;
  290. }
  291. }
  292. if (search) {
  293. let artSearch = await doImageSearch(search, {
  294. searchType: SEARCH_TYPE.TOKEN,
  295. searchOptions: { keywordSearch: worldHudSettings.includeKeywords },
  296. });
  297. // Merge full search, and keywords into a single array
  298. if (artSearch) {
  299. artSearch.forEach((results) => {
  300. results.forEach((img) => pushImage(img));
  301. });
  302. }
  303. }
  304. }
  305. } else {
  306. images = fp_files.map((f) => {
  307. return { path: decodeURISafely(f), name: getFileName(f) };
  308. });
  309. }
  310. // Retrieving the possibly custom name attached as a flag to the token
  311. let tokenImageName = '';
  312. if (token.flags['token-variants'] && token.flags['token-variants']['name']) {
  313. tokenImageName = token.flags['token-variants']['name'];
  314. } else {
  315. tokenImageName = getFileName(token.texture.src);
  316. }
  317. let imagesParsed = [];
  318. const tokenConfigs = (TVA_CONFIG.tokenConfigs || []).flat();
  319. const tkn = canvas.tokens.get(token._id);
  320. const userMappings = tkn.document.getFlag('token-variants', 'userMappings') || {};
  321. for (const imageObj of images) {
  322. const img = isImage(imageObj.path);
  323. const vid = isVideo(imageObj.path);
  324. const hasConfig = Boolean(
  325. tokenConfigs.find((config) => config.tvImgSrc === imageObj.path && config.tvImgName === imageObj.name)
  326. );
  327. let shared = false;
  328. if (TVA_CONFIG.permissions.hudFullAccess[game.user.role]) {
  329. actorVariants.forEach((variant) => {
  330. if (variant.imgSrc === imageObj.path && variant.names.includes(imageObj.name)) {
  331. shared = true;
  332. }
  333. });
  334. }
  335. const [title, style] = genTitleAndStyle(userMappings, imageObj.path, imageObj.name);
  336. imagesParsed.push({
  337. route: imageObj.path,
  338. name: imageObj.name,
  339. used: imageObj.path === token.texture.src && imageObj.name === tokenImageName,
  340. img,
  341. vid,
  342. unknownType: !img && !vid,
  343. shared: shared,
  344. hasConfig: hasConfig,
  345. title: title,
  346. style: game.user.isGM && style ? 'box-shadow: ' + style + ';' : null,
  347. });
  348. }
  349. //
  350. // Render
  351. //
  352. const imageDisplay = hudSettings.displayAsImage;
  353. const imageOpacity = hudSettings.imageOpacity / 100;
  354. const sideSelect = $(
  355. await renderTemplate('modules/token-variants/templates/sideSelect.html', {
  356. imagesParsed,
  357. imageDisplay,
  358. imageOpacity,
  359. tokenHud: true,
  360. })
  361. );
  362. // Activate listeners
  363. sideSelect.find('video').hover(
  364. function () {
  365. if (TVA_CONFIG.playVideoOnHover) {
  366. this.play();
  367. $(this).siblings('.fa-play').hide();
  368. }
  369. },
  370. function () {
  371. if (TVA_CONFIG.pauseVideoOnHoverOut) {
  372. this.pause();
  373. this.currentTime = 0;
  374. $(this).siblings('.fa-play').show();
  375. }
  376. }
  377. );
  378. sideSelect.find('.token-variants-button-select').click((event) => _onImageClick(event, token._id));
  379. if (FULL_ACCESS) {
  380. sideSelect.find('.token-variants-button-select').on('contextmenu', (event) => _onImageRightClick(event, token._id));
  381. }
  382. return sideSelect;
  383. }
  384. async function _onImageClick(event, tokenId) {
  385. event.preventDefault();
  386. event.stopPropagation();
  387. const token = canvas.tokens.controlled.find((t) => t.document.id === tokenId);
  388. if (!token) return;
  389. const worldHudSettings = TVA_CONFIG.worldHud;
  390. const imgButton = $(event.target).closest('.token-variants-button-select');
  391. const imgSrc = imgButton.attr('data-name');
  392. const name = imgButton.attr('data-filename');
  393. if (!imgSrc || !name) return;
  394. if (keyPressed('config') && game.user.isGM) {
  395. const toggleCog = (saved) => {
  396. const cog = imgButton.find('.fa-cog');
  397. if (saved) {
  398. cog.addClass('active');
  399. } else {
  400. cog.removeClass('active');
  401. }
  402. };
  403. new TokenCustomConfig(token, {}, imgSrc, name, toggleCog).render(true);
  404. } else if (token.document.texture.src === imgSrc) {
  405. let tokenImageName = token.document.getFlag('token-variants', 'name');
  406. if (!tokenImageName) tokenImageName = getFileName(token.document.texture.src);
  407. if (tokenImageName !== name) {
  408. await updateTokenImage(imgSrc, {
  409. token: token,
  410. imgName: name,
  411. animate: worldHudSettings.animate,
  412. });
  413. if (token.actor && worldHudSettings.updateActorImage) {
  414. if (worldHudSettings.useNameSimilarity) {
  415. updateActorWithSimilarName(imgSrc, name, token.actor);
  416. } else {
  417. updateActorImage(token.actor, imgSrc, { imgName: name });
  418. }
  419. }
  420. }
  421. } else {
  422. await updateTokenImage(imgSrc, {
  423. token: token,
  424. imgName: name,
  425. animate: worldHudSettings.animate,
  426. });
  427. if (token.actor && worldHudSettings.updateActorImage) {
  428. if (worldHudSettings.useNameSimilarity) {
  429. updateActorWithSimilarName(imgSrc, name, token.actor);
  430. } else {
  431. updateActorImage(token.actor, imgSrc, { imgName: name });
  432. }
  433. }
  434. }
  435. }
  436. async function _onImageRightClick(event, tokenId) {
  437. event.preventDefault();
  438. event.stopPropagation();
  439. let token = canvas.tokens.controlled.find((t) => t.document.id === tokenId);
  440. if (!token) return;
  441. const imgButton = $(event.target).closest('.token-variants-button-select');
  442. const imgSrc = imgButton.attr('data-name');
  443. const name = imgButton.attr('data-filename');
  444. if (!imgSrc || !name) return;
  445. if (keyPressed('config') && game.user.isGM) {
  446. const regenStyle = (token, img) => {
  447. const mappings = token.document.getFlag('token-variants', 'userMappings') || {};
  448. const name = imgButton.attr('data-filename');
  449. const [title, style] = genTitleAndStyle(mappings, img, name);
  450. imgButton
  451. .closest('.token-variants-wrap')
  452. .find(`.token-variants-button-select[data-name='${img}']`)
  453. .css('box-shadow', style)
  454. .prop('title', title);
  455. };
  456. new UserList(token, imgSrc, regenStyle).render(true);
  457. } else if (token.actor) {
  458. let tokenActor = game.actors.get(token.actor.id);
  459. let variants = getVariants(tokenActor);
  460. // Remove selected variant if present in the flag, add otherwise
  461. let del = false;
  462. let updated = false;
  463. for (let variant of variants) {
  464. if (variant.imgSrc === imgSrc) {
  465. let fNames = variant.names.filter((name) => name !== name);
  466. if (fNames.length === 0) {
  467. del = true;
  468. } else if (fNames.length === variant.names.length) {
  469. fNames.push(name);
  470. }
  471. variant.names = fNames;
  472. updated = true;
  473. break;
  474. }
  475. }
  476. if (del) variants = variants.filter((variant) => variant.imgSrc !== imgSrc);
  477. else if (!updated) variants.push({ imgSrc: imgSrc, names: [name] });
  478. // Set shared variants as an actor flag
  479. setVariants(tokenActor, variants);
  480. imgButton.find('.fa-share').toggleClass('active'); // Display green arrow
  481. }
  482. }
  483. async function _onImageSearchKeyUp(event, token) {
  484. event.preventDefault();
  485. event.stopPropagation();
  486. if (event.key === 'Enter' || event.keyCode === 13) {
  487. if (event.target.value.length >= 3) {
  488. const button = $(event.target).closest('.control-icon');
  489. button.find('.token-variants-wrap').remove();
  490. const sideSelect = await renderSideSelect(token, event.target.value);
  491. if (sideSelect) {
  492. sideSelect.addClass('active');
  493. button.append(sideSelect);
  494. }
  495. }
  496. }
  497. }
  498. function genTitleAndStyle(mappings, imgSrc, name) {
  499. let title = TVA_CONFIG.worldHud.showFullPath ? imgSrc : name;
  500. let style = '';
  501. let offset = 2;
  502. for (const [userId, img] of Object.entries(mappings)) {
  503. if (img === imgSrc) {
  504. const user = game.users.get(userId);
  505. if (!user) continue;
  506. if (style.length === 0) {
  507. style = `inset 0 0 0 ${offset}px ${user.color}`;
  508. } else {
  509. style += `, inset 0 0 0 ${offset}px ${user.color}`;
  510. }
  511. offset += 2;
  512. title += `\nDisplayed to: ${user.name}`;
  513. }
  514. }
  515. return [title, style];
  516. }
  517. async function updateActorWithSimilarName(imgSrc, imgName, actor) {
  518. const results = await findImagesFuzzy(
  519. imgName,
  520. SEARCH_TYPE.PORTRAIT,
  521. {
  522. algorithm: {
  523. fuzzyThreshold: 0.4,
  524. fuzzyLimit: 50,
  525. },
  526. },
  527. true
  528. );
  529. if (results && results.length !== 0) {
  530. updateActorImage(actor, results[0].path, { imgName: results[0].name });
  531. } else {
  532. updateActorImage(actor, imgSrc, { imgName: imgName });
  533. }
  534. }
  535. function activateStatusEffectListeners(token) {
  536. if (TVA_CONFIG.permissions.statusConfig[game.user.role] && token.actorId && game.actors.get(token.actorId)) {
  537. $('.control-icon[data-action="effects"]')
  538. .find('img:first')
  539. .click((event) => {
  540. event.preventDefault();
  541. if (keyPressed('config')) {
  542. event.stopPropagation();
  543. new EffectMappingForm(token).render(true);
  544. }
  545. });
  546. $('.control-icon[data-action="visibility"]')
  547. .find('img')
  548. .click((event) => {
  549. event.preventDefault();
  550. if (keyPressed('config')) {
  551. event.stopPropagation();
  552. new EffectMappingForm(token, {
  553. createMapping: { label: 'In Combat', expression: 'token-variants-visibility' },
  554. }).render(true);
  555. }
  556. });
  557. $('.control-icon[data-action="combat"]')
  558. .find('img')
  559. .click((event) => {
  560. event.preventDefault();
  561. if (keyPressed('config')) {
  562. event.stopPropagation();
  563. new EffectMappingForm(token, {
  564. createMapping: { label: 'In Combat', expression: 'token-variants-combat' },
  565. }).render(true);
  566. }
  567. });
  568. $('.status-effects')
  569. .find('img')
  570. .click((event) => {
  571. event.preventDefault();
  572. if (keyPressed('config')) {
  573. event.stopPropagation();
  574. let effectName = event.target.getAttribute('title');
  575. if (game.system.id === 'pf2e') {
  576. effectName = $(event.target).closest('picture').attr('title');
  577. }
  578. new EffectMappingForm(token, {
  579. createMapping: { label: effectName, expression: effectName },
  580. }).render(true);
  581. }
  582. });
  583. }
  584. }
  585. function getVariants(actor) {
  586. if (TOKEN_HUD_VARIANTS.variants) return TOKEN_HUD_VARIANTS.variants;
  587. else return actor?.getFlag('token-variants', 'variants') || [];
  588. }
  589. function setVariants(actor, variants) {
  590. TOKEN_HUD_VARIANTS.variants = variants;
  591. TOKEN_HUD_VARIANTS.actor = actor;
  592. }