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.

673 lines
22 KiB

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