Facebook
From Gruff Lizard, 4 Years ago, written in Plain Text.
Embed
Download Paste or View Raw
Hits: 3270
  1. // ==UserScript==
  2. // @name         betteR20-5etools
  3. // @namespace    https://5e.tools/
  4. // @license      MIT (https://opensource.org/licenses/MIT)
  5. // @version      1.16.13
  6. // @updateURL    https://get.5e.tools/script/betteR20-5etools.user.js
  7. // @downloadURL  https://get.5e.tools/script/betteR20-5etools.user.js
  8. // @description  Enhance your Roll20 experience
  9. // @author       5egmegaanon/astranauta/MrLabRat/TheGiddyLimit/DBAWiseMan/BDeveau/Remuz/Callador Julaan/Erogroth/Stormy/FlayedOne
  10.  
  11. // @match        https://app.roll20.net/editor
  12. // @match        https://app.roll20.net/editor#*
  13. // @match        https://app.roll20.net/editor?*
  14. // @match        https://app.roll20.net/editor/
  15. // @match        https://app.roll20.net/editor/#*
  16. // @match        https://app.roll20.net/editor/?*
  17.  
  18. // @grant        unsafeWindow
  19. // @run-at       document-start
  20. // ==/UserScript==
  21.  
  22.  
  23. ART_HANDOUT = "betteR20-art";
  24. CONFIG_HANDOUT = "betteR20-config";
  25.  
  26. BASE_SITE_URL = "https://5e.tools/"; // TODO automate to use mirror if main site is unavailable
  27. SITE_JS_URL = BASE_SITE_URL + "js/";
  28. DATA_URL = BASE_SITE_URL + "data/";
  29.  
  30. SCRIPT_EXTENSIONS = [];
  31.  
  32. CONFIG_OPTIONS = {
  33.         interface: {
  34.                 _name: "Interface",
  35.                 showCustomArtPreview: {
  36.                         name: "Show Custom Art Previews",
  37.                         default: true,
  38.                         _type: "boolean"
  39.                 }
  40.         }
  41. };
  42.  
  43. addConfigOptions = function (category, options) {
  44.         if (!CONFIG_OPTIONS[category]) CONFIG_OPTIONS[category] = options;
  45.         else CONFIG_OPTIONS[category] = Object.assign(CONFIG_OPTIONS[category], options);
  46. };
  47.  
  48. OBJECT_DEFINE_PROPERTY = Object.defineProperty;
  49. ACCOUNT_ORIGINAL_PERMS = {
  50.         largefeats: false,
  51.         xlfeats: false
  52. };
  53. Object.defineProperty = function (obj, prop, vals) {
  54.         try {
  55.                 if (prop === "largefeats" || prop === "xlfeats") {
  56.                         ACCOUNT_ORIGINAL_PERMS[prop] = vals.value;
  57.                         vals.value = true;
  58.                 }
  59.                 OBJECT_DEFINE_PROPERTY(obj, prop, vals);
  60.         } catch (e) {
  61.                 console.log("failed to define property:");
  62.                 console.log(e);
  63.                 console.log(obj, prop, vals);
  64.         }
  65. };
  66.  
  67. FINAL_CANVAS_MOUSEDOWN_LIST = [];
  68. FINAL_CANVAS_MOUSEMOVE_LIST = [];
  69. FINAL_CANVAS_MOUSEDOWN = null;
  70. FINAL_CANVAS_MOUSEMOVE = null;
  71. EventTarget.prototype.addEventListenerBase = EventTarget.prototype.addEventListener;
  72. EventTarget.prototype.addEventListener = function(type, listener, options, ...others) {
  73.         if (typeof d20 !== "undefined") {
  74.                 if (type === "mousedown" && this === d20.engine.final_canvas) FINAL_CANVAS_MOUSEDOWN = listener;
  75.                 if (type === "mousemove" && this === d20.engine.final_canvas) FINAL_CANVAS_MOUSEMOVE = listener;
  76.         } else {
  77.                 if (type === "mousedown") FINAL_CANVAS_MOUSEDOWN_LIST.push({listener, on: this});
  78.                 if (type === "mousemove") FINAL_CANVAS_MOUSEMOVE_LIST.push({listener, on: this});
  79.         }
  80.         this.addEventListenerBase(type, listener, options, ...others);
  81. };
  82.  
  83.  
  84. function baseUtil () {
  85.         d20plus.ut = {};
  86.  
  87.         d20plus.ut.log = (...args) => {
  88.                 console.log("%cD20Plus > ", "color: #3076b9; font-size: large", ...args);
  89.         };
  90.  
  91.         d20plus.ut.error = (...args) => {
  92.                 console.error("%cD20Plus > ", "color: #b93032; font-size: large", ...args);
  93.         };
  94.  
  95.         d20plus.ut.chatLog = (arg) => {
  96.                 d20.textchat.incoming(
  97.                         false,
  98.                         {
  99.                                 who: "betteR20",
  100.                                 type: "general",
  101.                                 content: (arg || "").toString(),
  102.                                 playerid: window.currentPlayer.id,
  103.                                 id: d20plus.ut.generateRowId(),
  104.                                 target: window.currentPlayer.id,
  105.                                 avatar: "https://i.imgur.com/bBhudno.png"
  106.                         }
  107.                 );
  108.         };
  109.  
  110.         d20plus.ut.ascSort = (a, b) => {
  111.                 if (b === a) return 0;
  112.                 return b < a ? 1 : -1;
  113.         };
  114.  
  115.         d20plus.ut.disable3dDice = () => {
  116.                 d20plus.ut.log("Disabling 3D dice");
  117.                 const $cb3dDice = $(`#enable3ddice`);
  118.                 $cb3dDice.prop("checked", false).attr("disabled", true);
  119.                 $cb3dDice.closest("p").after(`<p><i>3D dice are incompatible with betteR20. We apologise for any inconvenience caused.</i></p>`);
  120.  
  121.                 $(`#autoroll`).prop("checked", false).attr("disabled", true);;
  122.  
  123.                 d20.tddice.canRoll3D = () => false;
  124.         };
  125.  
  126.         d20plus.ut.checkVersion = (scriptType) => {
  127.                 d20plus.ut.log("Checking current version");
  128.  
  129.                 function cmpVersions (a, b) {
  130.                         const regExStrip0 = /(\.0+)+$/;
  131.                         const segmentsA = a.replace(regExStrip0, '').split('.');
  132.                         const segmentsB = b.replace(regExStrip0, '').split('.');
  133.                         const l = Math.min(segmentsA.length, segmentsB.length);
  134.  
  135.                         for (let i = 0; i < l; i++) {
  136.                                 const diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10);
  137.                                 if (diff) {
  138.                                         return diff;
  139.                                 }
  140.                         }
  141.                         return segmentsA.length - segmentsB.length;
  142.                 }
  143.  
  144.                 let scriptUrl;
  145.                 switch (scriptType) {
  146.                         case "core": scriptType = `https://get.5e.tools/script/betteR20-core.user.js${d20plus.ut.getAntiCacheSuffix()}`; break;
  147.                         case "5etools": scriptType = `https://get.5e.tools/script/betteR20-5etools.user.js${d20plus.ut.getAntiCacheSuffix()}`; break;
  148.                         default: scriptUrl = "https://get.5e.tools/"; break;
  149.                 }
  150.  
  151.                 $.ajax({
  152.                         url: `https://get.5e.tools`,
  153.                         success: (data) => {
  154.                                 const m = /<!--\s*(\d+\.\d+\.\d+)\s*-->/.exec(data);
  155.                                 if (m) {
  156.                                         const curr = d20plus.version;
  157.                                         const avail = m[1];
  158.                                         const cmp = cmpVersions(curr, avail);
  159.                                         if (cmp < 0) {
  160.                                                 setTimeout(() => {
  161.                                                         d20plus.ut.sendHackerChat(`A newer version of the script is available. Get ${avail} <a href="https://get.5e.tools/">here</a>. For help and support, see our <a href="https://discord.gg/nGvRCDs">Discord</a>.`);
  162.                                                 }, 1000);
  163.                                         }
  164.                                 }
  165.                         },
  166.                         error: () => {
  167.                                 d20plus.ut.log("Failed to check version");
  168.                         }
  169.                 })
  170.         };
  171.  
  172.         d20plus.ut.chatTag = (message) => {
  173.                 const isStreamer = !!d20plus.cfg.get("interface", "streamerChatTag");
  174.                 d20plus.ut.sendHackerChat(`
  175.                                 ${isStreamer ? "Script" : message} initialised.
  176.                                 ${window.enhancementSuiteEnabled ? `<br><br>Roll20 Enhancement Suite detected.` : ""}
  177.                                 ${isStreamer ? "" : `
  178.                                 <br>
  179.                                 <br>
  180.                                 Need help? Join our <a href="https://discord.gg/nGvRCDs">Discord</a>.
  181.                                 <br>
  182.                                 <br>
  183.                                 <span title="You'd think this would be obvious.">
  184.                                 Please DO NOT post about this script or any related content in official channels, including the Roll20 forums.
  185.                                 <br>
  186.                                 <br>
  187.                                 Before reporting a bug on the Roll20 forums, please disable the script and check if the problem persists.                              
  188.                                 `}
  189.                                 </span>
  190.                         `);
  191.         };
  192.  
  193.         d20plus.ut.showLoadingMessage = (message) => {
  194.                 const isStreamer = !!d20plus.cfg.get("interface", "streamerChatTag");
  195.                 d20plus.ut.sendHackerChat(`
  196.                         ${isStreamer ? "Script" : message} initialising, please wait...<br><br>
  197.                 `);
  198.         };
  199.  
  200.         d20plus.ut.sendHackerChat = (message) => {
  201.                 d20.textchat.incoming(false, ({
  202.                         who: "system",
  203.                         type: "system",
  204.                         content: `<span class="hacker-chat">
  205.                                 ${message}
  206.                         </span>`
  207.                 }));
  208.         };
  209.  
  210.         d20plus.ut.addCSS = (sheet, selectors, rules) => {
  211.                 if (!(selectors instanceof Array)) selectors = [selectors];
  212.  
  213.                 selectors.forEach(selector => {
  214.                         const index = sheet.cssRules.length;
  215.                         try {
  216.                                 if ("insertRule" in sheet) {
  217.                                         sheet.insertRule(selector + "{" + rules + "}", index);
  218.                                 } else if ("addRule" in sheet) {
  219.                                         sheet.addRule(selector, rules, index);
  220.                                 }
  221.                         } catch (e) {
  222.                                 if ((!selector && selector.startsWith("-webkit-"))) {
  223.                                         console.error(e);
  224.                                         console.error(`Selector was "${selector}"; rules were "${rules}"`);
  225.                                 }
  226.                         }
  227.                 });
  228.         };
  229.  
  230.         d20plus.ut.addAllCss = () => {
  231.                 d20plus.ut.log("Adding CSS");
  232.  
  233.                 const targetSheet =  [...window.document.styleSheets]
  234.                         .filter(it => it.href && (!it.href.startsWith("moz-extension") && !it.href.startsWith("chrome-extension")))
  235.                         .find(it => it.href.includes("app.css"));
  236.  
  237.                 _.each(d20plus.css.baseCssRules, function (r) {
  238.                         d20plus.ut.addCSS(targetSheet, r.s, r.r);
  239.                 });
  240.                 if (!window.is_gm) {
  241.                         _.each(d20plus.css.baseCssRulesPlayer, function (r) {
  242.                                 d20plus.ut.addCSS(targetSheet, r.s, r.r);
  243.                         });
  244.                 }
  245.                 _.each(d20plus.css.cssRules, function (r) {
  246.                         d20plus.ut.addCSS(targetSheet, r.s, r.r);
  247.                 });
  248.         };
  249.  
  250.         d20plus.ut.getAntiCacheSuffix = () => {
  251.                 return "?" + (new Date()).getTime();
  252.         };
  253.  
  254.         d20plus.ut.generateRowId = () => {
  255.                 return window.generateUUID().replace(/_/g, "Z");
  256.         };
  257.  
  258.         d20plus.ut.randomRoll = (roll, success, error) => {
  259.                 d20.textchat.diceengine.process(roll, success, error);
  260.         };
  261.  
  262.         d20plus.ut.getJournalFolderObj = () => {
  263.                 d20.journal.refreshJournalList();
  264.                 let journalFolder = d20.Campaign.get("journalfolder");
  265.                 if (journalFolder === "") {
  266.                         d20.journal.addFolderToFolderStructure("Characters");
  267.                         d20.journal.refreshJournalList();
  268.                         journalFolder = d20.Campaign.get("journalfolder");
  269.                 }
  270.                 return JSON.parse(journalFolder);
  271.         };
  272.  
  273.         d20plus.ut._lastInput = null;
  274.         d20plus.ut.getNumberRange = (promptText, min, max) => {
  275.                 function alertInvalid () {
  276.                         alert("Please enter a valid range.");
  277.                 }
  278.  
  279.                 function isOutOfRange (num) {
  280.                         return num < min || num > max;
  281.                 }
  282.  
  283.                 function addToRangeVal (range, num) {
  284.                         range.add(num);
  285.                 }
  286.  
  287.                 function addToRangeLoHi (range, lo, hi) {
  288.                         for (let i = lo; i <= hi; ++i) {
  289.                                 range.add(i);
  290.                         }
  291.                 }
  292.  
  293.                 function alertOutOfRange () {
  294.                         alert(`Please enter numbers in the range ${min}-${max} (inclusive).`);
  295.                 }
  296.  
  297.                 while (true) {
  298.                         const res =  prompt(promptText, d20plus.ut._lastInput || "E.g. 1-5, 8, 11-13");
  299.                         if (res && res.trim()) {
  300.                                 d20plus.ut._lastInput = res;
  301.                                 const clean = res.replace(/\s*/g, "");
  302.                                 if (/^((\d+-\d+|\d+),)*(\d+-\d+|\d+)$/.exec(clean)) {
  303.                                         const parts = clean.split(",");
  304.                                         const out = new Set();
  305.                                         let failed = false;
  306.  
  307.                                         for (const part of parts) {
  308.                                                 if (part.includes("-")) {
  309.                                                         const spl = part.split("-");
  310.                                                         const numLo = Number(spl[0]);
  311.                                                         const numHi = Number(spl[1]);
  312.  
  313.                                                         if (isNaN(numLo) || isNaN(numHi) || numLo === 0 || numHi === 0 || numLo > numHi) {
  314.                                                                 alertInvalid();
  315.                                                                 failed = true;
  316.                                                                 break;
  317.                                                         }
  318.  
  319.                                                         if (isOutOfRange(numLo) || isOutOfRange(numHi)) {
  320.                                                                 alertOutOfRange();
  321.                                                                 failed = true;
  322.                                                                 break;
  323.                                                         }
  324.  
  325.                                                         if (numLo === numHi) {
  326.                                                                 addToRangeVal(out, numLo);
  327.                                                         } else {
  328.                                                                 addToRangeLoHi(out, numLo, numHi);
  329.                                                         }
  330.                                                 } else {
  331.                                                         const num = Number(part);
  332.                                                         if (isNaN(num) || num === 0) {
  333.                                                                 alertInvalid();
  334.                                                                 failed = true;
  335.                                                                 break;
  336.                                                         } else {
  337.                                                                 if (isOutOfRange(num)) {
  338.                                                                         alertOutOfRange();
  339.                                                                         failed = true;
  340.                                                                         break;
  341.                                                                 }
  342.                                                                 addToRangeVal(out, num);
  343.                                                         }
  344.                                                 }
  345.                                         }
  346.  
  347.                                         if (!failed) {
  348.                                                 d20plus.ut._lastInput = null;
  349.                                                 return out;
  350.                                         }
  351.                                 } else {
  352.                                         alertInvalid();
  353.                                 }
  354.                         } else {
  355.                                 d20plus.ut._lastInput = null;
  356.                                 return null;
  357.                         }
  358.                 }
  359.         };
  360.  
  361.         d20plus.ut.getPathById = (pathId) => {
  362.                 return d20plus.ut._getCanvasElementById(pathId, "thepaths");
  363.         };
  364.  
  365.         d20plus.ut.getTokenById = (tokenId) => {
  366.                 return d20plus.ut._getCanvasElementById(tokenId, "thegraphics");
  367.         };
  368.  
  369.         d20plus.ut._getCanvasElementById = (id, prop) => {
  370.                 const foundArr = d20.Campaign.pages.models.map(model => model[prop] && model[prop].models ? model[prop].models.find(it => it.id === id) : null).filter(it => it);
  371.                 return foundArr.length ? foundArr[0] : null;
  372.         };
  373.  
  374.         d20plus.ut.getMacroByName = (macroName) => {
  375.                 const macros = d20.Campaign.players.map(p => p.macros.find(m => m.get("name") === macroName && (p.id === window.currentPlayer.id || m.visibleToCurrentPlayer())))
  376.                         .filter(Boolean);
  377.                 if (macros.length) {
  378.                         return macros[0];
  379.                 }
  380.                 return null;
  381.         };
  382.  
  383.         d20plus.ut._BYTE_UNITS = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
  384.         d20plus.ut.getReadableFileSizeString = (fileSizeInBytes) => {
  385.                 let i = -1;
  386.                 do {
  387.                         fileSizeInBytes = fileSizeInBytes / 1024;
  388.                         i++;
  389.                 } while (fileSizeInBytes > 1024);
  390.                 return Math.max(fileSizeInBytes, 0.1).toFixed(1) + d20plus.ut._BYTE_UNITS[i];
  391.         };
  392.  
  393.         d20plus.ut.sanitizeFilename = function (str) {
  394.                 return str.trim().replace(/[^\w\-]/g, "_");
  395.         };
  396.  
  397.         // based on:
  398.         /*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/src/FileSaver.js */
  399.         d20plus.ut.saveAs = function() {
  400.                 const view = window;
  401.                 var
  402.                         doc = view.document
  403.                         // only get URL when necessary in case Blob.js hasn't overridden it yet
  404.                         , get_URL = function() {
  405.                                 return view.URL || view.webkitURL || view;
  406.                         }
  407.                         , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a")
  408.                         , can_use_save_link = "download" in save_link
  409.                         , click = function(node) {
  410.                                 var event = new MouseEvent("click");
  411.                                 node.dispatchEvent(event);
  412.                         }
  413.                         , is_safari = /constructor/i.test(view.HTMLElement) || view.safari
  414.                         , is_chrome_ios =/CriOS\/[\d]+/.test(navigator.userAgent)
  415.                         , setImmediate = view.setImmediate || view.setTimeout
  416.                         , throw_outside = function(ex) {
  417.                                 setImmediate(function() {
  418.                                         throw ex;
  419.                                 }, 0);
  420.                         }
  421.                         , force_saveable_type = "application/octet-stream"
  422.                         // the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to
  423.                         , arbitrary_revoke_timeout = 1000 * 40 // in ms
  424.                         , revoke = function(file) {
  425.                                 var revoker = function() {
  426.                                         if (typeof file === "string") { // file is an object URL
  427.                                                 get_URL().revokeObjectURL(file);
  428.                                         } else { // file is a File
  429.                                                 file.remove();
  430.                                         }
  431.                                 };
  432.                                 setTimeout(revoker, arbitrary_revoke_timeout);
  433.                         }
  434.                         , dispatch = function(filesaver, event_types, event) {
  435.                                 event_types = [].concat(event_types);
  436.                                 var i = event_types.length;
  437.                                 while (i--) {
  438.                                         var listener = filesaver["on" + event_types[i]];
  439.                                         if (typeof listener === "function") {
  440.                                                 try {
  441.                                                         listener.call(filesaver, event || filesaver);
  442.                                                 } catch (ex) {
  443.                                                         throw_outside(ex);
  444.                                                 }
  445.                                         }
  446.                                 }
  447.                         }
  448.                         , auto_bom = function(blob) {
  449.                                 // prepend BOM for UTF-8 XML and text/* types (including HTML)
  450.                                 // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF
  451.                                 if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) {
  452.                                         return new Blob([String.fromCharCode(0xFEFF), blob], {type: blob.type});
  453.                                 }
  454.                                 return blob;
  455.                         }
  456.                         , FileSaver = function(blob, name, no_auto_bom) {
  457.                                 if (!no_auto_bom) {
  458.                                         blob = auto_bom(blob);
  459.                                 }
  460.                                 // First try a.download, then web filesystem, then object URLs
  461.                                 var
  462.                                         filesaver = this
  463.                                         , type = blob.type
  464.                                         , force = type === force_saveable_type
  465.                                         , object_url
  466.                                         , dispatch_all = function() {
  467.                                                 dispatch(filesaver, "writestart progress write writeend".split(" "));
  468.                                         }
  469.                                         // on any filesys errors revert to saving with object URLs
  470.                                         , fs_error = function() {
  471.                                                 if ((is_chrome_ios || (force && is_safari)) && view.FileReader) {
  472.                                                         // Safari doesn't allow downloading of blob urls
  473.                                                         var reader = new FileReader();
  474.                                                         reader.onloadend = function() {
  475.                                                                 var url = is_chrome_ios ? reader.result : reader.result.replace(/^data:[^;]*;/, 'data:attachment/file;');
  476.                                                                 var popup = view.open(url, '_blank');
  477.                                                                 if(!popup) view.location.href = url;
  478.                                                                 url=undefined; // release reference before dispatching
  479.                                                                 filesaver.readyState = filesaver.DONE;
  480.                                                                 dispatch_all();
  481.                                                         };
  482.                                                         reader.readAsDataURL(blob);
  483.                                                         filesaver.readyState = filesaver.INIT;
  484.                                                         return;
  485.                                                 }
  486.                                                 // don't create more object URLs than needed
  487.                                                 if (!object_url) {
  488.                                                         object_url = get_URL().createObjectURL(blob);
  489.                                                 }
  490.                                                 if (force) {
  491.                                                         view.location.href = object_url;
  492.                                                 } else {
  493.                                                         var opened = view.open(object_url, "_blank");
  494.                                                         if (!opened) {
  495.                                                                 // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html
  496.                                                                 view.location.href = object_url;
  497.                                                         }
  498.                                                 }
  499.                                                 filesaver.readyState = filesaver.DONE;
  500.                                                 dispatch_all();
  501.                                                 revoke(object_url);
  502.                                         };
  503.                                 filesaver.readyState = filesaver.INIT;
  504.  
  505.                                 if (can_use_save_link) {
  506.                                         object_url = get_URL().createObjectURL(blob);
  507.                                         setImmediate(function() {
  508.                                                 save_link.href = object_url;
  509.                                                 save_link.download = name;
  510.                                                 click(save_link);
  511.                                                 dispatch_all();
  512.                                                 revoke(object_url);
  513.                                                 filesaver.readyState = filesaver.DONE;
  514.                                         }, 0);
  515.                                         return;
  516.                                 }
  517.  
  518.                                 fs_error();
  519.                         }
  520.                         , FS_proto = FileSaver.prototype
  521.                         , saveAs = function(blob, name, no_auto_bom) {
  522.                                 return new FileSaver(blob, name || blob.name || "download", no_auto_bom);
  523.                         };
  524.                 // IE 10+ (native saveAs)
  525.                 if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) {
  526.                         return function(blob, name, no_auto_bom) {
  527.                                 name = name || blob.name || "download";
  528.  
  529.                                 if (!no_auto_bom) {
  530.                                         blob = auto_bom(blob);
  531.                                 }
  532.                                 return navigator.msSaveOrOpenBlob(blob, name);
  533.                         };
  534.                 }
  535.                 FS_proto.abort = function(){};
  536.                 FS_proto.readyState = FS_proto.INIT = 0;
  537.                 FS_proto.WRITING = 1;
  538.                 FS_proto.DONE = 2;
  539.                 FS_proto.error =
  540.                         FS_proto.onwritestart =
  541.                                 FS_proto.onprogress =
  542.                                         FS_proto.onwrite =
  543.                                                 FS_proto.onabort =
  544.                                                         FS_proto.onerror =
  545.                                                                 FS_proto.onwriteend =
  546.                                                                         null;
  547.  
  548.                 return saveAs;
  549.         }();
  550.  
  551.         d20plus.ut.promiseDelay = function (delay) {
  552.                 return new Promise(resolve => {
  553.                         setTimeout(() => resolve(), delay);
  554.                 })
  555.         };
  556.  
  557.         d20plus.ut.LAYERS = ["map", "background", "objects", "foreground", "gmlayer", "walls", "weather"];
  558.         d20plus.ut.layerToName = (l) => {
  559.                 switch (l) {
  560.                         case "map": return "Map";
  561.                         case "background": return "Background";
  562.                         case "objects": return "Objects & Tokens";
  563.                         case "foreground": return "Foreground";
  564.                         case "gmlayer": return "GM Info Overlay";
  565.                         case "walls":  return "Dynamic Lighting";
  566.                         case "weather": return "Weather Exclusions";
  567.                 }
  568.         };
  569.  
  570.         d20plus.ut.get$SelValue = ($sel) => {
  571.                 return $sel[0].options[$sel[0].selectedIndex].value;
  572.         };
  573.  
  574.         d20plus.ut.isUseSharedJs = () => {
  575.                 return BASE_SITE_URL.includes("://5e.tools") || BASE_SITE_URL.includes("://5etools.com");
  576.         };
  577.  
  578.         d20plus.ut.fixSidebarLayout = () => {
  579.                 $(`#textchat-input`).insertAfter(`#textchat`);
  580.                 const cached = d20.textchat.showPopout;
  581.                 d20.textchat.showPopout = function () {
  582.                         cached();
  583.                         const cached2 = d20.textchat.childWindow.onbeforeunload;
  584.                         d20.textchat.childWindow.onbeforeunload = function () {
  585.                                 cached2();
  586.                                 $(`#textchat-input`).insertAfter(`#textchat`);
  587.                         }
  588.                 }
  589.         };
  590.  
  591.         /**
  592.          * Assumes any other lists have been searched using the same term
  593.          */
  594.         d20plus.ut.getSearchTermAndReset = (list, ...otherLists) => {
  595.                 let lastSearch = null;
  596.                 if (list.searched) {
  597.                         lastSearch = $(`#search`).val();
  598.                         list.search();
  599.                         otherLists.forEach(l => l.search());
  600.                 }
  601.                 list.filter();
  602.                 otherLists.forEach(l => l.filter());
  603.                 return lastSearch;
  604.         };
  605. }
  606.  
  607. SCRIPT_EXTENSIONS.push(baseUtil);
  608.  
  609. /*
  610.  
  611. map
  612. afow
  613. grid
  614. background
  615. objects
  616. foreground
  617. gmlayer
  618. walls
  619. weather
  620.  
  621.  */
  622.  
  623.  
  624. function baseJsLoad () {
  625.         d20plus.js = {};
  626.  
  627.         d20plus.js.scripts = [
  628.                 {name: "listjs", url: "https://raw.githubusercontent.com/javve/list.js/v1.5.0/dist/list.min.js"},
  629.                 {name: "localforage", url: "https://raw.githubusercontent.com/localForage/localForage/1.7.3/dist/localforage.min.js"},
  630.                 {name: "JSZip", url: `https://raw.githubusercontent.com/Stuk/jszip/master/dist/jszip.min.js`},
  631.         ];
  632.  
  633.         if (d20plus.ut.isUseSharedJs()) d20plus.js.scripts.push({name: "5etoolsShared", url: `${SITE_JS_URL}shared.js`});
  634.         else d20plus.js.scripts.push({name: "5etoolsUtils", url: `${SITE_JS_URL}utils.js`});
  635.  
  636.         d20plus.js.apiScripts = [
  637.                 {name: "VecMath", url: "https://raw.githubusercontent.com/Roll20/roll20-api-scripts/master/Vector%20Math/1.0/VecMath.js"},
  638.                 {name: "MatrixMath", url: "https://raw.githubusercontent.com/Roll20/roll20-api-scripts/master/MatrixMath/1.0/matrixMath.js"},
  639.                 {name: "PathMath", url: "https://raw.githubusercontent.com/Roll20/roll20-api-scripts/master/PathMath/1.5/PathMath.js"}
  640.         ];
  641.  
  642.         d20plus.js.pAddScripts = async () => {
  643.                 d20plus.ut.log("Add JS");
  644.  
  645.                 await Promise.all(d20plus.js.scripts.map(async it => {
  646.                         const js = await d20plus.js.pLoadWithRetries(it.name, it.url);
  647.                         d20plus.js._addScript(it.name, js)
  648.                 }));
  649.  
  650.                 // Monkey patch JSON loading
  651.                 const cached = DataUtil.loadJSON;
  652.                 DataUtil.loadJSON = (...args) => {
  653.                         if (args.length > 0 && typeof args[0] === "string" && args[0].startsWith("data/")) {
  654.                                 args[0] = BASE_SITE_URL + args[0];
  655.                         }
  656.                         return cached.bind(DataUtil)(...args);
  657.                 };
  658.         };
  659.  
  660.         d20plus.js.pAddApiScripts = async () => {
  661.                 d20plus.ut.log("Add Builtin API Scripts");
  662.  
  663.                 await Promise.all(d20plus.js.apiScripts.map(async it => {
  664.                         const js = await d20plus.js.pLoadWithRetries(it.name, it.url);
  665.                         d20plus.js._addScript(it.name, js);
  666.                 }));
  667.         };
  668.  
  669.         d20plus.js._addScript = (name, js) => {
  670.                 // sanity check
  671.                 if (js instanceof Promise) throw new Error(`Promise was passed instead of text! This is a bug.`);
  672.                 try {
  673.                         window.eval(js);
  674.                         d20plus.ut.log(`JS [${name}] Loaded`);
  675.                 } catch (e) {
  676.                         d20plus.ut.log(`Error loading [${name}]`);
  677.                         d20plus.ut.log(e);
  678.                         throw e;
  679.                 }
  680.         };
  681.  
  682.         d20plus.js.pLoadWithRetries = async (name, url, isJson) => {
  683.                 let retries = 3;
  684.  
  685.                 function pFetchData () {
  686.                         return new Promise((resolve, reject) => {
  687.                                 $.ajax({
  688.                                         type: "GET",
  689.                                         url: `${url}${d20plus.ut.getAntiCacheSuffix()}${retries}`,
  690.                                         success: function (data) {
  691.                                                 if (isJson && typeof data === "string") resolve(JSON.parse(data));
  692.                                                 else resolve(data);
  693.                                         },
  694.                                         error: function (resp, qq, pp) {
  695.                                                 if (resp && resp.status >= 400 && retries-- > 0) {
  696.                                                         console.error(resp, qq, pp);
  697.                                                         d20plus.ut.log(`Error loading ${name}; retrying`);
  698.                                                         setTimeout(() => {
  699.                                                                 reject(new Error(`Loading "${name}" failed (status ${resp.status}): ${resp} ${qq} ${pp}`));
  700.                                                         }, 500);
  701.                                                 } else {
  702.                                                         console.error(resp, qq, pp);
  703.                                                         setTimeout(() => {
  704.                                                                 reject(new Error(`Loading "${name}" failed (status ${resp.status}): ${resp} ${qq} ${pp}`));
  705.                                                         }, 500);
  706.                                                 }
  707.                                         }
  708.                                 });
  709.                         })
  710.                 }
  711.  
  712.                 let data;
  713.                 do {
  714.                         try {
  715.                                 data = await pFetchData();
  716.                         } catch (e) {} // error handling is done as part of data fetching
  717.                 } while (!data && --retries > 0);
  718.  
  719.                 if (data) return data;
  720.                 else throw new Error(`Failed to load ${name} from URL ${url} (isJson: ${!!isJson})`);
  721.         };
  722. }
  723.  
  724. SCRIPT_EXTENSIONS.push(baseJsLoad);
  725.  
  726.  
  727. function baseQpi () {
  728.         const qpi = {
  729.                 _version: "0.01-pre-pre-alpha",
  730.                 _: {
  731.                         log: {
  732.                                 _ (...args) {
  733.                                         qpi._log(...args)
  734.                                 },
  735.                                 works: 1
  736.                         },
  737.  
  738.                         // Campaign: { // FIXME this overwrites the window's campaign, which breaks stuff
  739.                         //      _ () {
  740.                         //              return Campaign;
  741.                         //      },
  742.                         //      works: 0
  743.                         // },
  744.  
  745.                         on: {
  746.                                 _preInit () {
  747.                                         qpi._on_chatHandlers = [];
  748.                                         const seenMessages = new Set();
  749.                                         d20.textchat.chatref = d20.textchat.shoutref.parent().child("chat");
  750.                                         const handleChat = (e) => {
  751.                                                 if (!d20.textchat.chatstartingup) {
  752.                                                         e.id = e.key();
  753.                                                         if (!seenMessages.has(e.id)) {
  754.                                                                 seenMessages.add(e.id);
  755.  
  756.                                                                 var t = e.val();
  757.                                                                 if (t) {
  758.                                                                         if (window.DEBUG) console.log("CHAT: ", t);
  759.  
  760.                                                                         qpi._on_chatHandlers.forEach(fn => fn(t));
  761.                                                                 }
  762.                                                         }
  763.                                                 }
  764.                                         };
  765.                                         d20.textchat.chatref.on("child_added", handleChat);
  766.                                         d20.textchat.chatref.on("child_changed", handleChat);
  767.                                 },
  768.                                 _ (evtType, fn, ...others) {
  769.                                         switch (evtType) {
  770.                                                 case "chat:message":
  771.                                                         qpi._on_chatHandlers.push(fn);
  772.                                                         break;
  773.                                                 default:
  774.                                                         console.error("Unhandled message type: ", evtType, "with args", fn, others)
  775.                                                         break;
  776.                                         }
  777.                                 },
  778.                                 works: 0.01,
  779.                                 notes: [
  780.                                         `"chat:message" is the only available event.`
  781.                                 ]
  782.                         },
  783.  
  784.                         createObj: {
  785.                                 _ (objType, obj, ...others) {
  786.                                         switch (objType) {
  787.                                                 case "path": {
  788.                                                         const page = d20.Campaign.pages._byId[obj._pageid];
  789.                                                         obj.scaleX = obj.scaleX || 1;
  790.                                                         obj.scaleY = obj.scaleY || 1;
  791.                                                         obj.path = obj.path || obj._path
  792.                                                         return page.thepaths.create(obj)
  793.                                                         break;
  794.                                                 }
  795.                                                 default:
  796.                                                         console.error("Unhandled object type: ", objType, "with args", obj, others)
  797.                                                         break;
  798.                                         }
  799.                                 },
  800.                                 works: 0.01,
  801.                                 notes: [
  802.                                         `Only supports "path" obects.`
  803.                                 ]
  804.                         },
  805.  
  806.                         sendChat: { // TODO lift code from doChatInput
  807.                                 _ (speakingAs, input, callback, options) {
  808.                                         const message = {
  809.                                                 who: speakingAs,
  810.                                                 type: "general",
  811.                                                 content: input,
  812.                                                 playerid: window.currentPlayer.id,
  813.                                                 avatar: null,
  814.                                                 inlinerolls: []
  815.                                         };
  816.  
  817.                                         const key = d20.textchat.chatref.push().key();
  818.                                         d20.textchat.chatref.child(key).setWithPriority(message, Firebase.ServerValue.TIMESTAMP)
  819.                                 },
  820.                                 works: 0.01,
  821.                                 notes: [
  822.                                         `speakingAs: String only.`,
  823.                                         `input: String only.`,
  824.                                         `callback: Unimplemented.`,
  825.                                         `options: Unimplemented.`,
  826.                                         `Messages are always sent with the player ID of the QPI user.`
  827.                                 ]
  828.                         },
  829.  
  830.                         // findObjs: {
  831.                         //      _ (attrs) {
  832.                         //              // TODO
  833.                         //              // const getters = {
  834.                         //              //      attribute: () => {},
  835.                         //              //      character: () => {},
  836.                         //              //      handout: () => {}
  837.                         //              // };
  838.                         //              // const getAll = () => {
  839.                         //              //      const out = [];
  840.                         //              //      Object.values(getters).forEach(fn => out.push(...fn()));
  841.                         //              //      return out;
  842.                         //              // };
  843.                         //
  844.                         //              // let out = attrs._type ? getters[attrs._type]() : getAll();
  845.                         //
  846.                         //              throw new Error("findObjs is unimplemented!");
  847.                         //      },
  848.                         //      works: 0.00,
  849.                         //      notes: [
  850.                         //              `Unimplemented.`
  851.                         //      ]
  852.                         // }
  853.                 },
  854.  
  855.                 _loadedScripts: null,
  856.                 async _init () {
  857.                         Object.keys(qpi._).forEach(k => {
  858.                                 const it = qpi._[k];
  859.                                 if (it._preInit) it._preInit();
  860.                                 window[k] = it._;
  861.                         });
  862.  
  863.                         qpi._loadedScripts = await StorageUtil.pGet("VeQpi") || {};
  864.  
  865.                         $(`body`).append(`
  866.                                 <div id="qpi-manager" title="QPI Script Manager - v${qpi._version}">
  867.                                         <div class="qpi-table"></div>
  868.                                         <div>
  869.                                                 <input placeholder="URL*" class="qpi-url">
  870.                                                 <button class="btn qpi-add-url">Add URL</button>
  871.                                         </div>
  872.                                         <hr>
  873.                                         <div>
  874.                                                 <input placeholder="Name*" class="qpi-name">
  875.                                                 <button class="btn qpi-add-text">Load Script</button>
  876.                                                 <br>
  877.                                                 <textarea class="qpi-text" style="width: 100%; height: 300px; resize: vertical;"></textarea>
  878.                                         </div>
  879.                                         <hr>
  880.                                         <button class="btn qpi-help">Help/README</button> <i>Note that this tool is a for-testing faceplate over some internal code. It is intended for internal use only.</i>
  881.                                 </div> 
  882.                         `);
  883.                         $(`#qpi-manager`).dialog({
  884.                                 autoOpen: false,
  885.                                 resizable: true,
  886.                                 width: 800,
  887.                                 height: 600,
  888.                         });
  889.  
  890.                         $(`body`).append(`
  891.                                 <div id="qpi-manager-readme" title="QPI README - v${qpi._version}">
  892.                                         <div class="qpi-readme"></div>
  893.                                 </div> 
  894.                         `);
  895.                         $(`#qpi-manager-readme`).dialog({
  896.                                 autoOpen: false,
  897.                                 resizable: true,
  898.                                 width: 800,
  899.                                 height: 600,
  900.                         });
  901.  
  902.                         qpi._log("Initialised!");
  903.                 },
  904.  
  905.                 man (name) {
  906.                         if (!name) {
  907.                                 qpi._log(`Showing all...\n==== Available API Mimics ====\n  - ${Object.keys(qpi._).join("()\n  - ")}()`);
  908.                                 return;
  909.                         }
  910.  
  911.                         const found = Object.keys(qpi._).find(k => k === name);
  912.                         if (!found) qpi._log(`No mimic with ${name} found -- perhaps it's unimplemented?`);
  913.                         else {
  914.                                 const it = qpi._[found];
  915.                                 qpi._log(`Showing "${name}"...\n==== ${name} :: ${it.works * 100}% functional ====\n${(it.notes || []).join("\n")}`);
  916.                         }
  917.                 },
  918.  
  919.                 _manHtml () {
  920.                         let stack = "";
  921.                         Object.keys(qpi._).forEach(k => {
  922.                                 stack += `<h5>${k}</h5>`;
  923.                                 const it = qpi._[k];
  924.                                 stack += `<p><i>Estimated ${it.works * 100}% functional</i><br>${(it.notes || []).join("<br>")}</p>`;
  925.                         });
  926.                         return stack;
  927.                 },
  928.  
  929.                 _openManager () {
  930.                         const $win = $(`#qpi-manager`);
  931.  
  932.                         $win.find(`.qpi-help`).off("click").on("click", () => {
  933.                                 const $winReadme = $(`#qpi-manager-readme`);
  934.                                 $winReadme.dialog("open");
  935.  
  936.                                 $winReadme.find(`.qpi-readme`).html(qpi._manHtml());
  937.                         });
  938.  
  939.                         $win.find(`.qpi-add-url`).off("click").on("click", () => {
  940.                                 const url = $win.find(`.qpi-url`).val();
  941.                                 if (url && script.trim()) {
  942.                                         qpi._log(`Attempting to load: "${url}"`);
  943.                                         d20plus.js.pLoadWithRetries(
  944.                                                 url,
  945.                                                 url,
  946.                                                 (data) => {
  947.                                                         d20plus.js._addScript(url, data).then(() => {
  948.                                                                 alert("Loaded successfully!");
  949.                                                                 $win.find(`.qpi-url`).val("");
  950.                                                         }).catch(() => {
  951.                                                                 alert("Failed to load script! See the console for more details (CTRL-SHIFT-J on Chrome)");
  952.                                                         });
  953.                                                 }
  954.                                         )
  955.                                 } else {
  956.                                         alert("Please enter a URL!");
  957.                                 }
  958.                         });
  959.  
  960.                         $win.find(`.qpi-add-text`).off("click").on("click", () => {
  961.                                 const name = $win.find(`.qpi-name`).val();
  962.                                 const script = $win.find(`.qpi-text`).val();
  963.                                 if (name && script && name.trim() && script.trim()) {
  964.                                         qpi._log(`Attempting to eval user script: ${name}`);
  965.                                         d20plus.js._addScript(name, script).then(() => {
  966.                                                 alert("Loaded successfully!");
  967.                                                 $win.find(`.qpi-name`).val("");
  968.                                                 $win.find(`.qpi-text`).val("");
  969.                                         }).catch(() => {
  970.                                                 alert("Failed to load script! See the console for more details (CTRL-SHIFT-J on Chrome)");
  971.                                         });
  972.                                 } else {
  973.                                         alert("Please enter a name and some code!");
  974.                                 }
  975.                         });
  976.  
  977.                         $win.dialog("open");
  978.                 },
  979.  
  980.                 _log (...args) {
  981.                         console.log("%cQPI > ", "color: #ff00ff; font-size: large", ...args);
  982.                 }
  983.         };
  984.         window.qpi = qpi;
  985.  
  986.         d20plus.qpi = {};
  987.         d20plus.qpi.pInitMockApi = async () => { // TODO check if this needs to be enabled for players too
  988.                 d20plus.ut.log("Initialising mock API");
  989.                 await qpi._init();
  990.         };
  991. }
  992.  
  993. SCRIPT_EXTENSIONS.push(baseQpi);
  994.  
  995.  
  996. // Borrowed with <3 from Stormy's JukeboxIO
  997. function baseJukebox () {
  998.         d20plus.jukebox = {
  999.                 playPlaylist (playlistId) {
  1000.                         $(document)
  1001.                                 .find(`#jukeboxfolderroot .dd-folder[data-globalfolderid="${playlistId}"]`)
  1002.                                 .find("> .dd-content .play[data-isplaying=false]")
  1003.                                 .trigger("click");
  1004.                 },
  1005.  
  1006.                 playTrack (trackId) {
  1007.                         $(document)
  1008.                                 .find(`#jukeboxfolderroot .dd-item[data-itemid="${trackId}"]`)
  1009.                                 .find("> .dd-content .play[data-isplaying=false]")
  1010.                                 .trigger("click");
  1011.                 },
  1012.  
  1013.                 stopPlaylist (playlistId) {
  1014.                         $(document)
  1015.                                 .find(`#jukeboxfolderroot .dd-folder[data-globalfolderid="${playlistId}"]`)
  1016.                                 .find("> .dd-content .play[data-isplaying=true]")
  1017.                                 .trigger("click");
  1018.                 },
  1019.  
  1020.                 stopTrack (trackId) {
  1021.                         $(document)
  1022.                                 .find(`#jukeboxfolderroot .dd-item[data-itemid="${trackId}"]`)
  1023.                                 .find("> .dd-content .play[data-isplaying=true]")
  1024.                                 .trigger("click");
  1025.                 },
  1026.  
  1027.                 play (id) {
  1028.                         d20plus.jukebox.playPlaylist(id);
  1029.                         d20plus.jukebox.playTrack(id);
  1030.                 },
  1031.  
  1032.                 stop (id) {
  1033.                         d20plus.jukebox.stopPlaylist(id);
  1034.                         d20plus.jukebox.stopTrack(id);
  1035.                 },
  1036.  
  1037.                 stopAll () {
  1038.                         d20.jukebox.stopAllTracks();
  1039.                 },
  1040.  
  1041.                 skip () {
  1042.                         const playlistId = d20plus.jukebox.getCurrentPlayingPlaylist();
  1043.                         d20.jukebox.stopAllTracks();
  1044.                         d20plus.jukebox.playPlaylist(playlistId);
  1045.                 },
  1046.  
  1047.                 getCurrentPlayingTracks () {
  1048.                         let playlingTracks = [];
  1049.                         window.Jukebox.playlist.each((track) => {
  1050.                                 if (track.get("playing")) {
  1051.                                         playlingTracks.push(track.attributes);
  1052.                                 }
  1053.                         });
  1054.                         return playlingTracks;
  1055.                 },
  1056.  
  1057.                 getCurrentPlayingPlaylist () {
  1058.                         const id = d20.Campaign.attributes.jukeboxplaylistplaying;
  1059.                         return id ? id.split("|")[0] : id;
  1060.                 },
  1061.  
  1062.                 addJukeboxChangeHandler (func) {
  1063.                         d20plus.jukebox.addPlaylistChangeHandler(func);
  1064.                         d20plus.jukebox.addTrackChangeHandler(func);
  1065.                 },
  1066.  
  1067.                 addPlaylistChangeHandler (func) {
  1068.                         d20.Campaign.on("change:jukeboxplaylistplaying change:jukeboxfolder", func);
  1069.                 },
  1070.  
  1071.                 addTrackChangeHandler (func) {
  1072.                         window.Jukebox.playlist.each((track) => {
  1073.                                 track.on("change:playing", func);
  1074.                         });
  1075.                 },
  1076.  
  1077.                 getJukeboxFileStructure () {
  1078.                         d20plus.jukebox.forceJukeboxRefresh();
  1079.                         return window.d20.jukebox.lastFolderStructure;
  1080.                 },
  1081.  
  1082.                 getTrackById (id) {
  1083.                         return window.Jukebox.playlist.get(id);
  1084.                 },
  1085.  
  1086.                 getJukeboxPlaylists () {
  1087.                         const fs = d20plus.jukebox.getJukeboxFileStructure();
  1088.                         const retVals = [];
  1089.  
  1090.                         for (const fsItem of fs) {
  1091.                                 if (typeof (fsItem) === "string") continue;
  1092.  
  1093.                                 const rawPlaylist = fsItem;
  1094.  
  1095.                                 const playlist = {
  1096.                                         name: rawPlaylist.n,
  1097.                                         mode: rawPlaylist.s,
  1098.                                         tracks: [],
  1099.                                 };
  1100.  
  1101.                                 for (const trackId of rawPlaylist.i) {
  1102.                                         const track = d20plus.jukebox.getTrackById(trackId);
  1103.                                         if (!track) {
  1104.                                                 console.warn(`Tried to get track id ${trackId} but the query returned a falsy value. Skipping`);
  1105.                                                 continue;
  1106.                                         }
  1107.  
  1108.                                         playlist.tracks.push(track);
  1109.                                 }
  1110.  
  1111.                                 retVals.push(playlist);
  1112.                         }
  1113.  
  1114.                         return retVals;
  1115.                 },
  1116.  
  1117.                 getJukeboxTracks () {
  1118.                         const fs = d20plus.jukebox.getJukeboxFileStructure();
  1119.  
  1120.                         const retVals = [];
  1121.  
  1122.                         for (const fsItem of fs) {
  1123.                                 if (typeof (fsItem) !== "string") continue;
  1124.  
  1125.                                 const track = d20plus.jukebox.getTrackById(fsItem);
  1126.                                 if (!track) {
  1127.                                         console.warn(`Tried to get track id ${fsItem} but the query returned a falsy value. Skipping`);
  1128.                                         continue;
  1129.                                 }
  1130.  
  1131.                                 retVals.push(track);
  1132.                         }
  1133.  
  1134.                         return retVals;
  1135.                 },
  1136.  
  1137.                 _getExportableTrack (s) {
  1138.                         return {
  1139.                                 loop: s.attributes.loop,
  1140.                                 playing: s.attributes.playing,
  1141.                                 softstop: s.attributes.softstop,
  1142.                                 source: s.attributes.source,
  1143.                                 tags: s.attributes.tags,
  1144.                                 title: s.attributes.title,
  1145.                                 track_id: s.attributes.track_id,
  1146.                                 volume: s.attributes.volume,
  1147.                         };
  1148.                 },
  1149.  
  1150.                 getExportablePlaylists () {
  1151.                         return d20plus.jukebox.getJukeboxPlaylists().map(p => {
  1152.                                 return {
  1153.                                         name: p.name,
  1154.                                         mode: p.mode,
  1155.                                         tracks: p.tracks.map(d20plus.jukebox._getExportableTrack),
  1156.                                 };
  1157.                         });
  1158.                 },
  1159.  
  1160.                 getExportableTracks () {
  1161.                         return d20plus.jukebox.getJukeboxTracks().map(d20plus.jukebox._getExportableTrack);
  1162.                 },
  1163.  
  1164.                 importWrappedData (data) {
  1165.                         d20plus.jukebox.forceJukeboxRefresh();
  1166.  
  1167.                         const tracks = (data.tracks || []).map(t => d20plus.jukebox.createTrack(t).id);
  1168.  
  1169.                         const playlists = (data.playlists || []).map(p => {
  1170.                                 const trackIds = p.tracks.map(s => d20plus.jukebox.createTrack(s).id);
  1171.                                 return d20plus.jukebox.makePlaylistStructure(p.name, p.mode, trackIds);
  1172.                         });
  1173.  
  1174.                         let fs = JSON.parse(d20.Campaign.attributes.jukeboxfolder);
  1175.                         fs = fs.concat(tracks, playlists);
  1176.  
  1177.                         d20.Campaign.save({
  1178.                                 jukeboxfolder: JSON.stringify(fs)
  1179.                         });
  1180.                 },
  1181.  
  1182.                 createTrack (data) {
  1183.                         return window.Jukebox.playlist.create(data);
  1184.                 },
  1185.  
  1186.                 makePlaylistStructure (name, mode, trackIds) {
  1187.                         return {
  1188.                                 id: window.generateUUID(),
  1189.                                 n: name,
  1190.                                 s: mode,
  1191.                                 i: trackIds || []
  1192.                         };
  1193.                 },
  1194.  
  1195.                 forceJukeboxRefresh () {
  1196.                         const $jukebox = $("#jukebox");
  1197.                         const serializable = $jukebox.find("#jukeboxfolderroot").nestable("serialize");
  1198.                         serializable && d20.Campaign.save({
  1199.                                 jukeboxfolder: JSON.stringify(serializable)
  1200.                         });
  1201.                 }
  1202.         };
  1203. }
  1204.  
  1205. SCRIPT_EXTENSIONS.push(baseJukebox);
  1206.  
  1207.  
  1208. function baseMath () {
  1209.         d20plus.math = {
  1210.                 vec2: {
  1211.                         /**
  1212.                          * Normalize a 2d vector.
  1213.                          * @param out Result storage
  1214.                          * @param a Vector to normalise
  1215.                          */
  1216.                         normalize (out, a) {
  1217.                                 const x = a[0],
  1218.                                         y = a[1];
  1219.                                 let len = x*x + y*y;
  1220.                                 if (len > 0) {
  1221.                                         len = 1 / Math.sqrt(len);
  1222.                                         out[0] = a[0] * len;
  1223.                                         out[1] = a[1] * len;
  1224.                                 }
  1225.                                 return out;
  1226.                         },
  1227.  
  1228.                         /**
  1229.                          * Scale a 2d vector.
  1230.                          * @param out Resulst storage
  1231.                          * @param a Vector to scale
  1232.                          * @param b Value to scale by
  1233.                          */
  1234.                         scale (out, a, b) {
  1235.                                 out[0] = a[0] * b;
  1236.                                 out[1] = a[1] * b;
  1237.                                 return out;
  1238.                         },
  1239.  
  1240.                         /**
  1241.                          * Rotate a 2D vector
  1242.                          * @param {vec2} out The receiving vec2
  1243.                          * @param {vec2} a The vec2 point to rotate
  1244.                          * @param {vec2} b The origin of the rotation
  1245.                          * @param {Number} c The angle of rotation
  1246.                          * @returns {vec2} out
  1247.                          */
  1248.                         rotate (out, a, b, c) {
  1249.                                 //Translate point to the origin
  1250.                                 let p0 = a[0] - b[0],
  1251.                                         p1 = a[1] - b[1],
  1252.                                         sinC = Math.sin(c),
  1253.                                         cosC = Math.cos(c);
  1254.  
  1255.                                 //perform rotation and translate to correct position
  1256.                                 out[0] = p0*cosC - p1*sinC + b[0];
  1257.                                 out[1] = p0*sinC + p1*cosC + b[1];
  1258.                                 return out;
  1259.                         },
  1260.  
  1261.                         /**
  1262.                          * Adds two vec2's
  1263.                          *
  1264.                          * @param {vec2} out the receiving vector
  1265.                          * @param {vec2} a the first operand
  1266.                          * @param {vec2} b the second operand
  1267.                          * @returns {vec2} out
  1268.                          */
  1269.                         add (out, a, b) {
  1270.                                 out[0] = a[0] + b[0];
  1271.                                 out[1] = a[1] + b[1];
  1272.                                 return out;
  1273.                         },
  1274.  
  1275.                         /**
  1276.                          * Subtracts vector b from vector a
  1277.                          *
  1278.                          * @param {vec2} out the receiving vector
  1279.                          * @param {vec2} a the first operand
  1280.                          * @param {vec2} b the second operand
  1281.                          * @returns {vec2} out
  1282.                          */
  1283.                         sub (out, a, b) {
  1284.                                 out[0] = a[0] - b[0];
  1285.                                 out[1] = a[1] - b[1];
  1286.                                 return out;
  1287.                         },
  1288.  
  1289.                         /**
  1290.                          * Computes the cross product of two vec2's
  1291.                          * Note that the cross product must by definition produce a 3D vector
  1292.                          *
  1293.                          * @param {vec3} out the receiving vector
  1294.                          * @param {vec2} a the first operand
  1295.                          * @param {vec2} b the second operand
  1296.                          * @returns {vec3} out
  1297.                          */
  1298.                         cross (out, a, b) {
  1299.                                 let z = a[0] * b[1] - a[1] * b[0];
  1300.                                 out[0] = out[1] = 0;
  1301.                                 out[2] = z;
  1302.                                 return out;
  1303.                         },
  1304.  
  1305.                         /**
  1306.                          * Multiplies two vec2's
  1307.                          *
  1308.                          * @param {vec2} out the receiving vector
  1309.                          * @param {vec2} a the first operand
  1310.                          * @param {vec2} b the second operand
  1311.                          * @returns {vec2} out
  1312.                          */
  1313.                         mult (out, a, b) {
  1314.                                 out[0] = a[0] * b[0];
  1315.                                 out[1] = a[1] * b[1];
  1316.                                 return out;
  1317.                         },
  1318.  
  1319.                         /**
  1320.                          * Calculates the length of a vec2
  1321.                          *
  1322.                          * @param {vec2} a vector to calculate length of
  1323.                          * @returns {Number} length of a
  1324.                          */
  1325.                         len (a) {
  1326.                                 const x = a[0], y = a[1];
  1327.                                 return Math.sqrt(x * x + y * y);
  1328.                         }
  1329.                 },
  1330.  
  1331.                 /**
  1332.                  * Helper function to determine whether there is an intersection between the two polygons described
  1333.                  * by the lists of vertices. Uses the Separating Axis Theorem
  1334.                  *
  1335.                  * @param a an array of connected points [[x, y], [x, y],...] that form a closed polygon
  1336.                  * @param b an array of connected points [[x, y], [x, y],...] that form a closed polygon
  1337.                  * @return boolean true if there is any intersection between the 2 polygons, false otherwise
  1338.                  */
  1339.                 doPolygonsIntersect (a, b) {
  1340.                         const polygons = [a, b];
  1341.                         let minA, maxA, projected, i, i1, j, minB, maxB;
  1342.  
  1343.                         for (i = 0; i < polygons.length; i++) {
  1344.                                 // for each polygon, look at each edge of the polygon, and determine if it separates
  1345.                                 // the two shapes
  1346.                                 const polygon = polygons[i];
  1347.                                 for (i1 = 0; i1 < polygon.length; i1++) {
  1348.                                         // grab 2 vertices to create an edge
  1349.                                         const i2 = (i1 + 1) % polygon.length;
  1350.                                         const p1 = polygon[i1];
  1351.                                         const p2 = polygon[i2];
  1352.  
  1353.                                         // find the line perpendicular to this edge
  1354.                                         const normal = [p2[1] - p1[1], p1[0] - p2[0]];
  1355.  
  1356.                                         minA = maxA = undefined;
  1357.                                         // for each vertex in the first shape, project it onto the line perpendicular to the edge
  1358.                                         // and keep track of the min and max of these values
  1359.                                         for (j = 0; j < a.length; j++) {
  1360.                                                 projected = normal[0] * a[j][0] + normal[1] * a[j][1];
  1361.                                                 if (minA === undefined || projected < minA) minA = projected;
  1362.                                                 if (maxA === undefined || projected > maxA) maxA = projected;
  1363.                                         }
  1364.  
  1365.                                         // for each vertex in the second shape, project it onto the line perpendicular to the edge
  1366.                                         // and keep track of the min and max of these values
  1367.                                         minB = maxB = undefined;
  1368.                                         for (j = 0; j < b.length; j++) {
  1369.                                                 projected = normal[0] * b[j][0] + normal[1] * b[j][1];
  1370.                                                 if (minB === undefined || projected < minB) minB = projected;
  1371.                                                 if (maxB === undefined || projected > maxB) maxB = projected;
  1372.                                         }
  1373.  
  1374.                                         // if there is no overlap between the projects, the edge we are looking at separates the two
  1375.                                         // polygons, and we know there is no overlap
  1376.                                         if (maxA < minB || maxB < minA) {
  1377.                                                 return false;
  1378.                                         }
  1379.                                 }
  1380.                         }
  1381.                         return true;
  1382.                 }
  1383.         };
  1384. }
  1385.  
  1386. SCRIPT_EXTENSIONS.push(baseMath);
  1387.  
  1388.  
  1389. function baseConfig() {
  1390.         d20plus.cfg = {current: {}};
  1391.  
  1392.         d20plus.cfg.pLoadConfigFailed = false;
  1393.  
  1394.         d20plus.cfg.pLoadConfig = async () => {
  1395.                 d20plus.ut.log("Reading Config");
  1396.                 let configHandout = d20plus.cfg.getConfigHandout();
  1397.  
  1398.                 if (!configHandout) {
  1399.                         d20plus.ut.log("No config found! Initialising new config...");
  1400.                         await d20plus.cfg.pMakeDefaultConfig();
  1401.                 }
  1402.  
  1403.                 configHandout = d20plus.cfg.getConfigHandout();
  1404.                 if (configHandout) {
  1405.                         configHandout.view.render();
  1406.                         return new Promise(resolve => {
  1407.                                 configHandout._getLatestBlob("gmnotes", async function (gmnotes) {
  1408.                                         try {
  1409.                                                 const decoded = decodeURIComponent(gmnotes);
  1410.  
  1411.                                                 d20plus.cfg.current = JSON.parse(decoded);
  1412.  
  1413.                                                 d20plus.ut.log("Config Loaded:");
  1414.                                                 d20plus.ut.log(d20plus.cfg.current);
  1415.                                                 resolve();
  1416.                                         } catch (e) {
  1417.                                                 console.error(e);
  1418.                                                 if (!d20plus.cfg.pLoadConfigFailed) {
  1419.                                                         // prevent infinite loops
  1420.                                                         d20plus.cfg.pLoadConfigFailed = true;
  1421.  
  1422.                                                         d20plus.ut.log("Corrupted config! Rebuilding...");
  1423.                                                         await d20plus.cfg.pMakeDefaultConfig();
  1424.                                                         await d20plus.cfg.pLoadConfig();
  1425.                                                         resolve();
  1426.                                                 } else {
  1427.                                                         // if the config fails, continue to load anyway
  1428.                                                         resolve();
  1429.                                                 }
  1430.                                         }
  1431.                                 });
  1432.                         });
  1433.                 } else d20plus.ut.log("Failed to create config handout!");
  1434.         };
  1435.  
  1436.         d20plus.cfg.pLoadPlayerConfig = async () => {
  1437.                 d20plus.ut.log("Reading player Config");
  1438.                 const loaded = await StorageUtil.pGet(`Veconfig`);
  1439.                 if (!loaded) {
  1440.                         d20plus.ut.log("No player config found! Initialising new config...");
  1441.                         const dfltConfig = d20plus.cfg.getDefaultConfig();
  1442.                         d20plus.cfg.current = Object.assign(d20plus.cfg.current, dfltConfig);
  1443.                         await StorageUtil.pSet(`Veconfig`, d20plus.cfg.current);
  1444.                 } else {
  1445.                         d20plus.cfg.current = loaded;
  1446.                 }
  1447.                 d20plus.ut.log("Player config Loaded:");
  1448.                 d20plus.ut.log(d20plus.cfg.current);
  1449.         };
  1450.  
  1451.         d20plus.cfg.pMakeDefaultConfig = () => {
  1452.                 return new Promise(resolve => {
  1453.                         d20.Campaign.handouts.create({
  1454.                                 name: CONFIG_HANDOUT,
  1455.                                 archived: true
  1456.                         }, {
  1457.                                 success: function (handout) {
  1458.                                         notecontents = "The GM notes contain config options saved between sessions. If you want to wipe your saved settings, delete this handout and reload roll20. If you want to edit your settings, click the \"Edit Config\" button in the <b>Settings</b> (cog) panel.";
  1459.  
  1460.                                         // default settings
  1461.                                         // token settings mimic official content; other settings as vanilla as possible
  1462.                                         const gmnotes = JSON.stringify(d20plus.cfg.getDefaultConfig());
  1463.  
  1464.                                         handout.updateBlobs({notes: notecontents, gmnotes: gmnotes});
  1465.                                         handout.save({notes: (new Date).getTime(), inplayerjournals: ""});
  1466.  
  1467.                                         resolve();
  1468.                                 }
  1469.                         });
  1470.                 });
  1471.         };
  1472.  
  1473.         d20plus.cfg.getConfigHandout = () => {
  1474.                 d20plus.ut.getJournalFolderObj(); // ensure journal init
  1475.  
  1476.                 return d20.Campaign.handouts.models.find(function (handout) {
  1477.                         return handout.attributes.name === CONFIG_HANDOUT;
  1478.                 });
  1479.         };
  1480.  
  1481.         d20plus.cfg.getCfgKey = (group, val) => {
  1482.                 if (val === undefined || d20plus.cfg.current[group] === undefined) return undefined;
  1483.                 const gr = d20plus.cfg.current[group];
  1484.                 for (const key of Object.keys(d20plus.cfg.current[group])) {
  1485.                         if (gr[key] !== undefined && gr[key] === val) {
  1486.                                 return key;
  1487.                         }
  1488.                 }
  1489.                 return undefined;
  1490.         };
  1491.  
  1492.         d20plus.cfg.getRawCfgVal = (group, key) => {
  1493.                 if (d20plus.cfg.current[group] === undefined) return undefined;
  1494.                 if (d20plus.cfg.current[group][key] === undefined) return undefined;
  1495.                 return d20plus.cfg.current[group][key];
  1496.         };
  1497.  
  1498.         d20plus.cfg.get = (group, key) => {
  1499.                 if (d20plus.cfg.current[group] === undefined) return undefined;
  1500.                 if (d20plus.cfg.current[group][key] === undefined) return undefined;
  1501.                 if (CONFIG_OPTIONS[group][key]._type === "_SHEET_ATTRIBUTE") {
  1502.                         if (!NPC_SHEET_ATTRIBUTES[d20plus.cfg.current[group][key]]) return undefined;
  1503.                         return NPC_SHEET_ATTRIBUTES[d20plus.cfg.current[group][key]][d20plus.sheet];
  1504.                 }
  1505.                 if (CONFIG_OPTIONS[group][key]._type === "_SHEET_ATTRIBUTE_PC") {
  1506.                         if (!PC_SHEET_ATTRIBUTES[d20plus.cfg.current[group][key]]) return undefined;
  1507.                         return PC_SHEET_ATTRIBUTES[d20plus.cfg.current[group][key]][d20plus.sheet];
  1508.                 }
  1509.                 return d20plus.cfg.current[group][key];
  1510.         };
  1511.  
  1512.         d20plus.cfg.getDefault = (group, key) => {
  1513.                 return d20plus.cfg._getProp("default", group, key);
  1514.         };
  1515.  
  1516.         d20plus.cfg.getPlaceholder = (group, key) => {
  1517.                 return d20plus.cfg._getProp("_placeholder", group, key);
  1518.         };
  1519.  
  1520.         d20plus.cfg._getProp = (prop, group, key) => {
  1521.                 if (CONFIG_OPTIONS[group] === undefined) return undefined;
  1522.                 if (CONFIG_OPTIONS[group][key] === undefined) return undefined;
  1523.                 return CONFIG_OPTIONS[group][key][prop];
  1524.         };
  1525.  
  1526.         d20plus.cfg.getOrDefault = (group, key) => {
  1527.                 if (d20plus.cfg.has(group, key)) return d20plus.cfg.get(group, key);
  1528.                 return d20plus.cfg.getDefault(group, key);
  1529.         };
  1530.  
  1531.         d20plus.cfg.getCfgEnumVals = (group, key) => {
  1532.                 if (CONFIG_OPTIONS[group] === undefined) return undefined;
  1533.                 if (CONFIG_OPTIONS[group][key] === undefined) return undefined;
  1534.                 return CONFIG_OPTIONS[group][key].__values
  1535.         };
  1536.  
  1537.         d20plus.cfg.getCfgSliderVals = (group, key) => {
  1538.                 if (CONFIG_OPTIONS[group] === undefined) return undefined;
  1539.                 if (CONFIG_OPTIONS[group][key] === undefined) return undefined;
  1540.                 const it = CONFIG_OPTIONS[group][key];
  1541.                 return {
  1542.                         min: it.__sliderMin,
  1543.                         max: it.__sliderMax,
  1544.                         step: it.__sliderStep
  1545.                 }
  1546.         };
  1547.  
  1548.         d20plus.cfg.getDefaultConfig = () => {
  1549.                 const outCpy = {};
  1550.                 $.each(CONFIG_OPTIONS, (sectK, sect) => {
  1551.                         if (window.is_gm || sect._player) {
  1552.                                 outCpy[sectK] = outCpy[sectK] || {};
  1553.                                 $.each(sect, (k, data) => {
  1554.                                         if (!k.startsWith("_") && (window.is_gm || data._player)) {
  1555.                                                 outCpy[sectK][k] = data.default;
  1556.                                         }
  1557.                                 });
  1558.                         }
  1559.                 });
  1560.                 return outCpy;
  1561.         };
  1562.  
  1563.         // Helpful for checking if a boolean option is set even if false
  1564.         d20plus.cfg.has = (group, key) => {
  1565.                 if (d20plus.cfg.current[group] === undefined) return false;
  1566.                 return d20plus.cfg.current[group][key] !== undefined;
  1567.         };
  1568.  
  1569.         d20plus.cfg.setCfgVal = (group, key, val) => {
  1570.                 if (d20plus.cfg.current[group] === undefined) d20plus.cfg.current[group] = {};
  1571.                 d20plus.cfg.current[group][key] = val;
  1572.         };
  1573.  
  1574.         d20plus.cfg.makeTabPane = ($addTo, headers, content) => {
  1575.                 if (headers.length !== content.length) throw new Error("Tab header and content length were not equal!");
  1576.  
  1577.                 if ($addTo.attr("hastabs") !== "YES") {
  1578.                         const $tabBar = $(`<ul class="nav nav-tabs"/>`);
  1579.  
  1580.                         const tabList = [];
  1581.                         const paneList = [];
  1582.                         const $tabPanes = $(`<div class="tabcontent"/>`);
  1583.  
  1584.                         $.each(content, (i, e) => {
  1585.                                 const toAdd = $(`<div class="plustab${i} tab-pane" ${i === 0 ? "" : `style="display: none"`}/>`);
  1586.                                 toAdd.append(e);
  1587.                                 paneList[i] = toAdd;
  1588.                                 $tabPanes.append(toAdd);
  1589.                         });
  1590.  
  1591.                         $.each(headers, (i, e) => {
  1592.                                 const toAdd = $(`<li ${i === 0 ? `class="active"` : ""}><a data-tab="plustab${i}" href="#">${e}</a></li>`).on("click", () => {
  1593.                                         paneList.forEach((p, i2) => {
  1594.                                                 if (i2 === i) {
  1595.                                                         tabList[i2].addClass("active");
  1596.                                                         paneList[i2].show();
  1597.                                                 } else {
  1598.                                                         tabList[i2].removeClass("active");
  1599.                                                         paneList[i2].hide();
  1600.                                                 }
  1601.                                         });
  1602.                                 });
  1603.                                 tabList[i] = (toAdd);
  1604.                                 $tabBar.append(toAdd);
  1605.                         });
  1606.  
  1607.                         $addTo
  1608.                                 .append($tabBar)
  1609.                                 .append($tabPanes);
  1610.  
  1611.                         $addTo.attr("hastabs", "YES");
  1612.                 }
  1613.         };
  1614.  
  1615.         d20plus.cfg.openConfigEditor = () => {
  1616.                 const cEdit = $("#d20plus-configeditor");
  1617.                 cEdit.dialog("open");
  1618.  
  1619.                 if (cEdit.attr("hastabs") !== "YES") {
  1620.                         cEdit.attr("hastabs", "YES");
  1621.                         const appendTo = $(`<div/>`);
  1622.                         cEdit.prepend(appendTo);
  1623.  
  1624.                         const configFields = {};
  1625.  
  1626.                         let sortedKeys = Object.keys(CONFIG_OPTIONS).sort((a, b) => d20plus.ut.ascSort(CONFIG_OPTIONS[a]._name, CONFIG_OPTIONS[b]._name));
  1627.                         if (!window.is_gm) sortedKeys = sortedKeys.filter(k => CONFIG_OPTIONS[k]._player);
  1628.  
  1629.                         const tabList = sortedKeys.map(k => CONFIG_OPTIONS[k]._name);
  1630.                         const contentList = sortedKeys.map(k => makeTab(k));
  1631.  
  1632.                         function makeTab (cfgK) {
  1633.                                 const cfgGroup = CONFIG_OPTIONS[cfgK];
  1634.                                 configFields[cfgK] = {};
  1635.  
  1636.                                 const content = $(`
  1637.                                                 <div class="config-table-wrapper">
  1638.                                                         <table class="config-table">
  1639.                                                                 <thead><tr><th>Property</th><th>Value</th></tr></thead>
  1640.                                                                 <tbody></tbody>
  1641.                                                         </table>
  1642.                                                 </div>
  1643.                                         `);
  1644.                                 const tbody = content.find(`tbody`);
  1645.  
  1646.                                 let sortedTabKeys = Object.keys(cfgGroup).filter(k => !k.startsWith("_"));
  1647.                                 if (!window.is_gm) sortedTabKeys = sortedTabKeys.filter(k => cfgGroup[k]._player);
  1648.  
  1649.                                 sortedTabKeys.forEach((grpK, idx) => {
  1650.                                         const prop = cfgGroup[grpK];
  1651.  
  1652.                                         // IDs only used for label linking
  1653.                                         const toAdd = $(`<tr><td><label for="conf_field_${idx}" class="config-name">${prop.name}</label></td></tr>`);
  1654.  
  1655.                                         // Each config `_type` should have a case here. Each case should add a function to the map [configFields:[cfgK:grpK]]. These functions should return the value of the input.
  1656.                                         switch (prop._type) {
  1657.                                                 case "boolean": {
  1658.                                                         const field = $(`<input type="checkbox" id="conf_field_${idx}" ${d20plus.cfg.getOrDefault(cfgK, grpK) ? `checked` : ""}>`);
  1659.  
  1660.                                                         configFields[cfgK][grpK] = () => {
  1661.                                                                 return field.prop("checked")
  1662.                                                         };
  1663.  
  1664.                                                         const td = $(`<td/>`).append(field);
  1665.                                                         toAdd.append(td);
  1666.                                                         break;
  1667.                                                 }
  1668.                                                 case "String": {
  1669.                                                         const curr = d20plus.cfg.get(cfgK, grpK) || "";
  1670.                                                         const placeholder = d20plus.cfg.getPlaceholder(cfgK, grpK);
  1671.                                                         const def = d20plus.cfg.getDefault(cfgK, grpK) || "";
  1672.                                                         const field = $(`<input id="conf_field_${idx}" value="${curr}" ${placeholder ? `placeholder="${placeholder}"` : def ? `placeholder="Default: ${def}"` : ""}>`);
  1673.  
  1674.                                                         configFields[cfgK][grpK] = () => {
  1675.                                                                 return field.val() ? field.val().trim() : "";
  1676.                                                         };
  1677.  
  1678.                                                         const td = $(`<td/>`).append(field);
  1679.                                                         toAdd.append(td);
  1680.                                                         break;
  1681.                                                 }
  1682.                                                 case "_SHEET_ATTRIBUTE_PC":
  1683.                                                 case "_SHEET_ATTRIBUTE": {
  1684.                                                         const DICT = prop._type === "_SHEET_ATTRIBUTE" ? NPC_SHEET_ATTRIBUTES : PC_SHEET_ATTRIBUTES;
  1685.                                                         const sortedNpcsAttKeys = Object.keys(DICT).sort((at1, at2) => d20plus.ut.ascSort(DICT[at1].name, DICT[at2].name));
  1686.                                                         const field = $(`<select id="conf_field_${idx}" class="cfg_grp_${cfgK}" data-item="${grpK}">${sortedNpcsAttKeys.map(npcK => `<option value="${npcK}">${DICT[npcK].name}</option>`)}</select>`);
  1687.                                                         const cur = d20plus.cfg.get(cfgK, grpK);
  1688.                                                         if (cur !== undefined) {
  1689.                                                                 field.val(cur);
  1690.                                                         }
  1691.  
  1692.                                                         configFields[cfgK][grpK] = () => {
  1693.                                                                 return field.val()
  1694.                                                         };
  1695.  
  1696.                                                         const td = $(`<td/>`).append(field);
  1697.                                                         toAdd.append(td);
  1698.                                                         break;
  1699.                                                 }
  1700.                                                 case "float":
  1701.                                                 case "integer": {
  1702.                                                         const def = d20plus.cfg.getDefault(cfgK, grpK);
  1703.                                                         const curr = d20plus.cfg.get(cfgK, grpK);
  1704.                                                         const field = $(`<input id="conf_field_${idx}" type="number" ${curr != null ? `value="${curr}"` : ""} ${def != null ? `placeholder="Default: ${def}"` : ""} step="any">`);
  1705.  
  1706.                                                         configFields[cfgK][grpK] = () => {
  1707.                                                                 return Number(field.val());
  1708.                                                         };
  1709.  
  1710.                                                         const td = $(`<td/>`).append(field);
  1711.                                                         toAdd.append(td);
  1712.                                                         break;
  1713.                                                 }
  1714.                                                 case "_FORMULA": {
  1715.                                                         const $field = $(`<select id="conf_field_${idx}" class="cfg_grp_${cfgK}" data-item="${grpK}">${d20plus.formulas._options.sort().map(opt => `<option value="${opt}">${opt}</option>`)}</select>`);
  1716.  
  1717.                                                         const cur = d20plus.cfg.get(cfgK, grpK);
  1718.                                                         if (cur !== undefined) {
  1719.                                                                 $field.val(cur);
  1720.                                                         }
  1721.  
  1722.                                                         configFields[cfgK][grpK] = () => {
  1723.                                                                 return $field.val();
  1724.                                                         };
  1725.  
  1726.                                                         const td = $(`<td/>`).append($field);
  1727.                                                         toAdd.append(td);
  1728.                                                         break;
  1729.                                                 }
  1730.                                                 case "_WHISPERMODE": {
  1731.                                                         const $field = $(`<select id="conf_field_${idx}" class="cfg_grp_${cfgK}" data-item="${grpK}">${d20plus.whisperModes.map(mode => `<option value="${mode}">${mode}</option>`)}</select>`);
  1732.  
  1733.                                                         const cur = d20plus.cfg.get(cfgK, grpK);
  1734.                                                         if (cur !== undefined) {
  1735.                                                                 $field.val(cur);
  1736.                                                         }
  1737.  
  1738.                                                         configFields[cfgK][grpK] = () => {
  1739.                                                                 return $field.val();
  1740.                                                         };
  1741.  
  1742.                                                         const td = $(`<td/>`).append($field);
  1743.                                                         toAdd.append(td);
  1744.                                                         break;
  1745.                                                 }
  1746.                                                 case "_ADVANTAGEMODE": {
  1747.                                                         const $field = $(`<select id="conf_field_${idx}" class="cfg_grp_${cfgK}" data-item="${grpK}">${d20plus.advantageModes.map(mode => `<option value="${mode}">${mode}</option>`)}</select>`);
  1748.  
  1749.                                                         const cur = d20plus.cfg.get(cfgK, grpK);
  1750.                                                         if (cur !== undefined) {
  1751.                                                                 $field.val(cur);
  1752.                                                         }
  1753.  
  1754.                                                         configFields[cfgK][grpK] = () => {
  1755.                                                                 return $field.val();
  1756.                                                         };
  1757.  
  1758.                                                         const td = $(`<td/>`).append($field);
  1759.                                                         toAdd.append(td);
  1760.                                                         break;
  1761.                                                 }
  1762.                                                 case "_DAMAGEMODE": {
  1763.                                                         const $field = $(`<select id="conf_field_${idx}" class="cfg_grp_${cfgK}" data-item="${grpK}">${d20plus.damageModes.map(mode => `<option value="${mode}">${mode}</option>`)}</select>`);
  1764.  
  1765.                                                         const cur = d20plus.cfg.get(cfgK, grpK);
  1766.                                                         if (cur !== undefined) {
  1767.                                                                 $field.val(cur);
  1768.                                                         }
  1769.  
  1770.                                                         configFields[cfgK][grpK] = () => {
  1771.                                                                 return $field.val();
  1772.                                                         };
  1773.  
  1774.                                                         const td = $(`<td/>`).append($field);
  1775.                                                         toAdd.append(td);
  1776.                                                         break;
  1777.                                                 }
  1778.                                                 case "_enum": { // for generic String enums not covered above
  1779.                                                         const $field = $(`<select id="conf_field_${idx}" class="cfg_grp_${cfgK}" data-item="${grpK}">${d20plus.cfg.getCfgEnumVals(cfgK, grpK).map(it => `<option value="${it}">${it}</option>`)}</select>`);
  1780.  
  1781.                                                         const cur = d20plus.cfg.get(cfgK, grpK);
  1782.                                                         if (cur !== undefined) {
  1783.                                                                 $field.val(cur);
  1784.                                                         } else {
  1785.                                                                 const def = d20plus.cfg.getDefault(cfgK, grpK);
  1786.                                                                 if (def !== undefined) {
  1787.                                                                         $field.val(def);
  1788.                                                                 }
  1789.                                                         }
  1790.  
  1791.                                                         configFields[cfgK][grpK] = () => {
  1792.                                                                 return $field.val();
  1793.                                                         };
  1794.  
  1795.                                                         const td = $(`<td/>`).append($field);
  1796.                                                         toAdd.append(td);
  1797.                                                         break;
  1798.                                                 }
  1799.                                                 case "_slider": {
  1800.                                                         const def = d20plus.cfg.getDefault(cfgK, grpK);
  1801.                                                         const curr = d20plus.cfg.get(cfgK, grpK);
  1802.                                                         const sliderMeta = d20plus.cfg.getCfgSliderVals(cfgK, grpK);
  1803.  
  1804.                                                         const field = $(`<input style="max-width: calc(100% - 40px);" type="range" min="${sliderMeta.min || 0}" max="${sliderMeta.max || 0}" step="${sliderMeta.step || 1}" value="${curr == null ? def : curr}">`);
  1805.  
  1806.                                                         configFields[cfgK][grpK] = () => {
  1807.                                                                 return Number(field.val());
  1808.                                                         };
  1809.  
  1810.                                                         const td = $(`<td/>`).append(field);
  1811.                                                         toAdd.append(td);
  1812.                                                         break;
  1813.                                                 }
  1814.                                                 case "_color": {
  1815.                                                         const value = d20plus.cfg.getOrDefault(cfgK, grpK);
  1816.  
  1817.                                                         const field = $(`<input type="color" value="${value == null ? "" : value}">`);
  1818.  
  1819.                                                         configFields[cfgK][grpK] = () => {
  1820.                                                                 return field.val();
  1821.                                                         };
  1822.  
  1823.                                                         const td = $(`<td/>`).append(field);
  1824.                                                         toAdd.append(td);
  1825.                                                         break;
  1826.                                                 }
  1827.                                         }
  1828.                                         tbody.append(toAdd);
  1829.                                 });
  1830.  
  1831.                                 return content;
  1832.                         }
  1833.  
  1834.                         d20plus.cfg.makeTabPane(
  1835.                                 appendTo,
  1836.                                 tabList,
  1837.                                 contentList
  1838.                         );
  1839.  
  1840.                         const saveButton = $(`#configsave`);
  1841.                         saveButton.unbind("click");
  1842.                         saveButton.bind("click", () => {
  1843.                                 function _updateLoadedConfig () {
  1844.                                         $.each(configFields, (cfgK, grp) => {
  1845.                                                 $.each(grp, (grpK, grpVField) => {
  1846.                                                         d20plus.cfg.setCfgVal(cfgK, grpK, grpVField());
  1847.                                                 })
  1848.                                         });
  1849.                                 }
  1850.  
  1851.                                 if (window.is_gm) {
  1852.                                         let handout = d20plus.cfg.getConfigHandout();
  1853.                                         if (!handout) {
  1854.                                                 d20plus.cfg.pMakeDefaultConfig(doSave);
  1855.                                         } else {
  1856.                                                 doSave();
  1857.                                         }
  1858.  
  1859.                                         function doSave () {
  1860.                                                 _updateLoadedConfig();
  1861.  
  1862.                                                 const gmnotes = JSON.stringify(d20plus.cfg.current).replace(/%/g, "%25");
  1863.                                                 handout.updateBlobs({gmnotes: gmnotes});
  1864.                                                 handout.save({notes: (new Date).getTime()});
  1865.  
  1866.                                                 d20plus.ut.log("Saved config");
  1867.  
  1868.                                                 d20plus.cfg.baseHandleConfigChange();
  1869.                                                 if (d20plus.handleConfigChange) d20plus.handleConfigChange();
  1870.                                         }
  1871.                                 } else {
  1872.                                         _updateLoadedConfig();
  1873.                                         StorageUtil.pSet(`Veconfig`, d20plus.cfg.current);
  1874.                                         d20plus.cfg.baseHandleConfigChange();
  1875.                                         if (d20plus.handleConfigChange) d20plus.handleConfigChange();
  1876.                                 }
  1877.                         });
  1878.                 }
  1879.         };
  1880.  
  1881.         d20plus.cfg._handleStatusTokenConfigChange = () => {
  1882.                 if (window.is_gm) {
  1883.                         if (d20plus.cfg.get("token", "enhanceStatus")) {
  1884.                                 const sheetUrl = d20plus.cfg.get("token", "statusSheetUrl") || d20plus.cfg.getDefault("token", "statusSheetUrl");
  1885.                                 const sheetSmallUrl = d20plus.cfg.get("token", "statusSheetSmallUrl") || d20plus.cfg.getDefault("token", "statusSheetSmallUrl");
  1886.  
  1887.                                 window.Campaign && window.Campaign.save({
  1888.                                         "bR20cfg_statussheet": sheetUrl,
  1889.                                         "bR20cfg_statussheet_small": sheetSmallUrl
  1890.                                 });
  1891.  
  1892.                                 d20.token_editor.statussheet.src = sheetUrl;
  1893.                                 d20.token_editor.statussheet_small.src =  sheetSmallUrl;
  1894.                                 d20plus.engine._removeStatusEffectEntries(); // clean up any old data
  1895.                                 d20plus.engine._addStatusEffectEntries();
  1896.                         } else {
  1897.                                 window.Campaign && window.Campaign.save({
  1898.                                         "bR20cfg_statussheet": "",
  1899.                                         "bR20cfg_statussheet_small": ""
  1900.                                 });
  1901.  
  1902.                                 d20.token_editor.statussheet.src = "/images/statussheet.png";
  1903.                                 d20.token_editor.statussheet_small.src = "/images/statussheet_small.png";
  1904.                                 d20plus.engine._removeStatusEffectEntries();
  1905.                         }
  1906.                 } else {
  1907.                         if (window.Campaign && window.Campaign.attributes && window.Campaign.attributes.bR20cfg_statussheet && window.Campaign.attributes.bR20cfg_statussheet_small) {
  1908.                                 d20.token_editor.statussheet.src = window.Campaign.attributes.bR20cfg_statussheet;
  1909.                                 d20.token_editor.statussheet_small.src =  window.Campaign.attributes.bR20cfg_statussheet_small;
  1910.                                 d20plus.engine._addStatusEffectEntries();
  1911.                         } else {
  1912.                                 d20.token_editor.statussheet.src = "/images/statussheet.png";
  1913.                                 d20.token_editor.statussheet_small.src = "/images/statussheet_small.png";
  1914.                                 d20plus.engine._removeStatusEffectEntries();
  1915.                         }
  1916.                 }
  1917.         };
  1918.  
  1919.         /*
  1920.         // Left here for future use, in case anything similar is required
  1921.         d20plus.cfg._handleWeatherConfigChange = () => {
  1922.                 function handleProp (prop) {
  1923.                         const campaignKey = `bR20cfg_${prop}`;
  1924.                         if (d20plus.cfg.has("weather", prop)) {
  1925.                                 Campaign && Campaign.save({[campaignKey]: d20plus.cfg.get("weather", prop)});
  1926.                         } else {
  1927.                                 if (Campaign) {
  1928.                                         delete Campaign[campaignKey];
  1929.                                         Campaign.save();
  1930.                                 }
  1931.                         }
  1932.                 }
  1933.                 if (window.is_gm) {
  1934.                         handleProp("weatherType1");
  1935.                         handleProp("weatherTypeCustom1");
  1936.                         handleProp("weatherSpeed1");
  1937.                         handleProp("weatherDir1");
  1938.                         handleProp("weatherDirCustom1");
  1939.                         handleProp("weatherOscillate1");
  1940.                         handleProp("weatherOscillateThreshold1");
  1941.                         handleProp("weatherIntensity1");
  1942.                         handleProp("weatherTint1");
  1943.                         handleProp("weatherTintColor1");
  1944.                         handleProp("weatherEffect1");
  1945.                 }
  1946.         };
  1947.         */
  1948.  
  1949.         d20plus.cfg.baseHandleConfigChange = () => {
  1950.                 d20plus.cfg._handleStatusTokenConfigChange();
  1951.                 // d20plus.cfg._handleWeatherConfigChange();
  1952.                 if (d20plus.cfg.has("interface", "toolbarOpacity")) {
  1953.                         const v = Math.max(Math.min(Number(d20plus.cfg.get("interface", "toolbarOpacity")), 100), 0);
  1954.                         $(`#secondary-toolbar`).css({opacity: v * 0.01});
  1955.                 }
  1956.  
  1957.                 $(`#floatinglayerbar`).toggle(d20plus.cfg.getOrDefault("interface", "quickLayerButtons"));
  1958.                 $(`#init-quick-sort-desc`).toggle(d20plus.cfg.getOrDefault("interface", "quickInitButtons"));
  1959.                 $(`input[placeholder="Search by tag or name..."]`).parent().toggle(!d20plus.cfg.getOrDefault("interface", "hideDefaultJournalSearch"))
  1960.         };
  1961.  
  1962.         d20plus.cfg.startPlayerConfigHandler = () => {
  1963.                 function handlePlayerCfg () {
  1964.                         d20plus.cfg.baseHandleConfigChange();
  1965.                         if (d20plus.handleConfigChange) d20plus.handleConfigChange(true);
  1966.                 }
  1967.  
  1968.                 // every 5 seconds, poll and apply any config changes the GM might have made
  1969.                 if (!window.is_gm) {
  1970.                         setInterval(() => {
  1971.                                 handlePlayerCfg();
  1972.                         }, 5000);
  1973.                 }
  1974.                 handlePlayerCfg();
  1975.         };
  1976. }
  1977.  
  1978. SCRIPT_EXTENSIONS.push(baseConfig);
  1979.  
  1980.  
  1981. function baseTool() {
  1982.         d20plus.tool = {};
  1983.  
  1984.         /**
  1985.          * Each tool should have:
  1986.          *  - `name` List display name.
  1987.          *  - `desc` List display description.
  1988.          *  - `dialogFn` Function called to initialize dialog.
  1989.          *  - `openFn` Function called when tool is opened.
  1990.          */
  1991.         d20plus.tool.tools = [
  1992.                 {
  1993.                         name: "Journal Cleaner",
  1994.                         desc: "Quickly select and delete journal items, especially useful for cleaning up loose items after deleting a folder.",
  1995.                         html: `
  1996.                                 <div id="d20plus-quickdelete" title="Journal Root Cleaner">
  1997.                                 <p>A list of characters and handouts in the journal folder root, which allows them to be quickly deleted.</p>
  1998.                                 <label style="font-weight: bold">Root Only <input type="checkbox" class="cb-deep" checked></label>
  1999.                                 <hr>
  2000.                                 <p style="display: flex; justify-content: space-between"><label><input type="checkbox" title="Select all" id="deletelist-selectall"> Select All</label> <a class="btn" href="#" id="quickdelete-btn-submit">Delete Selected</a></p>
  2001.                                 <div id="delete-list-container">
  2002.                                         <input class="search" autocomplete="off" placeholder="Search list..." style="width: 100%;">
  2003.                                         <br><br>
  2004.                                         <ul class="list deletelist" style="max-height: 420px; overflow-y: scroll; display: block; margin: 0;"></ul>
  2005.                                 </div>
  2006.                                 </div>
  2007.                                 `,
  2008.                         dialogFn: () => {
  2009.                                 $("#d20plus-quickdelete").dialog({
  2010.                                         autoOpen: false,
  2011.                                         resizable: true,
  2012.                                         width: 800,
  2013.                                         height: 700,
  2014.                                 });
  2015.                         },
  2016.                         openFn: () => {
  2017.                                 const $win = $("#d20plus-quickdelete");
  2018.                                 $win.dialog("open");
  2019.                                 const $cbDeep = $win.find(`.cb-deep`);
  2020.  
  2021.                                 const $cbAll = $("#deletelist-selectall").unbind("click");
  2022.  
  2023.                                 const $btnDel = $(`#quickdelete-btn-submit`).off("click");
  2024.  
  2025.                                 $cbDeep.off("change").on("change", () => populateList());
  2026.  
  2027.                                 populateList();
  2028.  
  2029.                                 function populateList () {
  2030.                                         // collect a list of all journal items
  2031.                                         function getAllJournalItems () {
  2032.                                                 const out = [];
  2033.  
  2034.                                                 function recurse (entry, pos, isRoot) {
  2035.                                                         if (entry.i) {
  2036.                                                                 if (!isRoot) pos.push(entry.n);
  2037.                                                                 entry.i.forEach(nxt => recurse(nxt, pos));
  2038.                                                                 pos.pop();
  2039.                                                         } else out.push({id: entry, path: MiscUtil.copy(pos)});
  2040.                                                 }
  2041.  
  2042.                                                 const root = {i: d20plus.ut.getJournalFolderObj()};
  2043.                                                 recurse(root, [], true);
  2044.                                                 return out.map(it => getItemFromId(it.id, it.path.join(" / ")));
  2045.                                         }
  2046.  
  2047.                                         function getRootJournalItems () {
  2048.                                                 const rootItems = [];
  2049.                                                 const journal = d20plus.ut.getJournalFolderObj();
  2050.                                                 journal.forEach(it => {
  2051.                                                         if (it.i) return; // skip folders
  2052.                                                         rootItems.push(getItemFromId(it));
  2053.                                                 });
  2054.                                                 return rootItems;
  2055.                                         }
  2056.  
  2057.                                         function getItemFromId (itId, path = "") {
  2058.                                                 const handout = d20.Campaign.handouts.get(itId);
  2059.                                                 if (handout && (handout.get("name") === CONFIG_HANDOUT || handout.get("name") === ART_HANDOUT)) return null; // skip 5etools handouts
  2060.                                                 const character = d20.Campaign.characters.get(itId);
  2061.                                                 if (handout) return {type: "handouts", id: itId, name: handout.get("name"), path: path};
  2062.                                                 if (character) return {type: "characters", id: itId, name: character.get("name"), path: path};
  2063.                                         }
  2064.  
  2065.                                         function getJournalItems () {
  2066.                                                 if ($cbDeep.prop("checked")) return getRootJournalItems().filter(it => it);
  2067.                                                 else return getAllJournalItems().filter(it => it);
  2068.                                         }
  2069.  
  2070.                                         const journalItems = getJournalItems();
  2071.  
  2072.                                         const $delList = $win.find(`.list`);
  2073.                                         $delList.empty();
  2074.  
  2075.                                         journalItems.forEach((it, i) => {
  2076.                                                 $delList.append(`
  2077.                                                         <label class="import-cb-label" data-listid="${i}">
  2078.                                                                 <input type="checkbox">
  2079.                                                                 <span class="name readable">${it.path ? `${it.path} / ` : ""}${it.name}</span>
  2080.                                                         </label>
  2081.                                                 `);
  2082.                                         });
  2083.  
  2084.                                         // init list library
  2085.                                         const delList = new List("delete-list-container", {
  2086.                                                 valueNames: ["name"],
  2087.                                                 listClass: "deletelist"
  2088.                                         });
  2089.  
  2090.                                         $cbAll.prop("checked", false);
  2091.                                         $cbAll.off("click").click(() => d20plus.importer._importToggleSelectAll(delList, $cbAll));
  2092.  
  2093.                                         $btnDel.off("click").on("click", () => {
  2094.                                                 const sel = delList.items
  2095.                                                         .filter(it => $(it.elm).find(`input`).prop("checked"))
  2096.                                                         .map(it => journalItems[$(it.elm).attr("data-listid")]);
  2097.  
  2098.                                                 if (!sel.length) {
  2099.                                                         alert("No items selected!");
  2100.                                                 } else if (confirm(`Are you sure you want to delete the ${sel.length} selected item${sel.length > 1 ? "s" : ""}?`)) {
  2101.                                                         $win.dialog("close");
  2102.                                                         $("a.ui-tabs-anchor[href='#journal']").trigger("click");
  2103.                                                         sel.forEach(toDel => {
  2104.                                                                 d20.Campaign[toDel.type].get(toDel.id).destroy();
  2105.                                                         });
  2106.                                                         $("#journalfolderroot").trigger("change");
  2107.                                                 }
  2108.                                         });
  2109.                                 }
  2110.                         }
  2111.                 },
  2112.                 {
  2113.                         name: "SVG Draw",
  2114.                         desc: "Paste SVG data as text to automatically draw the paths.",
  2115.                         html: `
  2116.                                 <div id="d20plus-svgdraw" title="SVG Drawing Tool">
  2117.                                 <p>Paste SVG data as text to automatically draw any included &lt;path&gt;s. Draws to the current layer, in the top-left corner, with no scaling. Takes colour information from &quot;stroke&quot; attributes.</p>
  2118.                                 <p>Line width (px; default values are 1, 3, 5, 8, 14): <input name="stroke-width" placeholder="5" value="5" type="number"></p>
  2119.                                 <textarea rows="10" cols="100" placeholder="Paste SVG data here"></textarea>
  2120.                                 <br>
  2121.                                 <button class="btn">Draw</button>
  2122.                                 </div>
  2123.                                 `,
  2124.                         dialogFn: () => {
  2125.                                 $("#d20plus-svgdraw").dialog({
  2126.                                         autoOpen: false,
  2127.                                         resizable: true,
  2128.                                         width: 800,
  2129.                                         height: 650,
  2130.                                 });
  2131.                         },
  2132.                         openFn: () => {
  2133.                                 // adapted from `d20.engine.finishCurrentPolygon`
  2134.                                 function addShape(path, pathStroke, strokeWidth) {
  2135.                                         let i = d20.engine.convertAbsolutePathStringtoFabric(path);
  2136.                                         i = _.extend(i, {
  2137.                                                 strokeWidth: strokeWidth,
  2138.                                                 fill: "transparent",
  2139.                                                 stroke: pathStroke,
  2140.                                                 path: JSON.parse(i.path)
  2141.                                         });
  2142.                                         d20.Campaign.activePage().addPath(i);
  2143.                                         d20.engine.redrawScreenNextTick();
  2144.                                 }
  2145.  
  2146.                                 const $win = $("#d20plus-svgdraw");
  2147.                                 $win.dialog("open");
  2148.  
  2149.                                 $win.find(`button`).off("click").on("click", () => {
  2150.                                         d20plus.ut.log("Drawing paths");
  2151.                                         const input = $win.find(`textarea`).val();
  2152.                                         const svg = $.parseXML(input);
  2153.  
  2154.                                         const toDraw = $(svg).find("path").map((i, e) => {
  2155.                                                 const $e = $(e);
  2156.                                                 return {stroke: $e.attr("stroke") || "black", d: $e.attr("d")}
  2157.                                         }).get();
  2158.  
  2159.                                         const strokeWidth = Math.max(1, Number($win.find(`input[name="stroke-width"]`).val()));
  2160.  
  2161.                                         toDraw.forEach(it => {
  2162.                                                 addShape(it.d, it.stroke, strokeWidth)
  2163.                                         });
  2164.                                 });
  2165.                         }
  2166.                 },
  2167.                 {
  2168.                         name: "Multi-Whisper",
  2169.                         desc: "Send whispers to multiple players ",
  2170.                         html: `
  2171.                                 <div id="d20plus-whispers" title="Multi-Whisper Tool">
  2172.                                 <div>
  2173.                                         <button class="btn toggle-dc">Show Disconnected Players</button>
  2174.                                         <button class="btn send-all">Send All Messages</button>
  2175.                                         <button class="btn clear-all">Clear All Messages</button>
  2176.                                 </div>
  2177.                                 <hr>
  2178.                                 <div class="messages" style="max-height: 600px; overflow-y: auto; overflow-x: hidden; transform: translateZ(0)">
  2179.                                         <!-- populate with JS -->
  2180.                                 </div>
  2181.                                 </div>
  2182.                                 `,
  2183.                         dialogFn: () => {
  2184.                                 $("#d20plus-whispers").dialog({
  2185.                                         autoOpen: false,
  2186.                                         resizable: true,
  2187.                                         width: 1000,
  2188.                                         height: 760,
  2189.                                 });
  2190.                         },
  2191.                         openFn: () => {
  2192.                                 $("a.ui-tabs-anchor[href='#textchat']").trigger("click");
  2193.  
  2194.                                 const $win = $("#d20plus-whispers");
  2195.                                 $win.dialog("open");
  2196.  
  2197.                                 const $btnToggleDc = $win.find(`.toggle-dc`).off("click").text("Show Disconnected Players");
  2198.                                 const $btnSendAll = $win.find(`.send-all`).off("click");
  2199.                                 const $btnClearAll = $win.find(`.clear-all`).off("click");
  2200.  
  2201.                                 const $pnlMessages = $win.find(`.messages`).empty();
  2202.                                 const players = d20.Campaign.players.toJSON();
  2203.                                 players.forEach((p, i) => {
  2204.                                         const $btnSend = $(`<button class="btn send" style="margin-right: 5px;">Send</button>`).on("click", function () {
  2205.                                                 const $btn = $(this);
  2206.                                                 const $wrp = $btn.closest(`.wrp-message`);
  2207.                                                 const toMsg = $wrp.find(`input[data-player-id]:checked`).filter(":visible").map((ii, e) => $(e).attr("data-player-id")).get();
  2208.                                                 const content = $wrp.find(`.message`).val().trim();
  2209.                                                 toMsg.forEach(targetId => {
  2210.                                                         d20.textchat.doChatInput(`/w ${d20.Campaign.players.get(targetId).get("displayname").split(" ")[0]} ${content}`);
  2211.  
  2212.                                                         // This only posts to local player's chat, sadly
  2213.                                                         // d20.textchat.incoming(
  2214.                                                         //      false,
  2215.                                                         //      {
  2216.                                                         //              avatar: `/users/avatar/${window.currentPlayer.get("d20userid")}/30`,
  2217.                                                         //              who: d20.textchat.$speakingas.find("option:first-child").text(),
  2218.                                                         //              type: "whisper",
  2219.                                                         //              content: content,
  2220.                                                         //              playerid: window.currentPlayer.id,
  2221.                                                         //              id: d20plus.ut.generateRowId(),
  2222.                                                         //              target: targetId,
  2223.                                                         //              target_name: d20.Campaign.players.get(targetId).get("displayname") || ""
  2224.                                                         //      }
  2225.                                                         // );
  2226.                                                 })
  2227.                                         });
  2228.  
  2229.                                         const $btnClear =  $(`<button class="btn msg-clear">Clear</button>`).on("click", function () {
  2230.                                                 $(this).closest(`.wrp-message`).find(`.message`).val("");
  2231.                                         });
  2232.  
  2233.                                         $pnlMessages.append($(`
  2234.                                                         <div ${p.online || `style="display: none;"`} data-online="${p.online}" class="wrp-message">
  2235.                                                                 <div>
  2236.                                                                         ${players.map((pp, ii) => `<label style="margin-right: 10px; ${pp.online || ` display: none;`}" data-online="${pp.online}" class="display-inline-block">${pp.displayname} <input data-player-id="${pp.id}" type="checkbox" ${i === ii ? `checked="true"` : ""}></label>`).join("")}
  2237.                                                                 </div>
  2238.                                                                 <textarea style="display: block; width: 95%;" placeholder="Enter whisper" class="message"></textarea>
  2239.                                                         </div>                                         
  2240.                                                 `).append($btnSend).append($btnClear).append(`<hr>`));
  2241.                                 });
  2242.  
  2243.                                 $btnToggleDc.on("click", () => {
  2244.                                         $btnToggleDc.text($btnToggleDc.text().startsWith("Show") ? "Hide Disconnected Players" : "Show Disconnected Players");
  2245.                                         $pnlMessages.find(`[data-online="false"]`).toggle();
  2246.                                 });
  2247.  
  2248.                                 $btnSendAll.on("click", () => {
  2249.                                         $pnlMessages.find(`button.send`).click();
  2250.                                 });
  2251.  
  2252.                                 $btnClearAll.on("click", () => $pnlMessages.find(`button.msg-clear`).click());
  2253.                         }
  2254.                 },
  2255.                 {
  2256.                         name: "Table Importer",
  2257.                         desc: "Import TableExport data",
  2258.                         html: `
  2259.                                 <div id="d20plus-tables" title="Table Importer">
  2260.                                         <div>
  2261.                                         <button class="btn paste-clipboard">Paste from Clipboard</button> <i>Accepts <a href="https://app.roll20.net/forum/post/1144568/script-tableexport-a-script-for-exporting-and-importing-rollable-tables-between-accounts">TableExport</a> format.</i>
  2262.                                         </div>
  2263.                                         <br>
  2264.                                         <div id="table-list">
  2265.                                                 <input type="search" class="search" placeholder="Search tables...">
  2266.                                                 <div class="list" style="transform: translateZ(0); max-height: 490px; overflow-y: scroll; overflow-x: hidden;"><i>Loading...</i></div>
  2267.                                         </div>
  2268.                                 <br>
  2269.                                 <button class="btn start-import">Import</button>
  2270.                                 </div>
  2271.                                
  2272.                                 <div id="d20plus-tables-clipboard" title="Paste from Clipboard"/>
  2273.                                 `,
  2274.                         dialogFn: () => {
  2275.                                 $("#d20plus-tables").dialog({
  2276.                                         autoOpen: false,
  2277.                                         resizable: true,
  2278.                                         width: 650,
  2279.                                         height: 720,
  2280.                                 });
  2281.                                 $(`#d20plus-tables-clipboard`).dialog({
  2282.                                         autoOpen: false,
  2283.                                         resizable: true,
  2284.                                         width: 640,
  2285.                                         height: 480,
  2286.                                 });
  2287.                         },
  2288.                         openFn: () => {
  2289.                                 const $win = $("#d20plus-tables");
  2290.                                 $win.dialog("open");
  2291.  
  2292.                                 const $btnImport = $win.find(`.start-import`).off("click");
  2293.                                 const $btnClipboard = $win.find(`.paste-clipboard`).off("click");
  2294.  
  2295.                                 const url = `${BASE_SITE_URL}/data/roll20-tables.json`;
  2296.                                 DataUtil.loadJSON(url).then((data) => {
  2297.                                         function createTable (t) {
  2298.                                                 const r20t = d20.Campaign.rollabletables.create({
  2299.                                                         name: t.name.replace(/\s+/g, "-"),
  2300.                                                         showplayers: t.isShown,
  2301.                                                         id: d20plus.ut.generateRowId()
  2302.                                                 });
  2303.  
  2304.                                                 r20t.tableitems.reset(t.items.map(i => {
  2305.                                                         const out = {
  2306.                                                                 id: d20plus.ut.generateRowId(),
  2307.                                                                 name: i.row
  2308.                                                         };
  2309.                                                         if (i.weight !== undefined) out.weight = i.weight;
  2310.                                                         if (i.avatar) out.avatar = i.avatar;
  2311.                                                         return out;
  2312.                                                 }));
  2313.                                                 r20t.tableitems.forEach(it => it.save());
  2314.                                         }
  2315.  
  2316.                                         // Allow pasting of custom tables
  2317.                                         $btnClipboard.on("click", () => {
  2318.                                                 const $wrpClip = $(`#d20plus-tables-clipboard`);
  2319.                                                 const $iptClip = $(`<textarea placeholder="Paste TableExport data here" style="display: block; width: 600px; height: 340px;"/>`).appendTo($wrpClip);
  2320.                                                 const $btnCheck = $(`<button class="btn" style="margin-right: 5px;">Check if Valid</button>`).on("click", () => {
  2321.                                                         let error = false;
  2322.                                                         try {
  2323.                                                                 getFromPaste($iptClip.val());
  2324.                                                         } catch (e) {
  2325.                                                                 console.error(e);
  2326.                                                                 window.alert(e.message);
  2327.                                                                 error = true;
  2328.                                                         }
  2329.                                                         if (!error) window.alert("Looking good!");
  2330.                                                 }).appendTo($wrpClip);
  2331.                                                 const $btnImport = $(`<button class="btn">Import</button>`).on("click", () => {
  2332.                                                         $("a.ui-tabs-anchor[href='#deckstables']").trigger("click");
  2333.                                                         const ts = getFromPaste($iptClip.val());
  2334.                                                         ts.forEach(t => createTable(t));
  2335.                                                         window.alert("Import complete");
  2336.                                                 }).appendTo($wrpClip);
  2337.  
  2338.                                                 $wrpClip.dialog("open");
  2339.                                         });
  2340.  
  2341.                                         function getFromPaste (paste) {
  2342.                                                 const tables = [];
  2343.                                                 let tbl = null;
  2344.  
  2345.                                                 paste.split("\n").forEach(line => parseLine(line.trim()));
  2346.                                                 parseLine(""); // ensure trailing newline
  2347.                                                 return tables;
  2348.  
  2349.                                                 function parseLine (line) {
  2350.                                                         if (line.startsWith("!import-table-item")) {
  2351.                                                                 if (!tbl) {
  2352.                                                                         throw new Error("No !import-table statement found");
  2353.                                                                 }
  2354.                                                                 const [junk, tblName, row, weight, avatar] = line.split("--").map(it => it.trim());
  2355.                                                                 tbl.items.push({
  2356.                                                                         row,
  2357.                                                                         weight,
  2358.                                                                         avatar
  2359.                                                                 })
  2360.                                                         } else if (line.startsWith("!import-table")) {
  2361.                                                                 if (tbl) {
  2362.                                                                         throw new Error("No blank line found between tables")
  2363.                                                                 }
  2364.                                                                 const [junk, tblName, showHide] = line.split("--").map(it => it.trim());
  2365.                                                                 tbl = {
  2366.                                                                         name: tblName,
  2367.                                                                         isShown: (showHide || "").toLowerCase() === "show"
  2368.                                                                 };
  2369.                                                                 tbl.items = [];
  2370.                                                         } else if (line.trim()) {
  2371.                                                                 throw new Error("Non-empty line which didn't match !import-table or !import-table-item")
  2372.                                                         } else {
  2373.                                                                 if (tbl) {
  2374.                                                                         tables.push(tbl);
  2375.                                                                         tbl = null;
  2376.                                                                 }
  2377.                                                         }
  2378.                                                 }
  2379.                                         }
  2380.  
  2381.                                         // Official tables
  2382.                                         const $lst = $win.find(`.list`);
  2383.                                         const tables = data.table.sort((a, b) => SortUtil.ascSort(a.name, b.name));
  2384.                                         let tmp = "";
  2385.                                         tables.forEach((t, i) => {
  2386.                                                 tmp += `
  2387.                                                                 <label class="import-cb-label" data-listid="${i}">
  2388.                                                                         <input type="checkbox">
  2389.                                                                         <span class="name col-10">${t.name}</span>
  2390.                                                                         <span title="${t.source ? Parser.sourceJsonToFull(t.source) : "Unknown Source"}" class="source">SRC[${t.source ? Parser.sourceJsonToAbv(t.source) : "UNK"}]</span>
  2391.                                                                 </label>
  2392.                                                         `;
  2393.                                         });
  2394.                                         $lst.html(tmp);
  2395.                                         tmp = null;
  2396.  
  2397.                                         const tableList = new List("table-list", {
  2398.                                                 valueNames: ["name", "source"]
  2399.                                         });
  2400.  
  2401.                                         $btnImport.on("click", () => {
  2402.                                                 $("a.ui-tabs-anchor[href='#deckstables']").trigger("click");
  2403.                                                 const sel = tableList.items
  2404.                                                         .filter(it => $(it.elm).find(`input`).prop("checked"))
  2405.                                                         .map(it => tables[$(it.elm).attr("data-listid")]);
  2406.  
  2407.                                                 sel.forEach(t => createTable(t));
  2408.                                         });
  2409.                                 });
  2410.                         }
  2411.                 },
  2412.                 {
  2413.                         name: "Token Avatar URL Fixer",
  2414.                         desc: "Change the root URL for tokens en-masse.",
  2415.                         html: `
  2416.                                 <div id="d20plus-avatar-fixer" title="Avatar Fixer">
  2417.                                 <p><b>Warning:</b> this thing doesn't really work.</p>
  2418.                                 <p>Current URLs (view only): <select class="view-only"></select></p>
  2419.                                 <p><label>Replace:<br><input name="search" value="https://5etools.com/"></label></p>
  2420.                                 <p><label>With:<br><input name="replace" value="https://thegiddylimit.github.io/"></label></p>
  2421.                                 <p><button class="btn">Go!</button></p>
  2422.                                 </div>
  2423.                                 `,
  2424.                         dialogFn: () => {
  2425.                                 $("#d20plus-avatar-fixer").dialog({
  2426.                                         autoOpen: false,
  2427.                                         resizable: true,
  2428.                                         width: 400,
  2429.                                         height: 400,
  2430.                                 });
  2431.                         },
  2432.                         openFn: () => {
  2433.                                 // FIXME this doesn't work, because it saves a nonsensical blob (imgsrc) instead of defaulttoken
  2434.                                 // see the working code in `initArtFromUrlButtons` for how this _should_ be done
  2435.  
  2436.                                 function replaceAll (str, search, replacement) {
  2437.                                         return str.split(search).join(replacement);
  2438.                                 }
  2439.  
  2440.                                 const $win = $("#d20plus-avatar-fixer");
  2441.                                 $win.dialog("open");
  2442.  
  2443.                                 const $selView = $win.find(`.view-only`);
  2444.                                 const toView = [];
  2445.                                 d20.Campaign.characters.toJSON().forEach(c => {
  2446.                                         if (c.avatar && c.avatar.trim()) {
  2447.                                                 toView.push(c.avatar);
  2448.                                         }
  2449.                                 });
  2450.                                 toView.sort(SortUtil.ascSort).forEach(url => $selView.append(`<option disabled>${url}</option>`));
  2451.  
  2452.                                 const $btnGo = $win.find(`button`).off("click");
  2453.                                 $btnGo.on("click", () => {
  2454.                                         let count = 0;
  2455.                                         $("a.ui-tabs-anchor[href='#journal']").trigger("click");
  2456.  
  2457.                                         const search = $win.find(`[name="search"]`).val();
  2458.                                         const replace = $win.find(`[name="replace"]`).val();
  2459.  
  2460.                                         d20.Campaign.characters.toJSON().forEach(c => {
  2461.                                                 const id = c.id;
  2462.  
  2463.                                                 const realC = d20.Campaign.characters.get(id);
  2464.  
  2465.                                                 const curr = realC.get("avatar");
  2466.                                                 let toSave = false;
  2467.                                                 if (curr.includes(search)) {
  2468.                                                         count++;
  2469.                                                         realC.set("avatar", replaceAll(curr, search, replace));
  2470.                                                         toSave = true;
  2471.                                                 }
  2472.                                                 if (realC.get("defaulttoken")) {
  2473.                                                         realC._getLatestBlob("defaulttoken", (bl) => {
  2474.                                                                 if (bl && bl.imgsrc && bl.imgsrc.includes(search)) {
  2475.                                                                         count++;
  2476.                                                                         realC.updateBlobs({imgsrc: replaceAll(bl.imgsrc, search, replace)});
  2477.                                                                         toSave = true;
  2478.                                                                 }
  2479.                                                         });
  2480.                                                 }
  2481.                                                 if (toSave) {
  2482.                                                         realC.save();
  2483.                                                 }
  2484.                                         });
  2485.                                         window.alert(`Replaced ${count} item${count === 0 || count > 1 ? "s" : ""}.`)
  2486.                                 });
  2487.                         }
  2488.                 },
  2489.                 {
  2490.                         name: "Mass-Delete Pages",
  2491.                         desc: "Quickly delete multiple pages.",
  2492.                         html: `
  2493.                                 <div id="d20plus-mass-page-delete" title="Mass-Delete Pages">
  2494.                                         <div id="del-pages-list">
  2495.                                                 <div class="list" style="transform: translateZ(0); max-height: 490px; overflow-y: scroll; overflow-x: hidden; margin-bottom: 10px;"><i>Loading...</i></div>
  2496.                                         </div>
  2497.                                         <hr>
  2498.                                         <p><label class="ib"><input type="checkbox" class="select-all"> Select All</label> | <button class="btn btn-danger deleter">Delete</button></p>
  2499.                                         <p><i>This tool will delete neither your active page, nor a page active for players.</i></p>
  2500.                                 </div>
  2501.                                 `,
  2502.                         dialogFn: () => {
  2503.                                 $("#d20plus-mass-page-delete").dialog({
  2504.                                         autoOpen: false,
  2505.                                         resizable: true,
  2506.                                         width: 600,
  2507.                                         height: 800,
  2508.                                 });
  2509.                         },
  2510.                         openFn: () => {
  2511.                                 function deletePage (model, pageList) {
  2512.                                         if ($("#page-toolbar .availablepage[data-pageid=" + model.id + "]").remove()) {
  2513.                                                 var n = d20.Campaign.getPageIndex(model.id);
  2514.                                                 if (model.thegraphics) {
  2515.                                                         model.thegraphics.massdelete = true;
  2516.                                                         model.thegraphics.backboneFirebase.reference.set(null);
  2517.                                                 }
  2518.                                                 if (model.thetexts) {
  2519.                                                         model.thetexts.massdelete = true;
  2520.                                                         model.thetexts.backboneFirebase.reference.set(null);
  2521.                                                 }
  2522.                                                 if (model.thepaths) {
  2523.                                                         model.thepaths.backboneFirebase.reference.set(null);
  2524.                                                         model.thepaths.massdelete = true;
  2525.                                                 }
  2526.                                                 let i = d20.Campaign.get("playerspecificpages");
  2527.                                                 let o = false;
  2528.                                                 _.each(i, function(e, n) {
  2529.                                                         if (e === model.id) {
  2530.                                                                 delete i[n];
  2531.                                                                 o = true;
  2532.                                                         }
  2533.                                                 });
  2534.                                                 o && d20.Campaign.save({
  2535.                                                         playerspecificpages: i
  2536.                                                 });
  2537.                                                 model.destroy();
  2538.                                                 d20.Campaign.activePageIndex > n && (d20.Campaign.activePageIndex -= 1);
  2539.  
  2540.                                                 pageList.remove("page-id", model.id);
  2541.                                         }
  2542.                                 }
  2543.  
  2544.                                 const $win = $("#d20plus-mass-page-delete");
  2545.                                 $win.dialog("open");
  2546.  
  2547.                                 const $lst = $win.find(`.list`).empty();
  2548.  
  2549.                                 d20.Campaign.pages.models.forEach(m => {
  2550.                                         $lst.append(`
  2551.                                                         <label class="import-cb-label import-cb-label--img" data-listid="${m.id}">
  2552.                                                                 <input type="checkbox">
  2553.                                                                 <img class="import-label__img" src="${m.attributes.thumbnail}">
  2554.                                                                 <span class="name col-9">${m.attributes.name}</span>
  2555.                                                                 <span style="display: none;" class="page-id">${m.id}</span>
  2556.                                                         </label>
  2557.                                                 `);
  2558.                                 });
  2559.  
  2560.                                 const pageList = new List("del-pages-list", {
  2561.                                         valueNames: ["name", "page-id"]
  2562.                                 });
  2563.  
  2564.                                 const $cbAll = $win.find(`.select-all`).off("click").click(() => {
  2565.                                         pageList.items.forEach(it => {
  2566.                                                 $(it.elm).find(`input[type="checkbox"]`).prop("checked", $cbAll.prop("checked"));
  2567.                                         });
  2568.                                 });
  2569.  
  2570.                                 const $btnDel = $win.find(`.deleter`).off("click").click(() => {
  2571.                                         const sel = pageList.items
  2572.                                                 .filter(it => $(it.elm).find(`input`).prop("checked"))
  2573.                                                 .map(it => $(it.elm).attr("data-listid"))
  2574.                                                 .map(pId => d20.Campaign.pages.models.find(it => it.id === pId))
  2575.                                                 .filter(it => it);
  2576.  
  2577.                                         sel.forEach(m => {
  2578.                                                 if (m.id !== d20.Campaign.get("playerpageid") && m.id !== d20.Campaign.activePage().id) {
  2579.                                                         deletePage(m, pageList);
  2580.                                                 }
  2581.                                         });
  2582.                                         $cbAll.prop("checked", false);
  2583.                                 });
  2584.                         }
  2585.                 },
  2586.                 {
  2587.                         name: "Quantum Token Entangler",
  2588.                         desc: "Connect tokens between pages, linking their positions.",
  2589.                         html: `
  2590.                                 <div id="d20plus-token-entangle" title="Quantum Token Entangler">
  2591.                                         <p><i>Please note that this feature is highly experimental.
  2592.                                         <br>
  2593.                                         You can learn Token IDs by rightclicking a token -> "Advanced" -> "View Token ID."</i></p>
  2594.                                         <hr>
  2595.                                         <input id="token-entangle-id-1" placeholder="Master ID">
  2596.                                         Type:
  2597.                                         <select id="token-entangle-type-1">
  2598.                                                 <option value="0">Token</option>
  2599.                                                 <option value="1">Path</option>
  2600.                                         </select>
  2601.                                         <br>
  2602.                                         <input id="token-entangle-id-2" placeholder="Slave ID">
  2603.                                         Type:  
  2604.                                         <select id="token-entangle-type-2">
  2605.                                                 <option value="0">Token</option>
  2606.                                                 <option value="1">Path</option>
  2607.                                         </select>
  2608.                                         <br>
  2609.                                         <button class="btn btn-default" id="token-entangle-go">Entangle</button>
  2610.                                         <hr>
  2611.                                         <input id="token-clear-entangles" placeholder="ID to Clear">
  2612.                                         Type:  
  2613.                                         <select id="token-clear-type">
  2614.                                                 <option value="0">Token</option>
  2615.                                                 <option value="1">Path</option>
  2616.                                         </select>
  2617.                                         <button class="btn btn-default" id="token-entangle-clear">Clear Entangles</button>
  2618.                                 </div>
  2619.                                 `,
  2620.                         dialogFn: () => {
  2621.                                 const $win = $("#d20plus-token-entangle");
  2622.  
  2623.                                 const entangleTracker = {};
  2624.                                 const ALLOWED_TYPES = ["path", "image"];
  2625.                                 const SYNCABLE_ATTRS_IMAGE = [
  2626.                                         "rotation",
  2627.                                         "width",
  2628.                                         "height",
  2629.                                         "top",
  2630.                                         "left",
  2631.                                         "scaleX",
  2632.                                         "scaleY",
  2633.                                         "fliph",
  2634.                                         "flipv"
  2635.                                 ];
  2636.                                 const SYNCABLE_ATTRS_PATH = [
  2637.                                         "rotation",
  2638.                                         "top",
  2639.                                         "left",
  2640.                                         "scaleX",
  2641.                                         "scaleY"
  2642.                                 ];
  2643.  
  2644.                                 $win.data("VE_DO_ENTANGLE", (master) => {
  2645.                                         if (!ALLOWED_TYPES.includes(master.attributes.type)) return;
  2646.  
  2647.                                         // prevent double-binding
  2648.                                         if (entangleTracker[master.id]) return;
  2649.  
  2650.                                         const TO_SYNC = master.attributes.type === "image" ? SYNCABLE_ATTRS_IMAGE : SYNCABLE_ATTRS_PATH;
  2651.  
  2652.                                         master.on("change", (it) => {
  2653.                                                 let anyUpdates = false;
  2654.  
  2655.                                                 if (master.attributes.entangledImages && master.attributes.entangledImages.length) {
  2656.                                                         if (TO_SYNC.filter(attr => it.changed[attr] !== undefined).length) {
  2657.                                                                 master.attributes.entangledImages = master.attributes.entangledImages.filter(id => {
  2658.                                                                         const slave = d20plus.ut.getTokenById(id);
  2659.                                                                         const SLAVE_ATTRS = slave.attributes.type === "image" ? SYNCABLE_ATTRS_IMAGE : SYNCABLE_ATTRS_PATH;
  2660.                                                                         if (slave) {
  2661.                                                                                 TO_SYNC
  2662.                                                                                         .filter(attr => SLAVE_ATTRS.includes(attr))
  2663.                                                                                         .filter(attr => master.attributes[attr] != null)
  2664.                                                                                         .forEach(attr => slave.attributes[attr] = master.attributes[attr]);
  2665.                                                                                 slave.save();
  2666.                                                                                 return true;
  2667.                                                                         } else {
  2668.                                                                                 console.warn(`Cound not find entangled token with ID "${id}", removing...`);
  2669.                                                                                 anyUpdates = true;
  2670.                                                                         }
  2671.                                                                 });
  2672.  
  2673.                                                         }
  2674.                                                 }
  2675.  
  2676.                                                 if (master.attributes.entangledPaths && master.attributes.entangledPaths.length) {
  2677.                                                         if (TO_SYNC.filter(attr => it.changed[attr] !== undefined).length) {
  2678.                                                                 master.attributes.entangledPaths = master.attributes.entangledPaths.filter(id => {
  2679.                                                                         const slave = d20plus.ut.getPathById(id);
  2680.                                                                         const SLAVE_ATTRS = slave.attributes.type === "image" ? SYNCABLE_ATTRS_IMAGE : SYNCABLE_ATTRS_PATH;
  2681.                                                                         if (slave) {
  2682.                                                                                 TO_SYNC
  2683.                                                                                         .filter(attr => SLAVE_ATTRS.includes(attr))
  2684.                                                                                         .filter(attr => master.attributes[attr] != null)
  2685.                                                                                         .forEach(attr => slave.attributes[attr] = master.attributes[attr]);
  2686.                                                                                 slave.save();
  2687.                                                                                 return true;
  2688.                                                                         } else {
  2689.                                                                                 console.warn(`Cound not find entangled path with ID "${id}", removing...`);
  2690.                                                                                 anyUpdates = true;
  2691.                                                                         }
  2692.                                                                 });
  2693.                                                         }
  2694.                                                 }
  2695.  
  2696.                                                 if (anyUpdates) master.save();
  2697.                                         })
  2698.                                 });
  2699.  
  2700.                                 // do initial entangles
  2701.                                 const runInitial = () => {
  2702.                                         const pages = d20.Campaign.pages;
  2703.                                         if (pages && pages.models) {
  2704.                                                 d20plus.ut.log("Initialisng existing entangles...");
  2705.                                                 d20.Campaign.pages.models
  2706.                                                         .forEach(model => {
  2707.                                                                 const PROPS = {
  2708.                                                                         thegraphics: "entangledImages",
  2709.                                                                         thepaths: "entangledPaths"
  2710.                                                                 };
  2711.                                                                 Object.keys(PROPS).forEach(prop => {
  2712.                                                                         Object.values(PROPS).forEach(attrK => {
  2713.                                                                                 if (model[prop] && model[prop].models) {
  2714.                                                                                         model[prop].models.filter(it => it.attributes[attrK] && it.attributes[attrK].length).forEach(it => {
  2715.                                                                                                 $win.data("VE_DO_ENTANGLE")(it);
  2716.                                                                                         })
  2717.                                                                                 }
  2718.                                                                         });
  2719.                                                                 });
  2720.                                                         });
  2721.                                         } else {
  2722.                                                 console.log("Pages uninitialised, waiting...");
  2723.                                                 setTimeout(runInitial, 1000);
  2724.                                         }
  2725.                                 };
  2726.  
  2727.                                 runInitial();
  2728.  
  2729.                                 $win.dialog({
  2730.                                         autoOpen: false,
  2731.                                         resizable: true,
  2732.                                         width: 800,
  2733.                                         height: 400,
  2734.                                 });
  2735.                         },
  2736.                         openFn: () => {
  2737.                                 const ATTR_PROPS = ["entangledImages", "entangledPaths"];
  2738.  
  2739.                                 const notFound = (id, type) => alert(`${type === "image" ? "Token" : "Path"} with ID ${id} didn't exist!`);
  2740.  
  2741.                                 const $win = $("#d20plus-token-entangle");
  2742.                                 $win.dialog("open");
  2743.  
  2744.                                 const $ipt1 = $(`#token-entangle-id-1`);
  2745.                                 const $ipt2 = $(`#token-entangle-id-2`);
  2746.                                 const $selType1 = $(`#token-entangle-type-1`);
  2747.                                 const $selType2 = $(`#token-entangle-type-2`);
  2748.  
  2749.                                 const $btnGo = $(`#token-entangle-go`)
  2750.                                         .off("click")
  2751.                                         .click(() => {
  2752.                                                 const id1 = $ipt1.val();
  2753.                                                 const id2 = $ipt2.val();
  2754.                                                 const checkExisting = (a, b) => {
  2755.                                                         const _check = (p, q) => ATTR_PROPS.some(prop => p.attributes[prop] && a.attributes[prop].includes(q.id));
  2756.  
  2757.                                                         if (_check(a, b)) return `"${a.id}" is already entangled to "${b.id}"!`;
  2758.                                                         if (_check(b, a)) return `"${b.id}" is already entangled to "${a.id}"!`;
  2759.                                                         return false;
  2760.                                                 };
  2761.  
  2762.                                                 const entity1 = $selType1.val() === "0" ? d20plus.ut.getTokenById(id1) : d20plus.ut.getPathById(id1);
  2763.                                                 const entity2 = $selType2.val() === "0" ? d20plus.ut.getTokenById(id2) : d20plus.ut.getPathById(id2);
  2764.  
  2765.                                                 if (!entity1) return notFound(id1, $selType1.val() === "0" ? "image" : "path");
  2766.                                                 if (!entity2) return notFound(id2, $selType2.val() === "0" ? "image" : "path");
  2767.  
  2768.                                                 const existing = checkExisting(entity1, entity2);
  2769.                                                 if (existing) return alert(existing);
  2770.  
  2771.                                                 const prop1 = entity2.attributes.type === "image" ? "entangledImages" : "entangledPaths";
  2772.                                                 const prop2 = entity1.attributes.type === "image" ? "entangledImages" : "entangledPaths";
  2773.  
  2774.                                                 (entity1.attributes[prop1] = entity1.attributes[prop1] || []).push(id2);
  2775.                                                 entity1.save();
  2776.                                                 (entity2.attributes[prop2] = entity2.attributes[prop2] || []).push(id1);
  2777.                                                 entity2.save();
  2778.  
  2779.                                                 $win.data("VE_DO_ENTANGLE")(entity1);
  2780.                                                 $win.data("VE_DO_ENTANGLE")(entity2);
  2781.                                                 alert("Entangled!");
  2782.                                         });
  2783.  
  2784.                                 const $iptClear = $(`#token-clear-entangles`);
  2785.  
  2786.                                 const $selTypeClear = $(`#token-clear-type`);
  2787.  
  2788.                                 const $btnClear = $(`#token-entangle-clear`)
  2789.                                         .off("click")
  2790.                                         .click(() => {
  2791.                                                 const id = $iptClear.val();
  2792.                                                 const entity = $selTypeClear.val() === "0" ? d20plus.ut.getTokenById(id) : d20plus.ut.getPathById(id);
  2793.                                                 if (!entity) return notFound(id, $selTypeClear.val() === "0" ? "image" : "path");
  2794.  
  2795.                                                 const count = (entity.attributes.entangledImages ? entity.attributes.entangledImages.length : 0) + (entity.attributes.entangledPaths ? entity.attributes.entangledPaths.length : 0);
  2796.  
  2797.                                                 (entity.attributes.entangledImages || []).forEach(eId => {
  2798.                                                         const ent = d20plus.ut.getTokenById(eId);
  2799.                                                         if (ent && ent.attributes.entangledImages && ent.attributes.entangledImages.includes(id)) {
  2800.                                                                 ent.attributes.entangledImages.splice(ent.attributes.entangledImages.indexOf(id), 1);
  2801.                                                                 ent.save();
  2802.                                                         }
  2803.                                                 });
  2804.  
  2805.                                                 (entity.attributes.entangledPaths || []).forEach(eId => {
  2806.                                                         const ent = d20plus.ut.getPathById(eId);
  2807.                                                         if (ent && ent.attributes.entangledPaths && ent.attributes.entangledPaths.includes(id)) {
  2808.                                                                 ent.attributes.entangledPaths.splice(ent.attributes.entangledPaths.indexOf(id), 1);
  2809.                                                                 ent.save();
  2810.                                                         }
  2811.                                                 });
  2812.  
  2813.                                                 entity.attributes.entangledImages = [];
  2814.                                                 entity.attributes.entangledPaths = [];
  2815.                                                 entity.save();
  2816.                                                 alert(`${count} entangle${count === 1 ? "" : "s"} cleared.`);
  2817.                                         });
  2818.                         }
  2819.                 }
  2820.         ];
  2821.  
  2822.         d20plus.tool.get = (toolId) => {
  2823.                 return d20plus.tool.tools.find(it => it.toolId === toolId);
  2824.         };
  2825.  
  2826.         d20plus.tool.addTools = () => {
  2827.                 const $body = $(`body`);
  2828.                 const $tools = $(`#d20-tools-list`);
  2829.                 const $toolsList = $tools.find(`.tools-list`);
  2830.                 d20plus.tool.tools.sort((a, b) => SortUtil.ascSortLower(a.name || "", b.name || "")).forEach(t => {
  2831.                         $body.append(t.html); // add HTML
  2832.                         try {
  2833.                                 t.dialogFn(); // init window
  2834.                                 // add tool row
  2835.                                 const $wrp = $(`<div class="tool-row"/>`);
  2836.                                 $wrp.append(`<span style="width: 20%; padding: 4px;">${t.name}</span>`);
  2837.                                 $wrp.append(`<span style="width: calc(60% - 8px); padding: 4px;">${t.desc}</span>`);
  2838.                                 $(`<a style="width: 15%;" class="btn" href="#">Open</a>`).on(mousedowntype, () => {
  2839.                                         t.openFn.bind(t)();
  2840.                                         $tools.dialog("close");
  2841.                                 }).appendTo($wrp);
  2842.                                 $toolsList.append($wrp);
  2843.                         } catch (e) {
  2844.                                 console.error(`Failed to initialise tool "${t.name}"`);
  2845.                                 setTimeout(() => {
  2846.                                         throw e;
  2847.                                 }, 1);
  2848.                         }
  2849.                 });
  2850.  
  2851.                 $tools.dialog({
  2852.                         autoOpen: false,
  2853.                         resizable: true,
  2854.                         width: 800,
  2855.                         height: 650,
  2856.                 });
  2857.                 $(`#button-view-tools`).on(mousedowntype, () => {
  2858.                         $tools.dialog("open");
  2859.                 });
  2860.         };
  2861. }
  2862.  
  2863. SCRIPT_EXTENSIONS.push(baseTool);
  2864.  
  2865.  
  2866. function baseToolModule () {
  2867.         d20plus.tool.tools.push({
  2868.                 toolId: "MODULES",
  2869.                 name: "Module Importer/Exporter",
  2870.                 desc: "Import full games (modules), or import/export custom games",
  2871.                 html: `
  2872.                                 <div id="d20plus-module-importer" title="Module Importer/Exporter">
  2873.                                 <p style="margin-bottom: 4px;"><b style="font-size: 110%;">Exporter: </b> <button class="btn" name="export">Export Game to File</button> <i>The exported file can later be used with the "Upload File" option, below.</i></p>
  2874.                                 <hr style="margin: 4px;">
  2875.                                 <p style="margin-bottom: 4px;">
  2876.                                         <b style="font-size: 110%;">Importer:</b>
  2877.                                         <button class="btn readme" style="float: right;">Help/README</button>
  2878.                                         <div style="clear: both;"></div>
  2879.                                 </p>
  2880.                                 <div style="border-bottom: 1px solid #ccc; margin-bottom: 3px; padding-bottom: 3px;">
  2881.                                         <button class="btn" name="load-Vetools">Load from 5etools</button>
  2882.                                         <button class="btn" name="load-file">Upload File</button>
  2883.                                 </div>
  2884.                                 <div>
  2885.                                         <div name="data-loading-message"></div>
  2886.                                         <select name="data-type" disabled style="margin-bottom: 0;">
  2887.                                                 <option value="characters">Characters</option>
  2888.                                                 <option value="decks">Decks</option>
  2889.                                                 <option value="handouts">Handouts</option>
  2890.                                                 <option value="playlists">Jukebox Playlists</option>
  2891.                                                 <option value="tracks">Jukebox Tracks</option>
  2892.                                                 <option value="maps">Maps</option>
  2893.                                                 <option value="rolltables">Rollable Tables</option>
  2894.                                         </select>
  2895.                                         <button class="btn" name="view-select-entries">View/Select Entries</button>
  2896.                                         <br>
  2897.                                         <button class="btn" name="select-all-entries">Select Everything</button>
  2898.                                         <div name="selection-summary" style="margin-top: 5px;"></div>
  2899.                                 </div>
  2900.                                 <hr>
  2901.                                 <p><button class="btn" style="float: right;" name="import">Import Selected</button></p>
  2902.                                 </div>
  2903.                                
  2904.                                 <div id="d20plus-module-importer-list" title="Select Entries">                                 
  2905.                                         <div id="module-importer-list">
  2906.                                                 <input type="search" class="search" placeholder="Search..." disabled>
  2907.                                                 <div class="list" style="transform: translateZ(0); max-height: 650px; overflow-y: auto; overflow-x: hidden; margin-bottom: 10px;">
  2908.                                                 <i>Load a file to view the contents here</i>
  2909.                                                 </div>
  2910.                                         </div>
  2911.                                         <div>
  2912.                                                 <label class="ib"><input type="checkbox" class="select-all"> Select All</label>
  2913.                                                 <button class="btn" style="float: right;" name="confirm-selection">Confirm Selection</button>
  2914.                                         </div>
  2915.                                 </div>
  2916.                                
  2917.                                 <div id="d20plus-module-importer-progress" title="Import Progress">                                    
  2918.                                         <h3 class="name"></h3>
  2919.                                         <span class="remaining"></span>
  2920.                                         <p>Errors: <span class="errors">0</span> <span class="error-names"></span></p>
  2921.                                         <p><button class="btn cancel">Cancel</button></p>
  2922.                                 </div>
  2923.                                
  2924.                                 <div id="d20plus-module-importer-help" title="Readme">
  2925.                                         <p>First, either load a module from 5etools, or upload one from a file. Then, choose the category you wish to import, and "View/Select Entries." Once you've selected everything you wish to import from the module, hit "Import Selected." This ensures entries are imported in the correct order.</p>
  2926.                                         <p><b>Note:</b> The script-wide configurable "rest time" options affect how quickly each category of entries is imported (tables and decks use the "Handout" rest time).</p>
  2927.                                         <p><b>Note:</b> Configuration options (aside from "rest time" as detailed above) <i>do not</i> affect the module importer. It effectively "clones" the content as-exported from the original module, including any whisper/advantage/etc settings.</p>
  2928.                                 </div>
  2929.                                
  2930.                                 <div id="d20plus-module-importer-5etools" title="Select Module">
  2931.                                         <div id="module-importer-list-5etools">
  2932.                                                 <input type="search" class="search" placeholder="Search modules...">
  2933.                                                 <div>
  2934.                                                         <div style="display: inline-block; width: 13px; height: 1px;"></div>
  2935.                                                         <div class="col-5 col">Name</div>
  2936.                                                         <div class="col-1 col" style="text-align: center;">Version</div>
  2937.                                                         <div class="col-2 col" style="text-align: center;">Last Modified</div>
  2938.                                                         <div class="col-1 col" style="text-align: center;">Size</div>
  2939.                                                         <div class="col-2 col" style="text-align: center;">Source</div>
  2940.                                                 </div>
  2941.                                                 <div class="list" style="transform: translateZ(0); max-height: 480px; overflow-y: auto; overflow-x: hidden; margin-bottom: 10px;">
  2942.                                                 <i>Loading...</i>
  2943.                                                 </div>
  2944.                                         </div>
  2945.                                         <p><button class="btn load">Load Module Data</button></p>
  2946.                                 </div>
  2947.                                
  2948.                                 <div id="d20plus-module-importer-select-exports-p1" title="Select Categories to Export">
  2949.                                         <div>
  2950.                                                 <label>Characters <input type="checkbox" class="float-right" name="cb-characters"></label>
  2951.                                                 <label>Decks <input type="checkbox" class="float-right" name="cb-decks"></label>
  2952.                                                 <label>Handouts <input type="checkbox" class="float-right" name="cb-handouts"></label>
  2953.                                                 <label>Jukebox Playlists <input type="checkbox" class="float-right" name="cb-playlists"></label>
  2954.                                                 <label>Jukebox Tracks <input type="checkbox" class="float-right" name="cb-tracks"></label>
  2955.                                                 <label>Maps <input type="checkbox" class="float-right" name="cb-maps"></label>
  2956.                                                 <label>Rollable Tables <input type="checkbox" class="float-right" name="cb-rolltables"></label>
  2957.                                         </div>
  2958.                                         <div class="clear" style="width: 100%; border-bottom: #ccc solid 1px;"></div>
  2959.                                         <p style="margin-top: 5px;"><label>Select All <input type="checkbox" class="float-right" name="cb-all"></label></p>
  2960.                                         <p><button class="btn">Export</button></p>
  2961.                                 </div>
  2962.                                 `,
  2963.                 dialogFn: () => {
  2964.                         $("#d20plus-module-importer").dialog({
  2965.                                 autoOpen: false,
  2966.                                 resizable: true,
  2967.                                 width: 750,
  2968.                                 height: 360,
  2969.                         });
  2970.                         $(`#d20plus-module-importer-progress`).dialog({
  2971.                                 autoOpen: false,
  2972.                                 resizable: false
  2973.                         });
  2974.                         $("#d20plus-module-importer-5etools").dialog({
  2975.                                 autoOpen: false,
  2976.                                 resizable: true,
  2977.                                 width: 800,
  2978.                                 height: 600,
  2979.                         });
  2980.                         $("#d20plus-module-importer-help").dialog({
  2981.                                 autoOpen: false,
  2982.                                 resizable: true,
  2983.                                 width: 600,
  2984.                                 height: 400,
  2985.                         });
  2986.                         $("#d20plus-module-importer-select-exports-p1").dialog({
  2987.                                 autoOpen: false,
  2988.                                 resizable: true,
  2989.                                 width: 400,
  2990.                                 height: 275,
  2991.                         });
  2992.                         $("#d20plus-module-importer-list").dialog({
  2993.                                 autoOpen: false,
  2994.                                 resizable: true,
  2995.                                 width: 600,
  2996.                                 height: 800,
  2997.                         });
  2998.                 },
  2999.                 openFn: () => {
  3000.                         const DISPLAY_NAMES = {
  3001.                                 maps: "Maps",
  3002.                                 rolltables: "Rollable Tables",
  3003.                                 decks: "Decks",
  3004.                                 handouts: "Handouts",
  3005.                                 playlists: "Jukebox Playlists",
  3006.                                 tracks: "Jukebox Tracks",
  3007.                                 characters: "Characters",
  3008.                         };
  3009.  
  3010.                         const $win = $("#d20plus-module-importer");
  3011.                         $win.dialog("open");
  3012.  
  3013.                         const $winProgress = $(`#d20plus-module-importer-progress`);
  3014.                         const $btnCancel = $winProgress.find(".cancel").off("click");
  3015.  
  3016.                         const $win5etools = $(`#d20plus-module-importer-5etools`);
  3017.  
  3018.                         const $winHelp = $(`#d20plus-module-importer-help`);
  3019.                         const $btnHelp = $win.find(`.readme`).off("click").click(() => $winHelp.dialog("open"));
  3020.  
  3021.                         const $winList = $(`#d20plus-module-importer-list`);
  3022.                         const $wrpLst = $(`#module-importer-list`);
  3023.                         const $lst = $winList.find(`.list`).empty();
  3024.                         const $cbAll = $winList.find(`.select-all`).off("click").prop("disabled", true);
  3025.                         const $iptSearch = $winList.find(`.search`).prop("disabled", true);
  3026.                         const $btnConfirmSel = $winList.find(`[name="confirm-selection"]`).off("click");
  3027.  
  3028.                         const $wrpSummary = $win.find(`[name="selection-summary"]`);
  3029.                         const $wrpDataLoadingMessage = $win.find(`[name="data-loading-message"]`);
  3030.  
  3031.                         const $btnImport = $win.find(`[name="import"]`).off("click").prop("disabled", true);
  3032.                         const $btnViewCat = $win.find(`[name="view-select-entries"]`).off("click").prop("disabled", true);
  3033.                         const $btnSelAllContent = $win.find(`[name="select-all-entries"]`).off("click").prop("disabled", true);
  3034.  
  3035.                         const $selDataType = $win.find(`[name="data-type"]`).prop("disabled", true);
  3036.                         let lastDataType = $selDataType.val();
  3037.                         let genericFolder;
  3038.                         let lastLoadedData = null;
  3039.  
  3040.                         const getFreshSelected = () => ({
  3041.                                 characters: [],
  3042.                                 decks: [],
  3043.                                 handouts: [],
  3044.                                 maps: [],
  3045.                                 playlists: [],
  3046.                                 tracks: [],
  3047.                                 rolltables: []
  3048.                         });
  3049.  
  3050.                         let selected = getFreshSelected();
  3051.  
  3052.                         function handleLoadedData (data) {
  3053.                                 lastLoadedData = data;
  3054.                                 selected = getFreshSelected();
  3055.                                 $selDataType.prop("disabled", false);
  3056.  
  3057.                                 function updateSummary () {
  3058.                                         $wrpSummary.text(Object.entries(selected).filter(([prop, ents]) => ents && ents.length).map(([prop, ents]) => `${DISPLAY_NAMES[prop]}: ${ents.length} selected`).join("; "));
  3059.                                 }
  3060.  
  3061.                                 $btnViewCat.prop("disabled", false);
  3062.                                 $btnViewCat.off("click").click(() => {
  3063.                                         $winList.dialog("open");
  3064.                                         $iptSearch.prop("disabled", false);
  3065.  
  3066.                                         let prop = "";
  3067.                                         switch (lastDataType) {
  3068.                                                 case "rolltables":
  3069.                                                 case "decks":
  3070.                                                 case "playlists":
  3071.                                                 case "tracks":
  3072.                                                 case "maps": {
  3073.                                                         prop = lastDataType;
  3074.                                                         break;
  3075.                                                 }
  3076.                                                 case "handouts": {
  3077.                                                         prop = "handouts";
  3078.                                                         genericFolder = d20plus.journal.makeDirTree(`Handouts`);
  3079.                                                         break;
  3080.                                                 }
  3081.                                                 case "characters": {
  3082.                                                         prop = "characters";
  3083.                                                         genericFolder = d20plus.journal.makeDirTree(`Characters`);
  3084.                                                         break;
  3085.                                                 }
  3086.                                                 default: throw new Error(`Unhandled data type: ${lastDataType}`);
  3087.                                         }
  3088.  
  3089.                                         const moduleData = data[prop] || [];
  3090.                                         moduleData.sort((a, b) => SortUtil.ascSortLower(
  3091.                                                 (a.attributes && a.attributes.name) || a.name || a.title || "",
  3092.                                                 (b.attributes && a.attributes.name) || a.name || b.title || ""
  3093.                                         ));
  3094.  
  3095.                                         $lst.empty();
  3096.                                         moduleData.forEach((m, i) => {
  3097.                                                 const img = lastDataType === "maps" ? m.attributes.thumbnail :
  3098.                                                         (lastDataType === "characters" || lastDataType === "handouts" || lastDataType === "decks") ? m.attributes.avatar : "";
  3099.  
  3100.                                                 $lst.append(`
  3101.                                                                         <label class="import-cb-label ${img ? `import-cb-label--img` : ""}" data-listid="${i}">
  3102.                                                                                 <input type="checkbox">
  3103.                                                                                 ${img && img.trim() ? `<img class="import-label__img" src="${img}">` : ""}
  3104.                                                                                 <span class="name col-9 readable">${(m.attributes && m.attributes.name) || m.name || m.title || ""}</span>
  3105.                                                                         </label>
  3106.                                                                 `);
  3107.                                         });
  3108.  
  3109.                                         const entryList = new List("module-importer-list", {
  3110.                                                 valueNames: ["name"]
  3111.                                         });
  3112.  
  3113.                                         $cbAll.prop("disabled", false).off("click").click(() => {
  3114.                                                 entryList.items.forEach(it => {
  3115.                                                         $(it.elm).find(`input[type="checkbox"]`).prop("checked", $cbAll.prop("checked"));
  3116.                                                 });
  3117.                                         });
  3118.  
  3119.                                         $btnConfirmSel.off("click").click(() => {
  3120.                                                 const sel = entryList.items
  3121.                                                         .filter(it => $(it.elm).find(`input`).prop("checked"))
  3122.                                                         .map(it => moduleData[$(it.elm).attr("data-listid")]);
  3123.  
  3124.                                                 $cbAll.prop("checked", false);
  3125.                                                 $winList.dialog("close");
  3126.                                                 selected[prop] = sel;
  3127.                                                 updateSummary();
  3128.                                         });
  3129.                                 });
  3130.  
  3131.                                 $btnSelAllContent.prop("disabled", false);
  3132.                                 $btnSelAllContent.off("click").click(() => {
  3133.                                         Object.keys(selected).forEach(k => {
  3134.                                                 selected[k] = data[k];
  3135.                                                 updateSummary();
  3136.                                         });
  3137.                                 });
  3138.  
  3139.                                 $btnImport.prop("disabled", false).off("click").click(() => {
  3140.                                         const totalSelected = Object.values(selected).map(it => it ? it.length : 0).reduce((a, b) => a + b, 0);
  3141.                                         if (!totalSelected) return alert("No entries selected!");
  3142.  
  3143.                                         const $name = $winProgress.find(`.name`);
  3144.                                         const $remain = $winProgress.find(`.remaining`).text(`${totalSelected} remaining...`);
  3145.                                         const $errCount = $winProgress.find(`.errors`);
  3146.                                         const $errReasons = $winProgress.find(`.error-names`);
  3147.                                         let errCount = 0;
  3148.  
  3149.                                         $winProgress.dialog("open");
  3150.  
  3151.                                         const journal = data.journal ? MiscUtil.copy(data.journal).reverse() : null;
  3152.  
  3153.                                         let queue = [];
  3154.                                         let jukebox = {};
  3155.                                         Object.entries(selected).filter(([k, v]) => v && v.length).forEach(([prop, ents]) => {
  3156.                                                 if (prop === "playlists") return jukebox.playlists = (jukebox.playlists || []).concat(ents);
  3157.                                                 else if (prop === "tracks") return jukebox.tracks = (jukebox.tracks || []).concat(ents);
  3158.  
  3159.                                                 ents = MiscUtil.copy(ents);
  3160.  
  3161.                                                 // if importing journal items, make sure they get put back in the right order
  3162.                                                 if (journal && (prop === "characters" || prop === "handouts")) {
  3163.                                                         const nuQueue = [];
  3164.  
  3165.                                                         journal.forEach(jIt => {
  3166.                                                                 const qIx = ents.findIndex(qIt => qIt.attributes.id === jIt.id);
  3167.                                                                 if (~qIx) nuQueue.push(ents.splice(qIx, 1)[0]);
  3168.                                                         });
  3169.                                                         ents.forEach(qIt => nuQueue.push(qIt)); // add anything that wasn't in the journal to the end of the queue
  3170.                                                         ents = nuQueue;
  3171.                                                 }
  3172.  
  3173.                                                 const toAdd = ents.map(entry => ({entry, prop}));
  3174.                                                 // do maps first
  3175.                                                 if (prop === "maps") queue = toAdd.concat(queue);
  3176.                                                 else queue = queue.concat(toAdd);
  3177.                                         });
  3178.  
  3179.                                         // reset the tool
  3180.                                         selected = getFreshSelected();
  3181.                                         $wrpSummary.text("");
  3182.  
  3183.                                         let isCancelled = false;
  3184.                                         let lastTimeout = null;
  3185.                                         $btnCancel.off("click").click(() => {
  3186.                                                 isCancelled = true;
  3187.                                                 if (lastTimeout != null) {
  3188.                                                         clearTimeout(lastTimeout);
  3189.                                                         doImport();
  3190.                                                 }
  3191.                                         });
  3192.                                         const mapTimeout = d20plus.cfg.get("import", "importIntervalMap") || d20plus.cfg.getDefault("import", "importIntervalMap");
  3193.                                         const charTimeout = d20plus.cfg.get("import", "importIntervalCharacter") || d20plus.cfg.getDefault("import", "importIntervalCharacter");
  3194.                                         const handoutTimeout = d20plus.cfg.get("import", "importIntervalHandout") || d20plus.cfg.getDefault("import", "importIntervalHandout");
  3195.                                         const timeouts = {
  3196.                                                 characters: charTimeout,
  3197.                                                 decks: handoutTimeout,
  3198.                                                 handouts: handoutTimeout,
  3199.                                                 playlists: 0,
  3200.                                                 tracks: 0,
  3201.                                                 maps: mapTimeout,
  3202.                                                 rolltables: handoutTimeout
  3203.                                         };
  3204.  
  3205.                                         const addToJournal = (originalId, itId) => {
  3206.                                                 let handled = false;
  3207.                                                 if (journal) {
  3208.                                                         const found = journal.find(it => it.id === originalId);
  3209.                                                         if (found) {
  3210.                                                                 const rawPath = found.path;
  3211.                                                                 const cleanPath = rawPath.slice(1); // paths start with "Root"
  3212.                                                                 const folder = d20plus.journal.makeDirTree(...cleanPath);
  3213.                                                                 d20.journal.addItemToFolderStructure(itId, folder.id);
  3214.                                                                 handled = true;
  3215.                                                         }
  3216.                                                 }
  3217.  
  3218.                                                 if (!handled) d20.journal.addItemToFolderStructure(itId, genericFolder.id);
  3219.                                         };
  3220.  
  3221.                                         const doImport = () => {
  3222.                                                 if (isCancelled) {
  3223.                                                         $name.text("Import cancelled.");
  3224.                                                         $remain.text(`Cancelled with ${queue.length} remaining.`);
  3225.                                                 } else if (queue.length && !isCancelled) {
  3226.                                                         $remain.text(`${queue.length} remaining...`);
  3227.                                                         const {entry, prop} = queue.shift();
  3228.                                                         const timeout = timeouts[prop];
  3229.                                                         const name = entry.attributes.name;
  3230.                                                         try {
  3231.                                                                 $name.text(`Importing ${name}`);
  3232.  
  3233.                                                                 switch (prop) {
  3234.                                                                         case "maps": {
  3235.                                                                                 const map = d20.Campaign.pages.create(entry.attributes);
  3236.                                                                                 entry.graphics.forEach(it => map.thegraphics.create(it));
  3237.                                                                                 entry.paths.forEach(it => map.thepaths.create(it));
  3238.                                                                                 entry.text.forEach(it => map.thetexts.create(it));
  3239.                                                                                 map.save();
  3240.                                                                                 break;
  3241.                                                                         }
  3242.                                                                         case "rolltables": {
  3243.                                                                                 const table = d20.Campaign.rollabletables.create(entry.attributes);
  3244.                                                                                 table.tableitems.reset();
  3245.                                                                                 const toSave = entry.tableitems.map(it => table.tableitems.push(it));
  3246.                                                                                 toSave.forEach(s => s.save());
  3247.                                                                                 table.save();
  3248.                                                                                 break;
  3249.                                                                         }
  3250.                                                                         case "decks": {
  3251.                                                                                 const deck = d20.Campaign.decks.create(entry.attributes);
  3252.                                                                                 deck.cards.reset();
  3253.                                                                                 const toSave = entry.cards.map(it => deck.cards.push(it));
  3254.                                                                                 toSave.forEach(s => s.save());
  3255.                                                                                 deck.save();
  3256.                                                                                 break;
  3257.                                                                         }
  3258.                                                                         case "handouts": {
  3259.                                                                                 d20.Campaign.handouts.create(entry.attributes,
  3260.                                                                                         {
  3261.                                                                                                 success: function (handout) {
  3262.                                                                                                         handout.updateBlobs({
  3263.                                                                                                                 notes: entry.blobNotes,
  3264.                                                                                                                 gmnotes: entry.blobGmNotes
  3265.                                                                                                         });
  3266.  
  3267.                                                                                                         addToJournal(entry.attributes.id, handout.id);
  3268.                                                                                                 }
  3269.                                                                                         }
  3270.                                                                                 );
  3271.                                                                                 break;
  3272.                                                                         }
  3273.                                                                         case "characters": {
  3274.                                                                                 d20.Campaign.characters.create(entry.attributes,
  3275.                                                                                         {
  3276.                                                                                                 success: function (character) {
  3277.                                                                                                         character.attribs.reset();
  3278.                                                                                                         const toSave = entry.attribs.map(a => character.attribs.push(a));
  3279.                                                                                                         toSave.forEach(s => s.syncedSave());
  3280.  
  3281.                                                                                                         character.abilities.reset();
  3282.                                                                                                         if (entry.abilities) entry.abilities.map(a => character.abilities.push(a)).forEach(s => s.save());
  3283.  
  3284.                                                                                                         character.updateBlobs({
  3285.                                                                                                                 bio: entry.blobBio,
  3286.                                                                                                                 gmnotes: entry.blobGmNotes,
  3287.                                                                                                                 defaulttoken: entry.blobDefaultToken
  3288.                                                                                                         });
  3289.  
  3290.                                                                                                         addToJournal(entry.attributes.id, character.id);
  3291.                                                                                                 }
  3292.                                                                                         }
  3293.                                                                                 );
  3294.                                                                                 break;
  3295.                                                                         }
  3296.                                                                         default: throw new Error(`Unhandled data type: ${prop}`);
  3297.                                                                 }
  3298.                                                         } catch (e) {
  3299.                                                                 console.error(e);
  3300.  
  3301.                                                                 errCount++;
  3302.                                                                 $errCount.text(errCount);
  3303.                                                                 const prevReasons = $errReasons.text().trim();
  3304.                                                                 $errReasons.append(`${prevReasons.length ? ", " : ""}${name}: "${e.message}"`)
  3305.                                                         }
  3306.  
  3307.                                                         // queue up the next import
  3308.                                                         lastTimeout = setTimeout(doImport, timeout);
  3309.                                                 } else {
  3310.                                                         $name.text("Import complete!");
  3311.                                                         $remain.text(`${queue.length} remaining.`);
  3312.                                                 }
  3313.                                         };
  3314.  
  3315.                                         if (Object.keys(jukebox).length) d20plus.jukebox.importWrappedData(jukebox);
  3316.                                         doImport();
  3317.                                 });
  3318.                         }
  3319.  
  3320.                         $selDataType.off("change").on("change", () => {
  3321.                                 lastDataType = $selDataType.val();
  3322.                         });
  3323.  
  3324.                         const $btnLoadVetools = $win.find(`[name="load-Vetools"]`);
  3325.                         $btnLoadVetools.off("click").click(() => {
  3326.                                 $win5etools.dialog("open");
  3327.                                 const $btnLoad = $win5etools.find(`.load`).off("click");
  3328.  
  3329.                                 DataUtil.loadJSON(`${DATA_URL}roll20-module/roll20-module-index.json`).then(data => {
  3330.                                         const $lst = $win5etools.find(`.list`);
  3331.                                         const modules = data.map.sort((a, b) => SortUtil.ascSortLower(a.name, b.name));
  3332.                                         let tmp = "";
  3333.                                         modules.forEach((t, i) => {
  3334.                                                 tmp += `
  3335.                                                                 <label class="import-cb-label" data-listid="${i}">
  3336.                                                                         <input type="radio" name="map-5etools">
  3337.                                                                         <span class="name col-5 readable">${t.name}</span>
  3338.                                                                         <span class="version col-1 readable" style="text-align: center;">${t.version || ""}</span>
  3339.                                                                         <span class="lat-modified col-2 readable" style="text-align: center;">${t.dateLastModified ? MiscUtil.dateToStr(new Date(t.dateLastModified * 1000), true) : ""}</span>
  3340.                                                                         <span class="size col-1 readable" style="text-align: right;">${d20plus.ut.getReadableFileSizeString(t.size)}</span>
  3341.                                                                         <span title="${Parser.sourceJsonToFull(t.id)}" class="source readable" style="text-align: right;">SRC[${Parser.sourceJsonToAbv(t.id)}]</span>
  3342.                                                                 </label>
  3343.                                                         `;
  3344.                                         });
  3345.                                         $lst.html(tmp);
  3346.                                         tmp = null;
  3347.  
  3348.                                         const list5etools = new List("module-importer-list-5etools", {
  3349.                                                 valueNames: ["name"]
  3350.                                         });
  3351.  
  3352.                                         $btnLoad.on("click", () => {
  3353.                                                 const sel = list5etools.items
  3354.                                                         .filter(it => $(it.elm).find(`input`).prop("checked"))
  3355.                                                         .map(it => modules[$(it.elm).attr("data-listid")])[0];
  3356.  
  3357.                                                 $win5etools.dialog("close");
  3358.                                                 $win.dialog("open");
  3359.                                                 $wrpDataLoadingMessage.html("<i>Loading...</i>");
  3360.                                                 DataUtil.loadJSON(`${DATA_URL}roll20-module/roll20-module-${sel.id.toLowerCase()}.json`)
  3361.                                                         .then(moduleFile => {
  3362.                                                                 $wrpDataLoadingMessage.html("");
  3363.                                                                 return handleLoadedData(moduleFile);
  3364.                                                         })
  3365.                                                         .catch(e => {
  3366.                                                                 $wrpDataLoadingMessage.html("");
  3367.                                                                 console.error(e);
  3368.                                                                 alert(`Failed to load data! See the console for more information.`);
  3369.                                                         });
  3370.                                         });
  3371.                                 }).catch(e => {
  3372.                                         console.error(e);
  3373.                                         alert(`Failed to load data! See the console for more information.`);
  3374.                                 });
  3375.                         });
  3376.  
  3377.                         const $btnLoadFile = $win.find(`[name="load-file"]`);
  3378.                         $btnLoadFile.off("click").click(async () => {
  3379.                                 const data = await DataUtil.pUserUpload();
  3380.                                 handleLoadedData(data);
  3381.                         });
  3382.  
  3383.                         const $winExportP1 = $("#d20plus-module-importer-select-exports-p1");
  3384.                         const $cbAllExport = $winExportP1.find(`[name="cb-all"]`);
  3385.  
  3386.                         const $btnExport = $win.find(`[name="export"]`);
  3387.                         $btnExport.off("click").click(() => {
  3388.                                 const CATS = [
  3389.                                         "characters",
  3390.                                         "decks",
  3391.                                         "handouts",
  3392.                                         "playlists",
  3393.                                         "tracks",
  3394.                                         "maps",
  3395.                                         "rolltables",
  3396.                                 ];
  3397.  
  3398.                                 $winExportP1.dialog("open");
  3399.  
  3400.                                 $cbAllExport.off("change").on("change", () => {
  3401.                                         CATS.forEach(cat => $winExportP1.find(`input[name="cb-${cat}"]`).prop("checked", $cbAllExport.prop("checked")))
  3402.                                 });
  3403.  
  3404.                                 $winExportP1.find("button").off("click").click(async () => {
  3405.                                         const isCatSelected = (name) => $winExportP1.find(`input[name="cb-${name}"]`).prop("checked");
  3406.  
  3407.                                         const catsToExport = new Set(CATS.filter(it => isCatSelected(it)));
  3408.  
  3409.                                         console.log("Exporting journal...");
  3410.                                         const journal = d20plus.journal.getExportableJournal();
  3411.  
  3412.                                         let maps;
  3413.                                         if (catsToExport.has("maps")) {
  3414.                                                 console.log("Exporting maps..."); // shoutouts to Stormy
  3415.                                                 maps = await Promise.all(d20.Campaign.pages.models.map(async map => {
  3416.                                                         const getOut = () => {
  3417.                                                                 return {
  3418.                                                                         attributes: map.attributes,
  3419.                                                                         graphics: (map.thegraphics || []).map(g => g.attributes),
  3420.                                                                         text: (map.thetexts || []).map(t => t.attributes),
  3421.                                                                         paths: (map.thepaths || []).map(p => p.attributes)
  3422.                                                                 };
  3423.                                                         };
  3424.  
  3425.                                                         if (map.get("archived")) {
  3426.                                                                 map.set({archived: false});
  3427.                                                                 await d20plus.ut.promiseDelay(d20plus.cfg.getOrDefault("import", "importIntervalHandout") * 2);
  3428.                                                                 const out = getOut();
  3429.                                                                 map.set({archived: true});
  3430.                                                                 return out;
  3431.                                                         } else {
  3432.                                                                 return getOut();
  3433.                                                         }
  3434.                                                 }));
  3435.                                         }
  3436.  
  3437.                                         let rolltables;
  3438.                                         if (catsToExport.has("rolltables")) {
  3439.                                                 console.log("Exporting rolltables...");
  3440.                                                 rolltables = d20.Campaign.rollabletables.models.map(rolltable => ({
  3441.                                                         attributes: rolltable.attributes,
  3442.                                                         tableitems: (rolltable.tableitems.models || []).map(tableitem => tableitem.attributes)
  3443.                                                 }));
  3444.                                         }
  3445.  
  3446.                                         let decks;
  3447.                                         if (catsToExport.has("decks")) {
  3448.                                                 console.log("Exporting decks...");
  3449.                                                 decks = d20.Campaign.decks.models.map(deck => {
  3450.                                                         if (deck.name && deck.name.toLowerCase() === "playing cards") return;
  3451.                                                         return {
  3452.                                                                 attributes: deck.attributes,
  3453.                                                                 cards: (deck.cards.models || []).map(card => card.attributes)
  3454.                                                         };
  3455.                                                 }).filter(it => it);
  3456.                                         }
  3457.  
  3458.                                         let playlists;
  3459.                                         if (catsToExport.has("playlists")) {
  3460.                                                 console.log("Exporting jukebox playlists...");
  3461.                                                 playlists = d20plus.jukebox.getExportablePlaylists();
  3462.                                         }
  3463.  
  3464.                                         let tracks;
  3465.                                         if (catsToExport.has("tracks")) {
  3466.                                                 console.log("Exporting jukebox tracks...");
  3467.                                                 tracks = d20plus.jukebox.getExportableTracks();
  3468.                                         }
  3469.  
  3470.                                         let blobCount = 0;
  3471.                                         let onBlobsReady = null;
  3472.                                         let anyBlobs = false;
  3473.  
  3474.                                         const handleBlob = (addTo, asKey, data) => {
  3475.                                                 addTo[asKey] = data;
  3476.                                                 blobCount--;
  3477.                                                 if (onBlobsReady && blobCount === 0) onBlobsReady();
  3478.                                         };
  3479.  
  3480.                                         let characters;
  3481.                                         if (catsToExport.has("characters")) {
  3482.                                                 anyBlobs = true;
  3483.                                                 console.log("Exporting characters...");
  3484.                                                 characters = d20.Campaign.characters.models.map(character => {
  3485.                                                         const out = {
  3486.                                                                 attributes: character.attributes,
  3487.                                                                 attribs: character.attribs,
  3488.                                                         };
  3489.                                                         const abilities = (character.abilities || {models: []}).models.map(ability => ability.attributes);
  3490.                                                         if (abilities && abilities.length) out.abilities = abilities;
  3491.                                                         blobCount += 3;
  3492.                                                         character._getLatestBlob("bio", (data) => handleBlob(out, "blobBio", data));
  3493.                                                         character._getLatestBlob("gmnotes", (data) => handleBlob(out, "blobGmNotes", data));
  3494.                                                         character._getLatestBlob("defaulttoken", (data) => handleBlob(out, "blobDefaultToken", data));
  3495.                                                         return out;
  3496.                                                 });
  3497.                                         }
  3498.  
  3499.                                         let handouts;
  3500.                                         if (catsToExport.has("handouts")) {
  3501.                                                 anyBlobs = true;
  3502.                                                 console.log("Exporting handouts...");
  3503.                                                 handouts = d20.Campaign.handouts.models.map(handout => {
  3504.                                                         if (handout.attributes.name === ART_HANDOUT || handout.attributes.name === CONFIG_HANDOUT) return;
  3505.  
  3506.                                                         const out = {
  3507.                                                                 attributes: handout.attributes
  3508.                                                         };
  3509.                                                         blobCount += 2;
  3510.                                                         handout._getLatestBlob("notes", (data) => handleBlob(out, "blobNotes", data));
  3511.                                                         handout._getLatestBlob("gmnotes", (data) => handleBlob(out, "blobGmNotes", data));
  3512.                                                         return out;
  3513.                                                 }).filter(it => it);
  3514.                                         }
  3515.  
  3516.                                         if (anyBlobs) console.log("Waiting for blobs...");
  3517.                                         onBlobsReady = () => {
  3518.                                                 if (anyBlobs) console.log("Blobs are ready!");
  3519.  
  3520.                                                 console.log("Preparing payload");
  3521.  
  3522.                                                 const payload = {
  3523.                                                         schema_version: 1, // version number from r20es
  3524.                                                 };
  3525.                                                 if (maps) payload.maps = maps;
  3526.                                                 if (rolltables) payload.rolltables = rolltables;
  3527.                                                 if (decks) payload.decks = decks;
  3528.                                                 if (journal) payload.journal = journal;
  3529.                                                 if (handouts) payload.handouts = handouts;
  3530.                                                 if (characters) payload.characters = characters;
  3531.                                                 if (playlists) payload.playlists = playlists;
  3532.                                                 if (tracks) payload.tracks = tracks;
  3533.  
  3534.                                                 const filename = document.title.replace(/\|\s*Roll20$/i, "").trim().replace(/[^\w\-]/g, "_");
  3535.                                                 const data = JSON.stringify(payload, null, "\t");
  3536.  
  3537.                                                 console.log("Saving");
  3538.                                                 const blob = new Blob([data], {type: "application/json"});
  3539.                                                 d20plus.ut.saveAs(blob, `${filename}.json`);
  3540.                                         };
  3541.                                         if (!anyBlobs || blobCount === 0) onBlobsReady();
  3542.                                 });
  3543.  
  3544.  
  3545.                                 // TODO
  3546.                                 /*
  3547.                                 macro
  3548.                                  */
  3549.                         });
  3550.                 }
  3551.         })
  3552. }
  3553.  
  3554. SCRIPT_EXTENSIONS.push(baseToolModule);
  3555.  
  3556.  
  3557. function baseToolUnlock () {
  3558.         d20plus.tool.tools.push({
  3559.                 toolId: "UNLOCKER",
  3560.                 name: "Token Unlocker",
  3561.                 desc: "Unlock previously-locked tokens",
  3562.                 html: `
  3563.                         <div id="d20plus-token-unlocker" title="Token Unlocker">
  3564.                                 <p>
  3565.                                         <button class="btn" name="btn-refresh">Refresh</button>
  3566.                                 </p>
  3567.                                 <p class="split">
  3568.                                         <label><input type="checkbox" title="Select all" name="cb-all"> Select All</label>
  3569.                                         <button class="btn" name="btn-unlock">Unlock Selected</button>
  3570.                                 </p>
  3571.                                 <div id="token-unlocker-list-container">
  3572.                                         <input class="search" autocomplete="off" placeholder="Search list..." style="width: 100%;">
  3573.                                         <br><br>
  3574.                                         <ul class="list unlock-list" style="max-height: 420px; overflow-y: scroll; display: block; margin: 0;"></ul>
  3575.                                 </div>
  3576.                         </div>
  3577.                 `,
  3578.                 dialogFn: () => {
  3579.                         const $win = $("#d20plus-token-unlocker").dialog({
  3580.                                 autoOpen: false,
  3581.                                 resizable: true,
  3582.                                 width: 800,
  3583.                                 height: 600,
  3584.                         }).data("VE_HANDLE_UPDATE", () => {
  3585.                                 d20.engine.canvas._objects.forEach(ob => {
  3586.                                         if (ob.model) {
  3587.                                                 const locked = ob.model.get("VeLocked");
  3588.                                                 if (locked) {
  3589.                                                         ob.lockMovementX = true;
  3590.                                                         ob.lockMovementY = true;
  3591.                                                         ob.lockScalingX = true;
  3592.                                                         ob.lockScalingY = true;
  3593.                                                         ob.lockRotation = true;
  3594.                                                         ob.saveState();
  3595.                                                 }
  3596.                                         }
  3597.                                 });
  3598.                         });
  3599.  
  3600.                         document.addEventListener("VePageChange", () => {
  3601.                                 $win.data("VE_HANDLE_UPDATE")();
  3602.                         });
  3603.  
  3604.                         document.addEventListener("VeLayerChange", () => {
  3605.                                 $win.data("VE_HANDLE_UPDATE")();
  3606.                         });
  3607.  
  3608.                         try {
  3609.                                 $win.data("VE_HANDLE_UPDATE")();
  3610.                         } catch (e) {
  3611.                                 d20plus.ut.error("Failed to re-lock tokens!")
  3612.                         }
  3613.                 },
  3614.                 openFn: () => {
  3615.                         const $win = $("#d20plus-token-unlocker");
  3616.                         $win.dialog("open");
  3617.                         const $wrpCbs = $(`#token-unlocker-list-container`).find(`.unlock-list`);
  3618.                         const $cbAll = $win.find(`[name="cb-all"]`);
  3619.                         const $btnUnlock = $win.find(`[name="btn-unlock"]`);
  3620.                         const $btnRefresh = $win.find(`[name="btn-refresh"]`).click(() => populateList());
  3621.  
  3622.                         function populateList () {
  3623.                                 const objects = d20.engine.canvas._objects.filter(it => it.model && it.model.get("VeLocked"));
  3624.                                 $wrpCbs.empty();
  3625.  
  3626.                                 objects.forEach(it => {
  3627.                                         $wrpCbs.append(`
  3628.                                                 <label class="import-cb-label" data-listid="${it.model.get("id")}">
  3629.                                                         <input type="checkbox">
  3630.                                                         <span class="name readable">${it.model.get("name") || `Unnamed${it.type ? ` ${it.type}` : ""}`}</span>
  3631.                                                 </label>
  3632.                                         `);
  3633.                                 });
  3634.  
  3635.                                 // init list library
  3636.                                 const unlockList = new List("token-unlocker-list-container", {
  3637.                                         valueNames: ["name"],
  3638.                                         listClass: "unlock-list"
  3639.                                 });
  3640.  
  3641.                                 $cbAll.prop("checked", false);
  3642.                                 $cbAll.off("click").click(() => d20plus.importer._importToggleSelectAll(unlockList, $cbAll));
  3643.  
  3644.                                 $btnUnlock.off("click").on("click", () => {
  3645.                                         const sel = unlockList.items
  3646.                                                 .filter(it => $(it.elm).find(`input`).prop("checked"))
  3647.                                                 .map(it => $(it.elm).attr("data-listid"));
  3648.  
  3649.                                         if (!sel.length) {
  3650.                                                 alert("No items selected!");
  3651.                                         } else {
  3652.                                                 const currObjects = d20.engine.canvas._objects.filter(it => it.model);
  3653.                                                 let counter = 0;
  3654.                                                 sel.forEach(toUnlock => {
  3655.                                                         const ob = currObjects.find(it => it.model && it.model.get("id") === toUnlock);
  3656.                                                         if (ob) {
  3657.                                                                 counter++;
  3658.                                                                 ob.lockMovementX = false;
  3659.                                                                 ob.lockMovementY = false;
  3660.                                                                 ob.lockScalingX = false;
  3661.                                                                 ob.lockScalingY = false;
  3662.                                                                 ob.lockRotation = false;
  3663.                                                                 ob.saveState();
  3664.  
  3665.                                                                 ob.model.set("VeLocked", false);
  3666.                                                                 ob.model.save();
  3667.                                                         }
  3668.                                                 });
  3669.                                                 alert(`${counter} item${counter === 1 ? "" : "s"} unlocked.`);
  3670.                                                 populateList();
  3671.                                         }
  3672.                                 });
  3673.                         }
  3674.  
  3675.                         populateList();
  3676.                 }
  3677.         })
  3678. }
  3679.  
  3680. SCRIPT_EXTENSIONS.push(baseToolUnlock);
  3681.  
  3682.  
  3683. function baseToolAnimator () {
  3684.         function cleanNulls (obj) {
  3685.                 Object.entries(obj).filter(([k, v]) => v == null).forEach(([k]) => delete obj[k]);
  3686.                 return obj;
  3687.         }
  3688.  
  3689.         d20plus.anim = {
  3690.                 lineFromParsed (parsed) {
  3691.                         const stack = [];
  3692.                         const add = (...parts) => parts.forEach(p => stack.push(p == null ? "-" : p));
  3693.  
  3694.                         stack.push(d20plus.anim.COMMAND_TO_SHORT[parsed._type]);
  3695.                         stack.push(parsed.start || 0);
  3696.  
  3697.                         switch (parsed._type) {
  3698.                                 case "Move":
  3699.                                 case "MoveExact": {
  3700.                                         stack.push(parsed.duration || 0);
  3701.                                         add(parsed.x, parsed.y, parsed.z);
  3702.                                         break;
  3703.                                 }
  3704.                                 case "Rotate":
  3705.                                 case "RotateExact": {
  3706.                                         stack.push(parsed.duration || 0);
  3707.                                         add(parsed.degrees);
  3708.                                         break;
  3709.                                 }
  3710.                                 case "Copy": {
  3711.                                         add(parsed.animation);
  3712.                                         break;
  3713.                                 }
  3714.                                 case "Flip":
  3715.                                 case "FlipExact": {
  3716.                                         add(parsed.flipH, parsed.flipV);
  3717.                                         break;
  3718.                                 }
  3719.                                 case "Scale":
  3720.                                 case "ScaleExact": {
  3721.                                         stack.push(parsed.duration || 0);
  3722.                                         add(parsed.scaleX, parsed.scaleY);
  3723.                                         break;
  3724.                                 }
  3725.                                 case "Layer": {
  3726.                                         add(parsed.layer);
  3727.                                         break;
  3728.                                 }
  3729.                                 case "Lighting":
  3730.                                 case "LightingExact": {
  3731.                                         stack.push(parsed.duration || 0);
  3732.                                         add(parsed.lightRadius, parsed.dimStart, parsed.degrees);
  3733.                                         break;
  3734.                                 }
  3735.                                 case "SetProperty":
  3736.                                 case "SumProperty": {
  3737.                                         add(parsed.prop, parsed.value);
  3738.                                         break;
  3739.                                 }
  3740.                                 case "TriggerMacro": {
  3741.                                         add(parsed.macro);
  3742.                                         break;
  3743.                                 }
  3744.                                 case "TriggerAnimation": {
  3745.                                         add(parsed.animation);
  3746.                                         break;
  3747.                                 }
  3748.                                 default: throw new Error(`Unhandled type "${parsed._type}"`);
  3749.                         }
  3750.  
  3751.                         return stack.join(" ");
  3752.                 },
  3753.  
  3754.                 deserialize: function (json) {
  3755.                         let out;
  3756.                         switch (json._type) {
  3757.                                 case "Nop": out = new d20plus.anim.Nop(); break;
  3758.                                 case "Move": out = new d20plus.anim.Move(json.startTime, json.duration, json.x, json.y, json.z); break;
  3759.                                 case "MoveExact": out = new d20plus.anim.MoveExact(json.startTime, json.duration, json.x, json.y, json.z); break;
  3760.                                 case "Copy": out = new d20plus.anim.Copy(json.startTime, json.childAnimation); break;
  3761.                                 case "Rotate": out = new d20plus.anim.Rotate(json.startTime, json.duration, json.degrees); break;
  3762.                                 case "RotateExact": out = new d20plus.anim.RotateExact(json.startTime, json.duration, json.degrees); break;
  3763.                                 case "Flip": out = new d20plus.anim.Flip(json.startTime, json.isHorizontal, json.isVertical); break;
  3764.                                 case "FlipExact": out = new d20plus.anim.FlipExact(json.startTime, json.isHorizontal, json.isVertical); break;
  3765.                                 case "Scale": out = new d20plus.anim.Scale(json.startTime, json.duration, json.scaleFactorX, json.scaleFactorY); break;
  3766.                                 case "ScaleExact": out = new d20plus.anim.ScaleExact(json.startTime, json.duration, json.scaleFactorX, json.scaleFactorY); break;
  3767.                                 case "Layer": out = new d20plus.anim.Layer(json.startTime, json.layer); break;
  3768.                                 case "SetProperty": out = new d20plus.anim.SetProperty(json.startTime, json.prop, json.value); break;
  3769.                                 case "SumProperty": out = new d20plus.anim.SumProperty(json.startTime, json.prop, json.value); break;
  3770.                                 case "Lighting": out = new d20plus.anim.Lighting(json.startTime, json.duration, json.lightRadius, json.dimStart, json.degrees); break;
  3771.                                 case "LightingExact": out = new d20plus.anim.LightingExact(json.startTime, json.duration, json.lightRadius, json.dimStart, json.degrees); break;
  3772.                                 case "TriggerMacro": out = new d20plus.anim.TriggerMacro(json.startTime, json.macroName); break;
  3773.                                 case "TriggerAnimation": out = new d20plus.anim.TriggerAnimation(json.startTime, json.animation); break;
  3774.                                 default: throw new Error(`Unhandled type "${json._type}"`);
  3775.                         }
  3776.                         out._hasRun = json._hasRun;
  3777.                         out._offset = json._offset;
  3778.                         out._progress = json._progress;
  3779.                         out._snapshotDiff = json._snapshotDiff;
  3780.                         return out;
  3781.                 },
  3782.  
  3783.                 // region animations
  3784.                 // Each has `animate` which accepts up to four parameters:
  3785.                 //   token: the token object being animated
  3786.                 //   alpha: the absolute time since the start of the animation's life
  3787.                 //   delta: the time delta from the last time the `animate` function was run
  3788.                 //   queue: the queue this animation is part of
  3789.                 // The `animate` function returns `true` if the token needs to be saved, `false` otherwise
  3790.                 // Each should also have:
  3791.                 //   `serialize` function
  3792.                 //   `hasRun` function; returns `true` if the animation has been run, and can therefore be safely removed from any queues
  3793.                 //   `setOffset` function; sets a start time offset for the animation. Used when triggering child animations
  3794.                 _Base: function () {
  3795.                         this._hasRun = false;
  3796.                         this._offset = 0;
  3797.                         this._progress = 0; // 0 - 1f
  3798.                         this._snapshotDiff = null;
  3799.  
  3800.                         this.hasRun = () => this._hasRun;
  3801.                         this.setOffset = offset => this._offset = offset;
  3802.                         this.isLastTick = () => !(this._progress < (1 - Number.EPSILON));
  3803.                         this._serialize = () => {
  3804.                                 // remove any undefined properties
  3805.                                 const rawOut = {
  3806.                                         _type: this.constructor.name,
  3807.                                         _hasRun: this._hasRun,
  3808.                                         _offset: this._offset,
  3809.                                         _progress: this._progress,
  3810.                                         _snapshotDiff: this._snapshotDiff
  3811.                                 };
  3812.                                 const out = {};
  3813.                                 Object.entries(rawOut).forEach(([k, v]) => {
  3814.                                         if (v != null) out[k] = v;
  3815.                                 });
  3816.                                 return out;
  3817.                         };
  3818.  
  3819.                         this._getTickProgress = (duration, delta) => {
  3820.                                 let mProgress = duration === 0 ? 1 : Math.min(1, delta / duration);
  3821.                                 // prevent progress from going past 100%
  3822.                                 if (this._progress + mProgress > 1) mProgress = 1 - this._progress;
  3823.                                 return mProgress;
  3824.                         };
  3825.                 },
  3826.  
  3827.                 Nop: function () {
  3828.                         d20plus.anim._Base.call(this);
  3829.  
  3830.                         this.animate = function () {
  3831.                                 return false;
  3832.                         };
  3833.  
  3834.                         this.hasRun = () => true;
  3835.                         this.serialize = () => {};
  3836.                 },
  3837.  
  3838.                 _BaseMove: function (startTime, duration, x, y, z) {
  3839.                         d20plus.anim._Base.call(this);
  3840.  
  3841.                         this.serialize = () => {
  3842.                                 return cleanNulls({
  3843.                                         ...this._serialize(),
  3844.                                         startTime, duration, x, y, z
  3845.                                 })
  3846.                         };
  3847.  
  3848.                         this._getCurrentZ = (token) => {
  3849.                                 const statuses = (token.attributes.statusmarkers || "").split(",");
  3850.                                 let total = 0;
  3851.                                 let pow = 1;
  3852.                                 let stack = "";
  3853.  
  3854.                                 // reverse loop through the fluffy wings, multiplying vals by 1/10/100...
  3855.                                 const len = statuses.length;
  3856.                                 for (let i = len - 1; i >= 0; --i) {
  3857.                                         const [name, val] = statuses[i].split("@");
  3858.                                         if (name === "fluffy-wing") {
  3859.                                                 total += pow * Number(val);
  3860.                                                 pow = pow * 10;
  3861.                                         } else {
  3862.                                                 stack += statuses[i] + ",";
  3863.                                         }
  3864.                                 }
  3865.  
  3866.                                 return {total, stack};
  3867.                         };
  3868.  
  3869.                         this._setCurrentZ = (token, stack, total) => {
  3870.                                 if (total) {
  3871.                                         const nums = String(Math.round(total)).split("");
  3872.                                         for (let i = 0; i < nums.length; ++i) {
  3873.                                                 stack += `fluffy-wing@${nums[i]}${i < nums.length - 1 ? "," : ""}`;
  3874.                                         }
  3875.                                 } else stack = stack.replace(/,$/, "");
  3876.  
  3877.                                 token.attributes.statusmarkers = stack;
  3878.                         };
  3879.                 },
  3880.  
  3881.                 Move: function (startTime, duration, x, y, z) {
  3882.                         d20plus.anim._BaseMove.call(this, startTime, duration, x, y, z);
  3883.  
  3884.                         this.animate = function (token, alpha, delta) {
  3885.                                 alpha = alpha - this._offset;
  3886.  
  3887.                                 if (alpha >= startTime) {
  3888.                                         if (this._progress < (1 - Number.EPSILON)) {
  3889.                                                 const mProgress = this._getTickProgress(duration, delta);
  3890.  
  3891.                                                 // handle movement
  3892.                                                 if (x != null) token.attributes.left += mProgress * x;
  3893.                                                 if (y != null) token.attributes.top -= mProgress * y;
  3894.                                                 if (z != null) {
  3895.                                                         let {total, stack} = this._getCurrentZ(token);
  3896.                                                         total += mProgress * z;
  3897.                                                         this._setCurrentZ(token, stack, total);
  3898.                                                 }
  3899.  
  3900.                                                 // update progress
  3901.                                                 this._progress += mProgress;
  3902.  
  3903.                                                 return true;
  3904.                                         } else this._hasRun = true;
  3905.                                 }
  3906.                                 return false;
  3907.                         };
  3908.                 },
  3909.  
  3910.                 MoveExact: function (startTime, duration, x, y, z) {
  3911.                         d20plus.anim._BaseMove.call(this, startTime, duration, x, y, z);
  3912.  
  3913.                         this.animate = function (token, alpha, delta) {
  3914.                                 alpha = alpha - this._offset;
  3915.  
  3916.                                 if (alpha >= startTime) {
  3917.                                         if (this._snapshotDiff == null) {
  3918.                                                 const {total} = this._getCurrentZ(token);
  3919.                                                 this._snapshotDiff = {
  3920.                                                         x: (x || 0) - (token.attributes.left || 0),
  3921.                                                         y: (y || 0) - (token.attributes.top || 0),
  3922.                                                         z: (z || 0) - (total),
  3923.                                                 };
  3924.                                         }
  3925.  
  3926.                                         if (this._progress < (1 - Number.EPSILON)) {
  3927.                                                 const mProgress = this._getTickProgress(duration, delta);
  3928.  
  3929.                                                 // handle movement
  3930.                                                 if (x != null) token.attributes.left += mProgress * this._snapshotDiff.x;
  3931.                                                 if (y != null) token.attributes.top -= mProgress * this._snapshotDiff.y;
  3932.                                                 if (z != null) {
  3933.                                                         let {total, stack} = this._getCurrentZ(token);
  3934.                                                         total += mProgress * this._snapshotDiff.z;
  3935.                                                         this._setCurrentZ(token, stack, total);
  3936.                                                 }
  3937.  
  3938.                                                 // update progress
  3939.                                                 this._progress += mProgress;
  3940.  
  3941.                                                 // on the last tick, update to precise values
  3942.                                                 if (this.isLastTick()) {
  3943.                                                         if (x != null) token.attributes.left = x;
  3944.                                                         if (y != null) token.attributes.top = -y;
  3945.                                                         if (z != null) {
  3946.                                                                 let {stack} = this._getCurrentZ(token);
  3947.                                                                 this._setCurrentZ(token, stack, z);
  3948.                                                         }
  3949.                                                 }
  3950.  
  3951.                                                 return true;
  3952.                                         } else this._hasRun = true;
  3953.                                 }
  3954.                                 return false;
  3955.                         };
  3956.                 },
  3957.  
  3958.                 Copy: function (startTime, childAnimation = false) {
  3959.                         d20plus.anim._Base.call(this);
  3960.  
  3961.                         this.animate = function (token, alpha, delta, queue) {
  3962.                                 alpha = alpha - this._offset;
  3963.  
  3964.                                 if (!this._hasRun && alpha >= startTime) {
  3965.                                         this._hasRun = true;
  3966.  
  3967.                                         // based on "d20.clipboard.doCopy"
  3968.                                         const graphic = token.view.graphic;
  3969.                                         const attrs = {
  3970.                                                 ...MiscUtil.copy(graphic)
  3971.                                         };
  3972.  
  3973.                                         const modelattrs = {};
  3974.                                         const json = token.toJSON();
  3975.                                         d20.token_editor.tokenkeys.forEach(k => modelattrs[k] = json[k]);
  3976.  
  3977.                                         const cpy = {
  3978.                                                 type: token.attributes.type,
  3979.                                                 attrs,
  3980.                                                 modelattrs,
  3981.                                                 oldid: token.id,
  3982.                                                 groupwith: ""
  3983.                                         };
  3984.  
  3985.                                         // based on "d20.clipboard.doPaste"
  3986.                                         let childToken;
  3987.                                         const page = d20.Campaign.pages.models.find(model => model.thegraphics.models.find(it => it.id === token.id));
  3988.                                         if ("image" === cpy.type) {
  3989.                                                 attrs.imgsrc = attrs.src;
  3990.                                                 childToken = page.addImage(attrs, true, false, false, false, true);
  3991.                                                 if (cpy.modelattrs && cpy.modelattrs.represents) {
  3992.                                                         const char = d20.Campaign.characters.get(cpy.modelattrs.represents);
  3993.  
  3994.                                                         if (char) {
  3995.                                                                 const updateBarN = (n) => {
  3996.                                                                         const prop = `bar${n}_link`;
  3997.                                                                         if ("" !== cpy.modelattrs[prop] && (-1 !== cpy.modelattrs[prop].indexOf("sheetattr_"))) {
  3998.                                                                                 const l = cpy.modelattrs[prop].split("sheetattr_")[1];
  3999.                                                                                 setTimeout(() => char.updateTokensByName(l), 0.5);
  4000.                                                                         } else {
  4001.                                                                                 const s = char.attribs.get(cpy.modelattrs[prop]);
  4002.                                                                                 const l = s.get("name");
  4003.                                                                                 setTimeout(() => char.updateTokensByName(l, cpy.modelattrs[prop]), 0.5);
  4004.                                                                         }
  4005.                                                                 };
  4006.                                                                 updateBarN(1);
  4007.                                                                 updateBarN(2);
  4008.                                                                 updateBarN(3);
  4009.                                                         }
  4010.                                                 }
  4011.  
  4012.                                                 childToken && childToken.save(cpy.modelattrs);
  4013.                                         }
  4014.  
  4015.                                         if (childToken && childAnimation) {
  4016.                                                 const nxt = new d20plus.anim.TriggerAnimation(startTime, childAnimation);
  4017.                                                 nxt.animate(childToken, alpha, delta, queue);
  4018.                                         }
  4019.                                 }
  4020.                                 return false;
  4021.                         };
  4022.  
  4023.                         this.serialize = () => {
  4024.                                 return cleanNulls({
  4025.                                         ...this._serialize(),
  4026.                                         startTime, childAnimation
  4027.                                 })
  4028.                         };
  4029.                 },
  4030.  
  4031.                 _BaseRotate: function (startTime, duration, degrees) {
  4032.                         d20plus.anim._Base.call(this);
  4033.  
  4034.                         this.serialize = () => {
  4035.                                 return cleanNulls({
  4036.                                         ...this._serialize(),
  4037.                                         startTime, duration, degrees
  4038.                                 })
  4039.                         };
  4040.                 },
  4041.  
  4042.                 Rotate: function (startTime, duration, degrees) {
  4043.                         d20plus.anim._BaseRotate.call(this, startTime, duration, degrees);
  4044.  
  4045.                         this.animate = function (token, alpha, delta) {
  4046.                                 alpha = alpha - this._offset;
  4047.  
  4048.                                 if (alpha >= startTime) {
  4049.                                         if (this._progress < (1 - Number.EPSILON)) {
  4050.                                                 const mProgress = this._getTickProgress(duration, delta);
  4051.  
  4052.                                                 // handle rotation
  4053.                                                 if (degrees != null) {
  4054.                                                         const rot = mProgress * degrees;
  4055.                                                         token.attributes.rotation += rot;
  4056.                                                 }
  4057.  
  4058.                                                 // update progress
  4059.                                                 this._progress += mProgress;
  4060.  
  4061.                                                 return true;
  4062.                                         } else this._hasRun = true;
  4063.                                 }
  4064.                                 return false;
  4065.                         };
  4066.                 },
  4067.  
  4068.                 RotateExact: function (startTime, duration, degrees) {
  4069.                         d20plus.anim._BaseRotate.call(this, startTime, duration, degrees);
  4070.  
  4071.                         this.animate = function (token, alpha, delta) {
  4072.                                 alpha = alpha - this._offset;
  4073.  
  4074.                                 if (alpha >= startTime) {
  4075.                                         if (this._snapshotDiff == null) {
  4076.                                                 this._snapshotDiff = {
  4077.                                                         degrees: (degrees || 0) - Number(token.attributes.rotation || 0)
  4078.                                                 };
  4079.                                         }
  4080.  
  4081.                                         if (this._progress < (1 - Number.EPSILON)) {
  4082.                                                 const mProgress = this._getTickProgress(duration, delta);
  4083.  
  4084.                                                 // handle rotation
  4085.                                                 if (degrees != null) token.attributes.rotation += mProgress * this._snapshotDiff.degrees;
  4086.  
  4087.                                                 // update progress
  4088.                                                 this._progress += mProgress;
  4089.  
  4090.                                                 // on the last tick, update to precise values
  4091.                                                 if (this.isLastTick()) {
  4092.                                                         if (degrees != null) token.attributes.rotation = degrees;
  4093.                                                 }
  4094.  
  4095.                                                 return true;
  4096.                                         } else this._hasRun = true;
  4097.                                 }
  4098.                                 return false;
  4099.                         };
  4100.                 },
  4101.  
  4102.                 _BaseFlip: function (startTime, isHorizontal, isVertical) {
  4103.                         d20plus.anim._Base.call(this);
  4104.  
  4105.                         this.serialize = () => {
  4106.                                 return cleanNulls({
  4107.                                         ...this._serialize(),
  4108.                                         startTime, isHorizontal, isVertical
  4109.                                 })
  4110.                         };
  4111.                 },
  4112.  
  4113.                 Flip: function (startTime, isHorizontal, isVertical) {
  4114.                         d20plus.anim._BaseFlip.call(this, startTime, isHorizontal, isVertical);
  4115.  
  4116.                         this.animate = function (token, alpha) {
  4117.                                 alpha = alpha - this._offset;
  4118.  
  4119.                                 if (!this._hasRun && alpha >= startTime) {
  4120.                                         this._hasRun = true;
  4121.  
  4122.                                         if (isHorizontal != null && isHorizontal) token.set("fliph", !(typeof token.get("fliph") === "string" ? token.get("fliph") === "true" : token.get("fliph")));
  4123.                                         if (isVertical != null && isVertical) token.set("flipv", !(typeof token.get("flipv") === "string" ? token.get("flipv") === "true" : token.get("flipv")));
  4124.  
  4125.                                         return true;
  4126.                                 }
  4127.                                 return false;
  4128.                         };
  4129.                 },
  4130.  
  4131.                 FlipExact: function (startTime, isHorizontal, isVertical) {
  4132.                         d20plus.anim._BaseFlip.call(this, startTime, isHorizontal, isVertical);
  4133.  
  4134.                         this.animate = function (token, alpha) {
  4135.                                 alpha = alpha - this._offset;
  4136.  
  4137.                                 if (!this._hasRun && alpha >= startTime) {
  4138.                                         this._hasRun = true;
  4139.  
  4140.                                         if (isHorizontal != null) token.set("fliph", isHorizontal);
  4141.                                         if (isVertical != null) token.set("fliph", isVertical);
  4142.  
  4143.                                         return true;
  4144.                                 }
  4145.                                 return false;
  4146.                         };
  4147.                 },
  4148.  
  4149.                 _BaseScale: function (startTime, duration, scaleFactorX, scaleFactorY) {
  4150.                         d20plus.anim._Base.call(this);
  4151.  
  4152.                         this.serialize = () => {
  4153.                                 return cleanNulls({
  4154.                                         ...this._serialize(),
  4155.                                         startTime, duration, scaleFactorX, scaleFactorY
  4156.                                 })
  4157.                         };
  4158.                 },
  4159.  
  4160.                 Scale: function (startTime, duration, scaleFactorX, scaleFactorY) {
  4161.                         d20plus.anim._BaseScale.call(this, startTime, duration, scaleFactorX, scaleFactorY);
  4162.  
  4163.                         this.animate = function (token, alpha, delta) {
  4164.                                 alpha = alpha - this._offset;
  4165.  
  4166.                                 if (alpha >= startTime) {
  4167.                                         if (this._progress < (1 - Number.EPSILON)) {
  4168.                                                 const mProgress = this._getTickProgress(duration, delta);
  4169.  
  4170.                                                 // handle scaling
  4171.                                                 if (scaleFactorX != null) {
  4172.                                                         const mScaleX = mProgress * scaleFactorX;
  4173.                                                         token.view.graphic.scaleX = Number(token.view.graphic.scaleX || 0) + mScaleX;
  4174.                                                         token.attributes.scaleX = token.view.graphic.scaleX;
  4175.                                                 }
  4176.  
  4177.                                                 if (scaleFactorY != null) {
  4178.                                                         const mScaleY = mProgress * scaleFactorY;
  4179.                                                         token.view.graphic.scaleY = Number(token.view.graphic.scaleY || 0) + mScaleY;
  4180.                                                         token.attributes.scaleY = token.view.graphic.scaleY;
  4181.                                                 }
  4182.  
  4183.                                                 // update progress
  4184.                                                 this._progress += mProgress;
  4185.  
  4186.                                                 return true;
  4187.                                         } else this._hasRun = true;
  4188.                                 }
  4189.                                 return false;
  4190.                         };
  4191.                 },
  4192.  
  4193.                 ScaleExact: function (startTime, duration, scaleFactorX, scaleFactorY) {
  4194.                         d20plus.anim._BaseScale.call(this, startTime, duration, scaleFactorX, scaleFactorY);
  4195.  
  4196.                         this.animate = function (token, alpha, delta) {
  4197.                                 alpha = alpha - this._offset;
  4198.  
  4199.                                 if (alpha >= startTime) {
  4200.                                         if (this._snapshotDiff == null) {
  4201.                                                 this._snapshotDiff = {
  4202.                                                         scaleX: (scaleFactorX || 0) - (token.view.graphic.scaleX || 0),
  4203.                                                         scaleY: (scaleFactorY || 0) - (token.view.graphic.scaleY || 0),
  4204.                                                 };
  4205.                                         }
  4206.  
  4207.                                         if (this._progress < (1 - Number.EPSILON)) {
  4208.                                                 const mProgress = this._getTickProgress(duration, delta);
  4209.  
  4210.                                                 // handle scaling
  4211.                                                 if (scaleFactorX != null) {
  4212.                                                         token.view.graphic.scaleX += mProgress * this._snapshotDiff.scaleX;
  4213.                                                         token.attributes.scaleX = token.view.graphic.scaleX;
  4214.                                                 }
  4215.  
  4216.                                                 if (scaleFactorY != null) {
  4217.                                                         token.view.graphic.scaleY += mProgress * this._snapshotDiff.scaleY;
  4218.                                                         token.attributes.scaleY = token.view.graphic.scaleY;
  4219.                                                 }
  4220.  
  4221.                                                 // update progress
  4222.                                                 this._progress += mProgress;
  4223.  
  4224.                                                 // on the last tick, update to precise values
  4225.                                                 if (this.isLastTick()) {
  4226.                                                         if (scaleFactorX != null) {
  4227.                                                                 token.view.graphic.scaleX = scaleFactorX;
  4228.                                                                 token.attributes.scaleX = token.view.graphic.scaleX;
  4229.                                                         }
  4230.  
  4231.                                                         if (scaleFactorY != null) {
  4232.                                                                 token.view.graphic.scaleY = scaleFactorY;
  4233.                                                                 token.attributes.scaleY = token.view.graphic.scaleY;
  4234.                                                         }
  4235.                                                 }
  4236.  
  4237.                                                 return true;
  4238.                                         } else this._hasRun = true;
  4239.                                 }
  4240.                                 return false;
  4241.                         };
  4242.                 },
  4243.  
  4244.                 Layer: function (startTime, layer) {
  4245.                         d20plus.anim._Base.call(this);
  4246.  
  4247.                         this.animate = function (token, alpha) {
  4248.                                 alpha = alpha - this._offset;
  4249.  
  4250.                                 if (!this._hasRun && alpha >= startTime) {
  4251.                                         this._hasRun = true;
  4252.  
  4253.                                         if (layer != null) {
  4254.                                                 token.attributes.layer = layer;
  4255.                                         }
  4256.  
  4257.                                         return true;
  4258.                                 }
  4259.                                 return false;
  4260.                         };
  4261.  
  4262.                         this.serialize = () => {
  4263.                                 return cleanNulls({
  4264.                                         ...this._serialize(),
  4265.                                         startTime, layer
  4266.                                 })
  4267.                         };
  4268.                 },
  4269.  
  4270.                 _BaseProperty: function (startTime, prop, value) {
  4271.                         d20plus.anim._Base.call(this);
  4272.  
  4273.                         this.serialize = () => {
  4274.                                 return cleanNulls({
  4275.                                         ...this._serialize(),
  4276.                                         startTime, prop, value
  4277.                                 })
  4278.                         };
  4279.                 },
  4280.  
  4281.                 SumProperty: function (startTime, prop, value) {
  4282.                         d20plus.anim._BaseProperty.call(this, startTime, prop, value);
  4283.  
  4284.                         this.animate = function (token, alpha) {
  4285.                                 alpha = alpha - this._offset;
  4286.  
  4287.                                 if (!this._hasRun && alpha >= startTime) {
  4288.                                         this._hasRun = true;
  4289.  
  4290.                                         if (prop != null) {
  4291.                                                 const curNum = Number(token.attributes[prop]);
  4292.                                                 token.attributes[prop] = (isNaN(curNum) ? 0 : curNum) + eval(value);
  4293.                                         }
  4294.  
  4295.                                         return true;
  4296.                                 }
  4297.                                 return false;
  4298.                         };
  4299.                 },
  4300.  
  4301.                 // TODO consider making an alternate version which sets a property on the character
  4302.                 // TODO consider the ability to set properties on _other_ tokens -- might not be performant enough?
  4303.                 SetProperty: function (startTime, prop, value) {
  4304.                         d20plus.anim._BaseProperty.call(this, startTime, prop, value);
  4305.  
  4306.                         this.animate = function (token, alpha) {
  4307.                                 alpha = alpha - this._offset;
  4308.  
  4309.                                 if (!this._hasRun && alpha >= startTime) {
  4310.                                         this._hasRun = true;
  4311.  
  4312.                                         if (prop != null) {
  4313.                                                 if (prop === "gmnotes") value = escape(value);
  4314.                                                 else if (prop === "sides") value = value.split("|").map(it => escape(it)).join("|");
  4315.                                                 token.attributes[prop] = value;
  4316.                                         }
  4317.  
  4318.                                         return true;
  4319.                                 }
  4320.                                 return false;
  4321.                         };
  4322.                 },
  4323.  
  4324.                 _BaseLighting: function (startTime, duration, lightRadius, dimStart, degrees) {
  4325.                         d20plus.anim._Base.call(this);
  4326.  
  4327.                         this.serialize = () => {
  4328.                                 return cleanNulls({
  4329.                                         ...this._serialize(),
  4330.                                         startTime, duration, lightRadius, dimStart, degrees
  4331.                                 })
  4332.                         };
  4333.                 },
  4334.  
  4335.                 Lighting: function (startTime, duration, lightRadius, dimStart, degrees) {
  4336.                         d20plus.anim._BaseLighting.call(this, startTime, duration, lightRadius, dimStart, degrees);
  4337.  
  4338.                         this.animate = function (token, alpha, delta) {
  4339.                                 alpha = alpha - this._offset;
  4340.  
  4341.                                 if (alpha >= startTime) {
  4342.                                         if (this._progress < (1 - Number.EPSILON)) {
  4343.                                                 const mProgress = this._getTickProgress(duration, delta);
  4344.  
  4345.                                                 // handle lighting changes
  4346.                                                 if (lightRadius != null) token.attributes.light_radius = Number(token.attributes.light_radius || 0) + mProgress * lightRadius;
  4347.                                                 if (dimStart != null) token.attributes.light_dimradius = Number(token.attributes.light_dimradius || 0) + mProgress * dimStart;
  4348.                                                 if (degrees != null) {
  4349.                                                         if (token.attributes.light_angle === "") token.attributes.light_angle = 360;
  4350.                                                         token.attributes.light_angle = Number(token.attributes.light_angle || 0) + mProgress * degrees;
  4351.                                                 }
  4352.  
  4353.                                                 // update progress
  4354.                                                 this._progress += mProgress;
  4355.  
  4356.                                                 return true;
  4357.                                         } else this._hasRun = true;
  4358.                                 }
  4359.                                 return false;
  4360.                         };
  4361.                 },
  4362.  
  4363.                 LightingExact: function (startTime, duration, lightRadius, dimStart, degrees) {
  4364.                         d20plus.anim._BaseLighting.call(this, startTime, duration, lightRadius, dimStart, degrees);
  4365.  
  4366.                         this.animate = function (token, alpha, delta) {
  4367.                                 alpha = alpha - this._offset;
  4368.  
  4369.                                 if (alpha >= startTime) {
  4370.                                         if (this._snapshotDiff == null) {
  4371.                                                 this._snapshotDiff = {
  4372.                                                         lightRadius: (lightRadius || 0) - Number(token.attributes.light_radius || 0),
  4373.                                                         dimStart: (dimStart || 0) - Number(token.attributes.light_dimradius || 0),
  4374.                                                         degrees: (degrees || 0) - Number(token.attributes.light_angle || 0),
  4375.                                                 };
  4376.                                         }
  4377.  
  4378.                                         if (this._progress < (1 - Number.EPSILON)) {
  4379.                                                 const mProgress = this._getTickProgress(duration, delta);
  4380.  
  4381.                                                 // handle lighting changes
  4382.                                                 if (lightRadius != null) token.attributes.light_radius = Number(token.attributes.light_radius) + mProgress * this._snapshotDiff.lightRadius;
  4383.                                                 if (dimStart != null) token.attributes.light_dimradius = Number(token.attributes.light_dimradius) + mProgress * this._snapshotDiff.dimStart;
  4384.                                                 if (degrees != null) token.attributes.light_angle = Number(token.attributes.light_angle) + mProgress * this._snapshotDiff.degrees;
  4385.  
  4386.                                                 // update progress
  4387.                                                 this._progress += mProgress;
  4388.  
  4389.                                                 if (this.isLastTick()) {
  4390.                                                         if (lightRadius != null) token.attributes.light_radius = lightRadius;
  4391.                                                         if (dimStart != null) token.attributes.light_dimradius = dimStart;
  4392.                                                         if (degrees != null) token.attributes.light_angle = degrees;
  4393.                                                 }
  4394.  
  4395.                                                 return true;
  4396.                                         } else this._hasRun = true;
  4397.                                 }
  4398.                                 return false;
  4399.                         };
  4400.                 },
  4401.  
  4402.                 TriggerMacro: function (startTime, macroName) {
  4403.                         d20plus.anim._Base.call(this);
  4404.  
  4405.                         this.animate = function (token, alpha) {
  4406.                                 alpha = alpha - this._offset;
  4407.  
  4408.                                 if (!this._hasRun && alpha >= startTime) {
  4409.                                         this._hasRun = true;
  4410.  
  4411.                                         if (macroName != null) {
  4412.                                                 d20.textchat.doChatInput(`#${macroName}`)
  4413.                                         }
  4414.                                 }
  4415.                                 return false;
  4416.                         };
  4417.  
  4418.                         this.serialize = () => {
  4419.                                 return cleanNulls({
  4420.                                         ...this._serialize(),
  4421.                                         startTime, macroName
  4422.                                 })
  4423.                         };
  4424.                 },
  4425.  
  4426.                 TriggerAnimation: function (startTime, animation) {
  4427.                         d20plus.anim._Base.call(this);
  4428.  
  4429.                         this.animate = function (token, alpha, delta, queue) {
  4430.                                 alpha = alpha - this._offset;
  4431.  
  4432.                                 if (!this._hasRun && alpha >= startTime) {
  4433.                                         this._hasRun = true;
  4434.  
  4435.                                         if (animation != null) {
  4436.                                                 const anim = d20plus.anim.animatorTool.getAnimationByName(animation);
  4437.  
  4438.                                                 if (!anim) return false; // if it has been deleted/etc
  4439.  
  4440.                                                 const nxtQueue = d20plus.anim.animatorTool.getAnimQueue(anim);
  4441.                                                 nxtQueue.forEach(it => it.setOffset(alpha + this._offset));
  4442.                                                 queue.push(...nxtQueue);
  4443.                                         }
  4444.                                 }
  4445.                                 return false;
  4446.                         };
  4447.  
  4448.                         this.serialize = () => {
  4449.                                 return cleanNulls({
  4450.                                         ...this._serialize(),
  4451.                                         startTime, animation
  4452.                                 })
  4453.                         };
  4454.                 }
  4455.                 // endregion animations
  4456.         };
  4457.  
  4458.         function Command (line, error, cons = null, parsed = null) {
  4459.                 this.line = line;
  4460.                 this.error = error;
  4461.                 this.isRunnable = !!cons;
  4462.                 this.parsed = parsed;
  4463.  
  4464.                 this.getInstance = function () {
  4465.                         return new cons();
  4466.                 };
  4467.         }
  4468.  
  4469.         Command.errInvalidArgCount = function (line, ...counts) { return new Command(line, `Invalid argument count; expected ${counts.joinConjunct(", ", " or ")}`)};
  4470.         Command.errPropNum = function (line, prop, val) { return new Command(line, `${prop} "${val}" was not a number`)};
  4471.         Command.errPropBool = function (line, prop, val) { return new Command(line, `${prop} "${val}" was not a boolean`)};
  4472.         Command.errPropLayer = function (line, prop, val) { return new Command(line, `${prop} "${val}" was not a layer (valid layers are: ${d20plus.ut.LAYERS.joinConjunct(", ", " or ")})`)};
  4473.         Command.errPropToken = function (line, prop, val) { return new Command(line, `${prop} "${val}" was not a token property`)};
  4474.         Command.errValNeg = function (line, prop, val) { return new Command(line, `${prop} "${val}" was negative`)};
  4475.  
  4476.         Command.errStartNum = function (line, val) { return Command.errPropNum(line, "start time", val)};
  4477.         Command.errStartNeg = function (line, val) { return Command.errValNeg(line, "start time", val)};
  4478.         Command.errDurationNum = function (line, val) { return Command.errPropNum(line, "duration", val)};
  4479.         Command.errDurationNeg = function (line, val) { return Command.errValNeg(line, "duration", val)};
  4480.  
  4481.         Command.fromString = function (line) {
  4482.                 const cleanLine = line
  4483.                         .split("/\/\//g")[0] // handle comments
  4484.                         .trim();
  4485.                 const tokens = cleanLine.split(/ +/g).filter(Boolean);
  4486.                 if (!tokens.length) return new Command(line);
  4487.  
  4488.                 const op = tokens.shift();
  4489.                 switch (op) {
  4490.                         case "mv":
  4491.                         case "mvx": {
  4492.                                 if (tokens.length !== 5) return Command.errInvalidArgCount(line, 5);
  4493.                                 const nStart = Number(tokens[0]);
  4494.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4495.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4496.                                 const nDuration = Number(tokens[1]);
  4497.                                 if (isNaN(nDuration)) return Command.errDurationNum(line, tokens[1]);
  4498.                                 if (nDuration < 0) return Command.errDurationNeg(line, tokens[1]);
  4499.  
  4500.                                 const nX = tokens[2] === "-" ? null : Number(tokens[2]);
  4501.                                 if (nX != null && isNaN(nX)) return Command.errPropNum(line, "x", tokens[2]);
  4502.                                 const nY = tokens[3] === "-" ? null : Number(tokens[3]);
  4503.                                 if (nY != null && isNaN(nY)) return Command.errPropNum(line, "y", tokens[3]);
  4504.                                 const nZ = tokens[4] === "-" ? null : Number(tokens[4]);
  4505.                                 if (nZ != null && isNaN(nY)) return Command.errPropNum(line, "z", tokens[4]);
  4506.  
  4507.                                 if (op === "mv") {
  4508.                                         return new Command(
  4509.                                                 line,
  4510.                                                 null,
  4511.                                                 d20plus.anim.Move.bind(null, nStart, nDuration, nX, nY, nZ),
  4512.                                                 {
  4513.                                                         _type: "Move",
  4514.                                                         start: nStart,
  4515.                                                         duration: nDuration,
  4516.                                                         x: nX,
  4517.                                                         y: nY,
  4518.                                                         z: nZ
  4519.                                                 }
  4520.                                         );
  4521.                                 } else {
  4522.                                         return new Command(
  4523.                                                 line,
  4524.                                                 null,
  4525.                                                 d20plus.anim.MoveExact.bind(null, nStart, nDuration, nX, nY, nZ),
  4526.                                                 {
  4527.                                                         _type: "MoveExact",
  4528.                                                         start: nStart,
  4529.                                                         duration: nDuration,
  4530.                                                         x: nX,
  4531.                                                         y: nY,
  4532.                                                         z: nZ
  4533.                                                 }
  4534.                                         );
  4535.                                 }
  4536.                         }
  4537.  
  4538.                         case "rot":
  4539.                         case "rotx": {
  4540.                                 if (tokens.length !== 3) return Command.errInvalidArgCount(line, 3);
  4541.                                 const nStart = Number(tokens[0]);
  4542.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4543.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4544.                                 const nDuration = Number(tokens[1]);
  4545.                                 if (isNaN(nDuration)) return Command.errDurationNum(line, tokens[1]);
  4546.                                 if (nDuration < 0) return Command.errDurationNeg(line, tokens[1]);
  4547.  
  4548.                                 const nRot = tokens[2] === "-" ? null : Number(tokens[2]);
  4549.                                 if (nRot != null && isNaN(nRot)) return Command.errPropNum(line, "degrees", tokens[2]);
  4550.  
  4551.                                 if (op === "rot") {
  4552.                                         return new Command(
  4553.                                                 line,
  4554.                                                 null,
  4555.                                                 d20plus.anim.Rotate.bind(null, nStart, nDuration, nRot),
  4556.                                                 {
  4557.                                                         _type: "Rotate",
  4558.                                                         start: nStart,
  4559.                                                         duration: nDuration,
  4560.                                                         degrees: nRot
  4561.                                                 }
  4562.                                         );
  4563.                                 } else {
  4564.                                         return new Command(
  4565.                                                 line,
  4566.                                                 null,
  4567.                                                 d20plus.anim.RotateExact.bind(null, nStart, nDuration, nRot),
  4568.                                                 {
  4569.                                                         _type: "RotateExact",
  4570.                                                         start: nStart,
  4571.                                                         duration: nDuration,
  4572.                                                         degrees: nRot
  4573.                                                 }
  4574.                                         );
  4575.                                 }
  4576.                         }
  4577.  
  4578.                         case "cp": {
  4579.                                 if (tokens.length < 1 || tokens.length > 2) return Command.errInvalidArgCount(line, 1, 2);
  4580.                                 const nStart = Number(tokens[0]);
  4581.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4582.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4583.  
  4584.                                 const childAnim = tokens[1] === "-" ? null : tokens[1];
  4585.  
  4586.                                 return new Command(
  4587.                                         line,
  4588.                                         null,
  4589.                                         d20plus.anim.Copy.bind(null, nStart, childAnim),
  4590.                                         {
  4591.                                                 _type: "Copy",
  4592.                                                 start: nStart,
  4593.                                                 animation: childAnim
  4594.                                         }
  4595.                                 );
  4596.                         }
  4597.  
  4598.                         case "flip":
  4599.                         case "flipx": {
  4600.                                 if (tokens.length !== 3) return Command.errInvalidArgCount(line, 3);
  4601.                                 const nStart = Number(tokens[0]);
  4602.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4603.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4604.  
  4605.                                 const flipH = tokens[1] === "-" ? null : tokens[1] === "true" ? true : tokens[1] === "false" ? false : undefined;
  4606.                                 if (flipH === undefined) return Command.errPropBool(line, "flipH", tokens[1]);
  4607.                                 const flipV = tokens[2] === "-" ? null : tokens[2] === "true" ? true : tokens[2] === "false" ? false : undefined;
  4608.                                 if (flipV === undefined) return Command.errPropBool(line, "flipV", tokens[2]);
  4609.  
  4610.                                 if (op === "flip") {
  4611.                                         return new Command(
  4612.                                                 line,
  4613.                                                 null,
  4614.                                                 d20plus.anim.Flip.bind(null, nStart, flipH, flipV),
  4615.                                                 {
  4616.                                                         _type: "Flip",
  4617.                                                         start: nStart,
  4618.                                                         flipH: flipH,
  4619.                                                         flipV: flipV
  4620.                                                 }
  4621.                                         );
  4622.                                 } else {
  4623.                                         return new Command(
  4624.                                                 line,
  4625.                                                 null,
  4626.                                                 d20plus.anim.FlipExact.bind(null, nStart, flipH, flipV),
  4627.                                                 {
  4628.                                                         _type: "FlipExact",
  4629.                                                         start: nStart,
  4630.                                                         flipH: flipH,
  4631.                                                         flipV: flipV
  4632.                                                 }
  4633.                                         );
  4634.                                 }
  4635.                         }
  4636.  
  4637.                         case "scale":
  4638.                         case "scalex": {
  4639.                                 if (tokens.length !== 4) return Command.errInvalidArgCount(line, 4);
  4640.                                 const nStart = Number(tokens[0]);
  4641.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4642.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4643.                                 const nDuration = Number(tokens[1]);
  4644.                                 if (isNaN(nDuration)) return Command.errDurationNum(line, tokens[1]);
  4645.                                 if (nDuration < 0) return Command.errDurationNeg(line, tokens[1]);
  4646.  
  4647.                                 const nScaleX = tokens[2] === "-" ? null : Number(tokens[2]);
  4648.                                 if (nScaleX != null && isNaN(nScaleX)) return Command.errPropNum(line, "scaleX", tokens[2]);
  4649.                                 if (nScaleX != null && nScaleX < 0) return Command.errValNeg(line, "scaleX", tokens[2]);
  4650.                                 const nScaleY = tokens[3] === "-" ? null : Number(tokens[3]);
  4651.                                 if (nScaleY != null && isNaN(nScaleY)) return Command.errPropNum(line, "scaleY", tokens[3]);
  4652.                                 if (nScaleY != null && nScaleY < 0) return Command.errValNeg(line, "scaleY", tokens[3]);
  4653.  
  4654.                                 if (op === "scale") {
  4655.                                         return new Command(
  4656.                                                 line,
  4657.                                                 null,
  4658.                                                 d20plus.anim.Scale.bind(null, nStart, nDuration, nScaleX, nScaleY),
  4659.                                                 {
  4660.                                                         _type: "Scale",
  4661.                                                         start: nStart,
  4662.                                                         duration: nDuration,
  4663.                                                         scaleX: nScaleX,
  4664.                                                         scaleY: nScaleY
  4665.                                                 }
  4666.                                         );
  4667.                                 } else {
  4668.                                         return new Command(
  4669.                                                 line,
  4670.                                                 null,
  4671.                                                 d20plus.anim.ScaleExact.bind(null, nStart, nDuration, nScaleX, nScaleY),
  4672.                                                 {
  4673.                                                         _type: "ScaleExact",
  4674.                                                         start: nStart,
  4675.                                                         duration: nDuration,
  4676.                                                         scaleX: nScaleX,
  4677.                                                         scaleY: nScaleY
  4678.                                                 }
  4679.                                         );
  4680.                                 }
  4681.                         }
  4682.  
  4683.                         case "layer": {
  4684.                                 if (tokens.length !== 2) return Command.errInvalidArgCount(line, 2);
  4685.                                 const nStart = Number(tokens[0]);
  4686.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4687.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4688.  
  4689.                                 const layer = tokens[1] === "-" ? null : tokens[1];
  4690.                                 if (layer != null && !d20plus.anim.VALID_LAYER.has(layer)) return Command.errPropLayer(line, "layer", layer);
  4691.  
  4692.                                 return new Command(
  4693.                                         line,
  4694.                                         null,
  4695.                                         d20plus.anim.Layer.bind(null, nStart, layer),
  4696.                                         {
  4697.                                                 _type: "Layer",
  4698.                                                 start: nStart,
  4699.                                                 layer: layer
  4700.                                         }
  4701.                                 );
  4702.                         }
  4703.  
  4704.                         case "light":
  4705.                         case "lightx": {
  4706.                                 if (tokens.length !== 5) return Command.errInvalidArgCount(line, 5);
  4707.                                 const nStart = Number(tokens[0]);
  4708.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4709.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4710.                                 const nDuration = Number(tokens[1]);
  4711.                                 if (isNaN(nDuration)) return Command.errDurationNum(line, tokens[1]);
  4712.                                 if (nDuration < 0) return Command.errDurationNeg(line, tokens[1]);
  4713.  
  4714.                                 const nLightRadius = tokens[2] === "-" ? null : Number(tokens[2]);
  4715.                                 if (nLightRadius != null && isNaN(nLightRadius)) return Command.errPropNum(line, "lightRadius", tokens[2]);
  4716.                                 const nDimStart = tokens[3] === "-" ? null : Number(tokens[3]);
  4717.                                 if (nDimStart != null && isNaN(nDimStart)) return Command.errPropNum(line, "dimStart", tokens[3]);
  4718.                                 const nDegrees = tokens[4] === "-" ? null : Number(tokens[4]);
  4719.                                 if (nDegrees != null && isNaN(nDegrees)) return Command.errPropNum(line, "degrees", tokens[4]);
  4720.  
  4721.                                 if (op === "light") {
  4722.                                         return new Command(
  4723.                                                 line,
  4724.                                                 null,
  4725.                                                 d20plus.anim.Lighting.bind(null, nStart, nDuration, nLightRadius, nDimStart, nDegrees),
  4726.                                                 {
  4727.                                                         _type: "Lighting",
  4728.                                                         start: nStart,
  4729.                                                         duration: nDuration,
  4730.                                                         lightRadius: nLightRadius,
  4731.                                                         dimStart: nDimStart,
  4732.                                                         degrees: nDegrees
  4733.                                                 }
  4734.                                         );
  4735.                                 } else {
  4736.                                         return new Command(
  4737.                                                 line,
  4738.                                                 null,
  4739.                                                 d20plus.anim.LightingExact.bind(null, nStart, nDuration, nLightRadius, nDimStart, nDegrees),
  4740.                                                 {
  4741.                                                         _type: "LightingExact",
  4742.                                                         start: nStart,
  4743.                                                         duration: nDuration,
  4744.                                                         lightRadius: nLightRadius,
  4745.                                                         dimStart: nDimStart,
  4746.                                                         degrees: nDegrees
  4747.                                                 }
  4748.                                         );
  4749.                                 }
  4750.                         }
  4751.  
  4752.                         case "prop":
  4753.                         case "propSum": {
  4754.                                 if (tokens.length < 2) return Command.errInvalidArgCount(line, 3);
  4755.                                 const nStart = Number(tokens[0]);
  4756.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4757.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4758.  
  4759.                                 const prop = tokens[1] === "-" ? null : tokens[1];
  4760.                                 if (prop != null && !d20plus.anim.VALID_PROP_TOKEN.has(prop)) return Command.errPropToken(line, "prop", prop);
  4761.                                 let val = "";
  4762.                                 if (tokens.length > 2) val = tokens.slice(2, tokens.length).join(" "); // combine trailing tokens
  4763.                                 try { val = JSON.parse(val); } catch (ignored) { console.warn(`Failed to parse "${val}" as JSON, treating as raw string...`) }
  4764.  
  4765.                                 if (op === "propSum") {
  4766.                                         return new Command(
  4767.                                                 line,
  4768.                                                 null,
  4769.                                                 d20plus.anim.SumProperty.bind(null, nStart, prop, val),
  4770.                                                 {
  4771.                                                         _type: "SumProperty",
  4772.                                                         start: nStart,
  4773.                                                         prop: prop,
  4774.                                                         value: val
  4775.                                                 }
  4776.                                         );
  4777.                                 } else {
  4778.                                         return new Command(
  4779.                                                 line,
  4780.                                                 null,
  4781.                                                 d20plus.anim.SetProperty.bind(null, nStart, prop, val),
  4782.                                                 {
  4783.                                                         _type: "SetProperty",
  4784.                                                         start: nStart,
  4785.                                                         prop: prop,
  4786.                                                         value: val
  4787.                                                 }
  4788.                                         );
  4789.                                 }
  4790.                         }
  4791.  
  4792.                         case "macro": {
  4793.                                 if (tokens.length !== 2) return Command.errInvalidArgCount(line, 2);
  4794.                                 const nStart = Number(tokens[0]);
  4795.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4796.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4797.  
  4798.                                 // no validation for macro -- it might exist in the future if it doesn't now, or vice-versa
  4799.                                 const macro = tokens[1] === "-" ? null : tokens[1];
  4800.  
  4801.                                 return new Command(
  4802.                                         line,
  4803.                                         null,
  4804.                                         d20plus.anim.TriggerMacro.bind(null, nStart, macro),
  4805.                                         {
  4806.                                                 _type: "TriggerMacro",
  4807.                                                 start: nStart,
  4808.                                                 macro: macro
  4809.                                         }
  4810.                                 );
  4811.                         }
  4812.  
  4813.                         case "anim": {
  4814.                                 if (tokens.length !== 2) return Command.errInvalidArgCount(line, 2);
  4815.                                 const nStart = Number(tokens[0]);
  4816.                                 if (isNaN(nStart)) return Command.errStartNum(line, tokens[0]);
  4817.                                 if (nStart < 0) return Command.errStartNeg(line, tokens[0]);
  4818.  
  4819.                                 // no validation for animation -- it might exist in the future if it doesn't now, or vice-versa
  4820.                                 const animation = tokens[1] === "-" ? null : tokens[1];
  4821.  
  4822.                                 return new Command(
  4823.                                         line,
  4824.                                         null,
  4825.                                         d20plus.anim.TriggerAnimation.bind(null, nStart, animation),
  4826.                                         {
  4827.                                                 _type: "TriggerAnimation",
  4828.                                                 start: nStart,
  4829.                                                 animation: animation
  4830.                                         }
  4831.                                 );
  4832.                         }
  4833.                 }
  4834.         };
  4835.  
  4836.         d20plus.anim.animatorTool = {
  4837.                 name: "Token Animator",
  4838.                 desc: "Manage token animations",
  4839.                 html: `
  4840.                         <div id="d20plus-token-animator" title="Token Animator" class="anm__win">
  4841.                                 <div class="split mb-2">
  4842.                                         <div>
  4843.                                                 <button class="btn" name="btn-scenes">Edit Scenes</button>
  4844.                                                 <button class="btn" name="btn-disable">Stop Animations</button>
  4845.                                                 <button class="btn" name="btn-rescue">Rescue Tokens</button>
  4846.                                         </div>
  4847.                                         <div>
  4848.                                                 <button class="btn" name="btn-saving" title="If enabled, can have a serious performance impact. If disabled, animations will not resume when reloading the game.">Save Active Animations</button>
  4849.                                         </div>
  4850.                                 </div>
  4851.                                 <div class="split mb-2">
  4852.                                         <button class="btn" name="btn-add">Add Animation</button>
  4853.                                         <button class="btn mr-2" name="btn-import">Import Animation</button>
  4854.                                 </div>
  4855.                                
  4856.                                 <div class="anm__wrp-sel-all">
  4857.                                         <label class="flex-label"><input type="checkbox" title="Select all" name="cb-all" class="mr-2"> <span>Select All</span></label>
  4858.                                         <div>
  4859.                                                 <button class="btn" name="btn-export">Export Selected</button>
  4860.                                                 <button class="btn btn-danger" name="btn-delete">Delete Selected</button>
  4861.                                         </div>
  4862.                                 </div>
  4863.                                
  4864.                                 <div id="token-animator-list-container">
  4865.                                         <input class="search" autocomplete="off" placeholder="Search list..." style="width: 100%;">
  4866.                                         <br><br>
  4867.                                         <ul class="list" style="max-height: 420px; overflow-y: auto; display: block; margin: 0;"></ul>
  4868.                                 </div>
  4869.                         </div>
  4870.                        
  4871.                         <div id="d20plus-token-animator-disable" title="Stop Animation" class="anm__win">
  4872.                                 <p>
  4873.                                         <button class="btn" name="btn-refresh">Refresh</button>
  4874.                                 </p>
  4875.                                
  4876.                                 <p class="anm__wrp-sel-all">
  4877.                                         <label class="flex-label"><input type="checkbox" title="Select all" name="cb-all" class="mr-2"> <span>Select All</span></label>
  4878.                                         <button class="btn" name="btn-stop">Stop Selected</button>
  4879.                                 </p>
  4880.                                
  4881.                                 <div id="token-animator-disable-list-container">
  4882.                                         <input class="search" autocomplete="off" placeholder="Search list..." style="width: 100%;">
  4883.                                         <div class="bold flex-v-center mt-2">
  4884.                                                 <div class="col-1"></div>
  4885.                                                 <div class="col-3 text-center">Page</div>
  4886.                                                 <div class="col-2 text-center">Image</div>
  4887.                                                 <div class="col-3 text-center">Name</div>
  4888.                                                 <div class="col-3 text-center">Animation</div>
  4889.                                         </div>
  4890.                                         <ul class="list" style="max-height: 420px; overflow-y: auto; display: block; margin: 0;"></ul>
  4891.                                 </div>
  4892.                         </div>
  4893.                        
  4894.                         <div id="d20plus-token-animator-rescue" title="Token Rescue" class="anm__win">
  4895.                                 <p>
  4896.                                         <button class="btn mr-2" name="btn-refresh">Refresh</button>
  4897.                                 </p>
  4898.                                
  4899.                                 <p class="anm__wrp-sel-all">
  4900.                                         <label class="flex-label"><input type="checkbox" title="Select all" name="cb-all" class="mr-2"> <span>Select All</span></label>
  4901.                                         <button class="btn" name="btn-rescue">Rescue Selected</button>
  4902.                                 </p>
  4903.                                
  4904.                                 <div id="token-animator-rescue-list-container">
  4905.                                         <input class="search" autocomplete="off" placeholder="Search list..." style="width: 100%;">
  4906.                                         <div class="bold flex-v-center mt-2">
  4907.                                                 <div class="col-1"></div>
  4908.                                                 <div class="col-4 text-center">Page</div>
  4909.                                                 <div class="col-2 text-center">Image</div>
  4910.                                                 <div class="col-5 text-center">Name</div>
  4911.                                         </div>
  4912.                                         <ul class="list" style="max-height: 420px; overflow-y: auto; display: block; margin: 0;"></ul>
  4913.                                 </div>
  4914.                         </div>
  4915.                        
  4916.                         <div id="d20plus-token-animator-scene" title="Scene List" class="anm__win">
  4917.                                 <div class="split mb-2">
  4918.                                         <button class="btn" name="btn-add">Add Scene</button>
  4919.                                         <button class="btn mr-2" name="btn-import">Import Scene</button>
  4920.                                 </div>
  4921.                                
  4922.                                 <div class="anm__wrp-sel-all">
  4923.                                         <label class="flex-label"><input type="checkbox" title="Select all" name="cb-all" class="mr-2"> <span>Select All</span></label>
  4924.                                         <div>
  4925.                                                 <button class="btn" name="btn-export">Export Selected</button>
  4926.                                                 <button class="btn btn-danger" name="btn-delete">Delete Selected</button>
  4927.                                         </div>
  4928.                                 </div>
  4929.                                
  4930.                                 <div id="token-animator-scene-list-container">
  4931.                                         <input class="search" autocomplete="off" placeholder="Search list..." style="width: 100%;">
  4932.                                         <br><br>
  4933.                                         <ul class="list" style="max-height: 420px; overflow-y: auto; display: block; margin: 0;"></ul>
  4934.                                 </div>
  4935.                         </div>
  4936.                 `,
  4937.                 _html_template_editor: `
  4938.                         <div title="Animation Editor" class="anm__win anm-edit__gui flex-col">
  4939.                                 <div class="mb-2 no-shrink split flex-vh-center">
  4940.                                         <input name="ipt-name" placeholder="Name">
  4941.                                        
  4942.                                         <div class="flex">
  4943.                                                 <button class="btn mr-1" name="btn-save">Save</button>
  4944.                                                 <button class="btn" name="btn-export-file">Export to File</button>
  4945.                                                
  4946.                                                 <div class="anm-edit__gui-hidden flex">
  4947.                                                         <button class="btn ml-2" name="btn-help">View Help</button>
  4948.                                                         <button class="btn ml-1" name="btn-validate">Validate</button>
  4949.                                                 </div>
  4950.                                                
  4951.                                                 <div class="anm-edit__gui-visible flex">
  4952.                                                         <button class="btn ml-2" name="btn-add-command">Add Command</button>
  4953.                                                 </div>
  4954.                                                
  4955.                                                 <button class="btn ml-2" name="btn-edit-text">Edit as Text</button>
  4956.                                         </div>
  4957.                                 </div>
  4958.                                
  4959.                                 <div class="anm-edit__ipt-lines-wrp anm-edit__ipt-lines-wrp--gui anm-edit__gui-visible">
  4960.                                        
  4961.                                 </div>
  4962.                                
  4963.                                 <div class="anm-edit__ipt-lines-wrp anm-edit__ipt-lines-wrp--text anm-edit__gui-hidden">
  4964.                                         <textarea name="ipt-lines" placeholder="mv 0 100 50 -50" class="anm-edit__ipt-lines"></textarea>
  4965.                                 </div>
  4966.                         </div>
  4967.                 `,
  4968.                 _html_template_scene_editor: `
  4969.                         <div title="Scene Editor" class="anm__win flex-col">
  4970.                                 <div class="mb-2 no-shrink split">
  4971.                                         <input name="ipt-name" placeholder="Name">
  4972.                                        
  4973.                                         <div>
  4974.                                                 <button class="btn" name="btn-save">Save</button>
  4975.                                                 <button class="btn" name="btn-export-file">Export to File</button>
  4976.                                         </div>
  4977.                                 </div>
  4978.                                 <div class="mb-2">
  4979.                                         <button class="btn" name="btn-add">Add Part</button>
  4980.                                 </div>
  4981.                                 <div class="bold flex-v-center mt-2">
  4982.                                         <div class="col-3 text-center">Token</div>
  4983.                                         <div class="col-2"></div>
  4984.                                         <div class="col-2 text-center">Animation</div>
  4985.                                         <div class="col-2"></div>
  4986.                                         <div class="col-2 text-center help" title="Delay period upon starting the scene before this animation is run (in milliseconds)">Start Time</div>
  4987.                                         <div class="col-1"></div>
  4988.                                 </div>
  4989.                                 <div class="anm-edit__ipt-rows-wrp">
  4990.                                        
  4991.                                 </div>
  4992.                         </div>
  4993.                 `,
  4994.  
  4995.                 dialogFn () {
  4996.                         $("#d20plus-token-animator").dialog({
  4997.                                 autoOpen: false,
  4998.                                 resizable: true,
  4999.                                 width: 800,
  5000.                                 height: 600,
  5001.                         }).data("initialised", false);
  5002.  
  5003.                         $("#d20plus-token-animator-disable").dialog({
  5004.                                 autoOpen: false,
  5005.                                 resizable: true,
  5006.                                 width: 800,
  5007.                                 height: 600,
  5008.                         });
  5009.  
  5010.                         $("#d20plus-token-animator-rescue").dialog({
  5011.                                 autoOpen: false,
  5012.                                 resizable: true,
  5013.                                 width: 800,
  5014.                                 height: 600,
  5015.                         });
  5016.  
  5017.                         $("#d20plus-token-animator-scene").dialog({
  5018.                                 autoOpen: false,
  5019.                                 resizable: true,
  5020.                                 width: 800,
  5021.                                 height: 600,
  5022.                         });
  5023.                 },
  5024.  
  5025.                 openFn () {
  5026.                         this.init();
  5027.                         this.$win.dialog("open");
  5028.                 },
  5029.  
  5030.                 // region public
  5031.                 init () {
  5032.                         this.$win = this.$win || $("#d20plus-token-animator");
  5033.                         if (!this.$win.data("initialised")) {
  5034.                                 this._meta_init();
  5035.                                 // init the runner after, as we need to first load the animations
  5036.                                 d20plus.anim.animator.init();
  5037.                         }
  5038.                 },
  5039.  
  5040.                 getAnimation (uid) {
  5041.                         return this._anims[uid];
  5042.                 },
  5043.  
  5044.                 getAnimationByName (name) {
  5045.                         const fauxAnim = d20plus.anim.animatorTool.getAnimations().find(it => it.name === name);
  5046.                         if (!fauxAnim) return null;
  5047.                         return d20plus.anim.animatorTool.getAnimation(fauxAnim.uid);
  5048.                 },
  5049.  
  5050.                 getAnimQueue (anim, additionalOffset) {
  5051.                         additionalOffset = additionalOffset || 0;
  5052.                         this._edit_convertLines(anim);
  5053.                         const queue = anim.lines.filter(it => it.isRunnable).map(it => it.getInstance());
  5054.                         queue.forEach(it => it._offset += additionalOffset);
  5055.                         return queue;
  5056.                 },
  5057.  
  5058.                 _getUidItems (fromObj) {
  5059.                         return Object.entries(fromObj).map(([k, v]) => ({
  5060.                                 uid: k,
  5061.                                 name: v.name
  5062.                         }))
  5063.                 },
  5064.  
  5065.                 getAnimations () {
  5066.                         return this._getUidItems(this._anims);
  5067.                 },
  5068.  
  5069.                 getScenes () {
  5070.                         return this._getUidItems(this._scenes);
  5071.                 },
  5072.  
  5073.                 isSavingActive () {
  5074.                         return !!this._isSaveActive;
  5075.                 },
  5076.  
  5077.                 _pSelectUid (fnGetAll, msgNoneFound, title, defaultSelUid) {
  5078.                         // convert, as the UIDs are object keys
  5079.                         if (defaultSelUid != null) defaultSelUid = String(defaultSelUid);
  5080.  
  5081.                         const selFrom = fnGetAll();
  5082.                         if (!selFrom.length) return d20plus.ut.chatLog(msgNoneFound);
  5083.  
  5084.                         return new Promise(resolve => {
  5085.                                 const $selUid = $(`<select>
  5086.                                 <option disabled value="-1">${title}</option>
  5087.                                 ${selFrom.map(it => `<option value="${it.uid}">${it.name}</option>`).join("")}
  5088.                                 </select>`);
  5089.                                 if (defaultSelUid != null && selFrom.find(it => it.uid === defaultSelUid)) $selUid.val(defaultSelUid);
  5090.                                 else $selUid[0].selectedIndex = 0;
  5091.  
  5092.                                 const $dialog = $$`
  5093.                                         <div title="${title}">
  5094.                                                 ${$selUid}
  5095.                                         </div>
  5096.                                 `.appendTo($("body"));
  5097.  
  5098.                                 $dialog.dialog({
  5099.                                         dialogClass: "no-close",
  5100.                                         buttons: [
  5101.                                                 {
  5102.                                                         text: "Cancel",
  5103.                                                         click: function () {
  5104.                                                                 $(this).dialog("close");
  5105.                                                                 $dialog.remove();
  5106.                                                         }
  5107.                                                 },
  5108.                                                 {
  5109.                                                         text: "OK",
  5110.                                                         click: function () {
  5111.                                                                 const selected = Number(d20plus.ut.get$SelValue($selUid));
  5112.                                                                 $(this).dialog("close");
  5113.                                                                 $dialog.remove();
  5114.  
  5115.                                                                 if (~selected) resolve(selected);
  5116.                                                                 else resolve(null);
  5117.                                                         }
  5118.                                                 }
  5119.                                         ]
  5120.                                 });
  5121.                         });
  5122.                 },
  5123.  
  5124.                 pSelectAnimation (defaultSelUid) {
  5125.                         return this._pSelectUid(
  5126.                                 this.getAnimations.bind(this),
  5127.                                 `No animations available! Use the Token Animator tool to define some first. See <a href="https://wiki.5e.tools/index.php/Feature:_Animator" target="_blank">the Wiki for help.</a>`,
  5128.                                 "Select Animation",
  5129.                                 defaultSelUid
  5130.                         );
  5131.                 },
  5132.  
  5133.                 pSelectScene (defaultSelUid) {
  5134.                         return this._pSelectUid(
  5135.                                 this.getScenes.bind(this),
  5136.                                 `No scenes available! Use Edit Scenes in the Token Animator tool to define some first. See <a href="https://wiki.5e.tools/index.php/Feature:_Animator" target="_blank">the Wiki for help.</a>`,
  5137.                                 "Select Scene",
  5138.                                 defaultSelUid
  5139.                         );
  5140.                 },
  5141.  
  5142.                 doStartScene (sceneUid) {
  5143.                         const scene = this._scenes[sceneUid];
  5144.                         if (!scene) return d20plus.ut.chatLog(`Could not find scene!`);
  5145.  
  5146.                         (scene.anims || []).forEach(animMeta => {
  5147.                                 if (animMeta.tokenId && animMeta.animUid) {
  5148.                                         const token = d20plus.ut.getTokenById(animMeta.tokenId);
  5149.                                         if (!token) return;
  5150.                                         const anim = this.getAnimation(animMeta.animUid);
  5151.                                         if (!anim) return;
  5152.                                         d20plus.anim.animator.startAnimation(token, animMeta.animUid, {offset: animMeta.offset || 0});
  5153.                                 }
  5154.                         });
  5155.                 },
  5156.                 // endregion public
  5157.  
  5158.                 // region meta
  5159.                 _meta_doSaveState () {
  5160.                         // copy, and return any parsed commands to strings
  5161.                         const saveableAnims = {};
  5162.                         Object.entries(this._anims).forEach(([k, v]) => {
  5163.                                 saveableAnims[k] = {
  5164.                                         ...v,
  5165.                                         lines: [...(v.lines || [])].map(it => typeof it === "string" ? it : it.line)
  5166.                                 }
  5167.                         });
  5168.  
  5169.                         Campaign.save({
  5170.                                 bR20tool__anim_id: this._anim_id,
  5171.                                 bR20tool__anim_animations: saveableAnims,
  5172.                                 bR20tool__anim_save: this._isSaveActive,
  5173.                                 bR20tool__anim_scene_id: this._scene_id,
  5174.                                 bR20tool__anim_scenes: this._scenes,
  5175.                         });
  5176.                 },
  5177.  
  5178.                 _meta_doLoadState () {
  5179.                         this._anim_id = Campaign.attributes.bR20tool__anim_id || 1;
  5180.                         this._scene_id = Campaign.attributes.bR20tool__anim_scene_id || 1;
  5181.  
  5182.                         // convert legacy "array" versions to objects
  5183.                         this._anims = {};
  5184.                         if (Campaign.attributes.bR20tool__anim_animations) {
  5185.                                 const loadedAnims = MiscUtil.copy(Campaign.attributes.bR20tool__anim_animations);
  5186.                                 Object.entries(loadedAnims).filter(([k, v]) => !!v).forEach(([k, v]) => this._anims[k] = v);
  5187.                         }
  5188.  
  5189.                         this._scenes = {};
  5190.                         if (Campaign.attributes.bR20tool__anim_scenes) {
  5191.                                 const loadedScenes = MiscUtil.copy(Campaign.attributes.bR20tool__anim_scenes);
  5192.                                 Object.entries(loadedScenes).filter(([k, v]) => !!v).forEach(([k, v]) => this._scenes[k] = v);
  5193.                         }
  5194.  
  5195.                         this._isSaveActive = Campaign.attributes.bR20tool__anim_save || false;
  5196.                 },
  5197.  
  5198.                 _meta_init () {
  5199.                         this._meta_doLoadState();
  5200.                         this._doSaveStateDebounced = MiscUtil.debounce(this._meta_doSaveState, 100);
  5201.  
  5202.                         this._$winScene = $(`#d20plus-token-animator-scene`);
  5203.                         this._$winDisable = $(`#d20plus-token-animator-disable`);
  5204.                         this._$winRescue = $(`#d20plus-token-animator-rescue`);
  5205.  
  5206.                         this._main_init();
  5207.                         this._scene_init();
  5208.                         this._rescue_init();
  5209.                         this._dis_init();
  5210.                         this.$win.data("initialised", true);
  5211.                 },
  5212.                 // endregion meta
  5213.  
  5214.                 // region shared
  5215.                 async _shared_doImport (prop, name, fnNextId, fnNextName, fnGetValidMsg, fnAdd, ...requiredProps) {
  5216.                         let data;
  5217.                         try {
  5218.                                 data = await DataUtil.pUserUpload();
  5219.                         } catch (e) {
  5220.                                 d20plus.ut.chatLog("File was not valid JSON!");
  5221.                                 console.error(e);
  5222.                                 return;
  5223.                         }
  5224.  
  5225.                         if (data[prop] && data[prop].length) {
  5226.                                 let messages = [];
  5227.                                 data[prop].forEach((it, i) => {
  5228.                                         const missingProp = requiredProps.find(rp => it[rp] == null);
  5229.                                         if (missingProp != null) messages.push(`${name.uppercaseFirst()} at index ${i} is missing required fields!`);
  5230.                                         else {
  5231.                                                 const originalName = it.name;
  5232.                                                 it.uid = fnNextId();
  5233.                                                 it.name = fnNextName(it.name);
  5234.                                                 const msg = fnGetValidMsg(it);
  5235.                                                 if (msg) {
  5236.                                                         messages.push(`${originalName} was invalid: ${msg}`);
  5237.                                                 } else {
  5238.                                                         fnAdd(it);
  5239.                                                         messages.push(`Added ${originalName}${it.name !== originalName ? ` (renamed as ${it.name})` : ""}!`);
  5240.                                                 }
  5241.                                         }
  5242.                                 });
  5243.  
  5244.                                 if (messages.length) {
  5245.                                         console.log(messages.join("\n"));
  5246.                                         return d20plus.ut.chatLog(messages.join("\n"))
  5247.                                 }
  5248.                         } else {
  5249.                                 return d20plus.ut.chatLog(`File contained no ${name}s!`);
  5250.                         }
  5251.                 },
  5252.  
  5253.                 _shared_getValidNameMsg (obj, peers) {
  5254.                         if (!obj.name.length) return "Did not have a name!";
  5255.                         const illegalNameChars = obj.name.split(/[_0-9a-zA-Z]/g).filter(Boolean);
  5256.                         if (illegalNameChars.length) return `Illegal characters in name: ${illegalNameChars.map(it => `"${it}"`).join(", ")}`;
  5257.                         const sameName = Object.values(peers).filter(it => it.uid !== obj.uid).find(it => it.name === obj.name);
  5258.                         if (sameName) return "Name must be unique!";
  5259.                 },
  5260.  
  5261.                 _shared_getNextName (obj, baseName) {
  5262.                         let nxtName = baseName;
  5263.                         let suffix = 1;
  5264.                         while (Object.values(obj).find(it => it.name === nxtName)) nxtName = `${baseName}_${suffix++}`;
  5265.                         return nxtName;
  5266.                 },
  5267.                 // endregion
  5268.  
  5269.                 // region main
  5270.                 _main_init () {
  5271.                         const $btnAdd = this.$win.find(`[name="btn-add"]`);
  5272.                         const $btnImport = this.$win.find(`[name="btn-import"]`);
  5273.                         const $btnDisable = this.$win.find(`[name="btn-disable"]`);
  5274.                         const $btnScenes = this.$win.find(`[name="btn-scenes"]`);
  5275.                         const $btnRescue = this.$win.find(`[name="btn-rescue"]`);
  5276.                         const $btnToggleSave = this.$win.find(`[name="btn-saving"]`);
  5277.  
  5278.                         const $btnSelExport = this.$win.find(`[name="btn-export"]`);
  5279.                         const $btnSelDelete = this.$win.find(`[name="btn-delete"]`);
  5280.  
  5281.                         const $cbAll = this.$win.find(`[name="cb-all"]`);
  5282.                         this._$list = this.$win.find(`.list`);
  5283.  
  5284.                         $btnAdd.click(() => this._main_addAnim(this._main_getNewAnim()));
  5285.  
  5286.                         $btnImport.click(async () => {
  5287.                                 await this._shared_doImport(
  5288.                                         "animations",
  5289.                                         "animation",
  5290.                                         this._main_getNextId.bind(this),
  5291.                                         this._shared_getNextName.bind(this, this._anims),
  5292.                                         this._edit_getValidationMessage.bind(this),
  5293.                                         this._main_addAnim.bind(this),
  5294.                                         "uid", "name", "lines" // required properties
  5295.                                 );
  5296.                         });
  5297.  
  5298.                         $btnScenes.click(() => {
  5299.                                 this._scene_doPopulateList();
  5300.                                 this._$winScene.dialog("open");
  5301.                         });
  5302.  
  5303.                         $btnDisable.click(() => {
  5304.                                 this._dis_doPopulateList();
  5305.                                 this._$winDisable.dialog("open");
  5306.                         });
  5307.  
  5308.                         $btnRescue.click(() => {
  5309.                                 this._rescue_doPopulateList();
  5310.                                 this._$winRescue.dialog("open")
  5311.                         });
  5312.  
  5313.                         $btnToggleSave.toggleClass("active", this._isSaveActive);
  5314.                         $btnToggleSave.click(() => {
  5315.                                 this._isSaveActive = !this._isSaveActive;
  5316.                                 $btnToggleSave.toggleClass("active", this._isSaveActive);
  5317.                                 this._doSaveStateDebounced();
  5318.  
  5319.                                 // on disable, clear existing running animations
  5320.                                 // prevents next load from re-loading old running state
  5321.                                 if (!this._isSaveActive) {
  5322.                                         setTimeout(() => Campaign.save({bR20tool__anim_running: {}}), 100);
  5323.                                 }
  5324.                         });
  5325.  
  5326.                         const getSelButtons = ofClass => {
  5327.                                 return this._anim_list.items
  5328.                                         .map(it => $(it.elm))
  5329.                                         .filter($it => $it.find(`input`).prop("checked"))
  5330.                                         .map($it => $it.find(`.${ofClass}`));
  5331.                         };
  5332.  
  5333.                         $btnSelExport.click(() => {
  5334.                                 const out = {
  5335.                                         animations: this._anim_list.items
  5336.                                                 .filter(it => $(it.elm).find(`input`).prop("checked"))
  5337.                                                 .map(it => this._main_getExportableAnim(this._anims[it.values().uid]))
  5338.                                 };
  5339.                                 DataUtil.userDownload("animations", out);
  5340.                         });
  5341.  
  5342.                         $cbAll.click(() => {
  5343.                                 const val = $cbAll.prop("checked");
  5344.                                 this._anim_list.items.forEach(it => {
  5345.                                         $(it.elm.children[0].children[0]).prop("checked", val);
  5346.                                 })
  5347.                         });
  5348.  
  5349.                         $btnSelDelete.click(() => {
  5350.                                 const $btns = getSelButtons(`.anm__btn-delete`);
  5351.                                 if (!$btns.length) return;
  5352.                                 if (!confirm("Are you sure?")) return;
  5353.                                 $btns.forEach($btn => $btn.click());
  5354.                         });
  5355.  
  5356.                         this._main_doPopulateList();
  5357.                 },
  5358.  
  5359.                 _main_getExportableAnim (anim) {
  5360.                         const out = {...anim};
  5361.                         out.lines = out.lines.map(it => typeof it === "string" ? it : it.line);
  5362.                         return out;
  5363.                 },
  5364.  
  5365.                 _main_doPopulateList () {
  5366.                         this._$list.empty();
  5367.                         Object.values(this._anims).forEach(anim => this._$list.append(this._main_getListItem(anim)));
  5368.  
  5369.                         this._anim_list = new List("token-animator-list-container", {
  5370.                                 valueNames: ["name", "uid"]
  5371.                         });
  5372.                 },
  5373.  
  5374.                 _main_addAnim (anim) {
  5375.                         const lastSearch = d20plus.ut.getSearchTermAndReset(this._anim_list);
  5376.                         this._anims[anim.uid] = anim;
  5377.                         this._$list.append(this._main_getListItem(anim));
  5378.  
  5379.                         this._anim_list.reIndex();
  5380.                         if (lastSearch) this._anim_list.search(lastSearch);
  5381.                         this._anim_list.sort("name");
  5382.  
  5383.                         this._doSaveStateDebounced();
  5384.                 },
  5385.  
  5386.                 _main_getNewAnim () {
  5387.                         return {
  5388.                                 uid: this._main_getNextId(),
  5389.                                 name: this._shared_getNextName(this._anims, "new_animation"),
  5390.                                 lines: []
  5391.                         }
  5392.                 },
  5393.  
  5394.                 _main_getNextId () {
  5395.                         return this._anim_id++;
  5396.                 },
  5397.  
  5398.                 _main_getListItem (anim) {
  5399.                         const $name = $(`<div class="name readable col-9 clickable" title="Edit Animation">${anim.name}</div>`)
  5400.                                 .click(() => this._edit_openEditor(anim));
  5401.  
  5402.                         const $btnDuplicate = $(`<div class="btn anm__row-btn pictos mr-2" title="Duplicate">F</div>`)
  5403.                                 .click(() => {
  5404.                                         const copy = MiscUtil.copy(anim);
  5405.                                         copy.name = `${copy.name}_copy`;
  5406.                                         copy.uid = this._anim_id++;
  5407.                                         this._main_addAnim(copy);
  5408.                                 });
  5409.  
  5410.                         const $btnExport = $(`<div class="btn anm__row-btn pictos mr-2" title="Export to File">I</div>`)
  5411.                                 .click(() => {
  5412.                                         const out = {animations: [this._main_getExportableAnim(anim)]};
  5413.                                         DataUtil.userDownload(`${anim.name}`, out);
  5414.                                 });
  5415.  
  5416.                         const $btnDelete = $(`<div class="btn anm__row-btn btn-danger pictos anm__btn-delete mr-2" title="Delete">#</div>`)
  5417.                                 .click(() => {
  5418.                                         delete this._anims[anim.uid];
  5419.                                         this._anim_list.remove("uid", anim.uid);
  5420.                                         this._doSaveStateDebounced();
  5421.                                 });
  5422.  
  5423.                         return $$`<div class="anm__row">
  5424.                                 <label class="col-1 flex-vh-center full-height"><input type="checkbox"></label>
  5425.                                 ${$name}
  5426.                                 <div class="anm__row-controls col-2 text-center">
  5427.                                         ${$btnDuplicate}
  5428.                                         ${$btnExport}
  5429.                                         ${$btnDelete}
  5430.                                 </div>
  5431.                                 <div class="hidden uid">${anim.uid}</div>
  5432.                         </div>`;
  5433.                 },
  5434.                 // endregion main
  5435.  
  5436.                 // region scene
  5437.                 _scene_getSelected () {
  5438.                         return this._scene_list.items.filter(it => $(it.elm).find("input[type=checkbox]").prop("checked"));
  5439.                 },
  5440.  
  5441.                 _scene_addScene (scene) {
  5442.                         if (scene == null) return console.error(`Scene was null!`);
  5443.  
  5444.                         const lastSearch = d20plus.ut.getSearchTermAndReset(this._scene_list);
  5445.                         this._scenes[scene.uid] = scene;
  5446.                         this._scene_$wrpList.append(this._scene_$getListItem(scene));
  5447.  
  5448.                         this._scene_list.reIndex();
  5449.                         if (lastSearch) this._scene_list.search(lastSearch);
  5450.                         this._scene_list.sort("name");
  5451.  
  5452.                         this._doSaveStateDebounced();
  5453.                 },
  5454.  
  5455.                 _scene_$getListItem (scene) {
  5456.                         const $name = $(`<div class="name readable col-9 clickable" title="Edit Animation">${scene.name}</div>`)
  5457.                                 .click(() => this._scene_openEditor(scene));
  5458.  
  5459.                         const $btnDuplicate = $(`<div class="btn anm__row-btn pictos mr-2" title="Duplicate">F</div>`)
  5460.                                 .click(() => {
  5461.                                         const copy = MiscUtil.copy(scene);
  5462.                                         copy.name = `${copy.name}_copy`;
  5463.                                         copy.uid = this._scene_id++;
  5464.                                         this._scene_addScene(copy);
  5465.                                 });
  5466.  
  5467.                         const $btnExport = $(`<div class="btn anm__row-btn pictos mr-2" title="Export to File">I</div>`)
  5468.                                 .click(() => {
  5469.                                         const out = {scenes: [scene]};
  5470.                                         DataUtil.userDownload(`${scene.name}`, out);
  5471.                                 });
  5472.  
  5473.                         const $btnDelete = $(`<div class="btn anm__row-btn btn-danger pictos anm__btn-delete mr-2" title="Delete">#</div>`)
  5474.                                 .click(() => {
  5475.                                         delete this._scenes[scene.uid];
  5476.                                         this._scene_list.remove("uid", scene.uid);
  5477.                                         this._doSaveStateDebounced();
  5478.                                 });
  5479.  
  5480.                         return $$`<div class="flex-v-center mb-2">
  5481.                                 <label class="col-1 flex-vh-center full-height"><input type="checkbox"></label>
  5482.                                 ${$name}
  5483.                                 <div class="anm__row-controls col-2 text-center">
  5484.                                         ${$btnDuplicate}
  5485.                                         ${$btnExport}
  5486.                                         ${$btnDelete}
  5487.                                 </div>
  5488.                                 <div class="uid hidden">${scene.uid}</div>
  5489.                         </div>`
  5490.                 },
  5491.  
  5492.                 _scene_doPopulateList () {
  5493.                         this._scene_$wrpList.empty();
  5494.                         Object.values(this._scenes).forEach(scene => this._scene_$wrpList.append(this._scene_$getListItem(scene)));
  5495.  
  5496.                         this._scene_list = new List("token-animator-scene-list-container", {
  5497.                                 valueNames: [
  5498.                                         "name",
  5499.                                         "uid"
  5500.                                 ]
  5501.                         });
  5502.                 },
  5503.  
  5504.                 _scene_init () {
  5505.                         this._scene_$btnAdd = this._$winScene.find(`[name="btn-add"]`);
  5506.                         this._scene_$btnImport = this._$winScene.find(`[name="btn-import"]`);
  5507.                         this._scene_$btnExport = this._$winScene.find(`[name="btn-export"]`);
  5508.                         this._scene_$btnDelete = this._$winScene.find(`[name="btn-delete"]`);
  5509.                         this._scene_$cbAll = this._$winScene.find(`[name="cb-all"]`);
  5510.                         this._scene_$wrpList = this._$winScene.find(`.list`);
  5511.  
  5512.                         this._scene_list = null;
  5513.  
  5514.                         this._scene_$cbAll.click(() => {
  5515.                                 const toVal = this._scene_$cbAll.prop("checked");
  5516.                                 this._scene_list.items.forEach(it => $(it.elm).find("input[type=checkbox]").prop("checked", toVal));
  5517.                         });
  5518.  
  5519.                         this._scene_$btnAdd.off("click").click(() => this._scene_addScene(this._scene_getNewScene()));
  5520.  
  5521.                         this._scene_$btnImport.click(async () => {
  5522.                                 await this._shared_doImport(
  5523.                                         "scenes",
  5524.                                         "scene",
  5525.                                         this._scene_getNextId.bind(this),
  5526.                                         this._shared_getNextName.bind(this, this._scenes),
  5527.                                         this._scene_getValidationMessage.bind(this),
  5528.                                         this._scene_addScene.bind(this),
  5529.                                         "uid", "name", "anims" // required properties
  5530.                                 );
  5531.                         });
  5532.  
  5533.                         this._scene_$btnExport.click(() => {
  5534.                                 const out = {
  5535.                                         scenes: this._scene_getSelected()
  5536.                                                 .map(it => this._scenes[it.values().uid])
  5537.                                 };
  5538.                                 DataUtil.userDownload("scenes", out);
  5539.                         });
  5540.  
  5541.                         this._scene_$btnDelete.click(() => {
  5542.                                 const sel = this._scene_getSelected();
  5543.                                 if (!sel.length) return;
  5544.                                 if (!confirm("Are you sure?")) return;
  5545.                                 sel.forEach(it => {
  5546.                                         const uid = it.values()._scene_id;
  5547.                                         delete this._scenes[uid];
  5548.                                         this._scene_list.remove("uid", uid);
  5549.                                 });
  5550.                                 this._doSaveStateDebounced();
  5551.                         });
  5552.                 },
  5553.  
  5554.                 _scene_getNextId () {
  5555.                         return this._scene_id++;
  5556.                 },
  5557.  
  5558.                 _scene_getNewScene () {
  5559.                         return {
  5560.                                 uid: this._scene_getNextId(),
  5561.                                 name: this._shared_getNextName(this._scenes, "new_scene"),
  5562.                                 anims: []
  5563.                                 /*
  5564.                                 Anims array structure:
  5565.                                 [
  5566.                                         ...,
  5567.                                         {
  5568.                                                 tokenId: "",
  5569.                                                 animUid: "",
  5570.                                                 offset: 0
  5571.                                         },
  5572.                                         ...
  5573.                                 ]
  5574.                                  */
  5575.                         }
  5576.                 },
  5577.  
  5578.                 _scene_openEditor (scene) {
  5579.                         scene = MiscUtil.copy(scene);
  5580.                         scene.anims = scene.anims || []; // handle legacy data
  5581.                         const editorOptions = {};
  5582.  
  5583.                         const $winEditor = $(this._html_template_scene_editor)
  5584.                                 .attr("title", `Scene Editor - ${scene.name}`)
  5585.                                 .appendTo($("body"));
  5586.  
  5587.                         const $iptName = $winEditor.find(`[name="ipt-name"]`).disableSpellcheck()
  5588.                                 .val(scene.name)
  5589.                                 .change(() => {
  5590.                                         scene.name = $iptName.val().trim();
  5591.                                         $winEditor.dialog("option", "title", `Scene Editor - ${$iptName.val()}`);
  5592.                                 });
  5593.                         const $btnSave = $winEditor.find(`[name="btn-save"]`);
  5594.                         const $btnExportFile = $winEditor.find(`[name="btn-export-file"]`);
  5595.                         const $btnAdd = $winEditor.find(`[name="btn-add"]`);
  5596.                         const $wrpRows = $winEditor.find(`.anm-edit__ipt-rows-wrp`);
  5597.  
  5598.                         $btnSave.off("click").click(() => {
  5599.                                 const msg = this._scene_getValidationMessage(scene);
  5600.                                 if (msg) return d20plus.ut.chatLog(msg);
  5601.  
  5602.                                 // we passed validation
  5603.                                 this._scenes[scene.uid] = scene;
  5604.  
  5605.                                 this._doSaveStateDebounced();
  5606.  
  5607.                                 const matches = this._scene_list.get("uid", scene.uid);
  5608.                                 if (matches.length) {
  5609.                                         matches[0].values({name: scene.name})
  5610.                                 }
  5611.  
  5612.                                 d20plus.ut.chatLog("Saved!");
  5613.                         });
  5614.  
  5615.                         $btnExportFile.off("click").click(() => {
  5616.                                 const out = {scenes: [scene]};
  5617.                                 DataUtil.userDownload(`${scene.name}`, out);
  5618.                         });
  5619.  
  5620.                         $btnAdd.off("click").click(() => $wrpRows.append(this._scene_$getEditorRow(editorOptions, scene)));
  5621.  
  5622.                         $wrpRows.empty();
  5623.                         scene.anims.forEach(animMeta => $wrpRows.append(this._scene_$getEditorRow(editorOptions, scene, animMeta)));
  5624.  
  5625.                         $winEditor.dialog({
  5626.                                 resizable: true,
  5627.                                 width: 800,
  5628.                                 height: 600,
  5629.                                 close: () => {
  5630.                                         setTimeout(() => $winEditor.remove())
  5631.                                 }
  5632.                         });
  5633.                 },
  5634.  
  5635.                 _scene_$getEditorRow (editorOptions, scene, animMeta) {
  5636.                         if (!animMeta) {
  5637.                                 animMeta = {
  5638.                                         offset: 0
  5639.                                 };
  5640.                                 scene.anims.push(animMeta);
  5641.                         }
  5642.  
  5643.                         const $btnSelToken = $(`<button class="btn anm__row-btn">Select Token</button>`)
  5644.                                 .click(() => {
  5645.                                         let lastSelectedTokenId = null;
  5646.                                         const $wrpTokens = $$`<div class="anm-scene__wrp-tokens"></div>`;
  5647.  
  5648.                                         const $selPage = $(`<select><option disabled value="">Select Page</option></select>`)
  5649.                                                 .change(() => {
  5650.                                                         lastSelectedTokenId = null;
  5651.                                                         $wrpTokens.empty();
  5652.  
  5653.                                                         const page = d20.Campaign.pages.get(d20plus.ut.get$SelValue($selPage));
  5654.                                                         editorOptions.lastPageId = d20plus.ut.get$SelValue($selPage);
  5655.  
  5656.                                                         if (page.thegraphics && page.thegraphics.length) {
  5657.                                                                 const tokens = page.thegraphics.models
  5658.                                                                         .filter(it => it.attributes.type === "image")
  5659.                                                                         .map(it => ({
  5660.                                                                                 id: it.id,
  5661.                                                                                 name: it.attributes.name || "(Unnamed)",
  5662.                                                                                 imgsrc: it.attributes.imgsrc
  5663.                                                                         }))
  5664.                                                                         .sort((a, b) => SortUtil.ascSortLower(a.name, b.name));
  5665.                                                                 tokens.forEach(it => {
  5666.                                                                         const $wrpToken = $$`<div class="anm-scene__wrp-token">
  5667.                                                                                         <div class="no-shrink flex-vh-center" style="width: 80px; height: 80px;">
  5668.                                                                                                 <img
  5669.                                                                                                         class="no-shrink"
  5670.                                                                                                         style="max-width: 80px; max-height: 80px;"
  5671.                                                                                                         src="${it.imgsrc}"
  5672.                                                                                                 >
  5673.                                                                                         </div>
  5674.                                                                                         <div class="no-shrink full-width flex-vh-center anm-scene__wrp-token-name">
  5675.                                                                                                 <span title="${it.name}" class="anm-scene__wrp-token-name-inner">${it.name}</span>
  5676.                                                                                         </div>
  5677.                                                                                 </div>`.click(() => {
  5678.                                                                                 $wrpTokens.find(`.anm-scene__wrp-token`).removeClass(`anm-scene__wrp-token--active`);
  5679.                                                                                 $wrpToken.addClass(`anm-scene__wrp-token--active`);
  5680.                                                                                 lastSelectedTokenId = it.id;
  5681.                                                                         }).appendTo($wrpTokens);
  5682.                                                                 });
  5683.                                                         } else $wrpTokens.append("There are no tokens on this page!");
  5684.                                                 });
  5685.                                         // TODO alphabetise pages
  5686.                                         d20.Campaign.pages
  5687.                                                 .forEach(it => $(`<option value="${it.id}"></option>`).text(it.attributes.name || "(Unnamed)").appendTo($selPage));
  5688.                                         // default re-display last page
  5689.                                         if (editorOptions.lastPageId && d20.Campaign.pages.get(editorOptions.lastPageId)) $selPage.val(editorOptions.lastPageId).change();
  5690.                                         else $selPage[0].selectedIndex = 0;
  5691.  
  5692.                                         const $dialog = $$`
  5693.                                                         <div title="Select Token">
  5694.                                                                 <div class="flex-col full-width full-height">
  5695.                                                                         <div class="mb-2 no-shrink">${$selPage}</div>
  5696.                                                                         ${$wrpTokens}
  5697.                                                                 </div>
  5698.                                                         </div>
  5699.                                                 `.appendTo($("body"));
  5700.  
  5701.                                         $dialog.dialog({
  5702.                                                 dialogClass: "no-close",
  5703.                                                 buttons: [
  5704.                                                         {
  5705.                                                                 text: "Cancel",
  5706.                                                                 click: function () {
  5707.                                                                         $(this).dialog("close");
  5708.                                                                         $dialog.remove();
  5709.                                                                 }
  5710.                                                         },
  5711.                                                         {
  5712.                                                                 text: "OK",
  5713.                                                                 click: function () {
  5714.                                                                         $(this).dialog("close");
  5715.                                                                         $dialog.remove();
  5716.  
  5717.                                                                         if (lastSelectedTokenId != null) {
  5718.                                                                                 animMeta.tokenId = lastSelectedTokenId;
  5719.                                                                                 $wrpToken.html(getTokenPart());
  5720.                                                                                 $wrpTokenName.html(getTokenNamePart());
  5721.                                                                         }
  5722.                                                                 }
  5723.                                                         }
  5724.                                                 ],
  5725.                                                 width: 640,
  5726.                                                 height: 480
  5727.                                         });
  5728.                                 });
  5729.                         const getTokenPart = () => {
  5730.                                 const token = animMeta.tokenId ? d20plus.ut.getTokenById(animMeta.tokenId) : null;
  5731.                                 return token ? `<img src="${token.attributes.imgsrc}" style="max-width: 40px; max-height: 40px;">` : "";
  5732.                         };
  5733.                         const getTokenNamePart = () => {
  5734.                                 const token = animMeta.tokenId ? d20plus.ut.getTokenById(animMeta.tokenId) : null;
  5735.                                 return token ? token.attributes.name : "";
  5736.                         };
  5737.                         const $wrpToken = $(`<div>${getTokenPart()}</div>`);
  5738.                         const $wrpTokenName = $(`<div>${getTokenNamePart()}</div>`);
  5739.  
  5740.                         const $btnSelAnim = $(`<button class="btn anm__row-btn">Select Animation</button>`)
  5741.                                 .click(async () => {
  5742.                                         const anim = await this.pSelectAnimation(editorOptions.lastAnimUid);
  5743.                                         if (anim != null) {
  5744.                                                 editorOptions.lastAnimUid = anim;
  5745.                                                 animMeta.animUid = anim;
  5746.                                                 $wrpAnim.html(getAnimPart())
  5747.                                         }
  5748.                                 });
  5749.                         const getAnimPart = () => {
  5750.                                 const anim = animMeta.animUid ? this.getAnimation(animMeta.animUid) : null;
  5751.                                 return anim ? anim.name : "";
  5752.                         };
  5753.                         const $wrpAnim = $(`<div>${getAnimPart()}</div>`);
  5754.  
  5755.                         const $iptOffset = $(`<input type="number" min="0" style="max-width: 100%;" class="text-right">`)
  5756.                                 .val(animMeta.offset || 0)
  5757.                                 .change(() => {
  5758.                                         const rawNum = Number($iptOffset.val());
  5759.                                         const num = isNaN(rawNum) ? 0 : rawNum;
  5760.                                         animMeta.offset = Math.max(0, num);
  5761.                                         $iptOffset.val(animMeta.offset);
  5762.                                 });
  5763.  
  5764.                         const $btnDelete = $(`<button class="btn btn-danger anm__row-btn pictos">#</button>`)
  5765.                                 .click(() => {
  5766.                                         scene.anims.splice(scene.anims.indexOf(animMeta), 1);
  5767.                                         $out.remove();
  5768.                                 });
  5769.  
  5770.                         const $out = $$`<div class="flex-vh-center mb-1">
  5771.                                         <div class="col-1 text-center">${$wrpToken}</div>
  5772.                                         <div class="col-2 text-center">${$wrpTokenName}</div>
  5773.                                         <div class="col-2 text-center">${$btnSelToken}</div>
  5774.                                        
  5775.                                         <div class="col-2 text-center">${$wrpAnim}</div>
  5776.                                         <div class="col-2 text-center">${$btnSelAnim}</div>
  5777.                                        
  5778.                                         <div class="col-2">${$iptOffset}</div>
  5779.                                        
  5780.                                         <div class="col-1 text-center">${$btnDelete}</div>
  5781.                                 </div>`;
  5782.                         return $out;
  5783.                 },
  5784.  
  5785.                 _scene_getValidationMessage (scene) {
  5786.                         // validate name
  5787.                         return this._shared_getValidNameMsg(scene, this._scenes);
  5788.                 },
  5789.                 // endregion
  5790.  
  5791.                 // region rescue
  5792.                 _rescue_getSelected () {
  5793.                         return this._rescue_list.items.filter(it => $(it.elm).find("input[type=checkbox]").prop("checked"));
  5794.                 },
  5795.  
  5796.                 _rescue_getListItem (page, imgUrl, tokenName, _tokenId) {
  5797.                         return `<label class="flex-v-center">
  5798.                                 <div class="col-1 flex-vh-center full-height"><input type="checkbox"></div>
  5799.                                 <div class="page col-4">${page}</div>                          
  5800.                                 <div class="col-2">
  5801.                                         <a href="${imgUrl}" target="_blank"><img src="${imgUrl}" style="max-width: 40px; max-height: 40px;"></a>
  5802.                                 </div>                         
  5803.                                 <div class="col-5 tokenName">${tokenName || "(unnamed)"}</div>
  5804.                                 <div class="_tokenId hidden">${_tokenId}</div>         
  5805.                         </label>`
  5806.                 },
  5807.  
  5808.                 _rescue_doPopulateList () {
  5809.                         let temp = "";
  5810.  
  5811.                         const pageW = d20.Campaign.activePage().attributes.width * 70;
  5812.                         const pageH = d20.Campaign.activePage().attributes.height * 70;
  5813.  
  5814.                         const outOfBounds = d20.Campaign.activePage().thegraphics.models.filter(tokenModel => {
  5815.                                 return tokenModel.view.graphic.scaleX < 0.01 ||
  5816.                                         tokenModel.view.graphic.scaleX > 50.0 ||
  5817.                                         tokenModel.view.graphic.scaleY < 0.01 ||
  5818.                                         tokenModel.view.graphic.scaleY > 50.0 ||
  5819.                                         tokenModel.attributes.left < 0 ||
  5820.                                         tokenModel.attributes.left > pageW ||
  5821.                                         tokenModel.attributes.top < 0 ||
  5822.                                         tokenModel.attributes.top > pageH;
  5823.                         });
  5824.  
  5825.                         outOfBounds.forEach(token => {
  5826.                                 const pageId = token.attributes.page_id;
  5827.                                 const pageName = (d20.Campaign.pages.get(pageId) || {attributes: {name: "(unknown)"}}).attributes.name;
  5828.  
  5829.                                 temp += this._rescue_getListItem(
  5830.                                         pageName,
  5831.                                         token.attributes.imgsrc,
  5832.                                         token.attributes.name,
  5833.                                         token.attributes.id,
  5834.                                 )
  5835.                         });
  5836.  
  5837.                         this._rescue_$wrpList.empty().append(temp);
  5838.  
  5839.                         this._rescue_list = new List("token-animator-rescue-list-container", {
  5840.                                 valueNames: [
  5841.                                         "page",
  5842.                                         "tokenName",
  5843.                                         "_tokenId",
  5844.                                 ]
  5845.                         });
  5846.                 },
  5847.  
  5848.                 _rescue_init () {
  5849.                         this._rescue_$btnRefresh = this._$winRescue.find(`[name="btn-refresh"]`);
  5850.                         this._rescue_$btnRescue = this._$winRescue.find(`[name="btn-rescue"]`);
  5851.                         this._rescue_$cbAll = this._$winRescue.find(`[name="cb-all"]`);
  5852.                         this._rescue_$wrpList = this._$winRescue.find(`.list`);
  5853.  
  5854.                         this._rescue_list = null;
  5855.  
  5856.                         this._rescue_$cbAll.click(() => {
  5857.                                 const toVal = this._rescue_$cbAll.prop("checked");
  5858.                                 this._rescue_list.items.forEach(it => $(it.elm).find("input[type=checkbox]").prop("checked", toVal));
  5859.                         });
  5860.  
  5861.                         this._rescue_$btnRefresh.click(() => this._rescue_doPopulateList());
  5862.  
  5863.                         this._rescue_$btnRescue.off("click").click(() => {
  5864.                                 const sel = this._rescue_getSelected();
  5865.                                 if (!sel.length) return d20plus.ut.chatLog("Please select some items from the list!");
  5866.  
  5867.                                 sel.map(it => it.values()).forEach(it => {
  5868.                                         // disable animations for token
  5869.                                         delete d20plus.anim.animator._tracker[it._tokenId];
  5870.  
  5871.                                         // reset token properties; place in the top-left corner of the canvas on the GM layer
  5872.                                         const token = d20plus.ut.getTokenById(it._tokenId);
  5873.                                         token.attributes.scaleX = 1.0;
  5874.                                         token.view.graphic.scaleX = token.attributes.scaleX;
  5875.                                         token.attributes.scaleY = 1.0;
  5876.                                         token.view.graphic.scaleY = token.attributes.scaleY;
  5877.                                         token.attributes.flipv = false;
  5878.                                         token.attributes.fliph = false;
  5879.                                         token.attributes.left = 35;
  5880.                                         token.attributes.top = 35;
  5881.                                         token.attributes.width = 70;
  5882.                                         token.attributes.height = 70;
  5883.                                         token.attributes.rotation = 0;
  5884.                                         token.attributes.layer = "gmlayer";
  5885.                                         token.save();
  5886.                                 });
  5887.  
  5888.                                 d20plus.ut.chatLog("Rescued tokens will be placed on the GM layer, in the top-left corner of the map");
  5889.                                 this._rescue_doPopulateList();
  5890.                         });
  5891.                 },
  5892.                 // endregion rescue
  5893.  
  5894.                 // region disabler
  5895.                 _dis_getSelected () {
  5896.                         return this._dis_list.items.filter(it => $(it.elm).find("input[type=checkbox]").prop("checked"));
  5897.                 },
  5898.  
  5899.                 _dis_getListItem (page, imgUrl, tokenName, animName, _tokenId, _animUid) {
  5900.                         return `<label class="flex-v-center">
  5901.                                 <div class="col-1 flex-vh-center full-height"><input type="checkbox"></div>
  5902.                                 <div class="page col-3">${page}</div>                          
  5903.                                 <div class="col-2">
  5904.                                         <a href="${imgUrl}" target="_blank"><img src="${imgUrl}" style="max-width: 40px; max-height: 40px;"></a>
  5905.                                 </div>                         
  5906.                                 <div class="col-3 tokenName">${tokenName || "(unnamed)"}</div>                         
  5907.                                 <div class="col-3 animName">${animName}</div>
  5908.                                 <div class="_tokenId hidden">${_tokenId}</div>                         
  5909.                                 <div class="_animUid hidden">${_animUid}</div>                         
  5910.                         </label>`
  5911.                 },
  5912.  
  5913.                 _dis_doPopulateList () {
  5914.                         let temp = "";
  5915.  
  5916.                         Object.entries(d20plus.anim.animator._tracker).forEach(([tokenId, tokenMeta]) => {
  5917.                                 const imgUrl = tokenMeta.token.attributes.imgsrc;
  5918.                                 const pageId = tokenMeta.token.attributes.page_id;
  5919.                                 const pageName = (d20.Campaign.pages.get(pageId) || {attributes: {name: "(unknown)"}}).attributes.name;
  5920.  
  5921.                                 Object.entries(tokenMeta.active).forEach(([animUid, animMeta]) => {
  5922.                                         temp += this._dis_getListItem(
  5923.                                                 pageName,
  5924.                                                 imgUrl,
  5925.                                                 tokenMeta.token.attributes.name,
  5926.                                                 d20plus.anim.animatorTool.getAnimation(animUid).name,
  5927.                                                 tokenId,
  5928.                                                 animUid
  5929.                                         )
  5930.                                 });
  5931.                         });
  5932.  
  5933.                         this._dis_$wrpList.empty().append(temp);
  5934.  
  5935.                         this._dis_list = new List("token-animator-disable-list-container", {
  5936.                                 valueNames: [
  5937.                                         "page",
  5938.                                         "tokenName",
  5939.                                         "animName",
  5940.                                         "_tokenId",
  5941.                                         "_animUid"
  5942.                                 ]
  5943.                         });
  5944.                 },
  5945.  
  5946.                 _dis_init () {
  5947.                         this._dis_$btnRefresh = this._$winDisable.find(`[name="btn-refresh"]`);
  5948.                         this._dis_$btnStop = this._$winDisable.find(`[name="btn-stop"]`);
  5949.                         this._dis_$cbAll = this._$winDisable.find(`[name="cb-all"]`);
  5950.                         this._dis_$wrpList = this._$winDisable.find(`.list`);
  5951.  
  5952.                         this._dis_list = null;
  5953.  
  5954.                         this._dis_$cbAll.click(() => {
  5955.                                 const toVal = this._dis_$cbAll.prop("checked");
  5956.                                 this._dis_list.items.forEach(it => $(it.elm).find("input[type=checkbox]").prop("checked", toVal));
  5957.                         });
  5958.  
  5959.                         this._dis_$btnRefresh.click(() => this._dis_doPopulateList());
  5960.  
  5961.                         this._dis_$btnStop.off("click").click(() => {
  5962.                                 const sel = this._dis_getSelected();
  5963.                                 if (!sel.length) return d20plus.ut.chatLog("Please select some items from the list!");
  5964.                                 if (!confirm("Are you sure?")) return;
  5965.  
  5966.                                 sel.map(it => it.values()).forEach(it => {
  5967.                                         delete d20plus.anim.animator._tracker[it._tokenId].active[it._animUid];
  5968.  
  5969.                                         if (!hasAnyKey(d20plus.anim.animator._tracker[it._tokenId].active)) {
  5970.                                                 delete d20plus.anim.animator._tracker[it._tokenId];
  5971.                                         }
  5972.                                 });
  5973.  
  5974.                                 d20plus.anim.animator.saveState();
  5975.                                 this._dis_doPopulateList();
  5976.                         });
  5977.                 },
  5978.                 // endregion disabler
  5979.  
  5980.                 // region editor
  5981.                 _edit_openEditor (anim) {
  5982.                         const $winEditor = $(this._html_template_editor)
  5983.                                 .attr("title", `Animation Editor - ${anim.name}`)
  5984.                                 .appendTo($("body"));
  5985.  
  5986.                         $winEditor.dialog({
  5987.                                 resizable: true,
  5988.                                 width: 800,
  5989.                                 height: 600,
  5990.                                 close: () => {
  5991.                                         setTimeout(() => $winEditor.remove())
  5992.                                 }
  5993.                         });
  5994.  
  5995.                         const $iptName = $winEditor.find(`[name="ipt-name"]`).disableSpellcheck();
  5996.                         const $btnSave = $winEditor.find(`[name="btn-save"]`);
  5997.                         const $btnHelp = $winEditor.find(`[name="btn-help"]`);
  5998.                         const $btnAddCommand = $winEditor.find(`[name="btn-add-command"]`);
  5999.                         const $btnExportFile = $winEditor.find(`[name="btn-export-file"]`);
  6000.                         const $btnValidate = $winEditor.find(`[name="btn-validate"]`);
  6001.                         const $btnEditText = $winEditor.find(`[name="btn-edit-text"]`);
  6002.                         const $iptLines = $winEditor.find(`[name="ipt-lines"]`);
  6003.                         const $wrpRows = $winEditor.find(`.anm-edit__ipt-lines-wrp--gui`);
  6004.  
  6005.                         anim.lines = anim.lines || [];
  6006.                         $iptName
  6007.                                 .val(anim.name)
  6008.                                 .change(() => {
  6009.                                         $winEditor.dialog("option", "title", `Animation Editor - ${$iptName.val()}`);
  6010.                                 });
  6011.  
  6012.                         // map to strings to ensure fresh array
  6013.                         let myLines = anim.lines.map(it => typeof it === "string" ? it : it.line);
  6014.  
  6015.                         const doDisplayLines = () => {
  6016.                                 $iptLines.val(myLines.map(it => typeof it === "string" ? it : it.line).join("\n"));
  6017.                         };
  6018.  
  6019.                         const gui_getTitleFromType = (type, doRemoveExact) => {
  6020.                                 const clean = doRemoveExact ? type.replace(/exact/gi, "") : type;
  6021.  
  6022.                                 const splCaps = clean.split(/([A-Z])/g).filter(it => it.trim());
  6023.                                 const stack = [];
  6024.                                 for (let i = 0; i < splCaps.length; ++i) {
  6025.                                         const tok = splCaps[i];
  6026.                                         if (i % 2 === 0) stack.push(tok);
  6027.                                         else stack[stack.length - 1] = `${stack.last()}${tok}`;
  6028.                                 }
  6029.                                 return stack.join(" ");
  6030.                         };
  6031.  
  6032.                         const gui_getBasicRowMeta = (myLines, line, isDuration) => {
  6033.                                 const parsed = line.parsed;
  6034.  
  6035.                                 const _getTitleMeta = () => {
  6036.                                         const clean = parsed._type.replace(/exact/gi, "");
  6037.  
  6038.                                         const text = gui_getTitleFromType(parsed._type, true);
  6039.  
  6040.                                         return {
  6041.                                                 text,
  6042.                                                 className: `anm-edit__gui-row-name--${clean}`
  6043.                                         }
  6044.                                 };
  6045.  
  6046.                                 const doUpdate = () => {
  6047.                                         parsed.start = Math.round(Number($iptStart.val()));
  6048.                                         if (isDuration) parsed.duration = Math.round(Number($iptDuration.val()));
  6049.                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6050.                                 };
  6051.  
  6052.                                 const $btnRemove = $(`<button class="btn btn-danger mr-2">Delete</button>`).click(() => {
  6053.                                         myLines.splice(myLines.indexOf(line), 1);
  6054.                                         $row.remove();
  6055.                                 });
  6056.  
  6057.                                 const $iptStart = $(`<input type="number" min="0" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.start);
  6058.                                 const $iptDuration = isDuration ? $(`<input type="number" min="0" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.duration) : null;
  6059.  
  6060.                                 const $wrpHeaders = $$`<div class="flex-v-center mb-2">
  6061.                                                 <div class="col-2 bold flex-vh-center">Start Time (ms)</div>
  6062.                                                 ${isDuration ? `<div class="col-2 bold flex-vh-center">Duration (ms)</div>` : ""}
  6063.                                         </div>`;
  6064.  
  6065.                                 const $wrpInputs = $$`<div class="flex-v-center">
  6066.                                                 <div class="col-2 flex-vh-center">${$iptStart}</div>
  6067.                                                 ${isDuration ? $$`<div class="col-2 flex-vh-center">${$iptDuration}</div>` : ""}
  6068.                                         </div>`;
  6069.  
  6070.                                 const titleMeta = _getTitleMeta();
  6071.                                 const $dispName = $(`<div class="bold anm-edit__gui-row-name ${titleMeta.className}">${titleMeta.text}</div>`);
  6072.                                 const $row = $$`<div class="flex-col full-width anm-edit__gui-row">
  6073.                                                 <div class="split flex-v-center mb-2">
  6074.                                                         <div class="full-width flex-v-center full-height">${$dispName}</div>
  6075.                                                         ${$btnRemove}
  6076.                                                 </div>                 
  6077.                                                 ${$wrpHeaders}
  6078.                                                 ${$wrpInputs}
  6079.                                         </div>`;
  6080.  
  6081.                                 return {$row, doUpdate, $wrpHeaders, $wrpInputs, $dispName};
  6082.                         };
  6083.  
  6084.                         const gui_$getBtnAnim = (fnUpdate, $iptAnim) => {
  6085.                                 return $(`<button class="btn btn-xs mr-2 pictos">s</button>`)
  6086.                                         .click(async () => {
  6087.                                                 const name = await new Promise(resolve => {
  6088.                                                         const $selAnim = $(`<select>
  6089.                                                         <option value="-1">(None)</option>
  6090.                                                         ${d20plus.anim.animatorTool.getAnimations().map(it => `<option value="${it.uid}">${it.name}</option>`).join("")}
  6091.                                                         </select>`);
  6092.                                                         $selAnim[0].selectedIndex = 0;
  6093.  
  6094.                                                         const $dialog = $$`<div title="Select Animation">${$selAnim}</div>`.appendTo($("body"));
  6095.  
  6096.                                                         $dialog.dialog({
  6097.                                                                 dialogClass: "no-close",
  6098.                                                                 buttons: [
  6099.                                                                         {
  6100.                                                                                 text: "Cancel",
  6101.                                                                                 click: function () {
  6102.                                                                                         $(this).dialog("close");
  6103.                                                                                         $dialog.remove();
  6104.                                                                                 }
  6105.                                                                         },
  6106.                                                                         {
  6107.                                                                                 text: "OK",
  6108.                                                                                 click: function () {
  6109.                                                                                         const selected = Number(d20plus.ut.get$SelValue($selAnim));
  6110.                                                                                         $(this).dialog("close");
  6111.                                                                                         $dialog.remove();
  6112.  
  6113.                                                                                         if (~selected) resolve((d20plus.anim.animatorTool.getAnimation(selected) || {}).name);
  6114.                                                                                         else resolve(null);
  6115.                                                                                 }
  6116.                                                                         }
  6117.                                                                 ]
  6118.                                                         });
  6119.                                                 });
  6120.  
  6121.                                                 if (name != null) {
  6122.                                                         $iptAnim.val(name);
  6123.                                                         fnUpdate();
  6124.                                                 } else if (!allowNone) {
  6125.                                                         $iptAnim.val("-");
  6126.                                                         fnUpdate();
  6127.                                                 }
  6128.                                         });
  6129.                         };
  6130.  
  6131.                         const gui_$getWrapped = (it, width, bold) =>  $$`<div class="col-${width} flex-vh-center ${bold ? "bold" : ""}">${it}</div>`;
  6132.  
  6133.                         const gui_doAddRow = (myLines, line) => {
  6134.                                 const parsed = line.parsed;
  6135.                                 switch (parsed._type) {
  6136.                                         case "Move":
  6137.                                         case "MoveExact": {
  6138.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, true);
  6139.  
  6140.                                                 const doUpdate = () => {
  6141.                                                         baseMeta.doUpdate();
  6142.                                                         parsed.x = $iptX.val().trim() ? Math.round(Number($iptX.val())) : null;
  6143.                                                         parsed.y = $iptY.val().trim() ? Math.round(Number($iptY.val())) : null;
  6144.                                                         parsed.z = $iptZ.val().trim() ? Math.round(Number($iptZ.val())) : null;
  6145.                                                         parsed._type = $cbExact.prop("checked") ? "MoveExact" : "Move";
  6146.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6147.                                                         baseMeta.$dispName.text(parsed._type);
  6148.                                                 };
  6149.  
  6150.                                                 const $iptX = $(`<input type="number" min="0" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.x);
  6151.                                                 const $iptY = $(`<input type="number" min="0" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.y);
  6152.                                                 const $iptZ = $(`<input type="number" min="0" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.z);
  6153.                                                 const $cbExact = $(`<input type="checkbox">`).prop("checked", parsed._type === "MoveExact").change(() => doUpdate());
  6154.  
  6155.                                                 gui_$getWrapped("X", 1, true).appendTo(baseMeta.$wrpHeaders);
  6156.                                                 gui_$getWrapped("Y", 1, true).appendTo(baseMeta.$wrpHeaders);
  6157.                                                 gui_$getWrapped("Z", 1, true).appendTo(baseMeta.$wrpHeaders);
  6158.                                                 gui_$getWrapped("", 4).appendTo(baseMeta.$wrpHeaders);
  6159.                                                 gui_$getWrapped("Is Exact", 1, true).appendTo(baseMeta.$wrpHeaders);
  6160.  
  6161.                                                 gui_$getWrapped($iptX, 1).appendTo(baseMeta.$wrpInputs);
  6162.                                                 gui_$getWrapped($iptY, 1).appendTo(baseMeta.$wrpInputs);
  6163.                                                 gui_$getWrapped($iptZ, 1).appendTo(baseMeta.$wrpInputs);
  6164.                                                 gui_$getWrapped("", 4).appendTo(baseMeta.$wrpInputs);
  6165.                                                 gui_$getWrapped($cbExact, 1).appendTo(baseMeta.$wrpInputs);
  6166.  
  6167.                                                 $wrpRows.append(baseMeta.$row);
  6168.  
  6169.                                                 break;
  6170.                                         }
  6171.                                         case "Rotate":
  6172.                                         case "RotateExact": {
  6173.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, true);
  6174.  
  6175.                                                 const doUpdate = () => {
  6176.                                                         baseMeta.doUpdate();
  6177.                                                         parsed.degrees = $iptDegrees.val().trim() ? Math.round(Number($iptDegrees.val().trim())) : null;
  6178.                                                         parsed._type = $cbExact.prop("checked") ? "RotateExact" : "Rotate";
  6179.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6180.                                                         baseMeta.$dispName.text(parsed._type);
  6181.                                                 };
  6182.  
  6183.                                                 const $iptDegrees = $(`<input type="number" min="0" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.degrees);
  6184.                                                 const $cbExact = $(`<input type="checkbox">`).prop("checked", parsed._type === "RotateExact").change(() => doUpdate());
  6185.  
  6186.                                                 gui_$getWrapped("Degrees", 2, true).appendTo(baseMeta.$wrpHeaders);
  6187.                                                 gui_$getWrapped("", 6).appendTo(baseMeta.$wrpHeaders);
  6188.                                                 gui_$getWrapped("Is Exact", 1, true).appendTo(baseMeta.$wrpHeaders);
  6189.  
  6190.                                                 gui_$getWrapped($iptDegrees, 2).appendTo(baseMeta.$wrpInputs);
  6191.                                                 gui_$getWrapped("", 6).appendTo(baseMeta.$wrpInputs);
  6192.                                                 gui_$getWrapped($cbExact, 1).appendTo(baseMeta.$wrpInputs);
  6193.  
  6194.                                                 $wrpRows.append(baseMeta.$row);
  6195.  
  6196.                                                 break;
  6197.                                         }
  6198.                                         case "Copy": {
  6199.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, false);
  6200.  
  6201.                                                 const doUpdate = () => {
  6202.                                                         baseMeta.doUpdate();
  6203.                                                         parsed.animation = $iptAnim.val().trim() || null;
  6204.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6205.                                                 };
  6206.  
  6207.                                                 const $iptAnim = $(`<input class="full-width mr-1">`).change(() => doUpdate()).val(parsed.animation);
  6208.                                                 const $btnSelAnim = gui_$getBtnAnim(doUpdate, $iptAnim);
  6209.  
  6210.                                                 gui_$getWrapped("Animation", 3, true).appendTo(baseMeta.$wrpHeaders);
  6211.  
  6212.                                                 gui_$getWrapped($iptAnim, 3).append($btnSelAnim).appendTo(baseMeta.$wrpInputs);
  6213.  
  6214.                                                 $wrpRows.append(baseMeta.$row);
  6215.  
  6216.                                                 break;
  6217.                                         }
  6218.                                         case "Flip":
  6219.                                         case "FlipExact": {
  6220.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, false);
  6221.  
  6222.                                                 const doUpdate = () => {
  6223.                                                         baseMeta.doUpdate();
  6224.                                                         parsed.flipH = $selFlipH.val() === "0" ? null : $selFlipH.val() !== "1";
  6225.                                                         parsed.flipV = $selFlipV.val() === "0" ? null : $selFlipV.val() !== "1";
  6226.                                                         parsed._type = $cbExact.prop("checked") ? "FlipExact" : "Flip";
  6227.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6228.                                                         baseMeta.$dispName.text(parsed._type);
  6229.                                                 };
  6230.  
  6231.                                                 const $getSelFlip = () => {
  6232.                                                         const VALS = ["(None)", "No", "Yes"];
  6233.                                                         return $(`<select class="sel-xs mr-2">${VALS.map((it, i) => `<option value="${i}">${it}</option>`).join("")}</select>`);
  6234.                                                 };
  6235.  
  6236.                                                 const $selFlipH = $getSelFlip().val(parsed.flipH == null ? "0" : parsed.flipH ? "2" : "1").change(() => doUpdate());
  6237.                                                 const $selFlipV = $getSelFlip().val(parsed.flipV == null ? "0" : parsed.flipV ? "2" : "1").change(() => doUpdate());
  6238.                                                 const $cbExact = $(`<input type="checkbox">`).prop("checked", parsed._type === "FlipExact").change(() => doUpdate());
  6239.  
  6240.                                                 gui_$getWrapped("Flip Horizontally", 3, true).appendTo(baseMeta.$wrpHeaders);
  6241.                                                 gui_$getWrapped("Flip Vertically", 3, true).appendTo(baseMeta.$wrpHeaders);
  6242.                                                 gui_$getWrapped("", 3).appendTo(baseMeta.$wrpHeaders);
  6243.                                                 gui_$getWrapped("Is Exact", 1, true).appendTo(baseMeta.$wrpHeaders);
  6244.  
  6245.                                                 gui_$getWrapped($selFlipH, 3).appendTo(baseMeta.$wrpInputs);
  6246.                                                 gui_$getWrapped($selFlipV, 3).appendTo(baseMeta.$wrpInputs);
  6247.                                                 gui_$getWrapped("", 3).appendTo(baseMeta.$wrpInputs);
  6248.                                                 gui_$getWrapped($cbExact, 1).appendTo(baseMeta.$wrpInputs);
  6249.  
  6250.                                                 $wrpRows.append(baseMeta.$row);
  6251.  
  6252.                                                 break;
  6253.                                         }
  6254.                                         case "Scale":
  6255.                                         case "ScaleExact": {
  6256.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, true);
  6257.  
  6258.                                                 const doUpdate = () => {
  6259.                                                         baseMeta.doUpdate();
  6260.                                                         parsed.scaleX = $iptScaleX.val().trim() ? Number($iptScaleX.val()) : null;
  6261.                                                         parsed.scaleY = $iptScaleY.val().trim() ? Number($iptScaleY.val()) : null;
  6262.                                                         parsed._type = $cbExact.prop("checked") ? "ScaleExact" : "Scale";
  6263.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6264.                                                         baseMeta.$dispName.text(parsed._type);
  6265.                                                 };
  6266.  
  6267.                                                 const $iptScaleX = $(`<input type="number" min="0" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.scaleX);
  6268.                                                 const $iptScaleY = $(`<input type="number" min="0" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.scaleY);
  6269.                                                 const $cbExact = $(`<input type="checkbox">`).prop("checked", parsed._type === "ScaleExact").change(() => doUpdate());
  6270.  
  6271.                                                 gui_$getWrapped("Horizontal Scale", 3, true).appendTo(baseMeta.$wrpHeaders);
  6272.                                                 gui_$getWrapped("Vertical Scale", 3, true).appendTo(baseMeta.$wrpHeaders);
  6273.                                                 gui_$getWrapped("", 1).appendTo(baseMeta.$wrpHeaders);
  6274.                                                 gui_$getWrapped("Is Exact", 1, true).appendTo(baseMeta.$wrpHeaders);
  6275.  
  6276.                                                 gui_$getWrapped($iptScaleX, 3).appendTo(baseMeta.$wrpInputs);
  6277.                                                 gui_$getWrapped($iptScaleY, 3).appendTo(baseMeta.$wrpInputs);
  6278.                                                 gui_$getWrapped("", 1).appendTo(baseMeta.$wrpInputs);
  6279.                                                 gui_$getWrapped($cbExact, 1).appendTo(baseMeta.$wrpInputs);
  6280.  
  6281.                                                 $wrpRows.append(baseMeta.$row);
  6282.  
  6283.                                                 break;
  6284.                                         }
  6285.                                         case "Layer": {
  6286.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, false);
  6287.  
  6288.                                                 const doUpdate = () => {
  6289.                                                         baseMeta.doUpdate();
  6290.                                                         parsed.layer = $selLayer.val().trim() ? $selLayer.val() : null;
  6291.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6292.                                                 };
  6293.  
  6294.                                                 const $selLayer = $(`<select class="mr-2 sel-xs">
  6295.                                                         <option value="">Select a layer...</option>
  6296.                                                         ${d20plus.ut.LAYERS.map(l => `<option value="${l}">${d20plus.ut.layerToName(l)}</option>`).join("")}
  6297.                                                         </select>`)
  6298.                                                         .change(() => doUpdate()).val(parsed.layer);
  6299.  
  6300.                                                 gui_$getWrapped("Layer", 3, true).appendTo(baseMeta.$wrpHeaders);
  6301.  
  6302.                                                 gui_$getWrapped($selLayer, 3).appendTo(baseMeta.$wrpInputs);
  6303.  
  6304.                                                 $wrpRows.append(baseMeta.$row);
  6305.  
  6306.                                                 break;
  6307.                                         }
  6308.                                         case "Lighting":
  6309.                                         case "LightingExact": {
  6310.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, true);
  6311.  
  6312.                                                 const doUpdate = () => {
  6313.                                                         baseMeta.doUpdate();
  6314.                                                         parsed.lightRadius = $iptLightRadius.val().trim() ? Math.round(Number($iptLightRadius.val())) : null;
  6315.                                                         parsed.dimStart = $iptDimStart.val().trim() ? Math.round(Number($iptDimStart.val())) : null;
  6316.                                                         parsed.degrees = $iptDegrees.val().trim() ? Math.round(Number($iptDegrees.val())) : null;
  6317.                                                         parsed._type = $cbExact.prop("checked") ? "LightingExact" : "Lighting";
  6318.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6319.                                                         baseMeta.$dispName.text(parsed._type);
  6320.                                                 };
  6321.  
  6322.                                                 const $iptLightRadius = $(`<input type="number" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.lightRadius);
  6323.                                                 const $iptDimStart = $(`<input type="number" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.dimStart);
  6324.                                                 const $iptDegrees = $(`<input type="number" min="0" class="full-width mr-2">`).change(() => doUpdate()).val(parsed.degrees);
  6325.                                                 const $cbExact = $(`<input type="checkbox">`).prop("checked", parsed._type === "MoveExact").change(() => doUpdate());
  6326.  
  6327.                                                 gui_$getWrapped("Light Radius", 2, true).appendTo(baseMeta.$wrpHeaders);
  6328.                                                 gui_$getWrapped("Dim Start", 2, true).appendTo(baseMeta.$wrpHeaders);
  6329.                                                 gui_$getWrapped("Angle", 2, true).appendTo(baseMeta.$wrpHeaders);
  6330.                                                 gui_$getWrapped("", 1).appendTo(baseMeta.$wrpHeaders);
  6331.                                                 gui_$getWrapped("Is Exact", 1, true).appendTo(baseMeta.$wrpHeaders);
  6332.  
  6333.                                                 gui_$getWrapped($iptLightRadius, 2).appendTo(baseMeta.$wrpInputs);
  6334.                                                 gui_$getWrapped($iptDimStart, 2).appendTo(baseMeta.$wrpInputs);
  6335.                                                 gui_$getWrapped($iptDegrees, 2).appendTo(baseMeta.$wrpInputs);
  6336.                                                 gui_$getWrapped("", 1).appendTo(baseMeta.$wrpInputs);
  6337.                                                 gui_$getWrapped($cbExact, 1).appendTo(baseMeta.$wrpInputs);
  6338.  
  6339.                                                 $wrpRows.append(baseMeta.$row);
  6340.  
  6341.                                                 break;
  6342.                                         }
  6343.                                         case "SetProperty":
  6344.                                         case "SumProperty": {
  6345.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, false);
  6346.  
  6347.                                                 const doUpdate = () => {
  6348.                                                         baseMeta.doUpdate();
  6349.                                                         parsed.prop = $selProp.val();
  6350.                                                         try { parsed.value = JSON.parse($iptVal().trim()); }
  6351.                                                         catch (ignored) { parsed.value = $iptVal.val(); }
  6352.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6353.                                                         parsed._type = $selMode.val();
  6354.                                                         baseMeta.$dispName.text(parsed._type);
  6355.                                                 };
  6356.  
  6357.                                                 const $selProp = $(`<select class="mr-2 sel-xs">${d20plus.anim._PROP_TOKEN.sort(SortUtil.ascSortLower).map(it => `<option>${it}</option>`).join("")}</select>`)
  6358.                                                         .change(() => doUpdate()).val(parsed.prop);
  6359.                                                 const $iptVal = $(`<textarea class="full-width my-0" style="resize: vertical;"></textarea>`).change(() => doUpdate()).val(parsed.value);
  6360.                                                 const $selMode = $(`<select class="mr-2 sel-xs">
  6361.                                                         <option value="SetProperty">Set</option>
  6362.                                                         <option value="SumProperty">Sum</option>
  6363.                                                 </select>`)
  6364.                                                         .val(parsed._type)
  6365.                                                         .change(() => doUpdate());
  6366.  
  6367.                                                 gui_$getWrapped("Property", 4, true).appendTo(baseMeta.$wrpHeaders);
  6368.                                                 gui_$getWrapped("Value", 3, true).appendTo(baseMeta.$wrpHeaders);
  6369.                                                 gui_$getWrapped("", 1).appendTo(baseMeta.$wrpHeaders);
  6370.                                                 gui_$getWrapped("Mode", 2, true).appendTo(baseMeta.$wrpHeaders);
  6371.  
  6372.                                                 gui_$getWrapped($selProp, 4).appendTo(baseMeta.$wrpInputs);
  6373.                                                 gui_$getWrapped($iptVal, 3).appendTo(baseMeta.$wrpInputs);
  6374.                                                 gui_$getWrapped("", 1).appendTo(baseMeta.$wrpInputs);
  6375.                                                 gui_$getWrapped($selMode, 2).appendTo(baseMeta.$wrpInputs);
  6376.  
  6377.                                                 $wrpRows.append(baseMeta.$row);
  6378.  
  6379.                                                 break;
  6380.                                         }
  6381.                                         case "TriggerMacro": {
  6382.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, false);
  6383.  
  6384.                                                 const doUpdate = () => {
  6385.                                                         baseMeta.doUpdate();
  6386.                                                         parsed.macro = $iptMacro.val().trim() ? $iptMacro.val().trim() : null;
  6387.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6388.                                                 };
  6389.  
  6390.                                                 const $iptMacro = $(`<input class="full-width mr-2">`).change(() => doUpdate()).val(parsed.macro);
  6391.                                                 // TODO add macro search button?
  6392.  
  6393.                                                 gui_$getWrapped("Macro Name", 4, true).appendTo(baseMeta.$wrpHeaders);
  6394.  
  6395.                                                 gui_$getWrapped($iptMacro, 4).appendTo(baseMeta.$wrpInputs);
  6396.  
  6397.                                                 $wrpRows.append(baseMeta.$row);
  6398.  
  6399.                                                 break;
  6400.                                         }
  6401.                                         case "TriggerAnimation": {
  6402.                                                 const baseMeta = gui_getBasicRowMeta(myLines, line, false);
  6403.  
  6404.                                                 const doUpdate = () => {
  6405.                                                         baseMeta.doUpdate();
  6406.                                                         parsed.animation = $iptAnim.val().trim() ? $iptAnim.val().trim() : null;
  6407.                                                         line.line = d20plus.anim.lineFromParsed(parsed);
  6408.                                                 };
  6409.  
  6410.                                                 const $iptAnim = $(`<input class="full-width mr-1">`).change(() => doUpdate()).val(parsed.animation);
  6411.                                                 const $btnSelAnim = gui_$getBtnAnim(doUpdate, $iptAnim);
  6412.  
  6413.                                                 gui_$getWrapped("Animation", 3, true).appendTo(baseMeta.$wrpHeaders);
  6414.  
  6415.                                                 gui_$getWrapped($iptAnim, 3).append($btnSelAnim).appendTo(baseMeta.$wrpInputs);
  6416.  
  6417.                                                 $wrpRows.append(baseMeta.$row);
  6418.  
  6419.                                                 break;
  6420.                                         }
  6421.                                         default: throw new Error(`Unhandled type "${parsed._type}"`);
  6422.                                 }
  6423.                         };
  6424.  
  6425.                         const doDisplayRows = () => {
  6426.                                 $wrpRows.empty();
  6427.                                 const wrpMyLines = {lines: myLines};
  6428.                                 this._edit_convertLines(wrpMyLines);
  6429.  
  6430.                                 myLines.forEach(line => {
  6431.                                         if (line.error) {
  6432.                                                 console.error(`Failed to create GUI row from line "${line.line}"!`);
  6433.                                                 console.error(line.error)
  6434.                                         } else gui_doAddRow(myLines, line);
  6435.                                 });
  6436.                         };
  6437.  
  6438.                         const getValidationMessage = () => {
  6439.                                 if ($btnEditText.hasClass("active")) {
  6440.                                         // create a fake animation object, and check it for errors
  6441.                                         const toValidate = {
  6442.                                                 uid: anim.uid, // pass out UID, so the validator can ignore our old data when checking duplicate names
  6443.                                                 name: $iptName.val(),
  6444.                                                 lines: $iptLines.val().split("\n")
  6445.                                         };
  6446.                                         return this._edit_getValidationMessage(toValidate);
  6447.                                 }
  6448.                                 // (assume the GUI version passes validation)
  6449.                                 return null;
  6450.                         };
  6451.  
  6452.                         $btnSave.off("click").click(() => {
  6453.                                 if ($btnEditText.hasClass("active")) {
  6454.                                         const msg = getValidationMessage();
  6455.                                         if (msg) return d20plus.ut.chatLog(msg);
  6456.  
  6457.                                         // we passed validation
  6458.                                         anim.name = $iptName.val();
  6459.                                         anim.lines = $iptLines.val().split("\n");
  6460.                                 } else {
  6461.                                         const nameMsg = this._shared_getValidNameMsg({name: $iptName.val(), uid: anim.uid}, this._anims);
  6462.                                         if (nameMsg) return d20plus.ut.chatLog(nameMsg);
  6463.  
  6464.                                         anim.name = $iptName.val();
  6465.                                         anim.lines = myLines.map(it => typeof it === "string" ? it : it.line);
  6466.                                 }
  6467.                                 this._doSaveStateDebounced();
  6468.  
  6469.                                 const matches = this._anim_list.get("uid", anim.uid);
  6470.                                 if (matches.length) matches[0].values({name: anim.name});
  6471.  
  6472.                                 d20plus.ut.chatLog("Saved!");
  6473.                         });
  6474.  
  6475.                         $btnExportFile.off("click").click(() => {
  6476.                                 const out = {animations: [this._main_getExportableAnim(anim)]};
  6477.                                 DataUtil.userDownload(`${anim.name}`, out);
  6478.                         });
  6479.  
  6480.                         $btnValidate.off("click").click(() => {
  6481.                                 const msg = getValidationMessage();
  6482.                                 d20plus.ut.chatLog(msg || "Valid!");
  6483.                         });
  6484.  
  6485.                         $btnHelp.click(() => {
  6486.                                 d20plus.ut.chatLog(`<a href="https://wiki.5e.tools/index.php/Feature:_Animator" target="_blank">View the Wiki page for help!</a>`);
  6487.                                 window.open("https://wiki.5e.tools/index.php/Feature:_Animator");
  6488.                         });
  6489.  
  6490.                         let lastSelCommand = null;
  6491.                         $btnAddCommand.click(async () => {
  6492.                                 const _KEYS = [...new Set(Object.keys(d20plus.anim.COMMAND_TO_SHORT).map(it => it.replace(/exact/gi, "")))];
  6493.  
  6494.                                 const type = await new Promise(resolve => {
  6495.                                         const $selCommand = $(`<select>
  6496.                                         <option disabled value="-1">Select Command...</option>
  6497.                                         ${_KEYS.map((it, i) => `<option value="${i}">${gui_getTitleFromType(it, false)}</option>`).join("")}
  6498.                                         </select>`);
  6499.  
  6500.                                         if (lastSelCommand != null) $selCommand.val(lastSelCommand);
  6501.                                         else $selCommand[0].selectedIndex = 0;
  6502.  
  6503.                                         const $dialog = $$`<div title="Select Command">${$selCommand}</div>`.appendTo($("body"));
  6504.  
  6505.                                         $dialog.dialog({
  6506.                                                 dialogClass: "no-close",
  6507.                                                 buttons: [
  6508.                                                         {
  6509.                                                                 text: "Cancel",
  6510.                                                                 click: function () {
  6511.                                                                         $(this).dialog("close");
  6512.                                                                         $dialog.remove();
  6513.                                                                 }
  6514.                                                         },
  6515.                                                         {
  6516.                                                                 text: "OK",
  6517.                                                                 click: function () {
  6518.                                                                         const ix = Number(d20plus.ut.get$SelValue($selCommand));
  6519.                                                                         $(this).dialog("close");
  6520.                                                                         $dialog.remove();
  6521.  
  6522.                                                                         if (~ix) {
  6523.                                                                                 resolve(_KEYS[ix]);
  6524.                                                                                 lastSelCommand = String(ix);
  6525.                                                                         } else resolve(null);
  6526.                                                                 }
  6527.                                                         }
  6528.                                                 ]
  6529.                                         });
  6530.                                 });
  6531.  
  6532.                                 if (type == null) return;
  6533.  
  6534.                                 const nuLine = (() => {
  6535.                                         const short = d20plus.anim.COMMAND_TO_SHORT[type];
  6536.                                         if (!short) throw new Error(`No short form found for "${short}"`);
  6537.                                         const args = d20plus.anim.SHORT_TO_DEFAULT_ARGS[short];
  6538.                                         if (!args) throw new Error(`No default args found for "${short}"`);
  6539.                                         return `${short} ${args}`;
  6540.                                 })();
  6541.  
  6542.                                 myLines.push(nuLine);
  6543.                                 const wrpMyLines = {lines: myLines};
  6544.                                 this._edit_convertLines(wrpMyLines);
  6545.                                 gui_doAddRow(myLines, myLines.last());
  6546.                         });
  6547.  
  6548.                         $btnEditText.click(() => {
  6549.                                 const isTextModeNxt = !$btnEditText.hasClass("active");
  6550.                                 if (isTextModeNxt) {
  6551.                                         // myLines will already be up-to-date due to UI state changes; simply switch to text display
  6552.                                         doDisplayLines();
  6553.                                 } else {
  6554.                                         // validate + update state
  6555.                                         const msg = getValidationMessage();
  6556.                                         if (msg) return d20plus.ut.chatLog(msg);
  6557.  
  6558.                                         myLines = $iptLines.val().split("\n").map(it => it.trim()).filter(Boolean);
  6559.                                         doDisplayRows();
  6560.                                 }
  6561.  
  6562.                                 $btnEditText.toggleClass("active");
  6563.                                 $winEditor.toggleClass("anm-edit__text", isTextModeNxt);
  6564.                                 $winEditor.toggleClass("anm-edit__gui", !isTextModeNxt);
  6565.                         });
  6566.  
  6567.                         doDisplayRows();
  6568.                 },
  6569.  
  6570.                 /**
  6571.                  * Returns `null` if valid, or an error message if invalid.
  6572.                  * @private
  6573.                  */
  6574.                 _edit_getValidationMessage (anim) {
  6575.                         // validate name
  6576.                         const nameMsg = this._shared_getValidNameMsg(anim, this._anims);
  6577.                         if (nameMsg) return nameMsg;
  6578.  
  6579.                         // validate lines
  6580.                         this._edit_convertLines(anim);
  6581.  
  6582.                         const badLines = anim.lines.filter(c => c.error);
  6583.                         if (badLines.length) {
  6584.                                 return `Invalid, the following lines could not be parsed:\n${badLines.map(c => `${c.error} at line "${c.line}"`).join("\n")}`;
  6585.                         }
  6586.  
  6587.                         return null;
  6588.                 },
  6589.  
  6590.                 _edit_convertLines (anim) {
  6591.                         for (let i = 0; i < anim.lines.length; ++i) {
  6592.                                 const line = anim.lines[i];
  6593.                                 if (typeof line === "string") anim.lines[i] = Command.fromString(line);
  6594.                         }
  6595.                 },
  6596.                 // endregion editor
  6597.         };
  6598.  
  6599.         d20plus.tool.tools.push(d20plus.anim.animatorTool);
  6600.  
  6601.         function hasAnyKey (object) {
  6602.                 for (const k in object) {
  6603.                         if (!object.hasOwnProperty(k)) continue;
  6604.                         return true;
  6605.                 }
  6606.                 return false;
  6607.         }
  6608.  
  6609.         d20plus.anim.animator = {
  6610.            /*
  6611.                 _tracker: {
  6612.                         tokenId: {
  6613.                                 token: {...}, // Roll20 token
  6614.                                 active: {
  6615.                                         // only one instance of an animation can be active on a token at a time
  6616.                                         animUid: {
  6617.                                                 queue: [...], // returned by getAnimQueue
  6618.                                                 start, // start time
  6619.                                                 lastTick, // last tick time
  6620.                                                 lastAlpha // last alpha value passed -- used for deserialization
  6621.                                         },
  6622.                                         ... // other animations
  6623.                                 }
  6624.                         }
  6625.                 }
  6626.                 */
  6627.                 _tracker: {},
  6628.                 _restTicks: 1,
  6629.  
  6630.                 __tickCount: 0,
  6631.  
  6632.                 startAnimation (token, animUid, options) {
  6633.                         options = options || {};
  6634.  
  6635.                         const anim = d20plus.anim.animatorTool.getAnimation(animUid);
  6636.                         const queue = d20plus.anim.animatorTool.getAnimQueue(anim, options.offset || 0);
  6637.  
  6638.                         this._tracker[token.id] = this._tracker[token.id] || {token, active: {}};
  6639.                         const time = (new Date).getTime();
  6640.                         this._tracker[token.id].active[animUid] = {
  6641.                                 queue,
  6642.                                 start: time,
  6643.                                 lastTick: time
  6644.                         };
  6645.                 },
  6646.  
  6647.                 endAnimation (token, animUid) {
  6648.                         if (this._tracker[token.id] && this._tracker[token.id].active[animUid]) {
  6649.                                 delete this._tracker[token.id].active[animUid];
  6650.  
  6651.                                 if (hasAnyKey(this._tracker[token.id].active)) delete this._tracker[token.id];
  6652.                         }
  6653.                 },
  6654.  
  6655.                 setRestTicks (tickRate) {
  6656.                         this._restTicks = tickRate;
  6657.                 },
  6658.  
  6659.                 _lastTickActive: false,
  6660.                 _tickTimeout: null,
  6661.                 doTick () {
  6662.                         if (this._tickTimeout) clearTimeout(this._tickTimeout);
  6663.  
  6664.                         if (this._hasAnyActive()) {
  6665.                                 // if we've been sleeping, reset start times
  6666.                                 // prevents an initial "jolt" as anims suddenly have catch up on 1.5s of lag
  6667.                                 if (!this._lastTickActive) {
  6668.                                         this._lastTickActive = true;
  6669.                                         const time = (new Date()).getTime();
  6670.  
  6671.                                         for (const tokenId in this._tracker) {
  6672.                                                 if (!this._tracker.hasOwnProperty(tokenId)) continue;
  6673.                                                 const tokenMeta = this._tracker[tokenId];
  6674.  
  6675.                                                 for (const animUid in tokenMeta.active) {
  6676.                                                         if (!tokenMeta.active.hasOwnProperty(animUid)) continue;
  6677.                                                         const instance = tokenMeta.active[animUid];
  6678.                                                         instance.start = time;
  6679.                                                         instance.lastTick = time;
  6680.                                                 }
  6681.                                         }
  6682.                                 }
  6683.  
  6684.                                 this._doTick();
  6685.                         } else {
  6686.                                 this._lastTickActive = false;
  6687.                                 // if none are active, sleep for 1.5 seconds
  6688.                                 this._tickTimeout = setTimeout(() => this.doTick(), 1500);
  6689.                         }
  6690.                 },
  6691.  
  6692.                 _saveState () {
  6693.                         const toSave = {};
  6694.                         Object.entries(this._tracker).forEach(([tokenId, tokenMeta]) => {
  6695.                                 const saveableTokenMeta = {active: {}};
  6696.  
  6697.                                 Object.entries(tokenMeta.active).forEach(([animUid, state]) => {
  6698.                                         saveableTokenMeta.active[animUid] = {
  6699.                                                 queue: state.queue.map(it => it.serialize()),
  6700.                                                 lastAlpha: state.lastAlpha
  6701.                                         };
  6702.                                 });
  6703.  
  6704.                                 toSave[tokenId] = saveableTokenMeta;
  6705.                         });
  6706.  
  6707.                         Campaign.save({
  6708.                                 bR20tool__anim_running: toSave
  6709.                         });
  6710.                 },
  6711.  
  6712.                 saveState () {
  6713.                         if (d20plus.anim.animatorTool.isSavingActive()) this._doSaveStateThrottled();
  6714.                 },
  6715.  
  6716.                 loadState () {
  6717.                         const time = (new Date()).getTime();
  6718.                         const saved = Campaign.attributes.bR20tool__anim_running ? MiscUtil.copy(Campaign.attributes.bR20tool__anim_running) : {};
  6719.                         const toLoad = {};
  6720.                         Object.entries(saved).forEach(([tokenId, savedTokenMeta]) => {
  6721.                                 // load real token
  6722.                                 const token = d20plus.ut.getTokenById(tokenId);
  6723.                                 if (!token) return console.log(`Token ${tokenId} not found!`);
  6724.                                 const tokenMeta = {};
  6725.                                 tokenMeta.token = token;
  6726.  
  6727.                                 const active = {};
  6728.                                 Object.entries(savedTokenMeta.active).forEach(([animUid, savedState]) => {
  6729.                                         const anim = d20plus.anim.animatorTool.getAnimation(animUid);
  6730.                                         if (!anim) return console.log(`Animation ${animUid} not found!`);
  6731.  
  6732.                                         active[animUid] = {
  6733.                                                 queue: savedState.queue.map(it => d20plus.anim.deserialize(it)),
  6734.                                                 start: time - savedState.lastAlpha,
  6735.                                                 lastTick: time
  6736.                                         }
  6737.                                 });
  6738.  
  6739.                                 tokenMeta.active = active;
  6740.  
  6741.                                 toLoad[tokenId] = tokenMeta;
  6742.                         });
  6743.  
  6744.                         this._tracker = toLoad;
  6745.                 },
  6746.  
  6747.                 _hasAnyActive () {
  6748.                         return hasAnyKey(this._tracker);
  6749.                 },
  6750.  
  6751.                 _doTick () {
  6752.                         // higher tick rate = slower
  6753.                         if (++this.__tickCount >= this._restTicks) {
  6754.                                 this.__tickCount = 0;
  6755.                                 let anyGlobalModifications = false;
  6756.  
  6757.                                 const time = (new Date()).getTime();
  6758.  
  6759.                                 for (const tokenId in this._tracker) {
  6760.                                         if (!this._tracker.hasOwnProperty(tokenId)) continue;
  6761.                                         const tokenMeta = this._tracker[tokenId];
  6762.  
  6763.                                         let anyModification = false;
  6764.                                         for (const animUid in tokenMeta.active) {
  6765.                                                 if (!tokenMeta.active.hasOwnProperty(animUid)) continue;
  6766.                                                 const instance = tokenMeta.active[animUid];
  6767.  
  6768.                                                 const alpha = time - instance.start;
  6769.                                                 const delta = time - instance.lastTick;
  6770.  
  6771.                                                 // avoid using fast-loop length optimization, as we'll splice out completed animations
  6772.                                                 for (let i = 0; i < instance.queue.length; ++i) {
  6773.                                                         anyModification = instance.queue[i].animate(
  6774.                                                                 tokenMeta.token,
  6775.                                                                 alpha,
  6776.                                                                 delta,
  6777.                                                                 instance.queue
  6778.                                                         ) || anyModification;
  6779.  
  6780.                                                         if (instance.queue[i].hasRun()) {
  6781.                                                                 instance.queue.splice(i, 1);
  6782.                                                                 --i;
  6783.                                                         }
  6784.                                                 }
  6785.  
  6786.                                                 // queue empty -> this animation is no longer active
  6787.                                                 if (!instance.queue.length) delete tokenMeta.active[animUid];
  6788.                                                 else {
  6789.                                                         instance.lastTick = time;
  6790.                                                         instance.lastAlpha = alpha;
  6791.                                                 }
  6792.                                         }
  6793.  
  6794.                                         // no active animations -> stop tracking this token
  6795.                                         if (!hasAnyKey(tokenMeta.active)) delete this._tracker[tokenId];
  6796.  
  6797.                                         // save after applying animations
  6798.                                         if (anyModification) tokenMeta.token.save();
  6799.                                         anyGlobalModifications = anyGlobalModifications || anyModification;
  6800.                                 }
  6801.  
  6802.                                 this.saveState();
  6803.                                 if (anyGlobalModifications) d20.engine.canvas.renderAll();
  6804.                         }
  6805.  
  6806.                         requestAnimationFrame(this.doTick.bind(this))
  6807.                 },
  6808.  
  6809.                 init () {
  6810.                         this._doSaveStateThrottled = _.throttle(this._saveState, 100);
  6811.                         setTimeout(() => {
  6812.                                 this.loadState();
  6813.                                 this._lastTickActive = true;
  6814.                                 this.doTick();
  6815.                         }, 5000);
  6816.                 },
  6817.         };
  6818.  
  6819.         // all properties that can be set via the 'prop' command
  6820.         d20plus.anim._PROP_TOKEN = [
  6821.                 "left",
  6822.                 "top",
  6823.                 "width",
  6824.                 "height",
  6825.                 "z_index",
  6826.                 "imgsrc",
  6827.                 "rotation",
  6828.                 "type",
  6829.                 "layer",
  6830.                 "locked",
  6831.                 "flipv",
  6832.                 "fliph",
  6833.                 "anim_loop",
  6834.                 "anim_paused_at",
  6835.                 "anim_autoplay",
  6836.                 "name",
  6837.                 "gmnotes", // `escape`d HTML
  6838.                 "controlledby",
  6839.                 "represents",
  6840.                 "bar1_value",
  6841.                 "bar1_max",
  6842.                 "bar1_link",
  6843.                 "bar2_value",
  6844.                 "bar2_max",
  6845.                 "bar2_link",
  6846.                 "bar3_value",
  6847.                 "bar3_max",
  6848.                 "bar3_link",
  6849.                 "aura1_radius",
  6850.                 "aura1_color",
  6851.                 "aura1_square",
  6852.                 "aura2_radius",
  6853.                 "aura2_color",
  6854.                 "aura2_square",
  6855.                 "tint_color",
  6856.                 "status_dead",
  6857.                 "statusmarkers",
  6858.                 "showname",
  6859.                 "showplayers_name",
  6860.                 "showplayers_bar1",
  6861.                 "showplayers_bar2",
  6862.                 "showplayers_bar3",
  6863.                 "showplayers_aura1",
  6864.                 "showplayers_aura2",
  6865.                 "playersedit_name",
  6866.                 "playersedit_bar1",
  6867.                 "playersedit_bar2",
  6868.                 "playersedit_bar3",
  6869.                 "playersedit_aura1",
  6870.                 "playersedit_aura2",
  6871.                 "light_radius",
  6872.                 "light_dimradius",
  6873.                 "light_otherplayers",
  6874.                 "light_hassight",
  6875.                 "light_angle",
  6876.                 "light_losangle",
  6877.                 "light_multiplier",
  6878.                 "adv_fow_view_distance",
  6879.                 "groupwith",
  6880.                 "sides", // pipe-separated list of `escape`d image URLs
  6881.                 "currentSide"
  6882.         ];
  6883.         d20plus.anim.VALID_PROP_TOKEN = new Set(d20plus.anim._PROP_TOKEN);
  6884.  
  6885.         d20plus.anim.VALID_LAYER = new Set(d20plus.ut.LAYERS);
  6886.  
  6887.         d20plus.anim.COMMAND_TO_SHORT = {
  6888.                 "Move": "mv",
  6889.                 "MoveExact": "mvx",
  6890.                 "Rotate": "rot",
  6891.                 "RotateExact": "rotx",
  6892.                 "Copy": "cp",
  6893.                 "Flip": "flip",
  6894.                 "FlipExact": "flipx",
  6895.                 "Scale": "scale",
  6896.                 "ScaleExact": "scalex",
  6897.                 "Layer": "layer",
  6898.                 "Lighting": "light",
  6899.                 "LightingExact": "lightx",
  6900.                 "SetProperty": "prop",
  6901.                 "SumProperty": "propSum",
  6902.                 "TriggerMacro": "macro",
  6903.                 "TriggerAnimation": "anim",
  6904.         };
  6905.  
  6906.         d20plus.anim.SHORT_TO_DEFAULT_ARGS = {
  6907.                 "mv": "0 0 - - -",
  6908.                 "mvx": "0 0 - - -",
  6909.                 "rot": "0 0 -",
  6910.                 "rotx": "0 0 -",
  6911.                 "cp": "0",
  6912.                 "flip": "0 - -",
  6913.                 "flipx": "0 - -",
  6914.                 "scale": "0 0 - -",
  6915.                 "scalex": "0 0 - -",
  6916.                 "layer": "0 -",
  6917.                 "light": "0 0 - - -",
  6918.                 "lightx": "0 0 - - -",
  6919.                 "prop": "0 -",
  6920.                 "propSum": "0 -",
  6921.                 "macro": "0 -",
  6922.                 "anim": "0 -",
  6923.         };
  6924. }
  6925.  
  6926. SCRIPT_EXTENSIONS.push(baseToolAnimator);
  6927.  
  6928.  
  6929. function d20plusArt () {
  6930.         d20plus.art = {
  6931.                 button: () => {
  6932.                         // add external art button was clicked
  6933.                         const $art = $("#d20plus-artfolder");
  6934.                         $art.dialog("open");
  6935.                         const $artList = $art.find(`.list`);
  6936.                         $artList.empty();
  6937.  
  6938.                         if (d20plus.art.custom) {
  6939.                                 d20plus.art.custom.forEach(a => {
  6940.                                         const $liArt = getArtLi(a.name, a.url);
  6941.                                         $artList.append($liArt);
  6942.                                 });
  6943.                         }
  6944.  
  6945.                         // init list library
  6946.                         const artList = new List("art-list-container", {
  6947.                                 valueNames: ["name"],
  6948.                                 listClass: "artlist"
  6949.                         });
  6950.  
  6951.                         const $btnAdd = $(`#art-list-add-btn`);
  6952.                         const $iptAddName = $(`#art-list-add-name`);
  6953.                         const $iptAddUrl = $(`#art-list-add-url`);
  6954.                         $btnAdd.off("click");
  6955.                         $btnAdd.on("click", () => {
  6956.                                 const name = $iptAddName.val().trim();
  6957.                                 const url = $iptAddUrl.val().trim();
  6958.                                 if (!name || !url) {
  6959.                                         alert("Missing required fields!")
  6960.                                 } else {
  6961.                                         artList.search();
  6962.                                         artList.filter();
  6963.                                         const $liArt = getArtLi(name, url);
  6964.                                         $artList.append($liArt);
  6965.                                         refreshCustomArtList();
  6966.                                 }
  6967.                         });
  6968.  
  6969.                         const $btnMassAdd = $(`#art-list-multi-add-btn`);
  6970.                         $btnMassAdd.off("click");
  6971.                         $btnMassAdd.on("click", () => {
  6972.                                 $("#d20plus-artmassadd").dialog("open");
  6973.                                 const $btnMassAddSubmit = $(`#art-list-multi-add-btn-submit`);
  6974.                                 $btnMassAddSubmit.off("click");
  6975.                                 $btnMassAddSubmit.on("click", () => {
  6976.                                         artList.search();
  6977.                                         artList.filter();
  6978.                                         const $iptUrls = $(`#art-list-multi-add-area`);
  6979.                                         const massUrls = $iptUrls.val();
  6980.                                         const spl = massUrls.split("\n").map(s => s.trim()).filter(s => s);
  6981.                                         if (!spl.length) return;
  6982.                                         else {
  6983.                                                 const delim = "---";
  6984.                                                 const toAdd = [];
  6985.                                                 for (const s of spl) {
  6986.                                                         if (!s.includes(delim)) {
  6987.                                                                 alert(`Badly formatted line: ${s}`);
  6988.                                                                 return;
  6989.                                                         } else {
  6990.                                                                 const parts = s.split(delim);
  6991.                                                                 if (parts.length !== 2) {
  6992.                                                                         alert(`Badly formatted line: ${s}`);
  6993.                                                                         return;
  6994.                                                                 } else {
  6995.                                                                         toAdd.push({
  6996.                                                                                 name: parts[0],
  6997.                                                                                 url: parts[1]
  6998.                                                                         });
  6999.                                                                 }
  7000.                                                         }
  7001.                                                 }
  7002.                                                 toAdd.forEach(a => {
  7003.                                                         $artList.append(getArtLi(a.name, a.url));
  7004.                                                 });
  7005.                                                 refreshCustomArtList();
  7006.                                                 $("#d20plus-artmassadd").dialog("close");
  7007.                                         }
  7008.                                 });
  7009.                         });
  7010.  
  7011.                         const $btnDelAll = $(`#art-list-delete-all-btn`);
  7012.                         $btnDelAll.off("click").on("click", () => {
  7013.                                 $artList.empty();
  7014.                                 refreshCustomArtList();
  7015.                         });
  7016.  
  7017.                         makeDraggables();
  7018.                         d20plus.art.refreshList = refreshCustomArtList;
  7019.  
  7020.                         function getArtLi (name, url) {
  7021.                                 const showImage = d20plus.cfg.get("interface", "showCustomArtPreview");
  7022.                                 const $liArt = $(`
  7023.                                                 <li class="dd-item library-item draggableresult Vetools-draggable-art ui-draggable" data-fullsizeurl="${url}">
  7024.                                                         ${showImage ? `<img src="${url}" style="width: 30px; max-height: 30px; display: inline-block" draggable="false">` : ""}
  7025.                                                         <div class="dd-content name" style="display: inline-block; width: 35%;" data-url="${url}">${name}</div>
  7026.                                                         <a href="${url}"><span class="url" style="display: inline-block; width: ${showImage ? "40%" : "55%"};">${url}</span></a>
  7027.                                                 </li>
  7028.                                         `);
  7029.                                 if (!showImage) {
  7030.                                         $liArt.on("mousedown", () => {
  7031.                                                 const $loader = $(`<div class="temp-warning">Loading image - don't drop yet!</div>`);
  7032.                                                 const $img = $(`<img src="${url}" style="width: 30px; max-height: 30px; display: none">`);
  7033.                                                 if (!$img.prop("complete")) {
  7034.                                                         $(`body`).append($loader);
  7035.                                                         $img.on("load", () => {
  7036.                                                                 $loader.remove();
  7037.                                                         });
  7038.                                                         $loader.append($img);
  7039.                                                 }
  7040.                                         });
  7041.                                 }
  7042.  
  7043.                                 const $btnDel = $(`<span class="delete btn btn-danger"><span class="pictos">#</span></span>`).on("click", () => {
  7044.                                         $liArt.remove();
  7045.                                         refreshCustomArtList();
  7046.                                 });
  7047.                                 $liArt.append($btnDel);
  7048.                                 return $liArt;
  7049.                         }
  7050.  
  7051.                         function refreshCustomArtList () {
  7052.                                 artList.reIndex();
  7053.                                 const custom = [];
  7054.                                 artList.items.forEach(i => {
  7055.                                         const $ele = $(i.elm);
  7056.                                         custom.push({
  7057.                                                 name: $ele.find(`.name`).text(),
  7058.                                                 url: $ele.find(`.url`).text()
  7059.                                         });
  7060.                                 });
  7061.                                 d20plus.art.custom = custom;
  7062.                                 makeDraggables();
  7063.                                 d20plus.art.saveToHandout();
  7064.                         }
  7065.  
  7066.                         function makeDraggables () {
  7067.                                 $(`.Vetools-draggable-art`).draggable({
  7068.                                         handle: ".dd-content",
  7069.                                         revert: true,
  7070.                                         revertDuration: 0,
  7071.                                         helper: "clone",
  7072.                                         appendTo: "body"
  7073.                                 })
  7074.                         }
  7075.                 },
  7076.  
  7077.                 saveToHandout () {
  7078.                         const handout = d20plus.art.getArtHandout();
  7079.                         if (!handout) {
  7080.                                 d20.Campaign.handouts.create({
  7081.                                         name: ART_HANDOUT,
  7082.                                         archived: true
  7083.                                 }, {
  7084.                                         success: function (handout) {
  7085.                                                 notecontents = "This handout is used to store custom art URLs.";
  7086.  
  7087.                                                 const gmnotes = JSON.stringify(d20plus.art.custom);
  7088.                                                 handout.updateBlobs({notes: notecontents, gmnotes: gmnotes});
  7089.                                                 handout.save({notes: (new Date).getTime(), inplayerjournals: ""});
  7090.                                         }
  7091.                                 });
  7092.                         } else {
  7093.                                 const gmnotes = JSON.stringify(d20plus.art.custom);
  7094.                                 handout.updateBlobs({gmnotes: gmnotes});
  7095.                                 handout.save({notes: (new Date).getTime()});
  7096.                         }
  7097.                 },
  7098.  
  7099.                 /**
  7100.                  * @param items Array of Objects with "name" and "url" properties.
  7101.                  * @private
  7102.                  */
  7103.                 addToHandout (items) {
  7104.                         const invalid = items.find(it => !it.name || !it.url);
  7105.                         if (invalid) throw new Error(`Invalid item ${JSON.stringify(invalid)} did not contain required name and URL properties!`);
  7106.                         d20plus.art.custom = (d20plus.art.custom || []).concat(items);
  7107.                         d20plus.art.saveToHandout();
  7108.                 },
  7109.  
  7110.                 // TODO load a decent default art library from somewhere
  7111.                 default: [
  7112.                         {
  7113.                                 name: "Phoenix",
  7114.                                 url: "http://www.discgolfbirmingham.com/wordpress/wp-content/uploads/2014/04/phoenix-rising.jpg"
  7115.                         }
  7116.                 ]
  7117.         };
  7118.  
  7119.         d20plus.art.getArtHandout = () => {
  7120.                 return d20.Campaign.handouts.models.find((handout) => {
  7121.                         return handout.attributes.name === ART_HANDOUT;
  7122.                 });
  7123.         };
  7124.  
  7125.         d20plus.art.pLoadArt = async () => {
  7126.                 d20plus.ut.log("Loading custom art");
  7127.                 const handout = d20plus.art.getArtHandout();
  7128.                 if (handout) {
  7129.                         handout.view.render();
  7130.                         return new Promise(resolve => {
  7131.                                 handout._getLatestBlob("gmnotes", function (gmnotes) {
  7132.                                         const decoded = decodeURIComponent(gmnotes);
  7133.                                         try {
  7134.                                                 d20plus.art.custom = JSON.parse(decoded);
  7135.                                                 resolve();
  7136.                                         } catch (e) {
  7137.                                                 console.error(e);
  7138.                                                 resolve();
  7139.                                         }
  7140.                                 });
  7141.                         });
  7142.                 }
  7143.         };
  7144.  
  7145.         d20plus.art.addCustomArtSearch = () => {
  7146.                 d20plus.ut.log("Add custom art search");
  7147.                 const $afterTo = $(`#libraryresults`);
  7148.                 $afterTo.after(d20plus.artListHTML);
  7149.  
  7150.                 const $olNone = $(`#image-search-none`);
  7151.                 const $olHasResults = $(`#image-search-has-results`);
  7152.  
  7153.                 const $olArt = $(`#custom-art-results`);
  7154.                 const $srchImages = $(`#imagedialog .searchbox input.keywords`);
  7155.                 $srchImages.on("keyup", () => {
  7156.                         $olArt.empty();
  7157.                         const searched = $srchImages.val().trim().toLowerCase();
  7158.                         if (searched.length < 2) {
  7159.                                 $olNone.show();
  7160.                                 $olHasResults.hide();
  7161.                                 return;
  7162.                         }
  7163.  
  7164.                         let toShow = d20plus.art.default.filter(a => a.name.toLowerCase().includes(searched));
  7165.                         if (d20plus.art.custom) toShow = toShow.concat(d20plus.art.custom.filter(a => a.name.toLowerCase().includes(searched)));
  7166.  
  7167.                         if (!toShow.length) {
  7168.                                 $olNone.show();
  7169.                                 $olHasResults.hide();
  7170.                         } else {
  7171.                                 $olNone.hide();
  7172.                                 $olHasResults.show();
  7173.  
  7174.                                 toShow.forEach(a => {
  7175.                                         $olArt.append(`
  7176.                                                 <li class="dd-item library-item draggableresult Vetoolsresult ui-draggable" data-fullsizeurl="${a.url}">
  7177.                                                         <div class="dd-content">
  7178.                                                                 <div class="token"><img src="${a.url}" draggable="false"></div>
  7179.                                                                 <div class="name">
  7180.                                                                         <div class="namecontainer"><a href="${a.url}" rel="external">${a.name}</a></div>
  7181.                                                                 </div>
  7182.                                                         </div>
  7183.                                                 </li>
  7184.                                         `);
  7185.                                 });
  7186.                         }
  7187.  
  7188.                         $("#imagedialog #Vetoolsresults .draggableresult").draggable({
  7189.                                 handle: ".dd-content",
  7190.                                 revert: true,
  7191.                                 revertDuration: 0,
  7192.                                 helper: "clone",
  7193.                                 appendTo: "body"
  7194.                         }).addTouch();
  7195.                 });
  7196.         };
  7197.  
  7198.         d20plus.art.initArtFromUrlButtons = () => {
  7199.                 d20plus.ut.log("Add direct URL art buttons");
  7200.                 // requires templates to be swapped, which happens ASAP during Init
  7201.  
  7202.                 $(`.character-image-by-url`).live("click", function () {
  7203.                         const cId = $(this).closest(`[data-characterid]`).attr(`data-characterid`);
  7204.                         const url = window.prompt("Enter a URL", d20plus.art.getLastImageUrl());
  7205.                         if (url) {
  7206.                                 d20plus.art.setLastImageUrl(url);
  7207.                                 d20.Campaign.characters.get(cId).set("avatar", url);
  7208.                         }
  7209.                 });
  7210.  
  7211.                 $(`.handout-image-by-url`).live("click", function () {
  7212.                         const hId = $(this).closest(`[data-handoutid]`).attr(`data-handoutid`);
  7213.                         const url = window.prompt("Enter a URL", d20plus.art.getLastImageUrl());
  7214.                         if (url) {
  7215.                                 d20plus.art.setLastImageUrl(url);
  7216.                                 d20.Campaign.handouts.get(hId).set("avatar", url);
  7217.                         }
  7218.                 });
  7219.  
  7220.                 $(`.token-image-by-url`).live("click", function () {
  7221.                         const cId = $(this).closest(`[data-characterid]`).attr(`data-characterid`);
  7222.                         const url = window.prompt("Enter a URL", d20plus.art.getLastImageUrl());
  7223.                         if (url) {
  7224.                                 d20plus.art.setLastImageUrl(url);
  7225.                                 const char = d20.Campaign.characters.get(cId);
  7226.                                 char._getLatestBlob("defaulttoken", (blob) => {
  7227.                                         blob = blob && blob.trim() ? JSON.parse(blob) : {};
  7228.                                         blob.imgsrc = url;
  7229.                                         char.updateBlobs({defaulttoken: JSON.stringify(blob)});
  7230.                                 });
  7231.                         }
  7232.                 });
  7233.  
  7234.                 $(`.deck-image-by-url`).live("click", function () {
  7235.                         const dId = $(this).attr("data-deck-id");
  7236.                         const url = window.prompt("Enter a URL", d20plus.art.getLastImageUrl());
  7237.                         if (url) {
  7238.                                 d20plus.art.setLastImageUrl(url);
  7239.                                 d20.Campaign.decks.get(dId).set("avatar", url)
  7240.                         }
  7241.                 });
  7242.  
  7243.                 $(`.card-image-by-url`).live("click", function () {
  7244.                         const cId = $(this).attr("data-card-id");
  7245.                         const url = window.prompt("Enter a URL", d20plus.art.getLastImageUrl());
  7246.                         if (url) {
  7247.                                 d20plus.art.setLastImageUrl(url);
  7248.                                 const card = d20.Campaign.decks.find(it => it.cards.find(c => c.id === cId)).cards.find(c => c.id === cId);
  7249.                                 card.set("avatar", url);
  7250.                         }
  7251.                 });
  7252.  
  7253.                 $(`.deck-mass-cards-by-url`).live("click", function () {
  7254.                         const dId = $(this).attr("data-deck-id");
  7255.  
  7256.                         const deck = d20.Campaign.decks.get(dId);
  7257.  
  7258.                         const cleanTemplate = d20plus.addArtMassAdderHTML.replace(/id="[^"]+"/gi, "");
  7259.                         const $dialog = $(cleanTemplate).appendTo($("body"));
  7260.                         const $iptTxt = $dialog.find(`textarea`);
  7261.                         const $btnAdd = $dialog.find(`button`).click(() => {
  7262.                                 const lines = ($iptTxt.val() || "").split("\n");
  7263.                                 const toSaveAll = [];
  7264.                                 lines.filter(it => it && it.trim()).forEach(l => {
  7265.                                         const split = l.split("---").map(it => it.trim()).filter(Boolean);
  7266.                                         if (split.length >= 2) {
  7267.                                                 const [name, url] = split;
  7268.                                                 const toSave = deck.cards.push({
  7269.                                                         avatar: url,
  7270.                                                         id: d20plus.ut.generateRowId(),
  7271.                                                         name,
  7272.                                                         placement: 99
  7273.                                                 });
  7274.                                                 toSaveAll.push(toSave);
  7275.                                         }
  7276.                                 });
  7277.                                 $dialog.dialog("close");
  7278.  
  7279.                                 toSaveAll.forEach(s => s.save());
  7280.                                 deck.save();
  7281.                         });
  7282.  
  7283.                         $dialog.dialog({
  7284.                                 width: 800,
  7285.                                 height: 650
  7286.                         });
  7287.                 });
  7288.         };
  7289.  
  7290.         d20plus.art._lastImageUrl = "https://example.com/pic.png";
  7291.         d20plus.art.getLastImageUrl = () => {
  7292.                 return d20plus.art._lastImageUrl;
  7293.         };
  7294.         d20plus.art.setLastImageUrl = (url) => {
  7295.                 d20plus.art._lastImageUrl = url || d20plus.art._lastImageUrl;
  7296.         };
  7297. }
  7298.  
  7299. SCRIPT_EXTENSIONS.push(d20plusArt);
  7300.  
  7301.  
  7302. function d20plusArtBrowser () {
  7303.         d20plus.artBrowse = {};
  7304.  
  7305.         // ART IMPORTER 2.0
  7306.         d20plus.artBrowse.initRepoBrowser = () => {
  7307.                 const TIME = (new Date()).getTime();
  7308.                 const STATES = ["0", "1", "2"]; // off, blue, red
  7309.  
  7310.                 function pGetJson (url) { // avoid using the main site method's caching
  7311.                         return new Promise(resolve => $.getJSON(url, data => resolve(data)));
  7312.                 }
  7313.  
  7314.                 const $win = $(`<div title="Art Repository" class="artr__win"/>`)
  7315.                         .appendTo($(`body`))
  7316.                         .dialog({
  7317.                                 autoOpen: false,
  7318.                                 resizable: true,
  7319.                                 width: 1,
  7320.                                 height: 1
  7321.                         })
  7322.                         // bind droppable, so that elements dropped back onto the browser don't get caught by the canvas behind
  7323.                         .droppable({
  7324.                                 accept: ".draggableresult",
  7325.                                 tolerance: "pointer",
  7326.                                 drop: (event, ui) => {
  7327.                                         event.preventDefault();
  7328.                                         event.stopPropagation();
  7329.                                         event.originalEvent.dropHandled = true;
  7330.                                         d20plus.ut.log(`Dropped back onto art browser!`);
  7331.                                 }
  7332.                         });
  7333.  
  7334.                 async function doInit () {
  7335.                         const $sidebar = $(`<div class="artr__side"/>`).appendTo($win);
  7336.                         const $mainPane = $(`<div class="artr__main"/>`).appendTo($win);
  7337.                         const $loadings = [
  7338.                                 $(`<div class="artr__side__loading" title="Caching repository data, this may take some time">Loading...</div>`).appendTo($sidebar),
  7339.                                 $(`<div class="artr__main__loading" title="Caching repository data, this may take some time">Loading...</div>`).appendTo($mainPane)
  7340.                         ];
  7341.  
  7342.                         const start = (new Date()).getTime();
  7343.                         const GH_PATH = `https://raw.githubusercontent.com/DMsGuild201/Roll20_resources/master/ExternalArt/dist/`;
  7344.                         const [enums, index] = await Promise.all([pGetJson(`${GH_PATH}_meta_enums.json`), pGetJson(`${GH_PATH}_meta_index.json`)]);
  7345.                         d20plus.ut.log(`Loaded metadata in ${((new Date()).getTime() - start) / 1000} secs.`);
  7346.  
  7347.                         Object.keys(index).forEach(k => index[k]._key = k);
  7348.  
  7349.                         let filters = {};
  7350.                         let search = "";
  7351.                         let currentItem = null;
  7352.                         let currentIndexKey = null;
  7353.  
  7354.                         function _searchFeatures (item, doLowercase) {
  7355.                                 // features are lowercase in index
  7356.                                 return !!(item.features || []).find(x => (doLowercase ? x.toLowerCase() : x).includes(search));
  7357.                         }
  7358.  
  7359.                         function _filterProps (item) {
  7360.                                 if (Object.keys(filters).length) {
  7361.                                         const missingOrUnwanted = Object.keys(filters).find(prop => {
  7362.                                                 if (!item[prop]) return true;
  7363.                                                 const requiredVals = Object.keys(filters[prop]).filter(k => filters[prop][k]);
  7364.                                                 const missingEnum = !!requiredVals.find(x => !item[prop].includes(x));
  7365.                                                 const excludedVals = Object.keys(filters[prop]).filter(k => !filters[prop][k]);
  7366.                                                 const unwantedEnum = !!excludedVals.find(x => item[prop].includes(x));
  7367.                                                 return missingEnum || unwantedEnum;
  7368.                                         });
  7369.                                         if (missingOrUnwanted) return false;
  7370.                                 }
  7371.                                 return true;
  7372.                         }
  7373.  
  7374.                         function applyFilterAndSearchToIndex () {
  7375.                                 search = search.toLowerCase();
  7376.  
  7377.                                 // require the user to search or apply a filter before displaying any results
  7378.                                 if (Object.keys(filters).length === 0 && search.length < 2) return [];
  7379.  
  7380.                                 return Object.values(index).filter(it => {
  7381.                                         if (search) {
  7382.                                                 const searchVisible = it._set.toLowerCase().includes(search)
  7383.                                                         || it._artist.toLowerCase().includes(search)
  7384.                                                         || _searchFeatures(it);
  7385.                                                 if (!searchVisible) return false;
  7386.                                         }
  7387.                                         return _filterProps(it, 1);
  7388.                                 });
  7389.                         }
  7390.  
  7391.                         function applyFilterAndSearchToItem () {
  7392.                                 const cpy = MiscUtil.copy(currentItem);
  7393.                                 const filterItem = $cbMirrorFilters.prop("checked");
  7394.                                 cpy.data = cpy.data.filter(it => {
  7395.                                         if (search) if (!_searchFeatures(it, true)) return false;
  7396.                                         if (filterItem) return _filterProps(it);
  7397.                                         return true;
  7398.                                 });
  7399.                                 return cpy;
  7400.                         }
  7401.  
  7402.                         $loadings.forEach($l => $l.remove());
  7403.  
  7404.                         // SIDEBAR /////////////////////////////////////////////////////////////////////////////////////////
  7405.                         const $sideHead = $(`<div class="p-2 artr__side__head"><div class="artr__side__head__title">Filters</div></div>`).appendTo($sidebar);
  7406.                         // This functionality is contained in the filter buttons, but might need to be done here to improve performance in the future
  7407.                         // $(`<button class="btn">Apply</button>`).click(() => {
  7408.                         //      if (currentItem) doRenderItem(applyFilterAndSearchToItem());
  7409.                         //      else doRenderIndex(applyFilterAndSearchToIndex())
  7410.                         // }).appendTo($sideHead);
  7411.                         const $lbMirrorFilters = $(`<label class="split" title="Apply filters to results inside folders (as well as the index)"><span>Filter within folders</span></label>`).appendTo($sideHead);
  7412.                         const $cbMirrorFilters = $(`<input type="checkbox" checked>`).appendTo($lbMirrorFilters).change(() => {
  7413.                                 if (currentItem) {
  7414.                                         doRenderItem(applyFilterAndSearchToItem());
  7415.                                 }
  7416.                         });
  7417.  
  7418.                         const $sideBody = $(`<div class="artr__side__body"/>`).appendTo($sidebar);
  7419.                         const addSidebarSection = (prop, ix) => {
  7420.                                 const fullName = (() => {
  7421.                                         switch (prop) {
  7422.                                                 case "imageType": return "Image Type";
  7423.                                                 case "grid": return "Grid Type";
  7424.                                                 case "monster": return "Monster Type";
  7425.                                                 case "audience": return "Intended Audience";
  7426.                                                 default:
  7427.                                                         return prop.uppercaseFirst();
  7428.                                         }
  7429.                                 })();
  7430.  
  7431.                                 const $tagHead = $(`<div class="artr__side__tag_header"><div>${fullName}</div><div>[\u2013]</div></div>`).appendTo($sideBody).click(() => {
  7432.                                         $tagGrid.toggle();
  7433.                                         $tagHead.html($tagHead.html().replace(/\[.]/, (...m) => m[0] === "[+]" ? "[\u2013]" : "[+]"));
  7434.                                 });
  7435.  
  7436.                                 const $tagGrid = $(`<div class="artr__side__tag_grid"/>`).appendTo($sideBody);
  7437.                                 const getNextState = (state, dir) => {
  7438.                                         const ix = STATES.indexOf(state) + dir;
  7439.                                         if (ix > STATES.length - 1) return STATES[0];
  7440.                                         if (ix < 0) return STATES.last();
  7441.                                         return STATES[ix];
  7442.                                 };
  7443.  
  7444.                                 if (ix) $tagHead.click(); // hide by default
  7445.  
  7446.                                 enums[prop].sort((a, b) => SortUtil.ascSort(b.c, a.c)).forEach(enm => {
  7447.                                         const cycleState = dir => {
  7448.                                                 const nxtState = getNextState($btn.attr("data-state"), dir);
  7449.                                                 $btn.attr("data-state", nxtState);
  7450.  
  7451.                                                 if (nxtState === "0") {
  7452.                                                         delete filters[prop][enm.v];
  7453.                                                         if (!Object.keys(filters[prop]).length) delete filters[prop];
  7454.                                                 } else (filters[prop] = filters[prop] || {})[enm.v] = nxtState === "1";
  7455.  
  7456.                                                 if (currentItem) doRenderItem(applyFilterAndSearchToItem());
  7457.                                                 else doRenderIndex(applyFilterAndSearchToIndex());
  7458.                                         };
  7459.  
  7460.                                         const $btn = $(`<button class="btn artr__side__tag" data-state="0">${enm.v} (${enm.c})</button>`)
  7461.                                                 .click(() => cycleState(1))
  7462.                                                 .contextmenu((evt) => {
  7463.                                                         if (!evt.ctrlKey) {
  7464.                                                                 evt.preventDefault();
  7465.                                                                 cycleState(-1);
  7466.                                                         }
  7467.                                                 })
  7468.                                                 .appendTo($tagGrid);
  7469.                                 });
  7470.                         };
  7471.                         Object.keys(enums).forEach((k, i) => addSidebarSection(k, i));
  7472.  
  7473.                         // MAIN PAGE ///////////////////////////////////////////////////////////////////////////////////////
  7474.                         const $mainHead = $(`<div class="p-2 artr__search"/>`).appendTo($mainPane);
  7475.  
  7476.                         const $wrpBread = $(`<div class="artr__bread"/>`).appendTo($mainHead);
  7477.                         const updateCrumbs = () => {
  7478.                                 $wrpBread.empty();
  7479.                                 const $txtIndex = $(`<span class="artr__crumb btn">Index</span>`)
  7480.                                         .appendTo($wrpBread)
  7481.                                         .click(() => doRenderIndex(applyFilterAndSearchToIndex()));
  7482.  
  7483.                                 if (currentItem) {
  7484.                                         const $txtSlash = $(`<span class="artr__crumb artr__crumb--sep">/</span>`).appendTo($wrpBread);
  7485.                                         const $txtItem = $(`<span class="artr__crumb btn">${currentItem.set} \u2013 ${currentItem.artist}</span>`)
  7486.                                                 .appendTo($wrpBread)
  7487.                                                 .click(() => {
  7488.                                                         $iptSearch.val("");
  7489.                                                         search = "";
  7490.                                                         doRenderItem(applyFilterAndSearchToItem(), true);
  7491.                                                 });
  7492.                                 }
  7493.                         };
  7494.                         updateCrumbs();
  7495.  
  7496.                         let searchTimeout;
  7497.                         const doSearch = () => {
  7498.                                 search = ($iptSearch.val() || "").trim();
  7499.                                 if (currentItem) doRenderItem(applyFilterAndSearchToItem());
  7500.                                 else doRenderIndex(applyFilterAndSearchToIndex())
  7501.                         };
  7502.                         const $iptSearch = $(`<input placeholder="Search..." class="artr__search__field">`).on("keydown", (e) => {
  7503.                                 clearTimeout(searchTimeout);
  7504.                                 if (e.which === 13) {
  7505.                                         doSearch();
  7506.                                 } else {
  7507.                                         searchTimeout = setTimeout(() => {
  7508.                                                 doSearch();
  7509.                                         }, 100);
  7510.                                 }
  7511.                         }).appendTo($mainHead);
  7512.  
  7513.                         const $mainBody = $(`<div class="artr__view"/>`).appendTo($mainPane);
  7514.                         const $mainBodyInner = $(`<div class="artr__view_inner"/>`).appendTo($mainBody);
  7515.  
  7516.                         const $itemBody = $(`<div class="artr__view"/>`).hide().appendTo($mainPane);
  7517.                         const $itemBodyInner = $(`<div class="artr__view_inner"/>`).appendTo($itemBody);
  7518.  
  7519.                         function doRenderIndex (indexSlice) {
  7520.                                 currentItem = false;
  7521.                                 currentIndexKey = false;
  7522.                                 $mainBody.show();
  7523.                                 $itemBody.hide();
  7524.                                 $mainBodyInner.empty();
  7525.                                 updateCrumbs();
  7526.  
  7527.                                 if (!indexSlice.length) {
  7528.                                         $(`<div class="artr__no_results_wrp"><div class="artr__no_results"><div class="text-center"><span class="artr__no_results_headline">No results found</span><br>Please adjust the filters (on the left) or refine your search (above).</div></div></div>`).appendTo($mainBodyInner)
  7529.                                 } else {
  7530.                                         indexSlice.forEach(it => {
  7531.                                                 const $item = $(`<div class="artr__item artr__item--index"/>`).appendTo($mainBodyInner).click(() => doLoadAndRenderItem(it));
  7532.  
  7533.                                                 const $itemTop = $(`
  7534.                                                         <div class="artr__item__top artr__item__top--quart">
  7535.                                                                 ${[...new Array(4)].map((_, i) => `<div class="atr__item__quart">${it._sample[i] ? `<img class="artr__item__thumbnail" src="${GH_PATH}${it._key}--thumb-${it._sample[i]}.jpg">` : ""}</div>`).join("")}                                                        
  7536.                                                         </div>
  7537.                                                 `).appendTo($item);
  7538.  
  7539.                                                 const $itemStats = $(`<div class="artr__item__stats"/>`).appendTo($itemTop);
  7540.                                                 const $statsImages = $(`<div class="artr__item__stats_item help--subtle" title="Number of images">×${it._size.toLocaleString()}</div>`).appendTo($itemStats);
  7541.  
  7542.                                                 const $itemMenu = $(`<div class="artr__item__menu"/>`).appendTo($itemTop);
  7543.                                                 const $btnExternalArt = $(`<div class="artr__item__menu_item pictos btn" title="Add to External Art list (${it._size} image${it._size === 1 ? "" : "s"})">P</div>`)
  7544.                                                         .appendTo($itemMenu)
  7545.                                                         .click(async (evt) => {
  7546.                                                                 evt.stopPropagation();
  7547.                                                                 const file = await pGetJson(`${GH_PATH}${it._key}.json`);
  7548.                                                                 const toAdd = file.data.map((it, i) => ({
  7549.                                                                         name: `${file.set} \u2014 ${file.artist} (${i})`,
  7550.                                                                         url: it.uri
  7551.                                                                 }));
  7552.                                                                 d20plus.art.addToHandout(toAdd);
  7553.                                                                 alert(`Added ${file.data.length} image${file.data.length === 1 ? "" : "s"} to the External Art list.`);
  7554.                                                         });
  7555.                                                 const $btnDownload = $(`<div class="artr__item__menu_item pictos btn" title="Download ZIP (SHIFT to download a text file of URLs)">}</div>`)
  7556.                                                         .appendTo($itemMenu)
  7557.                                                         .click(async (evt) => {
  7558.                                                                 evt.stopPropagation();
  7559.                                                                 const file = await pGetJson(`${GH_PATH}${it._key}.json`);
  7560.                                                                 if (evt.shiftKey) {
  7561.                                                                         d20plus.artBrowse._downloadUrls(file);
  7562.                                                                 } else {
  7563.                                                                         d20plus.artBrowse._downloadZip(file);
  7564.                                                                 }
  7565.                                                         });
  7566.  
  7567.                                                 const $itemBottom = $(`
  7568.                                                         <div class="artr__item__bottom">
  7569.                                                                 <div class="artr__item__bottom__row" style="padding-bottom: 2px;" title="${it._set}">${it._set}</div>
  7570.                                                                 <div class="artr__item__bottom__row" style="padding-top: 2px;" title="${it._artist}"><i>By</i> ${it._artist}</div>
  7571.                                                         </div>
  7572.                                                 `).appendTo($item);
  7573.                                         });
  7574.                                 }
  7575.                         }
  7576.  
  7577.                         function doLoadAndRenderItem (indexItem) {
  7578.                                 pGetJson(`${GH_PATH}${indexItem._key}.json`).then(file => {
  7579.                                         currentItem = file;
  7580.                                         currentIndexKey = indexItem._key;
  7581.                                         doRenderItem(applyFilterAndSearchToItem(), true);
  7582.                                 });
  7583.                         }
  7584.  
  7585.                         function doRenderItem (file, resetScroll) {
  7586.                                 $mainBody.hide();
  7587.                                 $itemBody.show();
  7588.                                 $itemBodyInner.empty();
  7589.                                 updateCrumbs();
  7590.                                 if (resetScroll) $itemBodyInner.scrollTop(0);
  7591.                                 const $itmUp = $(`<div class="artr__item artr__item--item artr__item--back"><div class="pictos">[</div></div>`)
  7592.                                         .click(() => doRenderIndex(applyFilterAndSearchToIndex()))
  7593.                                         .appendTo($itemBodyInner);
  7594.  
  7595.                                 file.data.sort((a, b) => SortUtil.ascSort(a.hash, b.hash)).forEach((it, i) => {
  7596.                                         // "library-item" and "draggableresult" classes required for drag/drop
  7597.                                         const $item = $(`<div class="artr__item artr__item--item library-item draggableresult" data-fullsizeurl="${it.uri}"/>`)
  7598.                                                 .appendTo($itemBodyInner)
  7599.                                                 .click(() => {
  7600.                                                         const $wrpBigImg = $(`<div class="artr__wrp_big_img"><img class="artr__big_img" src="${it.uri}"></div>`)
  7601.                                                                 .click(() => $wrpBigImg.remove()).appendTo($(`body`));
  7602.                                                 });
  7603.                                         const $wrpImg = $(`<div class="artr__item__full"/>`).appendTo($item);
  7604.                                         const $img = $(`<img class="artr__item__thumbnail" src="${GH_PATH}${currentIndexKey}--thumb-${it.hash}.jpg">`).appendTo($wrpImg);
  7605.  
  7606.                                         const $itemMenu = $(`<div class="artr__item__menu"/>`).appendTo($item);
  7607.                                         const $btnExternalArt = $(`<div class="artr__item__menu_item pictos" title="Add to External Art list">P</div>`)
  7608.                                                 .appendTo($itemMenu)
  7609.                                                 .click((evt) => {
  7610.                                                         evt.stopPropagation();
  7611.                                                         d20plus.art.addToHandout([{name: `${file.set} \u2014 ${file.artist} (${i})`, url: it.uri}]);
  7612.                                                         alert(`Added image to the External Art list.`);
  7613.                                                 });
  7614.                                         const $btnDownload = $(`<div class="artr__item__menu_item pictos" title="Download">}</div>`)
  7615.                                                 .appendTo($itemMenu)
  7616.                                                 .click((evt) => {
  7617.                                                         evt.stopPropagation();
  7618.                                                         window.open(it.uri, "_blank");
  7619.                                                 });
  7620.                                         const $btnCopyUrl = $(`<div class="artr__item__menu_item pictos" title="Copy URL">A</div>`)
  7621.                                                 .appendTo($itemMenu)
  7622.                                                 .click(async (evt) => {
  7623.                                                         evt.stopPropagation();
  7624.                                                         await MiscUtil.pCopyTextToClipboard(it.uri);
  7625.                                                         JqueryUtil.showCopiedEffect($btnDownload, "Copied URL!");
  7626.                                                 });
  7627.                                         if (it.support) {
  7628.                                                 const $btnSupport = $(`<div class="artr__item__menu_item pictos" title="Support Artist">$</div>`)
  7629.                                                         .appendTo($itemMenu)
  7630.                                                         .click((evt) => {
  7631.                                                                 evt.stopPropagation();
  7632.                                                                 window.open(it.support, "_blank");
  7633.                                                         });
  7634.                                         }
  7635.  
  7636.                                         $item.draggable({
  7637.                                                 handle: ".artr__item",
  7638.                                                 revert: true,
  7639.                                                 revertDuration: 0,
  7640.                                                 helper: "clone",
  7641.                                                 appendTo: "body"
  7642.                                         });
  7643.                                 });
  7644.                         }
  7645.  
  7646.                         doRenderIndex(applyFilterAndSearchToIndex());
  7647.                 }
  7648.  
  7649.                 let firstClick = true;
  7650.                 const calcWidth = () => {
  7651.                         const base = d20.engine.canvasWidth * 0.66;
  7652.                         return (Math.ceil((base - 300) / 190) * 190) + 320;
  7653.                 };
  7654.                 const $btnBrowse = $(`#button-browse-external-art`).click(() => {
  7655.                         $win.dialog(
  7656.                                 "option",
  7657.                                 {
  7658.                                         width: calcWidth(),
  7659.                                         height: d20.engine.canvasHeight - 100,
  7660.                                         position: {
  7661.                                                 my: "left top",
  7662.                                                 at: "left+75 top+15",
  7663.                                                 collision: "none"
  7664.                                         }
  7665.                                 }
  7666.                         ).dialog("open");
  7667.  
  7668.                         if (firstClick) {
  7669.                                 doInit();
  7670.                                 firstClick = false;
  7671.                         }
  7672.                 });
  7673.         };
  7674.  
  7675.         d20plus.artBrowse._downloadZip = async item => {
  7676.                 function doCreateIdChat (str, isError) {
  7677.                         const uid = d20plus.ut.generateRowId();
  7678.                         d20.textchat.incoming(false, ({
  7679.                                 who: "system",
  7680.                                 type: "system",
  7681.                                 content: `<span id="${uid}" class="hacker-chat inline-block ${isError ? "is-error" : ""}">${str}</span>`
  7682.                         }));
  7683.                         return uid;
  7684.                 }
  7685.  
  7686.                 function doUpdateIdChat (id, str, isError = false) {
  7687.                         $(`#userscript-${id}`).toggleClass("is-error", isError).html(str);
  7688.                 }
  7689.  
  7690.                 let isHandled = false;
  7691.                 function handleCancel (id) {
  7692.                         if (isHandled) return;
  7693.                         isHandled = true;
  7694.                         doUpdateIdChat(id, "Download cancelled.");
  7695.                 }
  7696.  
  7697.                 function pAjaxLoad (url) {
  7698.                         const oReq = new XMLHttpRequest();
  7699.                         const p = new Promise((resolve, reject) => {
  7700.                                 // FIXME cors-anywhere has a usage limit, which is pretty easy to hit when downloading many files
  7701.                                 oReq.open("GET", `https://cors-anywhere.herokuapp.com/${url}`, true);
  7702.                                 oReq.responseType = "arraybuffer";
  7703.                                 let lastContentType = null;
  7704.                                 oReq.onreadystatechange = () => {
  7705.                                         const h = oReq.getResponseHeader("content-type");
  7706.                                         if (h) {
  7707.                                                 lastContentType = h;
  7708.                                         }
  7709.                                 };
  7710.                                 oReq.onload = function() {
  7711.                                         const arrayBuffer = oReq.response;
  7712.                                         resolve({buff: arrayBuffer, contentType: lastContentType});
  7713.                                 };
  7714.                                 oReq.onerror = (e) => reject(new Error(`Error during request: ${e}`));
  7715.                                 oReq.send();
  7716.                         });
  7717.                         p.abort = () => oReq.abort();
  7718.                         return p;
  7719.                 }
  7720.  
  7721.                 $(`#rightsidebar a[href="#textchat"]`).click();
  7722.                 const chatId = doCreateIdChat(`Download starting...`);
  7723.                 let isCancelled = false;
  7724.                 let downloadTasks = [];
  7725.                 const $btnStop = $(`<button class="btn btn-danger Ve-btn-chat" id="button-${chatId}">Stop</button>`)
  7726.                         .insertAfter($(`#userscript-${chatId}`))
  7727.                         .click(() => {
  7728.                                 isCancelled = true;
  7729.                                 downloadTasks.forEach(p => p.abort());
  7730.                                 handleCancel(chatId);
  7731.                                 $btnStop.remove();
  7732.                         });
  7733.                 try { $btnStop[0].scrollIntoView() }
  7734.                 catch (e) { console.error(e) }
  7735.  
  7736.                 if (isCancelled) return handleCancel(chatId);
  7737.  
  7738.                 try {
  7739.                         const toSave = [];
  7740.                         let downloaded = 0;
  7741.                         let errorCount = 0;
  7742.  
  7743.                         const getWrappedPromise = dataItem => {
  7744.                                 const pAjax = pAjaxLoad(dataItem.uri);
  7745.                                 const p = new Promise(async resolve => {
  7746.                                         try {
  7747.                                                 const data = await pAjax;
  7748.                                                 toSave.push(data);
  7749.                                         } catch (e) {
  7750.                                                 d20plus.ut.error(`Error downloading "${dataItem.uri}":`, e);
  7751.                                                 ++errorCount;
  7752.                                         }
  7753.                                         ++downloaded;
  7754.                                         doUpdateIdChat(chatId, `Downloading ${downloaded}/${item.data.length}... (${Math.floor(100 * downloaded / item.data.length)}%)${errorCount ? ` (${errorCount} error${errorCount === 1 ? "" : "s"})` : ""}`);
  7755.                                         resolve();
  7756.                                 });
  7757.                                 p.abort = () => pAjax.abort();
  7758.                                 return p;
  7759.                         };
  7760.  
  7761.                         downloadTasks = item.data.map(dataItem => getWrappedPromise(dataItem));
  7762.                         await Promise.all(downloadTasks);
  7763.  
  7764.                         if (isCancelled) return handleCancel(chatId);
  7765.  
  7766.                         doUpdateIdChat(chatId, `Building ZIP...`);
  7767.  
  7768.                         const zip = new JSZip();
  7769.                         toSave.forEach((data, i) => {
  7770.                                 const extension = (data.contentType || "unknown").split("/").last();
  7771.                                 zip.file(`${`${i}`.padStart(3, "0")}.${extension}`, data.buff, {binary: true});
  7772.                         });
  7773.  
  7774.                         if (isCancelled) return handleCancel(chatId);
  7775.  
  7776.                         zip.generateAsync({type:"blob"})
  7777.                                 .then((content) => {
  7778.                                         if (isCancelled) return handleCancel(chatId);
  7779.  
  7780.                                         doUpdateIdChat(chatId, `Downloading ZIP...`);
  7781.                                         d20plus.ut.saveAs(content, d20plus.ut.sanitizeFilename(`${item.set}__${item.artist}`));
  7782.                                         doUpdateIdChat(chatId, `Download complete.`);
  7783.                                         $btnStop.remove();
  7784.                                 });
  7785.                 } catch (e) {
  7786.                         doUpdateIdChat(chatId, `Download failed! Error was: ${e.message}<br>Check the log for more information.`, true);
  7787.                         console.error(e);
  7788.                 }
  7789.         };
  7790.  
  7791.         d20plus.artBrowse._downloadUrls = async item => {
  7792.                 const contents = item.data.map(it => it.uri).join("\n");
  7793.                 const blob = new Blob([contents], {type: "text/plain"});
  7794.                 d20plus.ut.saveAs(blob, d20plus.ut.sanitizeFilename(`${item.set}__${item.artist}`));
  7795.         };
  7796. }
  7797.  
  7798. SCRIPT_EXTENSIONS.push(d20plusArtBrowser);
  7799.  
  7800.  
  7801. function d20plusEngine () {
  7802.         d20plus.engine = {};
  7803.  
  7804.         d20plus.engine.addProFeatures = () => {
  7805.                 d20plus.ut.log("Add Pro features");
  7806.  
  7807.                 d20plus.setMode = d20plus.mod.setMode;
  7808.                 window.setMode = d20plus.mod.setMode;
  7809.  
  7810.                 // rebind buttons with new setMode
  7811.                 const $drawTools = $("#drawingtools");
  7812.                 const $rect = $drawTools.find(".chooserect");
  7813.                 const $path = $drawTools.find(".choosepath");
  7814.                 const $poly = $drawTools.find(".choosepolygon");
  7815.                 $drawTools.unbind(clicktype).bind(clicktype, function () {
  7816.                         $(this).hasClass("rect") ? setMode("rect") : $(this).hasClass("text") ? setMode("text") : $(this).hasClass("path") ? setMode("path") : $(this).hasClass("drawselect") ? setMode("drawselect") : $(this).hasClass("polygon") && setMode("polygon")
  7817.                 });
  7818.                 $rect.unbind(clicktype).bind(clicktype, () => {
  7819.                         setMode("rect");
  7820.                         return false;
  7821.                 });
  7822.                 $path.unbind(clicktype).bind(clicktype, () => {
  7823.                         setMode("path");
  7824.                         return false;
  7825.                 });
  7826.                 $poly.unbind(clicktype).bind(clicktype, () => {
  7827.                         setMode("polygon");
  7828.                         return false;
  7829.                 });
  7830.                 $("#rect").unbind(clicktype).bind(clicktype, () => setMode("rect"));
  7831.                 $("#path").unbind(clicktype).bind(clicktype, () => setMode("path"));
  7832.  
  7833.                 if (!$(`#fxtools`).length) {
  7834.                         const $fxMode = $(`<li id="fxtools"/>`).append(`<span class="pictos">e</span>`);
  7835.                         $fxMode.on("click", () => {
  7836.                                 d20plus.setMode("fxtools");
  7837.                         });
  7838.                         $(`#drawingtools`).after($fxMode);
  7839.                 }
  7840.  
  7841.                 // bind new hotkeys
  7842.                 Mousetrap.bind("q q", function () { // default ruler on q-q
  7843.                         setMode("measure");
  7844.                         $(`#measure_mode`).val("1").trigger("change");
  7845.                         return false;
  7846.                 });
  7847.  
  7848.                 Mousetrap.bind("q r", function () { // radius
  7849.                         setMode("measure");
  7850.                         $(`#measure_mode`).val("2").trigger("change");
  7851.                         return false;
  7852.                 });
  7853.  
  7854.                 Mousetrap.bind("q c", function () { // cone
  7855.                         setMode("measure");
  7856.                         $(`#measure_mode`).val("3").trigger("change");
  7857.                         return false;
  7858.                 });
  7859.  
  7860.                 Mousetrap.bind("q e", function () { // box
  7861.                         setMode("measure");
  7862.                         $(`#measure_mode`).val("4").trigger("change");
  7863.                         return false;
  7864.                 });
  7865.  
  7866.                 Mousetrap.bind("q w", function () { // line
  7867.                         setMode("measure");
  7868.                         $(`#measure_mode`).val("5").trigger("change");
  7869.                         return false;
  7870.                 });
  7871.  
  7872.                 if (window.is_gm) {
  7873.                         // add lighting layer tool
  7874.                         if (!$(`#editinglayer .choosewalls`).length) {
  7875.                                 $(`#editinglayer .choosegmlayer`).after(`<li class="choosewalls"><span class="pictostwo">r</span> Dynamic Lighting</li>`);
  7876.                         }
  7877.  
  7878.                         // ensure tokens have editable sight
  7879.                         $("#tmpl_tokeneditor").replaceWith(d20plus.template_TokenEditor);
  7880.                         // show dynamic lighting/etc page settings
  7881.                         $("#tmpl_pagesettings").replaceWith(d20plus.template_pageSettings);
  7882.                         $("#page-toolbar").on("mousedown", ".settings", function () {
  7883.                                 var e = d20.Campaign.pages.get($(this).parents(".availablepage").attr("data-pageid"));
  7884.                                 e.view._template = $.jqotec("#tmpl_pagesettings");
  7885.                         });
  7886.                 }
  7887.         };
  7888.  
  7889.         d20plus.engine.enhanceMeasureTool = () => {
  7890.                 d20plus.ut.log("Enhance Measure tool");
  7891.  
  7892.                 // add extra toolbar
  7893.                 const $wrpBar = $(`#secondary-toolbar`);
  7894.                 const toAdd = `
  7895.                                 <ul class="mode measure" style="display: none;">
  7896.                                         <li>
  7897.                                                 <select id="measure_mode" style="width: 100px;">
  7898.                                                         <option value="1" selected>Ruler</option>
  7899.                                                         <option value="2">Radius</option>
  7900.                                                         <option value="3">Cone</option>
  7901.                                                         <option value="4">Box</option>
  7902.                                                         <option value="5">Line</option>
  7903.                                                 </select>
  7904.                                         </li>
  7905.                                         <li class="measure_mode_sub measure_mode_sub_2" style="display: none;">
  7906.                                                 <select id="measure_mode_sel_2" style="width: 100px;">
  7907.                                                         <option value="1" selected>Burst</option>
  7908.                                                         <option value="2">Blast</option>
  7909.                                                 </select>
  7910.                                         </li>
  7911.                                         <li class="measure_mode_sub measure_mode_sub_3" style="display: none;">
  7912.                                                 <input type="number" min="0" id="measure_mode_ipt_3" style="width: 45px;" value="1">
  7913.                                                 <label style="display: inline-flex;" title="The PHB cone rules are the textbook definition of one radian.">rad.</label>
  7914.                                                 <select id="measure_mode_sel_3" style="width: 120px;">
  7915.                                                         <option value="1" selected>Edge: Flat</option>
  7916.                                                         <option value="2">Edge: Rounded</option>
  7917.                                                 </select>
  7918.                                         </li>
  7919.                                         <li class="measure_mode_sub measure_mode_sub_4" style="display: none;">
  7920.                                                 <select id="measure_mode_sel_4" style="width: 100px;">
  7921.                                                         <option value="1" selected>Burst</option>
  7922.                                                         <option value="2">Blast</option>
  7923.                                                 </select>
  7924.                                         </li>
  7925.                                         <li class="measure_mode_sub measure_mode_sub_5" style="display: none;">
  7926.                                                 <select id="measure_mode_sel_5" style="width: 120px;">
  7927.                                                         <option value="1" selected>Total Width: </option>
  7928.                                                         <option value="2">Width To Edge: </option>
  7929.                                                 </select>
  7930.                                                 <input type="number" min="0" id="measure_mode_ipt_5" style="width: 40px;" value="5">
  7931.                                                 <label style="display: inline-flex;">units</label>
  7932.                                         </li>
  7933.                                 </ul>`;
  7934.                 $wrpBar.append(toAdd);
  7935.  
  7936.                 $(`#measure`).click(() => {
  7937.                         d20plus.setMode("measure");
  7938.                 });
  7939.                 const $selMeasure = $(`#measure_mode`);
  7940.                 $selMeasure.on("change", () => {
  7941.                         $(`.measure_mode_sub`).hide();
  7942.                         $(`.measure_mode_sub_${$selMeasure.val()}`).show();
  7943.                 });
  7944.  
  7945.                 //      const event = {
  7946.                 //              type: "Ve_measure_clear_sticky",
  7947.                 //              player: window.currentPlayer.id,
  7948.                 //              time: (new Date).getTime()
  7949.                 //      };
  7950.                 //      d20.textchat.sendShout(event)
  7951.  
  7952.                 d20.textchat.shoutref.on("value", function(e) {
  7953.                         if (!d20.textchat.chatstartingup) {
  7954.                                 var t = e.val();
  7955.                                 if (t) {
  7956.                                         const msg = JSON.parse(t);
  7957.                                         if (window.DEBUG) console.log("SHOUT: ", msg);
  7958.  
  7959.                                         switch (msg.type) {
  7960.                                                 // case "Ve_measure_clear_sticky": {
  7961.                                                 //      delete d20plus._stickyMeasure[msg.player];
  7962.                                                 //      d20.engine.redrawScreenNextTick();
  7963.                                                 // }
  7964.                                         }
  7965.                                 }
  7966.                         }
  7967.                 });
  7968.  
  7969.                 d20plus.mod.drawMeasurements();
  7970.         };
  7971.  
  7972.         d20plus.engine._addStatusEffectEntries = () => {
  7973.                 const sheetUrl = window.is_gm ? d20plus.cfg.get("token", "statusSheetUrl") || d20plus.cfg.getDefault("token", "statusSheetUrl"): window.Campaign.attributes.bR20cfg_statussheet;
  7974.  
  7975.                 const temp = new Image();
  7976.                 temp.onload = () => {
  7977.                         const xSize = 34;
  7978.                         const iMin = 47;
  7979.                         const iMax = Math.ceil(temp.width / xSize); // round the last one up to a full image
  7980.                         for (let i = iMin; i < iMax; ++i) {
  7981.                                 d20.token_editor.statusmarkers["5etools_" + (i - iMin)] = String(i * xSize);
  7982.                         }
  7983.                 };
  7984.                 temp.src = sheetUrl;
  7985.  
  7986.                 $(`#5etools-status-css`).html(`#radial-menu .markermenu .markericon {
  7987.                                 background-image: url(${sheetUrl});
  7988.                         }`);
  7989.         };
  7990.  
  7991.         d20plus.engine._removeStatusEffectEntries = () => {
  7992.                 $(`#5etools-status-css`).html("");
  7993.                 Object.keys(d20.token_editor.statusmarkers).filter(k => k.startsWith("5etools_")).forEach(k => delete d20.token_editor.statusmarkers[k]);
  7994.         };
  7995.  
  7996.         d20plus.engine.enhanceStatusEffects = () => {
  7997.                 d20plus.ut.log("Enhance status effects");
  7998.                 $(`head`).append(`<style id="5etools-status-css"/>`);
  7999.                 d20plus.cfg._handleStatusTokenConfigChange();
  8000.  
  8001.                 d20plus.mod.overwriteStatusEffects();
  8002.  
  8003.                 d20.engine.canvas.off("object:added");
  8004.                 d20.engine.canvas.on("object:added", d20plus.mod.overwriteStatusEffects);
  8005.  
  8006.                 // the holy trinity
  8007.                 // d20.engine.canvas.on("object:removed", () => console.log("added"));
  8008.                 // d20.engine.canvas.on("object:removed", () => console.log("removed"));
  8009.                 // d20.engine.canvas.on("object:modified", () => console.log("modified"));
  8010.  
  8011.                 $(document).off("mouseenter", ".markermenu");
  8012.                 $(document).on("mouseenter", ".markermenu", d20plus.mod.mouseEnterMarkerMenu)
  8013.         };
  8014.  
  8015.         d20plus.engine.enhancePageSelector = () => {
  8016.                 d20plus.ut.log("Enhancing page selector");
  8017.  
  8018.                 var updatePageOrder = function () {
  8019.                         d20plus.ut.log("Saving page order...");
  8020.                         var pos = 0;
  8021.                         $("#page-toolbar .pages .chooseablepage").each(function () {
  8022.                                 var page = d20.Campaign.pages.get($(this).attr("data-pageid"));
  8023.                                 page && page.save({
  8024.                                         placement: pos
  8025.                                 });
  8026.                                 pos++;
  8027.                         });
  8028.                         d20.pagetoolbar.noReload = false;
  8029.                         d20.pagetoolbar.refreshPageListing();
  8030.                 };
  8031.  
  8032.                 function overwriteDraggables () {
  8033.                         // make them draggable on both axes
  8034.                         $("#page-toolbar .pages").sortable("destroy");
  8035.                         $("#page-toolbar .pages").sortable({
  8036.                                 items: "> .chooseablepage",
  8037.                                 start: function () {
  8038.                                         d20.pagetoolbar.noReload = true;
  8039.                                 },
  8040.                                 stop: function () {
  8041.                                         updatePageOrder()
  8042.                                 },
  8043.                                 distance: 15
  8044.                         }).addTouch();
  8045.                         $("#page-toolbar .playerbookmark").draggable("destroy");
  8046.                         $("#page-toolbar .playerbookmark").draggable({
  8047.                                 revert: "invalid",
  8048.                                 appendTo: "#page-toolbar",
  8049.                                 helper: "original"
  8050.                         }).addTouch();
  8051.                         $("#page-toolbar .playerspecificbookmark").draggable("destroy");
  8052.                         $("#page-toolbar .playerspecificbookmark").draggable({
  8053.                                 revert: "invalid",
  8054.                                 appendTo: "#page-toolbar",
  8055.                                 helper: "original"
  8056.                         }).addTouch();
  8057.                 }
  8058.  
  8059.                 overwriteDraggables();
  8060.                 $(`#page-toolbar`).css("top", "calc(-90vh + 40px)");
  8061.  
  8062.                 const originalFn = d20.pagetoolbar.refreshPageListing;
  8063.                 // original function is debounced at 100ms, so debounce this at 110ms and hope for the best
  8064.                 const debouncedOverwrite = _.debounce(() => {
  8065.                         overwriteDraggables();
  8066.                         // fire an event for other parts of the script to listen for
  8067.                         const pageChangeEvt = new Event(`VePageChange`);
  8068.                         d20plus.ut.log("Firing page-change event");
  8069.                         document.dispatchEvent(pageChangeEvt);
  8070.                 }, 110);
  8071.                 d20.pagetoolbar.refreshPageListing = () => {
  8072.                         originalFn();
  8073.                         debouncedOverwrite();
  8074.                 }
  8075.         };
  8076.  
  8077.         d20plus.engine.initQuickSearch = ($iptSearch, $outSearch) => {
  8078.                 $iptSearch.on("keyup", () => {
  8079.                         const searchVal = ($iptSearch.val() || "").trim();
  8080.                         $outSearch.empty();
  8081.                         if (searchVal.length <= 2) return; // ignore 2 characters or less, for performance reasons
  8082.                         const found = $(`#journal .content`).find(`li[data-itemid]`).filter((i, ele) => {
  8083.                                 const $ele = $(ele);
  8084.                                 return $ele.find(`.name`).text().trim().toLowerCase().includes(searchVal.toLowerCase());
  8085.                         });
  8086.                         if (found.length) {
  8087.                                 $outSearch.append(`<p><b>Search results:</b></p>`);
  8088.                                 const $outList = $(`<ol class="dd-list Vetools-search-results"/>`);
  8089.                                 $outSearch.append($outList);
  8090.                                 found.clone().addClass("Vetools-draggable").appendTo($outList);
  8091.                                 $outSearch.append(`<hr>`);
  8092.                                 $(`.Vetools-search-results .Vetools-draggable`).draggable({
  8093.                                         revert: true,
  8094.                                         distance: 10,
  8095.                                         revertDuration: 0,
  8096.                                         helper: "clone",
  8097.                                         handle: ".namecontainer",
  8098.                                         appendTo: "body",
  8099.                                         scroll: true,
  8100.                                         start: function () {
  8101.                                                 $("#journalfolderroot").addClass("externaldrag")
  8102.                                         },
  8103.                                         stop: function () {
  8104.                                                 $("#journalfolderroot").removeClass("externaldrag")
  8105.                                         }
  8106.                                 });
  8107.                         }
  8108.                 });
  8109.         };
  8110.  
  8111.         d20plus.engine.addSelectedTokenCommands = () => {
  8112.                 d20plus.ut.log("Add token rightclick commands");
  8113.                 $("#tmpl_actions_menu").replaceWith(d20plus.template_actionsMenu);
  8114.  
  8115.                 const getTokenWhisperPart = () => d20plus.cfg.getOrDefault("token", "massRollWhisperName") ? "/w gm Rolling for @{selected|token_name}...\n" : "";
  8116.  
  8117.                 Mousetrap.bind("b b", function () { // back on layer
  8118.                         const n = d20plus.engine._getSelectedToMove();
  8119.                         d20plus.engine.backwardOneLayer(n);
  8120.                         return false;
  8121.                 });
  8122.  
  8123.                 Mousetrap.bind("b f", function () { // forward one layer
  8124.                         const n = d20plus.engine._getSelectedToMove();
  8125.                         d20plus.engine.forwardOneLayer(n);
  8126.                         return false;
  8127.                 });
  8128.  
  8129.                 /**
  8130.                  * @param token A token.
  8131.                  * @return {number} 0 for unknown, 1 for NPC, 2 for PC.
  8132.                  */
  8133.                 function getTokenType (token) {
  8134.                         if (token && token.model && token.model.toJSON && token.model.toJSON().represents) {
  8135.                                 const charIdMaybe = token.model.toJSON().represents;
  8136.                                 if (!charIdMaybe) return 0; //
  8137.                                 const charMaybe = d20.Campaign.characters.get(charIdMaybe);
  8138.                                 if (charMaybe) {
  8139.                                         const atbs = charMaybe.attribs.toJSON();
  8140.                                         const npcAtbMaybe = atbs.find(it => it.name === "npc");
  8141.  
  8142.                                         if (npcAtbMaybe && npcAtbMaybe.current == 1) {
  8143.                                                 return 1;
  8144.                                         } else {
  8145.                                                 return 2;
  8146.                                         }
  8147.                                 } else return 0;
  8148.                         } else return 0;
  8149.                 }
  8150.  
  8151.                 const lastContextSelection = {
  8152.                         lastAnimUid: null,
  8153.                         lastSceneUid: null,
  8154.                 };
  8155.  
  8156.                 // BEGIN ROLL20 CODE
  8157.                 var e, t = !1, n = [];
  8158.                 var i = function() {
  8159.                         t && (t.remove(),
  8160.                                 t = !1),
  8161.                         e && clearTimeout(e)
  8162.                 };
  8163.                 var r = function (r) {
  8164.                         var o, a;
  8165.                         r.changedTouches && r.changedTouches.length > 0 ? (o = r.changedTouches[0].pageX,
  8166.                                 a = r.changedTouches[0].pageY) : (o = r.pageX,
  8167.                                 a = r.pageY),
  8168.                                 i(),
  8169.                                 n = [];
  8170.                         for (var s = [], l = d20.engine.selected(), c = 0; c < l.length; c++)
  8171.                                 n.push(l[c]),
  8172.                                         s.push(l[c].type);
  8173.                         if (s = _.uniq(s),
  8174.                         n.length > 0)
  8175.                                 if (1 == s.length) {
  8176.                                         var u = n[0];
  8177.                                         t = $("image" == u.type && 0 == u.model.get("isdrawing") ? $("#tmpl_actions_menu").jqote(u.model) : $("#tmpl_actions_menu").jqote(u.model))
  8178.                                 } else {
  8179.                                         var u = n[0];
  8180.                                         t = $($("#tmpl_actions_menu").jqote(u.model))
  8181.                                 }
  8182.                         else
  8183.                                 t = $($("#tmpl_actions_menu").jqote({}));
  8184.                         if (!window.is_gm && t[0].lastElementChild.childElementCount < 1)
  8185.                                 return !1;
  8186.                         t.appendTo("body");
  8187.                         var d = t.height()
  8188.                                 , h = t.width()
  8189.                                 , p = {};
  8190.                         return p.top = a > $("#editor-wrapper").height() - $("#playerzone").height() - d - 100 ? a - d + "px" : a + "px",
  8191.                                 p.left = o > $("#editor-wrapper").width() - h ? o + 10 - h + "px" : o + 10 + "px",
  8192.                                 t.css(p),
  8193.                                 $(".actions_menu").bind("mousedown mouseup touchstart", function(e) {
  8194.                                         e.stopPropagation()
  8195.                                 }),
  8196.                                 $(".actions_menu ul > li").bind("mouseover touchend", function() {
  8197.                                         if (e && (clearTimeout(e),
  8198.                                                 e = !1),
  8199.                                         $(this).parents(".hasSub").length > 0)
  8200.                                                 ;
  8201.                                         else if ($(this).hasClass("hasSub")) {
  8202.                                                 $(".actions_menu").css({
  8203.                                                         width: "215px",
  8204.                                                         height: "250px"
  8205.                                                 });
  8206.                                                 var t = this;
  8207.                                                 _.defer(function() {
  8208.                                                         $(".actions_menu ul.submenu").hide(),
  8209.                                                                 $(t).find("ul.submenu:hidden").show()
  8210.                                                 })
  8211.                                         } else
  8212.                                                 $(".actions_menu ul.submenu").hide()
  8213.                                 }),
  8214.                                 $(".actions_menu ul.submenu").live("mouseover", function() {
  8215.                                         e && (clearTimeout(e),
  8216.                                                 e = !1)
  8217.                                 }),
  8218.                                 $(".actions_menu, .actions_menu ul.submenu").live("mouseleave", function() {
  8219.                                         e || (e = setTimeout(function() {
  8220.                                                 $(".actions_menu ul.submenu").hide(),
  8221.                                                         $(".actions_menu").css("width", "100px").css("height", "auto"),
  8222.                                                         e = !1
  8223.                                         }, 500))
  8224.                                 }),
  8225.                                 $(".actions_menu li").on(clicktype, function() {
  8226.                                         var e = $(this).attr("data-action-type");
  8227.                                         if (null != e) {
  8228.                                                 if ("copy" == e)
  8229.                                                         d20.clipboard.doCopy(),
  8230.                                                                 i();
  8231.                                                 else if ("paste" == e)
  8232.                                                         d20.clipboard.doPaste(),
  8233.                                                                 i();
  8234.                                                 else if ("delete" == e) {
  8235.                                                         var t = d20.engine.selected();
  8236.                                                         d20.engine.canvas.deactivateAllWithDispatch();
  8237.                                                         for (var r = 0; r < t.length; r++)
  8238.                                                                 t[r].model.destroy();
  8239.                                                         i()
  8240.                                                 } else if ("undo" == e)
  8241.                                                         d20.undo && d20.undo.doUndo(),
  8242.                                                                 i();
  8243.                                                 else if ("tofront" == e)
  8244.                                                         d20.engine.canvas.getActiveGroup() && d20.engine.unselect(),
  8245.                                                                 _.each(n, function(e) {
  8246.                                                                         d20.engine.canvas.bringToFront(e)
  8247.                                                                 }),
  8248.                                                                 d20.Campaign.activePage().debounced_recordZIndexes(),
  8249.                                                                 i();
  8250.                                                 else if ("toback" == e)
  8251.                                                         d20.engine.canvas.getActiveGroup() && d20.engine.unselect(),
  8252.                                                                 _.each(n, function(e) {
  8253.                                                                         d20.engine.canvas.sendToBack(e)
  8254.                                                                 }),
  8255.                                                                 d20.Campaign.activePage().debounced_recordZIndexes(),
  8256.                                                                 i();
  8257.                                                 else if (-1 !== e.indexOf("tolayer_")) {
  8258.                                                         d20.engine.unselect();
  8259.                                                         var o = e.replace("tolayer_", "");
  8260.                                                         _.each(n, function(e) {
  8261.                                                                 e.model.save({
  8262.                                                                         layer: o
  8263.                                                                 })
  8264.                                                         }),
  8265.                                                                 i(),
  8266.                                                                 d20.token_editor.removeRadialMenu()
  8267.                                                 } else if ("addturn" == e)
  8268.                                                         _.each(n, function(e) {
  8269.                                                                 d20.Campaign.initiativewindow.addTokenToList(e.model.id)
  8270.                                                         }),
  8271.                                                                 i(),
  8272.                                                         d20.tutorial && d20.tutorial.active && $(document.body).trigger("addedTurn");
  8273.                                                 else if ("group" == e) {
  8274.                                                         var a = [];
  8275.                                                         d20.engine.unselect(),
  8276.                                                                 _.each(n, function(e) {
  8277.                                                                         a.push(e.model.id)
  8278.                                                                 }),
  8279.                                                                 _.each(n, function(e) {
  8280.                                                                         e.model.addToGroup(a)
  8281.                                                                 }),
  8282.                                                                 i();
  8283.                                                         var s = n[0];
  8284.                                                         d20.engine.select(s)
  8285.                                                 } else if ("ungroup" == e)
  8286.                                                         d20.engine.unselect(),
  8287.                                                                 _.each(n, function(e) {
  8288.                                                                         e.model.clearGroup()
  8289.                                                                 }),
  8290.                                                                 d20.token_editor.removeRadialMenu(),
  8291.                                                                 i();
  8292.                                                 else if ("toggledrawing" == e)
  8293.                                                         d20.engine.unselect(),
  8294.                                                                 _.each(n, function(e) {
  8295.                                                                         e.model.set({
  8296.                                                                                 isdrawing: !e.model.get("isdrawing")
  8297.                                                                         }).save()
  8298.                                                                 }),
  8299.                                                                 i(),
  8300.                                                                 d20.token_editor.removeRadialMenu();
  8301.                                                 else if ("toggleflipv" == e)
  8302.                                                         d20.engine.unselect(),
  8303.                                                                 _.each(n, function(e) {
  8304.                                                                         e.model.set({
  8305.                                                                                 flipv: !e.model.get("flipv")
  8306.                                                                         }).save()
  8307.                                                                 }),
  8308.                                                                 i(),
  8309.                                                                 d20.token_editor.removeRadialMenu();
  8310.                                                 else if ("togglefliph" == e)
  8311.                                                         d20.engine.unselect(),
  8312.                                                                 _.each(n, function(e) {
  8313.                                                                         e.model.set({
  8314.                                                                                 fliph: !e.model.get("fliph")
  8315.                                                                         }).save()
  8316.                                                                 }),
  8317.                                                                 i(),
  8318.                                                                 d20.token_editor.removeRadialMenu();
  8319.                                                 else if ("takecard" == e)
  8320.                                                         d20.engine.canvas.getActiveGroup() && d20.engine.unselect(),
  8321.                                                                 _.each(n, function(e) {
  8322.                                                                         var t = d20.decks.cardByID(e.model.get("cardid"));
  8323.                                                                         if (e.model.get("isdrawing") === !1)
  8324.                                                                                 var n = {
  8325.                                                                                         bar1_value: e.model.get("bar1_value"),
  8326.                                                                                         bar1_max: e.model.get("bar1_max"),
  8327.                                                                                         bar2_value: e.model.get("bar2_value"),
  8328.                                                                                         bar2_max: e.model.get("bar2_max"),
  8329.                                                                                         bar3_value: e.model.get("bar3_value"),
  8330.                                                                                         bar3_max: e.model.get("bar3_max")
  8331.                                                                                 };
  8332.                                                                         d20.Campaign.hands.addCardToHandForPlayer(t, window.currentPlayer, n ? n : void 0),
  8333.                                                                                 _.defer(function() {
  8334.                                                                                         e.model.destroy()
  8335.                                                                                 })
  8336.                                                                 }),
  8337.                                                                 d20.engine.unselect(),
  8338.                                                                 i();
  8339.                                                 else if ("flipcard" == e)
  8340.                                                         d20.engine.canvas.getActiveGroup() && d20.engine.unselect(),
  8341.                                                                 _.each(n, function(e) {
  8342.                                                                         var t = e.model.get("sides").split("|")
  8343.                                                                                 , n = e.model.get("currentSide")
  8344.                                                                                 , i = n + 1;
  8345.                                                                         i > t.length - 1 && (i = 0),
  8346.                                                                                 e.model.set({
  8347.                                                                                         currentSide: i,
  8348.                                                                                         imgsrc: unescape(t[i])
  8349.                                                                                 }).save()
  8350.                                                                 }),
  8351.                                                                 i();
  8352.                                                 else if ("setdimensions" == e) {
  8353.                                                         var l = n[0]
  8354.                                                                 , c = $($("#tmpl_setdimensions").jqote()).dialog({
  8355.                                                                 title: "Set Dimensions",
  8356.                                                                 width: 325,
  8357.                                                                 height: 225,
  8358.                                                                 buttons: {
  8359.                                                                         Set: function() {
  8360.                                                                                 var e, t;
  8361.                                                                                 "pixels" == c.find(".dimtype").val() ? (e = parseInt(c.find("input.width").val(), 10),
  8362.                                                                                         t = parseInt(c.find("input.height").val(), 10)) : (e = parseFloat(c.find("input.width").val()) * window.dpi,
  8363.                                                                                         t = parseFloat(c.find("input.height").val()) * window.dpi),
  8364.                                                                                         l.model.save({
  8365.                                                                                                 width: e,
  8366.                                                                                                 height: t
  8367.                                                                                         }),
  8368.                                                                                         c.off("change"),
  8369.                                                                                         c.dialog("destroy").remove()
  8370.                                                                         },
  8371.                                                                         Cancel: function() {
  8372.                                                                                 c.off("change"),
  8373.                                                                                         c.dialog("destroy").remove()
  8374.                                                                         }
  8375.                                                                 },
  8376.                                                                 beforeClose: function() {
  8377.                                                                         c.off("change"),
  8378.                                                                                 c.dialog("destroy").remove()
  8379.                                                                 }
  8380.                                                         });
  8381.                                                         c.on("change", ".dimtype", function() {
  8382.                                                                 "pixels" == $(this).val() ? (c.find("input.width").val(Math.round(l.get("width"))),
  8383.                                                                         c.find("input.height").val(Math.round(l.get("height")))) : (c.find("input.width").val(l.get("width") / window.dpi),
  8384.                                                                         c.find("input.height").val(l.get("height") / window.dpi))
  8385.                                                         }),
  8386.                                                                 c.find(".dimtype").trigger("change"),
  8387.                                                                 i()
  8388.                                                 } else if ("aligntogrid" == e)
  8389.                                                         if (0 === d20.Campaign.activePage().get("snapping_increment")) {
  8390.                                                                 i();
  8391.                                                                 var u = $($("#tmpl_grid-disabled").jqote(h)).dialog({
  8392.                                                                         title: "Grid Off",
  8393.                                                                         buttons: {
  8394.                                                                                 Ok: function() {
  8395.                                                                                         u.off("change"),
  8396.                                                                                                 u.dialog("destroy").remove()
  8397.                                                                                 }
  8398.                                                                         },
  8399.                                                                         beforeClose: function() {
  8400.                                                                                 u.off("change"),
  8401.                                                                                         u.dialog("destroy").remove()
  8402.                                                                         }
  8403.                                                                 })
  8404.                                                         } else
  8405.                                                                 d20.engine.gridaligner.target = n[0],
  8406.                                                                         d20plus.setMode("gridalign"),
  8407.                                                                         i();
  8408.                                                 else if ("side_random" == e) {
  8409.                                                         d20.engine.canvas.getActiveGroup() && d20.engine.unselect();
  8410.                                                         var d = [];
  8411.                                                         _.each(n, function(e) {
  8412.                                                                 if (e.model && "" != e.model.get("sides")) {
  8413.                                                                         var t = e.model.get("sides").split("|")
  8414.                                                                                 , n = t.length
  8415.                                                                                 , i = d20.textchat.diceengine.random(n);
  8416.                                                                         // BEGIN MOD
  8417.                                                                         const imgUrl = unescape(t[i]);
  8418.                                                                         e.model.save(getRollableTokenUpdate(imgUrl, i)),
  8419.                                                                         // END MOD
  8420.                                                                                 d.push(t[i])
  8421.                                                                 }
  8422.                                                         }),
  8423.                                                                 d20.textchat.rawChatInput({
  8424.                                                                         type: "tokenroll",
  8425.                                                                         content: d.join("|")
  8426.                                                                 }),
  8427.                                                                 i()
  8428.                                                 } else if ("side_choose" == e) {
  8429.                                                         const e = n[0]
  8430.                                                                 , t = e.model.toJSON()
  8431.                                                                 , o = t.sides.split("|");
  8432.                                                         let r = t.currentSide;
  8433.                                                         const a = $($("#tmpl_chooseside").jqote()).dialog({
  8434.                                                                 title: "Choose Side",
  8435.                                                                 width: 325,
  8436.                                                                 height: 225,
  8437.                                                                 buttons: {
  8438.                                                                         Choose: function() {
  8439.                                                                                 const imgUrl = unescape(o[r]);
  8440.                                                                                 d20.engine.canvas.getActiveGroup() && d20.engine.unselect(),
  8441.                                                                                         // BEGIN MOD
  8442.                                                                                         e.model.save(getRollableTokenUpdate(imgUrl, r)),
  8443.                                                                                         // END MOD
  8444.                                                                                         a.off("slide"),
  8445.                                                                                         a.dialog("destroy").remove()
  8446.                                                                         },
  8447.                                                                         Cancel: function() {
  8448.                                                                                 a.off("slide"),
  8449.                                                                                         a.dialog("destroy").remove()
  8450.                                                                         }
  8451.                                                                 },
  8452.                                                                 beforeClose: function() {
  8453.                                                                         a.off("slide"),
  8454.                                                                                 a.dialog("destroy").remove()
  8455.                                                                 }
  8456.                                                         })
  8457.                                                                 , s = a.find(".sidechoices");
  8458.                                                         for (const e of o) {
  8459.                                                                 const t = unescape(e);
  8460.                                                                 let n = d20.utils.isVideo(t) ? `<video src="${t.replace("/med.webm", "/thumb.webm")}" muted autoplay loop />` : `<img src="${t}" />`;
  8461.                                                                 n = `<div class="sidechoice">${n}</div>`,
  8462.                                                                         s.append(n)
  8463.                                                         }
  8464.                                                         s.children().attr("data-selected", !1).eq(r).attr("data-selected", !0),
  8465.                                                                 a.find(".sideslider").slider({
  8466.                                                                         min: 0,
  8467.                                                                         max: o.length - 1,
  8468.                                                                         step: 1,
  8469.                                                                         value: r
  8470.                                                                 }),
  8471.                                                                 a.on("slide", function(e, t) {
  8472.                                                                         t.value != r && (r = t.value,
  8473.                                                                                 a.find(".sidechoices .sidechoice").attr("data-selected", !1).eq(t.value).attr("data-selected", !0))
  8474.                                                                 }),
  8475.                                                                 i(),
  8476.                                                                 d20.token_editor.removeRadialMenu()
  8477.                                                 }
  8478.                                                 // BEGIN MOD
  8479.                                                 const showRollOptions = (formula, options) => {
  8480.                                                         const sel = d20.engine.selected();
  8481.  
  8482.                                                         options = options.map(it => `<option>${it}</option>`);
  8483.  
  8484.                                                         const dialog= $("<div><p style='font-size: 1.15em;'><strong>" + d20.utils.strip_tags("Select Save") + ":</strong> <select style='width: 150px; margin-left: 5px;'>" + options.join("") + "</select></p></div>");
  8485.  
  8486.                                                         dialog.dialog({
  8487.                                                                 title: "Input Value",
  8488.                                                                 beforeClose: function() {
  8489.                                                                         return false;
  8490.                                                                 },
  8491.                                                                 buttons: {
  8492.                                                                         Submit: function() {
  8493.                                                                                 const val = dialog.find("select").val();
  8494.                                                                                 console.log(val);
  8495.                                                                                 d20.engine.unselect();
  8496.                                                                                 sel.forEach(it => {
  8497.                                                                                         d20.engine.select(it);
  8498.                                                                                         const toRoll = formula(it, val);
  8499.                                                                                         d20.textchat.doChatInput(toRoll);
  8500.                                                                                         d20.engine.unselect();
  8501.                                                                                 });
  8502.  
  8503.                                                                                 dialog.off();
  8504.                                                                                 dialog.dialog("destroy").remove();
  8505.                                                                                 d20.textchat.$textarea.focus();
  8506.                                                                         },
  8507.                                                                         Cancel: function() {
  8508.                                                                                 dialog.off();
  8509.                                                                                 dialog.dialog("destroy").remove();
  8510.                                                                         }
  8511.                                                                 }
  8512.                                                         });
  8513.  
  8514.                                                         i();
  8515.                                                 };
  8516.  
  8517.                                                 if ("rollsaves" === e) {
  8518.                                                         // Mass roll: Saves
  8519.                                                         const options = ["str", "dex", "con", "int", "wis", "cha"].map(it => Parser.attAbvToFull(it));
  8520.                                                         if (d20plus.sheet === "ogl") {
  8521.                                                                 showRollOptions(
  8522.                                                                         (token, val) => {
  8523.                                                                                 if (getTokenType(token) === 1) {
  8524.                                                                                         const short = val.substring(0, 3);
  8525.                                                                                         return `${getTokenWhisperPart()}@{selected|wtype}&{template:npc} @{selected|npc_name_flag} {{type=Save}} @{selected|rtype} + [[@{selected|npc_${short.toLowerCase()}_save}]][${short.toUpperCase()}]]]}} {{rname=${val} Save}} {{r1=[[1d20 + [[@{selected|npc_${short.toLowerCase()}_save}]][${short.toUpperCase()}]]]}}`;
  8526.                                                                                 } else {
  8527.                                                                                         return `@{selected|wtype} &{template:simple} {{charname=@{selected|token_name}}} {{always=1}} {{rname=${val} Save}} {{mod=@{selected|${val.toLowerCase()}_save_bonus}}} {{r1=[[1d20+@{selected|${val.toLowerCase()}_save_bonus}]]}} {{r2=[[1d20+@{selected|${val.toLowerCase()}_save_bonus}]]}}`;
  8528.                                                                                 }
  8529.                                                                         },
  8530.                                                                         options
  8531.                                                                 );
  8532.                                                         }
  8533.                                                         else if (d20plus.sheet === "shaped") {
  8534.                                                                 showRollOptions(
  8535.                                                                         (token, val) => `@{selected|output_option}} &{template:5e-shaped} {{ability=1}} {{character_name=@{selected|token_name}}} {{title=${val} Save}} {{mod=@{selected|${val.toLowerCase()}_mod}}} {{roll1=[[1d20+@{selected|${val.toLowerCase()}_mod}]]}} {{roll2=[[1d20+@{selected|${val.toLowerCase()}_mod}]]}}`,
  8536.                                                                         options
  8537.                                                                 );
  8538.                                                         }
  8539.                                                 } else if ("rollinit" === e) {
  8540.                                                         // Mass roll: Initiative
  8541.                                                         const sel = d20.engine.selected();
  8542.                                                         d20.engine.unselect();
  8543.                                                         sel.forEach(it => {
  8544.                                                                 d20.engine.select(it);
  8545.                                                                 let toRoll = ``;
  8546.                                                                 if (d20plus.sheet === "ogl") {
  8547.                                                                         toRoll = `%{selected|Initiative}`;
  8548.                                                                 } else if (d20plus.sheet === "shaped") {
  8549.                                                                         toRoll = `@{selected|output_option} &{template:5e-shaped} {{ability=1}} {{title=INITIATIVE}} {{roll1=[[@{selected|initiative_formula}]]}}`;
  8550.                                                                 }
  8551.                                                                 d20.textchat.doChatInput(toRoll);
  8552.                                                                 d20.engine.unselect();
  8553.                                                         });
  8554.                                                         i();
  8555.                                                 } else if ("rollskills" === e) {
  8556.                                                         // TODO a "roll abilitiy check" option? NPC macro: @{selected|wtype}&{template:npc} @{selected|npc_name_flag} {{type=Check}} @{selected|rtype} + [[@{selected|strength_mod}]][STR]]]}} {{rname=Strength Check}} {{r1=[[1d20 + [[@{selected|strength_mod}]][STR]]]}}
  8557.  
  8558.                                                         // Mass roll: Skills
  8559.                                                         const options = [
  8560.                                                                 "Athletics",
  8561.                                                                 "Acrobatics",
  8562.                                                                 "Sleight of Hand",
  8563.                                                                 "Stealth",
  8564.                                                                 "Arcana",
  8565.                                                                 "History",
  8566.                                                                 "Investigation",
  8567.                                                                 "Nature",
  8568.                                                                 "Religion",
  8569.                                                                 "Animal Handling",
  8570.                                                                 "Insight",
  8571.                                                                 "Medicine",
  8572.                                                                 "Perception",
  8573.                                                                 "Survival",
  8574.                                                                 "Deception",
  8575.                                                                 "Intimidation",
  8576.                                                                 "Performance",
  8577.                                                                 "Persuasion"
  8578.                                                         ].sort();
  8579.  
  8580.                                                         showRollOptions(
  8581.                                                                 (token, val) => {
  8582.                                                                         const clean = val.toLowerCase().replace(/ /g, "_");
  8583.                                                                         const abil = `${Parser.attAbvToFull(Parser.skillToAbilityAbv(val.toLowerCase())).toLowerCase()}_mod`;
  8584.  
  8585.                                                                         let doRoll = '';
  8586.                                                                         if (d20plus.sheet === "ogl") {
  8587.                                                                                 doRoll = (atb = abil) => {
  8588.                                                                                         if (getTokenType(token) === 1) {
  8589.                                                                                                 const slugged = val.replace(/\s/g, "_").toLowerCase();
  8590.                                                                                                 return `${getTokenWhisperPart()}@{selected|wtype}&{template:npc} @{selected|npc_name_flag} {{type=Skill}} @{selected|rtype} + [[@{selected|npc_${slugged}}]]]]}}; {{rname=${val}}}; {{r1=[[1d20 + [[@{selected|npc_${slugged}}]]]]}}
  8591. `
  8592.                                                                                         } else {
  8593.                                                                                                 return `@{selected|wtype} &{template:simple} {{charname=@{selected|token_name}}} {{always=1}} {{rname=${val}}} {{mod=@{selected|${atb}}}} {{r1=[[1d20+@{selected|${atb}}]]}} {{r2=[[1d20+@{selected|${atb}}]]}}`;
  8594.                                                                                         }
  8595.                                                                                 }
  8596.                                                                         } else if (d20plus.sheet === "shaped"){
  8597.                                                                                 doRoll = (atb = abil) => {
  8598.                                                                                         return `@{selected|output_option} &{template:5e-shaped} {{ability=1}} {{character_name=@{selected|token_name}}} {{title=${val}}} {{mod=@{selected|${atb}}}} {{roll1=[[1d20+@{selected|${atb}}]]}} {{roll2=[[1d20+@{selected|${atb}}]]}}`;
  8599.                                                                                 }
  8600.                                                                         }
  8601.  
  8602.                                                                         try {
  8603.                                                                                 if (token && token.model && token.model.toJSON && token.model.toJSON().represents) {
  8604.                                                                                         const charIdMaybe = token.model.toJSON().represents;
  8605.                                                                                         if (!charIdMaybe) return doRoll();
  8606.                                                                                         const charMaybe = d20.Campaign.characters.get(charIdMaybe);
  8607.                                                                                         if (charMaybe) {
  8608.                                                                                                 const atbs = charMaybe.attribs.toJSON();
  8609.                                                                                                 const npcAtbMaybe = atbs.find(it => it.name === "npc");
  8610.  
  8611.                                                                                                 if (npcAtbMaybe && npcAtbMaybe.current == 1) {
  8612.                                                                                                         const npcClean = `npc_${clean}`;
  8613.                                                                                                         const bonusMaybe = atbs.find(it => it.name === npcClean);
  8614.                                                                                                         if (bonusMaybe) return doRoll(npcClean);
  8615.                                                                                                         else return doRoll();
  8616.                                                                                                 } else {
  8617.                                                                                                         const pcClean = `${clean}_bonus`;
  8618.                                                                                                         const bonusMaybe = atbs.find(it => it.name === pcClean);
  8619.                                                                                                         if (bonusMaybe) return doRoll(pcClean);
  8620.                                                                                                         else return doRoll();
  8621.                                                                                                 }
  8622.                                                                                         } else return doRoll();
  8623.                                                                                 } else return doRoll();
  8624.                                                                         } catch (x) {
  8625.                                                                                 console.error(x);
  8626.                                                                                 return doRoll();
  8627.                                                                         }
  8628.                                                                 },
  8629.                                                                 options
  8630.                                                         );
  8631.                                                 } else if ("forward-one" === e) {
  8632.                                                         d20plus.engine.forwardOneLayer(n);
  8633.                                                         i();
  8634.                                                 } else if ("back-one" === e) {
  8635.                                                         d20plus.engine.backwardOneLayer(n);
  8636.                                                         i();
  8637.                                                 } else if ("rollertokenresize" === e) {
  8638.                                                         resizeToken();
  8639.                                                         i();
  8640.                                                 } else if ("copy-tokenid" === e) {
  8641.                                                         const sel = d20.engine.selected();
  8642.                                                         window.prompt("Copy to clipboard: Ctrl+C, Enter", sel[0].model.id);
  8643.                                                         i();
  8644.                                                 } else if ("copy-pathid" === e) {
  8645.                                                         const sel = d20.engine.selected();
  8646.                                                         window.prompt("Copy to clipboard: Ctrl+C, Enter", sel[0].model.id);
  8647.                                                         i();
  8648.                                                 } else if ("token-fly" === e) {
  8649.                                                         const sel = d20.engine.selected().filter(it => it && it.type === "image");
  8650.                                                         new Promise((resolve, reject) => {
  8651.                                                                 const $dialog = $(`
  8652.                                                                         <div title="Flight Height">
  8653.                                                                                 <input type="number" placeholder="Flight height" name="flight">
  8654.                                                                         </div>
  8655.                                                                 `).appendTo($("body"));
  8656.                                                                 const $iptHeight = $dialog.find(`input[name="flight"]`).on("keypress", evt => {
  8657.                                                                         if (evt.which === 13) { // return
  8658.                                                                                 doHandleOk();
  8659.                                                                         }
  8660.                                                                 });
  8661.  
  8662.                                                                 const doHandleOk = () => {
  8663.                                                                         const selected = Number($iptHeight.val());
  8664.                                                                         $dialog.dialog("close");
  8665.                                                                         if (isNaN(selected)) reject(`Value "${$iptHeight.val()}" was not a number!`);
  8666.                                                                         else resolve(selected);
  8667.                                                                 };
  8668.  
  8669.                                                                 $dialog.dialog({
  8670.                                                                         dialogClass: "no-close",
  8671.                                                                         buttons: [
  8672.                                                                                 {
  8673.                                                                                         text: "Cancel",
  8674.                                                                                         click: function () {
  8675.                                                                                                 $(this).dialog("close");
  8676.                                                                                                 $dialog.remove();
  8677.                                                                                                 reject(`User cancelled the prompt`);
  8678.                                                                                         }
  8679.                                                                                 },
  8680.                                                                                 {
  8681.                                                                                         text: "OK",
  8682.                                                                                         click: function () {
  8683.                                                                                                 doHandleOk();
  8684.                                                                                         }
  8685.                                                                                 }
  8686.                                                                         ]
  8687.                                                                 });
  8688.                                                         }).then(num => {
  8689.                                                                 const STATUS_PREFIX = `fluffy-wing@`;
  8690.                                                                 const statusString = `${num}`.split("").map(it => `${STATUS_PREFIX}${it}`).join(",");
  8691.                                                                 sel.forEach(s => {
  8692.                                                                         const existing = s.model.get("statusmarkers");
  8693.                                                                         if (existing && existing.trim()) {
  8694.                                                                                 s.model.set("statusmarkers", [statusString, ...existing.split(",").filter(it => it && it && !it.startsWith(STATUS_PREFIX))].join(","));
  8695.                                                                         } else {
  8696.                                                                                 s.model.set("statusmarkers", statusString);
  8697.                                                                         }
  8698.                                                                         s.model.save();
  8699.                                                                 });
  8700.                                                         });
  8701.                                                         i();
  8702.                                                 } else if ("token-light" === e) {
  8703.                                                         const SOURCES = {
  8704.                                                                 "None (Blind)": {
  8705.                                                                         bright: 0,
  8706.                                                                         dim: 0
  8707.                                                                 },
  8708.                                                                 "Torch/Light (Spell)": {
  8709.                                                                         bright: 20,
  8710.                                                                         dim: 20
  8711.                                                                 },
  8712.                                                                 "Lamp": {
  8713.                                                                         bright: 15,
  8714.                                                                         dim: 30
  8715.                                                                 },
  8716.                                                                 "Lantern, Bullseye": {
  8717.                                                                         bright: 60,
  8718.                                                                         dim: 60,
  8719.                                                                         angle: 30
  8720.                                                                 },
  8721.                                                                 "Lantern, Hooded": {
  8722.                                                                         bright: 30,
  8723.                                                                         dim: 30
  8724.                                                                 },
  8725.                                                                 "Lantern, Hooded (Dimmed)": {
  8726.                                                                         bright: 0,
  8727.                                                                         dim: 5
  8728.                                                                 },
  8729.                                                                 "Candle": {
  8730.                                                                         bright: 5,
  8731.                                                                         dim: 5
  8732.                                                                 },
  8733.                                                                 "Darkvision": {
  8734.                                                                         bright: 0,
  8735.                                                                         dim: 60,
  8736.                                                                         hidden: true
  8737.                                                                 },
  8738.                                                                 "Superior Darkvision": {
  8739.                                                                         bright: 0,
  8740.                                                                         dim: 120,
  8741.                                                                         hidden: true
  8742.                                                                 }
  8743.                                                         };
  8744.  
  8745.                                                         const sel = d20.engine.selected().filter(it => it && it.type === "image");
  8746.                                                         new Promise((resolve, reject) => {
  8747.                                                                 const $dialog = $(`
  8748.                                                                         <div title="Light">
  8749.                                                                                 <label class="flex">
  8750.                                                                                         <span>Set Light Style</span>
  8751.                                                                                          <select style="width: 250px;">
  8752.                                                                                                 ${Object.keys(SOURCES).map(it => `<option>${it}</option>`).join("")}
  8753.                                                                                         </select>
  8754.                                                                                 </label>
  8755.                                                                         </div>
  8756.                                                                 `).appendTo($("body"));
  8757.                                                                 const $selLight = $dialog.find(`select`);
  8758.  
  8759.                                                                 $dialog.dialog({
  8760.                                                                         dialogClass: "no-close",
  8761.                                                                         buttons: [
  8762.                                                                                 {
  8763.                                                                                         text: "Cancel",
  8764.                                                                                         click: function () {
  8765.                                                                                                 $(this).dialog("close");
  8766.                                                                                                 $dialog.remove();
  8767.                                                                                                 reject(`User cancelled the prompt`);
  8768.                                                                                         }
  8769.                                                                                 },
  8770.                                                                                 {
  8771.                                                                                         text: "OK",
  8772.                                                                                         click: function () {
  8773.                                                                                                 const selected = $selLight.val();
  8774.                                                                                                 $dialog.dialog("close");
  8775.                                                                                                 if (!selected) reject(`No value selected!`);
  8776.                                                                                                 else resolve(selected);
  8777.                                                                                         }
  8778.                                                                                 }
  8779.                                                                         ]
  8780.                                                                 });
  8781.                                                         }).then(key => {
  8782.                                                                 const light = SOURCES[key];
  8783.  
  8784.                                                                 const light_otherplayers = !light.hidden;
  8785.                                                                 // these are all stored as strings
  8786.                                                                 const dimRad = (light.dim || 0);
  8787.                                                                 const brightRad = (light.bright || 0);
  8788.                                                                 const totalRad = dimRad + brightRad;
  8789.                                                                 const light_angle = `${light.angle}` || "";
  8790.                                                                 const light_dimradius = `${totalRad - dimRad}`;
  8791.                                                                 const light_radius = `${totalRad}`;
  8792.  
  8793.                                                                 sel.forEach(s => {
  8794.                                                                         s.model.set("light_angle", light_angle);
  8795.                                                                         s.model.set("light_dimradius", light_dimradius);
  8796.                                                                         s.model.set("light_otherplayers", light_otherplayers);
  8797.                                                                         s.model.set("light_radius", light_radius);
  8798.                                                                         s.model.save();
  8799.                                                                 });
  8800.                                                         });
  8801.                                                         i();
  8802.                                                 } else if ("unlock-tokens" === e) {
  8803.                                                         d20plus.tool.get("UNLOCKER").openFn();
  8804.                                                         i();
  8805.                                                 } else if ("lock-token" === e) {
  8806.                                                         d20.engine.selected().forEach(it => {
  8807.                                                                 if (it.model) {
  8808.                                                                         if (it.model.get("VeLocked")) {
  8809.                                                                                 it.lockMovementX = false;
  8810.                                                                                 it.lockMovementY = false;
  8811.                                                                                 it.lockScalingX = false;
  8812.                                                                                 it.lockScalingY = false;
  8813.                                                                                 it.lockRotation = false;
  8814.  
  8815.                                                                                 it.model.set("VeLocked", false);
  8816.                                                                         } else {
  8817.                                                                                 it.lockMovementX = true;
  8818.                                                                                 it.lockMovementY = true;
  8819.                                                                                 it.lockScalingX = true;
  8820.                                                                                 it.lockScalingY = true;
  8821.                                                                                 it.lockRotation = true;
  8822.  
  8823.                                                                                 it.model.set("VeLocked", true);
  8824.                                                                         }
  8825.                                                                         it.saveState();
  8826.                                                                         it.model.save();
  8827.                                                                 }
  8828.                                                         });
  8829.                                                         i();
  8830.                                                 } else if ("token-animate" === e) {
  8831.                                                         d20plus.anim.animatorTool.pSelectAnimation(lastContextSelection.lastAnimUid).then(animUid => {
  8832.                                                                 if (animUid == null) return;
  8833.  
  8834.                                                                 lastContextSelection.lastAnimUid = animUid;
  8835.                                                                 const selected = d20.engine.selected();
  8836.                                                                 d20.engine.unselect();
  8837.                                                                 selected.forEach(it => {
  8838.                                                                         if (it.model) {
  8839.                                                                                 d20plus.anim.animator.startAnimation(it.model, animUid)
  8840.                                                                         }
  8841.                                                                 });
  8842.                                                         });
  8843.                                                         i();
  8844.                                                 } else if ("util-scenes" === e) {
  8845.                                                         d20plus.anim.animatorTool.pSelectScene(lastContextSelection.lastSceneUid).then(sceneUid => {
  8846.                                                                 if (sceneUid == null) return;
  8847.  
  8848.                                                                 lastContextSelection.lastSceneUid = sceneUid;
  8849.                                                                 d20plus.anim.animatorTool.doStartScene(sceneUid);
  8850.                                                         });
  8851.                                                         i();
  8852.                                                 }
  8853.                                                 // END MOD
  8854.                                                 return !1
  8855.                                         }
  8856.                                 }),
  8857.                                 !1
  8858.                 };
  8859.                 // END ROLL20 CODE
  8860.  
  8861.                 function getRollableTokenUpdate (imgUrl, curSide) {
  8862.                         const m = /\?roll20_token_size=(.*)/.exec(imgUrl);
  8863.                         const toSave = {
  8864.                                 currentSide: curSide,
  8865.                                 imgsrc: imgUrl
  8866.                         };
  8867.                         if (m) {
  8868.                                 toSave.width = 70 * Number(m[1]);
  8869.                                 toSave.height = 70 * Number(m[1])
  8870.                         }
  8871.                         return toSave;
  8872.                 }
  8873.  
  8874.                 function resizeToken () {
  8875.                         const sel = d20.engine.selected();
  8876.  
  8877.                         const options = [["Tiny", 0.5], ["Small", 1], ["Medium", 1], ["Large", 2], ["Huge", 3], ["Gargantuan", 4], ["Colossal", 5]].map(it => `<option value='${it[1]}'>${it[0]}</option>`);
  8878.                         const dialog = $(`<div><p style='font-size: 1.15em;'><strong>${d20.utils.strip_tags("Select Size")}:</strong> <select style='width: 150px; margin-left: 5px;'>${options.join("")}</select></p></div>`);
  8879.                         dialog.dialog({
  8880.                                 title: "New Size",
  8881.                                 beforeClose: function () {
  8882.                                         return false;
  8883.                                 },
  8884.                                 buttons: {
  8885.                                         Submit: function () {
  8886.                                                 const size = dialog.find("select").val();
  8887.                                                 d20.engine.unselect();
  8888.                                                 sel.forEach(it => {
  8889.                                                         const nxtSize = size * 70;
  8890.                                                         const sides = it.model.get("sides");
  8891.                                                         if (sides) {
  8892.                                                                 const ueSides = unescape(sides);
  8893.                                                                 const cur = it.model.get("currentSide");
  8894.                                                                 const split = ueSides.split("|");
  8895.                                                                 if (split[cur].includes("roll20_token_size")) {
  8896.                                                                         split[cur] = split[cur].replace(/(\?roll20_token_size=).*/, `$1${size}`);
  8897.                                                                 } else {
  8898.                                                                         split[cur] += `?roll20_token_size=${size}`;
  8899.                                                                 }
  8900.                                                                 const toSaveSides = split.map(it => escape(it)).join("|");
  8901.                                                                 const toSave = {
  8902.                                                                         sides: toSaveSides,
  8903.                                                                         width: nxtSize,
  8904.                                                                         height: nxtSize
  8905.                                                                 };
  8906.                                                                 console.log(`Updating token:`, toSave);
  8907.                                                                 it.model.save(toSave);
  8908.                                                         } else {
  8909.                                                                 console.warn("Token had no side data!")
  8910.                                                         }
  8911.                                                 });
  8912.                                                 dialog.off();
  8913.                                                 dialog.dialog("destroy").remove();
  8914.                                                 d20.textchat.$textarea.focus();
  8915.                                         },
  8916.                                         Cancel: function () {
  8917.                                                 dialog.off();
  8918.                                                 dialog.dialog("destroy").remove();
  8919.                                         }
  8920.                                 }
  8921.                         });
  8922.                 }
  8923.  
  8924.                 d20.token_editor.showContextMenu = r;
  8925.                 d20.token_editor.closeContextMenu = i;
  8926.                 $(`#editor-wrapper`).on("click", d20.token_editor.closeContextMenu);
  8927.         };
  8928.  
  8929.         d20plus.engine._getSelectedToMove = () => {
  8930.                 const n = [];
  8931.                 for (var l = d20.engine.selected(), c = 0; c < l.length; c++)
  8932.                         n.push(l[c]);
  8933.         };
  8934.  
  8935.         d20plus.engine.forwardOneLayer = (n) => {
  8936.                 d20.engine.canvas.getActiveGroup() && d20.engine.unselect(),
  8937.                         _.each(n, function (e) {
  8938.                                 d20.engine.canvas.bringForward(e)
  8939.                         }),
  8940.                         d20.Campaign.activePage().debounced_recordZIndexes()
  8941.         };
  8942.  
  8943.         d20plus.engine.backwardOneLayer = (n) => {
  8944.                 d20.engine.canvas.getActiveGroup() && d20.engine.unselect(),
  8945.                         _.each(n, function (e) {
  8946.                                 d20.engine.canvas.sendBackwards(e)
  8947.                         }),
  8948.                         d20.Campaign.activePage().debounced_recordZIndexes()
  8949.         };
  8950.  
  8951.         d20plus.engine._tempTopRenderLines = {}, // format: {x: ..., y: ..., to_x: ..., to_y: ..., ticks: ..., offset: ...}
  8952.         // previously "enhanceSnap"
  8953.         d20plus.engine.enhanceMouseDown = () => {
  8954.                 /**
  8955.                  * Dumb variable names copy-pasted from uglified code
  8956.                  * @param c x co-ord
  8957.                  * @param u y c-ord
  8958.                  * @returns {*[]} 2-len array; [0] = x and [1] = y
  8959.                  */
  8960.                 function getClosestHexPoint (c, u) {
  8961.                         function getEuclidDist (x1, y1, x2, y2) {
  8962.                                 return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
  8963.                         }
  8964.  
  8965.                         const hx = d20.canvas_overlay.activeHexGrid.GetHexAt({
  8966.                                 X: c,
  8967.                                 Y: u
  8968.                         });
  8969.  
  8970.                         let minDist = 1000000;
  8971.                         let minPoint = [c, u];
  8972.  
  8973.                         function checkDist(x1, y1) {
  8974.                                 const dist = getEuclidDist(x1, y1, c, u);
  8975.                                 if (dist < minDist) {
  8976.                                         minDist =  dist;
  8977.                                         minPoint = [x1, y1];
  8978.                                 }
  8979.                         }
  8980.                         hx.Points.forEach(pt => {
  8981.                                 checkDist(pt.X, pt.Y);
  8982.                         });
  8983.                         checkDist(hx.MidPoint.X, hx.MidPoint.Y);
  8984.  
  8985.                         return minPoint;
  8986.                 }
  8987.  
  8988.                 // original roll20 mousedown code, minified as "T" (as of 2019-06-08)
  8989.                 // BEGIN ROLL20 CODE
  8990.                 var S = !1;
  8991.                 const T = function(e) {
  8992.                         // BEGIN MOD
  8993.                         var i = d20.engine.canvas;
  8994.                         var r = $("#editor-wrapper");
  8995.                         // END MOD
  8996.                         var n, o;
  8997.                         if (d20.tddice && d20.tddice.handleInteraction && d20.tddice.handleInteraction(),
  8998.                                 e.touches) {
  8999.                                 if ("pan" == d20.engine.mode)
  9000.                                         return;
  9001.                                 e.touches.length > 1 && (S = d20.engine.mode,
  9002.                                         d20.engine.mode = "pan",
  9003.                                         d20.engine.leftMouseIsDown = !0),
  9004.                                         d20.engine.lastTouchStarted = (new Date).getTime(),
  9005.                                         n = e.touches[0].pageX,
  9006.                                         o = e.touches[0].pageY,
  9007.                                         e.preventDefault()
  9008.                         } else
  9009.                                 n = e.pageX,
  9010.                                         o = e.pageY;
  9011.                         for (var a = d20.engine.showLastPaths.length; a--; )
  9012.                                 "selected" == d20.engine.showLastPaths[a].type && d20.engine.showLastPaths.splice(a, 1);
  9013.                         d20.engine.handleMetaKeys(e),
  9014.                         "select" != d20.engine.mode && "path" != d20.engine.mode || i.__onMouseDown(e),
  9015.                         (0 === e.button || e.touches && 1 == e.touches.length) && (d20.engine.leftMouseIsDown = !0),
  9016.                         2 === e.button && (d20.engine.rightMouseIsDown = !0);
  9017.                         var s = Math.floor(n / d20.engine.canvasZoom + d20.engine.currentCanvasOffset[0] - d20.engine.paddingOffset[0] / d20.engine.canvasZoom)
  9018.                                 , l = Math.floor(o / d20.engine.canvasZoom + d20.engine.currentCanvasOffset[1] - d20.engine.paddingOffset[1] / d20.engine.canvasZoom);
  9019.                         if (d20.engine.lastMousePos = [s, l],
  9020.                                 d20.engine.mousePos = [s, l],
  9021.                         !d20.engine.leftMouseIsDown || "fog-reveal" != d20.engine.mode && "fog-hide" != d20.engine.mode && "gridalign" != d20.engine.mode) {
  9022.                                 if (d20.engine.leftMouseIsDown && "fog-polygonreveal" == d20.engine.mode) {
  9023.                                         // BEGIN MOD
  9024.                                         var c = s, u = l;
  9025.  
  9026.                                         if (0 != d20.engine.snapTo && (e.shiftKey && !d20.Campaign.activePage().get("adv_fow_enabled") || !e.shiftKey && d20.Campaign.activePage().get("adv_fow_enabled"))) {
  9027.                                                 if ("square" == d20.Campaign.activePage().get("grid_type")) {
  9028.                                                         c = d20.engine.snapToIncrement(c, d20.engine.snapTo);
  9029.                                                         u = d20.engine.snapToIncrement(u, d20.engine.snapTo);
  9030.                                                 } else {
  9031.                                                         const minPoint = getClosestHexPoint(c, u);
  9032.                                                         c = minPoint[0];
  9033.                                                         u = minPoint[1];
  9034.                                                 }
  9035.                                         }
  9036.  
  9037.                                         d20.engine.fog.points.length > 0 && Math.abs(d20.engine.fog.points[0][0] - c) + Math.abs(d20.engine.fog.points[0][1] - u) < 15 ? (d20.engine.fog.points.push([d20.engine.fog.points[0][0], d20.engine.fog.points[0][1]]),
  9038.                                                 d20.engine.finishPolygonReveal()) : d20.engine.fog.points.push([c, u]),
  9039.                                                 d20.engine.redrawScreenNextTick(!0)
  9040.                                         // END MOD
  9041.                                 } else if (d20.engine.leftMouseIsDown && "measure" == d20.engine.mode)
  9042.                                         if (2 === e.button)
  9043.                                                 d20.engine.addWaypoint(e);
  9044.                                         else {
  9045.                                                 d20.engine.measure.sticky && d20.engine.endMeasure(),
  9046.                                                         d20.engine.measure.down[0] = s,
  9047.                                                         d20.engine.measure.down[1] = l,
  9048.                                                         d20.engine.measure.sticky = e.shiftKey;
  9049.                                                 let t = d20.Campaign.activePage().get("grid_type")
  9050.                                                         , n = "snap_center" === d20.engine.ruler_snapping && !e.altKey;
  9051.                                                 if (n |= "no_snap" === d20.engine.ruler_snapping && e.altKey,
  9052.                                                         n &= 0 !== d20.engine.snapTo)
  9053.                                                         if ("square" === t)
  9054.                                                                 d20.engine.measure.down[1] = d20.engine.snapToIncrement(d20.engine.measure.down[1] + Math.floor(d20.engine.snapTo / 2), d20.engine.snapTo) - Math.floor(d20.engine.snapTo / 2),
  9055.                                                                         d20.engine.measure.down[0] = d20.engine.snapToIncrement(d20.engine.measure.down[0] + Math.floor(d20.engine.snapTo / 2), d20.engine.snapTo) - Math.floor(d20.engine.snapTo / 2);
  9056.                                                         else {
  9057.                                                                 let e = d20.canvas_overlay.activeHexGrid.GetHexAt({
  9058.                                                                         X: d20.engine.measure.down[0],
  9059.                                                                         Y: d20.engine.measure.down[1]
  9060.                                                                 });
  9061.                                                                 e && (d20.engine.measure.down[1] = e.MidPoint.Y,
  9062.                                                                         d20.engine.measure.down[0] = e.MidPoint.X)
  9063.                                                         }
  9064.                                                 else if (0 === d20.engine.snapTo || "snap_corner" !== d20.engine.ruler_snapping || e.altKey)
  9065.                                                         d20.engine.measure.flags |= 1;
  9066.                                                 else {
  9067.                                                         if ("square" === t)
  9068.                                                                 d20.engine.measure.down[0] = d20.engine.snapToIncrement(d20.engine.measure.down[0], d20.engine.snapTo),
  9069.                                                                         d20.engine.measure.down[1] = d20.engine.snapToIncrement(d20.engine.measure.down[1], d20.engine.snapTo);
  9070.                                                         else {
  9071.                                                                 let e = d20.engine.snapToHexCorner([d20.engine.measure.down[0], d20.engine.measure.down[1]]);
  9072.                                                                 e && (d20.engine.measure.down[0] = e[0],
  9073.                                                                         d20.engine.measure.down[1] = e[1])
  9074.                                                         }
  9075.                                                         d20.engine.measure.flags |= 1
  9076.                                                 }
  9077.                                         }
  9078.                                 else if (d20.engine.leftMouseIsDown && "fxtools" == d20.engine.mode)
  9079.                                         d20.engine.fx.current || (d20.engine.fx.current = d20.fx.handleClick(s, l));
  9080.                                 else if (d20.engine.leftMouseIsDown && "text" == d20.engine.mode) {
  9081.                                         const e = {
  9082.                                                 fontFamily: $("#font-family").val(),
  9083.                                                 fontSize: $("#font-size").val(),
  9084.                                                 fill: $("#font-color").val(),
  9085.                                                 text: "",
  9086.                                                 left: s,
  9087.                                                 top: l
  9088.                                         }
  9089.                                                 , t = d20.Campaign.activePage().addText(e);
  9090.                                         $("body").on("mouseup.create_text_editor", ()=>{
  9091.                                                         $("body").off("mouseup.create_text_editor"),
  9092.                                                                 d20.engine.editText(t.view.graphic, e.top, e.left),
  9093.                                                                 $(".texteditor").focus()
  9094.                                                 }
  9095.                                         )
  9096.                                 } else if (d20.engine.leftMouseIsDown && "rect" == d20.engine.mode) {
  9097.                                         var p = parseInt($("#path_width").val(), 10)
  9098.                                                 , f = d20.engine.drawshape.shape = {
  9099.                                                 strokewidth: p,
  9100.                                                 x: 0,
  9101.                                                 y: 0,
  9102.                                                 width: 10,
  9103.                                                 height: 10,
  9104.                                                 type: e.altKey ? "circle" : "rect"
  9105.                                         };
  9106.                                         c = s,
  9107.                                                 u = l;
  9108.                                         0 != d20.engine.snapTo && e.shiftKey && (c = d20.engine.snapToIncrement(c, d20.engine.snapTo),
  9109.                                                 u = d20.engine.snapToIncrement(u, d20.engine.snapTo)),
  9110.                                                 f.x = c,
  9111.                                                 f.y = u,
  9112.                                                 f.fill = $("#path_fillcolor").val(),
  9113.                                                 f.stroke = $("#path_strokecolor").val(),
  9114.                                                 d20.engine.drawshape.start = [n + d20.engine.currentCanvasOffset[0] - d20.engine.paddingOffset[0], o + d20.engine.currentCanvasOffset[1] - d20.engine.paddingOffset[1]],
  9115.                                                 d20.engine.redrawScreenNextTick()
  9116.                                 } else if (d20.engine.leftMouseIsDown && "polygon" == d20.engine.mode) {
  9117.                                         if (d20.engine.drawshape.shape)
  9118.                                                 f = d20.engine.drawshape.shape;
  9119.                                         else {
  9120.                                                 p = parseInt($("#path_width").val(), 10);
  9121.                                                 (f = d20.engine.drawshape.shape = {
  9122.                                                         strokewidth: p,
  9123.                                                         points: [],
  9124.                                                         type: "polygon"
  9125.                                                 }).fill = $("#path_fillcolor").val(),
  9126.                                                         f.stroke = $("#path_strokecolor").val()
  9127.                                         }
  9128.                                         // BEGIN MOD
  9129.                                         c = s, u = l;
  9130.  
  9131.                                         if (0 != d20.engine.snapTo && e.shiftKey) {
  9132.                                                 if ("square" == d20.Campaign.activePage().get("grid_type")) {
  9133.                                                         c = d20.engine.snapToIncrement(c, d20.engine.snapTo);
  9134.                                                         u = d20.engine.snapToIncrement(u, d20.engine.snapTo);
  9135.                                                 } else {
  9136.                                                         const minPoint = getClosestHexPoint(c, u);
  9137.                                                         c = minPoint[0];
  9138.                                                         u = minPoint[1];
  9139.                                                 }
  9140.                                         }
  9141.  
  9142.                                         f.points.length > 0 && Math.abs(f.points[0][0] - c) + Math.abs(f.points[0][1] - u) < 15 ? (f.points.push([f.points[0][0], f.points[0][1]]),
  9143.                                                 d20.engine.finishCurrentPolygon()) : f.points.push([c, u]),
  9144.                                                 d20.engine.redrawScreenNextTick()
  9145.                                         // END MOD
  9146.                                 } else if (d20.engine.leftMouseIsDown && "targeting" === d20.engine.mode) {
  9147.                                         var g = d20.engine.canvas.findTarget(e, !0, !0);
  9148.                                         return void (g !== undefined && "image" === g.type && g.model && d20.engine.nextTargetCallback(g))
  9149.                                 }
  9150.                                 // BEGIN MOD
  9151.                                 else if (d20.engine.leftMouseIsDown && "line_splitter" === d20.engine.mode) {
  9152.                                         const lastPoint = {x: d20.engine.lastMousePos[0], y: d20.engine.lastMousePos[1]};
  9153.                                         (d20.engine.canvas._objects || []).forEach(o => {
  9154.                                                 if (o.type === "path" && o.containsPoint(lastPoint)) {
  9155.                                                         const asObj = o.toObject();
  9156.                                                         const anyCurves = asObj.path.filter(it => it instanceof Array && it.length > 0  && it[0] === "C");
  9157.                                                         if (!anyCurves.length) {
  9158.                                                                 // PathMath expects these
  9159.                                                                 o.model.set("_pageid", d20.Campaign.activePage().get("id"));
  9160.                                                                 o.model.set("_path", JSON.stringify(o.path));
  9161.  
  9162.                                                                 console.log("SPLITTING PATH: ", o.model.toJSON());
  9163.                                                                 const mainPath = o.model;
  9164.  
  9165.                                                                 // BEGIN PathSplitter CODE
  9166.                                                                 let mainSegments = PathMath.toSegments(mainPath);
  9167.                                                                 // BEGIN MOD
  9168.                                                                 // fake a tiny diagonal line
  9169.                                                                 const SLICE_LEN = 10;
  9170.                                                                 const slicePoint1 = [lastPoint.x + (SLICE_LEN / 2), lastPoint.y + (SLICE_LEN / 2), 1];
  9171.                                                                 const slicePoint2 = [lastPoint.x - (SLICE_LEN / 2), lastPoint.y - (SLICE_LEN / 2), 1];
  9172.                                                                 const nuId = d20plus.ut.generateRowId();
  9173.                                                                 d20plus.engine._tempTopRenderLines[nuId] = {
  9174.                                                                         ticks: 2,
  9175.                                                                         x: slicePoint1[0],
  9176.                                                                         y: slicePoint1[1],
  9177.                                                                         to_x: slicePoint2[0],
  9178.                                                                         to_y: slicePoint2[1],
  9179.                                                                         offset: [...d20.engine.currentCanvasOffset]
  9180.                                                                 };
  9181.                                                                 setTimeout(() => {
  9182.                                                                         d20.engine.redrawScreenNextTick();
  9183.                                                                 }, 1);
  9184.                                                                 let splitSegments = [
  9185.                                                                         [slicePoint1, slicePoint2]
  9186.                                                                 ];
  9187.                                                                 // END MOD
  9188.                                                                 let segmentPaths = _getSplitSegmentPaths(mainSegments, splitSegments);
  9189.  
  9190.                                                                 // (function moved into this scope)
  9191.                                                                 function _getSplitSegmentPaths(mainSegments, splitSegments) {
  9192.                                                                         let resultSegPaths = [];
  9193.                                                                         let curPathSegs = [];
  9194.  
  9195.                                                                         _.each(mainSegments, seg1 => {
  9196.  
  9197.                                                                                 // Find the points of intersection and their parametric coefficients.
  9198.                                                                                 let intersections = [];
  9199.                                                                                 _.each(splitSegments, seg2 => {
  9200.                                                                                         let i = PathMath.segmentIntersection(seg1, seg2);
  9201.                                                                                         if(i) intersections.push(i);
  9202.                                                                                 });
  9203.  
  9204.                                                                                 if(intersections.length > 0) {
  9205.                                                                                         // Sort the intersections in the order that they appear along seg1.
  9206.                                                                                         intersections.sort((a, b) => {
  9207.                                                                                                 return a[1] - b[1];
  9208.                                                                                         });
  9209.  
  9210.                                                                                         let lastPt = seg1[0];
  9211.                                                                                         _.each(intersections, i => {
  9212.                                                                                                 // Complete the current segment path.
  9213.                                                                                                 curPathSegs.push([lastPt, i[0]]);
  9214.                                                                                                 resultSegPaths.push(curPathSegs);
  9215.  
  9216.                                                                                                 // Start a new segment path.
  9217.                                                                                                 curPathSegs = [];
  9218.                                                                                                 lastPt = i[0];
  9219.                                                                                         });
  9220.                                                                                         curPathSegs.push([lastPt, seg1[1]]);
  9221.                                                                                 }
  9222.                                                                                 else {
  9223.                                                                                         curPathSegs.push(seg1);
  9224.                                                                                 }
  9225.                                                                         });
  9226.                                                                         resultSegPaths.push(curPathSegs);
  9227.  
  9228.                                                                         return resultSegPaths;
  9229.                                                                 };
  9230.                                                                 // (end function moved into this scope)
  9231.  
  9232.                                                                 // Convert the list of segment paths into paths.
  9233.                                                                 let _pageid = mainPath.get('_pageid');
  9234.                                                                 let controlledby = mainPath.get('controlledby');
  9235.                                                                 let fill = mainPath.get('fill');
  9236.                                                                 let layer = mainPath.get('layer');
  9237.                                                                 let stroke = mainPath.get('stroke');
  9238.                                                                 let stroke_width = mainPath.get('stroke_width');
  9239.  
  9240.                                                                 let results = [];
  9241.                                                                 _.each(segmentPaths, segments => {
  9242.                                                                         // BEGIN MOD
  9243.                                                                         if (!segments) {
  9244.                                                                                 d20plus.chatLog(`A path had no segments! This is probably a bug. Please report it.`);
  9245.                                                                                 return;
  9246.                                                                         }
  9247.                                                                         // END MOD
  9248.  
  9249.                                                                         let pathData = PathMath.segmentsToPath(segments);
  9250.                                                                         _.extend(pathData, {
  9251.                                                                                 _pageid,
  9252.                                                                                 controlledby,
  9253.                                                                                 fill,
  9254.                                                                                 layer,
  9255.                                                                                 stroke,
  9256.                                                                                 stroke_width
  9257.                                                                         });
  9258.                                                                         let path = createObj('path', pathData);
  9259.                                                                         results.push(path);
  9260.                                                                 });
  9261.  
  9262.                                                                 // Remove the original path and the splitPath.
  9263.                                                                 // BEGIN MOD
  9264.                                                                 mainPath.destroy();
  9265.                                                                 // END MOD
  9266.                                                                 // END PathSplitter CODE
  9267.                                                         }
  9268.                                                 }
  9269.                                         });
  9270.                                 }
  9271.                                 // END MOD
  9272.                         } else
  9273.                                 d20.engine.fog.down[0] = s,
  9274.                                         d20.engine.fog.down[1] = l,
  9275.                                 0 != d20.engine.snapTo && "square" == d20.Campaign.activePage().get("grid_type") && ("gridalign" == d20.engine.mode ? e.shiftKey && (d20.engine.fog.down[0] = d20.engine.snapToIncrement(d20.engine.fog.down[0], d20.engine.snapTo),
  9276.                                         d20.engine.fog.down[1] = d20.engine.snapToIncrement(d20.engine.fog.down[1], d20.engine.snapTo)) : (e.shiftKey && !d20.Campaign.activePage().get("adv_fow_enabled") || !e.shiftKey && d20.Campaign.activePage().get("adv_fow_enabled")) && (d20.engine.fog.down[0] = d20.engine.snapToIncrement(d20.engine.fog.down[0], d20.engine.snapTo),
  9277.                                         d20.engine.fog.down[1] = d20.engine.snapToIncrement(d20.engine.fog.down[1], d20.engine.snapTo)));
  9278.                         if (window.currentPlayer && d20.engine.leftMouseIsDown && "select" == d20.engine.mode) {
  9279.                                 if (2 === e.button && d20.engine.addWaypoint(e),
  9280.                                 d20.engine.pings[window.currentPlayer.id] && d20.engine.pings[window.currentPlayer.id].radius > 20)
  9281.                                         return;
  9282.                                 var m = {
  9283.                                         left: s,
  9284.                                         top: l,
  9285.                                         radius: -5,
  9286.                                         player: window.currentPlayer.id,
  9287.                                         pageid: d20.Campaign.activePage().id,
  9288.                                         currentLayer: window.currentEditingLayer
  9289.                                 };
  9290.                                 window.is_gm && e.shiftKey && (m.scrollto = !0),
  9291.                                         d20.engine.pings[window.currentPlayer.id] = m,
  9292.                                         d20.engine.pinging = {
  9293.                                                 downx: n,
  9294.                                                 downy: o
  9295.                                         },
  9296.                                         d20.engine.redrawScreenNextTick(!0)
  9297.                         }
  9298.                         d20.engine.rightMouseIsDown && ("select" == d20.engine.mode || "path" == d20.engine.mode || "text" == d20.engine.mode) || d20.engine.leftMouseIsDown && "pan" == d20.engine.mode ? (d20.engine.pan.beginPos = [r.scrollLeft(), r.scrollTop()],
  9299.                                 d20.engine.pan.panXY = [n, o],
  9300.                                 d20.engine.pan.panning = !0) : d20.engine.pan.panning = !1,
  9301.                         2 === e.button && !d20.engine.leftMouseIsDown && d20.engine.measurements[window.currentPlayer.id] && d20.engine.measurements[window.currentPlayer.id].sticky && (d20.engine.endMeasure(),
  9302.                                 d20.engine.announceEndMeasure({
  9303.                                         player: window.currentPlayer.id
  9304.                                 })),
  9305.                                 // BEGIN MOD
  9306.                         $(`#finalcanvas`).hasClass("hasfocus") || $(`#finalcanvas`).focus()
  9307.                         // END MOD
  9308.                 };
  9309.                 // END ROLL20 CODE
  9310.  
  9311.                 if (FINAL_CANVAS_MOUSEDOWN_LIST.length) {
  9312.                         FINAL_CANVAS_MOUSEDOWN = (FINAL_CANVAS_MOUSEDOWN_LIST.find(it => it.on === d20.engine.final_canvas) || {}).listener;
  9313.                 }
  9314.  
  9315.                 if (FINAL_CANVAS_MOUSEDOWN) {
  9316.                         d20plus.ut.log("Enhancing hex snap");
  9317.                         d20.engine.final_canvas.removeEventListener("mousedown", FINAL_CANVAS_MOUSEDOWN);
  9318.                         d20.engine.final_canvas.addEventListener("mousedown", T);
  9319.                 }
  9320.  
  9321.                 // add sub-grid snap
  9322.                 d20.engine.snapToIncrement = function(e, t) {
  9323.                         t *= Number(d20plus.cfg.getOrDefault("canvas", "gridSnap"));
  9324.                         return t * Math.round(e / t);
  9325.                 }
  9326.         };
  9327.  
  9328.         d20plus.engine.enhanceMouseUp = () => { // P
  9329.  
  9330.         };
  9331.  
  9332.         d20plus.engine.enhanceMouseMove = () => {
  9333.                 // needs to be called after `enhanceMeasureTool()`
  9334.                 const $selMeasureMode = $(`#measure_mode`);
  9335.                 const $selRadMode = $(`#measure_mode_sel_2`);
  9336.                 const $iptConeWidth = $(`#measure_mode_ipt_3`);
  9337.                 const $selConeMode = $(`#measure_mode_sel_3`);
  9338.                 const $selBoxMode = $(`#measure_mode_sel_4`);
  9339.                 const $selLineMode = $(`#measure_mode_sel_5`);
  9340.                 const $iptLineWidth = $(`#measure_mode_ipt_5`);
  9341.  
  9342.                 // BEGIN ROLL20 CODE
  9343.                 // not used?
  9344.                 var x = function(e) {
  9345.                         e.type = "measuring",
  9346.                                 e.time = (new Date).getTime(),
  9347.                                 d20.textchat.sendShout(e)
  9348.                 }
  9349.                         , k = _.throttle(x, 200)
  9350.                         , E = function(e) {
  9351.                         k(e),
  9352.                         d20.tutorial && d20.tutorial.active && $(document.body).trigger("measure"),
  9353.                                 d20.engine.receiveMeasureUpdate(e)
  9354.                 };
  9355.                 // END ROLL20 CODE
  9356.  
  9357.                 // add missing vars
  9358.                 var i = d20.engine.canvas;
  9359.                 var r = $("#editor-wrapper");
  9360.  
  9361.                 // Roll20 bug (present as of 2019-5-25) workaround
  9362.                 //   when box-selecting + moving tokens, the "object:moving" event throws an exception
  9363.                 //   try-catch-ignore this, because it's extremely annoying
  9364.                 const cachedFire = i.fire.bind(i);
  9365.                 i.fire = function (namespace, opts) {
  9366.                         if (namespace === "object:moving") {
  9367.                                 try {
  9368.                                         cachedFire(namespace, opts);
  9369.                                 } catch (e) {}
  9370.                         } else {
  9371.                                 cachedFire(namespace, opts);
  9372.                         }
  9373.                 };
  9374.  
  9375.                 // mousemove handler from Roll20 @ 2019-06-04
  9376.                 // BEGIN ROLL20 CODE
  9377.                 const A = function(e) {
  9378.                         var t, n;
  9379.                         if (e.changedTouches ? ((e.changedTouches.length > 1 || "pan" == d20.engine.mode) && (delete d20.engine.pings[window.currentPlayer.id],
  9380.                                 d20.engine.pinging = !1),
  9381.                                 e.preventDefault(),
  9382.                                 t = e.changedTouches[0].pageX,
  9383.                                 n = e.changedTouches[0].pageY) : (t = e.pageX,
  9384.                                 n = e.pageY),
  9385.                         "select" != d20.engine.mode && "path" != d20.engine.mode && "targeting" != d20.engine.mode || i.__onMouseMove(e),
  9386.                         d20.engine.leftMouseIsDown || d20.engine.rightMouseIsDown) {
  9387.                                 var o = Math.floor(t / d20.engine.canvasZoom + d20.engine.currentCanvasOffset[0] - d20.engine.paddingOffset[0] / d20.engine.canvasZoom)
  9388.                                         , a = Math.floor(n / d20.engine.canvasZoom + d20.engine.currentCanvasOffset[1] - d20.engine.paddingOffset[1] / d20.engine.canvasZoom);
  9389.                                 if (d20.engine.mousePos = [o, a],
  9390.                                 !d20.engine.leftMouseIsDown || "fog-reveal" != d20.engine.mode && "fog-hide" != d20.engine.mode && "gridalign" != d20.engine.mode) {
  9391.                                         if (d20.engine.leftMouseIsDown && "measure" == d20.engine.mode && d20.engine.measure.down[0] !== undefined && d20.engine.measure.down[1] !== undefined) {
  9392.                                                 d20.engine.measure.down[2] = o,
  9393.                                                         d20.engine.measure.down[3] = a,
  9394.                                                         d20.engine.measure.sticky |= e.shiftKey;
  9395.                                                 let t = d20.Campaign.activePage().get("grid_type")
  9396.                                                         , n = "snap_corner" === d20.engine.ruler_snapping && !e.altKey && 0 !== d20.engine.snapTo
  9397.                                                         , i = "snap_center" === d20.engine.ruler_snapping && !e.altKey;
  9398.                                                 if (i |= "no_snap" === d20.engine.ruler_snapping && e.altKey,
  9399.                                                         i &= 0 !== d20.engine.snapTo) {
  9400.                                                         if ("square" === t)
  9401.                                                                 d20.engine.measure.down[2] = d20.engine.snapToIncrement(d20.engine.measure.down[2] + Math.floor(d20.engine.snapTo / 2), d20.engine.snapTo) - Math.floor(d20.engine.snapTo / 2),
  9402.                                                                         d20.engine.measure.down[3] = d20.engine.snapToIncrement(d20.engine.measure.down[3] + Math.floor(d20.engine.snapTo / 2), d20.engine.snapTo) - Math.floor(d20.engine.snapTo / 2);
  9403.                                                         else {
  9404.                                                                 let e = d20.canvas_overlay.activeHexGrid.GetHexAt({
  9405.                                                                         X: d20.engine.measure.down[2],
  9406.                                                                         Y: d20.engine.measure.down[3]
  9407.                                                                 });
  9408.                                                                 e && (d20.engine.measure.down[3] = e.MidPoint.Y,
  9409.                                                                         d20.engine.measure.down[2] = e.MidPoint.X)
  9410.                                                         }
  9411.                                                         d20.engine.measure.flags &= -3
  9412.                                                 } else if (n) {
  9413.                                                         if ("square" === t)
  9414.                                                                 d20.engine.measure.down[2] = d20.engine.snapToIncrement(d20.engine.measure.down[2], d20.engine.snapTo),
  9415.                                                                         d20.engine.measure.down[3] = d20.engine.snapToIncrement(d20.engine.measure.down[3], d20.engine.snapTo);
  9416.                                                         else {
  9417.                                                                 let e = d20.engine.snapToHexCorner([d20.engine.measure.down[2], d20.engine.measure.down[3]]);
  9418.                                                                 e && (d20.engine.measure.down[2] = e[0],
  9419.                                                                         d20.engine.measure.down[3] = e[1])
  9420.                                                         }
  9421.                                                         d20.engine.measure.flags |= 2
  9422.                                                 } else
  9423.                                                         d20.engine.measure.flags |= 2;
  9424.                                                 var s = {
  9425.                                                         x: d20.engine.measure.down[0],
  9426.                                                         y: d20.engine.measure.down[1],
  9427.                                                         to_x: d20.engine.measure.down[2],
  9428.                                                         to_y: d20.engine.measure.down[3],
  9429.                                                         player: window.currentPlayer.id,
  9430.                                                         pageid: d20.Campaign.activePage().id,
  9431.                                                         currentLayer: window.currentEditingLayer,
  9432.                                                         waypoints: d20.engine.measure.waypoints,
  9433.                                                         sticky: d20.engine.measure.sticky,
  9434.                                                         flags: d20.engine.measure.flags,
  9435.                                                         hide: d20.engine.measure.hide
  9436.                                                         // BEGIN MOD
  9437.                                                         ,
  9438.                                                         Ve: {
  9439.                                                                 mode: $selMeasureMode.val(),
  9440.                                                                 radius: {
  9441.                                                                         mode: $selRadMode.val()
  9442.                                                                 },
  9443.                                                                 cone: {
  9444.                                                                         arc: $iptConeWidth.val(),
  9445.                                                                         mode: $selConeMode.val()
  9446.                                                                 },
  9447.                                                                 box: {
  9448.                                                                         mode: $selBoxMode.val(),
  9449.                                                                 },
  9450.                                                                 line: {
  9451.                                                                         mode: $selLineMode.val(),
  9452.                                                                         width: $iptLineWidth.val()
  9453.                                                                 }
  9454.                                                         }
  9455.                                                         // END MOD
  9456.                                                 };
  9457.                                                 d20.engine.announceMeasure(s)
  9458.                                         } else if (d20.engine.leftMouseIsDown && "fxtools" == d20.engine.mode) {
  9459.                                                 if (d20.engine.fx.current) {
  9460.                                                         var l = (new Date).getTime();
  9461.                                                         l - d20.engine.fx.lastMoveBroadcast > d20.engine.fx.MOVE_BROADCAST_FREQ ? (d20.fx.moveFx(d20.engine.fx.current, o, a),
  9462.                                                                 d20.engine.fx.lastMoveBroadcast = l) : d20.fx.moveFx(d20.engine.fx.current, o, a, !0)
  9463.                                                 }
  9464.                                         } else if (d20.engine.leftMouseIsDown && "rect" == d20.engine.mode) {
  9465.                                                 var c = (t + d20.engine.currentCanvasOffset[0] - d20.engine.paddingOffset[0] - d20.engine.drawshape.start[0]) / d20.engine.canvasZoom
  9466.                                                         , u = (n + d20.engine.currentCanvasOffset[1] - d20.engine.paddingOffset[1] - d20.engine.drawshape.start[1]) / d20.engine.canvasZoom;
  9467.                                                 0 != d20.engine.snapTo && e.shiftKey && (c = d20.engine.snapToIncrement(c, d20.engine.snapTo),
  9468.                                                         u = d20.engine.snapToIncrement(u, d20.engine.snapTo));
  9469.                                                 var d = d20.engine.drawshape.shape;
  9470.                                                 d.width = c,
  9471.                                                         d.height = u,
  9472.                                                         d20.engine.redrawScreenNextTick()
  9473.                                         }
  9474.                                 } else
  9475.                                         d20.engine.fog.down[2] = o,
  9476.                                                 d20.engine.fog.down[3] = a,
  9477.                                         0 != d20.engine.snapTo && "square" == d20.Campaign.activePage().get("grid_type") && ("gridalign" == d20.engine.mode ? e.shiftKey && (d20.engine.fog.down[2] = d20.engine.snapToIncrement(d20.engine.fog.down[2], d20.engine.snapTo),
  9478.                                                 d20.engine.fog.down[3] = d20.engine.snapToIncrement(d20.engine.fog.down[3], d20.engine.snapTo)) : (e.shiftKey && !d20.Campaign.activePage().get("adv_fow_enabled") || !e.shiftKey && d20.Campaign.activePage().get("adv_fow_enabled")) && (d20.engine.fog.down[2] = d20.engine.snapToIncrement(d20.engine.fog.down[2], d20.engine.snapTo),
  9479.                                                 d20.engine.fog.down[3] = d20.engine.snapToIncrement(d20.engine.fog.down[3], d20.engine.snapTo))),
  9480.                                                 d20.Campaign.activePage().get("showdarkness") ? d20.engine.redrawScreenNextTick(!0) : d20.engine.clearCanvasOnRedraw("fog");
  9481.                                 if (d20.engine.pinging)
  9482.                                         (c = Math.abs(d20.engine.pinging.downx - t)) + (u = Math.abs(d20.engine.pinging.downy - n)) > 10 && (delete d20.engine.pings[window.currentPlayer.id],
  9483.                                                 d20.engine.pinging = !1);
  9484.                                 if (d20.engine.pan.panning) {
  9485.                                         c = 2 * (t - d20.engine.pan.panXY[0]),
  9486.                                                 u = 2 * (n - d20.engine.pan.panXY[1]);
  9487.                                         if (d20.engine.pan.lastPanDist += Math.abs(c) + Math.abs(u),
  9488.                                         d20.engine.pan.lastPanDist < 10)
  9489.                                                 return;
  9490.                                         var h = d20.engine.pan.beginPos[0] - c
  9491.                                                 , p = d20.engine.pan.beginPos[1] - u;
  9492.                                         r.stop().animate({
  9493.                                                 scrollLeft: h,
  9494.                                                 scrollTop: p
  9495.                                         }, {
  9496.                                                 duration: 1500,
  9497.                                                 easing: "easeOutExpo",
  9498.                                                 queue: !1
  9499.                                         })
  9500.                                 }
  9501.                         }
  9502.                 };
  9503.                 // END ROLL20 CODE
  9504.  
  9505.                 if (FINAL_CANVAS_MOUSEMOVE_LIST.length) {
  9506.                         FINAL_CANVAS_MOUSEMOVE = (FINAL_CANVAS_MOUSEMOVE_LIST.find(it => it.on === d20.engine.final_canvas) || {}).listener;
  9507.                 }
  9508.  
  9509.                 if (FINAL_CANVAS_MOUSEMOVE) {
  9510.                         d20plus.ut.log("Enhancing mouse move");
  9511.                         d20.engine.final_canvas.removeEventListener("mousemove", FINAL_CANVAS_MOUSEMOVE);
  9512.                         d20.engine.final_canvas.addEventListener("mousemove", A);
  9513.                 }
  9514.         };
  9515.  
  9516.         d20plus.engine.addLineCutterTool = () => {
  9517.                 const $btnTextTool = $(`.choosetext`);
  9518.  
  9519.                 const $btnSplitTool = $(`<li class="choosesplitter">✂️ Line Splitter</li>`).click(() => {
  9520.                         d20plus.setMode("line_splitter");
  9521.                 });
  9522.  
  9523.                 $btnTextTool.after($btnSplitTool);
  9524.         };
  9525.  
  9526.         d20plus.engine._tokenHover = null;
  9527.         d20plus.engine._drawTokenHover = () => {
  9528.                 $(`.Vetools-token-hover`).remove();
  9529.                 if (!d20plus.engine._tokenHover || !d20plus.engine._tokenHover.text) return;
  9530.  
  9531.                 const pt = d20plus.engine._tokenHover.pt;
  9532.                 const txt = unescape(d20plus.engine._tokenHover.text);
  9533.  
  9534.                 $(`body`).append(`<div class="Vetools-token-hover" style="top: ${pt.y * d20.engine.canvasZoom}px; left: ${pt.x * d20.engine.canvasZoom}px">${txt}</div>`);
  9535.         };
  9536.         d20plus.engine.addTokenHover = () => {
  9537.                 // gm notes on shift-hover
  9538.                 const cacheRenderLoop = d20.engine.renderLoop;
  9539.                 d20.engine.renderLoop = () => {
  9540.                         d20plus.engine._drawTokenHover();
  9541.                         cacheRenderLoop();
  9542.                 };
  9543.  
  9544.                 // store data for the rendering function to access
  9545.                 d20.engine.canvas.on("mouse:move", (data, ...others) => {
  9546.                         // enable hover from GM layer -> token layer
  9547.                         let hoverTarget = data.target;
  9548.                         if (data.e && window.currentEditingLayer === "gmlayer") {
  9549.                                 const cache = window.currentEditingLayer;
  9550.                                 window.currentEditingLayer = "objects";
  9551.                                 hoverTarget = d20.engine.canvas.findTarget(data.e, null, true);
  9552.                                 window.currentEditingLayer = cache;
  9553.                         }
  9554.  
  9555.                         if (data.e.shiftKey && hoverTarget && hoverTarget.model) {
  9556.                                 d20.engine.redrawScreenNextTick();
  9557.                                 const gmNotes = hoverTarget.model.get("gmnotes");
  9558.                                 const pt = d20.engine.canvas.getPointer(data.e);
  9559.                                 pt.x -= d20.engine.currentCanvasOffset[0];
  9560.                                 pt.y -= d20.engine.currentCanvasOffset[1];
  9561.                                 d20plus.engine._tokenHover = {
  9562.                                         pt: pt,
  9563.                                         text: gmNotes,
  9564.                                         id: hoverTarget.model.id
  9565.                                 };
  9566.                         } else {
  9567.                                 if (d20plus.engine._tokenHover) d20.engine.redrawScreenNextTick();
  9568.                                 d20plus.engine._tokenHover = null;
  9569.                         }
  9570.                 })
  9571.         };
  9572.  
  9573.         d20plus.engine.enhanceMarkdown = () => {
  9574.                 const OUT_STRIKE = "<span style='text-decoration: line-through'>$1</span>";
  9575.  
  9576.                 // BEGIN ROLL20 CODE
  9577.                 window.Markdown.parse = function(e) {
  9578.                         {
  9579.                                 var t = e
  9580.                                         , n = []
  9581.                                         , i = [];
  9582.                                 -1 != t.indexOf("\r\n") ? "\r\n" : -1 != t.indexOf("\n") ? "\n" : ""
  9583.                         }
  9584.                         return t = t.replace(/{{{([\s\S]*?)}}}/g, function(e) {
  9585.                                 return n.push(e.substring(3, e.length - 3)),
  9586.                                         "{{{}}}"
  9587.                         }),
  9588.                                 t = t.replace(new RegExp("<pre>([\\s\\S]*?)</pre>","gi"), function(e) {
  9589.                                         return i.push(e.substring(5, e.length - 6)),
  9590.                                                 "<pre></pre>"
  9591.                                 }),
  9592.                                 // BEGIN MOD
  9593.                                 t = t.replace(/~~(.*?)~~/g, OUT_STRIKE),
  9594.                                 // END MOD
  9595.                                 t = t.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>"),
  9596.                                 t = t.replace(/\*(.*?)\*/g, "<em>$1</em>"),
  9597.                                 t = t.replace(/``(.*?)``/g, "<code>$1</code>"),
  9598.                                 t = t.replace(/\[([^\]]+)\]\(([^)]+(\.png|\.gif|\.jpg|\.jpeg))\)/g, '<a href="$2"><img src="$2" alt="$1" /></a>'),
  9599.                                 t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>'),
  9600.                                 t = t.replace(new RegExp("<pre></pre>","g"), function() {
  9601.                                         return "<pre>" + i.shift() + "</pre>"
  9602.                                 }),
  9603.                                 t = t.replace(/{{{}}}/g, function() {
  9604.                                         return n.shift()
  9605.                                 })
  9606.                 };
  9607.                 // END ROLL20 CODE
  9608.  
  9609.                 // after a short delay, replace any old content in the chat
  9610.                 setTimeout(() => {
  9611.                         $(`.message`).each(function () {
  9612.                                 $(this).html($(this).html().replace(/~~(.*?)~~/g, OUT_STRIKE))
  9613.                         })
  9614.                 }, 2500);
  9615.         };
  9616.  
  9617.         d20plus.engine.enhancePathWidths = () => {
  9618.                 const $selThicc = $(`#path_width`).css("width", "150px");
  9619.                 $selThicc.append(`
  9620.                                 <option value="5">Custom 1 (5 px.)</option>
  9621.                                 <option value="5">Custom 2 (5 px.)</option>
  9622.                                 <option value="5">Custom 3 (5 px.)</option>
  9623.                         `);
  9624.                 const $iptThicc = $(`<input type="number" style="max-width: 50px;">`).hide();
  9625.                 const $lblPixels = $(`<label style="display: inline-flex;"> pixels</label>`).hide();
  9626.                 $selThicc.after($lblPixels).after($iptThicc);
  9627.  
  9628.                 let $selOpt = null;
  9629.                 $selThicc.on("change", () => {
  9630.                         $selOpt = $selThicc.find(`option:selected`);
  9631.                         const txt = $selOpt.text();
  9632.                         if (txt.startsWith("Custom")) {
  9633.                                 const thicc = /\((.*?) px\.\)/.exec(txt)[1];
  9634.                                 $lblPixels.show();
  9635.                                 $iptThicc.show().val(Number(thicc));
  9636.                         } else {
  9637.                                 $lblPixels.hide();
  9638.                                 $iptThicc.hide();
  9639.                         }
  9640.                 });
  9641.  
  9642.                 $iptThicc.on("keyup", () => {
  9643.                         if (!$selOpt) $selOpt = $selThicc.find(`option:selected`);
  9644.                         if ($selOpt) {
  9645.                                 const clean = Math.round(Math.max(1, Number($iptThicc.val())));
  9646.                                 $selOpt.val(`${clean}`);
  9647.                                 $selOpt.text($selOpt.text().replace(/\(\d+ px\.\)/, `(${clean} px.)`));
  9648.                                 d20.engine.canvas.freeDrawingBrush.width = clean;
  9649.                         }
  9650.                 });
  9651.         };
  9652.  
  9653.         d20plus.engine.enhanceTransmogrifier = () => {
  9654.                 JqueryUtil.addSelectors();
  9655.  
  9656.                 $("#transmogrifier").on("click", () => {
  9657.                         setTimeout(() => {
  9658.                                 const $btnAlpha = $(`#vetools-transmog-alpha`);
  9659.                                 if (!$btnAlpha.length) {
  9660.                                         const $prependTarget = $(`.ui-dialog-title:textEquals(transmogrifier)`).first().parent().parent().find(`.ui-dialog-content`);
  9661.                                         $(`<button id="#vetools-transmog-alpha" class="btn btn default" style="margin-bottom: 5px;">Sort Items Alphabetically</button>`).on("click", () => {
  9662.                                                 // coped from a bookmarklet
  9663.                                                 $('iframe').contents().find('.objects').each((c,e)=>{ let $e=$(e); $e.children().sort( (a,b)=>{ let name1=$(a).find(".name").text().toLowerCase(), name2=$(b).find(".name").text().toLowerCase(), comp = name1.localeCompare(name2); return comp; }) .each((i,c)=>$e.append(c)); });
  9664.                                         }).prependTo($prependTarget);
  9665.                                 }
  9666.                         }, 35);
  9667.                 })
  9668.         };
  9669.  
  9670.         d20plus.engine.addLayers = () => {
  9671.                 d20plus.ut.log("Adding layers");
  9672.  
  9673.                 d20plus.mod.editingLayerOnclick();
  9674.                 if (window.is_gm) {
  9675.                         $(`#floatingtoolbar .choosemap`).html(`<span class="pictos" style="padding: 0 3px 0 3px;">@</span> Map`);
  9676.                         $(`#floatingtoolbar .choosemap`).after(`
  9677.                                 <li class="choosebackground">
  9678.                                         <span class="pictos">a</span>
  9679.                                         Background
  9680.                                 </li>
  9681.                         `);
  9682.                         $(`#floatingtoolbar .chooseobjects`).after(`
  9683.                                 <li class="chooseforeground">
  9684.                                         <span class="pictos">B</span>
  9685.                                         Foreground
  9686.                                 </li>
  9687.                         `);
  9688.                         $(`#floatingtoolbar .choosewalls`).after(`
  9689.                                 <li class="chooseweather">
  9690.                                         <span class="pictos">C</span>
  9691.                                         Weather Exclusions
  9692.                                 </li>
  9693.                         `);
  9694.                 }
  9695.  
  9696.                 d20.engine.canvas._renderAll = _.bind(d20plus.mod.renderAll, d20.engine.canvas);
  9697.                 d20.engine.canvas._layerIteratorGenerator = d20plus.mod.layerIteratorGenerator;
  9698.         };
  9699.  
  9700.         d20plus.engine.removeLinkConfirmation = function () {
  9701.                 d20.utils.handleURL = d20plus.mod.handleURL;
  9702.                 $(document).off("click", "a").on("click", "a", d20.utils.handleURL);
  9703.         };
  9704.  
  9705.         d20plus.engine.repairPrototypeMethods = function () {
  9706.                 d20plus.mod.fixHexMethods();
  9707.                 d20plus.mod.fixVideoMethods();
  9708.         };
  9709.  
  9710.         d20plus.engine.disableFrameRecorder = function () {
  9711.                 if (d20.engine.frame_recorder) {
  9712.                         d20.engine.frame_recorder.active = false;
  9713.                         d20.engine.frame_recorder._active = false;
  9714.                 }
  9715.         };
  9716. }
  9717.  
  9718. SCRIPT_EXTENSIONS.push(d20plusEngine);
  9719.  
  9720.  
  9721. function baseWeather () {
  9722.         d20plus.weather = {};
  9723.  
  9724.         d20plus.weather._lastSettingsPageId = null;
  9725.         d20plus.weather._initSettingsButton = () => {
  9726.                 $(`body`).on("click", ".Ve-btn-weather", function () {
  9727.                         // close the parent page settings + hide the page overlay
  9728.                         const $this = $(this);
  9729.                         $this.closest(`[role="dialog"]`).find(`.ui-dialog-buttonpane button:contains("OK")`).click();
  9730.                         const $barPage = $(`#page-toolbar`);
  9731.                         if (!$barPage.hasClass("closed")) {
  9732.                                 $barPage.find(`.handle`).click()
  9733.                         }
  9734.  
  9735.                         function doShowDialog (page) {
  9736.                                 const $dialog = $(`
  9737.                                         <div title="Weather Configuration">
  9738.                                                 <label class="split wth__row">
  9739.                                                         <span>Weather Type</span>
  9740.                                                         <select name="weatherType1">
  9741.                                                                 <option>None</option>
  9742.                                                                 <option>Fog</option>
  9743.                                                                 <option>Rain</option>
  9744.                                                                 <option>Ripples</option>
  9745.                                                                 <option>Snow</option>
  9746.                                                                 <option>Waves</option>
  9747.                                                                 <option>Blood Rain</option>
  9748.                                                                 <option>Custom (see below)</option>
  9749.                                                         </select>
  9750.                                                 </label>
  9751.                                                 <label class="split wth__row">
  9752.                                                         <span  class="help" title="When &quot;Custom&quot; is selected, above">Custom Weather Image</span>
  9753.                                                         <input name="weatherTypeCustom1" placeholder="https://example.com/pic.png">
  9754.                                                 </label>
  9755.                                                 <label class="flex wth__row">
  9756.                                                         <span>Weather Speed</span>
  9757.                                                         <input type="range" name="weatherSpeed1" min="0.01" max="1" step="0.01">
  9758.                                                 </label>
  9759.                                                 <label class="split wth__row">
  9760.                                                         <span>Weather Direction</span>
  9761.                                                         <select name="weatherDir1">
  9762.                                                                 <option>Northerly</option>
  9763.                                                                 <option>North-Easterly</option>
  9764.                                                                 <option>Easterly</option>
  9765.                                                                 <option>South-Easterly</option>
  9766.                                                                 <option>Southerly</option>
  9767.                                                                 <option>South-Westerly</option>
  9768.                                                                 <option>Westerly</option>
  9769.                                                                 <option>North-Westerly</option>
  9770.                                                                 <option>Custom (see below)</option>
  9771.                                                         </select>
  9772.                                                 </label>
  9773.                                                 <label class="flex wth__row">
  9774.                                                         <span class="help" title="When &quot;Custom&quot; is selected, above">Custom Weather Direction</span>
  9775.                                                         <input type="range" name="weatherDirCustom1" min="0" max="360" step="1">
  9776.                                                 </label>
  9777.                                                 <label class="flex wth__row">
  9778.                                                         <span>Weather Opacity</span>
  9779.                                                         <input type="range" name="weatherOpacity1" min="0.05" max="1" step="0.01">
  9780.                                                 </label>
  9781.                                                 <label class="split wth__row">
  9782.                                                         <span>Oscillate</span>
  9783.                                                         <input type="checkbox" name="weatherOscillate1">
  9784.                                                 </label>
  9785.                                                 <label class="flex wth__row">
  9786.                                                         <span>Oscillation Threshold</span>
  9787.                                                         <input type="range" name="weatherOscillateThreshold1" min="0.05" max="1" step="0.01">
  9788.                                                 </label>
  9789.                                                 <label class="split wth__row">
  9790.                                                         <span>Intensity</span>
  9791.                                                         <select name="weatherIntensity1">
  9792.                                                                 <option>Normal</option>
  9793.                                                                 <option>Heavy</option>
  9794.                                                         </select>
  9795.                                                 </label>
  9796.                                                 <label class="split wth__row">
  9797.                                                         <span>Tint</span>
  9798.                                                         <input type="checkbox" name="weatherTint1">
  9799.                                                 </label>
  9800.                                                 <label class="split wth__row">
  9801.                                                         <span>Tint Color</span>
  9802.                                                         <input type="color" name="weatherTintColor1" value="#4c566d">
  9803.                                                 </label>
  9804.                                                 <label class="split wth__row">
  9805.                                                         <span>Special Effects</span>
  9806.                                                         <select name="weatherEffect1">
  9807.                                                                 <option>None</option>
  9808.                                                                 <option>Lightning</option>
  9809.                                                         </select>
  9810.                                                 </label>
  9811.                                         </div>
  9812.                                 `).appendTo($("body"));
  9813.  
  9814.                                 const handleProp = (propName) => $dialog.find(`[name="${propName}"]`).each((i, e) => {
  9815.                                         const $e = $(e);
  9816.                                         if ($e.is(":checkbox")) {
  9817.                                                 $e.prop("checked", !!page.get(`bR20cfg_${propName}`));
  9818.                                         } else {
  9819.                                                 $e.val(page.get(`bR20cfg_${propName}`));
  9820.                                         }
  9821.                                 });
  9822.                                 const props = [
  9823.                                         "weatherType1",
  9824.                                         "weatherTypeCustom1",
  9825.                                         "weatherSpeed1",
  9826.                                         "weatherDir1",
  9827.                                         "weatherDirCustom1",
  9828.                                         "weatherOpacity1",
  9829.                                         "weatherOscillate1",
  9830.                                         "weatherOscillateThreshold1",
  9831.                                         "weatherIntensity1",
  9832.                                         "weatherTint1",
  9833.                                         "weatherTintColor1",
  9834.                                         "weatherEffect1"
  9835.                                 ];
  9836.                                 props.forEach(handleProp);
  9837.  
  9838.                                 function doSaveValues () {
  9839.                                         props.forEach(propName => {
  9840.                                                 page.set(`bR20cfg_${propName}`, (() => {
  9841.                                                         const $e = $dialog.find(`[name="${propName}"]`);
  9842.                                                         if ($e.is(":checkbox")) {
  9843.                                                                 return !!$e.prop("checked");
  9844.                                                         } else {
  9845.                                                                 return $e.val();
  9846.                                                         }
  9847.                                                 })())
  9848.                                         });
  9849.                                         page.save();
  9850.                                 }
  9851.  
  9852.                                 $dialog.dialog({
  9853.                                         width: 500,
  9854.                                         dialogClass: "no-close",
  9855.                                         buttons: [
  9856.                                                 {
  9857.                                                         text: "OK",
  9858.                                                         click: function () {
  9859.                                                                 $(this).dialog("close");
  9860.                                                                 $dialog.remove();
  9861.                                                                 doSaveValues();
  9862.                                                         }
  9863.                                                 },
  9864.                                                 {
  9865.                                                         text: "Apply",
  9866.                                                         click: function () {
  9867.                                                                 doSaveValues();
  9868.                                                         }
  9869.                                                 },
  9870.                                                 {
  9871.                                                         text: "Cancel",
  9872.                                                         click: function () {
  9873.                                                                 $(this).dialog("close");
  9874.                                                                 $dialog.remove();
  9875.                                                         }
  9876.                                                 }
  9877.                                         ]
  9878.                                 });
  9879.                         }
  9880.  
  9881.                         if (d20plus.weather._lastSettingsPageId) {
  9882.                                 const page = d20.Campaign.pages.get(d20plus.weather._lastSettingsPageId);
  9883.                                 if (page) {
  9884.                                         doShowDialog(page);
  9885.                                 } else d20plus.ut.error(`No page found with ID "${d20plus.weather._lastSettingsPageId}"`);
  9886.                         } else d20plus.ut.error(`No page settings button was clicked?!`);
  9887.                 }).on("mousedown", ".chooseablepage .settings", function () {
  9888.                         const $this = $(this);
  9889.                         d20plus.weather._lastSettingsPageId = $this.closest(`[data-pageid]`).data("pageid");
  9890.                 });
  9891.         };
  9892.  
  9893.         d20plus.weather.addWeather = () => {
  9894.                 d20plus.weather._initSettingsButton();
  9895.  
  9896.                 window.force = false; // missing variable in Roll20's code(?); define it here
  9897.  
  9898.                 d20plus.ut.log("Adding weather");
  9899.  
  9900.                 const MAX_ZOOM = 2.5; // max canvas zoom
  9901.                 const tmp = []; // temp vector
  9902.                 // cache images
  9903.                 const IMAGES = {
  9904.                         "Rain": new Image,
  9905.                         "Snow": new Image,
  9906.                         "Fog": new Image,
  9907.                         "Waves": new Image,
  9908.                         "Ripples": new Image,
  9909.                         "Blood Rain": new Image
  9910.                 };
  9911.                 IMAGES.Rain.src = "https://i.imgur.com/lZrqiVk.png";
  9912.                 IMAGES.Snow.src = "https://i.imgur.com/uwLQjWY.png";
  9913.                 IMAGES.Fog.src = "https://i.imgur.com/SRsUpHW.png";
  9914.                 IMAGES.Waves.src = "https://i.imgur.com/iYEzmvB.png";
  9915.                 IMAGES.Ripples.src = "https://i.imgur.com/fFCr0yx.png";
  9916.                 IMAGES["Blood Rain"].src = "https://i.imgur.com/SP2aoeq.png";
  9917.                 const SFX = {
  9918.                         lightning: []
  9919.                 };
  9920.  
  9921.                 // FIXME find a better way of handling this; `clip` is super-slow
  9922.                 const clipMode = "EXCLUDE";
  9923.  
  9924.                 function SfxLightning () {
  9925.                         this.brightness = 255;
  9926.                 }
  9927.  
  9928.                 const $wrpEditor = $("#editor-wrapper");
  9929.  
  9930.                 // add custom canvas
  9931.                 const $wrpCanvas = $wrpEditor.find(".canvas-container");
  9932.  
  9933.                 // make buffer canvas
  9934.                 const $canBuf = $("<canvas style='position: absolute; z-index: -100; left:0; top: 0; pointer-events: none;' tabindex='-1'/>").appendTo($wrpCanvas);
  9935.                 const cvBuf = $canBuf[0];
  9936.                 const ctxBuf = cvBuf.getContext("2d");
  9937.  
  9938.                 // make weather canvas
  9939.                 const $canvasWeather = $("<canvas id='Vet-canvas-weather' style='position: absolute; z-index: 2; left:0; top: 0; pointer-events: none;' tabindex='-1'/>").appendTo($wrpCanvas);
  9940.                 const cv = $canvasWeather[0];
  9941.                 d20.engine.weathercanvas = cv;
  9942.  
  9943.                 // add our canvas to those adjusted when canvas size changes
  9944.                 const cachedSetCanvasSize = d20.engine.setCanvasSize;
  9945.                 d20.engine.setCanvasSize = function (e, n) {
  9946.                         cv.width = e;
  9947.                         cv.height = n;
  9948.  
  9949.                         cvBuf.width = e;
  9950.                         cvBuf.height = n;
  9951.  
  9952.                         cachedSetCanvasSize(e, n);
  9953.                 };
  9954.  
  9955.                 d20.engine.setCanvasSize($wrpEditor[0].clientWidth, $wrpEditor[0].clientHeight);
  9956.  
  9957.                 const ctx = cv.getContext("2d");
  9958.  
  9959.                 const CTX = {
  9960.                         _hasWarned: new Set()
  9961.                 };
  9962.  
  9963.                 function ofX (x) { // offset X
  9964.                         return x - d20.engine.currentCanvasOffset[0];
  9965.                 }
  9966.  
  9967.                 function ofY (y) { // offset Y
  9968.                         return y - d20.engine.currentCanvasOffset[1];
  9969.                 }
  9970.  
  9971.                 function lineIntersectsBounds (points, bounds) {
  9972.                         return d20plus.math.doPolygonsIntersect([points[0], points[2], points[3], points[1]], bounds);
  9973.                 }
  9974.  
  9975.                 function copyPoints (toCopy) {
  9976.                         return [...toCopy.map(pt => [...pt])];
  9977.                 }
  9978.  
  9979.                 function getImage (page) {
  9980.                         const imageName = page.get("bR20cfg_weatherType1");
  9981.  
  9982.                         switch (imageName) {
  9983.                                 case "Rain":
  9984.                                 case "Snow":
  9985.                                 case "Fog":
  9986.                                 case "Waves":
  9987.                                 case "Ripples":
  9988.                                 case "Blood Rain":
  9989.                                         IMAGES["Custom"] = null;
  9990.                                         return IMAGES[imageName];
  9991.                                 case "Custom (see below)":
  9992.                                         if (!IMAGES["Custom"] || (
  9993.                                                 (IMAGES["Custom"].src !== page.get("bR20cfg_weatherTypeCustom1") && IMAGES["Custom"]._errorSrc == null) ||
  9994.                                                 (IMAGES["Custom"]._errorSrc != null && IMAGES["Custom"]._errorSrc !== page.get("bR20cfg_weatherTypeCustom1")))
  9995.                                         ) {
  9996.                                                 IMAGES["Custom"] = new Image;
  9997.                                                 IMAGES["Custom"]._errorSrc = null;
  9998.                                                 IMAGES["Custom"].onerror = () => {
  9999.                                                         if (IMAGES["Custom"]._errorSrc == null) {
  10000.                                                                 IMAGES["Custom"]._errorSrc = page.get("bR20cfg_weatherTypeCustom1");
  10001.                                                                 alert(`Custom weather image "${IMAGES["Custom"].src}" failed to load!`);
  10002.                                                         }
  10003.                                                         IMAGES["Custom"].src = IMAGES["Rain"].src;
  10004.                                                 };
  10005.                                                 IMAGES["Custom"].src = page.get("bR20cfg_weatherTypeCustom1");
  10006.                                         }
  10007.                                         return IMAGES["Custom"];
  10008.                                 default:
  10009.                                         IMAGES["Custom"] = null;
  10010.                                         return null;
  10011.                         }
  10012.                 }
  10013.  
  10014.                 function getDirectionRotation (page) {
  10015.                         const dir = page.get("bR20cfg_weatherDir1");
  10016.                         switch (dir) {
  10017.                                 case "Northerly": return 0.25 * Math.PI;
  10018.                                 case "North-Easterly": return 0.5 * Math.PI;
  10019.                                 case "Easterly": return 0.75 * Math.PI;
  10020.                                 case "South-Easterly": return Math.PI;
  10021.                                 case "Southerly": return 1.25 * Math.PI;
  10022.                                 case "South-Westerly": return 1.5 * Math.PI;
  10023.                                 case "Westerly": return 1.75 * Math.PI;
  10024.                                 case "North-Westerly": return 0;
  10025.                                 case "Custom (see below)":
  10026.                                         return Number(page.get("bR20cfg_weatherDirCustom1") || 0) * Math.PI / 180;
  10027.                                 default: return 0;
  10028.                         }
  10029.                 }
  10030.  
  10031.                 function getOpacity (page) {
  10032.                         return page.get("bR20cfg_weatherOpacity1") || 1;
  10033.                 }
  10034.  
  10035.                 let oscillateMode = null;
  10036.                 function isOscillating (page) {
  10037.                         return !!page.get("bR20cfg_weatherOscillate1");
  10038.                 }
  10039.  
  10040.                 function getOscillationThresholdFactor (page) {
  10041.                         return page.get("bR20cfg_weatherOscillateThreshold1") || 1;
  10042.                 }
  10043.  
  10044.                 function getIntensity (page) {
  10045.                         const tint = page.get("bR20cfg_weatherIntensity1");
  10046.                         switch (tint) {
  10047.                                 case "Heavy": return 1;
  10048.                                 default: return 0;
  10049.                         }
  10050.                 }
  10051.  
  10052.                 function getTintColor (page) {
  10053.                         const tintEnabled = page.get("bR20cfg_weatherTint1");
  10054.                         if (tintEnabled) {
  10055.                                 return `${(page.get("bR20cfg_weatherTintColor1") || "#4c566d")}80`;
  10056.                         } else return null;
  10057.                 }
  10058.  
  10059.                 function getEffect (page) {
  10060.                         const effect = page.get("bR20cfg_weatherEffect1");
  10061.                         switch (effect) {
  10062.                                 case "Lightning": return "lightning";
  10063.                                 default: return null;
  10064.                         }
  10065.                 }
  10066.  
  10067.                 function handleSvgCoord (coords, obj, basesXY, center, angle) {
  10068.                         const vec = [
  10069.                                 ofX(coords[0] * obj.scaleX) + basesXY[0],
  10070.                                 ofY(coords[1] * obj.scaleY) + basesXY[1]
  10071.                         ];
  10072.                         d20plus.math.vec2.scale(vec, vec, d20.engine.canvasZoom);
  10073.                         if (angle) d20plus.math.vec2.rotate(vec, vec, center, angle);
  10074.                         return vec;
  10075.                 }
  10076.  
  10077.                 let accum = 0;
  10078.                 let then = 0;
  10079.                 let image;
  10080.                 let currentSfx;
  10081.                 let hasWeather = false;
  10082.                 function drawFrame (now) {
  10083.                         const deltaTime = now - then;
  10084.                         then = now;
  10085.  
  10086.                         const page = d20 && d20.Campaign && d20.Campaign.activePage ? d20.Campaign.activePage() : null;
  10087.                         if (page && page.get("bR20cfg_weatherType1") !== "None") {
  10088.                                 image = getImage(page);
  10089.                                 currentSfx = getEffect(page);
  10090.  
  10091.                                 // generate SFX
  10092.                                 if (currentSfx) {
  10093.                                         if (currentSfx === "lightning" && Math.random() > 0.999) SFX.lightning.push(new SfxLightning());
  10094.                                 } else {
  10095.                                         SFX.lightning = [];
  10096.                                 }
  10097.  
  10098.                                 if (hasWeather) ctx.clearRect(0, 0, cv.width, cv.height);
  10099.                                 const hasImage = image && image.complete;
  10100.                                 const tint = getTintColor(page);
  10101.                                 const scaledW = hasImage ? Math.ceil((image.width * d20.engine.canvasZoom) / MAX_ZOOM) : -1;
  10102.                                 const scaledH = hasImage ? Math.ceil((image.height * d20.engine.canvasZoom) / MAX_ZOOM) : -1;
  10103.                                 const hasSfx = SFX.lightning.length;
  10104.                                 if (hasImage || tint || hasSfx) {
  10105.                                         hasWeather = true;
  10106.  
  10107.                                         // draw weather
  10108.                                         if (
  10109.                                                 hasImage &&
  10110.                                                 !(scaledW <= 0 || scaledH <= 0) // sanity check
  10111.                                         ) {
  10112.                                                 // mask weather
  10113.                                                 const doMaskStep = () => {
  10114.                                                         ctxBuf.clearRect(0, 0, cvBuf.width, cvBuf.height);
  10115.  
  10116.                                                         ctxBuf.fillStyle = "#ffffffff";
  10117.  
  10118.                                                         const objectLen = d20.engine.canvas._objects.length;
  10119.                                                         for (let i = 0; i < objectLen; ++i) {
  10120.                                                                 const obj = d20.engine.canvas._objects[i];
  10121.                                                                 if (obj.type === "path" && obj.model && obj.model.get("layer") === "weather") {
  10122.                                                                         // obj.top is X pos of center of object
  10123.                                                                         // obj.left is Y pos of center of object
  10124.                                                                         const xBase = (obj.left - (obj.width * obj.scaleX / 2));
  10125.                                                                         const yBase = (obj.top - (obj.height * obj.scaleY / 2));
  10126.                                                                         const basesXY = [xBase, yBase];
  10127.                                                                         const angle = (obj.angle > 360 ? obj.angle - 360 : obj.angle) / 180 * Math.PI;
  10128.                                                                         const center = [ofX(obj.left), ofY(obj.top)];
  10129.                                                                         d20plus.math.vec2.scale(center, center, d20.engine.canvasZoom);
  10130.  
  10131.                                                                         ctxBuf.beginPath();
  10132.                                                                         obj.path.forEach(opp => {
  10133.                                                                                 const [op, x, y, ...others] = opp;
  10134.                                                                                 switch (op) {
  10135.                                                                                         case "M": {
  10136.                                                                                                 const vec = handleSvgCoord([x, y], obj, basesXY, center, angle);
  10137.                                                                                                 ctxBuf.moveTo(vec[0], vec[1]);
  10138.                                                                                                 break;
  10139.                                                                                         }
  10140.                                                                                         case "L": {
  10141.                                                                                                 const vec = handleSvgCoord([x, y], obj, basesXY, center, angle);
  10142.                                                                                                 ctxBuf.lineTo(vec[0], vec[1]);
  10143.                                                                                                 break;
  10144.                                                                                         }
  10145.                                                                                         case "C": {
  10146.                                                                                                 const control1 = handleSvgCoord([x, y], obj, basesXY, center, angle);
  10147.                                                                                                 const control2 = handleSvgCoord([others[0], others[1]], obj, basesXY, center, angle);
  10148.                                                                                                 const end = handleSvgCoord([others[2], others[3]], obj, basesXY, center, angle);
  10149.                                                                                                 ctxBuf.bezierCurveTo(...control1, ...control2, ...end);
  10150.                                                                                                 break;
  10151.                                                                                         }
  10152.                                                                                         default:
  10153.                                                                                                 if (!CTX._hasWarned.has(op)) {
  10154.                                                                                                         CTX._hasWarned.add(op);
  10155.                                                                                                         console.error(`UNHANDLED OP!: ${op}`);
  10156.                                                                                                 }
  10157.                                                                                 }
  10158.                                                                         });
  10159.                                                                         ctxBuf.fill();
  10160.                                                                         ctxBuf.closePath();
  10161.                                                                 }
  10162.                                                         }
  10163.  
  10164.                                                         // draw final weather mask
  10165.                                                         //// change drawing mode
  10166.                                                         ctx.globalCompositeOperation = "destination-out";
  10167.                                                         ctx.drawImage(cvBuf, 0, 0);
  10168.  
  10169.                                                         // handle opacity
  10170.                                                         const opacity = Number(getOpacity(page));
  10171.                                                         if (opacity !== 1) {
  10172.                                                                 ctxBuf.clearRect(0, 0, cvBuf.width, cvBuf.height);
  10173.                                                                 ctxBuf.fillStyle = `#ffffff${Math.round((1 - opacity) * 255).toString(16)}`;
  10174.                                                                 ctxBuf.fillRect(0, 0, cvBuf.width, cvBuf.height);
  10175.                                                                 ctx.drawImage(cvBuf, 0, 0);
  10176.                                                         }
  10177.  
  10178.                                                         //// reset drawing mode
  10179.                                                         ctx.globalCompositeOperation = "source-over";
  10180.                                                 };
  10181.  
  10182.                                                 // if (clipMode === "INCLUDE") doMaskStep(true);
  10183.  
  10184.                                                 const speed = page.get("bR20cfg_weatherSpeed1") || 0.1;
  10185.                                                 const speedFactor = speed * d20.engine.canvasZoom;
  10186.                                                 const maxAccum = Math.floor(scaledW / speedFactor);
  10187.                                                 const rot = getDirectionRotation(page);
  10188.                                                 const w = scaledW;
  10189.                                                 const h = scaledH;
  10190.                                                 const boundingBox = [
  10191.                                                         [
  10192.                                                                 -1.5 * w,
  10193.                                                                 -1.5 * h
  10194.                                                         ],
  10195.                                                         [
  10196.                                                                 -1.5 * w,
  10197.                                                                 cv.height + (1.5 * h) + d20.engine.currentCanvasOffset[1]
  10198.                                                         ],
  10199.                                                         [
  10200.                                                                 cv.width + (1.5 * w) + d20.engine.currentCanvasOffset[0],
  10201.                                                                 cv.height + (1.5 * h) + d20.engine.currentCanvasOffset[1]
  10202.                                                         ],
  10203.                                                         [
  10204.                                                                 cv.width + (1.5 * w) + d20.engine.currentCanvasOffset[0],
  10205.                                                                 -1.5 * h
  10206.                                                         ]
  10207.                                                 ];
  10208.                                                 const BASE_OFFSET_X = -w / 2;
  10209.                                                 const BASE_OFFSET_Y = -h / 2;
  10210.  
  10211.                                                 // calculate resultant points of a rotated shape
  10212.                                                 const pt00 = [0, 0];
  10213.                                                 const pt01 = [0, 1];
  10214.                                                 const pt10 = [1, 0];
  10215.                                                 const pt11 = [1, 1];
  10216.                                                 const basePts = [
  10217.                                                         pt00,
  10218.                                                         pt01,
  10219.                                                         pt10,
  10220.                                                         pt11
  10221.                                                 ].map(pt => [
  10222.                                                         (pt[0] * w) + BASE_OFFSET_X - d20.engine.currentCanvasOffset[0],
  10223.                                                         (pt[1] * h) + BASE_OFFSET_Y - d20.engine.currentCanvasOffset[1]
  10224.                                                 ]);
  10225.                                                 basePts.forEach(pt => d20plus.math.vec2.rotate(pt, pt, [0, 0], rot));
  10226.  
  10227.                                                 // calculate animation values
  10228.                                                 (() => {
  10229.                                                         if (isOscillating(page)) {
  10230.                                                                 const oscThreshFactor = getOscillationThresholdFactor(page);
  10231.  
  10232.                                                                 if (oscillateMode == null) {
  10233.                                                                         oscillateMode = 1;
  10234.                                                                         accum += deltaTime;
  10235.                                                                         if (accum >= maxAccum * oscThreshFactor) accum -= maxAccum;
  10236.                                                                 } else {
  10237.                                                                         if (oscillateMode === 1) {
  10238.                                                                                 accum += deltaTime;
  10239.                                                                                 if (accum >= maxAccum * oscThreshFactor) {
  10240.                                                                                         accum -= 2 * deltaTime;
  10241.                                                                                         oscillateMode = -1;
  10242.                                                                                 }
  10243.                                                                         } else {
  10244.                                                                                 accum -= deltaTime;
  10245.                                                                                 if (accum <= 0) {
  10246.                                                                                         oscillateMode = 1;
  10247.                                                                                         accum += 2 * deltaTime;
  10248.                                                                                 }
  10249.                                                                         }
  10250.                                                                 }
  10251.                                                         } else {
  10252.                                                                 oscillateMode = null;
  10253.                                                                 accum += deltaTime;
  10254.                                                                 if (accum >= maxAccum) accum -= maxAccum;
  10255.                                                         }
  10256.                                                 })();
  10257.  
  10258.                                                 const intensity = getIntensity(page) * speedFactor;
  10259.                                                 const timeOffsetX = Math.ceil(speedFactor * accum);
  10260.                                                 const timeOffsetY = Math.ceil(speedFactor * accum);
  10261.  
  10262.                                                 //// rotate coord space
  10263.                                                 ctx.rotate(rot);
  10264.  
  10265.                                                 // draw base image
  10266.                                                 doDraw(0, 0);
  10267.  
  10268.                                                 function doDraw (offsetX, offsetY) {
  10269.                                                         const xPos = BASE_OFFSET_X + timeOffsetX + offsetX - d20.engine.currentCanvasOffset[0];
  10270.                                                         const yPos = BASE_OFFSET_Y + timeOffsetY + offsetY - d20.engine.currentCanvasOffset[1];
  10271.                                                         ctx.drawImage(
  10272.                                                                 image,
  10273.                                                                 xPos,
  10274.                                                                 yPos,
  10275.                                                                 w,
  10276.                                                                 h
  10277.                                                         );
  10278.  
  10279.                                                         if (intensity) {
  10280.                                                                 const offsetIntensity = -Math.floor(w / 4);
  10281.                                                                 ctx.drawImage(
  10282.                                                                         image,
  10283.                                                                         xPos + offsetIntensity,
  10284.                                                                         yPos + offsetIntensity,
  10285.                                                                         w,
  10286.                                                                         h
  10287.                                                                 );
  10288.                                                         }
  10289.                                                 }
  10290.  
  10291.                                                 function inBounds (nextPts) {
  10292.                                                         return lineIntersectsBounds(nextPts, boundingBox);
  10293.                                                 }
  10294.  
  10295.                                                 function moveXDir (pt, i, isAdd) {
  10296.                                                         if (i % 2) d20plus.math.vec2.sub(tmp, basePts[3], basePts[1]);
  10297.                                                         else d20plus.math.vec2.sub(tmp, basePts[2], basePts[0]);
  10298.  
  10299.                                                         if (isAdd) d20plus.math.vec2.add(pt, pt, tmp);
  10300.                                                         else d20plus.math.vec2.sub(pt, pt, tmp);
  10301.                                                 }
  10302.  
  10303.                                                 function moveYDir (pt, i, isAdd) {
  10304.                                                         if (i > 1) d20plus.math.vec2.sub(tmp, basePts[3], basePts[2]);
  10305.                                                         else d20plus.math.vec2.sub(tmp, basePts[1], basePts[0]);
  10306.  
  10307.                                                         if (isAdd) d20plus.math.vec2.add(pt, pt, tmp);
  10308.                                                         else d20plus.math.vec2.sub(pt, pt, tmp);
  10309.                                                 }
  10310.  
  10311.                                                 const getMaxMoves = () => {
  10312.                                                         const hyp = [];
  10313.                                                         d20plus.math.vec2.sub(hyp, boundingBox[2], boundingBox[0]);
  10314.  
  10315.                                                         const dist = d20plus.math.vec2.len(hyp);
  10316.                                                         const maxMoves = dist / Math.min(w, h);
  10317.                                                         return [Math.abs(hyp[0]) > Math.abs(hyp[1]) ? "x" : "y", maxMoves];
  10318.                                                 };
  10319.  
  10320.                                                 const handleXAxisYIncrease = (nxtPts, maxMoves, moves, xDir) => {
  10321.                                                         const handleY = (dir) => {
  10322.                                                                 let subNxtPts, subMoves;
  10323.                                                                 subNxtPts = copyPoints(nxtPts);
  10324.                                                                 subMoves = 0;
  10325.                                                                 while(subMoves <= maxMoves[1]) {
  10326.                                                                         subNxtPts.forEach((pt, i) => moveYDir(pt, i, dir > 0));
  10327.                                                                         subMoves++;
  10328.                                                                         if (inBounds(subNxtPts)) doDraw(xDir * moves * w, dir * (subMoves * h));
  10329.                                                                 }
  10330.                                                         };
  10331.  
  10332.                                                         handleY(1); // y axis increasing
  10333.                                                         handleY(-1); // y axis decreasing
  10334.                                                 };
  10335.  
  10336.                                                 const handleYAxisXIncrease = (nxtPts, maxMoves, moves, yDir) => {
  10337.                                                         const handleX = (dir) => {
  10338.                                                                 let subNxtPts, subMoves;
  10339.                                                                 subNxtPts = copyPoints(nxtPts);
  10340.                                                                 subMoves = 0;
  10341.                                                                 while(subMoves <= maxMoves[1]) {
  10342.                                                                         subNxtPts.forEach((pt, i) => moveXDir(pt, i, dir > 0));
  10343.                                                                         subMoves++;
  10344.                                                                         if (lineIntersectsBounds(subNxtPts, boundingBox)) doDraw(dir * (subMoves * w), yDir * moves * h);
  10345.                                                                 }
  10346.                                                         };
  10347.  
  10348.                                                         handleX(1); // x axis increasing
  10349.                                                         handleX(-1); // x axis decreasing
  10350.                                                 };
  10351.  
  10352.                                                 const handleBasicX = (maxMoves) => {
  10353.                                                         const handleX = (dir) => {
  10354.                                                                 let nxtPts, moves;
  10355.                                                                 nxtPts = copyPoints(basePts);
  10356.                                                                 moves = 0;
  10357.                                                                 while(moves < maxMoves) {
  10358.                                                                         nxtPts.forEach((pt, i) => moveXDir(pt, i, dir > 0));
  10359.                                                                         moves++;
  10360.                                                                         if (lineIntersectsBounds(nxtPts, boundingBox)) doDraw(dir * (moves * w), 0);
  10361.                                                                 }
  10362.                                                         };
  10363.  
  10364.                                                         handleX(1); // x axis increasing
  10365.                                                         handleX(-1); // x axis decreasing
  10366.                                                 };
  10367.  
  10368.                                                 const handleBasicY = (maxMoves) => {
  10369.                                                         const handleY = (dir) => {
  10370.                                                                 let nxtPts, moves;
  10371.                                                                 nxtPts = copyPoints(basePts);
  10372.                                                                 moves = 0;
  10373.                                                                 while(moves < maxMoves) {
  10374.                                                                         nxtPts.forEach((pt, i) => moveYDir(pt, i, dir > 0));
  10375.                                                                         moves++;
  10376.                                                                         if (lineIntersectsBounds(nxtPts, boundingBox)) doDraw(0, dir * (moves * h));
  10377.                                                                 }
  10378.                                                         };
  10379.  
  10380.                                                         handleY(1); // y axis increasing
  10381.                                                         handleY(-1); // y axis decreasing
  10382.                                                 };
  10383.  
  10384.                                                 (() => {
  10385.                                                         // choose largest axis
  10386.                                                         const maxMoves = getMaxMoves();
  10387.  
  10388.                                                         if (maxMoves[0] === "x") {
  10389.                                                                 const handleX = (dir) => {
  10390.                                                                         let nxtPts, moves;
  10391.                                                                         nxtPts = copyPoints(basePts);
  10392.                                                                         moves = 0;
  10393.                                                                         while(moves < maxMoves[1]) {
  10394.                                                                                 nxtPts.forEach((pt, i) => moveXDir(pt, i, dir > 0));
  10395.                                                                                 moves++;
  10396.                                                                                 if (lineIntersectsBounds(nxtPts, boundingBox)) doDraw(dir * (moves * w), 0);
  10397.                                                                                 handleXAxisYIncrease(nxtPts, maxMoves, moves, dir);
  10398.                                                                         }
  10399.                                                                 };
  10400.  
  10401.                                                                 handleBasicY(maxMoves[1]);
  10402.                                                                 handleX(1); // x axis increasing
  10403.                                                                 handleX(-1); // x axis decreasing
  10404.                                                         } else {
  10405.                                                                 const handleY = (dir) => {
  10406.                                                                         let nxtPts, moves;
  10407.                                                                         nxtPts = copyPoints(basePts);
  10408.                                                                         moves = 0;
  10409.                                                                         while(moves < maxMoves[1]) {
  10410.                                                                                 nxtPts.forEach((pt, i) => moveYDir(pt, i, dir > 0));
  10411.                                                                                 moves++;
  10412.                                                                                 if (lineIntersectsBounds(nxtPts, boundingBox)) doDraw(0, dir * (moves * h));
  10413.                                                                                 handleYAxisXIncrease(nxtPts, maxMoves, moves, dir);
  10414.                                                                         }
  10415.                                                                 };
  10416.  
  10417.                                                                 handleBasicX(maxMoves[1]);
  10418.                                                                 handleY(1); // y axis increasing
  10419.                                                                 handleY(-1); // y axis decreasing
  10420.                                                         }
  10421.                                                 })();
  10422.  
  10423.                                                 //// revert coord space rotation
  10424.                                                 ctx.rotate(-rot);
  10425.  
  10426.                                                 if (clipMode === "EXCLUDE") doMaskStep(false);
  10427.                                         }
  10428.  
  10429.                                         // draw sfx
  10430.                                         if (hasSfx) {
  10431.                                                 for (let i = SFX.lightning.length - 1; i >= 0; --i) {
  10432.                                                         const l = SFX.lightning[i];
  10433.                                                         if (l.brightness <= 5) {
  10434.                                                                 SFX.lightning.splice(i, 1);
  10435.                                                         } else {
  10436.                                                                 ctx.fillStyle = `#effbff${l.brightness.toString(16).padStart(2, "0")}`;
  10437.                                                                 ctx.fillRect(0, 0, cv.width, cv.height);
  10438.                                                                 l.brightness -= Math.floor(deltaTime);
  10439.                                                         }
  10440.                                                 }
  10441.                                         }
  10442.  
  10443.                                         // draw tint
  10444.                                         if (tint) {
  10445.                                                 ctx.fillStyle = tint;
  10446.                                                 ctx.fillRect(0, 0, cv.width, cv.height);
  10447.                                         }
  10448.                                 }
  10449.  
  10450.                                 requestAnimationFrame(drawFrame);
  10451.                         } else {
  10452.                                 // if weather is disabled, maintain a background tick
  10453.                                 if (hasWeather) {
  10454.                                         ctx.clearRect(0, 0, cv.width, cv.height);
  10455.                                         hasWeather = false;
  10456.                                 }
  10457.                                 setTimeout(() => {
  10458.                                         drawFrame(0);
  10459.                                 }, 1000);
  10460.                         }
  10461.                 }
  10462.  
  10463.                 requestAnimationFrame(drawFrame);
  10464.         };
  10465. }
  10466.  
  10467. SCRIPT_EXTENSIONS.push(baseWeather);
  10468.  
  10469.  
  10470. function d20plusJournal () {
  10471.         d20plus.journal = {};
  10472.  
  10473.         d20plus.journal.lastClickedFolderId = null;
  10474.  
  10475.         d20plus.journal.addJournalCommands = () => {
  10476.                 // Create new Journal commands
  10477.                 // stash the folder ID of the last folder clicked
  10478.                 $("#journalfolderroot").on("contextmenu", ".dd-content", function (e) {
  10479.                         if ($(this).parent().hasClass("dd-folder")) {
  10480.                                 const lastClicked = $(this).parent();
  10481.                                 d20plus.journal.lastClickedFolderId = lastClicked.attr("data-globalfolderid");
  10482.                         }
  10483.  
  10484.  
  10485.                         if ($(this).parent().hasClass("character")) {
  10486.                                 $(`.Vetools-make-tokenactions`).show();
  10487.                         } else {
  10488.                                 $(`.Vetools-make-tokenactions`).hide();
  10489.                         }
  10490.                 });
  10491.  
  10492.                 var first = $("#journalitemmenu ul li").first();
  10493.                 // "Make Tokenactions" option
  10494.                 first.after(`<li class="Vetools-make-tokenactions" data-action-type="additem">Make Tokenactions</li>`);
  10495.                 $("#journalitemmenu ul").on(window.mousedowntype, "li[data-action-type=additem]", function () {
  10496.                         var id = $currentItemTarget.attr("data-itemid");
  10497.                         var character = d20.Campaign.characters.get(id);
  10498.                         d20plus.ut.log("Making Token Actions..");
  10499.                         if (character) {
  10500.                                 var npc = character.attribs.find(function (a) {
  10501.                                         return a.get("name").toLowerCase() == "npc";
  10502.                                 });
  10503.                                 var isNPC = npc ? parseInt(npc.get("current")) : 0;
  10504.                                 if (isNPC) {
  10505.                                         //Npc specific tokenactions
  10506.                                         character.abilities.create({
  10507.                                                 name: "Perception",
  10508.                                                 istokenaction: true,
  10509.                                                 action: d20plus.actionMacroPerception
  10510.                                         });
  10511.                                         character.abilities.create({
  10512.                                                 name: "DR/Immunities",
  10513.                                                 istokenaction: true,
  10514.                                                 action: d20plus.actionMacroDrImmunities
  10515.                                         });
  10516.                                         character.abilities.create({
  10517.                                                 name: "Stats",
  10518.                                                 istokenaction: true,
  10519.                                                 action: d20plus.actionMacroStats
  10520.                                         });
  10521.                                         character.abilities.create({
  10522.                                                 name: "Saves",
  10523.                                                 istokenaction: true,
  10524.                                                 action: d20plus.actionMacroSaves
  10525.                                         });
  10526.                                         character.abilities.create({
  10527.                                                 name: "Skill-Check",
  10528.                                                 istokenaction: true,
  10529.                                                 action: d20plus.actionMacroSkillCheck
  10530.                                         });
  10531.                                         character.abilities.create({
  10532.                                                 name: "Ability-Check",
  10533.                                                 istokenaction: true,
  10534.                                                 action: d20plus.actionMacroAbilityCheck
  10535.                                         });
  10536.                                 } else {
  10537.                                         //player specific tokenactions
  10538.                                         //@{selected|repeating_attack_$0_atkname}
  10539.                                         character.abilities.create({
  10540.                                                 name: "Attack 1",
  10541.                                                 istokenaction: true,
  10542.                                                 action: "%{selected|repeating_attack_$0_attack}"
  10543.                                         });
  10544.                                         character.abilities.create({
  10545.                                                 name: "Attack 2",
  10546.                                                 istokenaction: true,
  10547.                                                 action: "%{selected|repeating_attack_$1_attack}"
  10548.                                         });
  10549.                                         character.abilities.create({
  10550.                                                 name: "Attack 3",
  10551.                                                 istokenaction: true,
  10552.                                                 action: "%{selected|repeating_attack_$2_attack}"
  10553.                                         });
  10554.                                         character.abilities.create({
  10555.                                                 name: "Tool 1",
  10556.                                                 istokenaction: true,
  10557.                                                 action: "%{selected|repeating_tool_$0_tool}"
  10558.                                         });
  10559.                                         //" + character.get("name") + "
  10560.                                         character.abilities.create({
  10561.                                                 name: "Whisper GM",
  10562.                                                 istokenaction: true,
  10563.                                                 action: "/w gm ?{Message to whisper the GM?}"
  10564.                                         });
  10565.                                         character.abilities.create({
  10566.                                                 name: "Favorite Spells",
  10567.                                                 istokenaction: true,
  10568.                                                 action: "/w @{character_name} &{template:npcaction} {{rname=Favorite Spells}} {{description=Favorite Spells are the first spells in each level of your spellbook.\n\r[Cantrip](~selected|repeating_spell-cantrip_$0_spell)\n[1st Level](~selected|repeating_spell-1_$0_spell)\n\r[2nd Level](~selected|repeating_spell-2_$0_spell)\n\r[3rd Level](~selected|repeating_spell-3_$0_spell)\n\r[4th Level](~selected|repeating_spell-4_$0_spell)\n\r[5th Level](~selected|repeating_spell-5_$0_spell)}}"
  10569.                                         });
  10570.                                         character.abilities.create({
  10571.                                                 name: "Dual Attack",
  10572.                                                 istokenaction: false,
  10573.                                                 action: "%{selected|repeating_attack_$0_attack}\n\r%{selected|repeating_attack_$0_attack}"
  10574.                                         });
  10575.                                         character.abilities.create({
  10576.                                                 name: "Saves",
  10577.                                                 istokenaction: true,
  10578.                                                 action: "@{selected|wtype}&{template:simple} @{selected|rtype}?{Save|Strength, +@{selected|strength_save_bonus}@{selected|pbd_safe}]]&#125;&#125; {{rname=Strength Save&#125;&#125 {{mod=@{selected|strength_save_bonus}&#125;&#125; {{r1=[[@{selected|d20}+@{selected|strength_save_bonus}@{selected|pbd_safe}]]&#125;&#125; |Dexterity, +@{selected|dexterity_save_bonus}@{selected|pbd_safe}]]&#125;&#125; {{rname=Dexterity Save&#125;&#125 {{mod=@{selected|dexterity_save_bonus}&#125;&#125; {{r1=[[@{selected|d20}+@{selected|dexterity_save_bonus}@{selected|pbd_safe}]]&#125;&#125; |Constitution, +@{selected|constitution_save_bonus}@{selected|pbd_safe}]]&#125;&#125; {{rname=Constitution Save&#125;&#125 {{mod=@{selected|constitution_save_bonus}&#125;&#125; {{r1=[[@{selected|d20}+@{selected|constitution_save_bonus}@{selected|pbd_safe}]]&#125;&#125; |Intelligence, +@{selected|intelligence_save_bonus}@{selected|pbd_safe}]]&#125;&#125; {{rname=Intelligence Save&#125;&#125 {{mod=@{selected|intelligence_save_bonus}&#125;&#125; {{r1=[[@{selected|d20}+@{selected|intelligence_save_bonus}@{selected|pbd_safe}]]&#125;&#125; |Wisdom, +@{selected|wisdom_save_bonus}@{selected|pbd_safe}]]&#125;&#125; {{rname=Wisdom Save&#125;&#125 {{mod=@{selected|wisdom_save_bonus}&#125;&#125; {{r1=[[@{selected|d20}+@{selected|wisdom_save_bonus}@{selected|pbd_safe}]]&#125;&#125; |Charisma, +@{selected|charisma_save_bonus}@{selected|pbd_safe}]]&#125;&#125; {{rname=Charisma Save&#125;&#125 {{mod=@{selected|charisma_save_bonus}&#125;&#125; {{r1=[[@{selected|d20}+@{selected|charisma_save_bonus}@{selected|pbd_safe}]]&#125;&#125;}@{selected|global_save_mod}@{selected|charname_output"
  10579.                                         });
  10580.                                         character.abilities.create({
  10581.                                                 name: "Skill-Check",
  10582.                                                 istokenaction: true,
  10583.                                                 action: "@{selected|wtype}&{template:simple} @{selected|rtype}?{Ability|Acrobatics, +@{selected|acrobatics_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Acrobatics&#125;&#125; {{mod=@{selected|acrobatics_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|acrobatics_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Animal Handling, +@{selected|animal_handling_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Animal Handling&#125;&#125; {{mod=@{selected|animal_handling_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|animal_handling_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Arcana, +@{selected|arcana_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Arcana&#125;&#125; {{mod=@{selected|arcana_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|arcana_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Athletics, +@{selected|athletics_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Athletics&#125;&#125; {{mod=@{selected|athletics_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|athletics_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Deception, +@{selected|deception_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Deception&#125;&#125; {{mod=@{selected|deception_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|deception_bonus}@{selected|pbd_safe} ]]&#125;&#125; |History, +@{selected|history_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=History&#125;&#125; {{mod=@{selected|history_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|history_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Insight, +@{selected|insight_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Insight&#125;&#125; {{mod=@{selected|insight_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|insight_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Intimidation, +@{selected|intimidation_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Intimidation&#125;&#125; {{mod=@{selected|intimidation_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|intimidation_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Investigation, +@{selected|investigation_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Investigation&#125;&#125; {{mod=@{selected|investigation_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|investigation_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Medicine, +@{selected|medicine_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Medicine&#125;&#125; {{mod=@{selected|medicine_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|medicine_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Nature, +@{selected|nature_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Nature&#125;&#125; {{mod=@{selected|nature_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|nature_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Perception, +@{selected|perception_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Perception&#125;&#125; {{mod=@{selected|perception_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|perception_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Performance, +@{selected|performance_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Performance&#125;&#125; {{mod=@{selected|performance_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|performance_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Persuasion, +@{selected|persuasion_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Persuasion&#125;&#125; {{mod=@{selected|persuasion_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|persuasion_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Religion, +@{selected|religion_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Religion&#125;&#125; {{mod=@{selected|religion_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|religion_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Sleight of Hand, +@{selected|sleight_of_hand_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Sleight of Hand&#125;&#125; {{mod=@{selected|sleight_of_hand_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|sleight_of_hand_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Stealth, +@{selected|stealth_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Stealth&#125;&#125; {{mod=@{selected|stealth_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|stealth_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Survival, +@{selected|survival_bonus}@{selected|pbd_safe} ]]&#125;&#125; {{rname=Survival&#125;&#125; {{mod=@{selected|survival_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|survival_bonus}@{selected|pbd_safe} ]]&#125;&#125; |Strength, +@{selected|strength_mod}@{selected|jack_attr}[STR]]]&#125;&#125; {{rname=Strength&#125;&#125; {{mod=@{selected|strength_mod}@{selected|jack_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|strength_mod}@{selected|jack_attr}[STR]]]&#125;&#125; |Dexterity, +@{selected|dexterity_mod}@{selected|jack_attr}[DEX]]]&#125;&#125; {{rname=Dexterity&#125;&#125; {{mod=@{selected|dexterity_mod}@{selected|jack_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|dexterity_mod}@{selected|jack_attr}[DEX]]]&#125;&#125; |Constitution, +@{selected|constitution_mod}@{selected|jack_attr}[CON]]]&#125;&#125; {{rname=Constitution&#125;&#125; {{mod=@{selected|constitution_mod}@{selected|jack_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|constitution_mod}@{selected|jack_attr}[CON]]]&#125;&#125; |Intelligence, +@{selected|intelligence_mod}@{selected|jack_attr}[INT]]]&#125;&#125; {{rname=Intelligence&#125;&#125; {{mod=@{selected|intelligence_mod}@{selected|jack_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|intelligence_mod}@{selected|jack_attr}[INT]]]&#125;&#125; |Wisdom, +@{selected|wisdom_mod}@{selected|jack_attr}[WIS]]]&#125;&#125; {{rname=Wisdom&#125;&#125; {{mod=@{selected|wisdom_mod}@{selected|jack_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|wisdom_mod}@{selected|jack_attr}[WIS]]]&#125;&#125; |Charisma, +@{selected|charisma_mod}@{selected|jack_attr}[CHA]]]&#125;&#125; {{rname=Charisma&#125;&#125; {{mod=@{selected|charisma_mod}@{selected|jack_bonus}&#125;&#125; {{r1=[[ @{selected|d20} + @{selected|charisma_mod}@{selected|jack_attr}[CHA]]]&#125;&#125; } @{selected|global_skill_mod} @{selected|charname_output}"
  10584.                                         });
  10585.                                 }
  10586.                                 //for everyone
  10587.                                 character.abilities.create({
  10588.                                         name: "Initiative",
  10589.                                         istokenaction: true,
  10590.                                         action: d20plus.actionMacroInit
  10591.                                 });
  10592.                         }
  10593.                 });
  10594.  
  10595.                 // "Duplicate" option
  10596.                 first.after("<li data-action-type=\"cloneitem\">Duplicate</li>");
  10597.                 first.after("<li style=\"height: 10px;\">&nbsp;</li>");
  10598.                 $("#journalitemmenu ul").on(window.mousedowntype, "li[data-action-type=cloneitem]", function () {
  10599.                         var id = $currentItemTarget.attr("data-itemid");
  10600.                         var character = d20.Campaign.characters.get(id);
  10601.                         var handout = d20.Campaign.handouts.get(id);
  10602.                         d20plus.ut.log("Duplicating..");
  10603.                         if (character) {
  10604.                                 character.editview.render();
  10605.                                 character.editview.$el.find("button.duplicate").trigger("click");
  10606.                         }
  10607.                         if (handout) {
  10608.                                 handout.view.render();
  10609.                                 var json = handout.toJSON();
  10610.                                 delete json.id;
  10611.                                 json.name = "Copy of " + json.name;
  10612.                                 handout.collection.create(json, {
  10613.                                         success: function (h) {
  10614.                                                 handout._getLatestBlob("gmnotes", function (gmnotes) {
  10615.                                                         h.updateBlobs({gmnotes: gmnotes});
  10616.                                                 });
  10617.                                                 handout._getLatestBlob("notes", function (notes) {
  10618.                                                         h.updateBlobs({notes: notes});
  10619.                                                 });
  10620.                                         }
  10621.                                 });
  10622.                         }
  10623.                 });
  10624.  
  10625.                 // New command on FOLDERS
  10626.                 const last = $("#journalmenu ul li").last();
  10627.                 last.after("<li style=\"background-color: #FA5050; color: white;\" data-action-type=\"fulldelete\">Delete Folder + Contents</li>");
  10628.                 last.after("<li data-action-type=\"archiveall\">Archive All Contents</li>");
  10629.  
  10630.                 const $journalUl = $("#journalmenu ul");
  10631.  
  10632.                 $journalUl.on(window.mousedowntype, "li[data-action-type=fulldelete]", function () {
  10633.                         d20plus.journal.recursiveRemoveDirById(d20plus.journal.lastClickedFolderId, true);
  10634.                         d20plus.journal.lastClickedFolderId = null;
  10635.                         $("#journalmenu").hide();
  10636.                 });
  10637.  
  10638.                 $journalUl.on(window.mousedowntype, "li[data-action-type=archiveall]", function () {
  10639.                         d20plus.journal.recursiveArchiveDirById(d20plus.journal.lastClickedFolderId, true);
  10640.                         $("#journalmenu").hide();
  10641.                 });
  10642.         };
  10643.  
  10644.         /**
  10645.          * Takes a path made up of strings and arrays of strings, and turns it into one flat array of strings
  10646.          */
  10647.         d20plus.journal.getCleanPath = function (...path) {
  10648.                 const clean = [];
  10649.                 getStrings(clean, path);
  10650.                 return clean.map(s => s.trim()).filter(s => s);
  10651.  
  10652.                 function getStrings (stack, toProc) {
  10653.                         toProc.forEach(tp => {
  10654.                                 if (typeof tp === "string") {
  10655.                                         stack.push(tp);
  10656.                                 } else if (tp instanceof Array) {
  10657.                                         getStrings(stack, tp);
  10658.                                 } else {
  10659.                                         throw new Error("Object in path was not a string or an array")
  10660.                                 }
  10661.                         });
  10662.                 }
  10663.         };
  10664.  
  10665.         d20plus.journal.makeDirTree = function (...path) {
  10666.                 const parts = d20plus.journal.getCleanPath(path);
  10667.                 // path e.g. d20plus.journal.makeDirTree("Spells", "Cantrips", "1")
  10668.                 // roll20 allows a max directory depth of 4 :joy: (5, but the 5th level is unusable)
  10669.                 if (parts.length > 4) throw new Error("Max directory depth exceeded! The maximum is 4.")
  10670.  
  10671.                 const madeSoFar = [];
  10672.  
  10673.                 const root = {i: d20plus.ut.getJournalFolderObj()};
  10674.  
  10675.                 // roll20 folder management is dumb, so just pick the first folder with the right name if there's multiple
  10676.                 let curDir = root;
  10677.                 parts.forEach(toMake => {
  10678.                         const existing = curDir.i.find((it) => {
  10679.                                 // n is folder name (only folders have the n property)
  10680.                                 return it.n && it.n === toMake && it.i;
  10681.                         });
  10682.                         if (!existing) {
  10683.                                 if (curDir.id) {
  10684.                                         d20.journal.addFolderToFolderStructure(toMake, curDir.id);
  10685.                                 } else {
  10686.                                         // root has no id
  10687.                                         d20.journal.addFolderToFolderStructure(toMake);
  10688.                                 }
  10689.                         }
  10690.                         d20.journal.refreshJournalList();
  10691.                         madeSoFar.push(toMake);
  10692.  
  10693.                         // we have to save -> reread the entire directory JSON -> walk back to where we were
  10694.                         let nextDir = {i: JSON.parse(d20.Campaign.get("journalfolder"))};
  10695.                         madeSoFar.forEach(f => {
  10696.                                 nextDir = nextDir.i.find(dir => dir.n && (dir.n.toLowerCase() === f.toLowerCase()));
  10697.                         });
  10698.  
  10699.                         curDir = nextDir;
  10700.                 });
  10701.                 return curDir;
  10702.         };
  10703.  
  10704.         d20plus.journal.recursiveRemoveDirById = function (folderId, withConfirmation) {
  10705.                 if (!withConfirmation || confirm("Are you sure you want to delete this folder, and everything in it? This cannot be undone.")) {
  10706.                         const folder = $(`[data-globalfolderid='${folderId}']`);
  10707.                         if (folder.length) {
  10708.                                 d20plus.ut.log("Nuking directory...");
  10709.                                 const childItems = folder.find("[data-itemid]").each((i, e) => {
  10710.                                         const $e = $(e);
  10711.                                         const itemId = $e.attr("data-itemid");
  10712.                                         let toDel = d20.Campaign.handouts.get(itemId);
  10713.                                         toDel || (toDel = d20.Campaign.characters.get(itemId));
  10714.                                         if (toDel) toDel.destroy();
  10715.                                 });
  10716.                                 const childFolders = folder.find(`[data-globalfolderid]`).remove();
  10717.                                 folder.remove();
  10718.                                 $("#journalfolderroot").trigger("change");
  10719.                         }
  10720.                 }
  10721.         };
  10722.  
  10723.         d20plus.journal.recursiveArchiveDirById = function (folderId, withConfirmation) {
  10724.                 if (!withConfirmation || confirm("Are you sure you want to archive this folder, and everything in it? This cannot be undone.")) {
  10725.                         const folder = $(`[data-globalfolderid='${folderId}']`);
  10726.                         if (folder.length) {
  10727.                                 d20plus.ut.log("Archiving directory...");
  10728.                                 folder.find("[data-itemid]").each((i, e) => {
  10729.                                         const $e = $(e);
  10730.                                         const itemId = $e.attr("data-itemid");
  10731.                                         let toArchive = d20.Campaign.handouts.get(itemId);
  10732.                                         toArchive || (toArchive = d20.Campaign.characters.get(itemId));
  10733.                                         if (toArchive && toArchive.attributes) {
  10734.                                                 toArchive.attributes.archived = true;
  10735.                                                 toArchive.save()
  10736.                                         }
  10737.                                 });
  10738.                         }
  10739.                 }
  10740.         };
  10741.  
  10742.         d20plus.journal.removeDirByPath = function (...path) {
  10743.                 path = d20plus.journal.getCleanPath(path);
  10744.                 return d20plus.journal._checkOrRemoveDirByPath(true, path);
  10745.         };
  10746.  
  10747.         d20plus.journal.checkDirExistsByPath = function (...path) {
  10748.                 path = d20plus.journal.getCleanPath(path);
  10749.                 return d20plus.journal._checkOrRemoveDirByPath(false, path);
  10750.         };
  10751.  
  10752.         d20plus.journal._checkOrRemoveDirByPath = function (doDelete, path) {
  10753.                 const parts = d20plus.journal.getCleanPath(path);
  10754.  
  10755.                 const root = {i: d20plus.ut.getJournalFolderObj()};
  10756.  
  10757.                 let curDir = root;
  10758.                 for (let i = 0; i < parts.length; ++i) {
  10759.                         const p = parts[i];
  10760.                         let lastId;
  10761.                         const existing = curDir.i.find((it) => {
  10762.                                 lastId = it.id;
  10763.                                 // n is folder name (only folders have the n property)
  10764.                                 return it.n && it.n === p;
  10765.                         });
  10766.                         if (!existing) return false;
  10767.                         curDir = existing;
  10768.                         if (i === parts.length - 1) {
  10769.                                 d20plus.journal.recursiveRemoveDirById(lastId, false);
  10770.                                 return true;
  10771.                         }
  10772.                 }
  10773.         };
  10774.  
  10775.         d20plus.journal.getExportableJournal = () => {
  10776.                 // build a list of (id, path) pairs
  10777.                 const out = [];
  10778.  
  10779.                 function recurse (entry, pos) {
  10780.                         if (entry.i) {
  10781.                                 // pos.push({name: entry.n, id: entry.id}); // if IDs are required, use this instead?
  10782.                                 pos.push(entry.n);
  10783.                                 entry.i.forEach(nxt => recurse(nxt, pos));
  10784.                                 pos.pop();
  10785.                         } else {
  10786.                                 out.push({id: entry, path: MiscUtil.copy(pos)});
  10787.                         }
  10788.                 }
  10789.  
  10790.                 const root = {i: d20plus.ut.getJournalFolderObj(), n: "Root", id: "root"};
  10791.                 recurse(root, []);
  10792.                 return out;
  10793.         };
  10794.  
  10795.         d20plus.journal.removeFileByPath = function (...path) {
  10796.                 path = d20plus.journal.getCleanPath(path);
  10797.                 return d20plus.journal._checkOrRemoveFileByPath(true, path);
  10798.         };
  10799.  
  10800.         d20plus.journal.checkFileExistsByPath = function (...path) {
  10801.                 path = d20plus.journal.getCleanPath(path);
  10802.                 return d20plus.journal._checkOrRemoveFileByPath(false, path);
  10803.         };
  10804.  
  10805.         d20plus.journal._checkOrRemoveFileByPath = function (doDelete, path) {
  10806.                 const parts = d20plus.journal.getCleanPath(path);
  10807.  
  10808.                 const root = {i: d20plus.ut.getJournalFolderObj()};
  10809.  
  10810.                 let curDir = root;
  10811.                 for (let i = 0; i < parts.length; ++i) {
  10812.                         const p = parts[i];
  10813.                         let lastId;
  10814.                         const existing = curDir.i.find((it) => {
  10815.                                 if (i === parts.length - 1) {
  10816.                                         // for the last item, check handouts/characters to see if the match it (which could be a string ID)
  10817.                                         const char = d20.Campaign.characters.get(it);
  10818.                                         const handout = d20.Campaign.handouts.get(it);
  10819.                                         if ((char && char.get("name") === p) || (handout && handout.get("name") === p)) {
  10820.                                                 lastId = it;
  10821.                                                 return true;
  10822.                                         }
  10823.                                 } else {
  10824.                                         lastId = it.id;
  10825.                                         // n is folder name (only folders have the n property)
  10826.                                         return it.n && it.n === p;
  10827.                                 }
  10828.                                 return false;
  10829.                         });
  10830.                         if (!existing) return false;
  10831.                         curDir = existing;
  10832.                         if (i === parts.length - 1) {
  10833.                                 if (doDelete) {
  10834.                                         // on the last item, delete
  10835.                                         let toDel = d20.Campaign.handouts.get(lastId);
  10836.                                         toDel || (toDel = d20.Campaign.characters.get(lastId))
  10837.                                         if (toDel) toDel.destroy();
  10838.                                 }
  10839.                                 return true;
  10840.                         }
  10841.                 }
  10842.                 return false;
  10843.         };
  10844. }
  10845.  
  10846. SCRIPT_EXTENSIONS.push(d20plusJournal);
  10847.  
  10848.  
  10849. function baseCss () {
  10850.         d20plus.css = {};
  10851.  
  10852.         // Convert to regular CSS:
  10853.         // `[ ... rules ... ].map(it => `${it.s} {\n${it.r.split(";").map(str => str.trim()).join(";\n")}}\n`).join("\n")`
  10854.         d20plus.css.baseCssRules = [
  10855.                 // generic
  10856.                 {
  10857.                         s: ".inline-block, .display-inline-block",
  10858.                         r: "display: inline-block;"
  10859.                 },
  10860.                 {
  10861.                         s: ".bold",
  10862.                         r: "font-weight: bold;"
  10863.                 },
  10864.                 {
  10865.                         s: ".clickable",
  10866.                         r: "cursor: pointer;"
  10867.                 },
  10868.                 {
  10869.                         s: ".split",
  10870.                         r: "display: flex; justify-content: space-between;"
  10871.                 },
  10872.                 {
  10873.                         s: ".flex",
  10874.                         r: "display: flex;"
  10875.                 },
  10876.                 {
  10877.                         s: ".flex-col",
  10878.                         r: "display: flex; flex-direction: column;"
  10879.                 },
  10880.                 {
  10881.                         s: ".flex-v-center",
  10882.                         r: "display: flex; align-items: center;"
  10883.                 },
  10884.                 {
  10885.                         s: ".flex-vh-center",
  10886.                         r: "display: flex; justify-content: center; align-items: center;"
  10887.                 },
  10888.                 {
  10889.                         s: ".no-shrink",
  10890.                         r: "flex-shrink: 0;"
  10891.                 },
  10892.                 {
  10893.                         s: ".flex-1",
  10894.                         r: "flex: 1"
  10895.                 },
  10896.                 {
  10897.                         s: ".full-width",
  10898.                         r: "width: 100%;"
  10899.                 },
  10900.                 {
  10901.                         s: ".full-height",
  10902.                         r: "height: 100%;"
  10903.                 },
  10904.                 {
  10905.                         s: ".text-center",
  10906.                         r: "text-align: center;"
  10907.                 },
  10908.                 {
  10909.                         s: ".text-right",
  10910.                         r: "text-align: right;"
  10911.                 },
  10912.                 {
  10913.                         s: ".is-error",
  10914.                         r: "color: #d60000;"
  10915.                 },
  10916.                 {
  10917.                         s: ".flex-label",
  10918.                         r: "display: inline-flex; align-items: center;"
  10919.                 },
  10920.                 {
  10921.                         s: ".sel-xs",
  10922.                         r: `
  10923.                                 height: 18px;
  10924.                                 line-height: 18px;
  10925.                                 margin: 0;
  10926.                                 padding: 0;
  10927.                         `
  10928.                 },
  10929.                 {
  10930.                         s: ".btn-xs",
  10931.                         r: `
  10932.                                 height: 18px;
  10933.                                 line-height: 18px;
  10934.                                 margin: 0;
  10935.                                 padding: 0 4px;
  10936.                         `
  10937.                 },
  10938.                 // // fix Roll20's <p> margins in the text editor // FIXME make this configurable
  10939.                 // {
  10940.                 //      s: ".note-editable p",
  10941.                 //      r: "margin-bottom: 0;"
  10942.                 // },
  10943.                 // ensure rightclick menu width doesn't break layout // FIXME might be fixing the symptoms and not the cause
  10944.                 {
  10945.                         s: ".actions_menu.d20contextmenu > ul > li",
  10946.                         r: "max-width: 100px;"
  10947.                 },
  10948.                 // page view enhancement
  10949.                 {
  10950.                         s: "#page-toolbar",
  10951.                         r: "height: calc(90vh - 40px);"
  10952.                 },
  10953.                 {
  10954.                         s: "#page-toolbar .container",
  10955.                         r: "height: 100%; white-space: normal;"
  10956.                 },
  10957.                 {
  10958.                         s: "#page-toolbar .pages .availablepage",
  10959.                         r: "width: 100px; height: 100px;"
  10960.                 },
  10961.                 {
  10962.                         s: "#page-toolbar .pages .availablepage img.pagethumb",
  10963.                         r: "max-width: 60px; max-height: 60px;"
  10964.                 },
  10965.                 {
  10966.                         s: "#page-toolbar .pages .availablepage span",
  10967.                         r: "bottom: 1px;"
  10968.                 },
  10969.                 {
  10970.                         s: "#page-toolbar",
  10971.                         r: "background: #a8aaad80;"
  10972.                 },
  10973.                 // search
  10974.                 {
  10975.                         s: ".Vetoolsresult",
  10976.                         r: "background: #ff8080;"
  10977.                 },
  10978.                 // config editor
  10979.                 {
  10980.                         s: "div.config-table-wrapper",
  10981.                         r: "min-height: 200px; width: 100%; height: 100%; max-height: 460px; overflow-y: auto; transform: translateZ(0);"
  10982.                 },
  10983.                 {
  10984.                         s: "table.config-table",
  10985.                         r: "width: 100%; table-layout: fixed;"
  10986.                 },
  10987.                 {
  10988.                         s: "table.config-table tbody tr:nth-child(odd)",
  10989.                         r: "background-color: #f8f8f8;"
  10990.                 },
  10991.                 {
  10992.                         s: "table.config-table tbody td > *",
  10993.                         r: "vertical-align: middle; margin: 0;"
  10994.                 },
  10995.                 {
  10996.                         s: ".config-name",
  10997.                         r: "display: inline-block; line-height: 35px; width: 100%;"
  10998.                 },
  10999.                 // tool list
  11000.                 {
  11001.                         s: ".tools-list",
  11002.                         r: "max-height: 70vh;"
  11003.                 },
  11004.                 {
  11005.                         s: ".tool-row",
  11006.                         r: "min-height: 40px; display: flex; flex-direction: row; align-items: center;"
  11007.                 },
  11008.                 {
  11009.                         s: ".tool-row:nth-child(odd)",
  11010.                         r: "background-color: #f0f0f0;"
  11011.                 },
  11012.                 {
  11013.                         s: ".tool-row > *",
  11014.                         r: "flex-shrink: 0;"
  11015.                 },
  11016.                 // warning overlay
  11017.                 {
  11018.                         s: ".temp-warning",
  11019.                         r: "position: fixed; top: 12px; left: calc(50vw - 200px); z-index: 10000; width: 320px; background: transparent; color: red; font-weight: bold; font-size: 150%; font-variant: small-caps; border: 1px solid red; padding: 4px; text-align: center; border-radius: 4px;"
  11020.                 },
  11021.                 // GM hover text
  11022.                 {
  11023.                         s: ".Vetools-token-hover",
  11024.                         r: "pointer-events: none; position: fixed; z-index: 100000; background: white; padding: 5px 5px 0 5px; border-radius: 5px;     border: 1px solid #ccc; max-width: 450px;"
  11025.                 },
  11026.                 // drawing tools bar
  11027.                 {
  11028.                         s: "#drawingtools.line_splitter .currentselection:after",
  11029.                         r: "content: '✂️';"
  11030.                 },
  11031.                 // chat tag
  11032.                 {
  11033.                         s: ".userscript-hacker-chat",
  11034.                         r: "margin-left: -45px; margin-right: -5px; margin-bottom: -7px; margin-top: -15px; display: inline-block; font-weight: bold; font-family: 'Lucida Console', Monaco, monospace; color: #20C20E; background: black; padding: 3px; min-width: calc(100% + 60px);"
  11035.                 },
  11036.                 {
  11037.                         s: ".userscript-hacker-chat a",
  11038.                         r: "color: white;"
  11039.                 },
  11040.                 {
  11041.                         s: ".withoutavatars .userscript-hacker-chat",
  11042.                         r: "margin-left: -15px; min-width: calc(100% + 30px);"
  11043.                 },
  11044.                 {
  11045.                         s: ".Ve-btn-chat",
  11046.                         r: "margin-top: 10px; margin-left: -35px;"
  11047.                 },
  11048.                 {
  11049.                         s: ".withoutavatars .Ve-btn-chat",
  11050.                         r: "margin-left: -5px;"
  11051.                 },
  11052.                 // Bootstrap-alikes
  11053.                 {
  11054.                         s: ".col",
  11055.                         r: "display: inline-block;"
  11056.                 },
  11057.                 {
  11058.                         s: ".col-1",
  11059.                         r: "width: 8.333%;"
  11060.                 },
  11061.                 {
  11062.                         s: ".col-2",
  11063.                         r: "width: 16.666%;"
  11064.                 },
  11065.                 {
  11066.                         s: ".col-3",
  11067.                         r: "width: 25%;"
  11068.                 },
  11069.                 {
  11070.                         s: ".col-4",
  11071.                         r: "width: 33.333%;"
  11072.                 },
  11073.                 {
  11074.                         s: ".col-5",
  11075.                         r: "width: 41.667%;"
  11076.                 },
  11077.                 {
  11078.                         s: ".col-6",
  11079.                         r: "width: 50%;"
  11080.                 },
  11081.                 {
  11082.                         s: ".col-7",
  11083.                         r: "width: 58.333%;"
  11084.                 },
  11085.                 {
  11086.                         s: ".col-8",
  11087.                         r: "width: 66.667%;"
  11088.                 },
  11089.                 {
  11090.                         s: ".col-9",
  11091.                         r: "width: 75%;"
  11092.                 },
  11093.                 {
  11094.                         s: ".col-10",
  11095.                         r: "width: 83.333%;"
  11096.                 },
  11097.                 {
  11098.                         s: ".col-11",
  11099.                         r: "width: 91.667%;"
  11100.                 },
  11101.                 {
  11102.                         s: ".col-12",
  11103.                         r: "width: 100%;"
  11104.                 },
  11105.                 {
  11106.                         s: ".ib",
  11107.                         r: "display: inline-block;"
  11108.                 },
  11109.                 {
  11110.                         s: ".float-right",
  11111.                         r: "float: right;"
  11112.                 },
  11113.                 {
  11114.                         s: ".my-0",
  11115.                         r: "margin-top: 0 !important; margin-bottom: 0 !important;"
  11116.                 },
  11117.                 {
  11118.                         s: ".m-1",
  11119.                         r: "margin: 0.25rem !important;"
  11120.                 },
  11121.                 {
  11122.                         s: ".mt-2",
  11123.                         r: "margin-top: 0.5rem !important;"
  11124.                 },
  11125.                 {
  11126.                         s: ".mr-1",
  11127.                         r: "margin-right: 0.25rem !important;"
  11128.                 },
  11129.                 {
  11130.                         s: ".ml-1",
  11131.                         r: "margin-left: 0.25rem !important;"
  11132.                 },
  11133.                 {
  11134.                         s: ".mr-2",
  11135.                         r: "margin-right: 0.5rem !important;"
  11136.                 },
  11137.                 {
  11138.                         s: ".ml-2",
  11139.                         r: "margin-left: 0.5rem !important;"
  11140.                 },
  11141.                 {
  11142.                         s: ".mb-2",
  11143.                         r: "margin-bottom: 0.5rem !important;"
  11144.                 },
  11145.                 {
  11146.                         s: ".mb-1",
  11147.                         r: "margin-bottom: 0.25rem !important;"
  11148.                 },
  11149.                 {
  11150.                         s: ".p-2",
  11151.                         r: "padding: 0.5rem !important;"
  11152.                 },
  11153.                 {
  11154.                         s: ".p-3",
  11155.                         r: "padding: 1rem !important;"
  11156.                 },
  11157.                 {
  11158.                         s: ".split",
  11159.                         r: "display: flex; justify-content: space-between;"
  11160.                 },
  11161.                 {
  11162.                         s: ".split--center",
  11163.                         r: "align-items: center;"
  11164.                 },
  11165.                 // image rows
  11166.                 {
  11167.                         s: ".import-cb-label--img",
  11168.                         r: "display: flex; height: 64px; align-items: center; padding: 4px;"
  11169.                 },
  11170.                 {
  11171.                         s: ".import-label__img",
  11172.                         r: "display: inline-block; width: 60px; height: 60px; padding: 0 5px;"
  11173.                 },
  11174.                 // importer
  11175.                 {
  11176.                         s: ".import-cb-label",
  11177.                         r: "display: block; margin-right: -13px !important;"
  11178.                 },
  11179.                 {
  11180.                         s: ".import-cb-label span",
  11181.                         r: "display: inline-block; overflow: hidden; max-height: 18px; letter-spacing: -1px; font-size: 12px;"
  11182.                 },
  11183.                 {
  11184.                         s: ".import-cb-label span.readable",
  11185.                         r: "letter-spacing: initial"
  11186.                 },
  11187.                 {
  11188.                         s: ".import-cb-label .source",
  11189.                         r: "width: calc(16.667% - 28px);'"
  11190.                 },
  11191.                 // horizontal toolbar
  11192.                 {
  11193.                         s: "#secondary-toolbar:hover",
  11194.                         r: "opacity: 1 !important;"
  11195.                 },
  11196.                 // addon layer bar
  11197.                 {
  11198.                         s: "#floatinglayerbar ul",
  11199.                         r: "margin: 0; padding: 0;"
  11200.                 },
  11201.                 {
  11202.                         s: "#floatinglayerbar li:hover, #floatinglayerbar li.activebutton",
  11203.                         r: "color: #333; background-color: #54C3E8; cursor: pointer;"
  11204.                 },
  11205.                 {
  11206.                         s: "#floatinglayerbar li",
  11207.                         r: "padding: 3px; margin: 0; border-bottom: 1px solid #999; display: block; text-align: center; line-height: 22px; font-size: 22px; color: #999; position: relative;"
  11208.                 },
  11209.                 {
  11210.                         s: "#floatinglayerbar.map li.choosemap, #floatinglayerbar.objects li.chooseobjects, #floatinglayerbar.gmlayer li.choosegmlayer, #floatinglayerbar.walls li.choosewalls, #floatinglayerbar.weather li.chooseweather, #floatinglayerbar.foreground li.chooseforeground, #floatinglayerbar.background li.choosebackground",
  11211.                         r: "background-color: #54C3E8; color: #333;"
  11212.                 },
  11213.                 // extra layer buttons
  11214.                 {
  11215.                         s: "#editinglayer.weather div.submenu li.chooseweather, #editinglayer.foreground div.submenu li.chooseforeground, #editinglayer.background div.submenu li.choosebackground",
  11216.                         r: "background-color: #54C3E8; color: #333;"
  11217.                 },
  11218.                 {
  11219.                         s: "#editinglayer.weather .currentselection:after",
  11220.                         r: "content: \"C\";"
  11221.                 },
  11222.                 {
  11223.                         s: "#editinglayer.foreground .currentselection:after",
  11224.                         r: "content: \"B\";"
  11225.                 },
  11226.                 {
  11227.                         s: "#editinglayer.background .currentselection:after",
  11228.                         r: "content: \"a\";"
  11229.                 },
  11230.                 // adjust the "Talking to Yourself" box
  11231.                 {
  11232.                         s: "#textchat-notifier",
  11233.                         r: "top: -5px; background-color: red; opacity: 0.5; color: white;"
  11234.                 },
  11235.                 {
  11236.                         s: "#textchat-notifier:after",
  11237.                         r: "content: '!'"
  11238.                 },
  11239.                 {
  11240.                         s: ".ctx__layer-icon",
  11241.                         r: `
  11242.                         display: inline-block;
  11243.                         width: 12px;
  11244.                         text-align: center;
  11245.                         `
  11246.                 },
  11247.                 // fix the shitty undersized "fire" icon
  11248.                 {
  11249.                         s: ".choosewalls > .pictostwo",
  11250.                         r: "width: 15px; height: 17px; display: inline-block; text-align: center;"
  11251.                 },
  11252.                 {
  11253.                         s: "#editinglayer.walls > .pictos",
  11254.                         r: "width: 20px; height: 22px; display: inline-block; text-align: center; font-size: 0.9em;"
  11255.                 },
  11256.                 // weather config window
  11257.                 {
  11258.                         s: ".ui-dialog .wth__row",
  11259.                         r: "margin-bottom: 10px; align-items: center; padding: 0 0 5px; border-bottom: 1px solid #eee;"
  11260.                 },
  11261.                 {
  11262.                         s: ".wth__row select",
  11263.                         r: "margin-bottom: 0"
  11264.                 },
  11265.                 {
  11266.                         s: `.wth__row input[type="range"]`,
  11267.                         r: "width: calc(100% - 8px);"
  11268.                 },
  11269.                 // context menu
  11270.                 {
  11271.                         s: `.ctx__divider`,
  11272.                         r: "width: calc(100% - 2px); border: 1px solid black;"
  11273.                 },
  11274.                 // sidebar fix
  11275.                 // {
  11276.                 //      s: `#rightsidebar`,
  11277.                 //      r: `
  11278.                 //          display: flex;
  11279.                 //          flex-direction: column;
  11280.                 //      `
  11281.                 // },
  11282.                 // {
  11283.                 //      s: `#rightsidebar ul.tabmenu`,
  11284.                 //      r: `
  11285.                 //          padding: 0;
  11286.         //              flex-shrink: 0;
  11287.         //              position: relative;
  11288.         //              top: 0;
  11289.         //              width: 100%;
  11290.                 //      `
  11291.                 // },
  11292.                 // {
  11293.                 //      s: `#rightsidebar .ui-tabs-panel`,
  11294.                 //      r: `
  11295.                 //              height: 100% !important;
  11296.                 //              display: block;
  11297.                 //              top: 0;
  11298.                 //      `
  11299.                 // },
  11300.                 // {
  11301.                 //      s: `#textchat-input`,
  11302.                 //      r: `
  11303.                 //              position: relative;
  11304.         //              flex-shrink: 0;
  11305.                 //      `
  11306.                 // },
  11307.                 // {
  11308.                 //      s: `#textchat-input textarea`,
  11309.                 //      r: `
  11310.                 //              width: calc(100% - 8px) !important;
  11311.                 //              resize: vertical;
  11312.                 //      `
  11313.                 // },
  11314.         ];
  11315.  
  11316.         d20plus.css.baseCssRulesPlayer = [
  11317.                 {
  11318.                         s: ".player-hidden",
  11319.                         r: "display: none !important;"
  11320.                 }
  11321.         ];
  11322.  
  11323.         d20plus.css.cssRules = []; // other scripts should populate this
  11324.  
  11325.         // Mirrors of 5etools CSS
  11326.         d20plus.css.cssRules = d20plus.css.cssRules.concat([
  11327.                 {
  11328.                         s: ".copied-tip",
  11329.                         r: "pointer-events: none; position: fixed; background: transparent; user-select: none; z-index: 100000; width: 80px; height: 24px; line-height: 24px;"
  11330.                 },
  11331.                 {
  11332.                         s: ".copied-tip > span",
  11333.                         r: "display: inline-block; width: 100%; text-align: center;"
  11334.                 },
  11335.                 {
  11336.                         s: ".help",
  11337.                         r: "cursor: help; text-decoration: underline; text-decoration-style: dotted;"
  11338.                 },
  11339.                 {
  11340.                         s: ".help--subtle",
  11341.                         r: "cursor: help;"
  11342.                 }
  11343.         ]);
  11344.  
  11345.         // Art repo browser CSS
  11346.         d20plus.css.cssRules = d20plus.css.cssRules.concat([
  11347.                 // full-width images search header
  11348.                 {
  11349.                         s: "#imagedialog .searchbox",
  11350.                         r: "width: calc(100% - 10px)"
  11351.                 },
  11352.                 ///////////////
  11353.                 {
  11354.                         s: ".artr__win",
  11355.                         r: "display: flex; align-items: stretch; width: 100%; height: 100%; padding: 0 !important;"
  11356.                 },
  11357.                 // fix box sizing
  11358.                 {
  11359.                         s: ".artr__win *",
  11360.                         r: "box-sizing: border-box;"
  11361.                 },
  11362.                 // custom scrollbars
  11363.                 {
  11364.                         s: ".artr__win *::-webkit-scrollbar",
  11365.                         r: "width: 9px; height: 9px;"
  11366.                 },
  11367.                 {
  11368.                         s: ".artr__win *::-webkit-scrollbar-track",
  11369.                         r: "background: transparent;"
  11370.                 },
  11371.                 {
  11372.                         s: ".artr__win *::-webkit-scrollbar-thumb",
  11373.                         r: "background: #cbcbcb;"
  11374.                 },
  11375.                 ///////////////
  11376.                 {
  11377.                         s: ".artr__side",
  11378.                         r: "width: 300px; height: 100%; border-right: 1px solid #ccc; background: #f8f8f8; position: relative; flex-shrink: 0; display: flex; flex-direction: column;"
  11379.                 },
  11380.                 {
  11381.                         s: ".artr__side__head",
  11382.                         r: "flex-shrink: 0; font-weight: bold; margin-bottom: 7px; margin-bottom: 7px; border-bottom: 3px solid #ccc; background: white;"
  11383.                 },
  11384.                 {
  11385.                         s: ".artr__side__head__title",
  11386.                         r: "font-size: 16px; font-weight: bold;"
  11387.                 },
  11388.                 {
  11389.                         s: ".artr__side__body",
  11390.                         r: "flex-shrink: 0; overflow-y: auto; transform: translateZ(0);"
  11391.                 },
  11392.                 {
  11393.                         s: ".artr__side__tag_header",
  11394.                         r: "width: 100%; border-bottom: 1px solid #ccc; display: flex; justify-content: space-between; padding: 0 6px; cursor: pointer; margin-bottom: 10px;"
  11395.                 },
  11396.                 {
  11397.                         s: ".artr__side__tag_grid",
  11398.                         r: "display: flex; width: 100%; flex-wrap: wrap; margin-bottom: 15px; background: #f0f0f0; border-radius: 5px;"
  11399.                 },
  11400.                 {
  11401.                         s: ".artr__side__tag",
  11402.                         r: "padding: 2px 4px; margin: 2px 4px; font-size: 11px;"
  11403.                 },
  11404.                 {
  11405.                         s: `.artr__side__tag[data-state="1"]`,
  11406.                         r: "background-image: linear-gradient(#fff, #337ab7);"
  11407.                 },
  11408.                 {
  11409.                         s: `.artr__side__tag[data-state="1"]:hover`,
  11410.                         r: "background-image: linear-gradient(rgb(#337ab7), rgb(#337ab7)); background-position: 0; transition: none;"
  11411.                 },
  11412.                 {
  11413.                         s: `.artr__side__tag[data-state="2"]`,
  11414.                         r: "background-image: linear-gradient(#fff, #8a1a1b);"
  11415.                 },
  11416.                 {
  11417.                         s: `.artr__side__tag[data-state="2"]:hover`,
  11418.                         r: "background-image: linear-gradient(rgb(#8a1a1b), rgb(#8a1a1b)); background-position: 0; transition: none;"
  11419.                 },
  11420.                 {
  11421.                         s: ".artr__main",
  11422.                         r: "width: 100%; height: 100%; display: flex; overflow-y: auto; flex-direction: column; position: relative;"
  11423.                 },
  11424.                 {
  11425.                         s: ".artr__side__loading, .artr__main__loading",
  11426.                         r: "width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;     font-style: italic;"
  11427.                 },
  11428.                 {
  11429.                         s: ".artr__bread",
  11430.                         r: "width: 100%; margin-bottom: 2px;"
  11431.                 },
  11432.                 {
  11433.                         s: ".artr__crumb",
  11434.                         r: "border: 1px solid #ccc; border-radius: 5px; padding: 0 5px; display: inline-block; cursor: pointer; user-select: none;"
  11435.                 },
  11436.                 {
  11437.                         s: ".artr__crumb--sep",
  11438.                         r: "border: 0; cursor: default;"
  11439.                 },
  11440.                 {
  11441.                         s: ".artr__search",
  11442.                         r: "flex-shrink: 0; width: 100%; border-bottom: 1px solid #ccc; display: flex; flex-direction: column;"
  11443.                 },
  11444.                 {
  11445.                         s: ".artr__search__field",
  11446.                         r: "width: 100%; height: 26px;"
  11447.                 },
  11448.                 {
  11449.                         s: ".artr__view",
  11450.                         r: "position: absolute; top: 64px; bottom: 0; left: 0; right: 0; overflow-y: auto; transform: translateZ(0); background-color: whitesmoke;"
  11451.                 },
  11452.                 {
  11453.                         s: ".artr__view_inner",
  11454.                         r: "display: flex; width: 100%; height: 100%; flex-wrap: wrap; align-content: flex-start;"
  11455.                 },
  11456.                 {
  11457.                         s: ".artr__no_results_wrp",
  11458.                         r: "width: 100%; height: 100%; display: flex; justify-content: center;"
  11459.                 },
  11460.                 {
  11461.                         s: ".artr__no_results",
  11462.                         r: "width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;"
  11463.                 },
  11464.                 {
  11465.                         s: ".artr__no_results_headline",
  11466.                         r: "font-size: 125%; font-weight: bold;"
  11467.                 },
  11468.                 {
  11469.                         s: ".artr__item",
  11470.                         r: "width: 180px; margin: 5px; box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.75); display: block; background: white; position: relative;"
  11471.                         // Using flex makes scrolling extremely sluggish
  11472.                         // display: flex; flex-direction: column; cursor: pointer; float: left;
  11473.                 },
  11474.                 {
  11475.                         s: ".artr__item__stats",
  11476.                         r: "position: absolute; left: 0; top: 0; display: none;"
  11477.                 },
  11478.                 {
  11479.                         s: ".artr__item:hover .artr__item__stats",
  11480.                         r: "display: block;"
  11481.                 },
  11482.                 {
  11483.                         s: ".artr__item__stats_item",
  11484.                         r: "color: grey; background: white; border-radius: 5px; margin: 4px 2px; padding: 0 2px; text-align: center; border: 1px solid #e0e0e0"
  11485.                 },
  11486.                 {
  11487.                         s: ".artr__item__menu",
  11488.                         r: "position: absolute; right: 0; top: 0; display: none;"
  11489.                 },
  11490.                 {
  11491.                         s: ".artr__item:hover .artr__item__menu",
  11492.                         r: "display: block;"
  11493.                 },
  11494.                 {
  11495.                         s: ".artr__item__menu_item",
  11496.                         r: "cursor: pointer; color: grey; font-size: 26px; line-height: 24px; border-radius: 5px; margin: 4px; padding: 2px; text-align: center; display: block; border: 1px solid #ccc; background: white;"
  11497.                 },
  11498.                 {
  11499.                         s: ".artr__item--index",
  11500.                         r: "height: 240px;"
  11501.                 },
  11502.                 {
  11503.                         s: ".artr__item--item",
  11504.                         r: "height: 180px;"
  11505.                 },
  11506.                 {
  11507.                         s: ".artr__item:hover",
  11508.                         r: "box-shadow: 0 0 8px 0 rgba(38, 167, 242, 1); opacity: 0.95;"
  11509.                 },
  11510.                 {
  11511.                         s: ".artr__item--back",
  11512.                         r: "display: flex; justify-content: center; align-items: center; font-size: 24px; color: #888;"
  11513.                 },
  11514.                 {
  11515.                         s: ".artr__item__top",
  11516.                         r: "width: 100%; height: 180px; flex-shrink: 0; margin: 0 auto; display: flex; align-items: center;"
  11517.                 },
  11518.                 {
  11519.                         s: ".artr__item__top--quart",
  11520.                         r: "display: flex; flex-wrap: wrap;"
  11521.                 },
  11522.                 {
  11523.                         s: ".artr__item__bottom",
  11524.                         r: "width: 100%; height: 60px; flex-shrink: 0;  border-top: 1px solid #ccc; background: #f8f8f8; display: flex; flex-direction: column; font-size: 12px; justify-content: space-evenly;"
  11525.                 },
  11526.                 {
  11527.                         s: ".artr__item__bottom__row",
  11528.                         r: "width: 100% height: 20px; flex-shrink: 0; padding: 4px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
  11529.                 },
  11530.                 {
  11531.                         s: ".artr__item__thumbnail",
  11532.                         r: "max-width: 100%; max-height: 100%; display: block; margin: 0 auto;"
  11533.                 },
  11534.                 {
  11535.                         s: ".atr__item__quart",
  11536.                         r: "width: 50%; height: 50%; display: block; margin: 0;"
  11537.                 },
  11538.                 {
  11539.                         s: ".atr__item__quart--more",
  11540.                         r: "display: flex; justify-content: center; align-items: center;"
  11541.                 },
  11542.                 {
  11543.                         s: ".artr__item__full",
  11544.                         r: "width: 100%; height: 180px; margin: 0 auto; display: flex; align-items: center; padding: 3px;"
  11545.                 },
  11546.                 {
  11547.                         s: ".artr__wrp_big_img",
  11548.                         r: "position: fixed; top: 0; bottom: 0; right: 0; left: 0; background: #30303080; padding: 30px; display: flex; justify-content: center; align-items: center; z-index: 99999;"
  11549.                 },
  11550.                 {
  11551.                         s: ".artr__big_img",
  11552.                         r: "display: block; max-width: 100%; max-height: 100%;"
  11553.                 },
  11554.         ]);
  11555.  
  11556.         // Animator CSS -- `anm__` prefix
  11557.         d20plus.css.cssRules = d20plus.css.cssRules.concat([
  11558.                 // fix box sizing
  11559.                 {
  11560.                         s: ".anm__win *",
  11561.                         r: "box-sizing: border-box;"
  11562.                 },
  11563.                 {
  11564.                         s: ".ui-dialog .anm__row",
  11565.                         r: `
  11566.                         display: flex;
  11567.                         align-items: center;
  11568.                         margin-bottom: 3px;
  11569.                         height: 20px;
  11570.                         `
  11571.                 },
  11572.                 {
  11573.                         s: ".anm__row > div",
  11574.                         r: `
  11575.                                 display: inline-flex;
  11576.                         `
  11577.                 },
  11578.                 {
  11579.                         s: ".anm__row-btn",
  11580.                         r: `
  11581.                                 padding: 0 6px;
  11582.                         `
  11583.                 },
  11584.                 {
  11585.                         s: ".anm__row-wrp-cb",
  11586.                         r: `
  11587.                                 justify-content: center;
  11588.                                 align-items: center;
  11589.                         `
  11590.                 },
  11591.                 {
  11592.                         s: ".anm__wrp-sel-all",
  11593.                         r: `
  11594.                                 align-items: center;
  11595.                                 margin-bottom: 5px;
  11596.                                 display: flex;
  11597.                                 justify-content: space-between;
  11598.                         `
  11599.                 },
  11600.                 {
  11601.                         s: ".anm-edit__ipt-lines-wrp",
  11602.                         r: `
  11603.                                 flex-basis: 100%;
  11604.                                 flex-shrink: 100;
  11605.                         `
  11606.                 },
  11607.                 {
  11608.                         s: ".anm-edit__gui .anm-edit__gui-hidden",
  11609.                         r: `
  11610.                                 display: none;
  11611.                         `
  11612.                 },
  11613.                 {
  11614.                         s: ".anm-edit__text .anm-edit__gui-visible",
  11615.                         r: `
  11616.                                 display: none;
  11617.                         `
  11618.                 },
  11619.                 {
  11620.                         s: ".anm-edit__ipt-lines-wrp--gui",
  11621.                         r: `
  11622.                                 overflow-y: auto;
  11623.                                 display: flex;
  11624.                                 flex-direction: column;
  11625.                         `
  11626.                 },
  11627.                 {
  11628.                         s: ".anm-edit__ipt-lines-wrp--gui > *",
  11629.                         r: `
  11630.                                 flex-shrink: 0;
  11631.                         `
  11632.                 },
  11633.                 {
  11634.                         s: ".anm-edit__ipt-lines",
  11635.                         r: `
  11636.                                 resize: none;
  11637.                                 width: 100%;
  11638.                                 height: 100%;
  11639.                                 margin-bottom: 0;
  11640.                         `
  11641.                 },
  11642.                 {
  11643.                         s: ".anm-edit__gui-row",
  11644.                         r: `
  11645.                                 padding: 4px;
  11646.                                 border: 1px solid #ccc;
  11647.                                 border-radius: 3px;
  11648.                                 margin-bottom: 3px;
  11649.                         `
  11650.                 },
  11651.                 {
  11652.                         s: ".anm-edit__gui-row:nth-child(even)",
  11653.                         r: `
  11654.                                 background: #f8f8f8;
  11655.                         `
  11656.                 },
  11657.                 {
  11658.                         s: ".anm-edit__gui-row-name",
  11659.                         r: `
  11660.                                 color: white;
  11661.                                 -webkit-text-stroke: 1px #555;
  11662.                                 text-stroke: 1px black;
  11663.                                 padding: 3px 5px;
  11664.                                 border-radius: 3px;
  11665.                                 font-size: 16px;
  11666.                                 display: inline-block;
  11667.                                 min-width: 150px;
  11668.                         `
  11669.                 },
  11670.                 {
  11671.                         s: ".anm-edit__gui-row-name--Move",
  11672.                         r: `
  11673.                                 background: #ff0004;
  11674.                         `
  11675.                 },
  11676.                 {
  11677.                         s: ".anm-edit__gui-row-name--Rotate",
  11678.                         r: `
  11679.                                 background: #ff6c00;
  11680.                         `
  11681.                 },
  11682.                 {
  11683.                         s: ".anm-edit__gui-row-name--Copy",
  11684.                         r: `
  11685.                                 background: #fff700;
  11686.                         `
  11687.                 },
  11688.                 {
  11689.                         s: ".anm-edit__gui-row-name--Flip",
  11690.                         r: `
  11691.                                 background: #a3ff00;
  11692.                         `
  11693.                 },
  11694.                 {
  11695.                         s: ".anm-edit__gui-row-name--Scale",
  11696.                         r: `
  11697.                                 background: #5eff00;
  11698.                         `
  11699.                 },
  11700.                 {
  11701.                         s: ".anm-edit__gui-row-name--Layer",
  11702.                         r: `
  11703.                                 background: #00ff25;
  11704.                         `
  11705.                 },
  11706.                 {
  11707.                         s: ".anm-edit__gui-row-name--Lighting",
  11708.                         r: `
  11709.                                 background: #00ffb6;
  11710.                         `
  11711.                 },
  11712.                 {
  11713.                         s: [
  11714.                                 ".anm-edit__gui-row-name--SetProperty",
  11715.                                 ".anm-edit__gui-row-name--SumProperty"
  11716.                         ],
  11717.                         r: `
  11718.                                 background: #006bff;
  11719.                         `
  11720.                 },
  11721.                 {
  11722.                         s: ".anm-edit__gui-row-name--TriggerMacro",
  11723.                         r: `
  11724.                                 background: #0023ff;
  11725.                         `
  11726.                 },
  11727.                 {
  11728.                         s: ".anm-edit__gui-row-name--TriggerAnimation",
  11729.                         r: `
  11730.                                 background: #9800ff;
  11731.                         `
  11732.                 },
  11733.                 {
  11734.                         s: ".anm-scene__wrp-tokens",
  11735.                         r: `
  11736.                                 width: 100%;
  11737.                                 max-height: 100%;
  11738.                                 overflow-y: auto;
  11739.                                 display: flex;
  11740.                                 flex-wrap: wrap;
  11741.                         `
  11742.                 },
  11743.                 {
  11744.                         s: ".anm-scene__wrp-token",
  11745.                         r: `
  11746.                                 width: 80px;
  11747.                                 height: 100px;
  11748.                                 background: #f0f0f0;
  11749.                                 box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.75);
  11750.                                 margin: 4px;
  11751.                                 display: flex;
  11752.                                 flex-direction: column;
  11753.                                 padding: 3px;
  11754.                         `
  11755.                 },
  11756.                 {
  11757.                         s: ".anm-scene__wrp-token--active",
  11758.                         r: `
  11759.                                 background: #a0f0ff;
  11760.                         `
  11761.                 },
  11762.                 {
  11763.                         s: ".anm-scene__wrp-token-name",
  11764.                         r: `
  11765.                                 height: 20px;
  11766.                                 overflow: hidden;
  11767.                         `
  11768.                 },
  11769.                 {
  11770.                         s: ".anm-scene__wrp-token-name-inner",
  11771.                         r: `
  11772.                                 height: 20px;
  11773.                                 overflow: hidden;
  11774.                                 text-overflow: ellipsis;
  11775.                                 white-space: nowrap;
  11776.                         `
  11777.                 }
  11778.         ]);
  11779.  
  11780.         // Jukebox CSS
  11781.         d20plus.css.cssRules = d20plus.css.cssRules.concat([
  11782.                 {
  11783.                         s: ".jukebox-widget-button",
  11784.                         r: `
  11785.                         flex: 1;
  11786.                         text-overflow: ellipsis;
  11787.                         overflow: hidden;
  11788.                         min-width: 50px;
  11789.                         `
  11790.                 },
  11791.                 {
  11792.                         s: ".jukebox-widget-slider",
  11793.                         r: `
  11794.                         margin: 10px;
  11795.                         display: inline-block;
  11796.                         flex: 15;
  11797.                         `
  11798.                 },
  11799.                 {
  11800.                         s: ".jukebox-widget-button",
  11801.                         r: `
  11802.                         letter-spacing: -1px
  11803.                         `
  11804.                 },
  11805.         ]);
  11806. }
  11807.  
  11808. SCRIPT_EXTENSIONS.push(baseCss);
  11809.  
  11810.  
  11811. function baseUi () {
  11812.         d20plus.ui = {};
  11813.  
  11814.         d20plus.ui.addHtmlHeader = () => {
  11815.                 d20plus.ut.log("Add HTML");
  11816.                 const $body = $("body");
  11817.  
  11818.                 const $wrpSettings = $(`<div id="betteR20-settings"/>`);
  11819.                 $("#mysettings > .content").children("hr").first().before($wrpSettings);
  11820.  
  11821.                 $wrpSettings.append(d20plus.settingsHtmlHeader);
  11822.                 $body.append(d20plus.configEditorHTML);
  11823.                 if (window.is_gm) {
  11824.                         $(`#imagedialog`).find(`.searchbox`).find(`.tabcontainer`).first().after(d20plus.artTabHtml);
  11825.                         $(`#button-add-external-art`).on(window.mousedowntype, d20plus.art.button);
  11826.  
  11827.                         $body.append(d20plus.addArtHTML);
  11828.                         $body.append(d20plus.addArtMassAdderHTML);
  11829.                         $body.append(d20plus.tool.toolsListHtml);
  11830.                         $("#d20plus-artfolder").dialog({
  11831.                                 autoOpen: false,
  11832.                                 resizable: true,
  11833.                                 width: 1000,
  11834.                                 height: 800,
  11835.                         });
  11836.                         $("#d20plus-artmassadd").dialog({
  11837.                                 autoOpen: false,
  11838.                                 resizable: true,
  11839.                                 width: 800,
  11840.                                 height: 650,
  11841.                         });
  11842.                 }
  11843.                 const $cfgEditor = $("#d20plus-configeditor");
  11844.                 $cfgEditor.dialog({
  11845.                         autoOpen: false,
  11846.                         resizable: true,
  11847.                         width: 800,
  11848.                         height: 650,
  11849.                 });
  11850.                 $cfgEditor.parent().append(d20plus.configEditorButtonBarHTML);
  11851.  
  11852.                 // shared GM/player conent
  11853.                 // quick search box
  11854.                 const $iptSearch = $(`<input id="player-search" class="ui-autocomplete-input" autocomplete="off" placeholder="Quick search by name...">`);
  11855.                 const $wrprResults = $(`<div id="player-search-results" class="content searchbox"/>`);
  11856.  
  11857.                 if (window.is_gm) {
  11858.                         $iptSearch.css("width", "calc(100% - 5px)");
  11859.                         const $addPoint = $("#journal").find("button.btn.superadd");
  11860.                         $addPoint.after($wrprResults);
  11861.                         $addPoint.after(`<br>`);
  11862.                         $addPoint.after($iptSearch);
  11863.                         $addPoint.after(`<br><br>`);
  11864.                 } else {
  11865.                         const $wrprControls = $(`<div class="content searchbox" id="search-wrp-controls"/>`);
  11866.                         $(`#journal .content`).before($wrprControls).before($wrprResults);
  11867.                         $iptSearch.css("max-width", "calc(100% - 140px)");
  11868.                         $wrprControls.append($iptSearch);
  11869.                 }
  11870.                 d20plus.engine.initQuickSearch($iptSearch, $wrprResults);
  11871.         };
  11872.  
  11873.         d20plus.ui.addHtmlFooter = () => {
  11874.                 const $wrpSettings = $(`#betteR20-settings`);
  11875.                 $wrpSettings.append(d20plus.settingsHtmlPtFooter);
  11876.  
  11877.                 $("#mysettings > .content a#button-edit-config").on(window.mousedowntype, d20plus.cfg.openConfigEditor);
  11878.                 $("#button-manage-qpi").on(window.mousedowntype, qpi._openManager);
  11879.                 d20plus.tool.addTools();
  11880.         };
  11881.  
  11882.         d20plus.ui.addQuickUiGm = () => {
  11883.                 const $wrpBtnsMain = $(`#floatingtoolbar`);
  11884.  
  11885.                 // add quick layer selection panel
  11886.                 const $ulBtns = $(`<div id="floatinglayerbar"><ul/></div>`)
  11887.                         .css({
  11888.                                 width: 30,
  11889.                                 position: "absolute",
  11890.                                 left: 20,
  11891.                                 top: $wrpBtnsMain.height() + 45,
  11892.                                 border: "1px solid #666",
  11893.                                 boxShadow: "1px 1px 3px #666",
  11894.                                 zIndex: 10600,
  11895.                                 backgroundColor: "rgba(255,255,255,0.80)"
  11896.                         })
  11897.                         .appendTo($(`body`)).find(`ul`);
  11898.  
  11899.                 const handleClick = (clazz, evt) => $wrpBtnsMain.find(`.${clazz}`).trigger("click", evt);
  11900.                 $(`<li title="Map" class="choosemap"><span class="pictos" style="padding: 0 3px;">@</span></li>`).appendTo($ulBtns).click((evt) => handleClick(`choosemap`, evt));
  11901.                 $(`<li title="Background" class="choosebackground"><span class="pictos">a</span></li>`).appendTo($ulBtns).click((evt) => handleClick(`choosebackground`, evt));
  11902.                 $(`<li title="Objects & Tokens" class="chooseobjects"><span class="pictos">b</span></li>`).appendTo($ulBtns).click((evt) => handleClick(`chooseobjects`, evt));
  11903.                 $(`<li title="Foreground" class="chooseforeground"><span class="pictos">B</span></li>`).appendTo($ulBtns).click((evt) => handleClick(`chooseforeground`, evt));
  11904.                 $(`<li title="GM Info Overlay" class="choosegmlayer"><span class="pictos">E</span></li>`).appendTo($ulBtns).click((evt) => handleClick(`choosegmlayer`, evt));
  11905.                 $(`<li title="Dynamic Lighting" class="choosewalls"><span class="pictostwo">r</span></li>`).appendTo($ulBtns).click((evt) => handleClick(`choosewalls`, evt));
  11906.                 $(`<li title="Weather Exclusions" class="chooseweather"><span class="pictos">C</span></li>`).appendTo($ulBtns).click((evt) => handleClick(`chooseweather`, evt));
  11907.  
  11908.                 $("body").on("click", "#editinglayer li", function () {
  11909.                         $("#floatinglayerbar").removeClass("map")
  11910.                                 .removeClass("background")
  11911.                                 .removeClass("objects")
  11912.                                 .removeClass("foreground")
  11913.                                 .removeClass("gmlayer")
  11914.                                 .removeClass("walls")
  11915.                                 .removeClass("weather");
  11916.                         setTimeout(() => {
  11917.                                 $("#floatinglayerbar").addClass(window.currentEditingLayer)
  11918.                         }, 1);
  11919.                 });
  11920.  
  11921.                 // add "desc sort" button to init tracker
  11922.                 const $initTracker = $(`#initiativewindow`);
  11923.                 const addInitSortBtn = () => {
  11924.                         $(`<div class="btn" id="init-quick-sort-desc" style="margin-right: 5px;"><span class="pictos">}</span></div>`).click(() => {
  11925.                                 // this will throw a benign error if the settings dialog has never been opened
  11926.                                 $("#initiativewindow_settings .sortlist_numericdesc").click();
  11927.                         }).prependTo($initTracker.parent().find(`.ui-dialog-buttonset`));
  11928.                 };
  11929.                 if (d20.Campaign.initiativewindow.model.attributes.initiativepage) {
  11930.                         addInitSortBtn();
  11931.                 } else {
  11932.                         d20.Campaign.initiativewindow.model.on("change", (e) => {
  11933.                                 if (d20.Campaign.initiativewindow.model.attributes.initiativepage && $(`#init-quick-sort-desc`).length === 0) {
  11934.                                         addInitSortBtn();
  11935.                                         d20plus.cfg.baseHandleConfigChange();
  11936.                                 }
  11937.                         })
  11938.                 }
  11939.         };
  11940. }
  11941.  
  11942. SCRIPT_EXTENSIONS.push(baseUi);
  11943.  
  11944.  
  11945. /**
  11946.  * All the modified minified based on parts of Roll20's `app.js`
  11947.  */
  11948. function d20plusMod() {
  11949.         d20plus.mod = {};
  11950.  
  11951.         // modified to allow players to use the FX tool, and to keep current colour selections when switching tool
  11952.         // BEGIN ROLL20 CODE
  11953.         d20plus.mod.setMode = function (e) {
  11954.                 d20plus.ut.log("Setting mode " + e);
  11955.                 // BEGIN MOD
  11956.                 // "text" === e || "rect" === e || "polygon" === e || "path" === e || "pan" === e || "select" === e || "targeting" === e || "measure" === e || window.is_gm || (e = "select"),
  11957.                 // END MOD
  11958.                 "text" == e ? $("#editor").addClass("texteditmode") : $("#editor").removeClass("texteditmode"),
  11959.                         $("#floatingtoolbar li").removeClass("activebutton"),
  11960.                         $("#" + e).addClass("activebutton"),
  11961.                 "fog" == e.substring(0, 3) && $("#fogcontrols").addClass("activebutton"),
  11962.                 "rect" == e && ($("#drawingtools").addClass("activebutton"),
  11963.                         $("#drawingtools").removeClass("text path polygon line_splitter").addClass("rect")),
  11964.                 "text" == e && ($("#drawingtools").addClass("activebutton"),
  11965.                         $("#drawingtools").removeClass("rect path polygon line_splitter").addClass("text")),
  11966.                 "path" == e && $("#drawingtools").addClass("activebutton").removeClass("text rect polygon line_splitter").addClass("path"),
  11967.                         "polygon" == e ? $("#drawingtools").addClass("activebutton").removeClass("text rect path line_splitter").addClass("polygon") : d20.engine.finishCurrentPolygon(),
  11968.                         // BEGIN MOD (also line_splitter added to above removeClass calls
  11969.                 "line_splitter" == e && ($("#drawingtools").addClass("activebutton"),
  11970.                         $("#drawingtools").removeClass("rect path polygon text").addClass("line_splitter")),
  11971.                         // END MOD
  11972.                 "pan" !== e && "select" !== e && d20.engine.unselect(),
  11973.                         "pan" == e ? ($("#select").addClass("pan").removeClass("select").addClass("activebutton"),
  11974.                                 d20.token_editor.removeRadialMenu(),
  11975.                                 $("#editor-wrapper").addClass("panning")) : $("#editor-wrapper").removeClass("panning"),
  11976.                 "select" == e && $("#select").addClass("select").removeClass("pan").addClass("activebutton"),
  11977.                         $("#floatingtoolbar .mode").hide(),
  11978.                 ("text" == e || "select" == e) && $("#floatingtoolbar ." + e).show(),
  11979.                         "gridalign" == e ? $("#gridaligninstructions").show() : "gridalign" === d20.engine.mode && $("#gridaligninstructions").hide(),
  11980.                         "targeting" === e ? ($("#targetinginstructions").show(),
  11981.                                 $("#finalcanvas").addClass("targeting"),
  11982.                                 d20.engine.canvas.hoverCursor = "crosshair") : "targeting" === d20.engine.mode && ($("#targetinginstructions").hide(),
  11983.                                 $("#finalcanvas").removeClass("targeting"),
  11984.                         d20.engine.nextTargetCallback && _.defer(function () {
  11985.                                 d20.engine.nextTargetCallback && d20.engine.nextTargetCallback(!1)
  11986.                         }),
  11987.                                 d20.engine.canvas.hoverCursor = "move"),
  11988.                         // BEGIN MOD
  11989.                         // console.log("Switch mode to " + e),
  11990.                         d20.engine.mode = e,
  11991.                 "measure" !== e && window.currentPlayer && d20.engine.measurements[window.currentPlayer.id] && !d20.engine.measurements[window.currentPlayer.id].sticky && (d20.engine.announceEndMeasure({
  11992.                         player: window.currentPlayer.id
  11993.                 }),
  11994.                         d20.engine.endMeasure()),
  11995.                         d20.engine.canvas.isDrawingMode = "path" == e ? !0 : !1;
  11996.                 if ("text" == e || "path" == e || "rect" == e || "polygon" == e || "fxtools" == e
  11997.                         // BEGIN MOD
  11998.                         || "measure" == e) {
  11999.                         // END MOD
  12000.                         $("#secondary-toolbar").show();
  12001.                         $("#secondary-toolbar .mode").hide();
  12002.                         $("#secondary-toolbar ." + e).show();
  12003.                         ("path" == e || "rect" == e || "polygon" == e) && ("" === $("#path_strokecolor").val() && ($("#path_strokecolor").val("#000000").trigger("change-silent"),
  12004.                                 $("#path_fillcolor").val("transparent").trigger("change-silent")),
  12005.                                 d20.engine.canvas.freeDrawingBrush.color = $("#path_strokecolor").val(),
  12006.                                 d20.engine.canvas.freeDrawingBrush.fill = $("#path_fillcolor").val() || "transparent",
  12007.                                 $("#path_width").trigger("change")),
  12008.                         "fxtools" == e && "" === $("#fxtools_color").val() && $("#fxtools_color").val("#a61c00").trigger("change-silent"),
  12009.                                 $("#floatingtoolbar").trigger("blur")
  12010.                 } else {
  12011.                         $("#secondary-toolbar").hide();
  12012.                         $("#floatingtoolbar").trigger("blur");
  12013.                 }
  12014.                 // END MOD
  12015.         };
  12016.         // END ROLL20 CODE
  12017.  
  12018.         d20plus.mod.drawMeasurements = function () {
  12019.                 // BEGIN ROLL20 CODE
  12020.                 var k = function(e, t) {
  12021.                         let n = {
  12022.                                 scale: 0,
  12023.                                 grid: 0
  12024.                         }
  12025.                                 , i = 0
  12026.                                 , o = -1;
  12027.                         _.each(t.waypoints, r=>{
  12028.                                         let a = {
  12029.                                                 to_x: r[0],
  12030.                                                 to_y: r[1],
  12031.                                                 color: t.color,
  12032.                                                 flags: t.flags,
  12033.                                                 hide: t.hide
  12034.                                         };
  12035.                                         o > -1 ? (a.x = t.waypoints[o][0],
  12036.                                                 a.y = t.waypoints[o][1]) : (a.x = t.x,
  12037.                                                 a.y = t.y);
  12038.                                         let s = E(e, a, !0, "nub", n, i);
  12039.                                         n.scale += s.scale_distance,
  12040.                                                 n.grid += s.grid_distance,
  12041.                                                 i = s.diagonals % 2,
  12042.                                                 ++o
  12043.                                 }
  12044.                         );
  12045.                         let r = {
  12046.                                 to_x: t.to_x,
  12047.                                 to_y: t.to_y,
  12048.                                 color: t.color,
  12049.                                 flags: t.flags,
  12050.                                 hide: t.hide
  12051.                                 // BEGIN MOD
  12052.                                 ,
  12053.                                 Ve: t.Ve
  12054.                                 // END MOD
  12055.                         };
  12056.                         -1 === o ? (r.x = t.x,
  12057.                                 r.y = t.y) : (r.x = t.waypoints[o][0],
  12058.                                 r.y = t.waypoints[o][1]),
  12059.                                 E(e, r, !0, "arrow", n, i)
  12060.                 }
  12061.                         , E = function(e, t, n, i, o, r) {
  12062.                         let a = e=>e / d20.engine.canvasZoom
  12063.                                 , s = d20.engine.getDistanceInScale({
  12064.                                 x: t.x,
  12065.                                 y: t.y
  12066.                         }, {
  12067.                                 x: t.to_x,
  12068.                                 y: t.to_y
  12069.                         }, r, 15 & t.flags);
  12070.                         e.save(),
  12071.                                 e.globalCompositeOperation = "source-over",
  12072.                                 e.globalAlpha = 1,
  12073.                                 e.strokeStyle = t.color,
  12074.                                 e.fillStyle = e.strokeStyle,
  12075.                                 e.lineWidth = a(3);
  12076.                         let l = {
  12077.                                 line: [t.to_x - t.x, t.to_y - t.y],
  12078.                                 arrow: [[-10.16, -24.53], [0, -20.33], [10.16, -24.53]],
  12079.                                 x: [1, 0],
  12080.                                 y: [0, 1]
  12081.                         };
  12082.                         if (e.beginPath(),
  12083.                                 e.moveTo(t.x, t.y),
  12084.                                 e.lineTo(t.to_x, t.to_y),
  12085.                         !0 === i || "arrow" === i) {
  12086.                                 let n = Math.atan2(l.line[1], l.line[0]);
  12087.                                 l.forward = [Math.cos(n), Math.sin(n)],
  12088.                                         l.right = [Math.cos(n + Math.PI / 2), Math.sin(n + Math.PI / 2)],
  12089.                                         l.arrow = _.map(l.arrow, e=>[d20.math.dot(e, l.right), d20.math.dot(e, l.forward)]),
  12090.                                         l.arrow = _.map(l.arrow, e=>[d20.math.dot(e, l.x), d20.math.dot(e, l.y)]),
  12091.                                         e.moveTo(t.to_x, t.to_y),
  12092.                                         _.each(l.arrow, n=>e.lineTo(t.to_x + a(n[0]), t.to_y + a(n[1]))),
  12093.                                         e.closePath(),
  12094.                                         e.fill()
  12095.                         }
  12096.                         if (e.closePath(),
  12097.                                 e.stroke(),
  12098.                         "nub" === i && (e.beginPath(),
  12099.                                 e.arc(t.to_x, t.to_y, a(7), 0, 2 * Math.PI, !0),
  12100.                                 e.closePath(),
  12101.                                 e.fill()),
  12102.                                 n) {
  12103.                                 let n = Math.round(a(16))
  12104.                                         , i = Math.round(a(14));
  12105.                                 e.font = `${n}px Arial Black`,
  12106.                                         e.textBaseline = "alphabetic",
  12107.                                         e.textAlign = "center";
  12108.                                 let r = {
  12109.                                         distance: Math.round(10 * (s.scale_distance + (o ? o.scale : 0))) / 10,
  12110.                                         units: d20.Campaign.activePage().get("scale_units")
  12111.                                 };
  12112.                                 r.text = `${r.distance} ${r.units}`,
  12113.                                         r.text_metrics = e.measureText(r.text);
  12114.                                 let l = {
  12115.                                         active: d20.Campaign.activePage().get("showgrid") && d20.engine.snapTo > 0 && "sq" !== r.units && "hex" !== r.units,
  12116.                                         text: ""
  12117.                                 };
  12118.                                 if (l.active) {
  12119.                                         let t = d20.Campaign.activePage().get("grid_type")
  12120.                                                 , n = "hex" === t || "hexr" === t ? "hex" : "sq";
  12121.                                         e.font = `${i}px Arial`,
  12122.                                                 l.distance = Math.round(10 * (s.grid_distance + (o ? o.grid : 0))) / 10,
  12123.                                                 l.text = `${l.distance} ${n}`,
  12124.                                                 l.text_metrics = e.measureText(l.text)
  12125.                                 }
  12126.                                 let c = n - Math.round(a(4))
  12127.                                         , u = i - Math.round(a(3.5))
  12128.                                         , d = {
  12129.                                         x: t.to_x - Math.round(a(35)),
  12130.                                         y: t.to_y - Math.round(a(35)),
  12131.                                         width: Math.max(r.text_metrics.width, l.active ? l.text_metrics.width : 0),
  12132.                                         height: c,
  12133.                                         padding: Math.round(a(5)),
  12134.                                         scale_baseline_offset: 0,
  12135.                                         cell_baseline_offset: 0,
  12136.                                         text_horizontal_offset: 0,
  12137.                                         line_spacing: Math.ceil(a(4)),
  12138.                                         image_width: a(20),
  12139.                                         image_height: a(20),
  12140.                                         image_padding_left: a(5)
  12141.                                 };
  12142.                                 d.height += 2 * d.padding,
  12143.                                         d.width += 2 * d.padding,
  12144.                                         d.text_horizontal_offset = .5 * d.width,
  12145.                                         d.scale_baseline_offset = d.height - d.padding,
  12146.                                 l.active && (d.height += u + d.line_spacing,
  12147.                                         d.cell_baseline_offset = d.height - d.padding),
  12148.                                 t.hide && (d.width += d.image_width + d.image_padding_left,
  12149.                                         d.height = Math.max(d.height, d.image_height + 2 * d.padding),
  12150.                                         d.text_width = Math.max(r.text_metrics.width, l.active ? l.text_metrics.width : 0)),
  12151.                                         e.fillStyle = "rgba(255,255,255,0.75)",
  12152.                                         e.fillRect(d.x, d.y, d.width, d.height),
  12153.                                         e.fillStyle = "rgba(0,0,0,1)",
  12154.                                         e.font = `${n}px Arial Black`,
  12155.                                         e.fillText(r.text, d.x + d.text_horizontal_offset, d.y + d.scale_baseline_offset),
  12156.                                         e.font = `${i}px Arial`,
  12157.                                         e.fillText(l.text, d.x + d.text_horizontal_offset, d.y + d.cell_baseline_offset),
  12158.                                 t.hide && (d.image_vertical_offset = .5 * d.height - .5 * d.image_height,
  12159.                                         d.image_horizontal_offset = d.padding + d.text_width + d.image_padding_left,
  12160.                                         e.drawImage($("#measure li.rulervisibility[mode='hide'] > img")[0], d.x + d.image_horizontal_offset, d.y + d.image_vertical_offset, d.image_width, d.image_height))
  12161.                         }
  12162.  
  12163.                         // BEGIN MOD
  12164.                         if (t.Ve) {
  12165.                                 const RAD_90_DEG = 1.5708;
  12166.  
  12167.                                 const euclid = (x1, y1, x2, y2) => {
  12168.                                         const a = x1 - x2;
  12169.                                         const b = y1 - y2;
  12170.                                         return Math.sqrt(a * a + b * b)
  12171.                                 };
  12172.  
  12173.                                 const rotPoint = (angleRad, pX, pY) => {
  12174.                                         const s = Math.sin(angleRad);
  12175.                                         const c = Math.cos(angleRad);
  12176.  
  12177.                                         pX -= t.x;
  12178.                                         pY -= t.y;
  12179.  
  12180.                                         const xNew = pX * c - pY * s;
  12181.                                         const yNew = pX * s + pY * c;
  12182.  
  12183.                                         pX = xNew + t.x;
  12184.                                         pY = yNew + t.y;
  12185.                                         return [pX, pY];
  12186.                                 };
  12187.  
  12188.                                 const getLineEquation = (x1, y1, x2, y2) => {
  12189.                                         const getM = () => {
  12190.                                                 return (y2 - y1) / (x2 - x1)
  12191.                                         };
  12192.                                         const m = getM();
  12193.  
  12194.                                         const getC = () => {
  12195.                                                 return y1 - (m * x1);
  12196.                                         };
  12197.  
  12198.                                         const c = getC();
  12199.  
  12200.                                         return {
  12201.                                                 fn: (x) => (m * x) + c,
  12202.                                                 m, c
  12203.                                         }
  12204.                                 };
  12205.  
  12206.                                 const getPerpLineEquation = (x, y, line) => {
  12207.                                         const m2 = -1 / line.m
  12208.                                         const c2 = y - (m2 * x);
  12209.                                         return {
  12210.                                                 fn: (x) => (m2 * x) + c2,
  12211.                                                 m: m2, c: c2
  12212.                                         }
  12213.                                 };
  12214.  
  12215.                                 const getIntersect = (pointPerp, line1, line2) => {
  12216.                                         if (Math.abs(line1.m) === Infinity) {
  12217.                                                 // intersecting with the y-axis...
  12218.                                                 return [pointPerp[0], line2.fn(pointPerp[0])];
  12219.                                         } else {
  12220.                                                 const x = (line2.c - line1.c) / (line1.m - line2.m);
  12221.                                                 const y = line1.fn(x);
  12222.                                                 return [x, y];
  12223.                                         }
  12224.                                 };
  12225.  
  12226.                                 switch (t.Ve.mode) {
  12227.                                         case "1": // standard ruler
  12228.                                                 break;
  12229.                                         case "2": { // radius
  12230.                                                 const drawCircle = (cx, cy, rad) => {
  12231.                                                         e.beginPath();
  12232.                                                         e.arc(cx, cy, rad, 0, 2*Math.PI);
  12233.                                                         e.stroke();
  12234.                                                         e.closePath();
  12235.                                                 };
  12236.  
  12237.                                                 switch (t.Ve.radius.mode) {
  12238.                                                         case "1": // origin
  12239.                                                                 drawCircle(t.x, t.y, euclid(t.x, t.y, t.to_x, t.to_y));
  12240.                                                                 break;
  12241.                                                         case "2": { // halfway
  12242.                                                                 const dx = t.to_x - t.x;
  12243.                                                                 const dy = t.to_y - t.y;
  12244.                                                                 const cX = t.x + (dx / 2);
  12245.                                                                 const cY = t.y + (dy / 2);
  12246.  
  12247.                                                                 drawCircle(cX, cY, euclid(cX, cY, t.to_x, t.to_y));
  12248.                                                                 break;
  12249.                                                         }
  12250.                                                 }
  12251.  
  12252.                                                 break;
  12253.                                         }
  12254.                                         case "3": { // cone
  12255.                                                 const arcRadians = (Number(t.Ve.cone.arc) || 0.017) / 2; // 1 degree minimum
  12256.  
  12257.                                                 const r = euclid(t.x, t.y, t.to_x, t.to_y);
  12258.                                                 const dx = t.to_x - t.x;
  12259.                                                 const dy = t.to_y - t.y;
  12260.                                                 const startR = Math.atan2(dy, dx);
  12261.  
  12262.                                                 if (t.Ve.cone.mode === "1") {
  12263.                                                         const line = getLineEquation(t.x, t.y, t.to_x, t.to_y);
  12264.                                                         const perpLine = getPerpLineEquation(t.to_x, t.to_y, line);
  12265.  
  12266.                                                         const pRot1 = rotPoint(arcRadians, t.to_x, t.to_y);
  12267.                                                         const lineRot1 = getLineEquation(t.x, t.y, pRot1[0], pRot1[1]);
  12268.                                                         const intsct1 = getIntersect([t.to_x, t.to_y], perpLine, lineRot1);
  12269.  
  12270.                                                         // border line 1
  12271.                                                         e.beginPath();
  12272.                                                         e.moveTo(t.x, t.y);
  12273.                                                         e.lineTo(intsct1[0], intsct1[1]);
  12274.                                                         e.stroke();
  12275.                                                         e.closePath();
  12276.  
  12277.                                                         // perp line 1
  12278.                                                         e.beginPath();
  12279.                                                         e.moveTo(t.to_x, t.to_y);
  12280.                                                         e.lineTo(intsct1[0], intsct1[1]);
  12281.                                                         e.stroke();
  12282.                                                         e.closePath();
  12283.  
  12284.                                                         const pRot2 = rotPoint(-arcRadians, t.to_x, t.to_y);
  12285.                                                         const lineRot2 = getLineEquation(t.x, t.y, pRot2[0], pRot2[1]);
  12286.                                                         const intsct2 = getIntersect([t.to_x, t.to_y], perpLine, lineRot2);
  12287.  
  12288.                                                         // border line 2
  12289.                                                         e.beginPath();
  12290.                                                         e.moveTo(t.x, t.y);
  12291.                                                         e.lineTo(intsct2[0], intsct2[1]);
  12292.                                                         e.stroke();
  12293.                                                         e.closePath();
  12294.  
  12295.                                                         // perp line 2
  12296.                                                         e.beginPath();
  12297.                                                         e.moveTo(t.to_x, t.to_y);
  12298.                                                         e.lineTo(intsct2[0], intsct2[1]);
  12299.                                                         e.stroke();
  12300.                                                         e.closePath();
  12301.                                                 } else {
  12302.                                                         // arc 1
  12303.                                                         e.beginPath();
  12304.                                                         e.arc(t.x, t.y, r, startR, startR + arcRadians);
  12305.                                                         e.stroke();
  12306.                                                         e.closePath();
  12307.                                                         // arc 2
  12308.                                                         e.beginPath();
  12309.                                                         e.arc(t.x, t.y, r, startR, startR - arcRadians, true); // draw counter-clockwise
  12310.                                                         e.stroke();
  12311.                                                         e.closePath();
  12312.  
  12313.                                                         // border line 1
  12314.                                                         const s1 = Math.sin(arcRadians);
  12315.                                                         const c1 = Math.cos(arcRadians);
  12316.                                                         const xb1 = dx * c1 - dy * s1;
  12317.                                                         const yb1 = dx * s1 + dy * c1;
  12318.                                                         e.beginPath();
  12319.                                                         e.moveTo(t.x, t.y);
  12320.                                                         e.lineTo(t.x + xb1, t.y + yb1);
  12321.                                                         e.stroke();
  12322.                                                         e.closePath();
  12323.  
  12324.                                                         // border line 2
  12325.                                                         const s2 = Math.sin(-arcRadians);
  12326.                                                         const c2 = Math.cos(-arcRadians);
  12327.                                                         const xb2 = dx * c2 - dy * s2;
  12328.                                                         const yb2 = dx * s2 + dy * c2;
  12329.                                                         e.beginPath();
  12330.                                                         e.moveTo(t.x, t.y);
  12331.                                                         e.lineTo(t.x + xb2, t.y + yb2);
  12332.                                                         e.stroke();
  12333.                                                         e.closePath();
  12334.                                                 }
  12335.                                                 break;
  12336.                                         }
  12337.                                         case "4": { // box
  12338.                                                 const dxHalf = (t.to_x - t.x) / 2;
  12339.                                                 const dyHalf = (t.to_y - t.y) / 2;
  12340.  
  12341.                                                 e.beginPath();
  12342.                                                 switch (t.Ve.box.mode) {
  12343.                                                         case "1": // origin
  12344.                                                                 const dx = t.to_x - t.x;
  12345.                                                                 const dy = t.to_y - t.y;
  12346.  
  12347.                                                                 const [x1, y1] = rotPoint(RAD_90_DEG, t.to_x, t.to_y);
  12348.                                                                 const [x3, y3] = rotPoint(-RAD_90_DEG, t.to_x, t.to_y);
  12349.  
  12350.                                                                 e.moveTo(x1, y1);
  12351.                                                                 e.lineTo(x1 + dx, y1 + dy);
  12352.                                                                 e.lineTo(x3 + dx, y3 + dy);
  12353.                                                                 e.lineTo(x3 - dx, y3 - dy);
  12354.                                                                 e.lineTo(x1 - dx, y1 - dy);
  12355.                                                                 e.lineTo(x1 + dx, y1 + dy);
  12356.  
  12357.                                                                 break;
  12358.                                                         case "2": { // halfway
  12359.                                                                 const [x1, y1] = rotPoint(RAD_90_DEG, t.to_x - dxHalf, t.to_y - dyHalf);
  12360.                                                                 const [x3, y3] = rotPoint(-RAD_90_DEG, t.to_x - dxHalf, t.to_y - dyHalf);
  12361.  
  12362.                                                                 e.moveTo(t.x, t.y);
  12363.                                                                 e.lineTo(x1, y1);
  12364.                                                                 e.lineTo(x3, y3);
  12365.  
  12366.                                                                 const dx3 = (x3 - t.x);
  12367.                                                                 const dy3 = (y3 - t.y);
  12368.                                                                 e.lineTo(t.to_x + dx3, t.to_y + dy3);
  12369.  
  12370.                                                                 const dx1 = (x1 - t.x);
  12371.                                                                 const dy1 = (y1 - t.y);
  12372.                                                                 e.lineTo(t.to_x + dx1, t.to_y + dy1);
  12373.  
  12374.                                                                 e.lineTo(x1, y1);
  12375.  
  12376.                                                                 break;
  12377.                                                         }
  12378.                                                 }
  12379.                                                 e.stroke();
  12380.                                                 e.closePath();
  12381.                                                 break;
  12382.                                         }
  12383.                                         case "5": { // line
  12384.                                                 e.beginPath();
  12385.  
  12386.                                                 const div = t.Ve.line.mode === "2" ? 1 : 2;
  12387.  
  12388.                                                 const norm = [];
  12389.                                                 d20plus.math.vec2.normalize(norm, [t.to_x - t.x, t.to_y - t.y]);
  12390.                                                 const width = (Number(t.Ve.line.width) || 0.1) / div;
  12391.                                                 const scaledWidth = (width / d20.Campaign.activePage().get("scale_number")) * 70;
  12392.                                                 d20plus.math.vec2.scale(norm, norm, scaledWidth);
  12393.  
  12394.                                                 const xRot = t.x + norm[0];
  12395.                                                 const yRot = t.y + norm[1];
  12396.  
  12397.                                                 const [x1, y1] = rotPoint(RAD_90_DEG, xRot, yRot);
  12398.                                                 const [x3, y3] = rotPoint(-RAD_90_DEG, xRot, yRot);
  12399.                                                 console.log(t.x, t.y, norm, xRot, yRot);
  12400.  
  12401.                                                 e.moveTo(t.x, t.y);
  12402.                                                 e.lineTo(x1, y1);
  12403.                                                 e.lineTo(x3, y3);
  12404.  
  12405.                                                 const dx3 = (x3 - t.x);
  12406.                                                 const dy3 = (y3 - t.y);
  12407.                                                 e.lineTo(t.to_x + dx3, t.to_y + dy3);
  12408.  
  12409.                                                 const dx1 = (x1 - t.x);
  12410.                                                 const dy1 = (y1 - t.y);
  12411.                                                 e.lineTo(t.to_x + dx1, t.to_y + dy1);
  12412.  
  12413.                                                 e.lineTo(x1, y1);
  12414.  
  12415.                                                 e.stroke();
  12416.                                                 e.closePath();
  12417.                                                 break;
  12418.                                         }
  12419.                                 }
  12420.                         }
  12421.                         // END MOD
  12422.  
  12423.                         return e.restore(),
  12424.                                 s
  12425.                 };
  12426.  
  12427.                 d20.engine.drawMeasurements = function (e) {
  12428.                         _.each(d20.engine.measurements, function(t) {
  12429.                                 if (t.pageid !== d20.Campaign.activePage().id)
  12430.                                         return;
  12431.                                 let n = {
  12432.                                         color: d20.Campaign.players.get(t.player).get("color"),
  12433.                                         to_x: t.to_x - d20.engine.currentCanvasOffset[0],
  12434.                                         to_y: t.to_y - d20.engine.currentCanvasOffset[1],
  12435.                                         x: t.x - d20.engine.currentCanvasOffset[0],
  12436.                                         y: t.y - d20.engine.currentCanvasOffset[1],
  12437.                                         flags: t.flags,
  12438.                                         hide: t.hide,
  12439.                                         waypoints: _.map(t.waypoints, e=>[e[0] - d20.engine.currentCanvasOffset[0], e[1] - d20.engine.currentCanvasOffset[1]])
  12440.                                         // BEGIN MOD
  12441.                                         ,
  12442.                                         Ve: t.Ve ? JSON.parse(JSON.stringify(t.Ve)) : undefined
  12443.                                         // END MOD
  12444.                                 };
  12445.                                 k(e, n)
  12446.                         }),
  12447.                                 e.restore()
  12448.  
  12449.                         // BEGIN MOD
  12450.                         const offset = (num, offset, xy) => {
  12451.                                 return (num + offset[xy]) - d20.engine.currentCanvasOffset[xy];
  12452.                         };
  12453.  
  12454.                         // unrelated code, but throw it in the render loop here
  12455.                         let doRender = false;
  12456.                         $.each(d20plus.engine._tempTopRenderLines, (id, toDraw) => {
  12457.                                 console.log("DRAWING", toDraw.ticks, toDraw.offset)
  12458.                                 e.beginPath();
  12459.                                 e.strokeStyle = window.currentPlayer.get("color");
  12460.                                 e.lineWidth = 2;
  12461.  
  12462.                                 const nuX = offset(toDraw.x - d20.engine.currentCanvasOffset[0], toDraw.offset, 0);
  12463.                                 const nuY = offset(toDraw.y - d20.engine.currentCanvasOffset[1], toDraw.offset, 1);
  12464.                                 const nuToX = offset(toDraw.to_x - d20.engine.currentCanvasOffset[0], toDraw.offset, 0);
  12465.                                 const nuToY = offset(toDraw.to_y - d20.engine.currentCanvasOffset[1], toDraw.offset, 1);
  12466.  
  12467.                                 e.moveTo(nuX, nuY);
  12468.                                 e.lineTo(nuToX, nuToY);
  12469.  
  12470.                                 e.moveTo(nuToX, nuY);
  12471.                                 e.lineTo(nuX, nuToY);
  12472.  
  12473.                                 e.stroke();
  12474.                                 e.closePath();
  12475.  
  12476.                                 toDraw.ticks--;
  12477.                                 doRender = true;
  12478.                                 if (toDraw.ticks <= 0) {
  12479.                                         delete d20plus.engine._tempTopRenderLines[id];
  12480.                                 }
  12481.                         });
  12482.                         if (doRender) {
  12483.                                 d20.engine.redrawScreenNextTick();
  12484.                         }
  12485.                         // END MOD
  12486.                 }
  12487.                 // END ROLL20 CODE
  12488.         };
  12489.  
  12490.         d20plus.mod.overwriteStatusEffects = function () {
  12491.                 d20.engine.canvasDirty = true;
  12492.                 d20.engine.canvasTopDirty = true;
  12493.                 d20.engine.canvas._objects.forEach(it => {
  12494.                         // avoid adding it to any objects that wouldn't have it to begin with
  12495.                         if (!it.model || !it.model.view || !it.model.view.updateBackdrops) return;
  12496.  
  12497.                         // BEGIN ROLL20 CODE
  12498.                         it.model.view.updateBackdrops = function (e) {
  12499.                                 if (!this.nohud && ("objects" == this.model.get("layer") || "gmlayer" == this.model.get("layer")) && "image" == this.model.get("type") && this.model && this.model.collection && this.graphic) {
  12500.                                         // BEGIN MOD
  12501.                                         const scaleFact = (d20plus.cfg.get("canvas", "scaleNamesStatuses") && d20.Campaign.activePage().get("snapping_increment"))
  12502.                                                 ? d20.Campaign.activePage().get("snapping_increment")
  12503.                                                 : 1;
  12504.                                         // END MOD
  12505.                                         var t = this.model.collection.page
  12506.                                                 , n = e || d20.engine.canvas.getContext();
  12507.                                         n.save(),
  12508.                                         (this.graphic.get("flipX") || this.graphic.get("flipY")) && n.scale(this.graphic.get("flipX") ? -1 : 1, this.graphic.get("flipY") ? -1 : 1);
  12509.                                         var i = this
  12510.                                                 , r = Math.floor(this.graphic.get("width") / 2)
  12511.                                                 , o = Math.floor(this.graphic.get("height") / 2)
  12512.                                                 , a = (parseFloat(t.get("scale_number")),
  12513.                                                 this.model.get("statusmarkers").split(","));
  12514.                                         -1 !== a.indexOf("dead") && (n.strokeStyle = "rgba(189,13,13,0.60)",
  12515.                                                 n.lineWidth = 10,
  12516.                                                 n.beginPath(),
  12517.                                                 n.moveTo(-r + 7, -o + 15),
  12518.                                                 n.lineTo(r - 7, o - 5),
  12519.                                                 n.moveTo(r - 7, -o + 15),
  12520.                                                 n.lineTo(-r + 7, o - 5),
  12521.                                                 n.closePath(),
  12522.                                                 n.stroke()),
  12523.                                                 n.rotate(-this.graphic.get("angle") * Math.PI / 180),
  12524.                                                 n.strokeStyle = "rgba(0,0,0,0.65)",
  12525.                                                 n.lineWidth = 1;
  12526.                                         var s = 0
  12527.                                                 , l = i.model.get("bar1_value")
  12528.                                                 , c = i.model.get("bar1_max");
  12529.                                         if ("" != c && (window.is_gm || this.model.get("showplayers_bar1") || this.model.currentPlayerControls() && this.model.get("playersedit_bar1"))) {
  12530.                                                 var u = parseInt(l, 10) / parseInt(c, 10)
  12531.                                                         , d = -o - 20 + 0;
  12532.                                                 n.fillStyle = "rgba(" + d20.Campaign.tokendisplay.bar1_rgb + ",0.75)",
  12533.                                                         n.beginPath(),
  12534.                                                         n.rect(-r + 3, d, Math.floor((2 * r - 6) * u), 8),
  12535.                                                         n.closePath(),
  12536.                                                         n.fill(),
  12537.                                                         n.beginPath(),
  12538.                                                         n.rect(-r + 3, d, 2 * r - 6, 8),
  12539.                                                         n.closePath(),
  12540.                                                         n.stroke(),
  12541.                                                         s++
  12542.                                         }
  12543.                                         var l = i.model.get("bar2_value")
  12544.                                                 , c = i.model.get("bar2_max");
  12545.                                         if ("" != c && (window.is_gm || this.model.get("showplayers_bar2") || this.model.currentPlayerControls() && this.model.get("playersedit_bar2"))) {
  12546.                                                 var u = parseInt(l, 10) / parseInt(c, 10)
  12547.                                                         , d = -o - 20 + 12;
  12548.                                                 n.fillStyle = "rgba(" + d20.Campaign.tokendisplay.bar2_rgb + ",0.75)",
  12549.                                                         n.beginPath(),
  12550.                                                         n.rect(-r + 3, d, Math.floor((2 * r - 6) * u), 8),
  12551.                                                         n.closePath(),
  12552.                                                         n.fill(),
  12553.                                                         n.beginPath(),
  12554.                                                         n.rect(-r + 3, d, 2 * r - 6, 8),
  12555.                                                         n.closePath(),
  12556.                                                         n.stroke(),
  12557.                                                         s++
  12558.                                         }
  12559.                                         var l = i.model.get("bar3_value")
  12560.                                                 , c = i.model.get("bar3_max");
  12561.                                         if ("" != c && (window.is_gm || this.model.get("showplayers_bar3") || this.model.currentPlayerControls() && this.model.get("playersedit_bar3"))) {
  12562.                                                 var u = parseInt(l, 10) / parseInt(c, 10)
  12563.                                                         , d = -o - 20 + 24;
  12564.                                                 n.fillStyle = "rgba(" + d20.Campaign.tokendisplay.bar3_rgb + ",0.75)",
  12565.                                                         n.beginPath(),
  12566.                                                         n.rect(-r + 3, d, Math.floor((2 * r - 6) * u), 8),
  12567.                                                         n.closePath(),
  12568.                                                         n.fill(),
  12569.                                                         n.beginPath(),
  12570.                                                         n.rect(-r + 3, d, 2 * r - 6, 8),
  12571.                                                         n.closePath(),
  12572.                                                         n.stroke()
  12573.                                         }
  12574.                                         var h, p, g = 1, f = !1;
  12575.                                         switch (d20.Campaign.get("markers_position")) {
  12576.                                                 case "bottom":
  12577.                                                         h = o - 10,
  12578.                                                                 p = r;
  12579.                                                         break;
  12580.                                                 case "left":
  12581.                                                         h = -o - 10,
  12582.                                                                 p = -r,
  12583.                                                                 f = !0;
  12584.                                                         break;
  12585.                                                 case "right":
  12586.                                                         h = -o - 10,
  12587.                                                                 p = r - 18,
  12588.                                                                 f = !0;
  12589.                                                         break;
  12590.                                                 default:
  12591.                                                         h = -o + 10,
  12592.                                                                 p = r
  12593.                                         }
  12594.                                         // BEGIN MOD
  12595.                                         n.strokeStyle = "white";
  12596.                                         n.lineWidth = 3 * scaleFact;
  12597.                                         const scaledFont = 14 * scaleFact;
  12598.                                         n.font = "bold " + scaledFont + "px Arial";
  12599.                                         // END MOD
  12600.                                         _.each(a, function (e) {
  12601.                                                 var t = d20.token_editor.statusmarkers[e.split("@")[0]];
  12602.                                                 if (!t)
  12603.                                                         return !0;
  12604.                                                 if ("dead" === e)
  12605.                                                         return !0;
  12606.                                                 var i = 0;
  12607.                                                 if (g--,
  12608.                                                 "#" === t.substring(0, 1))
  12609.                                                         n.fillStyle = t,
  12610.                                                                 n.beginPath(),
  12611.                                                                 f ? h += 16 : p -= 16,
  12612.                                                                 n.arc(p + 8, f ? h + 4 : h, 6, 0, 2 * Math.PI, !0),
  12613.                                                                 n.closePath(),
  12614.                                                                 n.stroke(),
  12615.                                                                 n.fill(),
  12616.                                                                 i = f ? 10 : 4;
  12617.                                                 else {
  12618.                                                         // BEGIN MOD
  12619.                                                         if (!d20.token_editor.statussheet_ready) return;
  12620.                                                         const scaledWH = 21 * scaleFact;
  12621.                                                         const scaledOffset = 22 * scaleFact;
  12622.                                                         f ? h += scaledOffset : p -= scaledOffset;
  12623.  
  12624.                                                         if (d20.engine.canvasZoom <= 1) {
  12625.                                                                 n.drawImage(d20.token_editor.statussheet_small, parseInt(t, 10), 0, 21, 21, p, h - 9, scaledWH, scaledWH);
  12626.                                                         } else {
  12627.                                                                 n.drawImage(d20.token_editor.statussheet, parseInt(t, 10), 0, 24, 24, p, h - 9, scaledWH, scaledWH)
  12628.                                                         }
  12629.  
  12630.                                                         i = f ? 14 : 12;
  12631.                                                         i *= scaleFact;
  12632.                                                         // END MOD
  12633.                                                 }
  12634.                                                 if (-1 !== e.indexOf("@")) {
  12635.                                                         var r = e.split("@")[1];
  12636.                                                         // BEGIN MOD
  12637.                                                         // bing backtick to "clear counter"
  12638.                                                         if (r === "`") return;
  12639.                                                         n.fillStyle = "rgb(222,31,31)";
  12640.                                                         var o = f ? 9 : 14;
  12641.                                                         o *= scaleFact;
  12642.                                                         o -= (14 - (scaleFact * 14));
  12643.                                                         n.strokeText(r + "", p + i, h + o);
  12644.                                                         n.fillText(r + "", p + i, h + o);
  12645.                                                         // END MOD
  12646.                                                 }
  12647.                                         });
  12648.                                         var m = i.model.get("name");
  12649.                                         if ("" != m && 1 == this.model.get("showname") && (window.is_gm || this.model.get("showplayers_name") || this.model.currentPlayerControls() && this.model.get("playersedit_name"))) {
  12650.                                                 n.textAlign = "center";
  12651.                                                 // BEGIN MOD
  12652.                                                 const fontSize = 14;
  12653.                                                 var scaledFontSize = fontSize * scaleFact;
  12654.                                                 const scaledY = 22 * scaleFact;
  12655.                                                 const scaled6 = 6 * scaleFact;
  12656.                                                 const scaled8 = 8 * scaleFact;
  12657.                                                 n.font = "bold " + scaledFontSize + "px Arial";
  12658.                                                 var v = n.measureText(m).width;
  12659.  
  12660.                                                 /*
  12661.                                                         Note(stormy): compatibility with R20ES's ScaleTokenNamesBySize module.
  12662.                                                  */
  12663.                                                 if(window.r20es && window.r20es.drawNameplate) {
  12664.                                                         window.r20es.drawNameplate(this.model, n, v, o, fontSize, m);
  12665.                                                 } else {
  12666.                                                         n.fillStyle = "rgba(255,255,255,0.50)";
  12667.                                                         n.fillRect(-1 * Math.floor((v + scaled6) / 2), o + scaled8, v + scaled6, scaledFontSize + scaled6);
  12668.                                                         n.fillStyle = "rgb(0,0,0)";
  12669.                                                         n.fillText(m + "", 0, o + scaledY, v);
  12670.                                                 }
  12671.                                                 // END MOD
  12672.                                         }
  12673.                                         n.restore()
  12674.                                 }
  12675.                         }
  12676.                         // END ROLL20 CODE
  12677.                 });
  12678.         };
  12679.  
  12680.         d20plus.mod.mouseEnterMarkerMenu = function () {
  12681.                 var e = this;
  12682.                 $(this).on("mouseover.statusiconhover", ".statusicon", function () {
  12683.                         a = $(this).attr("data-action-type").replace("toggle_status_", "")
  12684.                 }),
  12685.                         $(document).on("keypress.statusnum", function (t) {
  12686.                                 // BEGIN MOD // TODO see if this clashes with keyboard shortcuts
  12687.                                 if ("dead" !== a && currentcontexttarget) {
  12688.                                         // END MOD
  12689.                                         var n = String.fromCharCode(t.which)
  12690.                                                 ,
  12691.                                                 i = "" == currentcontexttarget.model.get("statusmarkers") ? [] : currentcontexttarget.model.get("statusmarkers").split(",")
  12692.                                                 , r = (_.map(i, function (e) {
  12693.                                                         return e.split("@")[0]
  12694.                                                 }),
  12695.                                                         !1);
  12696.                                         i = _.map(i, function (e) {
  12697.                                                 return e.split("@")[0] == a ? (r = !0,
  12698.                                                 a + "@" + n) : e
  12699.                                         }),
  12700.                                         r || ($(e).find(".statusicon[data-action-type=toggle_status_" + a + "]").addClass("active"),
  12701.                                                 i.push(a + "@" + n)),
  12702.                                                 currentcontexttarget.model.save({
  12703.                                                         statusmarkers: i.join(",")
  12704.                                                 })
  12705.                                 }
  12706.                         })
  12707.         };
  12708.  
  12709.         // BEGIN ROLL20 CODE
  12710.         d20plus.mod.handleURL = function(e) {
  12711.                 if (!($(this).hasClass("lightly") || $(this).parents(".note-editable").length > 0)) {
  12712.                         var t = $(this).attr("href");
  12713.                         if (void 0 === t)
  12714.                                 return !1;
  12715.                         if (-1 !== t.indexOf("journal.roll20.net") || -1 !== t.indexOf("wiki.roll20.net")) {
  12716.                                 var n = t.split("/")[3]
  12717.                                         , i = t.split("/")[4]
  12718.                                         , o = d20.Campaign[n + "s"].get(i);
  12719.                                 if (o) {
  12720.                                         var r = o.get("inplayerjournals").split(",");
  12721.                                         (window.is_gm || -1 !== _.indexOf(r, "all") || window.currentPlayer && -1 !== _.indexOf(r, window.currentPlayer.id)) && o.view.showDialog()
  12722.                                 }
  12723.                                 return $("#existing" + n + "s").find("tr[data-" + n + "id=" + i + "]").trigger("click"),
  12724.                                         !1
  12725.                         }
  12726.                         var a = /(?:(?:http(?:s?):\/\/(?:app\.)?roll20(?:staging)?\.(?:net|local:5000)\/|^\/?)compendium\/)([^\/]+)\/([^\/#?]+)/i
  12727.                                 , s = t.match(a);
  12728.                         if (s)
  12729.                                 return d20.utils.openCompendiumPage(s[1], s[2]),
  12730.                                         e.stopPropagation(),
  12731.                                         void e.preventDefault();
  12732.                         if (-1 !== t.indexOf("javascript:"))
  12733.                                 return !1;
  12734.                         if ("`" === t.substring(0, 1))
  12735.                                 return d20.textchat.doChatInput(t.substring(1)),
  12736.                                         !1;
  12737.                         if ("!" === t.substring(0, 1))
  12738.                                 return d20.textchat.doChatInput(t),
  12739.                                         !1;
  12740.                         if ("~" === t.substring(0, 1))
  12741.                                 return d20.textchat.doChatInput("%{" + t.substring(1, t.length) + "}"),
  12742.                                         !1;
  12743.                         if (t !== undefined && ("external" === $(this).attr("rel") || -1 === t.indexOf("javascript:") && -1 !== t.indexOf("://"))) {
  12744.                                 // BEGIN MOD
  12745.                                 e.stopPropagation();
  12746.                                 e.preventDefault();
  12747.                                 window.open(t);
  12748.                                 // END MOD
  12749.                         }
  12750.                 }
  12751.         };
  12752.         // END ROLL20 CODE
  12753.  
  12754.         d20plus.mod._renderAll_middleLayers = new Set(["objects", "background", "foreground"]);
  12755.         // BEGIN ROLL20 CODE
  12756.         d20plus.mod.renderAll = function (e) {
  12757.                 const t = e && e.context || this.contextContainer
  12758.                         , n = this.getActiveGroup()
  12759.                         , i = [d20.engine.canvasWidth / d20.engine.canvasZoom, d20.engine.canvasHeight / d20.engine.canvasZoom]
  12760.                         , o = new d20.math.Rectangle(...d20.math.add(d20.engine.currentCanvasOffset,d20.math.div(i,2)),...i,0);
  12761.                 n && !window.is_gm && (n.hideResizers = !0),
  12762.                         this.clipTo ? fabric.util.clipContext(this, t) : t.save();
  12763.                 const r = {
  12764.                         map: [],
  12765.                         // BEGIN MOD
  12766.                         background: [],
  12767.                         // END MOD
  12768.                         walls: [],
  12769.                         objects: [],
  12770.                         // BEGIN MOD
  12771.                         foreground: [],
  12772.                         // END MOD
  12773.                         gmlayer: []
  12774.                         // BEGIN MOD
  12775.                         , weather: []
  12776.                         // END MOD
  12777.                 };
  12778.                 r[Symbol.iterator] = this._layerIteratorGenerator.bind(r, e);
  12779.                 const a = e && e.tokens_to_render || this._objects;
  12780.                 for (let e of a)
  12781.                         if (e.model) {
  12782.                                 const t = e.model.get("layer");
  12783.                                 if (!r[t])
  12784.                                         continue;
  12785.                                 r[t].push(e)
  12786.                         } else
  12787.                                 r[window.currentEditingLayer].push(e);
  12788.                 for (const [i,a] of r) {
  12789.                         switch (a) {
  12790.                                 case "grid":
  12791.                                         d20.canvas_overlay.drawGrid(t);
  12792.                                         continue;
  12793.                                 case "afow":
  12794.                                         d20.canvas_overlay.drawAFoW(d20.engine.advfowctx, d20.engine.work_canvases.floater.context);
  12795.                                         continue;
  12796.                                 case "gmlayer":
  12797.                                         t.globalAlpha = d20.engine.gm_layer_opacity;
  12798.                                         break;
  12799.                                 // BEGIN MOD
  12800.                                 case "background":
  12801.                                 case "foreground":
  12802.                                         if (d20plus.mod._renderAll_middleLayers.has(window.currentEditingLayer) && window.currentEditingLayer !== a && window.currentEditingLayer !== "objects") {
  12803.                                                 t.globalAlpha = .45;
  12804.                                                 break;
  12805.                                         }
  12806.                                 // END MOD
  12807.                                 case "objects":
  12808.                                         if ("map" === window.currentEditingLayer || "walls" === window.currentEditingLayer) {
  12809.                                                 t.globalAlpha = .45;
  12810.                                                 break
  12811.                                         }
  12812.                                 default:
  12813.                                         t.globalAlpha = 1
  12814.                         }
  12815.                         _.chain(i).filter(i=>{
  12816.                                 // BEGIN MOD
  12817.                                 // forcibly render foreground elements over everything
  12818.                                 // if (a === "foreground") return true;
  12819.                                 // END MOD
  12820.  
  12821.                                 let r;
  12822.                                 return n && i && n.contains(i) ? (i.renderingInGroup = n,
  12823.                                         i.hasControls = !1) : (i.renderingInGroup = null,
  12824.                                         i.hasControls = !0,
  12825.                                         "text" !== i.type && window.is_gm ? i.hideResizers = !1 : i.hideResizers = !0),
  12826.                                         e && e.invalid_rects ? (r = i.intersects([o]) && (i.needsToBeDrawn || i.intersects(e.invalid_rects)),
  12827.                                         !e.skip_prerender && i.renderPre && i.renderPre(t)) : (r = i.needsRender(o),
  12828.                                         (!e || !e.skip_prerender) && r && i.renderPre && i.renderPre(t, {
  12829.                                                 should_update: !0
  12830.                                         })),
  12831.                                         r
  12832.                                 }
  12833.                         ).each(n=>{
  12834.                                 const i = "image" === n.type.toLowerCase() && n.model.controlledByPlayer(window.currentPlayer.id)
  12835.                                         , o = n._model && n._model.get("light_hassight")
  12836.                                         , r = e && e.owned_with_sight_auras_only;
  12837.                                 r && (!r || i && o) || this._draw(t, n),
  12838.                                         n.renderingInGroup = null
  12839.                         })
  12840.                 }
  12841.                 return t.restore(),
  12842.                         this
  12843.         };
  12844.         // END ROLL20 CODE
  12845.  
  12846.         // shoutouts to Roll20 for making me learn how `yield` works
  12847.         // BEGIN ROLL20 CODE
  12848.         d20plus.mod.layerIteratorGenerator = function*(e) { // e is just an options object
  12849.                 yield[this.map, "map"],
  12850.                 window.is_gm && "walls" === window.currentEditingLayer && (yield[this.walls, "walls"]);
  12851.                 const t = e && e.grid_before_afow
  12852.                         , n = !d20.Campaign.activePage().get("adv_fow_enabled") || e && e.disable_afow
  12853.                         , i = !d20.Campaign.activePage().get("showgrid") || e && e.disable_grid;
  12854.                 t && !i && (yield[null, "grid"]),
  12855.                 !n && window.largefeats && (yield[null, "afow"]),
  12856.                 t || i || (yield[null, "grid"]),
  12857.                         // BEGIN MOD
  12858.                         yield[this.background, "background"],
  12859.                         // END MOD
  12860.                         yield[this.objects, "objects"],
  12861.                         // BEGIN MOD
  12862.                         yield[this.foreground, "foreground"],
  12863.                         // END MOD
  12864.                 window.is_gm && (yield[this.gmlayer, "gmlayer"])
  12865.                 // BEGIN MOD
  12866.                 window.is_gm && "weather" === window.currentEditingLayer && (yield[this.weather, "weather"]);
  12867.                 // END MOD
  12868.         };
  12869.         // END ROLL20 CODE
  12870.  
  12871.         // BEGIN ROLL20 CODE
  12872.         d20plus.mod.editingLayerOnclick = () => {
  12873.                 $("#editinglayer").off(clicktype).on(clicktype, "li", function() {
  12874.                         var e = $(this);
  12875.                         $("#editinglayer").removeClass(window.currentEditingLayer);
  12876.                         $("#drawingtools .choosepath").show();
  12877.                         "polygon" !== d20.engine.mode && $("#drawingtools").hasClass("polygon") && $("#drawingtools").removeClass("polygon").addClass("path");
  12878.  
  12879.                         // BEGIN MOD
  12880.                         if (e.hasClass("chooseweather")) {
  12881.                                 window.currentEditingLayer = "weather";
  12882.                                 $("#drawingtools .choosepath").hide();
  12883.                                 "path" !== d20.engine.mode && $("#drawingtools").removeClass("path").addClass("polygon")
  12884.                         } else {
  12885.                                 e.hasClass("choosebackground") ? window.currentEditingLayer = "background" : e.hasClass("chooseforeground") ? window.currentEditingLayer = "foreground" : e.hasClass("chooseobjects") ? window.currentEditingLayer = "objects" : e.hasClass("choosemap") ? window.currentEditingLayer = "map" : e.hasClass("choosegmlayer") ? window.currentEditingLayer = "gmlayer" : e.hasClass("choosewalls") && (window.currentEditingLayer = "walls",
  12886.                                         $("#drawingtools .choosepath").hide(),
  12887.                                 "path" !== d20.engine.mode && $("#drawingtools").removeClass("path").addClass("polygon"));
  12888.                         }
  12889.                         // END MOD
  12890.                         $("#editinglayer").addClass(window.currentEditingLayer);
  12891.                         $(document).trigger("d20:editingLayerChanged");
  12892.                 });
  12893.         };
  12894.         // END ROLL20 CODE
  12895.  
  12896.         // prevent prototype methods from breaking some poorly-written property loops
  12897.         d20plus.mod.fixHexMethods = () => {
  12898.                 try {
  12899.                         // BEGIN ROLL20 CODE
  12900.                         HT.Grid.prototype.GetHexAt = function(e) {
  12901.                                 // BEGIN MOD
  12902.                                 for (const t of this.Hexes)
  12903.                                         if (t.Contains(e))
  12904.                                                 return t;
  12905.                                 // END MOD
  12906.                                 return null
  12907.                         };
  12908.                         // END ROLL20 CODE
  12909.                 } catch (ignored) {
  12910.                         console.error(ignored)
  12911.                 }
  12912.  
  12913.                 try {
  12914.                         // BEGIN ROLL20 CODE
  12915.                         HT.Grid.prototype.GetHexById = function(e) {
  12916.                                 // BEGIN MOD
  12917.                                 for (const t of this.Hexes)
  12918.                                         if (t.Id == e)
  12919.                                                 return t;
  12920.                                 // END MOD
  12921.                                 return null
  12922.                         };
  12923.                         // END ROLL20 CODE
  12924.                 } catch (ignored) {
  12925.                         console.error(ignored)
  12926.                 }
  12927.         };
  12928.  
  12929.         // prevent prototype methods from breaking some poorly-written property loops
  12930.         d20plus.mod.fixVideoMethods = () => {
  12931.                 const arr = [];
  12932.                 for (const k in arr) {
  12933.                         const v = arr[k];
  12934.                         if (typeof v === "function") {
  12935.                                 v.getReceiver = v.getReceiver || (() => null);
  12936.                                 v.getSender = v.getSender || (() => null);
  12937.                         }
  12938.                 }
  12939.         };
  12940. }
  12941.  
  12942. SCRIPT_EXTENSIONS.push(d20plusMod);
  12943.  
  12944.  
  12945. const baseTemplate = function () {
  12946.         d20plus.template = {};
  12947.  
  12948.         d20plus.template.swapTemplates = () => {
  12949.                 d20plus.ut.log("Swapping templates...");
  12950.                 $("#tmpl_charactereditor").html($(d20plus.template_charactereditor).html());
  12951.                 $("#tmpl_handouteditor").html($(d20plus.template_handouteditor).html());
  12952.                 $("#tmpl_deckeditor").html($(d20plus.template.deckeditor).html());
  12953.                 $("#tmpl_cardeditor").html($(d20plus.template.cardeditor).html());
  12954.         };
  12955.  
  12956.         d20plus.settingsHtmlPtFooter = `<p>
  12957.                         <a class="btn " href="#" id="button-edit-config" style="margin-top: 3px; width: calc(100% - 22px);">Edit Config</a>
  12958.                         </p>
  12959.                         <p>
  12960.                         For help, advice, and updates, <a href="https://discord.gg/nGvRCDs" target="_blank" style="color: #08c;">join our Discord!</a>
  12961.                         </p>
  12962.                         <p>
  12963.                         <a class="btn player-hidden" href="#" id="button-view-tools" style="margin-top: 3px; margin-right: 7px;">Open Tools List</a>
  12964.                         <a class="btn" href="#" id="button-manage-qpi" style="margin-top: 3px;" title="It's like the Roll20 API, but even less useful">Manage QPI Scripts</a>
  12965.                         </p>
  12966.                         <style id="dynamicStyle"></style>
  12967.                 `;
  12968.  
  12969.         d20plus.artTabHtml = `
  12970.         <p style="display: flex; width: 100%; justify-content: space-between;">
  12971.                 <button class="btn" id="button-add-external-art" style="margin-right: 5px; width: 100%;">Manage External Art</button>
  12972.                 <button class="btn" id="button-browse-external-art" style="width: 100%;">Browse Repo</button>
  12973.         </p>
  12974.         `;
  12975.  
  12976.         d20plus.addArtHTML = `
  12977.         <div id="d20plus-artfolder" title="External Art" style="position: relative">
  12978.         <p>Add external images by URL. Any direct link to an image should work.</p>
  12979.         <p>
  12980.         <input placeholder="Name*" id="art-list-add-name">
  12981.         <input placeholder="URL*" id="art-list-add-url">
  12982.         <a class="btn" href="#" id="art-list-add-btn">Add URL</a>
  12983.         <a class="btn" href="#" id="art-list-multi-add-btn">Add Multiple URLs...</a>
  12984.         <a class="btn btn-danger" href="#" id="art-list-delete-all-btn" style="margin-left: 12px;">Delete All</a>
  12985.         <p/>
  12986.         <hr>
  12987.         <div id="art-list-container">
  12988.         <input class="search" autocomplete="off" placeholder="Search list..." style="width: 100%;">
  12989.         <br>
  12990.         <p>
  12991.                 <span style="display: inline-block; width: 40%; font-weight: bold;">Name</span>
  12992.                 <span style="display: inline-block; font-weight: bold;">URL</span>
  12993.         </p>
  12994.         <ul class="list artlist" style="max-height: 600px; overflow-y: scroll; display: block; margin: 0; transform: translateZ(0);"></ul>
  12995.         </div>
  12996.         </div>`;
  12997.  
  12998.         d20plus.addArtMassAdderHTML = `
  12999.         <div id="d20plus-artmassadd" title="Mass Add Art URLs">
  13000.         <p>One entry per line; entry format: <b>[name]---[URL (direct link to image)]</b> <button class="btn" id="art-list-multi-add-btn-submit">Add URLs</button></p>
  13001.         <p><textarea id="art-list-multi-add-area" style="width: 100%; height: 100%; min-height: 500px;" placeholder="My Image---http://example.com/img1.png"></textarea></p>
  13002.         </div>`;
  13003.  
  13004.         d20plus.artListHTML = `
  13005.         <div id="Vetoolsresults">
  13006.         <ol class="dd-list" id="image-search-none"><div class="alert white">No results found in 5etools for those keywords.</div></ol>
  13007.        
  13008.         <ol class="dd-list" id="image-search-has-results">
  13009.                 <li class="dd-item dd-folder Vetoolsresult">
  13010.                         <div class="dd-content">
  13011.                                 <div class="folder-title">From 5etools</div>
  13012.                         </div>
  13013.        
  13014.                         <ol class="dd-list Vetoolsresultfolder" id="custom-art-results"></ol>
  13015.                 </li>
  13016.         </ol>
  13017.         </div>`;
  13018.  
  13019.         d20plus.configEditorHTML = `
  13020.         <div id="d20plus-configeditor" title="Config Editor" style="position: relative">
  13021.         <!-- populate with js -->
  13022.         </div>`;
  13023.  
  13024.         d20plus.configEditorButtonBarHTML = `
  13025.         <div class="ui-dialog-buttonpane ui-widget-content ui-helper-clearfix">
  13026.         <div class="ui-dialog-buttonset">
  13027.                 <button type="button" id="configsave" alt="Save" title="Save Config" class="btn" role="button" aria-disabled="false">
  13028.                         <span>Save</span>
  13029.                 </button>
  13030.         </div>
  13031.         </div>
  13032.         `;
  13033.  
  13034.         d20plus.tool.toolsListHtml = `
  13035.                 <div id="d20-tools-list" title="Tools List" style="position: relative">
  13036.                 <div class="tools-list">
  13037.                 <!-- populate with js -->
  13038.                 </div>
  13039.                 </div>
  13040.                 `;
  13041.  
  13042.         // no mods; just switched in to grant full features to non-pro
  13043.         d20plus.template_TokenEditor = `<script id='tmpl_tokeneditor' type='text/html'>
  13044.   <div class='dialog largedialog tokeneditor' style='display: block;'>
  13045.     <ul class='nav nav-tabs'>
  13046.       <li class='active'>
  13047.         <a data-tab='basic' href='javascript:void(0);'>Basic</a>
  13048.       </li>
  13049.       <li>
  13050.         <a data-tab='advanced' href='javascript:void(0);'>Advanced</a>
  13051.       </li>
  13052.     </ul>
  13053.     <div class='tab-content'>
  13054.       <div class='basic tab-pane'>
  13055.         <div style='float: left; width: 300px;'>
  13056.           <div style='float: right; margin-right: 85px; font-size: 1.2em; position: relative; top: -4px; cursor: help;'>
  13057.             <a class='showtip pictos' title='You can choose to have the token represent a Character from the Journal. If you do, the token&#39;s name, controlling players, and bar values will be based on the Character. Most times you&#39;ll just leave this set to None/Generic.'>?</a>
  13058.           </div>
  13059.           <label>Represents Character</label>
  13060.           <select class='represents'>
  13061.             <option value=''>None/Generic Token</option>
  13062.             <$ _.each(window.Campaign.activeCharacters(), function(char) { $>
  13063.             <option value="<$!char.id$>"><$!char.get("name")$></option>
  13064.             <$ }); $>
  13065.           </select>
  13066.           <div class='clear'></div>
  13067.           <div style='float: right; margin-right: 75px;'>
  13068.             <label>
  13069.               <input class='showname' type='checkbox' value='1'>
  13070.               Show nameplate?
  13071.             </label>
  13072.           </div>
  13073.           <label>Name</label>
  13074.           <input class='name' style='width: 210px;' type='text'>
  13075.           <div class='clear'></div>
  13076.           <label>Controlled By</label>
  13077.           <$ if(this.character) { $>
  13078.           <p>(Determined by Character settings)</p>
  13079.           <$ } else { $>
  13080.           <select class='controlledby selectize' multiple='true'>
  13081.             <option value='all'>All Players</option>
  13082.             <$ window.Campaign.players.each(function(player) { $>
  13083.             <option value="<$!player.id$>"><$!player.get("displayname")$></option>
  13084.             <$ }); $>
  13085.           </select>
  13086.           <$ } $>
  13087.           <div class='clear' style='height: 10px;'></div>
  13088.           <label>
  13089.             Tint Color
  13090.           </label>
  13091.           <input class='tint_color colorpicker' type='text'>
  13092.           <div class='clear'></div>
  13093.         </div>
  13094.         <div style='float: left; width: 300px;'>
  13095.           <label>
  13096.             <span class='bar_color_indicator' style='background-color: <$!window.Campaign.get('bar1_color')$>'></span>
  13097.             Bar 1
  13098.           </label>
  13099.           <div class='clear' style='height: 1px;'></div>
  13100.           <div class='inlineinputs' style='margin-top: 5px; margin-bottom: 5px;'>
  13101.             <input class='bar1_value' type='text'>
  13102.             /
  13103.             <input class='bar1_max' type='text'>
  13104.             <$ if(this.character) { $>
  13105.             <div style='float: right;'>
  13106.               <select class='bar1_link' style='width: 125px;'>
  13107.                 <option value=''>None</option>
  13108.                 <$ _.each(this.tokensettingsview.availAttribs(), function(attrib) { $>
  13109.                 <option value="<$!attrib.id$>"><$!attrib.name$>
  13110.                   <$ }); $>
  13111.               </select>
  13112.               <a class='pictos showtip' style='font-size: 1.2em; position: relative; top: -5px; margin-left: 10px; cursor: help;' title='You can choose an Attribute from the Character this token represents. The values for this bar will be synced to the values of that Attribute.'>?</a>
  13113.             </div>
  13114.             <$ } $>
  13115.           </div>
  13116.           <span style='color: #888;'>(Leave blank for no bar)</span>
  13117.           <div class='clear'></div>
  13118.           <label>
  13119.             <span class='bar_color_indicator' style='background-color: <$!window.Campaign.get('bar2_color')$>'></span>
  13120.             Bar 2
  13121.           </label>
  13122.           <div class='inlineinputs' style='margin-top: 5px; margin-bottom: 5px;'>
  13123.             <input class='bar2_value' type='text'>
  13124.             /
  13125.             <input class='bar2_max' type='text'>
  13126.             <$ if(this.character) { $>
  13127.             <div style='float: right; margin-right: 30px;'>
  13128.               <select class='bar2_link' style='width: 125px;'>
  13129.                 <option value=''>None</option>
  13130.                 <$ _.each(this.tokensettingsview.availAttribs(), function(attrib) { $>
  13131.                 <option value="<$!attrib.id$>"><$!attrib.name$>
  13132.                   <$ }); $>
  13133.               </select>
  13134.             </div>
  13135.             <$ } $>
  13136.           </div>
  13137.           <span style='color: #888;'>(Leave blank for no bar)</span>
  13138.           <div class='clear'></div>
  13139.           <label>
  13140.             <span class='bar_color_indicator' style='background-color: <$!window.Campaign.get('bar3_color')$>'></span>
  13141.             Bar 3
  13142.           </label>
  13143.           <div class='inlineinputs' style='margin-top: 5px; margin-bottom: 5px;'>
  13144.             <input class='bar3_value' type='text'>
  13145.             /
  13146.             <input class='bar3_max' type='text'>
  13147.             <$ if(this.character) { $>
  13148.             <div style='float: right; margin-right: 30px;'>
  13149.               <select class='bar3_link' style='width: 125px;'>
  13150.                 <option value=''>None</option>
  13151.                 <$ _.each(this.tokensettingsview.availAttribs(), function(attrib) { $>
  13152.                 <option value="<$!attrib.id$>"><$!attrib.name$>
  13153.                   <$ }); $>
  13154.               </select>
  13155.             </div>
  13156.             <$ } $>
  13157.           </div>
  13158.           <span style='color: #888;'>(Leave blank for no bar)</span>
  13159.           <div class='clear' style='height: 10px;'></div>
  13160.           <div style='float: left; width: 130px;'>
  13161.             <div style='float: right;'>
  13162.               <label>
  13163.                 <input class='aura1_square' type='checkbox'>
  13164.                 Square
  13165.               </label>
  13166.             </div>
  13167.             <label>
  13168.               Aura 1
  13169.             </label>
  13170.             <div class='inlineinputs' style='margin-top: 5px;'>
  13171.               <input class='aura1_radius' type='text'>
  13172.               <$!window.Campaign.activePage().get("scale_units")$>.
  13173.               <input class='aura1_color colorpicker' type='text'>
  13174.             </div>
  13175.           </div>
  13176.           <div style='float: left; width: 130px; margin-left: 20px;'>
  13177.             <div style='float: right;'>
  13178.               <label>
  13179.                 <input class='aura2_square' type='checkbox'>
  13180.                 Square
  13181.               </label>
  13182.             </div>
  13183.             <label>
  13184.               Aura 2
  13185.             </label>
  13186.             <div class='inlineinputs' style='margin-top: 5px;'>
  13187.               <input class='aura2_radius' type='text'>
  13188.               <$!window.Campaign.activePage().get("scale_units")$>.
  13189.               <input class='aura2_color colorpicker' type='text'>
  13190.             </div>
  13191.           </div>
  13192.           <div class='clear'></div>
  13193.         </div>
  13194.         <div class='clear'></div>
  13195.         <hr>
  13196.         <h4>
  13197.           GM Notes
  13198.           <span style='font-weight: regular; font-size: 0.9em;'>(Only visible to GMs)</span>
  13199.         </h4>
  13200.         <textarea class='gmnotes summernote'></textarea>
  13201.         <div class='clear'></div>
  13202.         <label>&nbsp;</label>
  13203.       </div>
  13204.       <div class='advanced tab-pane'>
  13205.         <div class='row-fluid'>
  13206.           <div class='span6'>
  13207.             <h4>Player Permissions</h4>
  13208.             <div style='margin-left: 5px;'>
  13209.               <div class='permission_section'>
  13210.                 <div class='inlineinputs'>
  13211.                   <label class='permissions_category'>Name</label>
  13212.                   <label>
  13213.                     <input class='showplayers_name' type='checkbox'>
  13214.                     See
  13215.                   </label>
  13216.                   <label>
  13217.                     <input class='playersedit_name' type='checkbox'>
  13218.                     Edit
  13219.                   </label>
  13220.                 </div>
  13221.                 <div class='clear'></div>
  13222.               </div>
  13223.               <hr>
  13224.               <div class='permission_section bar1'>
  13225.                 <div class='inlineinputs'>
  13226.                   <label class='permissions_category'>Bar 1</label>
  13227.                   <label>
  13228.                     <input class='showplayers_bar1' type='checkbox'>
  13229.                     See
  13230.                   </label>
  13231.                   <label>
  13232.                     <input class='playersedit_bar1' type='checkbox'>
  13233.                     Edit
  13234.                   </label>
  13235.                 </div>
  13236.                 <div class='clear'></div>
  13237.                 <label class='bar_val_permission'>
  13238.                   Text Overlay:
  13239.                   <select class='bar1options'>
  13240.                     <option value='hidden'>
  13241.                       Hidden
  13242.                     </option>
  13243.                     <option selected value='editors'>
  13244.                       Visible to Editors
  13245.                     </option>
  13246.                     <option value='everyone'>
  13247.                       Visible to Everyone
  13248.                     </option>
  13249.                   </select>
  13250.                 </label>
  13251.               </div>
  13252.               <hr>
  13253.               <div class='permission_section bar2'>
  13254.                 <div class='inlineinputs'>
  13255.                   <label class='permissions_category'>Bar 2</label>
  13256.                   <label>
  13257.                     <input class='showplayers_bar2' type='checkbox'>
  13258.                     See
  13259.                   </label>
  13260.                   <label>
  13261.                     <input class='playersedit_bar2' type='checkbox'>
  13262.                     Edit
  13263.                   </label>
  13264.                 </div>
  13265.                 <div class='clear'></div>
  13266.                 <label class='bar_val_permission'>
  13267.                   Text Overlay:
  13268.                   <select class='bar2options'>
  13269.                     <option value='hidden'>
  13270.                       Hidden
  13271.                     </option>
  13272.                     <option selected value='editors'>
  13273.                       Visible to Editors
  13274.                     </option>
  13275.                     <option value='everyone'>
  13276.                       Visible to Everyone
  13277.                     </option>
  13278.                   </select>
  13279.                 </label>
  13280.               </div>
  13281.               <hr>
  13282.               <div class='permission_section bar3'>
  13283.                 <div class='inlineinputs'>
  13284.                   <label class='permissions_category'>Bar 3</label>
  13285.                   <label>
  13286.                     <input class='showplayers_bar3' type='checkbox'>
  13287.                     See
  13288.                   </label>
  13289.                   <label>
  13290.                     <input class='playersedit_bar3' type='checkbox'>
  13291.                     Edit
  13292.                   </label>
  13293.                 </div>
  13294.                 <div class='clear'></div>
  13295.                 <label class='bar_val_permission'>
  13296.                   Text Overlay:
  13297.                   <select class='bar3options'>
  13298.                     <option value='hidden'>
  13299.                       Hidden
  13300.                     </option>
  13301.                     <option selected value='editors'>
  13302.                       Visible to Editors
  13303.                     </option>
  13304.                     <option value='everyone'>
  13305.                       Visible to Everyone
  13306.                     </option>
  13307.                   </select>
  13308.                 </label>
  13309.                 <div class='clear'></div>
  13310.               </div>
  13311.               <hr>
  13312.               <div class='permission_section barLocation'>
  13313.                 <label class='movable_token_bar'>
  13314.                   Bar Location:
  13315.                   <select>
  13316.                     <option selected value='above'>
  13317.                       Above
  13318.                     </option>
  13319.                     <option value='overlap_top'>
  13320.                       Top Overlapping
  13321.                     </option>
  13322.                     <option value='overlap_bottom'>
  13323.                       Bottom Overlapping
  13324.                     </option>
  13325.                     <option value='below'>
  13326.                       Below
  13327.                     </option>
  13328.                   </select>
  13329.                   <a class='showtip pictos' style='padding-left: 26px;' title='&lt;b&gt;Above:&lt;/b&gt; &lt;br&gt; All bars are above the token. (Default for new games) &lt;br&gt; &lt;b&gt;Top Overlapping:&lt;/b&gt; &lt;br&gt; The bottom-most bar overlaps the top of the token. Other bars float above it. &lt;br&gt; &lt;b&gt;Bottom Overlapping:&lt;/b&gt; &lt;br&gt; Bars fill the token from the bottom up. &lt;br&gt; &lt;b&gt;Below:&lt;/b&gt; &lt;br&gt; All bars are below the token.'>?</a>
  13330.                 </label>
  13331.                 <label class='compact_bar'>
  13332.                   Bar Style:
  13333.                   <div class='radio'>
  13334.                     <label>
  13335.                       <input name='barStyle' type='radio' value='standard'>
  13336.                       Standard
  13337.                     </label>
  13338.                   </div>
  13339.                   <div class='radio'>
  13340.                     <label style='width:50px;'>
  13341.                       <input name='barStyle' type='radio' value='compact'>
  13342.                       Compact
  13343.                     </label>
  13344.                   </div>
  13345.                   <a class='showtip pictos' id='barstyletip' title='&lt;b&gt;Standard:&lt;/b&gt;&lt;br&gt; Full sized token bar, displays text overlays. &lt;br&gt; &lt;b&gt;Compact:&lt;/b&gt; &lt;br&gt;Narrow token bars. No text overlay.'>?</a>
  13346.                 </label>
  13347.               </div>
  13348.               <hr>
  13349.               <div class='permission_section'>
  13350.                 <div class='inlineinputs'>
  13351.                   <label class='permissions_category'>Aura 1</label>
  13352.                   <label>
  13353.                     <input class='showplayers_aura1' type='checkbox'>
  13354.                     See
  13355.                   </label>
  13356.                   <label>
  13357.                     <input class='playersedit_aura1' type='checkbox'>
  13358.                     Edit
  13359.                   </label>
  13360.                 </div>
  13361.                 <div class='clear'></div>
  13362.               </div>
  13363.               <hr>
  13364.               <div class='permission_section'>
  13365.                 <div class='inlineinputs'>
  13366.                   <label class='permissions_category'>Aura 2</label>
  13367.                   <label>
  13368.                     <input class='showplayers_aura2' type='checkbox'>
  13369.                     See
  13370.                   </label>
  13371.                   <label>
  13372.                     <input class='playersedit_aura2' type='checkbox'>
  13373.                     Edit
  13374.                   </label>
  13375.                 </div>
  13376.                 <div class='clear'></div>
  13377.               </div>
  13378.               <hr>
  13379.               <small style='text-align: left; font-size: 0.9em;'>
  13380.                 See: All Players can view
  13381.                 <br>
  13382.                 Edit: Controlling players can view and change
  13383.               </small>
  13384.             </div>
  13385.             <div class='clear'></div>
  13386.           </div>
  13387.           <div class='span6'>
  13388.             <h4>Emits Light</h4>
  13389.             <div class='inlineinputs' style='margin-top: 5px; margin-bottom: 5px;'>
  13390.               <input class='light_radius' type='text'>
  13391.               <$!window.Campaign.activePage().get("scale_units")$>.
  13392.               <input class='light_dimradius' type='text'>
  13393.               <$!window.Campaign.activePage().get("scale_units")$>.
  13394.               <input class='light_angle' placeholder='360' type='text'>
  13395.               <span style='font-size: 2.0em;'>&deg;</span>
  13396.             </div>
  13397.             <span style='color: #888; padding-left: 5px;'>Light Radius / (optional) Start of Dim / Angle</span>
  13398.             <div class='inlineinputs' style='margin-top: 5px;'>
  13399.               <label style='margin-left: 7px;'>
  13400.                 <input class='light_otherplayers' type='checkbox'>
  13401.                 All Players See Light
  13402.               </label>
  13403.             </div>
  13404.             <div class='inlineinputs' style='margin-top: 2px;'>
  13405.               <label style='margin-left: 7px;'>
  13406.                 <input class='light_hassight' type='checkbox'>
  13407.                 Has Sight
  13408.               </label>
  13409.               <span style="margin-left: 9px; margin-right: 28px;">/</span>
  13410.               Angle:
  13411.               <input class='light_losangle' placeholder='360' type='text'>
  13412.               <span style='font-size: 2.0em;'>&deg;</span>
  13413.             </div>
  13414.             <div class='inlineinputs' style='margin-left: 90px; margin-top: 5px;'>
  13415.               <span style="margin-left: 8px; margin-right: 12px;">/</span>
  13416.               Multiplier:
  13417.               <input class='light_multiplier' placeholder='1.0' style='margin-right: 10px;' type='text'>x</input>
  13418.             </div>
  13419.             <h4>Advanced Fog of War</h4>
  13420.             <div class='inlineinputs' style='margin-top: 5px; margin-bottom: 5px;'>
  13421.               <input class='advfow_viewdistance' type='text'>
  13422.               <$!window.Campaign.activePage().get("scale_units")$>.
  13423.             </div>
  13424.             <span style='color: #888; padding-left: 5px;'>Reveal Distance</span>
  13425.             <!-- %h4 -->
  13426.             <!-- Token Actions -->
  13427.             <!-- %a.pictos.showtip(style="margin-left: 15px; cursor: help; font-size: 1.1em; position: relative; top: -2px;" title="Choose from Macros and Abilities of linked Character to show when token is selected") ? -->
  13428.             <!-- %p -->
  13429.             <!-- %strong Add New Token Action: -->
  13430.             <!-- %br -->
  13431.             <!-- %select.chosen(placeholder="Choose from the list...") -->
  13432.             <!-- %option(value="") Choose from the list... -->
  13433.             <!-- <$ if(this.character) { $> -->
  13434.             <!-- <optgroup label="Abilities"> -->
  13435.             <!-- <$ this.character.abilities.each(function(abil) { $> -->
  13436.             <!-- <option value="ability|<$!abil.get('id')$>"><$!abil.get('name')$></option> -->
  13437.             <!-- <$ }); $> -->
  13438.             <!-- </optgroup> -->
  13439.             <!-- <$ } $> -->
  13440.           </div>
  13441.         </div>
  13442.       </div>
  13443.     </div>
  13444.   </div>
  13445. </script>
  13446.         `;
  13447.  
  13448.         d20plus.template_pageSettings = `
  13449.         <script id="tmpl_pagesettings" type="text/html">
  13450.                 <label style='padding-top: 4px;'>
  13451.                         <strong>Page Size</strong>
  13452.                 </label>
  13453.                 <!-- BEGIN MOD -->
  13454.                 X: <input type="number" class="width" style="width: 50px;" value="<$!this.model.get("width")$>" /> un. (<$!this.model.get("width") * 70$> px)
  13455.                 <div style="margin-left: 110px; margin-top: 2px;">Y: <input type="number" class="height" style="width: 50px;" value="<$!this.model.get("height")$>" /> un. (<$!this.model.get("height") * 70$> px)</div>
  13456.                 <!-- END MOD -->
  13457.                 <small style='display: block; font-size: 0.9em; margin-left: 110px;'>width by height, 1 unit = 70 pixels</small>
  13458.                 <div class='clear' style='height: 15px;'></div>
  13459.                 <label style='margin-left: 55px; position: relative; top: 6px;'><strong>Scale:</strong> 1 unit =</label>
  13460.                 <input type="number" class="scale_number" style="width: 35px;" value="<$!this.model.get("scale_number")$>" />
  13461.                 <select class='scale_units' style='width: 65px; position: relative;'>
  13462.                         <option value='ft'>ft.</option>
  13463.                         <option value='m'>m.</option>
  13464.                         <option value='km'>km.</option>
  13465.                         <option value='mi'>mi.</option>
  13466.                         <option value='in'>in.</option>
  13467.                         <option value='cm'>cm.</option>
  13468.                         <option value='un'>un.</option>
  13469.                         <option value='hex'>hex</option>
  13470.                         <option value='sq'>sq.</option>
  13471.                         <option value='custom'>Custom...</option>
  13472.                 </select>
  13473.                 <div class='hidden' id='custom_scale_units'>
  13474.                         <label style='margin-left: 55px; position: relative; top: 6px;'><strong>Custom Unit</strong></label>
  13475.                         <input style='width: 60px;' type='text'>
  13476.                 </div>
  13477.                 <div class='clear' style='height: 15px;'></div>
  13478.                 <label>
  13479.                         <strong>Background</strong>
  13480.                 </label>
  13481.                 <input class='pagebackground' type='text'>
  13482.                 <hr>
  13483.                 <div data-feature_enabled='showgrid' id='grid_settings'>
  13484.                         <label style='position: relative; top: 8px;'>
  13485.                                 <strong>Grid</strong>
  13486.                         </label>
  13487.                         <input class='gridenabled' type='checkbox' value='1'>
  13488.                         <label class='checkbox'>&nbsp; Enabled, Size:</label>
  13489.                         <input type="number" class="snappingincrement" style="width: 35px;" value="<$!this.model.get("snapping_increment")$>" /> units
  13490.                         <div class='clear' style='height: 7px;'></div>
  13491.                         <label style='margin-left: 55px; position: relative; top: 4px;'>Type</label>
  13492.                         <select id='gridtype' style='width: 100px;'>
  13493.                                 <option selected value='square'>Square</option>
  13494.                                 <option value='hex'>Hex (V)</option>
  13495.                                 <option value='hexr'>Hex (H)</option>
  13496.                         </select>
  13497.                         <div class='clear' style='height: 7px;'></div>
  13498.                         <label class='checkbox' id='hexlabels' style='margin-left: 130px;'>
  13499.                                 <input class='gridlabels' type='checkbox' value='1'>&nbsp; Show Labels</input>
  13500.                         </label>
  13501.                         <div class='clear' style='height: 2px;'></div>
  13502.                         <label style='margin-left: 55px; position: relative; top: 4px;'>
  13503.                                 <a class='showtip pictos' href='https://wiki.roll20.net/Ruler' target='_blank'>?</a>
  13504.                                 Measurement
  13505.                         </label>
  13506.                         <select id='diagonaltype' style='width: 100px;'>
  13507.                                 <option class='squareonly' selected value='foure'>D&D 5E/4E Compatible</option>
  13508.                                 <option class='squareonly' value='threefive'>Pathfinder/3.5E Compatible</option>
  13509.                                 <option class='squareonly' value='manhattan'>Manhattan</option>
  13510.                                 <option class='hexonly' value='hex'>Hex Path</option>
  13511.                                 <option value='pythagorean'>Euclidean</option>
  13512.                         </select>
  13513.                         <div class='clear' style='height: 10px;'></div>
  13514.                         <label style='margin-left: 55px;'>Color</label>
  13515.                         <input class='gridcolor' type='text'>
  13516.                         <div class='clear' style='height: 7px;'></div>
  13517.                         <label style='margin-left: 55px;'>Opacity</label>
  13518.                         <div class='gridopacity'></div>
  13519.                 </div>
  13520.                 <div class='clear' style='height: 10px'></div>
  13521.                 <hr>
  13522.                 <!-- BEGIN MOD -->
  13523.                 <label style='position: relative; top: -2px;'>
  13524.                         <strong>Weather</strong>
  13525.                 </label>
  13526.                 <button class='btn Ve-btn-weather'>
  13527.                         Configure
  13528.                 </button>
  13529.                 <hr>
  13530.                 <!-- END MOD -->
  13531.                 <div class='lighting_feature' data-feature_enabled='showdarkness' id='fog_settings'>
  13532.                         <label class='feature_name'>
  13533.                                 <strong>Fog of War</strong>
  13534.                         </label>
  13535.                         <div class='feature_options'>
  13536.                                 <input class='darknessenabled feature_enabled' type='checkbox' value='1'>
  13537.                                 <label class='checkbox'>&nbsp; Enabled</label>
  13538.                         </div>
  13539.                 </div>
  13540.                 <div class='lighting_feature' data-feature_enabled='adv_fow_enabled' id='afow_settings'>
  13541.                         <!-- BEGIN MOD -->
  13542.                         <hr>
  13543.                         <strong style="display: block; margin-bottom: 10px;"><i>Requires a paid subscription or all players to use a betteR20 script</i></strong>
  13544.                         <!-- END MOD -->
  13545.                         <label class='feature_name'>
  13546.                                 <strong>Advanced Fog of War</strong>
  13547.                         </label>
  13548.                         <div class='feature_options'>
  13549.                                 <input class='advancedfowenabled feature_enabled showtip' type='checkbox' value='1'>
  13550.                                 <label class='checkbox'>&nbsp; Enabled</label>
  13551.                                 <div class='subsettings'>
  13552.                                         <div id='afow_grid_size'>
  13553.                                                 Size:
  13554.                                                 <input type="number" class="advancedfowgridsize" style="width: 30px; margin-bottom: 3px;" value="<$!this.model.get("adv_fow_grid_size")$>" />
  13555.                                                 units
  13556.                                         </div>
  13557.                                         <div>
  13558.                                                 <input class='advancedfowshowgrid showtip' title='By default the Advanced Fog of War hides the map grid anywhere revealed but the player can no longer see because of Dynamic Lighting. This option makes the grid always visible.' type='checkbox' value='1'>
  13559.                                                 <label class='checkbox'>&nbsp; Show Grid</label>
  13560.                                         </div>
  13561.                                         <div>
  13562.                                                 <input class='dimlightreveals showtip' title='By default the Advanced Fog of War will not be permanently revealed by Dynamic Lighting that is not bright. This option allows dim lighting to also reveal the fog.' type='checkbox' value='1'>
  13563.                                                 <label class='checkbox'>&nbsp; Dim Light Reveals</label>
  13564.                                         </div>
  13565.                                         <div>
  13566.                                                 <input class='showtip' id='afow_gm_see_all' title='By default, Advanced Fog of War is only revealed by tokens with sight that are controlled by at least one player.&lt;br&gt;This option allows tokens with sight which are not controlled by anyone to reveal Advanced Fog of War for the GM only.' type='checkbox' value='0'>
  13567.                                                 <label class='checkbox'>&nbsp; All Tokens Reveal (GM)</label>
  13568.                                         </div>
  13569.                                 </div>
  13570.                         </div>
  13571.                 </div>
  13572.                 <div class='lighting_feature' data-feature_enabled='showlighting' id='dynamic_lighting_settings'>
  13573.                         <label class='feature_name'>
  13574.                                 <strong>Dynamic Lighting</strong>
  13575.                         </label>
  13576.                         <div class='feature_options'>
  13577.                                 <input class='lightingenabled feature_enabled showtip' type='checkbox' value='1'>
  13578.                                 <label class='checkbox'>&nbsp; Enabled</label>
  13579.                                 <div class='subsettings'>
  13580.                                         <div>
  13581.                                                 <input class='lightenforcelos showtip' title="Player's line of sight set by what tokens they can control." type='checkbox' value='1'>
  13582.                                                 <label class='checkbox'>&nbsp; Enforce Line of Sight</label>
  13583.                                         </div>
  13584.                                         <div>
  13585.                                                 <input class='lightingupdate' type='checkbox' value='1'>
  13586.                                                 <label class='checkbox'>&nbsp; Only Update on Drop</label>
  13587.                                         </div>
  13588.                                         <div>
  13589.                                                 <input class='lightrestrictmove showtip' title="Don't allow player tokens to move through Dynamic Lighting walls. Can be enabled even if lighting is not used." type='checkbox' value='1'>
  13590.                                                 <label class='checkbox'>&nbsp; Restrict Movement</label>
  13591.                                         </div>
  13592.                                         <div>
  13593.                                                 <input class='lightglobalillum showtip' title='Instead of darkness show light in all places players can see.' type='checkbox' value='1'>
  13594.                                                 <label class='checkbox'>&nbsp; Global Illumination</label>
  13595.                                         </div>
  13596.                                 </div>
  13597.                         </div>
  13598.                 </div>
  13599.                 <div id='gm_darkness_opacity'>
  13600.                         <label class='feature_name'>
  13601.                                 <strong>Darkness Opacity (GM)</strong>
  13602.                         </label>
  13603.                         <div class='fogopacity showtip' title='The GM can see through dark areas hidden from the players when using Fog of War, Advanced Fog of War, and/or Dynamic Lighting. This setting adjusts the opacity of those dark areas for the GM only.'></div>
  13604.                 </div>
  13605.                 <div class='clear'></div>
  13606.                 <hr>
  13607.                 <label style='font-weight: bold;'>Play on Load</label>
  13608.                 <select class='pagejukeboxtrigger' style='width: 180px;'></select>
  13609.                 <div class='clear'></div>
  13610.                 <hr>
  13611.                 <button class='delete btn btn-danger' style='float: right;'>
  13612.                         Delete Page
  13613.                 </button>
  13614.                 <button class='archive btn'>
  13615.                         Archive Page
  13616.                 </button>
  13617.                 <div class='clear'></div>
  13618.         </script>
  13619.         `;
  13620.  
  13621.         d20plus.template_actionsMenu = `
  13622.                 <script id='tmpl_actions_menu' type='text/html'>
  13623.                         <div class='actions_menu d20contextmenu'>
  13624.                                 <ul>
  13625.                                         <$ if (Object.keys(this).length === 0) { $>
  13626.                                                 <li data-action-type='unlock-tokens'>Unlock...</li>
  13627.                                         <$ } $>
  13628.                                         <$ if(this.view && this.view.graphic.type == "image" && this.get("cardid") !== "") { $>
  13629.                                                 <li class='head hasSub' data-action-type='takecard'>Take Card</li>
  13630.                                                 <li class='head hasSub' data-action-type='flipcard'>Flip Card</li>
  13631.                                         <$ } $>
  13632.                                         <$ if(window.is_gm) { $>
  13633.                                                 <$ if(this.view && this.get("isdrawing") === false && window.currentEditingLayer != "map") { $>
  13634.                                                         <!-- BEGIN MOD -->
  13635.                                                         <li class='head hasSub' data-menuname='massroll'>
  13636.                                                                 Mass Roll &raquo;
  13637.                                                                 <ul class='submenu' data-menuname='massroll'>
  13638.                                                                         <li class='head hasSub' data-action-type='rollinit'>Initiative</li>
  13639.                                                                         <li class='head hasSub' data-action-type='rollsaves'>Save</li>
  13640.                                                                         <li class='head hasSub' data-action-type='rollskills'>Skill</li>
  13641.                                                                 </ul>
  13642.                                                         </li>
  13643.                                                         <!-- END MOD -->
  13644.                                                         <li class='head hasSub' data-action-type='addturn'>Add Turn</li>
  13645.                                                 <$ } $>
  13646.                                                 <!-- BEGIN MOD -->
  13647.                                                 <!-- <li class='head'>Edit</li> -->
  13648.                                                 <!-- END MOD -->
  13649.                                                 <$ if(this.view) { $>
  13650.                                                         <li data-action-type='delete'>Delete</li>
  13651.                                                         <li data-action-type='copy'>Copy</li>
  13652.                                                 <$ } $>
  13653.                                                 <li data-action-type='paste'>Paste</li>
  13654.                                                 <!-- BEGIN MOD -->
  13655.                                                 <$ if(!this.view) { $>
  13656.                                                         <li data-action-type='undo'>Undo</li>
  13657.                                                 <$ } $>
  13658.                                                 <!-- END MOD -->
  13659.                                                
  13660.                                                 <!-- BEGIN MOD -->          
  13661.                                                 <$ if(this.view) { $>
  13662.                                                         <li class='head hasSub' data-menuname='move'>
  13663.                                                         Move &raquo;
  13664.                                                                 <ul class='submenu' data-menuname='move'>
  13665.                                                                         <li data-action-type='tofront'>To Front</li>
  13666.                                                                         <li data-action-type='forward-one'>Forward One<!-- (B-F)--></li>
  13667.                                                                         <li data-action-type='back-one'>Back One<!-- (B-B)--></li>
  13668.                                                                         <li data-action-type='toback'>To Back</li>    
  13669.                                                                 </ul>
  13670.                                                         </li>
  13671.                                                 <$ } $>
  13672.                                                
  13673.                                                 <li class='head hasSub' data-menuname='VeUtil'>
  13674.                                                         Utilities &raquo;
  13675.                                                         <ul class='submenu' data-menuname='VeUtil'>
  13676.                                                                 <li data-action-type='util-scenes'>Start Scene</li>
  13677.                                                                 <$ if(this.get && this.get("type") == "image") { $>
  13678.                                                                         <div class="ctx__divider"></div>
  13679.                                                                         <li data-action-type='token-animate'>Animate</li>
  13680.                                                                         <li data-action-type='token-fly'>Set&nbsp;Flight&nbsp;Height</li>        
  13681.                                                                         <li data-action-type='token-light'>Set&nbsp;Light</li>
  13682.                                                                 <$ } $>
  13683.                                                         </ul>
  13684.                                                 </li>
  13685.                                                 <!-- END MOD -->
  13686.                                                
  13687.                                                 <li class='head hasSub' data-menuname='advanced'>
  13688.                                                         Advanced &raquo;
  13689.                                                         <ul class='submenu' data-menuname='advanced'>
  13690.                                                                 <li data-action-type='group'>Group</li>
  13691.                                                                 <li data-action-type='ungroup'>Ungroup</li>
  13692.                                                                 <$ if(this.get && this.get("type") == "image") { $>
  13693.                                                                         <li class="<$ if (this && this.get("isdrawing")) { $>active<$ } $>" data-action-type="toggledrawing">Is Drawing</li>
  13694.                                                                         <li class="<$ if (this && this.get("fliph")) { $>active<$ } $>" data-action-type="togglefliph">Flip Horizontal</li>
  13695.                                                                         <li class="<$ if (this && this.get("flipv")) { $>active<$ } $>" data-action-type="toggleflipv">Flip Vertical</li>
  13696.                                                                         <li data-action-type='setdimensions'>Set Dimensions</li>
  13697.                                                                         <$ if(window.currentEditingLayer == "map") { $>
  13698.                                                                                 <li data-action-type='aligntogrid'>Align to Grid</li>
  13699.                                                                         <$ } $>
  13700.                                                                 <$ } $>
  13701.                                                                
  13702.                                                                 <$ if(this.view) { $>
  13703.                                                                         <li data-action-type='lock-token'>Lock/Unlock Position</li>
  13704.                                                                 <$ } $>
  13705.                                                                
  13706.                                                                 <$ if(this.get && this.get("type") == "image") { $>
  13707.                                                                         <li data-action-type='copy-tokenid'>View Token ID</li>
  13708.                                                                 <$ } $>
  13709.                                                                 <$ if(this.get && this.get("type") == "path") { $>
  13710.                                                                         <li data-action-type='copy-pathid'>View Path ID</li>
  13711.                                                                 <$ } $>
  13712.                                                         </ul>
  13713.                                                 </li>
  13714.  
  13715.                                                 <li class='head hasSub' data-menuname='positioning'>
  13716.                                                         Layer &raquo;
  13717.                                                         <ul class='submenu' data-menuname='positioning'>
  13718.                                                                 <li data-action-type="tolayer_map" class='<$ if(this && this.get && this.get("layer") == "map") { $>active<$ } $>'><span class="pictos ctx__layer-icon">@</span> Map Layer</li>
  13719.                                                                 <!-- BEGIN MOD -->
  13720.                                                                 <li data-action-type="tolayer_background" class='<$ if(this && this.get && this.get("layer") == "background") { $>active<$ } $>'><span class="pictos ctx__layer-icon">a</span> Background Layer</li>
  13721.                                                                 <!-- END MOD -->
  13722.                                                                 <li data-action-type="tolayer_objects" class='<$ if(this && this.get && this.get("layer") == "objects") { $>active<$ } $>'><span class="pictos ctx__layer-icon">b</span> Token Layer</li>
  13723.                                                                 <!-- BEGIN MOD -->
  13724.                                                                 <li data-action-type="tolayer_foreground" class='<$ if(this && this.get && this.get("layer") == "foreground") { $>active<$ } $>'><span class="pictos ctx__layer-icon">B</span> Foreground Layer</li>
  13725.                                                                 <!-- END MOD -->
  13726.                                                                 <li data-action-type="tolayer_gmlayer" class='<$ if(this && this.get && this.get("layer") == "gmlayer") { $>active<$ } $>'><span class="pictos ctx__layer-icon">E</span> GM Layer</li>
  13727.                                                                 <li data-action-type="tolayer_walls" class='<$ if(this && this.get && this.get("layer") == "walls") { $>active<$ } $>'><span class="pictostwo ctx__layer-icon">r</span> Lighting Layer</li>
  13728.                                                                 <!-- BEGIN MOD -->
  13729.                                                                 <li data-action-type="tolayer_weather" class='<$ if(this && this.get && this.get("layer") == "weather") { $>active<$ } $>'><span class="pictos ctx__layer-icon">C</span> Weather Layer</li>
  13730.                                                                 <!-- END MOD -->
  13731.                                                         </ul>
  13732.                                                 </li>
  13733.                                         <$ } $>
  13734.  
  13735.                                         <$ if(this.view && this.get && this.get("sides") !== "" && this.get("cardid") === "") { $>
  13736.                                                 <li class='head hasSub' data-menuname='mutliside'>
  13737.                                                         Multi-Sided &raquo;
  13738.                                                         <ul class='submenu' data-menuname='multiside'>
  13739.                                                                 <li data-action-type='side_random'>Random Side</li>
  13740.                                                                 <li data-action-type='side_choose'>Choose Side</li>
  13741.                                                                 <li data-action-type='rollertokenresize'>Set Side Size</li>
  13742.                                                         </ul>
  13743.                                                 </li>
  13744.                                         <$ } $>
  13745.                                 </ul>
  13746.                         </div>
  13747.                 </script>
  13748.                 `;
  13749.  
  13750.         d20plus.template_charactereditor = `<script id='tmpl_charactereditor' type='text/html'>
  13751.   <div class='dialog largedialog charactereditor' style='display: block;'>
  13752.     <div class='tab-content'>
  13753.       <div class='bioinfo tab-pane'>
  13754.         <div class='row-fluid'>
  13755.           <div class='span5'>
  13756.             <label>
  13757.               <strong>Avatar</strong>
  13758.             </label>
  13759.             <$ if(true) { $>
  13760.             <div class="avatar dropbox <$! this.get("avatar") != "" ? "filled" : "" $>" style="width: 95%;">
  13761.             <div class="status"></div>
  13762.             <div class="inner">
  13763.               <$ if(this.get("avatar") == "") { $>
  13764.               <h4 style="padding-bottom: 0px; marigin-bottom: 0px; color: #777;">Drop a file from your <br>Art Library or computer<small>(JPG, GIF, PNG, WEBM, WP4)</small></h4>
  13765.               <br /> or
  13766.               <button class="btn">Click to Upload</button>
  13767.               <input class="manual" type="file" />
  13768.               <$ } else { $>
  13769.               <$ if(/.+\\.webm(\\?.*)?$/i.test(this.get("avatar"))) { $>
  13770.               <video src="<$!this.get("avatar")$>" draggable="false" muted autoplay loop />
  13771.               <$ } else { $>
  13772.               <img src="<$!this.get("avatar")$>" draggable="false" />
  13773.               <$ } $>
  13774.               <div class='remove'><a href='#'>Remove</a></div>
  13775.               <$ } $>
  13776.             </div>
  13777.           </div>
  13778.           <$ } else { $>
  13779.           <div class='avatar'>
  13780.             <$ if(this.get("avatar") != "") { $>
  13781.             <img src="<$!this.get("avatar")$>" draggable="false" />
  13782.             <$ } $>
  13783.           </div>
  13784.           <$ } $>
  13785.           <div class='clear'></div>
  13786.           <!-- BEGIN MOD -->
  13787.           <button class="btn character-image-by-url">Set Image from URL</button>
  13788.           <div class='clear'></div>
  13789.           <!-- END MOD -->
  13790.           <$ if (window.is_gm) { $>
  13791.           <label>
  13792.             <strong>Default Token (Optional)</strong>
  13793.           </label>
  13794.           <div class="defaulttoken tokenslot <$! this.get("defaulttoken") !== "" ? "filled" : "" $> style="width: 95%;">
  13795.           <$ if(this.get("defaulttoken") !== "") { $>
  13796.           <img src="" draggable="false" />
  13797.           <div class="remove"><a href="#">Remove</a></div>
  13798.           <$ } else { $>
  13799.           <button class="btn">Use Selected Token</button>
  13800.           <small>Select a token on the tabletop to use as the Default Token</small>
  13801.           <$ } $>
  13802.         </div>
  13803.         <!-- BEGIN MOD -->
  13804.         <button class="btn token-image-by-url">Set Token Image from URL</button>
  13805.         <small style="text-align: left;">(Update will only be visible upon re-opening the sheet)</small>
  13806.         <div class='clear'></div>
  13807.         <!-- END MOD -->
  13808.         <$ } $>
  13809.       </div>
  13810.       <div class='span7'>
  13811.         <label>
  13812.           <strong>Name</strong>
  13813.         </label>
  13814.         <input class='name' type='text'>
  13815.         <div class='clear'></div>
  13816.         <$ if(window.is_gm) { $>
  13817.         <label>
  13818.           <strong>In Player's Journals</strong>
  13819.         </label>
  13820.         <select class='inplayerjournals selectize' multiple='true' style='width: 100%;'>
  13821.           <option value="all">All Players</option>
  13822.           <$ window.Campaign.players.each(function(player) { $>
  13823.           <option value="<$!player.id$>"><$!player.get("displayname")$></option>
  13824.           <$ }); $>
  13825.         </select>
  13826.         <div class='clear'></div>
  13827.         <label>
  13828.           <strong>Can Be Edited &amp; Controlled By</strong>
  13829.         </label>
  13830.         <select class='controlledby selectize' multiple='true' style='width: 100%;'>
  13831.           <option value="all">All Players</option>
  13832.           <$ window.Campaign.players.each(function(player) { $>
  13833.           <option value="<$!player.id$>"><$!player.get("displayname")$></option>
  13834.           <$ }); $>
  13835.         </select>
  13836.         <div class='clear'></div>
  13837.         <label>
  13838.           <strong>Tags</strong>
  13839.         </label>
  13840.         <input class='tags'>
  13841.         <div class='clear'></div>
  13842.         <hr>
  13843.         <button class='delete btn btn-danger' style='float: right;'>
  13844.           Delete
  13845.         </button>
  13846.         <button class='duplicate btn' style='margin-right: 10px;'>
  13847.           Duplicate
  13848.         </button>
  13849.         <button class='archive btn'>
  13850.           <$ if(this.get("archived")) { $>Restore from Archive<$ } else { $>Archive<$ } $>
  13851.         </button>
  13852.         <div class='clear'></div>
  13853.         <$ } $>
  13854.         <div class='clear'></div>
  13855.       </div>
  13856.     </div>
  13857.     <div class='row-fluid'>
  13858.       <div class='span12'>
  13859.         <hr>
  13860.         <label>
  13861.           <strong>Bio & Info</strong>
  13862.         </label>
  13863.         <textarea class='bio'></textarea>
  13864.         <div class='clear'></div>
  13865.         <$ if(window.is_gm) { $>
  13866.         <label>
  13867.           <strong>GM Notes (Only visible to GM)</strong>
  13868.         </label>
  13869.         <textarea class='gmnotes'></textarea>
  13870.         <div class='clear'></div>
  13871.         <$ } $>
  13872.       </div>
  13873.     </div>
  13874.   </div>
  13875.   </div>
  13876.   </div>
  13877. </script>
  13878.                 `;
  13879.  
  13880.         d20plus.template_handouteditor = `<script id='tmpl_handouteditor' type='text/html'>
  13881.   <div class='dialog largedialog handouteditor' style='display: block;'>
  13882.     <div class='row-fluid'>
  13883.       <div class='span12'>
  13884.         <label>
  13885.           <strong>Name</strong>
  13886.         </label>
  13887.         <input class='name' type='text'>
  13888.         <div class='clear'></div>
  13889.         <$ if (window.is_gm) { $>
  13890.         <label>
  13891.           <strong>In Player's Journals</strong>
  13892.         </label>
  13893.         <select class='inplayerjournals chosen' multiple='true' style='width: 100%;'>
  13894.           <option value="all">All Players</option>
  13895.           <$ window.Campaign.players.each(function(player) { $>
  13896.           <option value="<$!player.id$>"><$!player.get("displayname")$></option>
  13897.           <$ }); $>
  13898.         </select>
  13899.         <div class='clear'></div>
  13900.         <label>
  13901.           <strong>Can Be Edited By</strong>
  13902.         </label>
  13903.         <select class='controlledby chosen' multiple='true' style='width: 100%;'>
  13904.           <option value="all">All Players</option>
  13905.           <$ window.Campaign.players.each(function(player) { $>
  13906.           <option value="<$!player.id$>"><$!player.get("displayname")$></option>
  13907.           <$ }); $>
  13908.         </select>
  13909.         <div class='clear'></div>
  13910.         <label>
  13911.           <strong>Tags</strong>
  13912.         </label>
  13913.         <input class='tags'>
  13914.         <div class='clear'></div>
  13915.         <$ } $>
  13916.       </div>
  13917.     </div>
  13918.     <div class='row-fluid'>
  13919.       <div class='span12'>
  13920.         <div class="avatar dropbox <$! this.get("avatar") != "" ? "filled" : "" $>">
  13921.         <div class="status"></div>
  13922.         <div class="inner">
  13923.           <$ if(this.get("avatar") == "") { $>
  13924.           <h4 style="padding-bottom: 0px; marigin-bottom: 0px; color: #777;">Drop a file</h4>
  13925.           <br /> or
  13926.           <button class="btn">Choose a file...</button>
  13927.           <input class="manual" type="file" />
  13928.           <$ } else { $>
  13929.           <$ if(/.+\\.webm(\\?.*)?$/i.test(this.get("avatar"))) { $>
  13930.           <video src="<$!this.get("avatar")$>" draggable="false" muted autoplay loop />
  13931.           <$ } else { $>
  13932.           <img src="<$!this.get("avatar")$>" />
  13933.           <$ } $>
  13934.           <div class='remove'><a href='#'>Remove</a></div>
  13935.           <$ } $>
  13936.         </div>
  13937.       </div>
  13938.       <div class='clear'></div>
  13939.     </div>
  13940.   </div>
  13941.   <!-- BEGIN MOD -->
  13942.   <div class='row-fluid'>
  13943.   <button class="btn handout-image-by-url">Set Image from URL</button>
  13944.   <div class='clear'></div>
  13945.   </div>
  13946.   <!-- END MOD -->
  13947.   <div class='row-fluid'>
  13948.     <div class='span12'>
  13949.       <label>
  13950.         <strong>Description & Notes</strong>
  13951.       </label>
  13952.       <textarea class='notes'></textarea>
  13953.       <div class='clear'></div>
  13954.       <$ if(window.is_gm) { $>
  13955.       <label>
  13956.         <strong>GM Notes (Only visible to GM)</strong>
  13957.       </label>
  13958.       <textarea class='gmnotes'></textarea>
  13959.       <div class='clear'></div>
  13960.       <hr>
  13961.       <button class='delete btn btn-danger' style='float: right;'>
  13962.         Delete Handout
  13963.       </button>
  13964.       <button class='duplicate btn' style='margin-right: 10px;'>
  13965.         Duplicate
  13966.       </button>
  13967.       <button class='archive btn'>
  13968.         <$ if(this.get("archived")) { $>Restore Handout from Archive<$ } else { $>Archive<$ } $>
  13969.       </button>
  13970.       <div class='clear'></div>
  13971.       <$ } $>
  13972.     </div>
  13973.   </div>
  13974.   </div>
  13975. </script>
  13976. <script id='tmpl_handoutviewer' type='text/html'>
  13977.   <div class='dialog largedialog handoutviewer' style='display: block;'>
  13978.     <div style='padding: 10px;'>
  13979.       <$ if(this.get("avatar") != "") { $>
  13980.       <div class='row-fluid'>
  13981.         <div class='span12'>
  13982.           <div class='avatar'>
  13983.             <a class="lightly" target="_blank" href="<$!(this.get("avatar").indexOf("d20.io/") !== -1 ? this.get("avatar").replace(/\\/med\\.(?!webm)/, "/max.") : this.get("avatar"))$>">
  13984.             <$ if(/.+\\.webm(\\?.*)?$/i.test(this.get("avatar"))) { $>
  13985.             <video src="<$!this.get("avatar")$>" draggable="false" loop muted autoplay />
  13986.             <$ } else { $>
  13987.             <img src="<$!this.get("avatar")$>" draggable="false" />
  13988.             <$ } $>
  13989.             <div class='mag-glass pictos'>s</div></a>
  13990.             </a>
  13991.           </div>
  13992.           <div class='clear'></div>
  13993.         </div>
  13994.       </div>
  13995.       <$ } $>
  13996.       <div class='row-fluid'>
  13997.         <div class='span12'>
  13998.           <div class='content note-editor notes'></div>
  13999.           <div class='clear'></div>
  14000.         </div>
  14001.       </div>
  14002.       <$ if(window.is_gm) { $>
  14003.       <div class='row-fluid'>
  14004.         <div class='span12'>
  14005.           <hr>
  14006.           <label>
  14007.             <strong>GM Notes (Only visible to GM)</strong>
  14008.           </label>
  14009.           <div class='content note-editor gmnotes'></div>
  14010.           <div class='clear'></div>
  14011.         </div>
  14012.       </div>
  14013.       <$ } $>
  14014.     </div>
  14015.   </div>
  14016. </script>
  14017.         `;
  14018.  
  14019.         d20plus.template.deckeditor = `
  14020.         <script id='tmpl_deckeditor' type='text/html'>
  14021.       <div class='dialog largedialog deckeditor' style='display: block;'>
  14022.         <label>Name</label>
  14023.         <input class='name' type='text'>
  14024.         <div class='clear' style='height: 14px;'></div>
  14025.         <label>
  14026.           <input class='showplayers' type='checkbox'>
  14027.           Show deck to players?
  14028.         </label>
  14029.         <div class='clear' style='height: 7px;'></div>
  14030.         <label>
  14031.           <input class='playerscandraw' type='checkbox'>
  14032.           Players can draw cards?
  14033.         </label>
  14034.         <div class='clear' style='height: 7px;'></div>
  14035.         <label>
  14036.           <input class='infinitecards' type='checkbox'>
  14037.           Cards in deck are infinite?
  14038.         </label>
  14039.         <p class='infinitecardstype'>
  14040.           <label>
  14041.             <input name='infinitecardstype' type='radio' value='random'>
  14042.             Always a random card
  14043.           </label>
  14044.           <label>
  14045.             <input name='infinitecardstype' type='radio' value='cycle'>
  14046.             Draw through deck, shuffle, repeat
  14047.           </label>
  14048.         </p>
  14049.         <div class='clear' style='height: 7px;'></div>
  14050.         <label>
  14051.           Allow choosing specific cards from deck:
  14052.           <select class='deckpilemode'>
  14053.             <option value='none'>Disabled</option>
  14054.             <option value='choosebacks_gm'>GM Choose: Show Backs</option>
  14055.             <option value='choosefronts_gm'>GM Choose: Show Fronts</option>
  14056.             <option value='choosebacks'>GM + Players Choose: Show Backs</option>
  14057.             <option value='choosefronts'>GM + Players Choose: Show Fronts</option>
  14058.           </select>
  14059.         </label>
  14060.         <div class='clear' style='height: 7px;'></div>
  14061.         <label>
  14062.           Discard Pile:
  14063.           <select class='discardpilemode'>
  14064.             <option value='none'>No discard pile</option>
  14065.             <option value='choosebacks'>Choose: Show Backs</option>
  14066.             <option value='choosefronts'>Choose: Show Fronts</option>
  14067.             <option value='drawtop'>Draw most recent/top card</option>
  14068.             <option value='drawbottom'>Draw oldest/bottom card</option>
  14069.           </select>
  14070.         </label>
  14071.         <div class='clear' style='height: 7px;'></div>
  14072.         <hr>
  14073.         <strong>When played to the tabletop...</strong>
  14074.         <div class='clear' style='height: 5px;'></div>
  14075.         <label>
  14076.           Played Facing:
  14077.           <select class='cardsplayed' style='display: inline-block; width: auto; position: relative; top: 3px;'>
  14078.             <option value='facedown'>Face Down</option>
  14079.             <option value='faceup'>Face Up</option>
  14080.           </select>
  14081.         </label>
  14082.         <div class='clear' style='height: 7px;'></div>
  14083.         <label>
  14084.           Considered:
  14085.           <select class='treatasdrawing' style='display: inline-block; width: auto; position: relative; top: 3px;'>
  14086.             <option value='true'>Drawings (No Bubbles/Stats)</option>
  14087.             <option value='false'>Tokens (Including Bubbles and Stats)</option>
  14088.           </select>
  14089.         </label>
  14090.         <div class='clear' style='height: 7px;'></div>
  14091.         <div class='inlineinputs'>
  14092.           Card Size:
  14093.           <input class='defaultwidth' type='text'>
  14094.           x
  14095.           <input class='defaultheight' type='text'>
  14096.           px
  14097.         </div>
  14098.         <small style='text-align: left; padding-left: 135px; width: auto;'>Leave blank for default auto-sizing</small>
  14099.         <div class='clear' style='height: 7px;'></div>
  14100.         <!-- %label -->
  14101.         <!-- %input.showalldrawn(type="checkbox") -->
  14102.         <!-- Everyone sees what card is drawn onto top of deck? -->
  14103.         <!-- .clear(style="height: 7px;") -->
  14104.         <hr>
  14105.         <strong>In other's hands...</strong>
  14106.         <div class='clear' style='height: 5px;'></div>
  14107.         <div class='inlineinputs'>
  14108.           <label style='width: 75px;'>Players see:</label>
  14109.           <label>
  14110.             <input class='players_seenumcards' type='checkbox'>
  14111.             Number of Cards
  14112.           </label>
  14113.           <label>
  14114.             <input class='players_seefrontofcards' type='checkbox'>
  14115.             Front of Cards
  14116.           </label>
  14117.         </div>
  14118.         <div class='clear' style='height: 5px;'></div>
  14119.         <div class='inlineinputs'>
  14120.           <label style='width: 75px;'>GM sees:</label>
  14121.           <label>
  14122.             <input class='gm_seenumcards' type='checkbox'>
  14123.             Number of Cards
  14124.           </label>
  14125.           <label>
  14126.             <input class='gm_seefrontofcards' type='checkbox'>
  14127.             Front of Cards
  14128.           </label>
  14129.         </div>
  14130.         <div class='clear' style='height: 5px;'></div>
  14131.         <hr>
  14132.         <!-- BEGIN MOD -->
  14133.         <button class='btn deck-mass-cards-by-url' style='float: right; margin-left: 5px;' data-deck-id="<$!this.id$>">
  14134.           Add Cards from URLs
  14135.         </button>
  14136.         <!-- END MOD -->
  14137.         <button class='addcard btn' style='float: right;'>
  14138.           <span class='pictos'>&</span>
  14139.           Add Card
  14140.         </button>
  14141.         <h3>Cards</h3>
  14142.         <div class='clear' style='height: 7px;'></div>
  14143.         <table class='table table-striped'>
  14144.           <tbody></tbody>
  14145.         </table>
  14146.         <div class='clear' style='height: 15px;'></div>
  14147.         <label>
  14148.           <strong>Card Backing (Required)</strong>
  14149.         </label>
  14150.         <div class='clear' style='height: 7px;'></div>
  14151.         <!-- BEGIN MOD -->
  14152.         <button class='btn deck-image-by-url' style="margin-bottom: 10px" data-deck-id="<$!this.id$>">Set image from URL...</button>
  14153.         <!-- END MOD -->
  14154.         <div class="avatar dropbox <$! this.get("avatar") != "" ? "filled" : "" $>">
  14155.         <div class='status'></div>
  14156.         <div class='inner'></div>
  14157.         <$ if(this.get("avatar") == "") { $>
  14158.         <h4 style='padding-bottom: 0px; marigin-bottom: 0px; color: #777;'>Drop a file</h4>
  14159.         <br>or</br>
  14160.         <button class='btn'>Choose a file...</button>
  14161.         <input class='manual' type='file'>
  14162.         <$ } else { $>
  14163.         <img src="<$!this.get("avatar")$>" />
  14164.         <div class='remove'>
  14165.           <a href='javascript:void(0);'>Remove</a>
  14166.         </div>
  14167.         <$ } $>
  14168.         </div>
  14169.         </div>
  14170.         <div class='clear' style='height: 20px;'></div>
  14171.         <p style='float: left;'>
  14172.           <button class='btn dupedeck'>Duplicate Deck</button>
  14173.         </p>
  14174.         <$ if(this.id != "A778E120-672D-49D0-BAF8-8646DA3D3FAC") { $>
  14175.         <p style='text-align: right;'>
  14176.           <button class='btn btn-danger deletedeck'>Delete Deck</button>
  14177.         </p>
  14178.         <$ } $>
  14179.       </div>
  14180.     </script>
  14181.         `;
  14182.         d20plus.template.cardeditor = `
  14183.     <script id='tmpl_cardeditor' type='text/html'>
  14184.       <div class='dialog largedialog cardeditor' style='display: block;'>
  14185.         <label>Name</label>
  14186.         <input class='name' type='text'>
  14187.         <div class='clear'></div>
  14188.         <!-- BEGIN MOD -->
  14189.         <button class='btn card-image-by-url' style="margin-bottom: 10px" data-card-id="<$!this.id$>">Set image from URL...</button>
  14190.         <!-- END MOD -->
  14191.         <div class="avatar dropbox <$! this.get("avatar") != "" ? "filled" : "" $>">
  14192.         <div class="status"></div>
  14193.         <div class="inner">
  14194.         <$ if(this.get("avatar") == "") { $>
  14195.         <h4 style='padding-bottom: 0px; marigin-bottom: 0px; color: #777;'>Drop a file</h4>
  14196.         <br>or</br>
  14197.         <button class='btn'>Choose a file...</button>
  14198.         <input class='manual' type='file'>
  14199.         <$ } else { $>
  14200.         <img src="<$!this.get("avatar")$>" />
  14201.         <div class='remove'>
  14202.           <a href='javascript:void(0);'>Remove</a>
  14203.         </div>
  14204.         <$ } $>
  14205.         </div>
  14206.         </div>
  14207.         <div class='clear'></div>
  14208.         <label>&nbsp;</label>
  14209.         <button class='deletecard btn btn-danger'>Delete Card</button>
  14210.       </div>
  14211.     </script>
  14212.         `
  14213. };
  14214.  
  14215. SCRIPT_EXTENSIONS.push(baseTemplate);
  14216.  
  14217.  
  14218. const betteR20Emoji = function () {
  14219.         d20plus.chat = {};
  14220.  
  14221.         // to dump the keys as one-per-line colon-fotmatted: `JSON.stringify(Object.keys(d20plus.chat.emojiIndex).sort(SortUtil.ascSortLower), null, 1).replace(/",/g, ":").replace(/"/g, ":").replace(/[ \[\]]/g, "").trim()`
  14222.         d20plus.chat.emojiIndex = {
  14223.                 joy: !0,
  14224.                 heart: !0,
  14225.                 heart_eyes: !0,
  14226.                 sob: !0,
  14227.                 blush: !0,
  14228.                 unamused: !0,
  14229.                 kissing_heart: !0,
  14230.                 two_hearts: !0,
  14231.                 weary: !0,
  14232.                 ok_hand: !0,
  14233.                 pensive: !0,
  14234.                 smirk: !0,
  14235.                 grin: !0,
  14236.                 recycle: !0,
  14237.                 wink: !0,
  14238.                 thumbsup: !0,
  14239.                 pray: !0,
  14240.                 relieved: !0,
  14241.                 notes: !0,
  14242.                 flushed: !0,
  14243.                 raised_hands: !0,
  14244.                 see_no_evil: !0,
  14245.                 cry: !0,
  14246.                 sunglasses: !0,
  14247.                 v: !0,
  14248.                 eyes: !0,
  14249.                 sweat_smile: !0,
  14250.                 sparkles: !0,
  14251.                 sleeping: !0,
  14252.                 smile: !0,
  14253.                 purple_heart: !0,
  14254.                 broken_heart: !0,
  14255.                 expressionless: !0,
  14256.                 sparkling_heart: !0,
  14257.                 blue_heart: !0,
  14258.                 confused: !0,
  14259.                 information_desk_person: !0,
  14260.                 stuck_out_tongue_winking_eye: !0,
  14261.                 disappointed: !0,
  14262.                 yum: !0,
  14263.                 neutral_face: !0,
  14264.                 sleepy: !0,
  14265.                 clap: !0,
  14266.                 cupid: !0,
  14267.                 heartpulse: !0,
  14268.                 revolving_hearts: !0,
  14269.                 arrow_left: !0,
  14270.                 speak_no_evil: !0,
  14271.                 kiss: !0,
  14272.                 point_right: !0,
  14273.                 cherry_blossom: !0,
  14274.                 scream: !0,
  14275.                 fire: !0,
  14276.                 rage: !0,
  14277.                 smiley: !0,
  14278.                 tada: !0,
  14279.                 tired_face: !0,
  14280.                 camera: !0,
  14281.                 rose: !0,
  14282.                 stuck_out_tongue_closed_eyes: !0,
  14283.                 muscle: !0,
  14284.                 skull: !0,
  14285.                 sunny: !0,
  14286.                 yellow_heart: !0,
  14287.                 triumph: !0,
  14288.                 new_moon_with_face: !0,
  14289.                 laughing: !0,
  14290.                 sweat: !0,
  14291.                 point_left: !0,
  14292.                 heavy_check_mark: !0,
  14293.                 heart_eyes_cat: !0,
  14294.                 grinning: !0,
  14295.                 mask: !0,
  14296.                 green_heart: !0,
  14297.                 wave: !0,
  14298.                 persevere: !0,
  14299.                 heartbeat: !0,
  14300.                 arrow_forward: !0,
  14301.                 arrow_backward: !0,
  14302.                 arrow_right_hook: !0,
  14303.                 leftwards_arrow_with_hook: !0,
  14304.                 crown: !0,
  14305.                 kissing_closed_eyes: !0,
  14306.                 stuck_out_tongue: !0,
  14307.                 disappointed_relieved: !0,
  14308.                 innocent: !0,
  14309.                 headphones: !0,
  14310.                 white_check_mark: !0,
  14311.                 confounded: !0,
  14312.                 arrow_right: !0,
  14313.                 angry: !0,
  14314.                 grimacing: !0,
  14315.                 star2: !0,
  14316.                 gun: !0,
  14317.                 raising_hand: !0,
  14318.                 thumbsdown: !0,
  14319.                 dancer: !0,
  14320.                 musical_note: !0,
  14321.                 no_mouth: !0,
  14322.                 dizzy: !0,
  14323.                 fist: !0,
  14324.                 point_down: !0,
  14325.                 red_circle: !0,
  14326.                 no_good: !0,
  14327.                 boom: !0,
  14328.                 thought_balloon: !0,
  14329.                 tongue: !0,
  14330.                 poop: !0,
  14331.                 cold_sweat: !0,
  14332.                 gem: !0,
  14333.                 ok_woman: !0,
  14334.                 pizza: !0,
  14335.                 joy_cat: !0,
  14336.                 sun_with_face: !0,
  14337.                 leaves: !0,
  14338.                 sweat_drops: !0,
  14339.                 penguin: !0,
  14340.                 zzz: !0,
  14341.                 walking: !0,
  14342.                 airplane: !0,
  14343.                 balloon: !0,
  14344.                 star: !0,
  14345.                 ribbon: !0,
  14346.                 ballot_box_with_check: !0,
  14347.                 worried: !0,
  14348.                 underage: !0,
  14349.                 fearful: !0,
  14350.                 four_leaf_clover: !0,
  14351.                 hibiscus: !0,
  14352.                 microphone: !0,
  14353.                 open_hands: !0,
  14354.                 ghost: !0,
  14355.                 palm_tree: !0,
  14356.                 bangbang: !0,
  14357.                 nail_care: !0,
  14358.                 x: !0,
  14359.                 alien: !0,
  14360.                 bow: !0,
  14361.                 cloud: !0,
  14362.                 soccer: !0,
  14363.                 angel: !0,
  14364.                 dancers: !0,
  14365.                 exclamation: !0,
  14366.                 snowflake: !0,
  14367.                 point_up: !0,
  14368.                 kissing_smiling_eyes: !0,
  14369.                 rainbow: !0,
  14370.                 crescent_moon: !0,
  14371.                 heart_decoration: !0,
  14372.                 gift_heart: !0,
  14373.                 gift: !0,
  14374.                 beers: !0,
  14375.                 anguished: !0,
  14376.                 earth_africa: !0,
  14377.                 movie_camera: !0,
  14378.                 anchor: !0,
  14379.                 zap: !0,
  14380.                 heavy_multiplication_x: !0,
  14381.                 runner: !0,
  14382.                 sunflower: !0,
  14383.                 earth_americas: !0,
  14384.                 bouquet: !0,
  14385.                 dog: !0,
  14386.                 moneybag: !0,
  14387.                 herb: !0,
  14388.                 couple: !0,
  14389.                 fallen_leaf: !0,
  14390.                 tulip: !0,
  14391.                 birthday: !0,
  14392.                 cat: !0,
  14393.                 coffee: !0,
  14394.                 dizzy_face: !0,
  14395.                 point_up_2: !0,
  14396.                 open_mouth: !0,
  14397.                 hushed: !0,
  14398.                 basketball: !0,
  14399.                 christmas_tree: !0,
  14400.                 ring: !0,
  14401.                 full_moon_with_face: !0,
  14402.                 astonished: !0,
  14403.                 two_women_holding_hands: !0,
  14404.                 money_with_wings: !0,
  14405.                 crying_cat_face: !0,
  14406.                 hear_no_evil: !0,
  14407.                 dash: !0,
  14408.                 cactus: !0,
  14409.                 hotsprings: !0,
  14410.                 telephone: !0,
  14411.                 maple_leaf: !0,
  14412.                 princess: !0,
  14413.                 massage: !0,
  14414.                 love_letter: !0,
  14415.                 trophy: !0,
  14416.                 person_frowning: !0,
  14417.                 us: !0,
  14418.                 confetti_ball: !0,
  14419.                 blossom: !0,
  14420.                 lips: !0,
  14421.                 fries: !0,
  14422.                 doughnut: !0,
  14423.                 frowning: !0,
  14424.                 ocean: !0,
  14425.                 bomb: !0,
  14426.                 ok: !0,
  14427.                 cyclone: !0,
  14428.                 rocket: !0,
  14429.                 umbrella: !0,
  14430.                 couplekiss: !0,
  14431.                 couple_with_heart: !0,
  14432.                 lollipop: !0,
  14433.                 clapper: !0,
  14434.                 pig: !0,
  14435.                 smiling_imp: !0,
  14436.                 imp: !0,
  14437.                 bee: !0,
  14438.                 kissing_cat: !0,
  14439.                 anger: !0,
  14440.                 musical_score: !0,
  14441.                 santa: !0,
  14442.                 earth_asia: !0,
  14443.                 football: !0,
  14444.                 guitar: !0,
  14445.                 panda_face: !0,
  14446.                 speech_balloon: !0,
  14447.                 strawberry: !0,
  14448.                 smirk_cat: !0,
  14449.                 banana: !0,
  14450.                 watermelon: !0,
  14451.                 snowman: !0,
  14452.                 smile_cat: !0,
  14453.                 top: !0,
  14454.                 eggplant: !0,
  14455.                 crystal_ball: !0,
  14456.                 fork_and_knife: !0,
  14457.                 calling: !0,
  14458.                 iphone: !0,
  14459.                 partly_sunny: !0,
  14460.                 warning: !0,
  14461.                 scream_cat: !0,
  14462.                 small_orange_diamond: !0,
  14463.                 baby: !0,
  14464.                 feet: !0,
  14465.                 footprints: !0,
  14466.                 beer: !0,
  14467.                 wine_glass: !0,
  14468.                 o: !0,
  14469.                 video_camera: !0,
  14470.                 rabbit: !0,
  14471.                 tropical_drink: !0,
  14472.                 smoking: !0,
  14473.                 space_invader: !0,
  14474.                 peach: !0,
  14475.                 snake: !0,
  14476.                 turtle: !0,
  14477.                 cherries: !0,
  14478.                 kissing: !0,
  14479.                 frog: !0,
  14480.                 milky_way: !0,
  14481.                 rotating_light: !0,
  14482.                 hatching_chick: !0,
  14483.                 closed_book: !0,
  14484.                 candy: !0,
  14485.                 hamburger: !0,
  14486.                 bear: !0,
  14487.                 tiger: !0,
  14488.                 fast_forward: !0,
  14489.                 icecream: !0,
  14490.                 pineapple: !0,
  14491.                 ear_of_rice: !0,
  14492.                 syringe: !0,
  14493.                 put_litter_in_its_place: !0,
  14494.                 chocolate_bar: !0,
  14495.                 black_small_square: !0,
  14496.                 tv: !0,
  14497.                 pill: !0,
  14498.                 octopus: !0,
  14499.                 jack_o_lantern: !0,
  14500.                 grapes: !0,
  14501.                 smiley_cat: !0,
  14502.                 cd: !0,
  14503.                 cocktail: !0,
  14504.                 cake: !0,
  14505.                 video_game: !0,
  14506.                 arrow_down: !0,
  14507.                 no_entry_sign: !0,
  14508.                 lipstick: !0,
  14509.                 whale: !0,
  14510.                 cookie: !0,
  14511.                 dolphin: !0,
  14512.                 loud_sound: !0,
  14513.                 man: !0,
  14514.                 hatched_chick: !0,
  14515.                 monkey: !0,
  14516.                 books: !0,
  14517.                 japanese_ogre: !0,
  14518.                 guardsman: !0,
  14519.                 loudspeaker: !0,
  14520.                 scissors: !0,
  14521.                 girl: !0,
  14522.                 mortar_board: !0,
  14523.                 fr: !0,
  14524.                 baseball: !0,
  14525.                 vertical_traffic_light: !0,
  14526.                 woman: !0,
  14527.                 fireworks: !0,
  14528.                 stars: !0,
  14529.                 sos: !0,
  14530.                 mushroom: !0,
  14531.                 pouting_cat: !0,
  14532.                 left_luggage: !0,
  14533.                 high_heel: !0,
  14534.                 dart: !0,
  14535.                 swimmer: !0,
  14536.                 key: !0,
  14537.                 bikini: !0,
  14538.                 family: !0,
  14539.                 pencil2: !0,
  14540.                 elephant: !0,
  14541.                 droplet: !0,
  14542.                 seedling: !0,
  14543.                 apple: !0,
  14544.                 cool: !0,
  14545.                 telephone_receiver: !0,
  14546.                 dollar: !0,
  14547.                 house_with_garden: !0,
  14548.                 book: !0,
  14549.                 haircut: !0,
  14550.                 computer: !0,
  14551.                 bulb: !0,
  14552.                 question: !0,
  14553.                 back: !0,
  14554.                 boy: !0,
  14555.                 closed_lock_with_key: !0,
  14556.                 person_with_pouting_face: !0,
  14557.                 tangerine: !0,
  14558.                 sunrise: !0,
  14559.                 poultry_leg: !0,
  14560.                 blue_circle: !0,
  14561.                 oncoming_automobile: !0,
  14562.                 shaved_ice: !0,
  14563.                 bird: !0,
  14564.                 first_quarter_moon_with_face: !0,
  14565.                 eyeglasses: !0,
  14566.                 goat: !0,
  14567.                 night_with_stars: !0,
  14568.                 older_woman: !0,
  14569.                 black_circle: !0,
  14570.                 new_moon: !0,
  14571.                 two_men_holding_hands: !0,
  14572.                 white_circle: !0,
  14573.                 customs: !0,
  14574.                 tropical_fish: !0,
  14575.                 house: !0,
  14576.                 arrows_clockwise: !0,
  14577.                 last_quarter_moon_with_face: !0,
  14578.                 round_pushpin: !0,
  14579.                 full_moon: !0,
  14580.                 athletic_shoe: !0,
  14581.                 lemon: !0,
  14582.                 baby_bottle: !0,
  14583.                 spaghetti: !0,
  14584.                 wind_chime: !0,
  14585.                 fish_cake: !0,
  14586.                 evergreen_tree: !0,
  14587.                 up: !0,
  14588.                 arrow_up: !0,
  14589.                 arrow_upper_right: !0,
  14590.                 arrow_lower_right: !0,
  14591.                 arrow_lower_left: !0,
  14592.                 performing_arts: !0,
  14593.                 nose: !0,
  14594.                 pig_nose: !0,
  14595.                 fish: !0,
  14596.                 man_with_turban: !0,
  14597.                 koala: !0,
  14598.                 ear: !0,
  14599.                 eight_spoked_asterisk: !0,
  14600.                 small_blue_diamond: !0,
  14601.                 shower: !0,
  14602.                 bug: !0,
  14603.                 ramen: !0,
  14604.                 tophat: !0,
  14605.                 bride_with_veil: !0,
  14606.                 fuelpump: !0,
  14607.                 checkered_flag: !0,
  14608.                 horse: !0,
  14609.                 watch: !0,
  14610.                 monkey_face: !0,
  14611.                 baby_symbol: !0,
  14612.                 new: !0,
  14613.                 free: !0,
  14614.                 sparkler: !0,
  14615.                 corn: !0,
  14616.                 tennis: !0,
  14617.                 alarm_clock: !0,
  14618.                 battery: !0,
  14619.                 grey_exclamation: !0,
  14620.                 wolf: !0,
  14621.                 moyai: !0,
  14622.                 cow: !0,
  14623.                 mega: !0,
  14624.                 older_man: !0,
  14625.                 dress: !0,
  14626.                 link: !0,
  14627.                 chicken: !0,
  14628.                 whale2: !0,
  14629.                 arrow_upper_left: !0,
  14630.                 deciduous_tree: !0,
  14631.                 bento: !0,
  14632.                 pushpin: !0,
  14633.                 soon: !0,
  14634.                 repeat: !0,
  14635.                 dragon: !0,
  14636.                 hamster: !0,
  14637.                 golf: !0,
  14638.                 surfer: !0,
  14639.                 mouse: !0,
  14640.                 waxing_crescent_moon: !0,
  14641.                 blue_car: !0,
  14642.                 a: !0,
  14643.                 interrobang: !0,
  14644.                 u5272: !0,
  14645.                 electric_plug: !0,
  14646.                 first_quarter_moon: !0,
  14647.                 cancer: !0,
  14648.                 trident: !0,
  14649.                 bread: !0,
  14650.                 cop: !0,
  14651.                 tea: !0,
  14652.                 fishing_pole_and_fish: !0,
  14653.                 bike: !0,
  14654.                 rice: !0,
  14655.                 radio: !0,
  14656.                 baby_chick: !0,
  14657.                 arrow_heading_down: !0,
  14658.                 waning_crescent_moon: !0,
  14659.                 arrow_up_down: !0,
  14660.                 last_quarter_moon: !0,
  14661.                 radio_button: !0,
  14662.                 sheep: !0,
  14663.                 person_with_blond_hair: !0,
  14664.                 waning_gibbous_moon: !0,
  14665.                 lock: !0,
  14666.                 green_apple: !0,
  14667.                 japanese_goblin: !0,
  14668.                 curly_loop: !0,
  14669.                 triangular_flag_on_post: !0,
  14670.                 arrows_counterclockwise: !0,
  14671.                 racehorse: !0,
  14672.                 fried_shrimp: !0,
  14673.                 sunrise_over_mountains: !0,
  14674.                 volcano: !0,
  14675.                 rooster: !0,
  14676.                 inbox_tray: !0,
  14677.                 wedding: !0,
  14678.                 sushi: !0,
  14679.                 wavy_dash: !0,
  14680.                 ice_cream: !0,
  14681.                 rewind: !0,
  14682.                 tomato: !0,
  14683.                 rabbit2: !0,
  14684.                 eight_pointed_black_star: !0,
  14685.                 small_red_triangle: !0,
  14686.                 high_brightness: !0,
  14687.                 heavy_plus_sign: !0,
  14688.                 man_with_gua_pi_mao: !0,
  14689.                 convenience_store: !0,
  14690.                 busts_in_silhouette: !0,
  14691.                 beetle: !0,
  14692.                 small_red_triangle_down: !0,
  14693.                 arrow_heading_up: !0,
  14694.                 name_badge: !0,
  14695.                 bath: !0,
  14696.                 no_entry: !0,
  14697.                 crocodile: !0,
  14698.                 dog2: !0,
  14699.                 cat2: !0,
  14700.                 hammer: !0,
  14701.                 meat_on_bone: !0,
  14702.                 shell: !0,
  14703.                 sparkle: !0,
  14704.                 b: !0,
  14705.                 m: !0,
  14706.                 poodle: !0,
  14707.                 aquarius: !0,
  14708.                 stew: !0,
  14709.                 jeans: !0,
  14710.                 honey_pot: !0,
  14711.                 musical_keyboard: !0,
  14712.                 unlock: !0,
  14713.                 black_nib: !0,
  14714.                 statue_of_liberty: !0,
  14715.                 heavy_dollar_sign: !0,
  14716.                 snowboarder: !0,
  14717.                 white_flower: !0,
  14718.                 necktie: !0,
  14719.                 diamond_shape_with_a_dot_inside: !0,
  14720.                 aries: !0,
  14721.                 womens: !0,
  14722.                 ant: !0,
  14723.                 scorpius: !0,
  14724.                 city_sunset: !0,
  14725.                 hourglass_flowing_sand: !0,
  14726.                 o2: !0,
  14727.                 dragon_face: !0,
  14728.                 snail: !0,
  14729.                 dvd: !0,
  14730.                 shirt: !0,
  14731.                 game_die: !0,
  14732.                 heavy_minus_sign: !0,
  14733.                 dolls: !0,
  14734.                 sagittarius: !0,
  14735.                 "8ball": !0,
  14736.                 bus: !0,
  14737.                 custard: !0,
  14738.                 crossed_flags: !0,
  14739.                 part_alternation_mark: !0,
  14740.                 camel: !0,
  14741.                 curry: !0,
  14742.                 steam_locomotive: !0,
  14743.                 hospital: !0,
  14744.                 large_blue_diamond: !0,
  14745.                 tanabata_tree: !0,
  14746.                 bell: !0,
  14747.                 leo: !0,
  14748.                 gemini: !0,
  14749.                 pear: !0,
  14750.                 large_orange_diamond: !0,
  14751.                 taurus: !0,
  14752.                 globe_with_meridians: !0,
  14753.                 door: !0,
  14754.                 clock6: !0,
  14755.                 oncoming_police_car: !0,
  14756.                 envelope_with_arrow: !0,
  14757.                 closed_umbrella: !0,
  14758.                 saxophone: !0,
  14759.                 church: !0,
  14760.                 bicyclist: !0,
  14761.                 pisces: !0,
  14762.                 dango: !0,
  14763.                 capricorn: !0,
  14764.                 office: !0,
  14765.                 rowboat: !0,
  14766.                 womans_hat: !0,
  14767.                 mans_shoe: !0,
  14768.                 love_hotel: !0,
  14769.                 mount_fuji: !0,
  14770.                 dromedary_camel: !0,
  14771.                 handbag: !0,
  14772.                 hourglass: !0,
  14773.                 negative_squared_cross_mark: !0,
  14774.                 trumpet: !0,
  14775.                 school: !0,
  14776.                 cow2: !0,
  14777.                 construction_worker: !0,
  14778.                 toilet: !0,
  14779.                 pig2: !0,
  14780.                 grey_question: !0,
  14781.                 beginner: !0,
  14782.                 violin: !0,
  14783.                 on: !0,
  14784.                 credit_card: !0,
  14785.                 id: !0,
  14786.                 secret: !0,
  14787.                 ferris_wheel: !0,
  14788.                 bowling: !0,
  14789.                 libra: !0,
  14790.                 virgo: !0,
  14791.                 barber: !0,
  14792.                 purse: !0,
  14793.                 roller_coaster: !0,
  14794.                 rat: !0,
  14795.                 date: !0,
  14796.                 rugby_football: !0,
  14797.                 ram: !0,
  14798.                 arrow_up_small: !0,
  14799.                 black_square_button: !0,
  14800.                 mobile_phone_off: !0,
  14801.                 tokyo_tower: !0,
  14802.                 congratulations: !0,
  14803.                 kimono: !0,
  14804.                 ship: !0,
  14805.                 mag_right: !0,
  14806.                 mag: !0,
  14807.                 fire_engine: !0,
  14808.                 clock1130: !0,
  14809.                 police_car: !0,
  14810.                 black_joker: !0,
  14811.                 bridge_at_night: !0,
  14812.                 package: !0,
  14813.                 oncoming_taxi: !0,
  14814.                 calendar: !0,
  14815.                 horse_racing: !0,
  14816.                 tiger2: !0,
  14817.                 boot: !0,
  14818.                 ambulance: !0,
  14819.                 white_square_button: !0,
  14820.                 boar: !0,
  14821.                 school_satchel: !0,
  14822.                 loop: !0,
  14823.                 pound: !0,
  14824.                 information_source: !0,
  14825.                 ox: !0,
  14826.                 rice_ball: !0,
  14827.                 vs: !0,
  14828.                 end: !0,
  14829.                 parking: !0,
  14830.                 sandal: !0,
  14831.                 tent: !0,
  14832.                 seat: !0,
  14833.                 taxi: !0,
  14834.                 black_medium_small_square: !0,
  14835.                 briefcase: !0,
  14836.                 newspaper: !0,
  14837.                 circus_tent: !0,
  14838.                 six_pointed_star: !0,
  14839.                 mens: !0,
  14840.                 european_castle: !0,
  14841.                 flashlight: !0,
  14842.                 foggy: !0,
  14843.                 arrow_double_up: !0,
  14844.                 bamboo: !0,
  14845.                 ticket: !0,
  14846.                 helicopter: !0,
  14847.                 minidisc: !0,
  14848.                 oncoming_bus: !0,
  14849.                 melon: !0,
  14850.                 white_small_square: !0,
  14851.                 european_post_office: !0,
  14852.                 keycap_ten: !0,
  14853.                 notebook: !0,
  14854.                 no_bell: !0,
  14855.                 oden: !0,
  14856.                 flags: !0,
  14857.                 carousel_horse: !0,
  14858.                 blowfish: !0,
  14859.                 chart_with_upwards_trend: !0,
  14860.                 sweet_potato: !0,
  14861.                 ski: !0,
  14862.                 clock12: !0,
  14863.                 signal_strength: !0,
  14864.                 construction: !0,
  14865.                 black_medium_square: !0,
  14866.                 satellite: !0,
  14867.                 euro: !0,
  14868.                 womans_clothes: !0,
  14869.                 ledger: !0,
  14870.                 leopard: !0,
  14871.                 low_brightness: !0,
  14872.                 clock3: !0,
  14873.                 department_store: !0,
  14874.                 truck: !0,
  14875.                 sake: !0,
  14876.                 railway_car: !0,
  14877.                 speedboat: !0,
  14878.                 vhs: !0,
  14879.                 clock1: !0,
  14880.                 arrow_double_down: !0,
  14881.                 water_buffalo: !0,
  14882.                 arrow_down_small: !0,
  14883.                 yen: !0,
  14884.                 mute: !0,
  14885.                 running_shirt_with_sash: !0,
  14886.                 white_large_square: !0,
  14887.                 wheelchair: !0,
  14888.                 clock2: !0,
  14889.                 paperclip: !0,
  14890.                 atm: !0,
  14891.                 cinema: !0,
  14892.                 telescope: !0,
  14893.                 rice_scene: !0,
  14894.                 blue_book: !0,
  14895.                 white_medium_square: !0,
  14896.                 postbox: !0,
  14897.                 "e-mail": !0,
  14898.                 mouse2: !0,
  14899.                 bullettrain_side: !0,
  14900.                 ideograph_advantage: !0,
  14901.                 nut_and_bolt: !0,
  14902.                 ng: !0,
  14903.                 hotel: !0,
  14904.                 wc: !0,
  14905.                 izakaya_lantern: !0,
  14906.                 repeat_one: !0,
  14907.                 mailbox_with_mail: !0,
  14908.                 chart_with_downwards_trend: !0,
  14909.                 green_book: !0,
  14910.                 tractor: !0,
  14911.                 fountain: !0,
  14912.                 metro: !0,
  14913.                 clipboard: !0,
  14914.                 no_mobile_phones: !0,
  14915.                 clock4: !0,
  14916.                 no_smoking: !0,
  14917.                 black_large_square: !0,
  14918.                 slot_machine: !0,
  14919.                 clock5: !0,
  14920.                 bathtub: !0,
  14921.                 scroll: !0,
  14922.                 station: !0,
  14923.                 rice_cracker: !0,
  14924.                 bank: !0,
  14925.                 wrench: !0,
  14926.                 u6307: !0,
  14927.                 articulated_lorry: !0,
  14928.                 page_facing_up: !0,
  14929.                 ophiuchus: !0,
  14930.                 bar_chart: !0,
  14931.                 no_pedestrians: !0,
  14932.                 vibration_mode: !0,
  14933.                 clock10: !0,
  14934.                 clock9: !0,
  14935.                 bullettrain_front: !0,
  14936.                 minibus: !0,
  14937.                 tram: !0,
  14938.                 clock8: !0,
  14939.                 u7a7a: !0,
  14940.                 traffic_light: !0,
  14941.                 mountain_bicyclist: !0,
  14942.                 microscope: !0,
  14943.                 japanese_castle: !0,
  14944.                 bookmark: !0,
  14945.                 bookmark_tabs: !0,
  14946.                 pouch: !0,
  14947.                 ab: !0,
  14948.                 page_with_curl: !0,
  14949.                 flower_playing_cards: !0,
  14950.                 clock11: !0,
  14951.                 fax: !0,
  14952.                 clock7: !0,
  14953.                 white_medium_small_square: !0,
  14954.                 currency_exchange: !0,
  14955.                 sound: !0,
  14956.                 chart: !0,
  14957.                 cl: !0,
  14958.                 floppy_disk: !0,
  14959.                 post_office: !0,
  14960.                 speaker: !0,
  14961.                 japan: !0,
  14962.                 u55b6: !0,
  14963.                 mahjong: !0,
  14964.                 incoming_envelope: !0,
  14965.                 orange_book: !0,
  14966.                 restroom: !0,
  14967.                 u7121: !0,
  14968.                 u6709: !0,
  14969.                 triangular_ruler: !0,
  14970.                 train: !0,
  14971.                 u7533: !0,
  14972.                 trolleybus: !0,
  14973.                 u6708: !0,
  14974.                 notebook_with_decorative_cover: !0,
  14975.                 u7981: !0,
  14976.                 u6e80: !0,
  14977.                 postal_horn: !0,
  14978.                 factory: !0,
  14979.                 children_crossing: !0,
  14980.                 train2: !0,
  14981.                 straight_ruler: !0,
  14982.                 pager: !0,
  14983.                 accept: !0,
  14984.                 u5408: !0,
  14985.                 lock_with_ink_pen: !0,
  14986.                 clock130: !0,
  14987.                 sa: !0,
  14988.                 outbox_tray: !0,
  14989.                 twisted_rightwards_arrows: !0,
  14990.                 mailbox: !0,
  14991.                 light_rail: !0,
  14992.                 clock930: !0,
  14993.                 busstop: !0,
  14994.                 open_file_folder: !0,
  14995.                 file_folder: !0,
  14996.                 potable_water: !0,
  14997.                 card_index: !0,
  14998.                 clock230: !0,
  14999.                 monorail: !0,
  15000.                 clock1230: !0,
  15001.                 clock1030: !0,
  15002.                 abc: !0,
  15003.                 mailbox_closed: !0,
  15004.                 clock430: !0,
  15005.                 mountain_railway: !0,
  15006.                 do_not_litter: !0,
  15007.                 clock330: !0,
  15008.                 heavy_division_sign: !0,
  15009.                 clock730: !0,
  15010.                 clock530: !0,
  15011.                 capital_abcd: !0,
  15012.                 mailbox_with_no_mail: !0,
  15013.                 symbols: !0,
  15014.                 aerial_tramway: !0,
  15015.                 clock830: !0,
  15016.                 clock630: !0,
  15017.                 abcd: !0,
  15018.                 mountain_cableway: !0,
  15019.                 koko: !0,
  15020.                 passport_control: !0,
  15021.                 "non-potable_water": !0,
  15022.                 suspension_railway: !0,
  15023.                 baggage_claim: !0,
  15024.                 no_bicycles: !0,
  15025.                 skull_crossbones: !0,
  15026.                 hugging: !0,
  15027.                 thinking: !0,
  15028.                 nerd: !0,
  15029.                 zipper_mouth: !0,
  15030.                 rolling_eyes: !0,
  15031.                 upside_down: !0,
  15032.                 slight_smile: !0,
  15033.                 middle_finger: !0,
  15034.                 writing_hand: !0,
  15035.                 dark_sunglasses: !0,
  15036.                 eye: !0,
  15037.                 man_in_suit: !0,
  15038.                 golfer: !0,
  15039.                 heart_exclamation: !0,
  15040.                 star_of_david: !0,
  15041.                 cross: !0,
  15042.                 "fleur-de-lis": !0,
  15043.                 atom: !0,
  15044.                 wheel_of_dharma: !0,
  15045.                 yin_yang: !0,
  15046.                 peace: !0,
  15047.                 star_and_crescent: !0,
  15048.                 orthodox_cross: !0,
  15049.                 biohazard: !0,
  15050.                 radioactive: !0,
  15051.                 place_of_worship: !0,
  15052.                 anger_right: !0,
  15053.                 menorah: !0,
  15054.                 om_symbol: !0,
  15055.                 coffin: !0,
  15056.                 gear: !0,
  15057.                 alembic: !0,
  15058.                 scales: !0,
  15059.                 crossed_swords: !0,
  15060.                 keyboard: !0,
  15061.                 shield: !0,
  15062.                 bed: !0,
  15063.                 shopping_bags: !0,
  15064.                 sleeping_accommodation: !0,
  15065.                 ballot_box: !0,
  15066.                 compression: !0,
  15067.                 wastebasket: !0,
  15068.                 file_cabinet: !0,
  15069.                 trackball: !0,
  15070.                 printer: !0,
  15071.                 joystick: !0,
  15072.                 hole: !0,
  15073.                 candle: !0,
  15074.                 prayer_beads: !0,
  15075.                 camera_with_flash: !0,
  15076.                 amphora: !0,
  15077.                 label: !0,
  15078.                 flag_black: !0,
  15079.                 flag_white: !0,
  15080.                 film_frames: !0,
  15081.                 control_knobs: !0,
  15082.                 level_slider: !0,
  15083.                 thermometer: !0,
  15084.                 airplane_arriving: !0,
  15085.                 airplane_departure: !0,
  15086.                 railway_track: !0,
  15087.                 motorway: !0,
  15088.                 synagogue: !0,
  15089.                 mosque: !0,
  15090.                 kaaba: !0,
  15091.                 stadium: !0,
  15092.                 desert: !0,
  15093.                 classical_building: !0,
  15094.                 cityscape: !0,
  15095.                 camping: !0,
  15096.                 bow_and_arrow: !0,
  15097.                 rosette: !0,
  15098.                 volleyball: !0,
  15099.                 medal: !0,
  15100.                 reminder_ribbon: !0,
  15101.                 popcorn: !0,
  15102.                 champagne: !0,
  15103.                 hot_pepper: !0,
  15104.                 burrito: !0,
  15105.                 taco: !0,
  15106.                 hotdog: !0,
  15107.                 shamrock: !0,
  15108.                 comet: !0,
  15109.                 turkey: !0,
  15110.                 scorpion: !0,
  15111.                 lion_face: !0,
  15112.                 crab: !0,
  15113.                 spider_web: !0,
  15114.                 spider: !0,
  15115.                 chipmunk: !0,
  15116.                 wind_blowing_face: !0,
  15117.                 fog: !0,
  15118.                 play_pause: !0,
  15119.                 track_previous: !0,
  15120.                 track_next: !0,
  15121.                 beach_umbrella: !0,
  15122.                 chains: !0,
  15123.                 pick: !0,
  15124.                 stopwatch: !0,
  15125.                 ferry: !0,
  15126.                 mountain: !0,
  15127.                 shinto_shrine: !0,
  15128.                 ice_skate: !0,
  15129.                 skier: !0,
  15130.                 flag_ac: !0,
  15131.                 flag_ad: !0,
  15132.                 flag_ae: !0,
  15133.                 flag_af: !0,
  15134.                 flag_ag: !0,
  15135.                 flag_ai: !0,
  15136.                 flag_al: !0,
  15137.                 flag_am: !0,
  15138.                 "flag-ao": !0,
  15139.                 "flag-aq": !0,
  15140.                 "flag-ar": !0,
  15141.                 "flag-as": !0,
  15142.                 "flag-at": !0,
  15143.                 "flag-au": !0,
  15144.                 "flag-aw": !0,
  15145.                 "flag-ax": !0,
  15146.                 "flag-az": !0,
  15147.                 "flag-ba": !0,
  15148.                 "flag-bb": !0,
  15149.                 "flag-bd": !0,
  15150.                 "flag-be": !0,
  15151.                 "flag-bf": !0,
  15152.                 "flag-bg": !0,
  15153.                 "flag-bh": !0,
  15154.                 "flag-bi": !0,
  15155.                 "flag-bj": !0,
  15156.                 "flag-bl": !0,
  15157.                 "flag-bm": !0,
  15158.                 "flag-bn": !0,
  15159.                 "flag-bo": !0,
  15160.                 "flag-bq": !0,
  15161.                 "flag-br": !0,
  15162.                 "flag-bs": !0,
  15163.                 "flag-bt": !0,
  15164.                 "flag-bv": !0,
  15165.                 "flag-bw": !0,
  15166.                 "flag-by": !0,
  15167.                 "flag-bz": !0,
  15168.                 "flag-ca": !0,
  15169.                 "flag-cc": !0,
  15170.                 "flag-cd": !0,
  15171.                 "flag-cf": !0,
  15172.                 "flag-cg": !0,
  15173.                 "flag-ch": !0,
  15174.                 "flag-ci": !0,
  15175.                 "flag-ck": !0,
  15176.                 "flag-cl": !0,
  15177.                 "flag-cm": !0,
  15178.                 "flag-cn": !0,
  15179.                 "flag-co": !0,
  15180.                 "flag-cp": !0,
  15181.                 "flag-cr": !0,
  15182.                 "flag-cu": !0,
  15183.                 "flag-cv": !0,
  15184.                 "flag-cw": !0,
  15185.                 "flag-cx": !0,
  15186.                 "flag-cy": !0,
  15187.                 "flag-cz": !0,
  15188.                 "flag-de": !0,
  15189.                 "flag-dg": !0,
  15190.                 "flag-dj": !0,
  15191.                 "flag-dk": !0,
  15192.                 "flag-dm": !0,
  15193.                 "flag-do": !0,
  15194.                 "flag-dz": !0,
  15195.                 "flag-ea": !0,
  15196.                 "flag-ec": !0,
  15197.                 "flag-ee": !0,
  15198.                 "flag-eg": !0,
  15199.                 "flag-eh": !0,
  15200.                 "flag-er": !0,
  15201.                 "flag-es": !0,
  15202.                 "flag-et": !0,
  15203.                 "flag-eu": !0,
  15204.                 "flag-fi": !0,
  15205.                 "flag-fj": !0,
  15206.                 "flag-fk": !0,
  15207.                 "flag-fm": !0,
  15208.                 "flag-fo": !0,
  15209.                 "flag-fr": !0,
  15210.                 "flag-ga": !0,
  15211.                 "flag-gb": !0,
  15212.                 "flag-gd": !0,
  15213.                 "flag-ge": !0,
  15214.                 "flag-gf": !0,
  15215.                 "flag-gg": !0,
  15216.                 "flag-gh": !0,
  15217.                 "flag-gi": !0,
  15218.                 "flag-gl": !0,
  15219.                 "flag-gm": !0,
  15220.                 "flag-gn": !0,
  15221.                 "flag-gp": !0,
  15222.                 "flag-gq": !0,
  15223.                 "flag-gr": !0,
  15224.                 "flag-gs": !0,
  15225.                 "flag-gt": !0,
  15226.                 "flag-gu": !0,
  15227.                 "flag-gw": !0,
  15228.                 "flag-gy": !0,
  15229.                 "flag-hk": !0,
  15230.                 "flag-hm": !0,
  15231.                 "flag-hn": !0,
  15232.                 "flag-hr": !0,
  15233.                 "flag-ht": !0,
  15234.                 "flag-hu": !0,
  15235.                 "flag-ic": !0,
  15236.                 "flag-id": !0,
  15237.                 "flag-ie": !0,
  15238.                 "flag-il": !0,
  15239.                 "flag-im": !0,
  15240.                 "flag-in": !0,
  15241.                 "flag-io": !0,
  15242.                 "flag-iq": !0,
  15243.                 "flag-ir": !0,
  15244.                 "flag-is": !0,
  15245.                 "flag-it": !0,
  15246.                 "flag-je": !0,
  15247.                 "flag-jm": !0,
  15248.                 "flag-jo": !0,
  15249.                 "flag-jp": !0,
  15250.                 "flag-ke": !0,
  15251.                 "flag-kg": !0,
  15252.                 "flag-kh": !0,
  15253.                 "flag-ki": !0,
  15254.                 "flag-km": !0,
  15255.                 "flag-kn": !0,
  15256.                 "flag-kp": !0,
  15257.                 "flag-kr": !0,
  15258.                 "flag-kw": !0,
  15259.                 "flag-ky": !0,
  15260.                 "flag-kz": !0,
  15261.                 "flag-la": !0,
  15262.                 "flag-lb": !0,
  15263.                 "flag-lc": !0,
  15264.                 "flag-li": !0,
  15265.                 "flag-lk": !0,
  15266.                 "flag-lr": !0,
  15267.                 "flag-ls": !0,
  15268.                 "flag-lt": !0,
  15269.                 "flag-lu": !0,
  15270.                 "flag-lv": !0,
  15271.                 "flag-ly": !0,
  15272.                 "flag-ma": !0,
  15273.                 "flag-mc": !0,
  15274.                 "flag-md": !0,
  15275.                 "flag-me": !0,
  15276.                 "flag-mf": !0,
  15277.                 "flag-mg": !0,
  15278.                 "flag-mh": !0,
  15279.                 "flag-mk": !0,
  15280.                 "flag-ml": !0,
  15281.                 "flag-mm": !0,
  15282.                 "flag-mn": !0,
  15283.                 "flag-mo": !0,
  15284.                 "flag-mp": !0,
  15285.                 "flag-mq": !0,
  15286.                 "flag-mr": !0,
  15287.                 "flag-ms": !0,
  15288.                 "flag-mt": !0,
  15289.                 "flag-mu": !0,
  15290.                 "flag-mv": !0,
  15291.                 "flag-mw": !0,
  15292.                 "flag-mx": !0,
  15293.                 "flag-my": !0,
  15294.                 "flag-mz": !0,
  15295.                 "flag-na": !0,
  15296.                 "flag-nc": !0,
  15297.                 "flag-ne": !0,
  15298.                 "flag-nf": !0,
  15299.                 "flag-ng": !0,
  15300.                 "flag-ni": !0,
  15301.                 "flag-nl": !0,
  15302.                 "flag-no": !0,
  15303.                 "flag-np": !0,
  15304.                 "flag-nr": !0,
  15305.                 "flag-nu": !0,
  15306.                 "flag-nz": !0,
  15307.                 "flag-om": !0,
  15308.                 "flag-pa": !0,
  15309.                 "flag-pe": !0,
  15310.                 "flag-pf": !0,
  15311.                 "flag-pg": !0,
  15312.                 "flag-ph": !0,
  15313.                 "flag-pk": !0,
  15314.                 "flag-pl": !0,
  15315.                 "flag-pm": !0,
  15316.                 "flag-pn": !0,
  15317.                 "flag-pr": !0,
  15318.                 "flag-ps": !0,
  15319.                 "flag-pt": !0,
  15320.                 "flag-pw": !0,
  15321.                 "flag-py": !0,
  15322.                 "flag-qa": !0,
  15323.                 "flag-re": !0,
  15324.                 "flag-ro": !0,
  15325.                 "flag-rs": !0,
  15326.                 "flag-ru": !0,
  15327.                 "flag-rw": !0,
  15328.                 "flag-sa": !0,
  15329.                 "flag-sb": !0,
  15330.                 "flag-sc": !0,
  15331.                 "flag-sd": !0,
  15332.                 "flag-se": !0,
  15333.                 "flag-sg": !0,
  15334.                 "flag-sh": !0,
  15335.                 "flag-si": !0,
  15336.                 "flag-sj": !0,
  15337.                 "flag-sk": !0,
  15338.                 "flag-sl": !0,
  15339.                 "flag-sm": !0,
  15340.                 "flag-sn": !0,
  15341.                 "flag-so": !0,
  15342.                 "flag-sr": !0,
  15343.                 "flag-ss": !0,
  15344.                 "flag-st": !0,
  15345.                 "flag-sv": !0,
  15346.                 "flag-sx": !0,
  15347.                 "flag-sy": !0,
  15348.                 "flag-sz": !0,
  15349.                 "flag-ta": !0,
  15350.                 "flag-tc": !0,
  15351.                 "flag-td": !0,
  15352.                 "flag-tf": !0,
  15353.                 "flag-tg": !0,
  15354.                 "flag-th": !0,
  15355.                 "flag-tj": !0,
  15356.                 "flag-tk": !0,
  15357.                 "flag-tl": !0,
  15358.                 "flag-tm": !0,
  15359.                 "flag-tn": !0,
  15360.                 "flag-to": !0,
  15361.                 "flag-tr": !0,
  15362.                 "flag-tt": !0,
  15363.                 "flag-tv": !0,
  15364.                 "flag-tw": !0,
  15365.                 "flag-tz": !0,
  15366.                 "flag-ua": !0,
  15367.                 "flag-ug": !0,
  15368.                 "flag-um": !0,
  15369.                 "flag-us": !0,
  15370.                 "flag-uy": !0,
  15371.                 "flag-uz": !0,
  15372.                 "flag-va": !0,
  15373.                 "flag-vc": !0,
  15374.                 "flag-ve": !0,
  15375.                 "flag-vg": !0,
  15376.                 "flag-vi": !0,
  15377.                 "flag-vn": !0,
  15378.                 flag_vu: !0,
  15379.                 flag_wf: !0,
  15380.                 flag_ws: !0,
  15381.                 flag_xk: !0,
  15382.                 flag_ye: !0,
  15383.                 flag_yt: !0,
  15384.                 flag_za: !0,
  15385.                 flag_zm: !0,
  15386.                 flag_zw: !0,
  15387.                 black_heart: !0,
  15388.                 speech_left: !0,
  15389.                 egg: !0,
  15390.                 octagonal_sign: !0,
  15391.                 spades: !0,
  15392.                 hearts: !0,
  15393.                 diamonds: !0,
  15394.                 clubs: !0,
  15395.                 drum: !0,
  15396.                 left_right_arrow: !0,
  15397.                 tm: !0,
  15398.                 "100": !0
  15399.         };
  15400.  
  15401.         addConfigOptions(
  15402.                 "interface", {
  15403.                         _name: "Interface",
  15404.                         emoji: {
  15405.                                 name: "Add Emoji Replacement to Chat",
  15406.                                 default: true,
  15407.                                 _type: "boolean",
  15408.                                 _player: true
  15409.                         }
  15410.                 }
  15411.         );
  15412.  
  15413.         d20plus.chat.enhanceChat = () => {
  15414.                 d20plus.ut.log("Enhancing chat");
  15415.                 const tc = d20.textchat.$textarea;
  15416.                 $("#textchat-input").off("click", "button")
  15417.                 $("#textchat-input").on("click", "button", function () {
  15418.                         if (d20plus.cfg.getOrDefault("interface", "emoji")) {
  15419.                                 tc.val(tc.val().replace(/(:\w*?:)/g, (m0, m1) => {
  15420.                                         const clean = m1.replace(/:/g, "");
  15421.                                         return d20plus.chat.emojiIndex && d20plus.chat.emojiIndex[clean] ? `[${clean}](https://github.com/TheGiddyLimit/emoji-dump/raw/master/out/${clean}.png)` : m1;
  15422.                                 }));
  15423.                         }
  15424.  
  15425.                         // add custom commands
  15426.                         tc.val(tc.val().replace(/^\/ttms( |$)/, "/talktomyself$1"));
  15427.  
  15428.                         const toSend = $.trim(tc.val());
  15429.                         d20.textchat.doChatInput(toSend);
  15430.                         tc.val("").focus();
  15431.                 });
  15432.         };
  15433. };
  15434.  
  15435. SCRIPT_EXTENSIONS.push(betteR20Emoji);
  15436.  
  15437.  
  15438. function remoteLibre () {
  15439.         d20plus.remoteLibre = {
  15440.                 getRemotePlaylists () {
  15441.                         return fetch('https://api.github.com/repos/DMsGuild201/Roll20_resources/contents/playlist')
  15442.                                 .then(response => response.json())
  15443.                                 .then(data => {
  15444.                                         const promises = data.filter(file => file.download_url.toLowerCase().endsWith(".json"))
  15445.                                                 .map(file => d20plus.remoteLibre.downloadPlaylist(file.download_url));
  15446.                                         return Promise.all(promises).then(res => d20plus.remoteLibre.processRemotePlaylists(res));
  15447.                                 })
  15448.                                 .catch(error => console.error(error));
  15449.                 },
  15450.  
  15451.                 downloadPlaylist (url) {
  15452.                         return fetch(url)
  15453.                                 .then(response => response.json())
  15454.                                 .catch(error => console.error("Error when fetching", url, error));
  15455.                 },
  15456.  
  15457.                 processRemotePlaylists (imports) {
  15458.                         return $.map(imports.filter(p => !!p), entry => {
  15459.                                 return $.map(entry.playlists, playlist => playlist.songs);
  15460.                         }).filter(track => track.source === "My Audio");
  15461.                 },
  15462.  
  15463.                 drawRemoteLibreResults (tracks) {
  15464.                         return tracks.map((t, i) => `
  15465.                 <p style="margin-top:15px">${t.title}</p>
  15466.                 <div class="br20-result" style="display: flex">
  15467.                     <audio class="audio" controls preload="none" style="flex: 35" src="${t.track_id}"></audio>
  15468.                    
  15469.                     <button class="remote-track btn" data-id=${i} style="margin-top:auto;margin-bottom:auto;flex:1;font-size:15px;margin-left:10px;">
  15470.                         <span class="pictos">&amp;</span>
  15471.                     </button>
  15472.                 </div>
  15473.             `);
  15474.                 },
  15475.  
  15476.                 drawJukeBoxTab (tracks) {
  15477.                         const trackHtml = d20plus.remoteLibre.drawRemoteLibreResults(tracks);
  15478.                         return `
  15479.             <div class="betteR20 tab-pane" style="display: none;">
  15480.                 <div class="row-fluid">
  15481.                     <div class="span12">
  15482.                         <h3 style="margin-top: 6px; margin-bottom: 10px; text-align:initial;">Search for:</h3>
  15483.                         <input id="remoteLibreSearch" type="text" placeholder="Begin typing to choose tracks by title..." style="width: 100%;">
  15484.                         <div id="remoteLibreResults">
  15485.                             ${trackHtml.join("")}
  15486.                         </div>
  15487.                     </div>
  15488.                 </div>
  15489.             </div>`;
  15490.                 },
  15491.  
  15492.                 wireTrackButtons () {
  15493.                         $(".remote-track.btn").click((e) => {
  15494.                                 const id = $(e.currentTarget).data().id;
  15495.                                 d20plus.jukebox.createTrack(d20plus.remoteLibre.filteredResults[id]);
  15496.                         });
  15497.                 },
  15498.  
  15499.                 init () {
  15500.                         d20plus.remoteLibre.jukeboxInjected = false;
  15501.                         d20plus.remoteLibre.remoteLibreTracks = {};
  15502.                         d20plus.remoteLibre.filteredResults = {};
  15503.  
  15504.                         d20plus.remoteLibre.getRemotePlaylists().then((tracks) => {
  15505.                                 d20plus.remoteLibre.remoteLibreTracks = tracks;
  15506.                                 d20plus.remoteLibre.filteredResults = tracks;
  15507.                         });
  15508.  
  15509.                         $("#addjukebox").click(() => {
  15510.                                 if (!d20plus.remoteLibre.jukeboxInjected) {
  15511.                                         setTimeout(() => {
  15512.                                                 const html = d20plus.remoteLibre.drawJukeBoxTab(d20plus.remoteLibre.filteredResults);
  15513.                                                 $(".nav.nav-tabs").append(`<li><a data-tab="betteR20" href="javascript:void(0);">BetteR20</a></li>`);
  15514.                                                 $(".tab-content").append(html);
  15515.                                                 d20plus.remoteLibre.wireTrackButtons();
  15516.                                                 $("#remoteLibreSearch").bind("paste keyup", function () {
  15517.                                                         if ($(this).val()) {
  15518.                                                                 d20plus.remoteLibre.filteredResults = d20plus.remoteLibre.remoteLibreTracks.filter(t => t.title.toLowerCase().indexOf($(this).val()) >= 0);
  15519.                                                         } else {
  15520.                                                                 d20plus.remoteLibre.filteredResults = d20plus.remoteLibre.remoteLibreTracks;
  15521.                                                         }
  15522.                                                         const results = d20plus.remoteLibre.drawRemoteLibreResults(d20plus.remoteLibre.filteredResults);
  15523.                                                         $("#remoteLibreResults").html(results);
  15524.                                                         d20plus.remoteLibre.wireTrackButtons();
  15525.                                                 });
  15526.                                                 // this needs to be moved
  15527.                                                 d20plus.remoteLibre.jukeboxInjected = true;
  15528.                                         }, 100);
  15529.                                 }
  15530.                         });
  15531.                 },
  15532.  
  15533.         };
  15534. }
  15535.  
  15536. SCRIPT_EXTENSIONS.push(remoteLibre);
  15537.  
  15538.  
  15539. function jukeboxWidget () {
  15540.         d20plus.jukeboxWidget = {
  15541.                 getPlaylistButtonsHtml () {
  15542.                         const buttons = d20plus.jukebox.getJukeboxFileStructure()
  15543.                                 .map((playlist, i) => {
  15544.                                         const hotkey = i + 1 < 10 ? i + 1 : false;
  15545.                                         let baseName, id;
  15546.                                         if (typeof playlist === "object") {
  15547.                                                 baseName = playlist.n;
  15548.                                                 id = playlist.id;
  15549.                                         } else {
  15550.                                                 baseName = d20plus.jukebox.getTrackById(playlist).attributes.title;
  15551.                                                 id = playlist;
  15552.                                         }
  15553.                                         const title = `${hotkey ? `[ALT+${hotkey}] ` : ""}${baseName}`;
  15554.  
  15555.                                         return `
  15556.                                                 <div
  15557.                                                         class="btn btn-xs jukebox-widget-button m-1"
  15558.                                                         title="${title}"
  15559.                                                         data-id=${id}
  15560.                                                 >
  15561.                                                         <span>${hotkey ? `[${i + 1}] ` : ""}${baseName}</span>
  15562.                                                 </div>
  15563.                                         `;
  15564.                                 })
  15565.                                 .filter(p => !!p);
  15566.  
  15567.                         return buttons.join("");
  15568.                 },
  15569.  
  15570.                 init () {
  15571.                         const changeTrackVolume = (trackId, value) => {
  15572.                                 const track = d20plus.jukebox.getTrackById(trackId);
  15573.                                 if (track && value) {
  15574.                                         track.changeVolume(value);
  15575.                                 }
  15576.                         };
  15577.  
  15578.                         $(`<div id="masterVolume" style="margin:10px;display:inline-block;width:80%;"></div>`)
  15579.                                 .insertAfter("#jukeboxwhatsplaying").slider({
  15580.                                 slide: (e, ui) => {
  15581.                                         if ($("#masterVolumeEnabled").prop("checked")) {
  15582.                                                 window.d20.jukebox.lastFolderStructure.forEach(playlist => {
  15583.                                                         // The track is outside a playlist
  15584.                                                         if (!playlist.i) {
  15585.                                                                 changeTrackVolume(playlist, ui.value);
  15586.                                                         } else {
  15587.                                                                 playlist.i.forEach(trackId => changeTrackVolume(trackId, ui.value))
  15588.                                                         }
  15589.                                                 });
  15590.                                         }
  15591.                                         $("#jbwMasterVolume").slider("value", ui.value);
  15592.                                 },
  15593.                                 value: 50,
  15594.                         });
  15595.                         $("<h4>Master Volume</h4>").insertAfter("#jukeboxwhatsplaying").css("margin-left", "10px");
  15596.                         $(`<input type="checkbox" id="masterVolumeEnabled" style="position:relative;top:-11px;" title="Enable this to change the volume of all the tracks at the same time"/>`).insertAfter("#masterVolume").tooltip();
  15597.  
  15598.                         //TODO: Make the slider a separate component at some point
  15599.                         const slider = $(`<div id="jbwMasterVolume" class="jukebox-widget-slider"></div>`)
  15600.                                 .slider({
  15601.                                         slide: (e, ui) => {
  15602.                                                 if ($("#masterVolumeEnabled").prop("checked")) {
  15603.                                                         window.d20.jukebox.lastFolderStructure.forEach(playlist => {
  15604.                                                                 // The track is outside a playlist
  15605.                                                                 if (!playlist.i) {
  15606.                                                                         changeTrackVolume(playlist, ui.value);
  15607.                                                                 } else {
  15608.                                                                         playlist.i.forEach(trackId => changeTrackVolume(trackId, ui.value));
  15609.                                                                 }
  15610.                                                         });
  15611.                                                 }
  15612.                                                 $("#masterVolume").slider("value", ui.value);
  15613.                                         },
  15614.                                         value: 50,
  15615.                                 });
  15616.  
  15617.                         // Stop and skip buttons
  15618.                         const controls = $(`
  15619.                         <div class="flex mb-2">
  15620.                                 <div id="jbwStop" title="ALT+S" class="btn btn-inverse flex-1 mr-2"><span class="pictos">6</span></div>
  15621.                                 <div id="jbwSkip" title="ALT+D" class="btn btn-inverse flex-1 mr-2"><span class="pictos">7</span></div>
  15622.                         </div>
  15623.                         `).append(slider);
  15624.  
  15625.                         // Jukebox widget layout
  15626.                         const dialog = $(`<div id="jukeboxWidget" title="Jukebox Player" style="margin-top:10px"></div>`)
  15627.                                 .dialog({
  15628.                                         autoOpen: false,
  15629.                                         resizable: true,
  15630.                                         width: 350,
  15631.                                 })
  15632.                                 .append("body")
  15633.                                 .css("padding-top", "0")
  15634.                                 .html(`<div id="jbwButtons" style="display:flex;flex-wrap:wrap">${d20plus.jukeboxWidget.getPlaylistButtonsHtml()}</div>`)
  15635.                                 .prepend(controls)
  15636.                                 .prepend(`<div id="widgeNowPlaying"></div>`);
  15637.  
  15638.                         dialog.parent().find(".ui-dialog-title").css("margin", "0").css("padding", "0");
  15639.                         $("#jbwStop").click(d20plus.jukebox.stopAll);
  15640.                         $("#jbwSkip").click(d20plus.jukebox.skip);
  15641.  
  15642.                         // Start listening to jukebox state changes
  15643.                         d20plus.jukebox.addJukeboxChangeHandler(() => {
  15644.                                 $("#jbwButtons").html(d20plus.jukeboxWidget.getPlaylistButtonsHtml());
  15645.                                 $(".jukebox-widget-button")
  15646.                                         .removeClass("active")
  15647.                                         .click((e) => {
  15648.                                                 const id = e.currentTarget.dataset.id;
  15649.                                                 if (d20plus.jukebox.getCurrentPlayingPlaylist() === id || d20plus.jukebox.getCurrentPlayingTracks().find(t => t.id === id)) {
  15650.                                                         d20plus.jukebox.stop(e.currentTarget.dataset.id);
  15651.                                                 } else {
  15652.                                                         d20plus.jukebox.play(e.currentTarget.dataset.id);
  15653.                                                 }
  15654.                                         });
  15655.                                 $(`.jukebox-widget-button[data-id=${d20plus.jukebox.getCurrentPlayingPlaylist()}]`).addClass("active");
  15656.                                 d20plus.jukebox.getCurrentPlayingTracks().forEach(t => {
  15657.                                         $(`.jukebox-widget-button[data-id=${t.id}]`).addClass("active");
  15658.                                 });
  15659.                         });
  15660.  
  15661.                         // Add widget button in the Jukebox tab
  15662.                         $(`<button class="btn" style="margin-right:10px;"><span class="pictos">|</span>Widget</button>`)
  15663.                                 .click(() => {
  15664.                                         dialog.dialog("open");
  15665.                                 })
  15666.                                 .insertAfter("[href=#superjukeboxadd]");
  15667.  
  15668.                         // Add keyboard shortcuts
  15669.                         $(document).keyup((e) => {
  15670.                                 if (e.altKey) {
  15671.                                         if (e.keyCode > 48 && e.keyCode < 58) {
  15672.                                                 const numberKey = e.keyCode - 48;
  15673.                                                 const playElement = d20plus.jukebox.getJukeboxFileStructure()[numberKey - 1];
  15674.                                                 if (typeof playElement === "object") {
  15675.                                                         if (d20plus.jukebox.getCurrentPlayingPlaylist() === playElement.id) {
  15676.                                                                 d20plus.jukebox.stopPlaylist(playElement.id);
  15677.                                                         } else {
  15678.                                                                 d20plus.jukebox.playPlaylist(playElement.id);
  15679.                                                         }
  15680.                                                 } else {
  15681.                                                         if (d20plus.jukebox.getCurrentPlayingTracks().find(t => t.id === playElement)) {
  15682.                                                                 d20plus.jukebox.stopTrack(playElement);
  15683.                                                         } else {
  15684.                                                                 d20plus.jukebox.playTrack(playElement);
  15685.                                                         }
  15686.                                                 }
  15687.                                         } else if (e.keyCode === 83) {
  15688.                                                 d20plus.jukebox.stopAll();
  15689.                                         } else if (e.keyCode === 68) {
  15690.                                                 d20plus.jukebox.skip();
  15691.                                         }
  15692.                                 }
  15693.                         });
  15694.                 }
  15695.         };
  15696. }
  15697.  
  15698. SCRIPT_EXTENSIONS.push(jukeboxWidget);
  15699.  
  15700.  
  15701. const betteR205etools = function () {
  15702.         // Page fully loaded and visible
  15703.         d20plus.Init = async function () {
  15704.                 const scriptName = `betteR20-5etools v${d20plus.version}`;
  15705.                 try {
  15706.                         d20plus.ut.log("Init (v" + d20plus.version + ")");
  15707.                         d20plus.ut.showLoadingMessage(scriptName);
  15708.  
  15709.                         d20plus.ut.checkVersion("5etools");
  15710.                         d20plus.settingsHtmlHeader = `<hr><h3>betteR20-5etools v${d20plus.version}</h3>`;
  15711.  
  15712.                         d20plus.template.swapTemplates();
  15713.  
  15714.                         d20plus.ut.addAllCss();
  15715.                         if (window.is_gm) {
  15716.                                 d20plus.ut.log("Is GM");
  15717.                                 d20plus.engine.enhancePageSelector();
  15718.                         } else d20plus.ut.log("Not GM. Some functionality will be unavailable.");
  15719.  
  15720.                         d20plus.setSheet();
  15721.                         await d20plus.js.pAddScripts();
  15722.                         await d20plus.qpi.pInitMockApi();
  15723.                         await d20plus.js.pAddApiScripts();
  15724.  
  15725.                         JqueryUtil.initEnhancements();
  15726.                         await loadHomebrewMetadata();
  15727.  
  15728.                         await d20plus.pAddJson();
  15729.                         await monkeyPatch5etoolsCode();
  15730.  
  15731.                         if (window.is_gm) await d20plus.cfg.pLoadConfig();
  15732.                         else await d20plus.cfg.pLoadPlayerConfig();
  15733.  
  15734.                         if (window.is_gm) await d20plus.art.pLoadArt();
  15735.  
  15736.                         d20plus.bindDropLocations();
  15737.                         d20plus.ui.addHtmlHeader();
  15738.                         d20plus.addCustomHTML();
  15739.                         d20plus.ui.addHtmlFooter();
  15740.                         d20plus.engine.enhanceMarkdown();
  15741.                         d20plus.engine.addProFeatures();
  15742.                         d20plus.art.initArtFromUrlButtons();
  15743.                         if (window.is_gm) {
  15744.                                 d20plus.journal.addJournalCommands();
  15745.                                 d20plus.engine.addSelectedTokenCommands();
  15746.                                 d20plus.art.addCustomArtSearch();
  15747.                                 d20plus.engine.addTokenHover();
  15748.                                 d20plus.engine.enhanceTransmogrifier();
  15749.                                 d20plus.engine.removeLinkConfirmation();
  15750.                                 d20plus.artBrowse.initRepoBrowser();
  15751.                                 d20plus.ui.addQuickUiGm();
  15752.                                 d20plus.anim.animatorTool.init();
  15753.                                 // Better20 jukebox tab
  15754.                                 d20plus.remoteLibre.init();
  15755.                                 d20plus.jukeboxWidget.init();
  15756.                         }
  15757.                         d20.Campaign.pages.each(d20plus.bindGraphics);
  15758.                         d20.Campaign.activePage().collection.on("add", d20plus.bindGraphics);
  15759.                         d20plus.engine.addSelectedTokenCommands();
  15760.                         d20plus.engine.enhanceStatusEffects();
  15761.                         d20plus.engine.enhanceMeasureTool();
  15762.                         d20plus.engine.enhanceMouseDown();
  15763.                         d20plus.engine.enhanceMouseMove();
  15764.                         d20plus.engine.addLineCutterTool();
  15765.                         d20plus.engine.enhancePathWidths();
  15766.                         d20plus.ut.disable3dDice();
  15767.                         d20plus.engine.addLayers();
  15768.                         d20plus.weather.addWeather();
  15769.                         d20plus.engine.repairPrototypeMethods();
  15770.                         d20plus.engine.disableFrameRecorder();
  15771.                         // d20plus.ut.fixSidebarLayout();
  15772.                         d20plus.chat.enhanceChat();
  15773.  
  15774.                         // apply config
  15775.                         if (window.is_gm) {
  15776.                                 d20plus.cfg.baseHandleConfigChange();
  15777.                                 d20plus.handleConfigChange();
  15778.                         } else {
  15779.                                 d20plus.cfg.startPlayerConfigHandler();
  15780.                         }
  15781.  
  15782.                         d20plus.ut.log("All systems operational");
  15783.                         d20plus.ut.chatTag(`betteR20-5etools v${d20plus.version}`);
  15784.                 } catch (e) {
  15785.                         console.error(e);
  15786.                         alert(`${scriptName} failed to initialise! See the logs (CTRL-SHIFT-J) for more information.`)
  15787.                 }
  15788.         };
  15789.  
  15790.         async function loadHomebrewMetadata () {
  15791.                 d20plus.ut.log("Loading homebrew metadata");
  15792.                 const brewUrl = DataUtil.brew.getDirUrl("creature");
  15793.                 try {
  15794.                         const brewMeta = await DataUtil.loadJSON(brewUrl);
  15795.                         brewMeta.forEach(it => {
  15796.                                 const url = `${it.download_url}${d20plus.ut.getAntiCacheSuffix()}`;
  15797.                                 const name = `Homebrew: ${it.name.trim().replace(/\.json$/i, "")}`;
  15798.                                 monsterBrewDataUrls.push({url, name});
  15799.                         });
  15800.                 } catch (e) {
  15801.                         d20plus.ut.error(`Failed to load bestiary homebrew metadata!`);
  15802.                 }
  15803.  
  15804.                 try {
  15805.                         brewCollectionIndex = await DataUtil.brew.pLoadCollectionIndex();
  15806.                 } catch (e) {
  15807.                         d20plus.ut.error("Failed to pre-load homebrew collection index");
  15808.                 }
  15809.         }
  15810.  
  15811.         async function monkeyPatch5etoolsCode () {
  15812.                 IS_VTT = true; // global variable from 5etools' utils.js
  15813.                 BrewUtil._buildSourceCache = function () {
  15814.                         // no-op when building source cache; we'll handle this elsewhere
  15815.                         BrewUtil._sourceCache = BrewUtil._sourceCache || {};
  15816.                 };
  15817.                 // dummy values
  15818.                 BrewUtil.homebrew = {};
  15819.                 BrewUtil.homebrewMeta = {};
  15820.  
  15821.                 Renderer.get().setBaseUrl(BASE_SITE_URL);
  15822.         }
  15823. };
  15824.  
  15825. SCRIPT_EXTENSIONS.push(betteR205etools);
  15826.  
  15827.  
  15828. const betteR205etoolsMain = function () {
  15829.         const IMG_URL = BASE_SITE_URL + "img/";
  15830.  
  15831.         const SPELL_DATA_DIR = `${DATA_URL}spells/`;
  15832.         const SPELL_META_URL = `${SPELL_DATA_DIR}roll20.json`;
  15833.         const MONSTER_DATA_DIR = `${DATA_URL}bestiary/`;
  15834.         const ADVENTURE_DATA_DIR = `${DATA_URL}adventure/`;
  15835.         const CLASS_DATA_DIR = `${DATA_URL}class/`;
  15836.  
  15837.         const ITEM_DATA_URL = `${DATA_URL}items.json`;
  15838.         const FEAT_DATA_URL = `${DATA_URL}feats.json`;
  15839.         const PSIONIC_DATA_URL = `${DATA_URL}psionics.json`;
  15840.         const OBJECT_DATA_URL = `${DATA_URL}objects.json`;
  15841.         const BACKGROUND_DATA_URL = `${DATA_URL}backgrounds.json`;
  15842.         const OPT_FEATURE_DATA_URL = `${DATA_URL}optionalfeatures.json`;
  15843.         const RACE_DATA_URL = `${DATA_URL}races.json`;
  15844.  
  15845.         // the GitHub API has a 60 requests/hour limit per IP which we quickly hit if the user refreshes their Roll20 a couple of times
  15846.         // embed shitty OAth2 details here to enable 5k/hour requests per IP (sending them with requests to the API relaxes the limit)
  15847.         // naturally these are client-visible and should not be used to secure anything
  15848.         const HOMEBREW_CLIENT_ID = `67e57877469da38a85a7`;
  15849.         const HOMEBREW_CLIENT_SECRET = `c00dede21ca63a855abcd9a113415e840aca3f92`;
  15850.  
  15851.         const REQUIRED_PROPS = {
  15852.                 "monster": [
  15853.                         "ac",
  15854.                         "alignment",
  15855.                         "cha",
  15856.                         "con",
  15857.                         "cr",
  15858.                         "dex",
  15859.                         "hp",
  15860.                         "int",
  15861.                         "name",
  15862.                         "passive",
  15863.                         "size",
  15864.                         "source",
  15865.                         "speed",
  15866.                         "str",
  15867.                         "type",
  15868.                         "wis"
  15869.                 ],
  15870.                 "spell": [
  15871.                         "name",
  15872.                         "level",
  15873.                         "school",
  15874.                         "time",
  15875.                         "range",
  15876.                         "components",
  15877.                         "duration",
  15878.                         "classes",
  15879.                         "entries",
  15880.                         "source"
  15881.                 ],
  15882.                 "item": [
  15883.                         "name",
  15884.                         "rarity",
  15885.                         "source"
  15886.                 ],
  15887.                 "psionic": [
  15888.                         "name",
  15889.                         "source",
  15890.                         "type"
  15891.                 ],
  15892.                 "feat": [
  15893.                         "name",
  15894.                         "source",
  15895.                         "entries"
  15896.                 ],
  15897.                 "object": [
  15898.                         "name",
  15899.                         "source",
  15900.                         "size",
  15901.                         "type",
  15902.                         "ac",
  15903.                         "hp",
  15904.                         "immune",
  15905.                         "entries"
  15906.                 ],
  15907.                 "class": [
  15908.                         "name",
  15909.                         "source",
  15910.                         "hd",
  15911.                         "proficiency",
  15912.                         "classTableGroups",
  15913.                         "startingProficiencies",
  15914.                         "startingEquipment",
  15915.                         "classFeatures",
  15916.                         "subclassTitle",
  15917.                         "subclasses"
  15918.                 ],
  15919.                 "subclass": [
  15920.  
  15921.                 ],
  15922.                 "background": [
  15923.                         "name",
  15924.                         "source",
  15925.                         "skillProficiencies",
  15926.                         "entries"
  15927.                 ],
  15928.                 "race": [
  15929.                         "name",
  15930.                         "source"
  15931.                 ],
  15932.                 "optionalfeature": [
  15933.                         "name",
  15934.                         "source",
  15935.                         "entries"
  15936.                 ]
  15937.         };
  15938.  
  15939.         let spellDataUrls = {};
  15940.         let spellMetaData = {};
  15941.         let monsterDataUrls = {};
  15942.         let monsterFluffDataUrls = {};
  15943.         let monsterFluffData = {};
  15944.         let monsterMetadata = {};
  15945.         let adventureMetadata = {};
  15946.         let itemMetadata = {};
  15947.         let classDataUrls = {};
  15948.         let brewCollectionIndex = {};
  15949.  
  15950.         let monsterBrewDataUrls = [];
  15951.  
  15952. // build a big dictionary of sheet properties to be used as reference throughout // TODO use these as reference throughout
  15953.         function SheetAttribute (name, ogl, shaped) {
  15954.                 this.name = name;
  15955.                 this.ogl = ogl;
  15956.                 this.shaped = shaped;
  15957.         }
  15958.  
  15959.         NPC_SHEET_ATTRIBUTES = {};
  15960. // these (other than the name, which is for display only) are all lowercased; any comparison should be lowercased
  15961.         NPC_SHEET_ATTRIBUTES["empty"] = new SheetAttribute("--Empty--", "", "");
  15962. // TODO: implement custom entry (enable textarea)
  15963. //NPC_SHEET_ATTRIBUTES["custom"] = new SheetAttribute("-Custom-", "-Custom-", "-Custom-");
  15964.         NPC_SHEET_ATTRIBUTES["npc_hpbase"] = new SheetAttribute("HP", "npc_hpbase", "npc_hpbase");
  15965.         NPC_SHEET_ATTRIBUTES["npc_ac"] = new SheetAttribute("AC", "npc_ac", "ac");
  15966.         NPC_SHEET_ATTRIBUTES["passive"] = new SheetAttribute("Passive Perception", "passive", "passive");
  15967.         NPC_SHEET_ATTRIBUTES["npc_hpformula"] = new SheetAttribute("HP Formula", "npc_hpformula", "npc_hpformula");
  15968.         NPC_SHEET_ATTRIBUTES["npc_speed"] = new SheetAttribute("Speed", "npc_speed", "npc_speed");
  15969.         NPC_SHEET_ATTRIBUTES["spell_save_dc"] = new SheetAttribute("Spell Save DC", "spell_save_dc", "spell_save_DC");
  15970.         NPC_SHEET_ATTRIBUTES["npc_legendary_actions"] = new SheetAttribute("Legendary Actions", "npc_legendary_actions", "npc_legendary_actions");
  15971.         NPC_SHEET_ATTRIBUTES["npc_challenge"] = new SheetAttribute("CR", "npc_challenge", "challenge");
  15972.  
  15973.         PC_SHEET_ATTRIBUTES = {};
  15974.         PC_SHEET_ATTRIBUTES["empty"] = new SheetAttribute("--Default--", "", "");
  15975.         PC_SHEET_ATTRIBUTES["hp"] = new SheetAttribute("Current HP", "hp", "HP");
  15976.         PC_SHEET_ATTRIBUTES["ac"] = new SheetAttribute("AC", "ac", "ac"); // TODO check shaped
  15977.         PC_SHEET_ATTRIBUTES["passive_wisdom"] = new SheetAttribute("Passive Perception", "passive_wisdom", "passive_wisdom"); // TODO check shaped
  15978.         PC_SHEET_ATTRIBUTES["speed"] = new SheetAttribute("Speed", "speed", "speed"); // TODO check shaped
  15979.         PC_SHEET_ATTRIBUTES["spell_save_dc"] = new SheetAttribute("Spell Save DC", "spell_save_dc", "spell_save_dc"); // TODO check shaped
  15980.  
  15981.         addConfigOptions("token", {
  15982.                 "_name": "Tokens",
  15983.                 "_player": true,
  15984.                 "bar1": {
  15985.                         "name": "Bar 1 (NPC)",
  15986.                         "default": "npc_hpbase",
  15987.                         "_type": "_SHEET_ATTRIBUTE",
  15988.                         "_player": true
  15989.                 },
  15990.                 "bar1_pc": {
  15991.                         "name": "Bar 1 (PC)",
  15992.                         "default": "",
  15993.                         "_type": "_SHEET_ATTRIBUTE_PC"
  15994.                 },
  15995.                 "bar1_max": {
  15996.                         "name": "Set Bar 1 Max",
  15997.                         "default": true,
  15998.                         "_type": "boolean",
  15999.                         "_player": true
  16000.                 },
  16001.                 "bar1_reveal": {
  16002.                         "name": "Reveal Bar 1",
  16003.                         "default": false,
  16004.                         "_type": "boolean",
  16005.                         "_player": true
  16006.                 },
  16007.                 "bar2": {
  16008.                         "name": "Bar 2 (NPC)",
  16009.                         "default": "npc_ac",
  16010.                         "_type": "_SHEET_ATTRIBUTE",
  16011.                         "_player": true
  16012.                 },
  16013.                 "bar2_pc": {
  16014.                         "name": "Bar 2 (PC)",
  16015.                         "default": "",
  16016.                         "_type": "_SHEET_ATTRIBUTE_PC"
  16017.                 },
  16018.                 "bar2_max": {
  16019.                         "name": "Set Bar 2 Max",
  16020.                         "default": false,
  16021.                         "_type": "boolean",
  16022.                         "_player": true
  16023.                 },
  16024.                 "bar2_reveal": {
  16025.                         "name": "Reveal Bar 2",
  16026.                         "default": false,
  16027.                         "_type": "boolean",
  16028.                         "_player": true
  16029.                 },
  16030.                 "bar3": {
  16031.                         "name": "Bar 3 (NPC)",
  16032.                         "default": "passive",
  16033.                         "_type": "_SHEET_ATTRIBUTE",
  16034.                         "_player": true
  16035.                 },
  16036.                 "bar3_pc": {
  16037.                         "name": "Bar 3 (PC)",
  16038.                         "default": "",
  16039.                         "_type": "_SHEET_ATTRIBUTE_PC"
  16040.                 },
  16041.                 "bar3_max": {
  16042.                         "name": "Set Bar 3 Max",
  16043.                         "default": false,
  16044.                         "_type": "boolean",
  16045.                         "_player": true
  16046.                 },
  16047.                 "bar3_reveal": {
  16048.                         "name": "Reveal Bar 3",
  16049.                         "default": false,
  16050.                         "_type": "boolean",
  16051.                         "_player": true
  16052.                 },
  16053.                 "rollHP": {
  16054.                         "name": "Roll Token HP",
  16055.                         "default": false,
  16056.                         "_type": "boolean"
  16057.                 },
  16058.                 "maximiseHp": {
  16059.                         "name": "Maximise Token HP",
  16060.                         "default": false,
  16061.                         "_type": "boolean"
  16062.                 },
  16063.                 "name": {
  16064.                         "name": "Show Nameplate",
  16065.                         "default": true,
  16066.                         "_type": "boolean",
  16067.                         "_player": true
  16068.                 },
  16069.                 "name_reveal": {
  16070.                         "name": "Reveal Nameplate",
  16071.                         "default": false,
  16072.                         "_type": "boolean",
  16073.                         "_player": true
  16074.                 },
  16075.                 "barLocation": {
  16076.                         "name": "Bar Location",
  16077.                         "default": "above",
  16078.                         "_type": "_enum",
  16079.                         "__values": [
  16080.                                 "Above",
  16081.                                 "Top Overlapping",
  16082.                                 "Bottom Overlapping",
  16083.                                 "Below"
  16084.                         ],
  16085.                         "_player": true
  16086.                 },
  16087.                 "isCompactBars": {
  16088.                         "name": "Compact Bars",
  16089.                         "default": false,
  16090.                         "_type": "boolean",
  16091.                         "_player": true
  16092.                 },
  16093.         });
  16094.         addConfigOptions("import", {
  16095.                 "_name": "Import",
  16096.                 "allSourcesIncludeUnofficial": {
  16097.                         "name": `Include Unofficial (UA/etc) Content in "Import Monsters From All Sources" List`,
  16098.                         "default": false,
  16099.                         "_type": "boolean"
  16100.                 },
  16101.                 "allSourcesIncludeHomebrew": {
  16102.                         "name": `Include Homebrew in "Import Monsters From All Sources" List (Warning: Slow)`,
  16103.                         "default": false,
  16104.                         "_type": "boolean"
  16105.                 },
  16106.                 "importIntervalHandout": {
  16107.                         "name": "Rest Time between Each Handout (msec)",
  16108.                         "default": 100,
  16109.                         "_type": "integer"
  16110.                 },
  16111.                 "importIntervalCharacter": {
  16112.                         "name": "Rest Time between Each Character (msec)",
  16113.                         "default": 2500,
  16114.                         "_type": "integer"
  16115.                 },
  16116.                 "importFluffAs": {
  16117.                         "name": "Import Creature Fluff As...",
  16118.                         "default": "Bio",
  16119.                         "_type": "_enum",
  16120.                         "__values": ["Bio", "GM Notes"]
  16121.                 },
  16122.                 "importCharAvatar": {
  16123.                         "name": "Set Character Avatar As...",
  16124.                         "default": "Portrait (where available)",
  16125.                         "_type": "_enum",
  16126.                         "__values": ["Portrait (where available)", "Token"]
  16127.                 },
  16128.                 "whispermode": {
  16129.                         "name": "Sheet Whisper Mode on Import",
  16130.                         "default": "Toggle (Default GM)",
  16131.                         "_type": "_WHISPERMODE"
  16132.                 },
  16133.                 "advantagemode": {
  16134.                         "name": "Sheet Advantage Mode on Import",
  16135.                         "default": "Toggle (Default Advantage)",
  16136.                         "_type": "_ADVANTAGEMODE"
  16137.                 },
  16138.                 "damagemode": {
  16139.                         "name": "Sheet Auto Roll Damage Mode on Import",
  16140.                         "default": "Auto Roll",
  16141.                         "_type": "_DAMAGEMODE"
  16142.                 },
  16143.                 "hideActionDescs": {
  16144.                         "name": "Hide Action Descriptions on Import",
  16145.                         "default": false,
  16146.                         "_type": "boolean"
  16147.                 },
  16148.                 "skipSenses": {
  16149.                         "name": "Skip Importing Creature Senses",
  16150.                         "default": false,
  16151.                         "_type": "boolean"
  16152.                 },
  16153.                 "showNpcNames": {
  16154.                         "name": "Show NPC Names in Rolls",
  16155.                         "default": true,
  16156.                         "_type": "boolean"
  16157.                 },
  16158.                 "dexTiebreaker": {
  16159.                         "name": "Add DEX Tiebreaker to Initiative",
  16160.                         "default": false,
  16161.                         "_type": "boolean"
  16162.                 },
  16163.                 "tokenactions": {
  16164.                         "name": "Add TokenAction Macros on Import (Actions)",
  16165.                         "default": true,
  16166.                         "_type": "boolean"
  16167.                 },
  16168.                 "tokenactionsTraits": {
  16169.                         "name": "Add TokenAction Macros on Import (Traits)",
  16170.                         "default": true,
  16171.                         "_type": "boolean"
  16172.                 },
  16173.                 "tokenactionsSkills": {
  16174.                         "name": "Add TokenAction Macros on Import (Skills)",
  16175.                         "default": true,
  16176.                         "_type": "boolean"
  16177.                 },
  16178.                 "tokenactionsPerception": {
  16179.                         "name": "Add TokenAction Macros on Import (Perception)",
  16180.                         "default": true,
  16181.                         "_type": "boolean"
  16182.                 },
  16183.                 "tokenactionsSaves": {
  16184.                         "name": "Add TokenAction Macros on Import (Saves)",
  16185.                         "default": true,
  16186.                         "_type": "boolean"
  16187.                 },
  16188.                 "tokenactionsInitiative": {
  16189.                         "name": "Add TokenAction Macros on Import (Initiative)",
  16190.                         "default": true,
  16191.                         "_type": "boolean"
  16192.                 },
  16193.                 "tokenactionsChecks": {
  16194.                         "name": "Add TokenAction Macros on Import (Checks)",
  16195.                         "default": true,
  16196.                         "_type": "boolean"
  16197.                 },
  16198.                 "tokenactionsOther": {
  16199.                         "name": "Add TokenAction Macros on Import (Other)",
  16200.                         "default": true,
  16201.                         "_type": "boolean"
  16202.                 },
  16203.                 "tokenactionsSpells": {
  16204.                         "name": "Add TokenAction Macros on Import (Spells)",
  16205.                         "default": true,
  16206.                         "_type": "boolean"
  16207.                 },
  16208.                 "namesuffix": {
  16209.                         "name": "Append Text to Names on Import",
  16210.                         "default": "",
  16211.                         "_type": "String"
  16212.                 }
  16213.         });
  16214.         addConfigOptions("interface", {
  16215.                 "_name": "Interface",
  16216.                 "_player": true,
  16217.                 "customTracker": {
  16218.                         "name": "Add Additional Info to Tracker",
  16219.                         "default": true,
  16220.                         "_type": "boolean"
  16221.                 },
  16222.                 "trackerCol1": {
  16223.                         "name": "Tracker Column 1",
  16224.                         "default": "HP",
  16225.                         "_type": "_FORMULA"
  16226.                 },
  16227.                 "trackerCol2": {
  16228.                         "name": "Tracker Column 2",
  16229.                         "default": "AC",
  16230.                         "_type": "_FORMULA"
  16231.                 },
  16232.                 "trackerCol3": {
  16233.                         "name": "Tracker Column 3",
  16234.                         "default": "PP",
  16235.                         "_type": "_FORMULA"
  16236.                 },
  16237.                 "trackerSheetButton": {
  16238.                         "name": "Add Sheet Button To Tracker",
  16239.                         "default": false,
  16240.                         "_type": "boolean"
  16241.                 },
  16242.                 "minifyTracker": {
  16243.                         "name": "Shrink Initiative Tracker Text",
  16244.                         "default": false,
  16245.                         "_type": "boolean"
  16246.                 },
  16247.                 "showDifficulty": {
  16248.                         "name": "Show Difficulty in Tracker",
  16249.                         "default": true,
  16250.                         "_type": "boolean"
  16251.                 },
  16252.                 "emoji": {
  16253.                         "name": "Add Emoji Replacement to Chat",
  16254.                         "default": true,
  16255.                         "_type": "boolean",
  16256.                         "_player": true
  16257.                 },
  16258.                 "showCustomArtPreview": {
  16259.                         "name": "Show Custom Art Previews",
  16260.                         "default": true,
  16261.                         "_type": "boolean"
  16262.                 }
  16263.         });
  16264.  
  16265.         d20plus.sheet = "ogl";
  16266.         d20plus.psionics = {};
  16267.         d20plus.races = {};
  16268.         d20plus.objects = {};
  16269.         d20plus.adventures = {};
  16270.         d20plus.optionalfeatures = {};
  16271.  
  16272.         d20plus.advantageModes = ["Toggle (Default Advantage)", "Toggle", "Toggle (Default Disadvantage)", "Always", "Query", "Never"];
  16273.         d20plus.whisperModes = ["Toggle (Default GM)", "Toggle (Default Public)", "Always", "Query", "Never"];
  16274.         d20plus.damageModes = ["Auto Roll", "Don't Auto Roll"];
  16275.  
  16276.         d20plus.formulas = {
  16277.                 _options: ["--Empty--", "AC", "HP", "Passive Perception", "Spell DC"],
  16278.                 "ogl": {
  16279.                         "cr": "@{npc_challenge}",
  16280.                         "ac": "@{ac}",
  16281.                         "npcac": "@{npc_ac}",
  16282.                         "hp": "@{hp}",
  16283.                         "pp": "@{passive_wisdom}",
  16284.                         "macro": "",
  16285.                         "spellDc": "@{spell_save_dc}"
  16286.                 },
  16287.                 "community": {
  16288.                         "cr": "@{npc_challenge}",
  16289.                         "ac": "@{AC}",
  16290.                         "npcac": "@{AC}",
  16291.                         "hp": "@{HP}",
  16292.                         "pp": "10 + @{perception}",
  16293.                         "macro": "",
  16294.                         "spellDc": "@{spell_save_dc}"
  16295.                 },
  16296.                 "shaped": {
  16297.                         "cr": "@{challenge}",
  16298.                         "ac": "@{AC}",
  16299.                         "npcac": "@{AC}",
  16300.                         "hp": "@{HP}",
  16301.                         "pp": "@{repeating_skill_$11_passive}",
  16302.                         "macro": "shaped_statblock",
  16303.                         "spellDc": "@{spell_save_dc}"
  16304.                 }
  16305.         };
  16306.  
  16307.         if (!d20plus.ut.isUseSharedJs()) {
  16308.                 d20plus.js.scripts.push({name: "5etoolsRender", url: `${SITE_JS_URL}render.js`});
  16309.                 d20plus.js.scripts.push({name: "5etoolsScalecreature", url: `${SITE_JS_URL}scalecreature.js`});
  16310.         }
  16311.  
  16312.         d20plus.json = [
  16313.                 {name: "class index", url: `${CLASS_DATA_DIR}index.json`, isJson: true},
  16314.                 {name: "spell index", url: `${SPELL_DATA_DIR}index.json`, isJson: true},
  16315.                 {name: "spell metadata", url: SPELL_META_URL, isJson: true},
  16316.                 {name: "bestiary index", url: `${MONSTER_DATA_DIR}index.json`, isJson: true},
  16317.                 {name: "bestiary fluff index", url: `${MONSTER_DATA_DIR}fluff-index.json`, isJson: true},
  16318.                 {name: "bestiary metadata", url: `${MONSTER_DATA_DIR}meta.json`, isJson: true},
  16319.                 {name: "adventures index", url: `${DATA_URL}adventures.json`, isJson: true},
  16320.                 {name: "base items", url: `${DATA_URL}items-base.json`, isJson: true},
  16321.                 {name: "item modifiers", url: `${DATA_URL}roll20-items.json`, isJson: true},
  16322.         ];
  16323.  
  16324.         // add JSON index/metadata
  16325.         d20plus.pAddJson = async function () {
  16326.                 d20plus.ut.log("Load JSON");
  16327.  
  16328.                 await Promise.all(d20plus.json.map(async it => {
  16329.                         const data = await d20plus.js.pLoadWithRetries(it.name, it.url, true);
  16330.  
  16331.                         if (it.name === "class index") classDataUrls = data;
  16332.                         else if (it.name === "spell index") spellDataUrls = data;
  16333.                         else if (it.name === "spell metadata") spellMetaData = data;
  16334.                         else if (it.name === "bestiary index") monsterDataUrls = data;
  16335.                         else if (it.name === "bestiary fluff index") monsterFluffDataUrls = data;
  16336.                         else if (it.name === "bestiary metadata") monsterMetadata = data;
  16337.                         else if (it.name === "adventures index") adventureMetadata = data;
  16338.                         else if (it.name === "base items") {
  16339.                                 data.itemProperty.forEach(p => Renderer.item._addProperty(p));
  16340.                                 data.itemType.forEach(t => Renderer.item._addType(t));
  16341.                         }
  16342.                         else if (it.name === "item modifiers") itemMetadata = data;
  16343.                         else throw new Error(`Unhandled data from JSON ${it.name} (${it.url})`);
  16344.  
  16345.                         d20plus.ut.log(`JSON [${it.name}] Loaded`);
  16346.                 }));
  16347.         };
  16348.  
  16349.         d20plus.handleConfigChange = function (isSyncingPlayer) {
  16350.                 if (!isSyncingPlayer) d20plus.ut.log("Applying config");
  16351.                 if (window.is_gm) {
  16352.                         d20plus.setInitiativeShrink(d20plus.cfg.get("interface", "minifyTracker"));
  16353.                         d20.Campaign.initiativewindow.rebuildInitiativeList();
  16354.                         d20plus.updateDifficulty();
  16355.                         if (d20plus.art.refreshList) d20plus.art.refreshList();
  16356.                 }
  16357.         };
  16358.  
  16359.         // get the user config'd token HP bar
  16360.         d20plus.getCfgHpBarNumber = function () {
  16361.                 const bars = [
  16362.                         d20plus.cfg.get("token", "bar1"),
  16363.                         d20plus.cfg.get("token", "bar2"),
  16364.                         d20plus.cfg.get("token", "bar3")
  16365.                 ];
  16366.                 return bars[0] === "npc_hpbase" ? 1 : bars[1] === "npc_hpbase" ? 2 : bars[2] === "npc_hpbase" ? 3 : null;
  16367.         };
  16368.  
  16369.         // Bind Graphics Add on page
  16370.         d20plus.bindGraphics = function (page) {
  16371.                 d20plus.ut.log("Bind Graphics");
  16372.                 try {
  16373.                         if (page.get("archived") === false) {
  16374.                                 page.thegraphics.on("add", function (e) {
  16375.                                         var character = e.character;
  16376.                                         if (character) {
  16377.                                                 var npc = character.attribs.find(function (a) {
  16378.                                                         return a.get("name").toLowerCase() == "npc";
  16379.                                                 });
  16380.                                                 var isNPC = npc ? parseInt(npc.get("current")) : 0;
  16381.                                                 // Set bars if configured to do so
  16382.                                                 var barsList = ["bar1", "bar2", "bar3"];
  16383.                                                 $.each(barsList, (i, barName) => {
  16384.                                                         // PC config keys are suffixed "_pc"
  16385.                                                         const confVal = d20plus.cfg.get("token", `${barName}${isNPC ? "" : "_pc"}`);
  16386.                                                         if (confVal) {
  16387.                                                                 const charAttr = character.attribs.find(a => a.get("name").toLowerCase() == confVal);
  16388.                                                                 if (charAttr) {
  16389.                                                                         e.attributes[barName + "_value"] = charAttr.get("current");
  16390.                                                                         if (d20plus.cfg.has("token", barName + "_max")) {
  16391.                                                                                 if (d20plus.cfg.get("token", barName + "_max") && !isNPC && confVal === "hp") { // player HP is current; need to set max to max
  16392.                                                                                         e.attributes[barName + "_max"] = charAttr.get("max");
  16393.                                                                                 } else {
  16394.                                                                                         if (isNPC) {
  16395.                                                                                                 // TODO: Setting a value to empty/null does not overwrite existing values on the token.
  16396.                                                                                                 // setting a specific value does. Must figure this out.
  16397.                                                                                                 e.attributes[barName + "_max"] = d20plus.cfg.get("token", barName + "_max") ? charAttr.get("current") : "";
  16398.                                                                                         } else {
  16399.                                                                                                 // preserve default token for player tokens
  16400.                                                                                                 if (d20plus.cfg.get("token", barName + "_max")) {
  16401.                                                                                                         e.attributes[barName + "_max"] = charAttr.get("current");
  16402.                                                                                                 }
  16403.                                                                                         }
  16404.                                                                                 }
  16405.                                                                         }
  16406.                                                                         if (d20plus.cfg.has("token", barName + "_reveal")) {
  16407.                                                                                 e.attributes["showplayers_" + barName] = d20plus.cfg.get("token", barName + "_reveal");
  16408.                                                                         }
  16409.                                                                 }
  16410.                                                         }
  16411.                                                 });
  16412.  
  16413.                                                 // NPC-only settings
  16414.                                                 if (isNPC) {
  16415.                                                         // Set Nametag
  16416.                                                         if (d20plus.cfg.has("token", "name")) {
  16417.                                                                 e.attributes["showname"] = d20plus.cfg.get("token", "name");
  16418.                                                                 if (d20plus.cfg.has("token", "name_reveal")) {
  16419.                                                                         e.attributes["showplayers_name"] = d20plus.cfg.get("token", "name_reveal");
  16420.                                                                 }
  16421.                                                         }
  16422.  
  16423.                                                         // Roll HP
  16424.                                                         // TODO: npc_hpbase appears to be hardcoded here? Refactor for NPC_SHEET_ATTRIBUTES?
  16425.                                                         if ((d20plus.cfg.get("token", "rollHP") || d20plus.cfg.get("token", "maximiseHp")) && d20plus.cfg.getCfgKey("token", "npc_hpbase")) {
  16426.                                                                 var hpf = character.attribs.find(function (a) {
  16427.                                                                         return a.get("name").toLowerCase() == NPC_SHEET_ATTRIBUTES["npc_hpformula"][d20plus.sheet];
  16428.                                                                 });
  16429.                                                                 var barName = d20plus.cfg.getCfgKey("token", "npc_hpbase");
  16430.  
  16431.                                                                 if (hpf && hpf.get("current")) {
  16432.                                                                         var hpformula = hpf.get("current");
  16433.                                                                         if (d20plus.cfg.get("token", "maximiseHp")) {
  16434.                                                                                 const maxSum = hpformula.replace("d", "*");
  16435.                                                                                 try {
  16436.                                                                                         const max = eval(maxSum);
  16437.                                                                                         if (!isNaN(max)) {
  16438.                                                                                                 e.attributes[barName + "_value"] = max;
  16439.                                                                                                 e.attributes[barName + "_max"] = max;
  16440.                                                                                         }
  16441.                                                                                 } catch (error) {
  16442.                                                                                         d20plus.ut.log("Error Maximising HP");
  16443.                                                                                         console.log(error);
  16444.                                                                                 }
  16445.                                                                         } else {
  16446.                                                                                 d20plus.ut.randomRoll(hpformula, function (result) {
  16447.                                                                                         e.attributes[barName + "_value"] = result.total;
  16448.                                                                                         e.attributes[barName + "_max"] = result.total;
  16449.                                                                                         d20plus.ut.log("Rolled HP for [" + character.get("name") + "]");
  16450.                                                                                 }, function (error) {
  16451.                                                                                         d20plus.ut.log("Error Rolling HP Dice");
  16452.                                                                                         console.log(error);
  16453.                                                                                 });
  16454.                                                                         }
  16455.                                                                 }
  16456.                                                         }
  16457.                                                 }
  16458.                                         }
  16459.                                 });
  16460.                         }
  16461.                 } catch (e) {
  16462.                         console.log("D20Plus bindGraphics Exception", e);
  16463.                         console.log("PAGE", page);
  16464.                 }
  16465.         };
  16466.  
  16467. // bind token HP to initiative tracker window HP field
  16468.         d20plus.bindToken = function (token) {
  16469.                 function getInitTrackerToken () {
  16470.                         const $window = $("#initiativewindow");
  16471.                         if (!$window.length) return [];
  16472.                         return $window.find(`li.token`).filter((i, e) => {
  16473.                                 return $(e).data("tokenid") === token.id;
  16474.                         });
  16475.                 }
  16476.  
  16477.                 const $initToken = getInitTrackerToken();
  16478.                 if (!$initToken.length) return;
  16479.                 const $iptHp = $initToken.find(`.hp.editable`);
  16480.                 const npcFlag = token.character ? token.character.attribs.find((a) => {
  16481.                         return a.get("name").toLowerCase() === "npc";
  16482.                 }) : null;
  16483.                 // if there's a HP column enabled
  16484.                 if ($iptHp.length) {
  16485.                         let toBind;
  16486.                         if (!token.character || npcFlag && npcFlag.get("current") == "1") {
  16487.                                 const hpBar = d20plus.getCfgHpBarNumber();
  16488.                                 // and a HP bar chosen
  16489.                                 if (hpBar) {
  16490.                                         $iptHp.text(token.attributes[`bar${hpBar}_value`])
  16491.                                 }
  16492.  
  16493.                                 toBind = (token, changes) => {
  16494.                                         const $initToken = getInitTrackerToken();
  16495.                                         if (!$initToken.length) return;
  16496.                                         const $iptHp = $initToken.find(`.hp.editable`);
  16497.                                         const hpBar = d20plus.getCfgHpBarNumber();
  16498.  
  16499.                                         if ($iptHp && hpBar) {
  16500.                                                 if (changes.changes[`bar${hpBar}_value`]) {
  16501.                                                         $iptHp.text(token.changed[`bar${hpBar}_value`]);
  16502.                                                 }
  16503.                                         }
  16504.                                 };
  16505.                         } else {
  16506.                                 toBind = (token, changes) => {
  16507.                                         const $initToken = getInitTrackerToken();
  16508.                                         if (!$initToken.length) return;
  16509.                                         const $iptHp = $initToken.find(`.hp.editable`);
  16510.                                         if ($iptHp) {
  16511.                                                 $iptHp.text(token.character.autoCalcFormula(d20plus.formulas[d20plus.sheet].hp));
  16512.                                         }
  16513.                                 }
  16514.                         }
  16515.                         // clean up old handler
  16516.                         if (d20plus.tokenBindings[token.id]) token.off("change", d20plus.tokenBindings[token.id]);
  16517.                         // add new handler
  16518.                         d20plus.tokenBindings[token.id] = toBind;
  16519.                         token.on("change", toBind);
  16520.                 }
  16521.         };
  16522.         d20plus.tokenBindings = {};
  16523.  
  16524. // Determine difficulty of current encounter (iniativewindow)
  16525.         d20plus.getDifficulty = function () {
  16526.                 var difficulty = "Unknown";
  16527.                 var partyXPThreshold = [0, 0, 0, 0];
  16528.                 var players = [];
  16529.                 var npcs = [];
  16530.                 try {
  16531.                         $.each(d20.Campaign.initiativewindow.cleanList(), function (i, v) {
  16532.                                 var page = d20.Campaign.pages.get(v._pageid);
  16533.                                 if (page) {
  16534.                                         var token = page.thegraphics.get(v.id);
  16535.                                         if (token) {
  16536.                                                 var char = token.character;
  16537.                                                 if (char) {
  16538.                                                         var npc = char.attribs.find(function (a) {
  16539.                                                                 return a.get("name").toLowerCase() === "npc";
  16540.                                                         });
  16541.                                                         if (npc && (npc.get("current") === 1 || npc.get("current") === "1")) { // just in casies
  16542.                                                                 npcs.push(char);
  16543.                                                         } else {
  16544.                                                                 var level = char.attribs.find(function (a) {
  16545.                                                                         return a.get("name").toLowerCase() === "level";
  16546.                                                                 });
  16547.                                                                 // Can't determine difficulty without level
  16548.                                                                 if (!level || partyXPThreshold === null) {
  16549.                                                                         partyXPThreshold = null;
  16550.                                                                         return;
  16551.                                                                 }
  16552.                                                                 // Total party threshold
  16553.                                                                 for (i = 0; i < partyXPThreshold.length; i++) partyXPThreshold[i] += Parser.levelToXpThreshold(level.get("current"))[i];
  16554.                                                                 players.push(players.length + 1);
  16555.                                                         }
  16556.                                                 }
  16557.                                         }
  16558.                                 }
  16559.                         });
  16560.                         if (!players.length) return difficulty;
  16561.                         // If a player doesn't have level set, fail out.
  16562.                         if (partyXPThreshold !== null) {
  16563.                                 var len = npcs.length;
  16564.                                 var multiplier = 0;
  16565.                                 var adjustedxp = 0;
  16566.                                 var xp = 0;
  16567.                                 var index = 0;
  16568.                                 // Adjust for number of monsters
  16569.                                 if (len < 2) index = 0;
  16570.                                 else if (len < 3) index = 1;
  16571.                                 else if (len < 7) index = 2;
  16572.                                 else if (len < 11) index = 3;
  16573.                                 else if (len < 15) index = 4;
  16574.                                 else
  16575.                                         index = 5;
  16576.                                 // Adjust for smaller parties
  16577.                                 if (players.length < 3) index++;
  16578.                                 // Set multiplier
  16579.                                 multiplier = d20plus.multipliers[index];
  16580.                                 // Total monster xp
  16581.                                 $.each(npcs, function (i, v) {
  16582.                                         var cr = v.attribs.find(function (a) {
  16583.                                                 return a.get("name").toLowerCase() === "npc_challenge";
  16584.                                         });
  16585.                                         if (cr && cr.get("current")) xp += parseInt(Parser.crToXpNumber(cr.get("current")));
  16586.                                 });
  16587.                                 // Encounter's adjusted xp
  16588.                                 adjustedxp = xp * multiplier;
  16589.                                 console.log("Party XP Threshold", partyXPThreshold);
  16590.                                 console.log("Adjusted XP", adjustedxp);
  16591.                                 // Determine difficulty
  16592.                                 if (adjustedxp < partyXPThreshold[0]) difficulty = "Trivial";
  16593.                                 else if (adjustedxp < partyXPThreshold[1]) difficulty = "Easy";
  16594.                                 else if (adjustedxp < partyXPThreshold[2]) difficulty = "Medium";
  16595.                                 else if (adjustedxp < partyXPThreshold[3]) difficulty = "Hard";
  16596.                                 else difficulty = "Deadly";
  16597.                         }
  16598.                 } catch (e) {
  16599.                         console.log("D20Plus getDifficulty Exception", e);
  16600.                 }
  16601.                 return difficulty;
  16602.         };
  16603.  
  16604.         d20plus.formSrcUrl = function (dataDir, fileName) {
  16605.                 return dataDir + fileName;
  16606.         };
  16607.  
  16608.         d20plus.addCustomHTML = function () {
  16609.                 function populateDropdown (dropdownId, inputFieldId, baseUrl, srcUrlObject, defaultSel, homebrewDir) {
  16610.                         const defaultUrl = defaultSel ? d20plus.formSrcUrl(baseUrl, srcUrlObject[defaultSel]) : "";
  16611.                         $(inputFieldId).val(defaultUrl);
  16612.                         const dropdown = $(dropdownId);
  16613.                         $.each(Object.keys(srcUrlObject), function (i, src) {
  16614.                                 dropdown.append($('<option>', {
  16615.                                         value: d20plus.formSrcUrl(baseUrl, srcUrlObject[src]),
  16616.                                         text: homebrewDir === "class" ? src.uppercaseFirst() : Parser.sourceJsonToFullCompactPrefix(src)
  16617.                                 }));
  16618.                         });
  16619.                         dropdown.append($('<option>', {
  16620.                                 value: "",
  16621.                                 text: "Custom"
  16622.                         }));
  16623.  
  16624.                         const brewUrl = DataUtil.brew.getDirUrl(homebrewDir);
  16625.                         DataUtil.loadJSON(brewUrl).then(async (data, debugUrl) => {
  16626.                                 if (data.message) console.error(debugUrl, data.message);
  16627.  
  16628.                                 const collectionItems = Object.keys(brewCollectionIndex).filter(k => brewCollectionIndex[k].includes(BrewUtil._pRenderBrewScreen_dirToCat(homebrewDir)));
  16629.                                 if (collectionItems.length) {
  16630.                                         data = MiscUtil.copy(data);
  16631.                                         const collectionIndex = await DataUtil.loadJSON(DataUtil.brew.getDirUrl("collection"));
  16632.                                         collectionIndex.filter(it => collectionItems.includes(it.name)).forEach(it => data.push(it));
  16633.                                 }
  16634.  
  16635.                                 data.sort((a, b) => SortUtil.ascSortLower(a.name, b.name)).forEach(it => {
  16636.                                         dropdown.append($('<option>', {
  16637.                                                 value: `${it.download_url}${d20plus.ut.getAntiCacheSuffix()}`,
  16638.                                                 text: `Homebrew: ${it.name.trim().replace(/\.json$/i, "")}`
  16639.                                         }));
  16640.                                 });
  16641.                         }, brewUrl);
  16642.  
  16643.                         dropdown.val(defaultUrl);
  16644.                         dropdown.change(function () {
  16645.                                 $(inputFieldId).val(this.value);
  16646.                         });
  16647.                 }
  16648.  
  16649.                 function populateBasicDropdown (dropdownId, inputFieldId, defaultSel, homebrewDir, addForPlayers) {
  16650.                         function doPopulate (dropdownId, inputFieldId) {
  16651.                                 const $sel = $(dropdownId);
  16652.                                 const existingItems = !!$sel.find(`option`).length;
  16653.                                 if (defaultSel) {
  16654.                                         $(inputFieldId).val(defaultSel);
  16655.                                         $sel.append($('<option>', {
  16656.                                                 value: defaultSel,
  16657.                                                 text: "Official Sources"
  16658.                                         }));
  16659.                                 }
  16660.                                 if (!existingItems) {
  16661.                                         $sel.append($('<option>', {
  16662.                                                 value: "",
  16663.                                                 text: "Custom"
  16664.                                         }));
  16665.                                 }
  16666.  
  16667.                                 const brewUrl = DataUtil.brew.getDirUrl(homebrewDir);
  16668.                                 DataUtil.loadJSON(brewUrl).then(async (data, debugUrl) => {
  16669.                                         if (data.message) console.error(debugUrl, data.message);
  16670.  
  16671.                                         const collectionItems = Object.keys(brewCollectionIndex).filter(k => brewCollectionIndex[k].includes(homebrewDir));
  16672.                                         if (collectionItems.length) {
  16673.                                                 data = MiscUtil.copy(data);
  16674.                                                 const collectionIndex = await DataUtil.loadJSON(DataUtil.brew.getDirUrl("collection"));
  16675.                                                 collectionIndex.filter(it => collectionItems.includes(it.name)).forEach(it => data.push(it));
  16676.                                         }
  16677.  
  16678.                                         data.forEach(it => {
  16679.                                                 $sel.append($('<option>', {
  16680.                                                         value: `${it.download_url}${d20plus.ut.getAntiCacheSuffix()}`,
  16681.                                                         text: `Homebrew: ${it.name.trim().replace(/\.json$/i, "")}`
  16682.                                                 }));
  16683.                                         });
  16684.                                 }, brewUrl);
  16685.  
  16686.                                 $sel.val(defaultSel);
  16687.                                 $sel.change(function () {
  16688.                                         $(inputFieldId).val(this.value);
  16689.                                 });
  16690.                         }
  16691.  
  16692.                         doPopulate(dropdownId, inputFieldId, defaultSel, homebrewDir);
  16693.                         if (addForPlayers) doPopulate(`${dropdownId}-player`, `${inputFieldId}-player`, defaultSel, homebrewDir);
  16694.                 }
  16695.  
  16696.                 const $body = $("body");
  16697.                 if (window.is_gm) {
  16698.                         const $wrpSettings = $(`#betteR20-settings`);
  16699.  
  16700.                         $wrpSettings.append(d20plus.settingsHtmlImportHeader);
  16701.                         $wrpSettings.append(d20plus.settingsHtmlSelector);
  16702.                         $wrpSettings.append(d20plus.settingsHtmlPtMonsters);
  16703.                         $wrpSettings.append(d20plus.settingsHtmlPtItems);
  16704.                         $wrpSettings.append(d20plus.settingsHtmlPtSpells);
  16705.                         $wrpSettings.append(d20plus.settingsHtmlPtPsionics);
  16706.                         $wrpSettings.append(d20plus.settingsHtmlPtRaces);
  16707.                         $wrpSettings.append(d20plus.settingsHtmlPtFeats);
  16708.                         $wrpSettings.append(d20plus.settingsHtmlPtObjects);
  16709.                         $wrpSettings.append(d20plus.settingsHtmlPtClasses);
  16710.                         $wrpSettings.append(d20plus.settingsHtmlPtSubclasses);
  16711.                         $wrpSettings.append(d20plus.settingsHtmlPtBackgrounds);
  16712.                         $wrpSettings.append(d20plus.settingsHtmlPtOptfeatures);
  16713.                         const $ptAdventures = $(d20plus.settingsHtmlPtAdventures);
  16714.                         $wrpSettings.append($ptAdventures);
  16715.                         $ptAdventures.find(`.Vetools-module-tool-open`).click(() => d20plus.tool.get('MODULES').openFn());
  16716.                         $wrpSettings.append(d20plus.settingsHtmlPtImportFooter);
  16717.  
  16718.                         $("#mysettings > .content a#button-monsters-load").on(window.mousedowntype, d20plus.monsters.button);
  16719.                         $("#mysettings > .content a#button-monsters-load-all").on(window.mousedowntype, d20plus.monsters.buttonAll);
  16720.                         $("#mysettings > .content a#import-objects-load").on(window.mousedowntype, d20plus.objects.button);
  16721.                         $("#mysettings > .content a#button-adventures-load").on(window.mousedowntype, d20plus.adventures.button);
  16722.  
  16723.                         $("#mysettings > .content a#bind-drop-locations").on(window.mousedowntype, d20plus.bindDropLocations);
  16724.                         $("#initiativewindow .characterlist").before(d20plus.initiativeHeaders);
  16725.  
  16726.                         d20plus.setTurnOrderTemplate();
  16727.                         d20.Campaign.initiativewindow.rebuildInitiativeList();
  16728.                         d20plus.hpAllowEdit();
  16729.                         d20.Campaign.initiativewindow.model.on("change:turnorder", function () {
  16730.                                 d20plus.updateDifficulty();
  16731.                         });
  16732.                         d20plus.updateDifficulty();
  16733.  
  16734.                         populateDropdown("#button-monsters-select", "#import-monster-url", MONSTER_DATA_DIR, monsterDataUrls, "MM", "creature");
  16735.                         populateBasicDropdown("#button-objects-select", "#import-objects-url", OBJECT_DATA_URL, "object");
  16736.  
  16737.                         populateAdventuresDropdown();
  16738.  
  16739.                         function populateAdventuresDropdown () {
  16740.                                 const defaultAdvUrl = d20plus.formSrcUrl(ADVENTURE_DATA_DIR, "adventure-lmop.json");
  16741.                                 const $iptUrl = $("#import-adventures-url");
  16742.                                 $iptUrl.val(defaultAdvUrl);
  16743.                                 $iptUrl.data("id", "lmop");
  16744.                                 const $sel = $("#button-adventures-select");
  16745.                                 adventureMetadata.adventure.forEach(a => {
  16746.                                         $sel.append($('<option>', {
  16747.                                                 value: d20plus.formSrcUrl(ADVENTURE_DATA_DIR, `adventure-${a.id.toLowerCase()}.json|${a.id}`),
  16748.                                                 text: a.name
  16749.                                         }));
  16750.                                 });
  16751.                                 $sel.append($('<option>', {
  16752.                                         value: "",
  16753.                                         text: "Custom"
  16754.                                 }));
  16755.                                 $sel.val(defaultAdvUrl);
  16756.                                 $sel.change(() => {
  16757.                                         const [url, id] = $sel.val().split("|");
  16758.                                         $($iptUrl).val(url);
  16759.                                         $iptUrl.data("id", id);
  16760.                                 });
  16761.                         }
  16762.  
  16763.                         // import
  16764.                         $("a#button-spells-load").on(window.mousedowntype, () => d20plus.spells.button());
  16765.                         $("a#button-spells-load-all").on(window.mousedowntype, () => d20plus.spells.buttonAll());
  16766.                         $("a#import-psionics-load").on(window.mousedowntype, () => d20plus.psionics.button());
  16767.                         $("a#import-items-load").on(window.mousedowntype, () => d20plus.items.button());
  16768.                         $("a#import-races-load").on(window.mousedowntype, () => d20plus.races.button());
  16769.                         $("a#import-feats-load").on(window.mousedowntype, () => d20plus.feats.button());
  16770.                         $("a#button-classes-load").on(window.mousedowntype, () => d20plus.classes.button());
  16771.                         $("a#button-classes-load-all").on(window.mousedowntype, () => d20plus.classes.buttonAll());
  16772.                         $("a#import-subclasses-load").on(window.mousedowntype, () => d20plus.subclasses.button());
  16773.                         $("a#import-backgrounds-load").on(window.mousedowntype, () => d20plus.backgrounds.button());
  16774.                         $("a#import-optionalfeatures-load").on(window.mousedowntype, () => d20plus.optionalfeatures.button());
  16775.                         $("select#import-mode-select").on("change", () => d20plus.importer.importModeSwitch());
  16776.                 } else {
  16777.                         // player-only HTML if required
  16778.                 }
  16779.  
  16780.                 $body.append(d20plus.playerImportHtml);
  16781.                 const $winPlayer = $("#d20plus-playerimport");
  16782.                 const $appTo = $winPlayer.find(`.append-target`);
  16783.                 $appTo.append(d20plus.settingsHtmlSelectorPlayer);
  16784.                 $appTo.append(d20plus.settingsHtmlPtItemsPlayer);
  16785.                 $appTo.append(d20plus.settingsHtmlPtSpellsPlayer);
  16786.                 $appTo.append(d20plus.settingsHtmlPtPsionicsPlayer);
  16787.                 $appTo.append(d20plus.settingsHtmlPtRacesPlayer);
  16788.                 $appTo.append(d20plus.settingsHtmlPtFeatsPlayer);
  16789.                 $appTo.append(d20plus.settingsHtmlPtClassesPlayer);
  16790.                 $appTo.append(d20plus.settingsHtmlPtSubclassesPlayer);
  16791.                 $appTo.append(d20plus.settingsHtmlPtBackgroundsPlayer);
  16792.                 $appTo.append(d20plus.settingsHtmlPtOptfeaturesPlayer);
  16793.  
  16794.                 $winPlayer.dialog({
  16795.                         autoOpen: false,
  16796.                         resizable: true,
  16797.                         width: 800,
  16798.                         height: 650,
  16799.                 });
  16800.  
  16801.                 const $wrpPlayerImport = $(`
  16802.                         <div style="padding: 0 10px">
  16803.                                 <div style="clear: both"></div>
  16804.                         </div>`);
  16805.                 const $btnPlayerImport = $(`<button class="btn" href="#" title="A tool to import temporary copies of various things, which can be drag-and-dropped to character sheets." style="margin-top: 5px">Temp Import Spells, Items, Classes,...</button>`)
  16806.                         .on("click", () => {
  16807.                                 $winPlayer.dialog("open");
  16808.                         });
  16809.                 $wrpPlayerImport.prepend($btnPlayerImport);
  16810.                 $(`#journal`).prepend($wrpPlayerImport);
  16811.  
  16812.                 // SHARED WINDOWS/BUTTONS
  16813.                 // import
  16814.                 $("a#button-spells-load-player").on(window.mousedowntype, () => d20plus.spells.button(true));
  16815.                 $("a#button-spells-load-all-player").on(window.mousedowntype, () => d20plus.spells.buttonAll(true));
  16816.                 $("a#import-psionics-load-player").on(window.mousedowntype, () => d20plus.psionics.button(true));
  16817.                 $("a#import-items-load-player").on(window.mousedowntype, () => d20plus.items.button(true));
  16818.                 $("a#import-races-load-player").on(window.mousedowntype, () => d20plus.races.button(true));
  16819.                 $("a#import-feats-load-player").on(window.mousedowntype, () => d20plus.feats.button(true));
  16820.                 $("a#button-classes-load-player").on(window.mousedowntype, () => d20plus.classes.button(true));
  16821.                 $("a#button-classes-load-all-player").on(window.mousedowntype, () => d20plus.classes.buttonAll(true));
  16822.                 $("a#import-subclasses-load-player").on(window.mousedowntype, () => d20plus.subclasses.button(true));
  16823.                 $("a#import-backgrounds-load-player").on(window.mousedowntype, () => d20plus.backgrounds.button(true));
  16824.                 $("a#import-optionalfeatures-load-player").on(window.mousedowntype, () => d20plus.optionalfeatures.button(true));
  16825.                 $("select#import-mode-select-player").on("change", () => d20plus.importer.importModeSwitch());
  16826.  
  16827.                 $body.append(d20plus.importDialogHtml);
  16828.                 $body.append(d20plus.importListHTML);
  16829.                 $body.append(d20plus.importListPropsHTML);
  16830.                 $("#d20plus-import").dialog({
  16831.                         autoOpen: false,
  16832.                         resizable: false
  16833.                 });
  16834.                 $("#d20plus-importlist").dialog({
  16835.                         autoOpen: false,
  16836.                         resizable: true,
  16837.                         width: 1000,
  16838.                         height: 700
  16839.                 });
  16840.                 $("#d20plus-import-props").dialog({
  16841.                         autoOpen: false,
  16842.                         resizable: true,
  16843.                         width: 300,
  16844.                         height: 600
  16845.                 });
  16846.  
  16847.                 populateDropdown("#button-spell-select", "#import-spell-url", SPELL_DATA_DIR, spellDataUrls, "PHB", "spell");
  16848.                 populateDropdown("#button-spell-select-player", "#import-spell-url-player", SPELL_DATA_DIR, spellDataUrls, "PHB", "spell");
  16849.                 populateDropdown("#button-classes-select", "#import-classes-url", CLASS_DATA_DIR, classDataUrls, "", "class");
  16850.                 populateDropdown("#button-classes-select-player", "#import-classes-url-player", CLASS_DATA_DIR, classDataUrls, "", "class");
  16851.  
  16852.                 // add class subclasses to the subclasses dropdown(s)
  16853.                 populateDropdown("#button-subclasses-select", "#import-subclasses-url", CLASS_DATA_DIR, classDataUrls, "", "class");
  16854.                 populateDropdown("#button-subclasses-select-player", "#import-subclasses-url-player", CLASS_DATA_DIR, classDataUrls, "", "class");
  16855.  
  16856.                 populateBasicDropdown("#button-items-select", "#import-items-url", ITEM_DATA_URL, "item", true);
  16857.                 populateBasicDropdown("#button-psionics-select", "#import-psionics-url", PSIONIC_DATA_URL, "psionic", true);
  16858.                 populateBasicDropdown("#button-feats-select", "#import-feats-url", FEAT_DATA_URL, "feat", true);
  16859.                 populateBasicDropdown("#button-races-select", "#import-races-url", RACE_DATA_URL, "race", true);
  16860.                 populateBasicDropdown("#button-subclasses-select", "#import-subclasses-url", "", "subclass", true);
  16861.                 populateBasicDropdown("#button-backgrounds-select", "#import-backgrounds-url", BACKGROUND_DATA_URL, "background", true);
  16862.                 populateBasicDropdown("#button-optionalfeatures-select", "#import-optionalfeatures-url", OPT_FEATURE_DATA_URL, "optionalfeature", true);
  16863.  
  16864.                 // bind tokens button
  16865.                 const altBindButton = $(`<button id="bind-drop-locations-alt" class="btn bind-drop-locations" title="Bind drop locations and handouts">Bind Drag-n-Drop</button>`);
  16866.                 altBindButton.on("click", function () {
  16867.                         d20plus.bindDropLocations();
  16868.                 });
  16869.  
  16870.                 if (window.is_gm) {
  16871.                         const $addPoint = $(`#journal button.btn.superadd`);
  16872.                         altBindButton.css("margin-right", "5px");
  16873.                         $addPoint.after(altBindButton);
  16874.                 } else {
  16875.                         altBindButton.css("margin-top", "5px");
  16876.                         const $wrprControls = $(`#search-wrp-controls`);
  16877.                         $wrprControls.append(altBindButton);
  16878.                 }
  16879.                 $("#journal #bind-drop-locations").on(window.mousedowntype, d20plus.bindDropLocations);
  16880.         };
  16881.  
  16882.         d20plus.updateDifficulty = function () {
  16883.                 const $initWindow = $("div#initiativewindow");
  16884.                 if (!$initWindow.parent().is("body")) {
  16885.                         const $btnPane = $initWindow.parent().find(".ui-dialog-buttonpane");
  16886.  
  16887.                         let $span = $btnPane.find("span.difficulty");
  16888.  
  16889.                         if (!$span.length) {
  16890.                                 $btnPane.prepend(d20plus.difficultyHtml);
  16891.                                 $span = $btnPane.find("span.difficulty");
  16892.                         }
  16893.  
  16894.                         if (d20plus.cfg.get("interface", "showDifficulty")) {
  16895.                                 $span.text("Difficulty: " + d20plus.getDifficulty());
  16896.                                 $span.show();
  16897.                         } else {
  16898.                                 $span.hide();
  16899.                         }
  16900.                 }
  16901.         };
  16902.  
  16903. // bind tokens to the initiative tracker
  16904.         d20plus.bindTokens = function () {
  16905.                 // Gets a list of all the tokens on the current page:
  16906.                 const curTokens = d20.Campaign.pages.get(d20.Campaign.activePage()).thegraphics.toArray();
  16907.                 curTokens.forEach(t => {
  16908.                         d20plus.bindToken(t);
  16909.                 });
  16910.         };
  16911.  
  16912. // bind drop locations on sheet to accept custom handouts
  16913.         d20plus.bindDropLocations = function () {
  16914.                 if (window.is_gm) {
  16915.                         // Bind Spells and Items, add compendium-item to each of them
  16916.                         var journalFolder = d20.Campaign.get("journalfolder");
  16917.                         if (journalFolder === "") {
  16918.                                 d20.journal.addFolderToFolderStructure("Spells");
  16919.                                 d20.journal.addFolderToFolderStructure("Psionics");
  16920.                                 d20.journal.addFolderToFolderStructure("Items");
  16921.                                 d20.journal.addFolderToFolderStructure("Feats");
  16922.                                 d20.journal.addFolderToFolderStructure("Classes");
  16923.                                 d20.journal.addFolderToFolderStructure("Subclasses");
  16924.                                 d20.journal.addFolderToFolderStructure("Backgrounds");
  16925.                                 d20.journal.addFolderToFolderStructure("Races");
  16926.                                 d20.journal.addFolderToFolderStructure("Optional Features");
  16927.                                 d20.journal.refreshJournalList();
  16928.                                 journalFolder = d20.Campaign.get("journalfolder");
  16929.                         }
  16930.                 }
  16931.  
  16932.                 function addClasses (folderName) {
  16933.                         $(`#journalfolderroot > ol.dd-list > li.dd-folder > div.dd-content:contains(${folderName})`).parent().find("ol li[data-itemid]").addClass("compendium-item").addClass("ui-draggable").addClass("Vetools-draggable");
  16934.                 }
  16935.  
  16936.                 addClasses("Spells");
  16937.                 addClasses("Psionics");
  16938.                 addClasses("Items");
  16939.                 addClasses("Feats");
  16940.                 addClasses("Classes");
  16941.                 addClasses("Subclasses");
  16942.                 addClasses("Backgrounds");
  16943.                 addClasses("Races");
  16944.                 addClasses("Optional Features");
  16945.  
  16946.                 // if player, force-enable dragging
  16947.                 if (!window.is_gm) {
  16948.                         $(`.Vetools-draggable`).draggable({
  16949.                                 revert: true,
  16950.                                 distance: 10,
  16951.                                 revertDuration: 0,
  16952.                                 helper: "clone",
  16953.                                 handle: ".namecontainer",
  16954.                                 appendTo: "body",
  16955.                                 scroll: true,
  16956.                                 start: function () {
  16957.                                         $("#journalfolderroot").addClass("externaldrag")
  16958.                                 },
  16959.                                 stop: function () {
  16960.                                         $("#journalfolderroot").removeClass("externaldrag")
  16961.                                 }
  16962.                         });
  16963.                 }
  16964.  
  16965.                 class CharacterAttributesProxy {
  16966.                         constructor (character) {
  16967.                                 this.character = character;
  16968.                                 this._changedAttrs = [];
  16969.                         }
  16970.  
  16971.                         findByName (attrName) {
  16972.                                 return this.character.model.attribs.toJSON()
  16973.                                         .find(a => a.name === attrName) || {};
  16974.                         }
  16975.  
  16976.                         findOrGenerateRepeatingRowId (namePattern, current) {
  16977.                                 const [namePrefix, nameSuffix] = namePattern.split(/\$\d?/);
  16978.                                 const attr = this.character.model.attribs.toJSON()
  16979.                                         .find(a => a.name.startsWith(namePrefix) && a.name.endsWith(nameSuffix) && a.current == current);
  16980.                                 return attr ?
  16981.                                         attr.name.replace(RegExp(`^${namePrefix}(.*)${nameSuffix}$`), "$1") :
  16982.                                         d20plus.ut.generateRowId();
  16983.                         }
  16984.  
  16985.                         add (name, current, max) {
  16986.                                 this.character.model.attribs.create({
  16987.                                         name: name,
  16988.                                         current: current,
  16989.                                         ...(max == undefined ? {} : {max: max})
  16990.                                 }).save();
  16991.                                 this._changedAttrs.push(name);
  16992.                         }
  16993.  
  16994.                         addOrUpdate (name, current, max) {
  16995.                                 const id = this.findByName(name).id;
  16996.                                 if (id) {
  16997.                                         this.character.model.attribs.get(id).set({
  16998.                                                 current: current,
  16999.                                                 ...(max == undefined ? {} : {max: max})
  17000.                                         }).save();
  17001.                                         this._changedAttrs.push(name);
  17002.                                 } else {
  17003.                                         this.add(name, current, max);
  17004.                                 }
  17005.                         }
  17006.  
  17007.                         notifySheetWorkers () {
  17008.                                 d20.journal.notifyWorkersOfAttrChanges(this.character.model.id, this._changedAttrs);
  17009.                                 this._changedAttrs = [];
  17010.                         }
  17011.                 }
  17012.  
  17013.                 function importFeat (character, data) {
  17014.                         const featName = data.name;
  17015.                         const featText = data.Vetoolscontent;
  17016.                         const attrs = new CharacterAttributesProxy(character);
  17017.                         const rowId = d20plus.ut.generateRowId();
  17018.  
  17019.                         if (d20plus.sheet == "ogl") {
  17020.                                 attrs.add(`repeating_traits_${rowId}_options-flag`, "0");
  17021.                                 attrs.add(`repeating_traits_${rowId}_name`, featName);
  17022.                                 attrs.add(`repeating_traits_${rowId}_description`, featText);
  17023.                                 attrs.add(`repeating_traits_${rowId}_source`, "Feat");
  17024.                         } else if (d20plus.sheet == "shaped") {
  17025.                                 attrs.add(`repeating_feat_${rowId}_name`, featName);
  17026.                                 attrs.add(`repeating_feat_${rowId}_content`, featText);
  17027.                                 attrs.add(`repeating_feat_${rowId}_content_toggle`, "1");
  17028.                         } else {
  17029.                                 console.warn(`Feat import is not supported for ${d20plus.sheet} character sheet`);
  17030.                         }
  17031.  
  17032.                         attrs.notifySheetWorkers();
  17033.                 }
  17034.  
  17035.                 async function importBackground (character, data) {
  17036.                         const bg = data.Vetoolscontent;
  17037.  
  17038.                         const renderer = new Renderer();
  17039.                         renderer.setBaseUrl(BASE_SITE_URL);
  17040.                         const renderStack = [];
  17041.                         let feature = {};
  17042.                         bg.entries.forEach(e => {
  17043.                                 if (e.name && e.name.includes("Feature:")) {
  17044.                                         feature = JSON.parse(JSON.stringify(e));
  17045.                                         feature.name = feature.name.replace("Feature:", "").trim();
  17046.                                 }
  17047.                         });
  17048.                         if (feature) renderer.recursiveRender({entries: feature.entries}, renderStack);
  17049.                         feature.text = renderStack.length ? d20plus.importer.getCleanText(renderStack.join("")) : "";
  17050.  
  17051.                         async function chooseSkills (from, count) {
  17052.                                 return new Promise((resolve, reject) => {
  17053.                                         const $dialog = $(`
  17054.                                                 <div title="Choose Skills">
  17055.                                                         <div name="remain" style="font-weight: bold">Remaining: ${count}</div>
  17056.                                                         <div>
  17057.                                                                 ${from.map(it => `<label class="split"><span>${it.toTitleCase()}</span> <input data-skill="${it}" type="checkbox"></label>`).join("")}
  17058.                                                         </div>
  17059.                                                 </div>
  17060.                                         `).appendTo($("body"));
  17061.                                         const $remain = $dialog.find(`[name="remain"]`);
  17062.                                         const $cbSkill = $dialog.find(`input[type="checkbox"]`);
  17063.  
  17064.                                         $cbSkill.on("change", function () {
  17065.                                                 const $e = $(this);
  17066.                                                 let selectedCount = getSelected().length;
  17067.                                                 if (selectedCount > count) {
  17068.                                                         $e.prop("checked", false);
  17069.                                                         selectedCount--;
  17070.                                                 }
  17071.                                                 $remain.text(`Remaining: ${count - selectedCount}`);
  17072.                                         });
  17073.  
  17074.                                         function getSelected () {
  17075.                                                 return $cbSkill.map((i, e) => ({skill: $(e).data("skill"), selected: $(e).prop("checked")})).get()
  17076.                                                         .filter(it => it.selected).map(it => it.skill);
  17077.                                         }
  17078.  
  17079.                                         $dialog.dialog({
  17080.                                                 dialogClass: "no-close",
  17081.                                                 buttons: [
  17082.                                                         {
  17083.                                                                 text: "Cancel",
  17084.                                                                 click: function () {
  17085.                                                                         $(this).dialog("close");
  17086.                                                                         $dialog.remove();
  17087.                                                                         reject(`User cancelled the prompt`);
  17088.                                                                 }
  17089.                                                         },
  17090.                                                         {
  17091.                                                                 text: "OK",
  17092.                                                                 click: function () {
  17093.                                                                         const selected = getSelected();
  17094.                                                                         if (selected.length === count) {
  17095.                                                                                 $(this).dialog("close");
  17096.                                                                                 $dialog.remove();
  17097.                                                                                 resolve(selected);
  17098.                                                                         } else {
  17099.                                                                                 alert(`Please select ${count} skill${count === 1 ? "" : "s"}`);
  17100.                                                                         }
  17101.                                                                 }
  17102.                                                         }
  17103.                                                 ]
  17104.                                         })
  17105.                                 });
  17106.                         }
  17107.  
  17108.                         async function chooseSkillsGroup (options) {
  17109.                                 return new Promise((resolve, reject) => {
  17110.                                         const $dialog = $(`
  17111.                                                 <div title="Choose Skills">
  17112.                                                         <div>
  17113.                                                                 ${options.map((it, i) => `<label class="split"><input name="skill-group" data-ix="${i}" type="radio" ${i === 0 ? `checked` : ""}> <span>${it}</span></label>`).join("")}
  17114.                                                         </div>
  17115.                                                 </div>
  17116.                                         `).appendTo($("body"));
  17117.                                         const $rdOpt = $dialog.find(`input[type="radio"]`);
  17118.  
  17119.                                         $dialog.dialog({
  17120.                                                 dialogClass: "no-close",
  17121.                                                 buttons: [
  17122.                                                         {
  17123.                                                                 text: "Cancel",
  17124.                                                                 click: function () {
  17125.                                                                         $(this).dialog("close");
  17126.                                                                         $dialog.remove();
  17127.                                                                         reject(`User cancelled the prompt`);
  17128.                                                                 }
  17129.                                                         },
  17130.                                                         {
  17131.                                                                 text: "OK",
  17132.                                                                 click: function () {
  17133.                                                                         const selected = $rdOpt.filter((i, e) => $(e).prop("checked"))
  17134.                                                                                 .map((i, e) => $(e).data("ix")).get()[0];
  17135.                                                                         $(this).dialog("close");
  17136.                                                                         $dialog.remove();
  17137.                                                                         resolve(selected);
  17138.                                                                 }
  17139.                                                         }
  17140.                                                 ]
  17141.                                         })
  17142.                                 });
  17143.                         }
  17144.  
  17145.                         const skills = [];
  17146.  
  17147.                         async function handleSkillsItem (item) {
  17148.                                 Object.keys(item).forEach(k => {
  17149.                                         if (k !== "choose") skills.push(k);
  17150.                                 });
  17151.  
  17152.                                 if (item.choose) {
  17153.                                         const choose = item.choose;
  17154.                                         const sansExisting = choose.from.filter(it => !skills.includes(it));
  17155.                                         const count = choose.count || 1;
  17156.                                         const chosenSkills = await chooseSkills(sansExisting, count);
  17157.                                         chosenSkills.forEach(it => skills.push(it));
  17158.                                 }
  17159.                         }
  17160.  
  17161.                         if (bg.skillProficiencies && bg.skillProficiencies.length) {
  17162.                                 if (bg.skillProficiencies.length > 1) {
  17163.                                         const options = bg.skillProficiencies.map(item => Renderer.background.getSkillSummary([item], true, []))
  17164.                                         const chosenIndex = await chooseSkillsGroup(options);
  17165.                                         await handleSkillsItem(bg.skillProficiencies[chosenIndex]);
  17166.                                 } else {
  17167.                                         await handleSkillsItem(bg.skillProficiencies[0]);
  17168.                                 }
  17169.                         }
  17170.  
  17171.                         const attrs = new CharacterAttributesProxy(character);
  17172.                         const fRowId = d20plus.ut.generateRowId();
  17173.  
  17174.                         if (d20plus.sheet === "ogl") {
  17175.                                 attrs.addOrUpdate("background", bg.name);
  17176.  
  17177.                                 attrs.add(`repeating_traits_${fRowId}_name`, feature.name);
  17178.                                 attrs.add(`repeating_traits_${fRowId}_source`, "Background");
  17179.                                 attrs.add(`repeating_traits_${fRowId}_source_type`, bg.name);
  17180.                                 attrs.add(`repeating_traits_${fRowId}_options-flag`, "0");
  17181.                                 if (feature.text) {
  17182.                                         attrs.add(`repeating_traits_${fRowId}_description`, feature.text);
  17183.                                 }
  17184.  
  17185.                                 skills.map(s => s.toLowerCase().replace(/ /g, "_")).forEach(s => {
  17186.                                         attrs.addOrUpdate(`${s}_prof`, `(@{pb}*@{${s}_type})`);
  17187.                                 });
  17188.                         } else if (d20plus.sheet === "shaped") {
  17189.                                 attrs.addOrUpdate("background", bg.name);
  17190.                                 attrs.add(`repeating_trait_${fRowId}_name`, `${feature.name} (${bg.name})`);
  17191.                                 if (feature.text) {
  17192.                                         attrs.add(`repeating_trait_${fRowId}_content`, feature.text);
  17193.                                         attrs.add(`repeating_trait_${fRowId}_content_toggle`, "1");
  17194.                                 }
  17195.  
  17196.                                 skills.map(s => s.toUpperCase().replace(/ /g, "")).forEach(s => {
  17197.                                         const rowId = attrs.findOrGenerateRepeatingRowId("repeating_skill_$0_storage_name", s);
  17198.                                         attrs.addOrUpdate(`repeating_skill_${rowId}_proficiency`, "proficient");
  17199.                                 });
  17200.                         } else {
  17201.                                 console.warn(`Background import is not supported for ${d20plus.sheet} character sheet`);
  17202.                         }
  17203.  
  17204.                         attrs.notifySheetWorkers();
  17205.                 }
  17206.  
  17207.                 function importRace (character, data) {
  17208.                         const renderer = new Renderer();
  17209.                         renderer.setBaseUrl(BASE_SITE_URL);
  17210.  
  17211.                         const race = data.Vetoolscontent;
  17212.  
  17213.                         race.entries.filter(it => typeof it !== "string").forEach(e => {
  17214.                                 const renderStack = [];
  17215.                                 renderer.recursiveRender({entries: e.entries}, renderStack);
  17216.                                 e.text = d20plus.importer.getCleanText(renderStack.join(""));
  17217.                         });
  17218.  
  17219.                         const attrs = new CharacterAttributesProxy(character);
  17220.  
  17221.                         if (d20plus.sheet === "ogl") {
  17222.                                 attrs.addOrUpdate(`race`, race.name);
  17223.                                 attrs.addOrUpdate(`race_display`, race.name);
  17224.                                 attrs.addOrUpdate(`speed`, Parser.getSpeedString(race));
  17225.  
  17226.                                 race.entries.filter(it => it.text).forEach(e => {
  17227.                                         const fRowId = d20plus.ut.generateRowId();
  17228.                                         attrs.add(`repeating_traits_${fRowId}_name`, e.name);
  17229.                                         attrs.add(`repeating_traits_${fRowId}_source`, "Race");
  17230.                                         attrs.add(`repeating_traits_${fRowId}_source_type`, race.name);
  17231.                                         attrs.add(`repeating_traits_${fRowId}_description`, e.text);
  17232.                                         attrs.add(`repeating_traits_${fRowId}_options-flag`, "0");
  17233.                                 });
  17234.  
  17235.                                 if (race.languageProficiencies && race.languageProficiencies.length) {
  17236.                                         // FIXME this discards information
  17237.                                         const profs = race.languageProficiencies[0];
  17238.                                         const asText = Object.keys(profs).filter(it => it !== "choose").map(it => it === "anyStandard" ? "any" : it).map(it => it.toTitleCase()).join(", ");
  17239.  
  17240.                                         const lRowId = d20plus.ut.generateRowId();
  17241.                                         attrs.add(`repeating_proficiencies_${lRowId}_name`, asText);
  17242.                                         attrs.add(`repeating_proficiencies_${lRowId}_options-flag`, "0");
  17243.                                 }
  17244.                         } else if (d20plus.sheet === "shaped") {
  17245.                                 attrs.addOrUpdate("race", race.name);
  17246.                                 attrs.addOrUpdate("size", Parser.sizeAbvToFull(race.size).toUpperCase());
  17247.                                 attrs.addOrUpdate("speed_string", Parser.getSpeedString(race));
  17248.  
  17249.                                 if (race.speed instanceof Object) {
  17250.                                         for (const locomotion of ["walk", "burrow", "climb", "fly", "swim"]) {
  17251.                                                 if (race.speed[locomotion]) {
  17252.                                                         const attrName = locomotion === "walk" ? "speed" : `speed_${locomotion}`;
  17253.                                                         if (locomotion !== "walk") {
  17254.                                                                 attrs.addOrUpdate("other_speeds", "1");
  17255.                                                         }
  17256.                                                         // note: this doesn't cover hover
  17257.                                                         attrs.addOrUpdate(attrName, race.speed[locomotion]);
  17258.                                                 }
  17259.                                         }
  17260.                                 } else {
  17261.                                         attrs.addOrUpdate("speed", race.speed);
  17262.                                 }
  17263.  
  17264.                                 // really there seems to be only darkvision for PCs
  17265.                                 for (const vision of ["darkvision", "blindsight", "tremorsense", "truesight"]) {
  17266.                                         if (race[vision]) {
  17267.                                                 attrs.addOrUpdate(vision, race[vision]);
  17268.                                         }
  17269.                                 }
  17270.  
  17271.                                 race.entries.filter(it => it.text).forEach(e => {
  17272.                                         const fRowId = d20plus.ut.generateRowId();
  17273.                                         attrs.add(`repeating_racialtrait_${fRowId}_name`, e.name);
  17274.                                         attrs.add(`repeating_racialtrait_${fRowId}_content`, e.text);
  17275.                                         attrs.add(`repeating_racialtrait_${fRowId}_content_toggle`, "1");
  17276.                                 });
  17277.  
  17278.                                 const fRowId = d20plus.ut.generateRowId();
  17279.                                 attrs.add(`repeating_modifier_${fRowId}_name`, race.name);
  17280.                                 attrs.add(`repeating_modifier_${fRowId}_ability_score_toggle`, "1");
  17281.                                 (race.ability || []).forEach(raceAbility => {
  17282.                                         Object.keys(raceAbility).filter(it => it !== "choose").forEach(abilityAbv => {
  17283.                                                 const value = raceAbility[abilityAbv];
  17284.                                                 const ability = Parser.attAbvToFull(abilityAbv).toLowerCase();
  17285.                                                 attrs.add(`repeating_modifier_${fRowId}_${ability}_score_modifier`, value);
  17286.                                         });
  17287.                                 });
  17288.                         } else {
  17289.                                 console.warn(`Race import is not supported for ${d20plus.sheet} character sheet`);
  17290.                         }
  17291.  
  17292.                         attrs.notifySheetWorkers();
  17293.                 }
  17294.  
  17295.                 function importOptionalFeature (character, data) {
  17296.                         const optionalFeature = data.Vetoolscontent;
  17297.                         const renderer = new Renderer();
  17298.                         renderer.setBaseUrl(BASE_SITE_URL);
  17299.                         const rendered = renderer.render({entries: optionalFeature.entries});
  17300.                         const optionalFeatureText = d20plus.importer.getCleanText(rendered);
  17301.  
  17302.                         const attrs = new CharacterAttributesProxy(character);
  17303.                         const fRowId = d20plus.ut.generateRowId();
  17304.  
  17305.                         if (d20plus.sheet == "ogl") {
  17306.                                 attrs.add(`repeating_traits_${fRowId}_name`, optionalFeature.name);
  17307.                                 attrs.add(`repeating_traits_${fRowId}_source`, Parser.optFeatureTypeToFull(optionalFeature.featureType));
  17308.                                 attrs.add(`repeating_traits_${fRowId}_source_type`, optionalFeature.name);
  17309.                                 attrs.add(`repeating_traits_${fRowId}_description`, optionalFeatureText);
  17310.                                 attrs.add(`repeating_traits_${fRowId}_options-flag`, "0");
  17311.                         } else if (d20plus.sheet == "shaped") {
  17312.                                 attrs.add(`repeating_classfeature_${fRowId}_name`, optionalFeature.name);
  17313.                                 attrs.add(`repeating_classfeature_${fRowId}_content`, optionalFeatureText);
  17314.                                 attrs.add(`repeating_classfeature_${fRowId}_content_toggle`, "1");
  17315.                         } else {
  17316.                                 console.warn(`Optional feature (invocation, maneuver, or metamagic) import is not supported for ${d20plus.sheet} character sheet`);
  17317.                         }
  17318.  
  17319.                         attrs.notifySheetWorkers();
  17320.                 }
  17321.  
  17322.                 function importClass (character, data) {
  17323.                         let levels = d20plus.ut.getNumberRange("What levels?", 1, 20);
  17324.                         if (!levels) return;
  17325.  
  17326.                         const maxLevel = Math.max(...levels);
  17327.  
  17328.                         const clss = data.Vetoolscontent;
  17329.                         const renderer = Renderer.get().setBaseUrl(BASE_SITE_URL);
  17330.                         const shapedSheetPreFilledFeaturesByClass = {
  17331.                                 "Artificer": [
  17332.                                         "Magic Item Analysis",
  17333.                                         "Tool Expertise",
  17334.                                         "Wondrous Invention",
  17335.                                         "Infuse Magic",
  17336.                                         "Superior Attunement",
  17337.                                         "Mechanical Servant",
  17338.                                         "Soul of Artifice",
  17339.                                 ],
  17340.                                 "Barbarian": [
  17341.                                         "Rage",
  17342.                                         "Unarmored Defense",
  17343.                                         "Reckless Attack",
  17344.                                         "Danger Sense",
  17345.                                         "Extra Attack",
  17346.                                         "Fast Movement",
  17347.                                         "Feral Instinct",
  17348.                                         "Brutal Critical",
  17349.                                         "Relentless Rage",
  17350.                                         "Persistent Rage",
  17351.                                         "Indomitable Might",
  17352.                                         "Primal Champion",
  17353.                                 ],
  17354.                                 "Bard": [
  17355.                                         "Bardic Inspiration",
  17356.                                         "Jack of All Trades",
  17357.                                         "Song of Rest",
  17358.                                         "Expertise",
  17359.                                         "Countercharm",
  17360.                                         "Magical Secrets",
  17361.                                         "Superior Inspiration",
  17362.                                 ],
  17363.                                 "Cleric": [
  17364.                                         "Channel Divinity",
  17365.                                         "Turn Undead",
  17366.                                         "Divine Intervention",
  17367.                                 ],
  17368.                                 "Druid": [
  17369.                                         "Druidic",
  17370.                                         "Wild Shape",
  17371.                                         "Timeless Body",
  17372.                                         "Beast Spells",
  17373.                                         "Archdruid",
  17374.                                 ],
  17375.                                 "Fighter": [
  17376.                                         "Fighting Style",
  17377.                                         "Second Wind",
  17378.                                         "Action Surge",
  17379.                                         "Extra Attack",
  17380.                                         "Indomitable",
  17381.                                 ],
  17382.                                 "Monk": [
  17383.                                         "Unarmored Defense",
  17384.                                         "Martial Arts",
  17385.                                         "Ki",
  17386.                                         "Flurry of Blows",
  17387.                                         "Patient Defense",
  17388.                                         "Step of the Wind",
  17389.                                         "Unarmored Movement",
  17390.                                         "Deflect Missiles",
  17391.                                         "Slow Fall",
  17392.                                         "Extra Attack",
  17393.                                         "Stunning Strike",
  17394.                                         "Ki-Empowered Strikes",
  17395.                                         "Evasion",
  17396.                                         "Stillness of Mind",
  17397.                                         "Purity of Body",
  17398.                                         "Tongue of the Sun and Moon",
  17399.                                         "Diamond Soul",
  17400.                                         "Timeless Body",
  17401.                                         "Empty Body",
  17402.                                         "Perfect Soul",
  17403.                                 ],
  17404.                                 "Paladin": [
  17405.                                         "Divine Sense",
  17406.                                         "Lay on Hands",
  17407.                                         "Fighting Style",
  17408.                                         "Divine Smite",
  17409.                                         "Divine Health",
  17410.                                         "Channel Divinity",
  17411.                                         "Extra Attack",
  17412.                                         "Aura of Protection",
  17413.                                         "Aura of Courage",
  17414.                                         "Improved Divine Smite",
  17415.                                         "Cleansing Touch",
  17416.                                 ],
  17417.                                 "Ranger": [
  17418.                                         "Favored Enemy",
  17419.                                         "Natural Explorer",
  17420.                                         "Fighting Style",
  17421.                                         "Primeval Awareness",
  17422.                                         "Land’s Stride",
  17423.                                         "Hide in Plain Sight",
  17424.                                         "Vanish",
  17425.                                         "Feral Senses",
  17426.                                         "Foe Slayer",
  17427.                                 ],
  17428.                                 "Ranger (Revised)": [ // "Ranger UA (2016)"
  17429.                                         "Favored Enemy",
  17430.                                         "Natural Explorer",
  17431.                                         "Fighting Style",
  17432.                                         "Primeval Awareness",
  17433.                                         "Greater Favored Enemy",
  17434.                                         "Fleet of Foot",
  17435.                                         "Hide in Plain Sight",
  17436.                                         "Vanish",
  17437.                                         "Feral Senses",
  17438.                                         "Foe Slayer",
  17439.                                 ],
  17440.                                 "Rogue": [
  17441.                                         "Expertise",
  17442.                                         "Sneak Attack",
  17443.                                         "Thieves' Cant",
  17444.                                         "Cunning Action",
  17445.                                         "Uncanny Dodge",
  17446.                                         "Evasion",
  17447.                                         "Reliable Talent",
  17448.                                         "Blindsense",
  17449.                                         "Slippery Mind",
  17450.                                         "Elusive",
  17451.                                         "Stroke of Luck",
  17452.                                 ],
  17453.                                 "Sorcerer": [
  17454.                                         "Sorcery Points",
  17455.                                         "Flexible Casting",
  17456.                                         "Metamagic",
  17457.                                         "Sorcerous Restoration",
  17458.                                 ],
  17459.                                 "Warlock": [
  17460.                                         "Eldritch Invocations",
  17461.                                         "Pact Boon",
  17462.                                         "Mystic Arcanum",
  17463.                                         "Eldritch Master",
  17464.                                 ],
  17465.                                 "Wizard": [
  17466.                                         "Arcane Recovery",
  17467.                                         "Spell Mastery",
  17468.                                         "Signature Spells",
  17469.                                 ],
  17470.                         };
  17471.                         const shapedSheetPreFilledFeatures = shapedSheetPreFilledFeaturesByClass[clss.name] || [];
  17472.  
  17473.                         const attrs = new CharacterAttributesProxy(character);
  17474.  
  17475.                         importClassGeneral(attrs, clss, maxLevel);
  17476.  
  17477.                         for (let i = 0; i < maxLevel; i++) {
  17478.                                 const level = i + 1;
  17479.                                 if (!levels.has(level)) continue;
  17480.  
  17481.                                 const lvlFeatureList = clss.classFeatures[i];
  17482.                                 for (let j = 0; j < lvlFeatureList.length; j++) {
  17483.                                         const feature = lvlFeatureList[j];
  17484.                                         // don't add "you gain a subclass feature" or ASI's
  17485.                                         if (!feature.gainSubclassFeature && feature.name !== "Ability Score Improvement") {
  17486.                                                 const renderStack = [];
  17487.                                                 renderer.recursiveRender({entries: feature.entries}, renderStack);
  17488.                                                 feature.text = d20plus.importer.getCleanText(renderStack.join(""));
  17489.                                                 importClassFeature(attrs, clss, level, feature);
  17490.                                         }
  17491.                                 }
  17492.                         }
  17493.  
  17494.                         function importClassGeneral (attrs, clss, maxLevel) {
  17495.                                 if (d20plus.sheet === "ogl") {
  17496.                                         setTimeout(() => {
  17497.                                                 attrs.addOrUpdate("pb", d20plus.getProfBonusFromLevel(Number(maxLevel)));
  17498.                                                 attrs.addOrUpdate("class", clss.name);
  17499.                                                 attrs.addOrUpdate("level", maxLevel);
  17500.                                                 attrs.addOrUpdate("base_level", String(maxLevel));
  17501.                                         }, 500);
  17502.                                 } else if (d20plus.sheet === "shaped") {
  17503.                                         const isSupportedClass = clss.source === "PHB" || ["Artificer", "Ranger (Revised)"].includes(clss.name);
  17504.                                         let className = "CUSTOM";
  17505.                                         if (isSupportedClass) {
  17506.                                                 className = clss.name.toUpperCase();
  17507.                                                 if (clss.name === "Ranger (Revised)")
  17508.                                                         className = "RANGERUA";
  17509.                                         }
  17510.  
  17511.                                         const fRowId = attrs.findOrGenerateRepeatingRowId("repeating_class_$0_name", className);
  17512.                                         attrs.addOrUpdate(`repeating_class_${fRowId}_name`, className);
  17513.                                         attrs.addOrUpdate(`repeating_class_${fRowId}_level`, maxLevel);
  17514.                                         if (!isSupportedClass) {
  17515.                                                 attrs.addOrUpdate(`repeating_class_${fRowId}_hd`, `d${clss.hd.faces}`);
  17516.                                                 attrs.addOrUpdate(`repeating_class_${fRowId}_custom_class_toggle`, "1");
  17517.                                                 attrs.addOrUpdate(`repeating_class_${fRowId}_custom_name`, clss.name);
  17518.                                         }
  17519.  
  17520.                                         if (!isSupportedClass && clss.name === "Mystic") {
  17521.                                                 const classResourcesForLevel = clss.classTableGroups[0].rows[maxLevel - 1];
  17522.                                                 const [talentsKnown, disciplinesKnown, psiPoints, psiLimit] = classResourcesForLevel;
  17523.  
  17524.                                                 attrs.addOrUpdate("spell_points_name", "PSI");
  17525.                                                 attrs.addOrUpdate("show_spells", "1");
  17526.                                                 attrs.addOrUpdate("spell_points_toggle", "1");
  17527.                                                 attrs.addOrUpdate("spell_ability", "INTELLIGENCE");
  17528.                                                 attrs.addOrUpdate("spell_points_limit", psiLimit);
  17529.                                                 attrs.addOrUpdate("spell_points", psiPoints, psiPoints);
  17530.                                                 // talentsKnown, disciplinesKnown;      // unused
  17531.  
  17532.                                                 for (let i = 1; i <= 7; i++) {
  17533.                                                         attrs.addOrUpdate(`spell_level_${i}_cost`, i);
  17534.                                                 }
  17535.                                                 for (let i = 0; i <= psiLimit; i++) {
  17536.                                                         attrs.addOrUpdate(`spell_level_filter_${i}`, "1");
  17537.                                                 }
  17538.                                         }
  17539.  
  17540.                                         attrs.notifySheetWorkers();
  17541.                                 } else {
  17542.                                         console.warn(`Class import is not supported for ${d20plus.sheet} character sheet`);
  17543.                                 }
  17544.                         }
  17545.  
  17546.                         function importClassFeature (attrs, clss, level, feature) {
  17547.                                 if (d20plus.sheet == "ogl") {
  17548.                                         const fRowId = d20plus.ut.generateRowId();
  17549.                                         attrs.add(`repeating_traits_${fRowId}_name`, feature.name);
  17550.                                         attrs.add(`repeating_traits_${fRowId}_source`, "Class");
  17551.                                         attrs.add(`repeating_traits_${fRowId}_source_type`, `${clss.name} ${level}`);
  17552.                                         attrs.add(`repeating_traits_${fRowId}_description`, feature.text);
  17553.                                         attrs.add(`repeating_traits_${fRowId}_options-flag`, "0");
  17554.                                 } else if (d20plus.sheet == "shaped") {
  17555.                                         if (shapedSheetPreFilledFeatures.includes(feature.name))
  17556.                                                 return;
  17557.  
  17558.                                         const fRowId = d20plus.ut.generateRowId();
  17559.                                         attrs.add(`repeating_classfeature_${fRowId}_name`, `${feature.name} (${clss.name} ${level})`);
  17560.                                         attrs.add(`repeating_classfeature_${fRowId}_content`, feature.text);
  17561.                                         attrs.add(`repeating_classfeature_${fRowId}_content_toggle`, "1");
  17562.                                 }
  17563.  
  17564.                                 attrs.notifySheetWorkers();
  17565.                         }
  17566.                 }
  17567.  
  17568.                 function importSubclass (character, data) {
  17569.                         if (d20plus.sheet != "ogl" && d20plus.sheet != "shaped") {
  17570.                                 console.warn(`Subclass import is not supported for ${d20plus.sheet} character sheet`);
  17571.                                 return;
  17572.                         }
  17573.  
  17574.                         const attrs = new CharacterAttributesProxy(character);
  17575.                         const sc = data.Vetoolscontent;
  17576.  
  17577.                         const desiredIxs = new Set(); // indexes into the subclass feature array
  17578.                         const gainLevels = [];
  17579.  
  17580.                         // _gainAtLevels should be a 20-length array of booleans
  17581.                         if (sc._gainAtLevels) {
  17582.                                 const levels = d20plus.ut.getNumberRange("What levels?", 1, 20);
  17583.                                 if (levels) {
  17584.                                         let scFeatureIndex = 0;
  17585.                                         for (let i = 0; i < 20; i++) {
  17586.                                                 if (sc._gainAtLevels[i]) {
  17587.                                                         if (levels.has(i + 1)) {
  17588.                                                                 desiredIxs.add(scFeatureIndex);
  17589.                                                         }
  17590.                                                         scFeatureIndex++;
  17591.                                                         gainLevels.push(i + 1);
  17592.                                                 }
  17593.                                         }
  17594.                                 } else {
  17595.                                         return;
  17596.                                 }
  17597.                         } else {
  17598.                                 throw new Error("No subclass._gainAtLevels supplied!");
  17599.                         }
  17600.  
  17601.                         if (!desiredIxs.size) {
  17602.                                 alert("No subclass features were found within the range specified.");
  17603.                                 return;
  17604.                         }
  17605.  
  17606.                         const renderer = new Renderer();
  17607.                         renderer.setBaseUrl(BASE_SITE_URL);
  17608.                         let firstFeatures = true;
  17609.                         for (let i = 0; i < sc.subclassFeatures.length; i++) {
  17610.                                 if (!desiredIxs.has(i)) continue;
  17611.  
  17612.                                 const lvlFeatureList = sc.subclassFeatures[i];
  17613.                                 for (let j = 0; j < lvlFeatureList.length; j++) {
  17614.                                         const featureCpy = JSON.parse(JSON.stringify(lvlFeatureList[j]));
  17615.                                         let feature = lvlFeatureList[j];
  17616.  
  17617.                                         try {
  17618.                                                 while (!feature.name || (feature[0] && !feature[0].name)) {
  17619.                                                         if (feature.entries && feature.entries.name) {
  17620.                                                                 feature = feature.entries;
  17621.                                                                 continue;
  17622.                                                         } else if (feature.entries[0] && feature.entries[0].name) {
  17623.                                                                 feature = feature.entries[0];
  17624.                                                                 continue;
  17625.                                                         } else {
  17626.                                                                 feature = feature.entries;
  17627.                                                         }
  17628.  
  17629.                                                         if (!feature) {
  17630.                                                                 // in case something goes wrong, reset break the loop
  17631.                                                                 feature = featureCpy;
  17632.                                                                 break;
  17633.                                                         }
  17634.                                                 }
  17635.                                         } catch (e) {
  17636.                                                 console.error("Failed to find feature");
  17637.                                                 // in case something goes _really_ wrong, reset
  17638.                                                 feature = featureCpy;
  17639.                                         }
  17640.  
  17641.                                         // for the first batch of subclass features, try to split them up
  17642.                                         if (firstFeatures && feature.name && feature.entries) {
  17643.                                                 const subFeatures = [];
  17644.                                                 const baseFeatures = feature.entries.filter(f => {
  17645.                                                         if (f.name && f.type === "entries") {
  17646.                                                                 subFeatures.push(f);
  17647.                                                                 return false;
  17648.                                                         } else return true;
  17649.                                                 });
  17650.                                                 importSubclassFeature(attrs, sc, gainLevels[i],
  17651.                                                                 {name: feature.name, type: feature.type, entries: baseFeatures});
  17652.                                                 subFeatures.forEach(sf => {
  17653.                                                         importSubclassFeature(attrs, sc, gainLevels[i], sf);
  17654.                                                 })
  17655.                                         } else {
  17656.                                                 importSubclassFeature(attrs, sc, gainLevels[i], feature);
  17657.                                         }
  17658.  
  17659.                                         firstFeatures = false;
  17660.                                 }
  17661.                         }
  17662.  
  17663.                         function importSubclassFeature (attrs, sc, level, feature) {
  17664.                                 const renderStack = [];
  17665.                                 renderer.recursiveRender({entries: feature.entries}, renderStack);
  17666.                                 feature.text = d20plus.importer.getCleanText(renderStack.join(""));
  17667.  
  17668.                                 const fRowId = d20plus.ut.generateRowId();
  17669.  
  17670.                                 if (d20plus.sheet == "ogl") {
  17671.                                         attrs.add(`repeating_traits_${fRowId}_name`, feature.name);
  17672.                                         attrs.add(`repeating_traits_${fRowId}_source`, "Class");
  17673.                                         attrs.add(`repeating_traits_${fRowId}_source_type`, `${sc.class} (${sc.name} ${level})`);
  17674.                                         attrs.add(`repeating_traits_${fRowId}_description`, feature.text);
  17675.                                         attrs.add(`repeating_traits_${fRowId}_options-flag`, "0");
  17676.                                 } else if (d20plus.sheet == "shaped") {
  17677.                                         attrs.add(`repeating_classfeature_${fRowId}_name`, `${feature.name} (${sc.name} ${level})`);
  17678.                                         attrs.add(`repeating_classfeature_${fRowId}_content`, feature.text);
  17679.                                         attrs.add(`repeating_classfeature_${fRowId}_content_toggle`, "1");
  17680.                                 }
  17681.  
  17682.                                 attrs.notifySheetWorkers();
  17683.                         }
  17684.                 }
  17685.  
  17686.                 function importPsionicAbility (character, data) {
  17687.                         const renderer = new Renderer();
  17688.                         renderer.setBaseUrl(BASE_SITE_URL);
  17689.  
  17690.                         const attrs = new CharacterAttributesProxy(character);
  17691.                         data = data.Vetoolscontent;
  17692.                         if (!data) {
  17693.                                 alert("Missing data. Please re-import Psionics.");
  17694.                                 return;
  17695.                         }
  17696.  
  17697.                         function getCostStr (cost) {
  17698.                                 return cost.min === cost.max ? cost.min : `${cost.min}-${cost.max}`;
  17699.                         }
  17700.  
  17701.                         function getCleanText (entries) {
  17702.                                 if (typeof entries == "string") {
  17703.                                         return d20plus.importer.getCleanText(renderer.render(entries));
  17704.                                 } else {
  17705.                                         const renderStack = [];
  17706.                                         renderer.recursiveRender({entries: entries}, renderStack, {depth: 2});
  17707.                                         return d20plus.importer.getCleanText(renderStack.join(""));
  17708.                                 }
  17709.                         }
  17710.  
  17711.                         if (d20plus.sheet == "ogl") {
  17712.                                 const makeSpellTrait = function (level, rowId, propName, content) {
  17713.                                         const attrName = `repeating_spell-${level}_${rowId}_${propName}`;
  17714.                                         attrs.add(attrName, content);
  17715.                                 }
  17716.  
  17717.                                 // disable all components
  17718.                                 const noComponents = function (level, rowId, hasM) {
  17719.                                         makeSpellTrait(level, rowId, "spellcomp_v", 0);
  17720.                                         makeSpellTrait(level, rowId, "spellcomp_s", 0);
  17721.                                         if (!hasM) {
  17722.                                                 makeSpellTrait(level, rowId, "spellcomp_m", 0);
  17723.                                         }
  17724.                                         makeSpellTrait(level, rowId, "options-flag", 0);
  17725.                                 }
  17726.  
  17727.                                 if (data.type === "D") {
  17728.                                         const rowId = d20plus.ut.generateRowId();
  17729.  
  17730.                                         // make focus
  17731.                                         const focusLevel = "cantrip";
  17732.                                         makeSpellTrait(focusLevel, rowId, "spelllevel", "cantrip");
  17733.                                         makeSpellTrait(focusLevel, rowId, "spellname", `${data.name} Focus`);
  17734.                                         makeSpellTrait(focusLevel, rowId, "spelldescription", getCleanText(data.focus));
  17735.                                         makeSpellTrait(focusLevel, rowId, "spellcastingtime", "1 bonus action");
  17736.                                         noComponents(focusLevel, rowId);
  17737.  
  17738.                                         data.modes.forEach(m => {
  17739.                                                 if (m.submodes) {
  17740.                                                         m.submodes.forEach(sm => {
  17741.                                                                 const rowId = d20plus.ut.generateRowId();
  17742.                                                                 const smLevel = sm.cost.min;
  17743.                                                                 makeSpellTrait(smLevel, rowId, "spelllevel", smLevel);
  17744.                                                                 makeSpellTrait(smLevel, rowId, "spellname", `${m.name} (${sm.name})`);
  17745.                                                                 makeSpellTrait(smLevel, rowId, "spelldescription", getCleanText(sm.entries));
  17746.                                                                 makeSpellTrait(smLevel, rowId, "spellcomp_materials", `${getCostStr(sm.cost)} psi points`);
  17747.                                                                 noComponents(smLevel, rowId, true);
  17748.                                                         });
  17749.                                                 } else {
  17750.                                                         const rowId = d20plus.ut.generateRowId();
  17751.                                                         const mLevel = m.cost.min;
  17752.                                                         makeSpellTrait(mLevel, rowId, "spelllevel", mLevel);
  17753.                                                         makeSpellTrait(mLevel, rowId, "spellname", `${m.name}`);
  17754.                                                         makeSpellTrait(mLevel, rowId, "spelldescription", `Psionic Discipline mode\n\n${getCleanText(m.entries)}`);
  17755.                                                         makeSpellTrait(mLevel, rowId, "spellcomp_materials", `${getCostStr(m.cost)} psi points`);
  17756.                                                         if (m.concentration) {
  17757.                                                                 makeSpellTrait(mLevel, rowId, "spellduration", `${m.concentration.duration} ${m.concentration.unit}`);
  17758.                                                                 makeSpellTrait(mLevel, rowId, "spellconcentration", "Yes");
  17759.                                                         }
  17760.                                                         noComponents(mLevel, rowId, true);
  17761.                                                 }
  17762.                                         });
  17763.                                 } else {
  17764.                                         const rowId = d20plus.ut.generateRowId();
  17765.                                         const level = "cantrip";
  17766.                                         makeSpellTrait(level, rowId, "spelllevel", "cantrip");
  17767.                                         makeSpellTrait(level, rowId, "spellname", data.name);
  17768.                                         makeSpellTrait(level, rowId, "spelldescription", `Psionic Talent\n\n${getCleanText(Renderer.psionic.getTalentText(data, renderer))}`);
  17769.                                         noComponents(level, rowId, false);
  17770.                                 }
  17771.                         } else if (d20plus.sheet == "shaped") {
  17772.                                 const makeSpellTrait = function (level, rowId, propName, content) {
  17773.                                         const attrName = `repeating_spell${level}_${rowId}_${propName}`;
  17774.                                         attrs.add(attrName, content);
  17775.                                 }
  17776.  
  17777.                                 const shapedSpellLevel = function (level) {
  17778.                                         return level ? `${Parser.getOrdinalForm(String(level))}_LEVEL`.toUpperCase() : "CANTRIP";
  17779.                                 }
  17780.  
  17781.                                 const shapedConcentration = function (conc) {
  17782.                                         const CONC_ABV_TO_FULL = {
  17783.                                                 rnd: "round",
  17784.                                                 min: "minute",
  17785.                                                 hr: "hour",
  17786.                                         };
  17787.                                         return `CONCENTRATION_UP_TO_${conc.duration}_${CONC_ABV_TO_FULL[conc.unit]}${conc.duration > 1 ? "S" : ""}`.toUpperCase();
  17788.                                 }
  17789.  
  17790.                                 const inferCastingTime = function (content) {
  17791.                                         if (content.search(/\b(as an action)\b/i) >= 0) {
  17792.                                                 return "1_ACTION";
  17793.                                         } else if (content.search(/\b(as a bonus action)\b/i) >= 0) {
  17794.                                                 return "1_BONUS_ACTION";
  17795.                                         } else if (content.search(/\b(as a reaction)\b/i) >= 0) {
  17796.                                                 return "1_REACTION";
  17797.                                         }
  17798.                                         return "1_ACTION";
  17799.                                 }
  17800.  
  17801.                                 const inferDuration = function (content) {
  17802.                                         let duration, unit, match;
  17803.                                         if ((match = content.match(/\b(?:for the next|for 1) (round|minute|hour)\b/i))) {
  17804.                                                 [duration, unit] = [1, match[1]];
  17805.                                         } else if ((match = content.match(/\b(?:for|for the next) (\d+) (minutes|hours|days)\b/i))) {
  17806.                                                 [duration, unit] = [match[1], match[2]];
  17807.                                         }
  17808.  
  17809.                                         return (duration && unit) ? `${duration}_${unit}`.toUpperCase() : `INSTANTANEOUS`;
  17810.                                 }
  17811.  
  17812.                                 if (data.type === "D") {
  17813.                                         const typeStr = `**Psionic Discipline:** ${data.name}\n**Psionic Order:** ${data.order}\n`;
  17814.                                         const rowId = d20plus.ut.generateRowId();
  17815.  
  17816.                                         // make focus
  17817.                                         const focusLevel = 0;
  17818.                                         makeSpellTrait(focusLevel, rowId, "spell_level", shapedSpellLevel(focusLevel));
  17819.                                         makeSpellTrait(focusLevel, rowId, "name", `${data.name} Focus`);
  17820.                                         makeSpellTrait(focusLevel, rowId, "content", `${typeStr}\n${getCleanText(data.focus)}`);
  17821.                                         makeSpellTrait(focusLevel, rowId, "content_toggle", "1");
  17822.                                         makeSpellTrait(focusLevel, rowId, "casting_time", "1_BONUS_ACTION");
  17823.                                         makeSpellTrait(focusLevel, rowId, "components", "COMPONENTS_M");
  17824.                                         makeSpellTrait(focusLevel, rowId, "duration", "SPECIAL");
  17825.  
  17826.                                         data.modes.forEach(m => {
  17827.                                                 const modeContent = `${typeStr}\n${getCleanText(m.entries)}`;
  17828.  
  17829.                                                 if (m.submodes) {
  17830.                                                         m.submodes.forEach(sm => {
  17831.                                                                 const rowId = d20plus.ut.generateRowId();
  17832.                                                                 const smLevel = sm.cost.min;
  17833.                                                                 const costStr = getCostStr(sm.cost);
  17834.                                                                 const content = `${modeContent}\n${getCleanText(sm.entries)}`;
  17835.                                                                 makeSpellTrait(smLevel, rowId, "spell_level", shapedSpellLevel(smLevel));
  17836.                                                                 makeSpellTrait(smLevel, rowId, "name", `${m.name} (${sm.name})` + (sm.cost.min < sm.cost.max ? ` (${costStr} psi)` : ""));
  17837.                                                                 makeSpellTrait(smLevel, rowId, "content", content);
  17838.                                                                 makeSpellTrait(smLevel, rowId, "content_toggle", "1");
  17839.                                                                 makeSpellTrait(smLevel, rowId, "casting_time", inferCastingTime(content));
  17840.                                                                 makeSpellTrait(smLevel, rowId, "materials", `${costStr} psi points`);
  17841.                                                                 makeSpellTrait(smLevel, rowId, "components", "COMPONENTS_M");
  17842.                                                                 makeSpellTrait(smLevel, rowId, "duration", inferDuration(content));
  17843.                                                         });
  17844.                                                 } else {
  17845.                                                         const rowId = d20plus.ut.generateRowId();
  17846.                                                         const mLevel = m.cost.min;
  17847.                                                         const costStr = getCostStr(m.cost);
  17848.                                                         makeSpellTrait(mLevel, rowId, "spell_level", shapedSpellLevel(mLevel));
  17849.                                                         makeSpellTrait(mLevel, rowId, "name", m.name + (m.cost.min < m.cost.max ? ` (${costStr} psi)` : ""));
  17850.                                                         makeSpellTrait(mLevel, rowId, "content", modeContent);
  17851.                                                         makeSpellTrait(mLevel, rowId, "content_toggle", "1");
  17852.                                                         makeSpellTrait(mLevel, rowId, "casting_time", inferCastingTime(modeContent));
  17853.                                                         makeSpellTrait(mLevel, rowId, "materials", `${costStr} psi points`);
  17854.                                                         makeSpellTrait(mLevel, rowId, "components", "COMPONENTS_M");
  17855.                                                         if (m.concentration) {
  17856.                                                                 makeSpellTrait(mLevel, rowId, "duration", shapedConcentration(m.concentration));
  17857.                                                                 makeSpellTrait(mLevel, rowId, "concentration", "Yes");
  17858.                                                         } else {
  17859.                                                                 makeSpellTrait(mLevel, rowId, "duration", inferDuration(modeContent));
  17860.                                                         }
  17861.                                                 }
  17862.                                         });
  17863.                                 } else {
  17864.                                         const typeStr = `**Psionic Talent**\n`;
  17865.                                         const talentContent = `${typeStr}\n${getCleanText(Renderer.psionic.getTalentText(data, renderer))}`;
  17866.                                         const rowId = d20plus.ut.generateRowId();
  17867.                                         const level = 0;
  17868.                                         makeSpellTrait(level, rowId, "spell_level", shapedSpellLevel(level));
  17869.                                         makeSpellTrait(level, rowId, "name", data.name);
  17870.                                         makeSpellTrait(level, rowId, "content", talentContent);
  17871.                                         makeSpellTrait(level, rowId, "content_toggle", "1");
  17872.                                         makeSpellTrait(level, rowId, "casting_time", inferCastingTime(talentContent));
  17873.                                         makeSpellTrait(level, rowId, "components", "COMPONENTS_M");
  17874.                                         makeSpellTrait(level, rowId, "duration", inferDuration(talentContent));
  17875.                                 }
  17876.                         } else {
  17877.                                 console.warn(`Psionic ability import is not supported for ${d20plus.sheet} character sheet`);
  17878.                         }
  17879.  
  17880.                         attrs.notifySheetWorkers();
  17881.                 }
  17882.  
  17883.                 function importItem (character, data, event) {
  17884.                         if (d20plus.sheet == "ogl") {
  17885.                                 if (data.data._versatile) {
  17886.                                         setTimeout(() => {
  17887.                                                 const rowId = d20plus.ut.generateRowId();
  17888.  
  17889.                                                 function makeItemTrait (key, val) {
  17890.                                                         const toSave = character.model.attribs.create({
  17891.                                                                 name: `repeating_attack_${rowId}_${key}`,
  17892.                                                                 current: val
  17893.                                                         }).save();
  17894.                                                         toSave.save();
  17895.                                                 }
  17896.  
  17897.                                                 const attr = (data.data["Item Type"] || "").includes("Melee") ? "strength" : "dexterity";
  17898.                                                 const attrTag = `@{${attr}_mod}`;
  17899.  
  17900.                                                 const proficiencyBonus = character.model.attribs.toJSON().find(it => it.name.includes("pb"));
  17901.                                                 const attrToFind = character.model.attribs.toJSON().find(it => it.name === attr);
  17902.                                                 const attrBonus = attrToFind ? Parser.getAbilityModNumber(Number(attrToFind.current)) : 0;
  17903.  
  17904.                                                 // This links the item to the attack, and vice-versa.
  17905.                                                 // Unfortunately, it doesn't work,
  17906.                                                 //   because Roll20 thinks items<->attacks is a 1-to-1 relationship.
  17907.                                                 /*
  17908.                                                 let lastItemId = null;
  17909.                                                 try {
  17910.                                                         const items = character.model.attribs.toJSON().filter(it => it.name.includes("repeating_inventory"));
  17911.                                                         const lastItem = items[items.length - 1];
  17912.                                                         lastItemId = lastItem.name.replace(/repeating_inventory_/, "").split("_")[0];
  17913.  
  17914.                                                         // link the inventory item to this attack
  17915.                                                         const toSave = character.model.attribs.create({
  17916.                                                                 name: `repeating_inventory_${lastItemId}_itemattackid`,
  17917.                                                                 current: rowId
  17918.                                                         });
  17919.                                                         toSave.save();
  17920.                                                 } catch (ex) {
  17921.                                                         console.error("Failed to get last item ID");
  17922.                                                         console.error(ex);
  17923.                                                 }
  17924.  
  17925.                                                 if (lastItemId) {
  17926.                                                         makeItemTrait("itemid", lastItemId);
  17927.                                                 }
  17928.                                                 */
  17929.  
  17930.                                                 makeItemTrait("options-flag", "0");
  17931.                                                 makeItemTrait("atkname", data.name);
  17932.                                                 makeItemTrait("dmgbase", data.data._versatile);
  17933.                                                 makeItemTrait("dmgtype", data.data["Damage Type"]);
  17934.                                                 makeItemTrait("atkattr_base", attrTag);
  17935.                                                 makeItemTrait("dmgattr", attrTag);
  17936.                                                 makeItemTrait("rollbase_dmg", `@{wtype}&{template:dmg} {{rname=@{atkname}}} @{atkflag} {{range=@{atkrange}}} @{dmgflag} {{dmg1=[[${data.data._versatile}+${attrBonus}]]}} {{dmg1type=${data.data["Damage Type"]} }} @{dmg2flag} {{dmg2=[[0]]}} {{dmg2type=}} @{saveflag} {{desc=@{atk_desc}}} @{hldmg} {{spelllevel=@{spelllevel}}} {{innate=@{spell_innate}}} {{globaldamage=[[0]]}} {{globaldamagetype=@{global_damage_mod_type}}} @{charname_output}`);
  17937.                                                 makeItemTrait("rollbase_crit", `@{wtype}&{template:dmg} {{crit=1}} {{rname=@{atkname}}} @{atkflag} {{range=@{atkrange}}} @{dmgflag} {{dmg1=[[${data.data._versatile}+${attrBonus}]]}} {{dmg1type=${data.data["Damage Type"]} }} @{dmg2flag} {{dmg2=[[0]]}} {{dmg2type=}} {{crit1=[[${data.data._versatile}]]}} {{crit2=[[0]]}} @{saveflag} {{desc=@{atk_desc}}} @{hldmg}  {{spelllevel=@{spelllevel}}} {{innate=@{spell_innate}}} {{globaldamage=[[0]]}} {{globaldamagecrit=[[0]]}} {{globaldamagetype=@{global_damage_mod_type}}} @{charname_output}`);
  17938.                                                 if (proficiencyBonus) {
  17939.                                                         makeItemTrait("atkbonus", `+${Number(proficiencyBonus.current) + attrBonus}`);
  17940.                                                 }
  17941.                                                 makeItemTrait("atkdmgtype", `${data.data._versatile}${attrBonus > 0 ? `+${attrBonus}` : attrBonus < 0 ? attrBonus : ""} ${data.data["Damage Type"]}`);
  17942.                                                 makeItemTrait("rollbase", "@{wtype}&{template:atk} {{mod=@{atkbonus}}} {{rname=[@{atkname}](~repeating_attack_attack_dmg)}} {{rnamec=[@{atkname}](~repeating_attack_attack_crit)}} {{r1=[[@{d20}cs>@{atkcritrange} + 2[PROF]]]}} @{rtype}cs>@{atkcritrange} + 2[PROF]]]}} {{range=@{atkrange}}} {{desc=@{atk_desc}}} {{spelllevel=@{spelllevel}}} {{innate=@{spell_innate}}} {{globalattack=@{global_attack_mod}}} ammo=@{ammo} @{charname_output}");
  17943.                                         }, 350); // defer this, so we can hopefully pull item ID
  17944.                                 }
  17945.  
  17946.                                 // for packs, etc
  17947.                                 if (data._subItems) {
  17948.                                         const queue = [];
  17949.                                         data._subItems.forEach(si => {
  17950.                                                 function makeProp (rowId, propName, content) {
  17951.                                                         character.model.attribs.create({
  17952.                                                                 "name": `repeating_inventory_${rowId}_${propName}`,
  17953.                                                                 "current": content
  17954.                                                         }).save();
  17955.                                                 }
  17956.  
  17957.                                                 if (si.count) {
  17958.                                                         const rowId = d20plus.ut.generateRowId();
  17959.                                                         const siD = typeof si.subItem === "string" ? JSON.parse(si.subItem) : si.subItem;
  17960.  
  17961.                                                         makeProp(rowId, "itemname", siD.name);
  17962.                                                         const w = (siD.data || {}).Weight;
  17963.                                                         if (w) makeProp(rowId, "itemweight", w);
  17964.                                                         makeProp(rowId, "itemcontent", Object.entries(siD.data).map(([k, v]) => `${k}: ${v}`).join(", "));
  17965.                                                         makeProp(rowId, "itemcount", String(si.count));
  17966.  
  17967.                                                 } else {
  17968.                                                         queue.push(si.subItem);
  17969.                                                 }
  17970.                                         });
  17971.  
  17972.                                         const interval = d20plus.cfg.get("import", "importIntervalHandout") || d20plus.cfg.getDefault("import", "importIntervalHandout");
  17973.                                         queue.map(it => typeof it === "string" ? JSON.parse(it) : it).forEach((item, ix) => {
  17974.                                                 setTimeout(() => {
  17975.                                                         d20plus.importer.doFakeDrop(event, character, item, null);
  17976.                                                 }, (ix + 1) * interval);
  17977.                                         });
  17978.  
  17979.                                         return;
  17980.                                 }
  17981.                         }
  17982.  
  17983.                         // Fallback to native drag-n-drop
  17984.                         d20plus.importer.doFakeDrop(event, character, data, null);
  17985.                 }
  17986.  
  17987.                 function importData (character, data, event) {
  17988.                         // TODO remove feature import workarounds below when roll20 and sheets supports their drag-n-drop properly
  17989.                         if (data.data.Category === "Feats") {
  17990.                                 importFeat(character, data);
  17991.                         } else if (data.data.Category === "Backgrounds") {
  17992.                                 importBackground(character, data);
  17993.                         } else if (data.data.Category === "Races") {
  17994.                                 importRace(character, data);
  17995.                         } else if (data.data.Category === "Optional Features") {
  17996.                                 importOptionalFeature(character, data);
  17997.                         } else if (data.data.Category === "Classes") {
  17998.                                 importClass(character, data);
  17999.                         } else if (data.data.Category === "Subclasses") {
  18000.                                 importSubclass(character, data);
  18001.                         } else if (data.data.Category === "Psionics") {
  18002.                                 importPsionicAbility(character, data);
  18003.                         } else if (data.data.Category === "Items") {
  18004.                                 importItem(character, data, event);
  18005.                         } else {
  18006.                                 d20plus.importer.doFakeDrop(event, character, data, null);
  18007.                         }
  18008.                 }
  18009.  
  18010.                 d20.Campaign.characters.models.each(function (v, i) {
  18011.                         v.view.rebindCompendiumDropTargets = function () {
  18012.                                 // ready character sheet for draggable
  18013.                                 $(".sheet-compendium-drop-target").each(function () {
  18014.                                         $(this).droppable({
  18015.                                                 hoverClass: "dropping",
  18016.                                                 tolerance: "pointer",
  18017.                                                 activeClass: "active-drop-target",
  18018.                                                 accept: ".compendium-item",
  18019.                                                 drop: function (t, i) {
  18020.                                                         var characterid = $(".characterdialog").has(t.target).attr("data-characterid");
  18021.                                                         var character = d20.Campaign.characters.get(characterid).view;
  18022.                                                         var inputData;
  18023.                                                         const $hlpr = $(i.helper[0]);
  18024.  
  18025.                                                         if ($hlpr.hasClass("handout")) {
  18026.                                                                 console.log("Handout item dropped onto target!");
  18027.                                                                 t.originalEvent.dropHandled = !0;
  18028.  
  18029.                                                                 if ($hlpr.hasClass(`player-imported`)) {
  18030.                                                                         const data = d20plus.importer.retrievePlayerImport($hlpr.attr("data-playerimportid"));
  18031.                                                                         importData(character, data, t);
  18032.                                                                 } else {
  18033.                                                                         var id = $hlpr.attr("data-itemid");
  18034.                                                                         var handout = d20.Campaign.handouts.get(id);
  18035.                                                                         console.log(character);
  18036.                                                                         var data = "";
  18037.                                                                         if (window.is_gm) {
  18038.                                                                                 handout._getLatestBlob("gmnotes", function (gmnotes) {
  18039.                                                                                         data = gmnotes;
  18040.                                                                                         handout.updateBlobs({gmnotes: gmnotes});
  18041.                                                                                         importData(character, JSON.parse(data), t);
  18042.                                                                                 });
  18043.                                                                         } else {
  18044.                                                                                 handout._getLatestBlob("notes", function (notes) {
  18045.                                                                                         data = $(notes).filter("del").html();
  18046.                                                                                         importData(character, JSON.parse(data), t);
  18047.                                                                                 });
  18048.                                                                         }
  18049.                                                                 }
  18050.                                                         } else {
  18051.                                                                 // rename some variables...
  18052.                                                                 const e = character;
  18053.                                                                 const n = i;
  18054.  
  18055.                                                                 // BEGIN ROLL20 CODE
  18056.                                                                 console.log("Compendium item dropped onto target!"),
  18057.                                                                         t.originalEvent.dropHandled = !0,
  18058.                                                                         window.wantsToReceiveDrop(this, t, function() {
  18059.                                                                                 var i = $(n.helper[0]).attr("data-pagename");
  18060.                                                                                 console.log(d20.compendium.compendiumBase + "compendium/" + COMPENDIUM_BOOK_NAME + "/" + i + ".json?plaintext=true"),
  18061.                                                                                         $.get(d20.compendium.compendiumBase + "compendium/" + COMPENDIUM_BOOK_NAME + "/" + i + ".json?plaintext=true", function(n) {
  18062.                                                                                                 var o = _.clone(n.data);
  18063.                                                                                                 o.Name = n.name,
  18064.                                                                                                         o.data = JSON.stringify(n.data),
  18065.                                                                                                         o.uniqueName = i,
  18066.                                                                                                         o.Content = n.content,
  18067.                                                                                                         $(t.target).find("*[accept]").each(function() {
  18068.                                                                                                                 var t = $(this)
  18069.                                                                                                                         , n = t.attr("accept");
  18070.                                                                                                                 o[n] && ("input" === t[0].tagName.toLowerCase() && "checkbox" === t.attr("type") ? t.val() == o[n] ? t.prop("checked", !0) : t.prop("checked", !1) : "input" === t[0].tagName.toLowerCase() && "radio" === t.attr("type") ? t.val() == o[n] ? t.prop("checked", !0) : t.prop("checked", !1) : "select" === t[0].tagName.toLowerCase() ? t.find("option").each(function() {
  18071.                                                                                                                         var e = $(this);
  18072.                                                                                                                         e.val() !== o[n] && e.text() !== o[n] || e.prop("selected", !0)
  18073.                                                                                                                 }) : $(this).val(o[n]),
  18074.                                                                                                                         e.saveSheetValues(this))
  18075.                                                                                                         })
  18076.                                                                                         })
  18077.                                                                         });
  18078.                                                                 // END ROLL20 CODE
  18079.                                                         }
  18080.                                                 }
  18081.                                         });
  18082.                                 });
  18083.                         };
  18084.                 });
  18085.         };
  18086.  
  18087.         d20plus.getProfBonusFromLevel = function (level) {
  18088.                 if (level < 5) return "2";
  18089.                 if (level < 9) return "3";
  18090.                 if (level < 13) return "4";
  18091.                 if (level < 17) return "5";
  18092.                 return "6";
  18093.         };
  18094.  
  18095.         // Import dialog showing names of monsters failed to import
  18096.         d20plus.addImportError = function (name) {
  18097.                 var $span = $("#import-errors");
  18098.                 if ($span.text() == "0") {
  18099.                         $span.text(name);
  18100.                 } else {
  18101.                         $span.text($span.text() + ", " + name);
  18102.                 }
  18103.         };
  18104.  
  18105.         // Get NPC size from chr
  18106.         d20plus.getSizeString = function (chr) {
  18107.                 const result = Parser.sizeAbvToFull(chr);
  18108.                 return result ? result : "(Unknown Size)";
  18109.         };
  18110.  
  18111.         // Create editable HP variable and autocalculate + or -
  18112.         d20plus.hpAllowEdit = function () {
  18113.                 $("#initiativewindow").on(window.mousedowntype, ".hp.editable", function () {
  18114.                         if ($(this).find("input").length > 0) return void $(this).find("input").focus();
  18115.                         var val = $.trim($(this).text());
  18116.                         const $span = $(this);
  18117.                         $span.html(`<input type='text' value='${val}'/>`);
  18118.                         const $ipt = $(this).find("input");
  18119.                         $ipt[0].focus();
  18120.                 });
  18121.                 $("#initiativewindow").on("keydown", ".hp.editable", function (event) {
  18122.                         if (event.which == 13) {
  18123.                                 const $span = $(this);
  18124.                                 const $ipt = $span.find("input");
  18125.                                 if (!$ipt.length) return;
  18126.  
  18127.                                 var el, token, id, char, hp,
  18128.                                         val = $.trim($ipt.val());
  18129.  
  18130.                                 // roll20 token modification supports plus/minus for a single integer; mimic this
  18131.                                 const m = /^((\d+)?([+-]))?(\d+)$/.exec(val);
  18132.                                 if (m) {
  18133.                                         let op = null;
  18134.                                         if (m[3]) {
  18135.                                                 op = m[3] === "+" ? "ADD" : "SUB";
  18136.                                         }
  18137.                                         const base = m[2] ? eval(m[0]) : null;
  18138.                                         const mod = Number(m[4]);
  18139.  
  18140.                                         el = $(this).parents("li.token");
  18141.                                         id = el.data("tokenid");
  18142.                                         token = d20.Campaign.pages.get(d20.Campaign.activePage()).thegraphics.get(id);
  18143.                                         char = token.character;
  18144.  
  18145.                                         npc = char.attribs ? char.attribs.find(function (a) {
  18146.                                                 return a.get("name").toLowerCase() === "npc";
  18147.                                         }) : null;
  18148.                                         let total;
  18149.                                         // char.attribs doesn't exist for generico tokens, in this case stick stuff in an appropriate bar
  18150.                                         if (!char.attribs || npc && npc.get("current") == "1") {
  18151.                                                 const hpBar = d20plus.getCfgHpBarNumber();
  18152.                                                 if (hpBar) {
  18153.                                                         total;
  18154.                                                         if (base !== null) {
  18155.                                                                 total = base;
  18156.                                                         } else if (op) {
  18157.                                                                 const curr = token.attributes[`bar${hpBar}_value`];
  18158.                                                                 if (op === "ADD") total = curr + mod;
  18159.                                                                 else total = curr - mod;
  18160.                                                         } else {
  18161.                                                                 total = mod;
  18162.                                                         }
  18163.                                                         token.attributes[`bar${hpBar}_value`] = total;
  18164.                                                 }
  18165.                                         } else {
  18166.                                                 hp = char.attribs.find(function (a) {
  18167.                                                         return a.get("name").toLowerCase() === "hp";
  18168.                                                 });
  18169.                                                 if (hp) {
  18170.                                                         total;
  18171.                                                         if (base !== null) {
  18172.                                                                 total = base;
  18173.                                                         } else if (op) {
  18174.                                                                 if (op === "ADD") total = hp.attributes.current + mod;
  18175.                                                                 else total = hp.attributes.current - mod;
  18176.                                                         } else {
  18177.                                                                 total = mod;
  18178.                                                         }
  18179.                                                         hp.syncedSave({current: total});
  18180.                                                 } else {
  18181.                                                         total;
  18182.                                                         if (base !== null) {
  18183.                                                                 total = base;
  18184.                                                         } else if (op) {
  18185.                                                                 if (op === "ADD") total = mod;
  18186.                                                                 else total = 0 - mod;
  18187.                                                         } else {
  18188.                                                                 total = mod;
  18189.                                                         }
  18190.                                                         char.attribs.create({name: "hp", current: total});
  18191.                                                 }
  18192.                                         }
  18193.                                         // convert the field back to text
  18194.                                         $span.html(total);
  18195.                                 }
  18196.                                 d20.Campaign.initiativewindow.rebuildInitiativeList();
  18197.                         }
  18198.                 });
  18199.         };
  18200.  
  18201. // Change character sheet formulas
  18202.         d20plus.setSheet = function () {
  18203.                 d20plus.ut.log("Switched Character Sheet Template")
  18204.                 d20plus.sheet = "ogl";
  18205.                 if (window.is_gm && (!d20.journal.customSheets || !d20.journal.customSheets)) {
  18206.                         d20.textchat.incoming(false, ({
  18207.                                 who: "system",
  18208.                                 type: "system",
  18209.                                 content: `<span style="color: red;">5etoolsR20: no character sheet selected! Exiting...</span>`
  18210.                         }));
  18211.                         throw new Error("No character sheet selected!");
  18212.                 }
  18213.                 if (d20.journal.customSheets.layouthtml.indexOf("shaped_d20") > 0) d20plus.sheet = "shaped";
  18214.                 if (d20.journal.customSheets.layouthtml.indexOf("DnD5e_Character_Sheet") > 0) d20plus.sheet = "community";
  18215.                 d20plus.ut.log("Switched Character Sheet Template to " + d20plus.sheet);
  18216.         };
  18217.  
  18218.         // Return Initiative Tracker template with formulas
  18219.         d20plus.initErrorHandler = null;
  18220.         d20plus.setTurnOrderTemplate = function () {
  18221.                 if (!d20plus.turnOrderCachedFunction) {
  18222.                         d20plus.turnOrderCachedFunction = d20.Campaign.initiativewindow.rebuildInitiativeList;
  18223.                         d20plus.turnOrderCachedTemplate = $("#tmpl_initiativecharacter").clone();
  18224.                 }
  18225.  
  18226.                 d20.Campaign.initiativewindow.rebuildInitiativeList = function () {
  18227.                         var html = d20plus.initiativeTemplate;
  18228.                         var columnsAdded = [];
  18229.                         $(".tracker-header-extra-columns").empty();
  18230.  
  18231.                         const cols = [
  18232.                                 d20plus.cfg.get("interface", "trackerCol1"),
  18233.                                 d20plus.cfg.get("interface", "trackerCol2"),
  18234.                                 d20plus.cfg.get("interface", "trackerCol3")
  18235.                         ];
  18236.  
  18237.                         const headerStack = [];
  18238.                         const replaceStack = [
  18239.                                 // this is hidden by CSS
  18240.                                 `<span class='cr' alt='CR' title='CR'>
  18241.                                         <$ if(npc && npc.get("current") == "1") { $>
  18242.                                                 <$ var crAttr = char.attribs.find(function(e) { return e.get("name").toLowerCase() === "npc_challenge" }); $>
  18243.                                                 <$ if(crAttr) { $>
  18244.                                                         <$!crAttr.get("current")$>
  18245.                                                 <$ } $>
  18246.                                         <$ } $>
  18247.                                 </span>`
  18248.                         ];
  18249.                         cols.forEach((c, i) => {
  18250.                                 switch (c) {
  18251.                                         case "HP": {
  18252.                                                 const hpBar = d20plus.getCfgHpBarNumber();
  18253.                                                 replaceStack.push(`
  18254.                                                         <span class='hp editable tracker-col' alt='HP' title='HP'>
  18255.                                                                 <$ if(npc && npc.get("current") == "1") { $>
  18256.                                                                         ${hpBar ? `<$!token.attributes.bar${hpBar}_value$>` : ""}
  18257.                                                                 <$ } else if (typeof char !== "undefined" && char && typeof char.autoCalcFormula !== "undefined") { $>
  18258.                                                                         <$!char.autoCalcFormula('${d20plus.formulas[d20plus.sheet].hp}')$>
  18259.                                                                 <$ } else { $>
  18260.                                                                         <$!"\u2014"$>
  18261.                                                                 <$ } $>
  18262.                                                         </span>
  18263.                                                 `);
  18264.                                                 headerStack.push(`<span class='tracker-col'>HP</span>`);
  18265.                                                 break;
  18266.                                         }
  18267.                                         case "AC": {
  18268.                                                 replaceStack.push(`
  18269.                                                         <span class='ac tracker-col' alt='AC' title='AC'>
  18270.                                                                 <$ if(npc && npc.get("current") == "1" && typeof char !== "undefined" && char && typeof char.autoCalcFormula !== "undefined") { $>
  18271.                                                                         <$!char.autoCalcFormula('${d20plus.formulas[d20plus.sheet].npcac}')$>
  18272.                                                                 <$ } else if (typeof char !== "undefined" && char && typeof char.autoCalcFormula !== "undefined") { $>
  18273.                                                                         <$!char.autoCalcFormula('${d20plus.formulas[d20plus.sheet].ac}')$>
  18274.                                                                 <$ } else { $>
  18275.                                                                         <$!"\u2014"$>
  18276.                                                                 <$ } $>
  18277.                                                         </span>
  18278.                                                 `);
  18279.                                                 headerStack.push(`<span class='tracker-col'>AC</span>`);
  18280.                                                 break;
  18281.                                         }
  18282.                                         case "Passive Perception": {
  18283.                                                 replaceStack.push(`
  18284.                                                         <$ var passive = (typeof char !== "undefined" && char && typeof char.autoCalcFormula !== "undefined") ? (char.autoCalcFormula('@{passive}') || char.autoCalcFormula('${d20plus.formulas[d20plus.sheet].pp}')) : "\u2014"; $>
  18285.                                                         <span class='pp tracker-col' alt='Passive Perception' title='Passive Perception'><$!passive$></span>                                                   
  18286.                                                 `);
  18287.                                                 headerStack.push(`<span class='tracker-col'>PP</span>`);
  18288.                                                 break;
  18289.                                         }
  18290.                                         case "Spell DC": {
  18291.                                                 replaceStack.push(`
  18292.                                                         <$ var dc = (typeof char !== "undefined" && char && typeof char.autoCalcFormula !== "undefined") ? (char.autoCalcFormula('${d20plus.formulas[d20plus.sheet].spellDc}')) : "\u2014"; $>
  18293.                                                         <span class='dc tracker-col' alt='Spell DC' title='Spell DC'><$!dc$></span>
  18294.                                                 `);
  18295.                                                 headerStack.push(`<span class='tracker-col'>DC</span>`);
  18296.                                                 break;
  18297.                                         }
  18298.                                         default: {
  18299.                                                 replaceStack.push(`<span class="tracker-col"/>`);
  18300.                                                 headerStack.push(`<span class="tracker-col"/>`);
  18301.                                         }
  18302.                                 }
  18303.                         });
  18304.  
  18305.                         console.log("use custom tracker val was ", d20plus.cfg.get("interface", "customTracker"))
  18306.                         if (d20plus.cfg.get("interface", "customTracker")) {
  18307.                                 $(`.init-header`).show();
  18308.                                 if (d20plus.cfg.get("interface", "trackerSheetButton")) {
  18309.                                         $(`.init-sheet-header`).show();
  18310.                                 } else {
  18311.                                         $(`.init-sheet-header`).hide();
  18312.                                 }
  18313.                                 $(`.init-init-header`).show();
  18314.                                 const $header = $(".tracker-header-extra-columns");
  18315.                                 // prepend/reverse used since tracker gets populated in right-to-left order
  18316.                                 headerStack.forEach(h => $header.prepend(h))
  18317.                                 html = html.replace(`<!--5ETOOLS_REPLACE_TARGET-->`, replaceStack.reverse().join(" \n"));
  18318.                         } else {
  18319.                                 $(`.init-header`).hide();
  18320.                                 $(`.init-sheet-header`).hide();
  18321.                                 $(`.init-init-header`).hide();
  18322.                         }
  18323.  
  18324.                         $("#tmpl_initiativecharacter").replaceWith(html);
  18325.  
  18326.                         // Hack to catch errors, part 1
  18327.                         const startTime = (new Date).getTime();
  18328.  
  18329.                         var results = d20plus.turnOrderCachedFunction.apply(this, []);
  18330.                         setTimeout(function () {
  18331.                                 $(".initmacrobutton").unbind("click");
  18332.                                 $(".initmacrobutton").bind("click", function () {
  18333.                                         console.log("Macro button clicked");
  18334.                                         tokenid = $(this).parent().parent().data("tokenid");
  18335.                                         var token, char;
  18336.                                         var page = d20.Campaign.activePage();
  18337.                                         if (page) token = page.thegraphics.get(tokenid);
  18338.                                         if (token) char = token.character;
  18339.                                         if (char) {
  18340.                                                 char.view.showDialog();
  18341.                                                 // d20.textchat.doChatInput(`%{` + char.id + `|` + d20plus.formulas[d20plus.sheet]["macro"] + `}`)
  18342.                                         }
  18343.                                 });
  18344.  
  18345.                                 d20plus.bindTokens();
  18346.                         }, 100);
  18347.  
  18348.                         // Hack to catch errors, part 2
  18349.                         if (d20plus.initErrorHandler) {
  18350.                                 window.removeEventListener("error", d20plus.initErrorHandler);
  18351.                         }
  18352.                         d20plus.initErrorHandler = function (event) {
  18353.                                 // if we see an error within 250 msec of trying to override the initiative window...
  18354.                                 if (((new Date).getTime() - startTime) < 250) {
  18355.                                         d20plus.ut.log("ERROR: failed to populate custom initiative tracker, restoring default...");
  18356.                                         // restore the default functionality
  18357.                                         $("#tmpl_initiativecharacter").replaceWith(d20plus.turnOrderCachedTemplate);
  18358.                                         return d20plus.turnOrderCachedFunction();
  18359.                                 }
  18360.                         };
  18361.                         window.addEventListener("error", d20plus.initErrorHandler);
  18362.                         return results;
  18363.                 };
  18364.  
  18365.                 const getTargetWidth = () => d20plus.cfg.get("interface", "minifyTracker") ? 250 : 350;
  18366.                 // wider tracker
  18367.                 const cachedDialog = d20.Campaign.initiativewindow.$el.dialog;
  18368.                 d20.Campaign.initiativewindow.$el.dialog = (...args) => {
  18369.                         const widen = d20plus.cfg.get("interface", "customTracker");
  18370.                         if (widen && args[0] && args[0].width) {
  18371.                                 args[0].width = getTargetWidth();
  18372.                         }
  18373.                         cachedDialog.bind(d20.Campaign.initiativewindow.$el)(...args);
  18374.                 };
  18375.  
  18376.                 // if the tracker is already open, widen it
  18377.                 if (d20.Campaign.initiativewindow.model.attributes.initiativepage) d20.Campaign.initiativewindow.$el.dialog("option", "width", getTargetWidth());
  18378.         };
  18379.  
  18380.         d20plus.psionics._groupOptions = ["Alphabetical", "Order", "Source"];
  18381.         d20plus.psionics._listCols = ["name", "order", "source"];
  18382.         d20plus.psionics._listItemBuilder = (it) => `
  18383.                 <span class="name col-6">${it.name}</span>
  18384.                 <span class="order col-4">ORD[${it.order || "None"}]</span>
  18385.                 <span title="${Parser.sourceJsonToFull(it.source)}" class="source col-2">SRC[${Parser.sourceJsonToAbv(it.source)}]</span>`;
  18386.         d20plus.psionics._listIndexConverter = (p) => {
  18387.                 return {
  18388.                         name: p.name.toLowerCase(),
  18389.                         order: (p.order || "none").toLowerCase(),
  18390.                         source: Parser.sourceJsonToAbv(p.source).toLowerCase()
  18391.                 };
  18392.         };
  18393. // Import Psionics button was clicked
  18394.         d20plus.psionics.button = function (forcePlayer) {
  18395.                 const playerMode = forcePlayer || !window.is_gm;
  18396.                 const url = playerMode ? $("#import-psionics-url-player").val() : $("#import-psionics-url").val();
  18397.                 if (url && url.trim()) {
  18398.                         const handoutBuilder = playerMode ? d20plus.psionics.playerImportBuilder : d20plus.psionics.handoutBuilder;
  18399.  
  18400.                         DataUtil.loadJSON(url).then((data) => {
  18401.                                 d20plus.importer.addMeta(data._meta);
  18402.                                 d20plus.importer.showImportList(
  18403.                                         "psionic",
  18404.                                         data.psionic,
  18405.                                         handoutBuilder,
  18406.                                         {
  18407.                                                 groupOptions: d20plus.psionics._groupOptions,
  18408.                                                 forcePlayer,
  18409.                                                 listItemBuilder: d20plus.psionics._listItemBuilder,
  18410.                                                 listIndex: d20plus.psionics._listCols,
  18411.                                                 listIndexConverter: d20plus.psionics._listIndexConverter
  18412.                                         }
  18413.                                 );
  18414.                         });
  18415.                 }
  18416.         };
  18417.  
  18418.         d20plus.psionics.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  18419.                 // make dir
  18420.                 const folder = d20plus.journal.makeDirTree(`Psionics`, folderName);
  18421.                 const path = ["Psionics", ...folderName, data.name];
  18422.  
  18423.                 // handle duplicates/overwrites
  18424.                 if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  18425.  
  18426.                 const name = data.name;
  18427.                 d20.Campaign.handouts.create({
  18428.                         name: name,
  18429.                         tags: d20plus.importer.getTagString([
  18430.                                 Parser.psiTypeToFull(data.type),
  18431.                                 data.order || "orderless",
  18432.                                 Parser.sourceJsonToFull(data.source)
  18433.                                 ], "psionic")
  18434.                 }, {
  18435.                         success: function (handout) {
  18436.                                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_PSIONICS](data)] = {name: data.name, source: data.source, type: "handout", roll20Id: handout.id};
  18437.  
  18438.                                 const [noteContents, gmNotes] = d20plus.psionics._getHandoutData(data);
  18439.  
  18440.                                 handout.updateBlobs({notes: noteContents, gmnotes: gmNotes});
  18441.                                 handout.save({notes: (new Date).getTime(), inplayerjournals: inJournals});
  18442.                                 d20.journal.addItemToFolderStructure(handout.id, folder.id);
  18443.                         }
  18444.                 });
  18445.         };
  18446.  
  18447.         d20plus.psionics.playerImportBuilder = function (data) {
  18448.                 const [notecontents, gmnotes] = d20plus.psionics._getHandoutData(data);
  18449.  
  18450.                 const importId = d20plus.ut.generateRowId();
  18451.                 d20plus.importer.storePlayerImport(importId, JSON.parse(gmnotes));
  18452.                 d20plus.importer.makePlayerDraggable(importId, data.name);
  18453.         };
  18454.  
  18455.         d20plus.psionics._getHandoutData = function (data) {
  18456.                 function renderTalent () {
  18457.                         const renderStack = [];
  18458.                         renderer.recursiveRender(({entries: data.entries, type: "entries"}), renderStack);
  18459.                         return renderStack.join(" ");
  18460.                 }
  18461.  
  18462.                 const renderer = new Renderer();
  18463.                 renderer.setBaseUrl(BASE_SITE_URL);
  18464.                 const r20json = {
  18465.                         "name": data.name,
  18466.                         "Vetoolscontent": data,
  18467.                         "data": {
  18468.                                 "Category": "Psionics"
  18469.                         }
  18470.                 };
  18471.                 const gmNotes = JSON.stringify(r20json);
  18472.  
  18473.                 const baseNoteContents = `
  18474.                         <h3>${data.name}</h3>
  18475.                         <p><em>${data.type === "D" ? `${data.order} ${Parser.psiTypeToFull(data.type)}` : `${Parser.psiTypeToFull(data.type)}`}</em></p>
  18476.                         ${data.type === "D" ? `${Renderer.psionic.getDisciplineText(data, renderer)}` : `${renderTalent()}`}
  18477.                         `;
  18478.  
  18479.                 const noteContents = `${baseNoteContents}<br><del class="hidden">${gmNotes}</del>`;
  18480.  
  18481.                 return [noteContents, gmNotes];
  18482.         };
  18483.  
  18484. // Import Races button was clicked
  18485.         d20plus.races.button = function (forcePlayer) {
  18486.                 const playerMode = forcePlayer || !window.is_gm;
  18487.                 const url = playerMode ? $("#import-races-url-player").val() : $("#import-races-url").val();
  18488.                 if (url && url.trim()) {
  18489.                         const handoutBuilder = playerMode ? d20plus.races.playerImportBuilder : d20plus.races.handoutBuilder;
  18490.  
  18491.                         DataUtil.loadJSON(url).then((data) => {
  18492.                                 d20plus.importer.addMeta(data._meta);
  18493.                                 d20plus.importer.showImportList(
  18494.                                         "race",
  18495.                                         Renderer.race.mergeSubraces(data.race),
  18496.                                         handoutBuilder,
  18497.                                         {
  18498.                                                 forcePlayer
  18499.                                         }
  18500.                                 );
  18501.                         });
  18502.                 }
  18503.         };
  18504.  
  18505.         d20plus.races.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  18506.                 // make dir
  18507.                 const folder = d20plus.journal.makeDirTree(`Races`, folderName);
  18508.                 const path = ["Races", ...folderName, data.name];
  18509.  
  18510.                 // handle duplicates/overwrites
  18511.                 if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  18512.  
  18513.                 const name = data.name;
  18514.                 d20.Campaign.handouts.create({
  18515.                         name: name,
  18516.                         tags: d20plus.importer.getTagString([
  18517.                                 Parser.sizeAbvToFull(data.size),
  18518.                                 Parser.sourceJsonToFull(data.source)
  18519.                         ], "race")
  18520.                 }, {
  18521.                         success: function (handout) {
  18522.                                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_RACES](data)] = {name: data.name, source: data.source, type: "handout", roll20Id: handout.id};
  18523.  
  18524.                                 const [noteContents, gmNotes] = d20plus.races._getHandoutData(data);
  18525.  
  18526.                                 handout.updateBlobs({notes: noteContents, gmnotes: gmNotes});
  18527.                                 handout.save({notes: (new Date).getTime(), inplayerjournals: inJournals});
  18528.                                 d20.journal.addItemToFolderStructure(handout.id, folder.id);
  18529.                         }
  18530.                 });
  18531.         };
  18532.  
  18533.         d20plus.races.playerImportBuilder = function (data) {
  18534.                 const [notecontents, gmnotes] = d20plus.races._getHandoutData(data);
  18535.  
  18536.                 const importId = d20plus.ut.generateRowId();
  18537.                 d20plus.importer.storePlayerImport(importId, JSON.parse(gmnotes));
  18538.                 d20plus.importer.makePlayerDraggable(importId, data.name);
  18539.         };
  18540.  
  18541.         d20plus.races._getHandoutData = function (data) {
  18542.                 const renderer = new Renderer();
  18543.                 renderer.setBaseUrl(BASE_SITE_URL);
  18544.  
  18545.                 const renderStack = [];
  18546.                 const ability = Renderer.getAbilityData(data.ability);
  18547.                 renderStack.push(`
  18548.                 <h3>${data.name}</h3>
  18549.                 <p>
  18550.                         <strong>Ability Scores:</strong> ${ability.asText}<br>
  18551.                         <strong>Size:</strong> ${Parser.sizeAbvToFull(data.size)}<br>
  18552.                         <strong>Speed:</strong> ${Parser.getSpeedString(data)}<br>
  18553.                 </p>
  18554.         `);
  18555.                 renderer.recursiveRender({entries: data.entries}, renderStack, {depth: 1});
  18556.                 const rendered = renderStack.join("");
  18557.  
  18558.                 const r20json = {
  18559.                         "name": data.name,
  18560.                         "Vetoolscontent": data,
  18561.                         "data": {
  18562.                                 "Category": "Races"
  18563.                         }
  18564.                 };
  18565.                 const gmNotes = JSON.stringify(r20json);
  18566.                 const noteContents = `${rendered}\n\n<del class="hidden">${gmNotes}</del>`;
  18567.  
  18568.                 return [noteContents, gmNotes];
  18569.         };
  18570.  
  18571. // Import Object button was clicked
  18572.         d20plus.objects.button = function () {
  18573.                 const url = $("#import-objects-url").val();
  18574.                 if (url && url.trim()) {
  18575.                         DataUtil.loadJSON(url).then((data) => {
  18576.                                 d20plus.importer.addMeta(data._meta);
  18577.                                 d20plus.importer.showImportList(
  18578.                                         "object",
  18579.                                         data.object,
  18580.                                         d20plus.objects.handoutBuilder
  18581.                                 );
  18582.                         });
  18583.                 }
  18584.         };
  18585.  
  18586.         d20plus.objects.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  18587.                 // make dir
  18588.                 const folder = d20plus.journal.makeDirTree(`Objects`, folderName);
  18589.                 const path = ["Objects", ...folderName, data.name];
  18590.  
  18591.                 // handle duplicates/overwrites
  18592.                 if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  18593.  
  18594.                 const name = data.name;
  18595.                 d20.Campaign.characters.create(
  18596.                         {
  18597.                                 name: name,
  18598.                                 tags: d20plus.importer.getTagString([
  18599.                                         Parser.sizeAbvToFull(data.size),
  18600.                                         Parser.sourceJsonToFull(data.source)
  18601.                                 ], "object")
  18602.                         },
  18603.                         {
  18604.                         success: function (character) {
  18605.                                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_OBJECTS](data)] = {name: data.name, source: data.source, type: "character", roll20Id: character.id};
  18606.  
  18607.                                 try {
  18608.                                         const avatar = data.tokenUrl || `${IMG_URL}objects/${name}.png`;
  18609.                                         character.size = data.size;
  18610.                                         character.name = name;
  18611.                                         character.senses = data.senses ? data.senses instanceof Array ? data.senses.join(", ") : data.senses : null;
  18612.                                         character.hp = data.hp;
  18613.                                         $.ajax({
  18614.                                                 url: avatar,
  18615.                                                 type: 'HEAD',
  18616.                                                 error: function () {
  18617.                                                         d20plus.importer.getSetAvatarImage(character, `${IMG_URL}blank.png`);
  18618.                                                 },
  18619.                                                 success: function () {
  18620.                                                         d20plus.importer.getSetAvatarImage(character, avatar);
  18621.                                                 }
  18622.                                         });
  18623.                                         const ac = data.ac.match(/^\d+/);
  18624.                                         const size = Parser.sizeAbvToFull(data.size);
  18625.                                         character.attribs.create({name: "npc", current: 1});
  18626.                                         character.attribs.create({name: "npc_toggle", current: 1});
  18627.                                         character.attribs.create({name: "npc_options-flag", current: 0});
  18628.                                         // region disable charachtermancer
  18629.                                         character.attribs.create({name: "mancer_confirm_flag", current: ""});
  18630.                                         character.attribs.create({name: "mancer_cancel", current: "on"});
  18631.                                         character.attribs.create({name: "l1mancer_status", current: "completed"});
  18632.                                         // endregion
  18633.                                         character.attribs.create({name: "wtype", current: d20plus.importer.getDesiredWhisperType()});
  18634.                                         character.attribs.create({name: "rtype", current: d20plus.importer.getDesiredRollType()});
  18635.                                         character.attribs.create({
  18636.                                                 name: "advantagetoggle",
  18637.                                                 current: d20plus.importer.getDesiredAdvantageToggle()
  18638.                                         });
  18639.                                         character.attribs.create({
  18640.                                                 name: "whispertoggle",
  18641.                                                 current: d20plus.importer.getDesiredWhisperToggle()
  18642.                                         });
  18643.                                         character.attribs.create({name: "dtype", current: d20plus.importer.getDesiredDamageType()});
  18644.                                         character.attribs.create({name: "npc_name", current: name});
  18645.                                         character.attribs.create({name: "npc_size", current: size});
  18646.                                         character.attribs.create({name: "type", current: data.type});
  18647.                                         character.attribs.create({name: "npc_type", current: `${size} ${data.type}`});
  18648.                                         character.attribs.create({name: "npc_ac", current: ac != null ? ac[0] : ""});
  18649.                                         character.attribs.create({name: "npc_actype", current: ""});
  18650.                                         character.attribs.create({name: "npc_hpbase", current: data.hp});
  18651.                                         character.attribs.create({name: "npc_hpformula", current: data.hp ? `${data.hp}d1` : ""});
  18652.  
  18653.                                         character.attribs.create({name: "npc_immunities", current: data.immune ? data.immune : ""});
  18654.                                         character.attribs.create({name: "damage_immunities", current: data.immune ? data.immune : ""});
  18655.  
  18656.                                         //Should only be one entry for objects
  18657.                                         if (data.entries != null) {
  18658.                                                 character.attribs.create({name: "repeating_npctrait_0_name", current: name});
  18659.                                                 character.attribs.create({name: "repeating_npctrait_0_desc", current: data.entries});
  18660.                                                 if (d20plus.cfg.getOrDefault("import", "tokenactionsTraits")) {
  18661.                                                         character.abilities.create({
  18662.                                                                 name: "Information: " + name,
  18663.                                                                 istokenaction: true,
  18664.                                                                 action: d20plus.actionMacroTrait(0)
  18665.                                                         });
  18666.                                                 }
  18667.                                         }
  18668.  
  18669.                                         const renderer = new Renderer();
  18670.                                         renderer.setBaseUrl(BASE_SITE_URL);
  18671.                                         if (data.actionEntries) {
  18672.                                                 data.actionEntries.forEach((e, i) => {
  18673.                                                         const renderStack = [];
  18674.                                                         renderer.recursiveRender({entries: e.entries}, renderStack, {depth: 2});
  18675.                                                         const actionText = d20plus.importer.getCleanText(renderStack.join(""));
  18676.                                                         d20plus.importer.addAction(character, d20plus.importer.getCleanText(renderer.render(e.name)), actionText, i);
  18677.                                                 });
  18678.                                         }
  18679.  
  18680.                                         character.view._updateSheetValues();
  18681.  
  18682.                                         if (data.entries) {
  18683.                                                 const bio = renderer.render({type: "entries", entries: data.entries});
  18684.  
  18685.                                                 setTimeout(() => {
  18686.                                                         const fluffAs = d20plus.cfg.get("import", "importFluffAs") || d20plus.cfg.getDefault("import", "importFluffAs");
  18687.                                                         let k = fluffAs === "Bio"? "bio" : "gmnotes";
  18688.                                                         character.updateBlobs({
  18689.                                                                 [k]: Markdown.parse(bio)
  18690.                                                         });
  18691.                                                         character.save({
  18692.                                                                 [k]: (new Date).getTime()
  18693.                                                         });
  18694.                                                 }, 500);
  18695.                                         }
  18696.                                 } catch (e) {
  18697.                                         d20plus.ut.log(`Error loading [${name}]`);
  18698.                                         d20plus.addImportError(name);
  18699.                                         console.log(data);
  18700.                                         console.log(e);
  18701.                                 }
  18702.                                 d20.journal.addItemToFolderStructure(character.id, folder.id);
  18703.                         }
  18704.                 });
  18705.         };
  18706.  
  18707.         d20plus.optionalfeatures.button = function (forcePlayer) {
  18708.                 const playerMode = forcePlayer || !window.is_gm;
  18709.                 const url = playerMode ? $("#import-optionalfeatures-url-player").val() : $("#import-optionalfeatures-url").val();
  18710.                 if (url && url.trim()) {
  18711.                         const handoutBuilder = playerMode ? d20plus.optionalfeatures.playerImportBuilder : d20plus.optionalfeatures.handoutBuilder;
  18712.  
  18713.                         DataUtil.loadJSON(url).then((data) => {
  18714.                                 d20plus.importer.addMeta(data._meta);
  18715.                                 d20plus.importer.showImportList(
  18716.                                         "optionalfeature",
  18717.                                         data.optionalfeature,
  18718.                                         handoutBuilder,
  18719.                                         {
  18720.                                                 forcePlayer
  18721.                                         }
  18722.                                 );
  18723.                         });
  18724.                 }
  18725.         };
  18726.  
  18727.         d20plus.optionalfeatures.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  18728.                 // make dir
  18729.                 const folder = d20plus.journal.makeDirTree(`Optional Features`, folderName);
  18730.                 const path = ["Optional Features", ...folderName, data.name];
  18731.  
  18732.                 // handle duplicates/overwrites
  18733.                 if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  18734.  
  18735.                 const name = data.name;
  18736.                 d20.Campaign.handouts.create({
  18737.                         name: name,
  18738.                         tags: d20plus.importer.getTagString([
  18739.                                 Parser.sourceJsonToFull(data.source)
  18740.                         ], "optionalfeature")
  18741.                 }, {
  18742.                         success: function (handout) {
  18743.                                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_OPT_FEATURES](data)] = {name: data.name, source: data.source, type: "handout", roll20Id: handout.id};
  18744.  
  18745.                                 const [noteContents, gmNotes] = d20plus.optionalfeatures._getHandoutData(data);
  18746.  
  18747.                                 handout.updateBlobs({notes: noteContents, gmnotes: gmNotes});
  18748.                                 handout.save({notes: (new Date).getTime(), inplayerjournals: inJournals});
  18749.                                 d20.journal.addItemToFolderStructure(handout.id, folder.id);
  18750.                         }
  18751.                 });
  18752.         };
  18753.  
  18754.         d20plus.optionalfeatures.playerImportBuilder = function (data) {
  18755.                 const [notecontents, gmnotes] = d20plus.optionalfeatures._getHandoutData(data);
  18756.  
  18757.                 const importId = d20plus.ut.generateRowId();
  18758.                 d20plus.importer.storePlayerImport(importId, JSON.parse(gmnotes));
  18759.                 d20plus.importer.makePlayerDraggable(importId, data.name);
  18760.         };
  18761.  
  18762.         d20plus.optionalfeatures._getHandoutData = function (data) {
  18763.                 const renderer = new Renderer();
  18764.                 renderer.setBaseUrl(BASE_SITE_URL);
  18765.  
  18766.                 const renderStack = [];
  18767.  
  18768.                 renderer.recursiveRender({entries: data.entries}, renderStack, {depth: 1});
  18769.  
  18770.                 const rendered = renderStack.join("");
  18771.                 const prereqs = Renderer.utils.getPrerequisiteText(data.prerequisites);
  18772.  
  18773.                 const r20json = {
  18774.                         "name": data.name,
  18775.                         "Vetoolscontent": data,
  18776.                         "data": {
  18777.                                 "Category": "Optional Features"
  18778.                         }
  18779.                 };
  18780.                 const gmNotes = JSON.stringify(r20json);
  18781.                 const noteContents = `${prereqs ? `<p><i>Prerequisite: ${prereqs}.</i></p>` : ""}${rendered}\n\n<del class="hidden">${gmNotes}</del>`;
  18782.  
  18783.                 return [noteContents, gmNotes];
  18784.         };
  18785.  
  18786.         // Import Adventures button was clicked
  18787.         d20plus.adventures.button = function () {
  18788.                 const url = $("#import-adventures-url").val();
  18789.                 if (url !== null) d20plus.adventures.load(url);
  18790.         };
  18791.  
  18792.         // Fetch adventure data from file
  18793.         d20plus.adventures.load = function (url) {
  18794.                 $("a.ui-tabs-anchor[href='#journal']").trigger("click");
  18795.                 $.ajax({
  18796.                         type: "GET",
  18797.                         url: url,
  18798.                         dataType: "text",
  18799.                         success: function (data) {
  18800.                                 data = JSON.parse(data);
  18801.  
  18802.                                 function isPart (e) {
  18803.                                         return typeof e === "string" || typeof e === "object" && (e.type !== "entries");
  18804.                                 }
  18805.  
  18806.                                 // open progress window
  18807.                                 $("#d20plus-import").dialog("open");
  18808.                                 $("#import-remaining").text("Initialising...");
  18809.  
  18810.                                 // FIXME(homebrew) this always selects the first item in a list of homebrew adventures
  18811.                                 // FIXME(5etools) this selects the source based on the select dropdown, which can be wrong
  18812.                                 // get metadata
  18813.                                 const adMeta = data.adventure
  18814.                                         ? data.adventure[0] :
  18815.                                         adventureMetadata.adventure.find(a => a.id.toLowerCase() === $("#import-adventures-url").data("id").toLowerCase());
  18816.  
  18817.                                 const addQueue = [];
  18818.                                 const sections = JSON.parse(JSON.stringify(data.adventureData ? data.adventureData[0].data : data.data));
  18819.                                 const adDir = `${Parser.sourceJsonToFull(adMeta.id)}`;
  18820.                                 sections.forEach((s, i) => {
  18821.                                         if (i >= adMeta.contents.length) return;
  18822.  
  18823.                                         const chapterDir = [adDir, adMeta.contents[i].name];
  18824.  
  18825.                                         const introEntries = [];
  18826.                                         if (s.entries && s.entries.length && isPart(s.entries[0])) {
  18827.                                                 while (isPart(s.entries[0])) {
  18828.                                                         introEntries.push(s.entries[0]);
  18829.                                                         s.entries.shift();
  18830.                                                 }
  18831.                                         }
  18832.                                         addQueue.push({
  18833.                                                 dir: chapterDir,
  18834.                                                 type: "entries",
  18835.                                                 name: s.name,
  18836.                                                 entries: introEntries,
  18837.                                         });
  18838.  
  18839.                                         // compact entries into layers
  18840.                                         front = null;
  18841.                                         let tempStack = [];
  18842.                                         let textIndex = 1;
  18843.                                         while ((front = s.entries.shift())) {
  18844.                                                 if (isPart(front)) {
  18845.                                                         tempStack.push(front);
  18846.                                                 } else {
  18847.                                                         if (tempStack.length) {
  18848.                                                                 addQueue.push({
  18849.                                                                         dir: chapterDir,
  18850.                                                                         type: "entries",
  18851.                                                                         name: `Text ${textIndex++}`,
  18852.                                                                         entries: tempStack
  18853.                                                                 });
  18854.                                                                 tempStack = [];
  18855.                                                         }
  18856.                                                         front.dir = chapterDir;
  18857.                                                         addQueue.push(front);
  18858.                                                 }
  18859.                                         }
  18860.                                 });
  18861.  
  18862.                                 const renderer = new Renderer();
  18863.                                 renderer.setBaseUrl(BASE_SITE_URL);
  18864.  
  18865.                                 const $stsName = $("#import-name");
  18866.                                 const $stsRemain = $("#import-remaining");
  18867.                                 const interval = d20plus.cfg.get("import", "importIntervalHandout") || d20plus.cfg.getDefault("import", "importIntervalHandout");
  18868.  
  18869.                                 ////////////////////////////////////////////////////////////////////////////////////////////////////////
  18870.                                 Renderer.get().setBaseUrl(BASE_SITE_URL);
  18871.                                 // pre-import tags
  18872.                                 const tags = {};
  18873.                                 renderer.doExportTags(tags);
  18874.                                 addQueue.forEach(entry => {
  18875.                                         renderer.recursiveRender(entry, []);
  18876.                                 });
  18877.  
  18878.                                 // storage for returned handout/character IDs
  18879.                                 const RETURNED_IDS = {};
  18880.  
  18881.                                 // monsters
  18882.                                 const preMonsters = Object.keys(tags)
  18883.                                         .filter(k => tags[k].page === "bestiary.html")
  18884.                                         .map(k => tags[k]);
  18885.                                 if (confirm("Import creatures from this adventure?")) doPreImport(preMonsters, showMonsterImport);
  18886.                                 else doItemImport();
  18887.  
  18888.                                 function showMonsterImport (toImport) {
  18889.                                         d20plus.ut.log(`Displaying monster import list for [${adMeta.name}]`);
  18890.                                         d20plus.importer.showImportList(
  18891.                                                 "monster",
  18892.                                                 toImport.filter(it => it),
  18893.                                                 d20plus.monsters.handoutBuilder,
  18894.                                                 {
  18895.                                                         groupOptions: d20plus.monsters._groupOptions,
  18896.                                                         saveIdsTo: RETURNED_IDS,
  18897.                                                         callback: doItemImport,
  18898.                                                         listItemBuilder: d20plus.monsters._listItemBuilder,
  18899.                                                         listIndex: d20plus.monsters._listCols,
  18900.                                                         listIndexConverter: d20plus.monsters._listIndexConverter
  18901.                                                 }
  18902.                                         );
  18903.                                 }
  18904.  
  18905.                                 // items
  18906.                                 function doItemImport () {
  18907.                                         const preItems = Object.keys(tags)
  18908.                                                 .filter(k => tags[k].page === "items.html")
  18909.                                                 .map(k => tags[k]);
  18910.                                         if (confirm("Import items from this adventure?")) doPreImport(preItems, showItemImport);
  18911.                                         else doMainImport();
  18912.                                 }
  18913.  
  18914.                                 function showItemImport (toImport) {
  18915.                                         d20plus.ut.log(`Displaying item import list for [${adMeta.name}]`);
  18916.                                         d20plus.importer.showImportList(
  18917.                                                 "item",
  18918.                                                 toImport.filter(it => it),
  18919.                                                 d20plus.items.handoutBuilder,
  18920.                                                 {
  18921.                                                         groupOptions: d20plus.items._groupOptions,
  18922.                                                         saveIdsTo: RETURNED_IDS,
  18923.                                                         callback: doMainImport,
  18924.                                                         listItemBuilder: d20plus.items._listItemBuilder,
  18925.                                                         listIndex: d20plus.items._listCols,
  18926.                                                         listIndexConverter: d20plus.items._listIndexConverter
  18927.                                                 }
  18928.                                         );
  18929.                                 }
  18930.  
  18931.                                 function doPreImport (asTags, callback) {
  18932.                                         const tmp = [];
  18933.                                         let cachedCount = asTags.length;
  18934.                                         asTags.forEach(it => {
  18935.                                                 try {
  18936.                                                         Renderer.hover._doFillThenCall(
  18937.                                                                 it.page,
  18938.                                                                 it.source,
  18939.                                                                 it.hash,
  18940.                                                                 () => {
  18941.                                                                         tmp.push(Renderer.hover._getFromCache(it.page, it.source, it.hash));
  18942.                                                                         cachedCount--;
  18943.                                                                         if (cachedCount <= 0) callback(tmp);
  18944.                                                                 }
  18945.                                                         );
  18946.                                                 } catch (x) {
  18947.                                                         console.log(x);
  18948.                                                         cachedCount--;
  18949.                                                         if (cachedCount <= 0) callback(tmp);
  18950.                                                 }
  18951.                                         });
  18952.                                 }
  18953.                                 ////////////////////////////////////////////////////////////////////////////////////////////////////////
  18954.                                 function doMainImport () {
  18955.                                         // pass in any created handouts/characters to use for links in the renderer
  18956.                                         renderer.setRoll20Ids(RETURNED_IDS);
  18957.  
  18958.                                         let cancelWorker = false;
  18959.                                         const $btnCancel = $(`#importcancel`);
  18960.                                         $btnCancel.off("click");
  18961.                                         $btnCancel.on("click", () => {
  18962.                                                 cancelWorker = true;
  18963.                                         });
  18964.  
  18965.                                         let remaining = addQueue.length;
  18966.  
  18967.                                         d20plus.ut.log(`Running import of [${adMeta.name}] with ${interval} ms delay between each handout create`);
  18968.                                         let lastId = null;
  18969.                                         let lastName = null;
  18970.  
  18971.                                         const worker = setInterval(() => {
  18972.                                                 if (!addQueue.length || cancelWorker) {
  18973.                                                         clearInterval(worker);
  18974.                                                         $stsName.text("DONE!");
  18975.                                                         $stsRemain.text("0");
  18976.                                                         d20plus.ut.log(`Finished import of [${adMeta.name}]`);
  18977.                                                         renderer.resetRoll20Ids();
  18978.                                                         return;
  18979.                                                 }
  18980.  
  18981.                                                 // pull items out the queue in LIFO order, for journal ordering (last created will be at the top)
  18982.                                                 const entry = addQueue.pop();
  18983.                                                 entry.name = entry.name || "(Unknown)";
  18984.                                                 entry.name = d20plus.importer.getCleanText(renderer.render(entry.name));
  18985.                                                 $stsName.text(entry.name);
  18986.                                                 $stsRemain.text(remaining--);
  18987.                                                 const folder = d20plus.journal.makeDirTree(entry.dir);
  18988.  
  18989.                                                 d20.Campaign.handouts.create({
  18990.                                                         name: entry.name
  18991.                                                 }, {
  18992.                                                         success: function (handout) {
  18993.                                                                 const renderStack = [];
  18994.                                                                 renderer.recursiveRender(entry, renderStack);
  18995.                                                                 if (lastId && lastName) renderStack.push(`<br><p>Next handout: <a href="http://journal.roll20.net/handout/${lastId}">${lastName}</a></p>`);
  18996.                                                                 const rendered = renderStack.join("");
  18997.  
  18998.                                                                 lastId = handout.id;
  18999.                                                                 lastName = entry.name;
  19000.                                                                 handout.updateBlobs({notes: rendered});
  19001.                                                                 handout.save({notes: (new Date).getTime(), inplayerjournals: ""});
  19002.                                                                 d20.journal.addItemToFolderStructure(handout.id, folder.id);
  19003.                                                         }
  19004.                                                 });
  19005.                                         }, interval);
  19006.                                 }
  19007.                         }
  19008.                 });
  19009.         };
  19010.  
  19011.         d20plus.miniInitStyle = `
  19012.         #initiativewindow button.initmacrobutton {
  19013.                 padding: 1px 4px;
  19014.         }
  19015.  
  19016.         #initiativewindow input {
  19017.                 font-size: 8px;
  19018.         }
  19019.  
  19020.         #initiativewindow ul li span.name {
  19021.                 font-size: 13px;
  19022.                 padding-top: 0;
  19023.                 padding-left: 4px;
  19024.                 margin-top: -3px;
  19025.         }
  19026.  
  19027.         #initiativewindow ul li img {
  19028.                 min-height: 15px;
  19029.                 max-height: 15px;
  19030.         }
  19031.  
  19032.         #initiativewindow ul li {
  19033.                 min-height: 15px;
  19034.         }
  19035.  
  19036.         #initiativewindow div.header span.initiative,
  19037.         #initiativewindow ul li span.initiative,
  19038.         #initiativewindow ul li span.tracker-col,
  19039.         #initiativewindow div.header span.tracker-col,
  19040.         #initiativewindow div.header span.initmacro,
  19041.         #initiativewindow ul li span.initmacro {
  19042.                 font-size: 10px;
  19043.                 font-weight: bold;
  19044.                 text-align: right;
  19045.                 float: right;
  19046.                 padding: 0 5px;
  19047.                 width: 7%;
  19048.                 min-height: 20px;
  19049.                 display: block;
  19050.                 overflow: hidden;
  19051.         }
  19052.  
  19053.         #initiativewindow ul li .controls {
  19054.                 padding: 0 3px;
  19055.         }
  19056. `;
  19057.  
  19058.         d20plus.setInitiativeShrink = function (doShrink) {
  19059.                 const customStyle = $(`#dynamicStyle`);
  19060.                 if (doShrink) {
  19061.                         customStyle.html(d20plus.miniInitStyle);
  19062.                 } else {
  19063.                         customStyle.html("");
  19064.                 }
  19065.         };
  19066.  
  19067.         d20plus.difficultyHtml = `<span class="difficulty" style="position: absolute; pointer-events: none"></span>`;
  19068.  
  19069.         d20plus.multipliers = [1, 1.5, 2, 2.5, 3, 4, 5];
  19070.  
  19071.         d20plus.playerImportHtml = `<div id="d20plus-playerimport" title="Temporary Import">
  19072.         <div class="append-target">
  19073.                 <!-- populate with js -->
  19074.         </div>
  19075.         <div class="append-list-journal" style="max-height: 400px; overflow-y: auto;">
  19076.                 <!-- populate with js -->              
  19077.         </div>
  19078.         <p><i>Player-imported items are temporary, as players can't make handouts. GMs may also use this functionality to avoid cluttering the journal. Once imported, items can be drag-dropped to character sheets.</i></p>
  19079.         </div>`;
  19080.  
  19081.         d20plus.importListHTML = `<div id="d20plus-importlist" title="Import..." style="width: 1000px;">
  19082. <p style="display: flex">
  19083.         <button type="button" id="importlist-selectall" class="btn" style="margin: 0 2px;"><span>Select All</span></button>
  19084.         <button type="button" id="importlist-deselectall" class="btn" style="margin: 0 2px;"><span>Deselect All</span></button>
  19085.         <button type="button" id="importlist-selectvis" class="btn" style="margin: 0 2px;"><span>Select Visible</span></button>
  19086.         <button type="button" id="importlist-deselectvis" class="btn" style="margin: 0 2px;"><span>Deselect Visible</span></button>
  19087.         <span style="width:1px;background: #bbb;height: 26px;margin: 2px;"></span>
  19088.         <button type="button" id="importlist-selectall-published" class="btn" style="margin: 0 2px;"><span>Select All Published</span></button>
  19089. </p>
  19090. <p>
  19091. <span id="import-list">
  19092.         <input class="search" autocomplete="off" placeholder="Search list...">
  19093.         <input type="search" id="import-list-filter" class="filter" placeholder="Filter...">
  19094.         <span id ="import-list-filter-help" title="Filter format example: 'cr:1/4; cr:1/2; type:beast; source:MM' -- hover over the columns to see the filterable name." style="cursor: help;">[?]</span>
  19095.         <br>
  19096.         <span class="list" style="max-height: 400px; overflow-y: auto; overflow-x: hidden; display: block; margin-top: 1em; transform: translateZ(0);"></span>
  19097. </span>
  19098. </p>
  19099. <p id="import-options">
  19100. <label style="display: inline-block">Group Handouts By... <select id="organize-by"></select></label>
  19101. <button type="button" id="import-open-props" class="btn" role="button" aria-disabled="false" style="padding: 3px; display: inline-block;">Select Properties</button>
  19102. <label>Make handouts visible to all players? <input type="checkbox" title="Make items visible to all players" id="import-showplayers" checked></label>
  19103. <label>Overwrite existing? <input type="checkbox" title="Overwrite existing" id="import-overwrite"></label>
  19104. </p>
  19105. <button type="button" id="importstart" class="btn" role="button" aria-disabled="false">
  19106. <span>Start Import</span>
  19107. </button>
  19108. </div>`;
  19109.  
  19110.         d20plus.importListPropsHTML = `<div id="d20plus-import-props" title="Choose Properties to Import">
  19111.         <div class="select-props" style="max-height: 400px; overflow-y: auto; transform: translateZ(0)">
  19112.                 <!-- populate with JS -->              
  19113.         </div>
  19114.         <p>
  19115.                 Warning: this feature is highly experimental, and disabling <span style="color: red;">properties which are assumed to always exist</span> is not recommended.
  19116.                 <br>
  19117.                 <button type="button" id="save-import-props" class="btn" role="button" aria-disabled="false">Save</button>
  19118.         </p>
  19119.         </div>`;
  19120.  
  19121.         d20plus.importDialogHtml = `<div id="d20plus-import" title="Importing">
  19122. <p>
  19123. <h3 id="import-name"></h3>
  19124. </p>
  19125. <b id="import-remaining"></b> <span id="import-remaining-text">remaining</span>
  19126. <p>
  19127. Errors: <b id="import-errors">0</b>
  19128. </p>
  19129. <p>
  19130. <button style="width: 90%" type="button" id="importcancel" alt="Cancel" title="Cancel Import" class="btn btn-danger" role="button" aria-disabled="false">
  19131.         <span>Cancel</span>
  19132. </button>
  19133. </p>
  19134. </div>`;
  19135.  
  19136.         d20plus.settingsHtmlImportHeader = `
  19137. <h4>Import By Category</h4>
  19138. <p><small><i>We strongly recommend the OGL sheet for importing. You can switch afterwards.</i></small></p>
  19139. `;
  19140.         d20plus.settingsHtmlSelector = `
  19141. <select id="import-mode-select">
  19142. <option value="none" disabled selected>Select category...</option>
  19143. <option value="adventure">Adventures</option>
  19144. <option value="background">Backgrounds</option>
  19145. <option value="class">Classes</option>
  19146. <option value="feat">Feats</option>
  19147. <option value="item">Items</option>
  19148. <option value="monster">Monsters</option>
  19149. <option value="object">Objects</option>
  19150. <option value="optionalfeature">Optional Features (Invocations, etc.)</option>
  19151. <option value="psionic">Psionics</option>
  19152. <option value="race">Races</option>
  19153. <option value="spell">Spells</option>
  19154. <option value="subclass">Subclasses</option>
  19155. </select>
  19156. `;
  19157.         d20plus.settingsHtmlSelectorPlayer = `
  19158. <select id="import-mode-select-player">
  19159. <option value="none" disabled selected>Select category...</option>
  19160. <option value="background">Backgrounds</option>
  19161. <option value="class">Classes</option>
  19162. <option value="feat">Feats</option>
  19163. <option value="item">Items</option>
  19164. <option value="optionalfeature">Optional Features (Invocations, etc.)</option>
  19165. <option value="psionic">Psionics</option>
  19166. <option value="race">Races</option>
  19167. <option value="spell">Spells</option>
  19168. <option value="subclass">Subclasses</option>
  19169. </select>
  19170. `;
  19171.         d20plus.settingsHtmlPtMonsters = `
  19172. <div class="importer-section" data-import-group="monster">
  19173. <h4>Monster Importing</h4>
  19174. <label for="import-monster-url">Monster Data URL:</label>
  19175. <select id="button-monsters-select">
  19176. <!-- populate with JS-->
  19177. </select>
  19178. <input type="text" id="import-monster-url">
  19179. <p><a class="btn" href="#" id="button-monsters-load">Import Monsters</a></p>
  19180. <p><a class="btn" href="#" id="button-monsters-load-all" title="Standard sources only; no third-party or UA">Import Monsters From All Sources</a></p>
  19181. <p>
  19182. WARNING: Importing huge numbers of character sheets slows the game down. We recommend you import them as needed.<br>
  19183. The "Import Monsters From All Sources" button presents a list containing monsters from official sources only.<br>
  19184. To import from third-party sources, either individually select one available in the list or enter a custom URL, and "Import Monsters."
  19185. </p>
  19186. </div>
  19187. `;
  19188.  
  19189.         d20plus.settingsHtmlPtItems = `
  19190. <div class="importer-section" data-import-group="item">
  19191. <h4>Item Importing</h4>
  19192. <label for="import-items-url">Item Data URL:</label>
  19193. <select id="button-items-select"><!-- populate with JS--></select>
  19194. <input type="text" id="import-items-url">
  19195. <a class="btn" href="#" id="import-items-load">Import Items</a>
  19196. </div>
  19197. `;
  19198.  
  19199.         d20plus.settingsHtmlPtItemsPlayer = `
  19200. <div class="importer-section" data-import-group="item">
  19201. <h4>Item Importing</h4>
  19202. <label for="import-items-url-player">Item Data URL:</label>
  19203. <select id="button-items-select-player"><!-- populate with JS--></select>
  19204. <input type="text" id="import-items-url-player">
  19205. <a class="btn" href="#" id="import-items-load-player">Import Items</a>
  19206. </div>
  19207. `;
  19208.  
  19209.         d20plus.settingsHtmlPtSpells = `
  19210. <div class="importer-section" data-import-group="spell">
  19211. <h4>Spell Importing</h4>
  19212. <label for="import-spell-url">Spell Data URL:</label>
  19213. <select id="button-spell-select">
  19214. <!-- populate with JS-->
  19215. </select>
  19216. <input type="text" id="import-spell-url">
  19217. <p><a class="btn" href="#" id="button-spells-load">Import Spells</a><p/>
  19218. <p><a class="btn" href="#" id="button-spells-load-all" title="Standard sources only; no third-party or UA">Import Spells From All Sources</a></p>
  19219. <p>
  19220. The "Import Spells From All Sources" button presents a list containing spells from official sources only.<br>
  19221. To import from third-party sources, either individually select one available in the list or enter a custom URL, and "Import Spells."
  19222. </p>
  19223. </div>
  19224. `;
  19225.  
  19226.         d20plus.settingsHtmlPtSpellsPlayer = `
  19227. <div class="importer-section" data-import-group="spell">
  19228. <h4>Spell Importing</h4>
  19229. <label for="import-spell-url-player">Spell Data URL:</label>
  19230. <select id="button-spell-select-player">
  19231. <!-- populate with JS-->
  19232. </select>
  19233. <input type="text" id="import-spell-url-player">
  19234. <p><a class="btn" href="#" id="button-spells-load-player">Import Spells</a><p/>
  19235. <p><a class="btn" href="#" id="button-spells-load-all-player" title="Standard sources only; no third-party or UA">Import Spells From All Sources</a></p>
  19236. <p>
  19237. The "Import Spells From All Sources" button presents a list containing spells from official sources only.<br>
  19238. To import from third-party sources, either individually select one available in the list or enter a custom URL, and "Import Spells."
  19239. </p>
  19240. </div>
  19241. `;
  19242.  
  19243.         d20plus.settingsHtmlPtPsionics = `
  19244. <div class="importer-section" data-import-group="psionic">
  19245. <h4>Psionic Importing</h4>
  19246. <label for="import-psionics-url">Psionics Data URL:</label>
  19247. <select id="button-psionics-select"><!-- populate with JS--></select>
  19248. <input type="text" id="import-psionics-url">
  19249. <a class="btn" href="#" id="import-psionics-load">Import Psionics</a>
  19250. </div>
  19251. `;
  19252.  
  19253.         d20plus.settingsHtmlPtPsionicsPlayer = `
  19254. <div class="importer-section" data-import-group="psionic">
  19255. <h4>Psionic Importing</h4>
  19256. <label for="import-psionics-url-player">Psionics Data URL:</label>
  19257. <select id="button-psionics-select-player"><!-- populate with JS--></select>
  19258. <input type="text" id="import-psionics-url-player">
  19259. <a class="btn" href="#" id="import-psionics-load-player">Import Psionics</a>
  19260. </div>
  19261. `;
  19262.  
  19263.         d20plus.settingsHtmlPtFeats = `
  19264. <div class="importer-section" data-import-group="feat">
  19265. <h4>Feat Importing</h4>
  19266. <label for="import-feats-url">Feat Data URL:</label>
  19267. <select id="button-feats-select"><!-- populate with JS--></select>
  19268. <input type="text" id="import-feats-url">
  19269. <a class="btn" href="#" id="import-feats-load">Import Feats</a>
  19270. </div>
  19271. `;
  19272.  
  19273.         d20plus.settingsHtmlPtFeatsPlayer = `
  19274. <div class="importer-section" data-import-group="feat">
  19275. <h4>Feat Importing</h4>
  19276. <label for="import-feats-url-player">Feat Data URL:</label>
  19277. <select id="button-feats-select-player"><!-- populate with JS--></select>
  19278. <input type="text" id="import-feats-url-player">
  19279. <a class="btn" href="#" id="import-feats-load-player">Import Feats</a>
  19280. </div>
  19281. `;
  19282.  
  19283.         d20plus.settingsHtmlPtObjects = `
  19284. <div class="importer-section" data-import-group="object">
  19285. <h4>Object Importing</h4>
  19286. <label for="import-objects-url">Object Data URL:</label>
  19287. <select id="button-objects-select"><!-- populate with JS--></select>
  19288. <input type="text" id="import-objects-url">
  19289. <a class="btn" href="#" id="import-objects-load">Import Objects</a>
  19290. </div>
  19291. `;
  19292.  
  19293.         d20plus.settingsHtmlPtRaces = `
  19294. <div class="importer-section" data-import-group="race">
  19295. <h4>Race Importing</h4>
  19296. <label for="import-races-url">Race Data URL:</label>
  19297. <select id="button-races-select"><!-- populate with JS--></select>
  19298. <input type="text" id="import-races-url">
  19299. <a class="btn" href="#" id="import-races-load">Import Races</a>
  19300. </div>
  19301. `;
  19302.  
  19303.         d20plus.settingsHtmlPtRacesPlayer = `
  19304. <div class="importer-section" data-import-group="race">
  19305. <h4>Race Importing</h4>
  19306. <label for="import-races-url-player">Race Data URL:</label>
  19307. <select id="button-races-select-player"><!-- populate with JS--></select>
  19308. <input type="text" id="import-races-url-player">
  19309. <a class="btn" href="#" id="import-races-load-player">Import Races</a>
  19310. </div>
  19311. `;
  19312.  
  19313.         d20plus.settingsHtmlPtClasses = `
  19314. <div class="importer-section" data-import-group="class">
  19315. <h4>Class Importing</h4>
  19316. <p style="margin-top: 5px"><a class="btn" href="#" id="button-classes-load-all" title="Standard sources only; no third-party or UA">Import Classes from 5etools</a></p>
  19317. <label for="import-classes-url">Class Data URL:</label>
  19318. <select id="button-classes-select">
  19319. <!-- populate with JS-->
  19320. </select>
  19321. <input type="text" id="import-classes-url">
  19322. <p><a class="btn" href="#" id="button-classes-load">Import Classes from URL</a><p/>
  19323. </div>
  19324. `;
  19325.  
  19326.         d20plus.settingsHtmlPtClassesPlayer = `
  19327. <div class="importer-section" data-import-group="class">
  19328. <h4>Class Importing</h4>
  19329. <p style="margin-top: 5px"><a class="btn" href="#" id="button-classes-load-all-player">Import Classes from 5etools</a></p>
  19330. <label for="import-classes-url-player">Class Data URL:</label>
  19331. <select id="button-classes-select-player">
  19332. <!-- populate with JS-->
  19333. </select>
  19334. <input type="text" id="import-classes-url-player">
  19335. <p><a class="btn" href="#" id="button-classes-load-player">Import Classes from URL</a><p/>
  19336. </div>
  19337. `;
  19338.  
  19339.         d20plus.settingsHtmlPtSubclasses = `
  19340. <div class="importer-section" data-import-group="subclass">
  19341. <h4>Subclass Importing</h4>
  19342. <label for="import-subclasses-url">Subclass Data URL:</label>
  19343. <select id="button-subclasses-select"><!-- populate with JS--></select>
  19344. <input type="text" id="import-subclasses-url">
  19345. <a class="btn" href="#" id="import-subclasses-load">Import Subclasses</a>
  19346. </div>
  19347. `;
  19348.  
  19349.         d20plus.settingsHtmlPtSubclassesPlayer = `
  19350. <div class="importer-section" data-import-group="subclass">
  19351. <h4>Subclass Importing</h4>
  19352. <label for="import-subclasses-url-player">Subclass Data URL:</label>
  19353. <select id="button-subclasses-select-player"><!-- populate with JS--></select>
  19354. <input type="text" id="import-subclasses-url-player">
  19355. <a class="btn" href="#" id="import-subclasses-load-player">Import Subclasses</a>
  19356. </div>
  19357. `;
  19358.  
  19359.         d20plus.settingsHtmlPtBackgrounds = `
  19360. <div class="importer-section" data-import-group="background">
  19361. <h4>Background Importing</h4>
  19362. <label for="import-backgrounds-url">Background Data URL:</label>
  19363. <select id="button-backgrounds-select"><!-- populate with JS--></select>
  19364. <input type="text" id="import-backgrounds-url">
  19365. <a class="btn" href="#" id="import-backgrounds-load">Import Backgrounds</a>
  19366. </div>
  19367. `;
  19368.  
  19369.         d20plus.settingsHtmlPtBackgroundsPlayer = `
  19370. <div class="importer-section" data-import-group="background">
  19371. <h4>Background Importing</h4>
  19372. <label for="import-backgrounds-url-player">Background Data URL:</label>
  19373. <select id="button-backgrounds-select-player"><!-- populate with JS--></select>
  19374. <input type="text" id="import-backgrounds-url-player">
  19375. <a class="btn" href="#" id="import-backgrounds-load-player">Import Backgrounds</a>
  19376. </div>
  19377. `;
  19378.  
  19379.  
  19380.         d20plus.settingsHtmlPtOptfeatures = `
  19381. <div class="importer-section" data-import-group="optionalfeature">
  19382. <h4>Optional Feature (Invocations, etc.) Importing</h4>
  19383. <label for="import-optionalfeatures-url">Optional Feature Data URL:</label>
  19384. <select id="button-optionalfeatures-select"><!-- populate with JS--></select>
  19385. <input type="text" id="import-optionalfeatures-url">
  19386. <a class="btn" href="#" id="import-optionalfeatures-load">Import Optional Features</a>
  19387. </div>
  19388. `;
  19389.  
  19390.         d20plus.settingsHtmlPtOptfeaturesPlayer = `
  19391. <div class="importer-section" data-import-group="optionalfeature">
  19392. <h4>Optional Feature (Invocations, etc.) Importing</h4>
  19393. <label for="import-optionalfeatures-url-player">Optional Feature Data URL:</label>
  19394. <select id="button-optionalfeatures-select-player"><!-- populate with JS--></select>
  19395. <input type="text" id="import-optionalfeatures-url-player">
  19396. <a class="btn" href="#" id="import-optionalfeatures-load-player">Import Optional Features</a>
  19397. </div>
  19398. `;
  19399.  
  19400.         d20plus.settingsHtmlPtAdventures = `
  19401. <div class="importer-section" data-import-group="adventure">
  19402. <b style="color: red">Please note that this importer has been superceded by the Module Importer tool, found in the Tools List, or <a href="#" class="Vetools-module-tool-open" style="color: darkred; font-style: italic">by clicking here</a>.</b>
  19403. <h4>Adventure Importing</h4>
  19404. <label for="import-adventures-url">Adventure Data URL:</label>
  19405. <select id="button-adventures-select">
  19406. <!-- populate with JS-->
  19407. </select>
  19408. <input type="text" id="import-adventures-url">
  19409. <p><a class="btn" href="#" id="button-adventures-load">Import Adventure</a><p/>
  19410. <p>
  19411. </p>
  19412. </div>
  19413. `;
  19414.  
  19415.         d20plus.settingsHtmlPtImportFooter = `
  19416. <p><a class="btn bind-drop-locations" href="#" id="bind-drop-locations" style="margin-top: 5px;width: 100%;box-sizing: border-box;">Bind Drag-n-Drop</a></p>
  19417. <p><strong>Readme</strong></p>
  19418. <p>
  19419. You can drag-and-drop imported handouts to character sheets.<br>
  19420. If a handout is glowing green in the journal, it's draggable. This breaks when Roll20 decides to hard-refresh the journal.<br>
  19421. To restore this functionality, press the "Bind Drag-n-Drop" button.<br>
  19422. <i>Note: to drag a handout to a character sheet, you need to drag the name, and not the handout icon.</i>
  19423. </p>
  19424. `;
  19425.  
  19426.         d20plus.css.cssRules = d20plus.css.cssRules.concat([
  19427.                 {
  19428.                         s: ".no-shrink",
  19429.                         r: "flex-shrink: 0;"
  19430.                 },
  19431.                 {
  19432.                         s: "#initiativewindow ul li span.initiative,#initiativewindow ul li span.tracker-col,#initiativewindow ul li span.initmacro",
  19433.                         r: "font-size: 25px;font-weight: bold;text-align: right;float: right;padding: 2px 5px;width: 10%;min-height: 20px;display: block;"
  19434.                 },
  19435.                 {
  19436.                         s: "#initiativewindow ul li span.editable input",
  19437.                         r: "width: 100%; box-sizing: border-box;height: 100%;"
  19438.                 },
  19439.                 {
  19440.                         s: "#initiativewindow div.header",
  19441.                         r: "height: 30px;"
  19442.                 },
  19443.                 {
  19444.                         s: "#initiativewindow div.header span",
  19445.                         r: "cursor: default;font-size: 15px;font-weight: bold;text-align: right;float: right;width: 10%;min-height: 20px;padding: 5px;"
  19446.                 },
  19447.                 {
  19448.                         s: ".ui-dialog-buttonpane span.difficulty",
  19449.                         r: "display: inline-block;padding: 5px 4px 6px;margin: .5em .4em .5em 0;font-size: 18px;"
  19450.                 },
  19451.                 {
  19452.                         s: ".ui-dialog-buttonpane.buttonpane-absolute-position",
  19453.                         r: "position: absolute;bottom: 0;box-sizing: border-box;width: 100%;"
  19454.                 },
  19455.                 {
  19456.                         s: ".ui-dialog.dialog-collapsed .ui-dialog-buttonpane",
  19457.                         r: "position: initial;"
  19458.                 },
  19459.                 {
  19460.                         s: ".token .cr,.header .cr",
  19461.                         r: "display: none!important;"
  19462.                 },
  19463.                 {
  19464.                         s: "li.handout.compendium-item .namecontainer",
  19465.                         r: "box-shadow: inset 0px 0px 25px 2px rgb(195, 239, 184);"
  19466.                 },
  19467.                 {
  19468.                         s: ".bind-drop-locations:active",
  19469.                         r: "box-shadow: inset 0px 0px 25px 2px rgb(195, 239, 184);"
  19470.                 },
  19471.                 {
  19472.                         s: "del.userscript-hidden",
  19473.                         r: "display: none;"
  19474.                 },
  19475.                 {
  19476.                         s: ".importer-section",
  19477.                         r: "display: none;"
  19478.                 },
  19479.                 {
  19480.                         s: ".userscript-rd__h",
  19481.                         r: "font-weight: bold;"
  19482.                 },
  19483.                 {
  19484.                         s: ".userscript-rd__h--0",
  19485.                         r: "font-weight: bold; font-size: 1.5em;"
  19486.                 },
  19487.                 {
  19488.                         s: ".userscript-rd__h--2",
  19489.                         r: "font-weight: bold; font-size: 1.3em;"
  19490.                 },
  19491.                 {
  19492.                         s: ".userscript-rd__h--3, .userscript-rd__h--4",
  19493.                         r: "font-style: italic"
  19494.                 },
  19495.                 {
  19496.                         s: ".userscript-rd__b-inset--readaloud",
  19497.                         r: "background: #cbd6c688 !important"
  19498.                 },
  19499.         ]);
  19500.  
  19501.         d20plus.tool.tools = d20plus.tool.tools.concat([
  19502.                 {
  19503.                         name: "Shapeshifter Token Builder",
  19504.                         desc: "Build a rollable table and related token to represent a shapeshifting creature.",
  19505.                         html: `
  19506.                                 <div id="d20plus-shapeshiftbuild" title="Shapeshifter Token Builder">
  19507.                                         <div id="shapeshiftbuild-list">
  19508.                                                 <input type="search" class="search" placeholder="Search creatures...">
  19509.                                                 <input type="search" class="filter" placeholder="Filter...">
  19510.                                                 <span title="Filter format example: 'cr:1/4; cr:1/2; type:beast; source:MM'" style="cursor: help;">[?]</span>
  19511.                                                 <div class="list" style="transform: translateZ(0); max-height: 490px; overflow-y: auto; overflow-x: hidden;"><i>Loading...</i></div>
  19512.                                         </div>
  19513.                                 <br>
  19514.                                 <input id="shapeshift-name" placeholder="Table name">
  19515.                                 <button class="btn">Create Table</button>
  19516.                                 </div>
  19517.                                 `,
  19518.                         dialogFn: () => {
  19519.                                 $("#d20plus-shapeshiftbuild").dialog({
  19520.                                         autoOpen: false,
  19521.                                         resizable: true,
  19522.                                         width: 800,
  19523.                                         height: 650,
  19524.                                 });
  19525.                         },
  19526.                         openFn: async () => {
  19527.                                 const $win = $("#d20plus-shapeshiftbuild");
  19528.                                 $win.dialog("open");
  19529.  
  19530.                                 const toLoad = Object.keys(monsterDataUrls).map(src => d20plus.monsters.formMonsterUrl(monsterDataUrls[src]));
  19531.  
  19532.                                 const $fltr = $win.find(`.filter`);
  19533.                                 $fltr.off("keydown").off("keyup");
  19534.                                 $win.find(`button`).off("click");
  19535.  
  19536.                                 const $lst = $win.find(`.list`);
  19537.                                 let tokenList;
  19538.  
  19539.                                 const dataStack = (await Promise.all(toLoad.map(async url => await DataUtil.loadJSON(url)))).flat();
  19540.  
  19541.                                 $lst.empty();
  19542.                                 let toShow = [];
  19543.  
  19544.                                 const seen = {};
  19545.                                 await Promise.all(dataStack.map(async d => {
  19546.                                         const toAdd = d.monster.filter(m => {
  19547.                                                 const out = !(seen[m.source] && seen[m.source].has(m.name));
  19548.                                                 if (!seen[m.source]) seen[m.source] = new Set();
  19549.                                                 seen[m.source].add(m.name);
  19550.                                                 return out;
  19551.                                         });
  19552.  
  19553.                                         toShow = toShow.concat(toAdd);
  19554.                                 }));
  19555.  
  19556.                                 toShow = toShow.sort((a, b) => SortUtil.ascSort(a.name, b.name));
  19557.  
  19558.                                 let tmp = "";
  19559.                                 toShow.forEach((m, i)  => {
  19560.                                         m.__pType = Parser.monTypeToFullObj(m.type).asText;
  19561.  
  19562.                                         tmp += `
  19563.                                                                 <label class="import-cb-label" data-listid="${i}">
  19564.                                                                         <input type="checkbox">
  19565.                                                                         <span class="name col-4">${m.name}</span>
  19566.                                                                         <span class="type col-4">TYP[${m.__pType.uppercaseFirst()}]</span>
  19567.                                                                         <span class="cr col-2">${m.cr === undefined ? "CR[Unknown]" : `CR[${(m.cr.cr || m.cr)}]`}</span>
  19568.                                                                         <span title="${Parser.sourceJsonToFull(m.source)}" class="source">SRC[${Parser.sourceJsonToAbv(m.source)}]</span>
  19569.                                                                 </label>
  19570.                                                         `;
  19571.                                 });
  19572.                                 $lst.html(tmp);
  19573.                                 tmp = null;
  19574.  
  19575.                                 tokenList = new List("shapeshiftbuild-list", {
  19576.                                         valueNames: ["name", "type", "cr", "source"]
  19577.                                 });
  19578.  
  19579.                                 d20plus.importer.addListFilter($fltr, toShow, tokenList, d20plus.monsters._listIndexConverter);
  19580.  
  19581.                                 $win.find(`button`).on("click", () => {
  19582.                                         function getSizeInTiles (size) {
  19583.                                                 switch (size) {
  19584.                                                         case SZ_TINY:
  19585.                                                                 return 0.5;
  19586.                                                         case SZ_SMALL:
  19587.                                                         case SZ_MEDIUM:
  19588.                                                                 return 1;
  19589.                                                         case SZ_LARGE:
  19590.                                                                 return 2;
  19591.                                                         case SZ_HUGE:
  19592.                                                                 return 3;
  19593.                                                         case SZ_GARGANTUAN:
  19594.                                                                 return 4;
  19595.                                                         case SZ_COLOSSAL:
  19596.                                                                 return 5;
  19597.                                                 }
  19598.                                         }
  19599.  
  19600.                                         console.log("Assembling creature list");
  19601.                                         if (tokenList) {
  19602.                                                 $("a.ui-tabs-anchor[href='#deckstables']").trigger("click");
  19603.  
  19604.                                                 const sel = tokenList.items
  19605.                                                         .filter(it => $(it.elm).find(`input`).prop("checked"))
  19606.                                                         .map(it => toShow[$(it.elm).attr("data-listid")]);
  19607.  
  19608.                                                 const id = d20.Campaign.rollabletables.create().id;
  19609.                                                 const table = d20.Campaign.rollabletables.get(id);
  19610.                                                 table.set("name", $(`#shapeshift-name`).val().trim() || "Shapeshifter");
  19611.                                                 table.save();
  19612.                                                 sel.forEach(m => {
  19613.                                                         const item = table.tableitems.create();
  19614.                                                         item.set("name", m.name);
  19615.                                                         // encode size info into the URL, which will get baked into the token
  19616.                                                         const avatar = m.tokenUrl || `${IMG_URL}${Parser.sourceJsonToAbv(m.source)}/${m.name.replace(/"/g, "")}.png?roll20_token_size=${getSizeInTiles(m.size)}`;
  19617.                                                         item.set("avatar", avatar);
  19618.                                                         item.set("token_size", getSizeInTiles(m.size));
  19619.                                                         item.save();
  19620.                                                 });
  19621.                                                 table.save();
  19622.                                                 d20.rollabletables.refreshTablesList();
  19623.                                                 alert("Created table!")
  19624.                                         }
  19625.                                 });
  19626.                         }
  19627.                 },
  19628.                 {
  19629.                         name: "Pauper's Character Vault",
  19630.                         desc: "Dump characters to JSON, or import dumped characters.",
  19631.                         html: `
  19632.                                 <div id="d20plus-paupervault" title="Pauper's Character Vault">
  19633.                                 <p>
  19634.                                         This experimental tool allows you to download characters as JSON, to later upload to other games.
  19635.                                 </p>
  19636.                                 <select name="sel_char" style="margin-bottom: 0;"></select> <button class="btn download">Download</button>
  19637.                                 <hr>
  19638.                                 <button class="btn upload">Upload</button><input accept=".json" type="file" style="position: absolute; left: -9999px;"> (Previously Downloaded files only)
  19639.                                 <br>
  19640.                                 <select name="sel_player" class="mt-2"></select>
  19641.                                 </div>
  19642.                                 `,
  19643.                         dialogFn: () => {
  19644.                                 $("#d20plus-paupervault").dialog({
  19645.                                         autoOpen: false,
  19646.                                         resizable: true,
  19647.                                         width: 400,
  19648.                                         height: 300,
  19649.                                 });
  19650.                         },
  19651.                         openFn: () => {
  19652.                                 const $win = $("#d20plus-paupervault");
  19653.                                 $win.dialog("open");
  19654.  
  19655.                                 const $selChar = $win.find(`select[name="sel_char"]`).empty();
  19656.  
  19657.                                 $selChar.append(d20.Campaign.characters.toJSON().sort((a, b) => SortUtil.ascSort(a.name, b.name)).map(c => {
  19658.                                         return `<option value="${c.id}">${c.name || `(Unnamed; ID ${c.id})`}</option>`
  19659.                                 }).join(""));
  19660.  
  19661.                                 const $btnDl = $win.find(`.download`);
  19662.                                 $btnDl.off("click");
  19663.                                 $btnDl.on("click", () => {
  19664.                                         const id = $selChar.val();
  19665.                                         const rawChar = d20.Campaign.characters.get(id);
  19666.                                         const char = rawChar.toJSON();
  19667.                                         char.attribs = rawChar.attribs.toJSON();
  19668.                                         const out = {
  19669.                                                 char,
  19670.                                                 blobs: {}
  19671.                                         };
  19672.                                         blobCount = 3;
  19673.                                         const onBlobsReady = () => DataUtil.userDownload(char.name.replace(/[^0-9A-Za-z -_()[\]{}]/, "_"), JSON.stringify(out, null, "\t"));
  19674.  
  19675.                                         const handleBlob = (asKey, data) => {
  19676.                                                 out.blobs[asKey] = data;
  19677.                                                 blobCount--;
  19678.                                                 if (blobCount === 0) onBlobsReady();
  19679.                                         };
  19680.  
  19681.                                         rawChar._getLatestBlob("bio", (data) => handleBlob("bio", data));
  19682.                                         rawChar._getLatestBlob("gmnotes", (data) => handleBlob("gmnotes", data));
  19683.                                         rawChar._getLatestBlob("defaulttoken", (data) => handleBlob("defaulttoken", data));
  19684.                                 });
  19685.  
  19686.                                 const $selPlayer = $win.find(`select[name="sel_player"]`).empty().append(`<option value="">Assign to...</option>`);
  19687.                                 $selPlayer[0].selectedIndex = 0;
  19688.                                 d20.Campaign.players.toJSON()
  19689.                                         .sort((a, b) => SortUtil.ascSortLower(a.displayname, b.displayname))
  19690.                                         .forEach(pl => $(`<option/>`).text(pl.displayname).val(pl.id).appendTo($selPlayer));
  19691.  
  19692.                                 const $btnUl = $win.find(`.upload`);
  19693.                                 $btnUl.off("click");
  19694.                                 $btnUl.on("click", () => {
  19695.                                         const $iptFile = $win.find(`input[type="file"]`);
  19696.  
  19697.                                         const input = $iptFile[0];
  19698.  
  19699.                                         const reader = new FileReader();
  19700.                                         reader.onload = () => {
  19701.                                                 $("a.ui-tabs-anchor[href='#journal']").trigger("click");
  19702.  
  19703.                                                 try {
  19704.                                                         const text = reader.result;
  19705.                                                         const json = JSON.parse(text);
  19706.  
  19707.                                                         if (!json.char) {
  19708.                                                                 window.alert("Failed to import character! See the log for details.");
  19709.                                                                 console.error(`No "char" attribute found in parsed JSON!`);
  19710.                                                                 return;
  19711.                                                         }
  19712.                                                         const char = json.char;
  19713.  
  19714.                                                         const assignTo = d20plus.ut.get$SelValue($selPlayer);
  19715.                                                         if (assignTo) {
  19716.                                                                 char.inplayerjournals = assignTo;
  19717.                                                                 char.controlledby = assignTo
  19718.                                                         }
  19719.  
  19720.                                                         const newId = d20plus.ut.generateRowId();
  19721.                                                         d20.Campaign.characters.create(
  19722.                                                                 {
  19723.                                                                         ...char,
  19724.                                                                         id: newId
  19725.                                                                 },
  19726.                                                                 {
  19727.                                                                         success: function (character) {
  19728.                                                                                 try {
  19729.                                                                                         character.attribs.reset();
  19730.                                                                                         if (!char.attribs) {
  19731.                                                                                                 window.alert(`Warning: Uploaded character had no "attribs" attribute. The character sheet will contain no data.`);
  19732.                                                                                                 return;
  19733.                                                                                         }
  19734.                                                                                         const toSave = char.attribs.map(a => character.attribs.push(a));
  19735.                                                                                         toSave.forEach(s => s.syncedSave());
  19736.  
  19737.                                                                                         const blobs = json.blobs;
  19738.                                                                                         if (blobs) {
  19739.                                                                                                 character.updateBlobs({
  19740.                                                                                                         bio: blobs.bio || "",
  19741.                                                                                                         gmnotes: blobs.gmnotes || "",
  19742.                                                                                                         defaulttoken: blobs.defaulttoken || ""
  19743.                                                                                                 });
  19744.                                                                                         }
  19745.                                                                                         alert("Done!")
  19746.                                                                                 } catch (e) {
  19747.                                                                                         window.alert("Failed to import character! See the log for details.");
  19748.                                                                                         console.error(e);
  19749.                                                                                 }
  19750.                                                                         }
  19751.                                                                 }
  19752.                                                         );
  19753.                                                 } catch (e) {
  19754.                                                         console.error(e);
  19755.                                                         window.alert("Failed to load file! See the log for details.")
  19756.                                                 }
  19757.                                         };
  19758.                                         input.onchange = function () {
  19759.                                                 reader.readAsText(input.files[0]);
  19760.                                         };
  19761.  
  19762.                                         $iptFile.click();
  19763.                                 });
  19764.                         }
  19765.                 },
  19766.                 {
  19767.                         name: "Wild Shape Builder",
  19768.                         desc: "Build a character sheet to represent a character in Wild Shape.",
  19769.                         html: `
  19770.                                 <div id="d20plus-wildformbuild" title="Wild Shape Character Builder">
  19771.                                         <div id="wildformbuild-list">
  19772.                                                 <input type="search" class="search" placeholder="Search creatures...">
  19773.                                                 <input type="search" class="filter" placeholder="Filter...">
  19774.                                                 <span title="Filter format example: 'cr:1/4; cr:1/2; type:beast; source:MM'" style="cursor: help;">[?]</span>
  19775.                                                 <div class="list" style="transform: translateZ(0); max-height: 490px; overflow-y: auto; overflow-x: hidden;"><i>Loading...</i></div>
  19776.                                         </div>
  19777.                                 <br>
  19778.                                 <select id="wildform-character">
  19779.                                         <option value="" disabled selected>Select Character</option>
  19780.                                 </select>
  19781.                                 <button class="btn">Create Character Sheets</button>
  19782.                                 </div>
  19783.                                 `,
  19784.                         dialogFn: () => {
  19785.                                 $("#d20plus-wildformbuild").dialog({
  19786.                                         autoOpen: false,
  19787.                                         resizable: true,
  19788.                                         width: 800,
  19789.                                         height: 650,
  19790.                                 });
  19791.                         },
  19792.                         openFn: async () => {
  19793.                                 const $win = $("#d20plus-wildformbuild");
  19794.                                 $win.dialog("open");
  19795.  
  19796.                                 const $selChar = $(`#wildform-character`);
  19797.                                 $selChar.empty();
  19798.                                 $selChar.append(`<option value="" disabled>Select Character</option>`);
  19799.                                 const allChars = d20.Campaign.characters.toJSON().map(it => {
  19800.                                         const out = {id: it.id, name: it.name || ""};
  19801.                                         const npc = d20.Campaign.characters.get(it.id).attribs.toJSON().find(it => it.name === "npc");
  19802.                                         out.npc = !!(npc && npc.current && Number(npc.current));
  19803.                                         return out;
  19804.                                 });
  19805.                                 let hasNpc = false;
  19806.                                 allChars.sort((a, b) => a.npc - b.npc || SortUtil.ascSort(a.name.toLowerCase(), b.name.toLowerCase()))
  19807.                                         .forEach(it => {
  19808.                                                 if (it.npc && !hasNpc) {
  19809.                                                         $selChar.append(`<option value="" disabled>--NPCs--</option>`);
  19810.                                                         hasNpc = true;
  19811.                                                 }
  19812.                                                 $selChar.append(`<option value="${it.id}">${it.name}</option>`)
  19813.                                         });
  19814.  
  19815.  
  19816.                                 const $fltr = $win.find(`.filter`);
  19817.                                 $fltr.off("keydown").off("keyup");
  19818.                                 $win.find(`button`).off("click");
  19819.  
  19820.                                 const $lst = $win.find(`.list`);
  19821.  
  19822.                                 let tokenList;
  19823.  
  19824.                                 const toLoad = Object.keys(monsterDataUrls).map(src => d20plus.monsters.formMonsterUrl(monsterDataUrls[src]));
  19825.  
  19826.                                 const dataStack = (await Promise.all(toLoad.map(async url => DataUtil.loadJSON(url)))).flat();
  19827.  
  19828.                                 $lst.empty();
  19829.                                 let toShow = [];
  19830.  
  19831.                                 const seen = {};
  19832.                                 await Promise.all(dataStack.map(async d => {
  19833.                                         const toAdd = d.monster.filter(m => {
  19834.                                                 const out = !(seen[m.source] && seen[m.source].has(m.name));
  19835.                                                 if (!seen[m.source]) seen[m.source] = new Set();
  19836.                                                 seen[m.source].add(m.name);
  19837.                                                 return out;
  19838.                                         });
  19839.  
  19840.                                         toShow = toShow.concat(toAdd);
  19841.                                 }));
  19842.  
  19843.                                 toShow = toShow.sort((a, b) => SortUtil.ascSort(a.name, b.name));
  19844.  
  19845.                                 let tmp = "";
  19846.                                 toShow.forEach((m, i)  => {
  19847.                                         m.__pType = Parser.monTypeToFullObj(m.type).asText;
  19848.  
  19849.                                         tmp += `
  19850.                                                                 <label class="import-cb-label" data-listid="${i}">
  19851.                                                                 <input type="checkbox">
  19852.                                                                 <span class="name col-4">${m.name}</span>
  19853.                                                                 <span class="type col-4">TYP[${m.__pType.uppercaseFirst()}]</span>
  19854.                                                                 <span class="cr col-2">${m.cr === undefined ? "CR[Unknown]" : `CR[${(m.cr.cr || m.cr)}]`}</span>
  19855.                                                                 <span title="${Parser.sourceJsonToFull(m.source)}" class="source">SRC[${Parser.sourceJsonToAbv(m.source)}]</span>
  19856.                                                                 </label>
  19857.                                                                 `;
  19858.                                 });
  19859.                                 $lst.html(tmp);
  19860.                                 tmp = null;
  19861.  
  19862.                                 tokenList = new List("wildformbuild-list", {
  19863.                                         valueNames: ["name", "type", "cr", "source"]
  19864.                                 });
  19865.  
  19866.                                 d20plus.importer.addListFilter($fltr, toShow, tokenList, d20plus.monsters._listIndexConverter);
  19867.  
  19868.                                 $win.find(`button`).on("click", () => {
  19869.                                         const allSel = tokenList.items
  19870.                                                 .filter(it => $(it.elm).find(`input`).prop("checked"))
  19871.                                                 .map(it => toShow[$(it.elm).attr("data-listid")]);
  19872.  
  19873.                                         const character = $selChar.val();
  19874.                                         if (!character) return alert("No character selected!");
  19875.  
  19876.                                         const d20Character = d20.Campaign.characters.get(character);
  19877.                                         if (!d20Character) return alert("Failed to get character data!");
  19878.  
  19879.                                         const getAttrib = (name) => d20Character.attribs.toJSON().find(x => x.name === name);
  19880.  
  19881.                                         allSel.filter(it => it).forEach(sel => {
  19882.                                                 sel = $.extend(true, {}, sel);
  19883.  
  19884.                                                 sel.wis = (d20Character.attribs.toJSON().find(x => x.name === "wisdom")|| {}).current || 10;
  19885.                                                 sel.int = (d20Character.attribs.toJSON().find(x => x.name === "intelligence")|| {}).current || 10;
  19886.                                                 sel.cha = (d20Character.attribs.toJSON().find(x => x.name === "charisma")|| {}).current || 10;
  19887.  
  19888.                                                 const attribsSkills = {
  19889.                                                         acrobatics_bonus: "acrobatics",
  19890.                                                         animal_handling_bonus: "animal_handling",
  19891.                                                         arcana_bonus: "arcana",
  19892.                                                         athletics_bonus: "athletics",
  19893.                                                         deception_bonus: "deception",
  19894.                                                         history_bonus: "history",
  19895.                                                         insight_bonus: "insight",
  19896.                                                         intimidation_bonus: "intimidation",
  19897.                                                         investigation_bonus: "investigation",
  19898.                                                         medicine_bonus: "medicine",
  19899.                                                         nature_bonus: "nature",
  19900.                                                         perception_bonus: "perception",
  19901.                                                         performance_bonus: "performance",
  19902.                                                         persuasion_bonus: "persuasion",
  19903.                                                         religion_bonus: "religion",
  19904.                                                         slight_of_hand_bonus: "slight_of_hand",
  19905.                                                         stealth_bonus: "stealth",
  19906.                                                 };
  19907.                                                 const attribsSaves = {
  19908.                                                         npc_int_save: "int",
  19909.                                                         npc_wis_save: "wis",
  19910.                                                         npc_cha_save: "cha"
  19911.                                                 };
  19912.                                                 sel.skill = sel.skill || {};
  19913.                                                 sel.save = sel.save || {};
  19914.  
  19915.                                                 for (const a in attribsSkills) {
  19916.                                                         const characterValue = getAttrib(a);
  19917.                                                         if (characterValue) {
  19918.                                                                 sel.skill[attribsSkills[a]] = Math.max(sel.skill[attribsSkills[a]] || 0, characterValue.current);
  19919.                                                         }
  19920.                                                 }
  19921.  
  19922.                                                 for (const a in attribsSaves) {
  19923.                                                         const characterValue = getAttrib(a);
  19924.                                                         if (characterValue) {
  19925.                                                                 sel.save[attribsSkills[a]] = Math.max(sel.save[attribsSkills[a]] || 0, characterValue.current);
  19926.                                                         }
  19927.                                                 }
  19928.  
  19929.                                                 (() => {
  19930.                                                         const attr = d20plus.sheet === "ogl" ? "passive_wisdom" : d20plus.sheet === "shaped" ? "perception" : "";
  19931.                                                         if (!attr) return;
  19932.                                                         const charAttr = getAttrib(attr);
  19933.                                                         if (!charAttr) return;
  19934.                                                         const passivePer = Number(charAttr.current || 0) + (d20plus.sheet === "shaped" ? 10 : 0);
  19935.                                                         sel.passive = Math.max(passivePer, sel.passive);
  19936.                                                 })();
  19937.  
  19938.                                                 const doBuild = (result) => {
  19939.                                                         const options = {
  19940.                                                                 charFunction: (character) => {
  19941.                                                                         character._getLatestBlob("defaulttoken", y => {
  19942.                                                                                 if (y) {
  19943.                                                                                         const token = JSON.parse(y);
  19944.                                                                                         token.name = `${sel.name} (${d20Character.attributes.name})`;
  19945.                                                                                         token.showplayers_aura1 = true;
  19946.                                                                                         token.showplayers_aura2 = true;
  19947.                                                                                         token.showplayers_bar1 = true;
  19948.                                                                                         token.showplayers_bar2 = true;
  19949.                                                                                         token.showplayers_bar3 = true;
  19950.                                                                                         token.showplayers_name = true;
  19951.                                                                                         token.bar3_max = result.total;
  19952.                                                                                         token.bar3_value = result.total;
  19953.                                                                                         character.updateBlobs({defaulttoken: JSON.stringify(token)});
  19954.                                                                                         character.save({defaulttoken: (new Date()).getTime()});
  19955.                                                                                 }
  19956.                                                                         });
  19957.  
  19958.                                                                         $("a.ui-tabs-anchor[href='#journal']").trigger("click");
  19959.                                                                 },
  19960.                                                                 charOptions: {
  19961.                                                                         inplayerjournals: d20Character.attributes.inplayerjournals,
  19962.                                                                         controlledby: d20Character.attributes.controlledby
  19963.                                                                 }
  19964.                                                         };
  19965.  
  19966.                                                         d20plus.monsters.handoutBuilder(sel, true, false, `Wild Forms - ${d20Character.attributes.name}`, {}, options);
  19967.                                                 };
  19968.  
  19969.                                                 if (sel.hp.formula) d20plus.ut.randomRoll(sel.hp.formula, result => doBuild(result));
  19970.                                                 else doBuild({total: 0});
  19971.                                         });
  19972.                                 });
  19973.  
  19974.                         }
  19975.                 }
  19976.         ]);
  19977.  
  19978.         d20plus.initiativeHeaders = `<div class="header init-header">
  19979. <span class="ui-button-text initmacro init-sheet-header"></span>
  19980. <span class="initiative init-init-header" alt="Initiative" title="Initiative">Init</span>
  19981. <span class="cr" alt="CR" title="CR">CR</span>
  19982. <div class="tracker-header-extra-columns"></div>
  19983. </div>`;
  19984.  
  19985.         d20plus.initiativeTemplate = `<script id="tmpl_initiativecharacter" type="text/html">
  19986. <![CDATA[
  19987.         <li class='token <$ if (this.layer === "gmlayer") { $>gmlayer<$ } $>' data-tokenid='<$!this.id$>' data-currentindex='<$!this.idx$>'>
  19988.                 <$ var token = d20.Campaign.pages.get(d20.Campaign.activePage()).thegraphics.get(this.id); $>
  19989.                 <$ var char = (token) ? token.character : null; $>
  19990.                 <$ if (d20plus.cfg.get("interface", "customTracker") && d20plus.cfg.get("interface", "trackerSheetButton")) { $>
  19991.                         <span alt='Sheet Macro' title='Sheet Macro' class='initmacro'>
  19992.                                 <button type='button' class='initmacrobutton ui-button ui-widget ui-state-default ui-corner-all ui-button-text-only pictos' role='button' aria-disabled='false'>
  19993.                                 <span class='ui-button-text'>N</span>
  19994.                                 </button>
  19995.                         </span>        
  19996.                 <$ } $>
  19997.                 <span alt='Initiative' title='Initiative' class='initiative <$ if (this.iseditable) { $>editable<$ } $>'>
  19998.                         <$!this.pr$>
  19999.                 </span>
  20000.                 <$ if (char) { $>
  20001.                         <$ var npc = char.attribs ? char.attribs.find(function(a){return a.get("name").toLowerCase() == "npc" }) : null; $>
  20002.                 <$ } $>
  20003.                 <div class="tracker-extra-columns">
  20004.                         <!--5ETOOLS_REPLACE_TARGET-->
  20005.                 </div>
  20006.                 <$ if (this.avatar) { $><img src='<$!this.avatar$>' /><$ } $>
  20007.                 <span class='name'><$!this.name$></span>
  20008.                         <div class='clear' style='height: 0px;'></div>
  20009.                         <div class='controls'>
  20010.                 <span class='pictos remove'>#</span>
  20011.                 </div>
  20012.         </li>
  20013. ]]>
  20014. </script>`;
  20015.  
  20016.         d20plus.actionMacroPerception = "%{Selected|npc_perception} @{selected|wtype} &{template:default} {{name=Senses}}  @{selected|wtype} @{Selected|npc_senses} ";
  20017.         d20plus.actionMacroInit = "%{selected|npc_init}";
  20018.         d20plus.actionMacroDrImmunities = "@{selected|wtype} &{template:default} {{name=DR/Immunities}} {{Damage Resistance= @{selected|npc_resistances}}} {{Damage Vulnerability= @{selected|npc_vulnerabilities}}} {{Damage Immunity= @{selected|npc_immunities}}} {{Condition Immunity= @{selected|npc_condition_immunities}}} ";
  20019.         d20plus.actionMacroStats = "@{selected|wtype} &{template:default} {{name=Stats}} {{Armor Class= @{selected|npc_AC}}} {{Hit Dice= @{selected|npc_hpformula}}} {{Speed= @{selected|npc_speed}}} {{Senses= @{selected|npc_senses}}} {{Languages= @{selected|npc_languages}}} {{Challenge= @{selected|npc_challenge}(@{selected|npc_xp}xp)}}";
  20020.         d20plus.actionMacroSaves = "@{selected|wtype} &{template:simple}{{always=1}}?{Saving Throw?|STR,{{rname=Strength Save&#125;&#125;{{mod=@{npc_str_save}&#125;&#125; {{r1=[[1d20+@{npc_str_save}]]&#125;&#125;{{r2=[[1d20+@{npc_str_save}]]&#125;&#125;|DEX,{{rname=Dexterity Save&#125;&#125;{{mod=@{npc_dex_save}&#125;&#125; {{r1=[[1d20+@{npc_dex_save}]]&#125;&#125;{{r2=[[1d20+@{npc_dex_save}]]&#125;&#125;|CON,{{rname=Constitution Save&#125;&#125;{{mod=@{npc_con_save}&#125;&#125; {{r1=[[1d20+@{npc_con_save}]]&#125;&#125;{{r2=[[1d20+@{npc_con_save}]]&#125;&#125;|INT,{{rname=Intelligence Save&#125;&#125;{{mod=@{npc_int_save}&#125;&#125; {{r1=[[1d20+@{npc_int_save}]]&#125;&#125;{{r2=[[1d20+@{npc_int_save}]]&#125;&#125;|WIS,{{rname=Wisdom Save&#125;&#125;{{mod=@{npc_wis_save}&#125;&#125; {{r1=[[1d20+@{npc_wis_save}]]&#125;&#125;{{r2=[[1d20+@{npc_wis_save}]]&#125;&#125;|CHA,{{rname=Charisma Save&#125;&#125;{{mod=@{npc_cha_save}&#125;&#125; {{r1=[[1d20+@{npc_cha_save}]]&#125;&#125;{{r2=[[1d20+@{npc_cha_save}]]&#125;&#125;}{{charname=@{character_name}}} ";
  20021.         d20plus.actionMacroSkillCheck = "@{selected|wtype} &{template:simple}{{always=1}}?{Ability?|Acrobatics,{{rname=Acrobatics&#125;&#125;{{mod=@{npc_acrobatics}&#125;&#125; {{r1=[[1d20+@{npc_acrobatics}]]&#125;&#125;{{r2=[[1d20+@{npc_acrobatics}]]&#125;&#125;|Animal Handling,{{rname=Animal Handling&#125;&#125;{{mod=@{npc_animal_handling}&#125;&#125; {{r1=[[1d20+@{npc_animal_handling}]]&#125;&#125;{{r2=[[1d20+@{npc_animal_handling}]]&#125;&#125;|Arcana,{{rname=Arcana&#125;&#125;{{mod=@{npc_arcana}&#125;&#125; {{r1=[[1d20+@{npc_arcana}]]&#125;&#125;{{r2=[[1d20+@{npc_arcana}]]&#125;&#125;|Athletics,{{rname=Athletics&#125;&#125;{{mod=@{npc_athletics}&#125;&#125; {{r1=[[1d20+@{npc_athletics}]]&#125;&#125;{{r2=[[1d20+@{npc_athletics}]]&#125;&#125;|Deception,{{rname=Deception&#125;&#125;{{mod=@{npc_deception}&#125;&#125; {{r1=[[1d20+@{npc_deception}]]&#125;&#125;{{r2=[[1d20+@{npc_deception}]]&#125;&#125;|History,{{rname=History&#125;&#125;{{mod=@{npc_history}&#125;&#125; {{r1=[[1d20+@{npc_history}]]&#125;&#125;{{r2=[[1d20+@{npc_history}]]&#125;&#125;|Insight,{{rname=Insight&#125;&#125;{{mod=@{npc_insight}&#125;&#125; {{r1=[[1d20+@{npc_insight}]]&#125;&#125;{{r2=[[1d20+@{npc_insight}]]&#125;&#125;|Intimidation,{{rname=Intimidation&#125;&#125;{{mod=@{npc_intimidation}&#125;&#125; {{r1=[[1d20+@{npc_intimidation}]]&#125;&#125;{{r2=[[1d20+@{npc_intimidation}]]&#125;&#125;|Investigation,{{rname=Investigation&#125;&#125;{{mod=@{npc_investigation}&#125;&#125; {{r1=[[1d20+@{npc_investigation}]]&#125;&#125;{{r2=[[1d20+@{npc_investigation}]]&#125;&#125;|Medicine,{{rname=Medicine&#125;&#125;{{mod=@{npc_medicine}&#125;&#125; {{r1=[[1d20+@{npc_medicine}]]&#125;&#125;{{r2=[[1d20+@{npc_medicine}]]&#125;&#125;|Nature,{{rname=Nature&#125;&#125;{{mod=@{npc_nature}&#125;&#125; {{r1=[[1d20+@{npc_nature}]]&#125;&#125;{{r2=[[1d20+@{npc_nature}]]&#125;&#125;|Perception,{{rname=Perception&#125;&#125;{{mod=@{npc_perception}&#125;&#125; {{r1=[[1d20+@{npc_perception}]]&#125;&#125;{{r2=[[1d20+@{npc_perception}]]&#125;&#125;|Performance,{{rname=Performance&#125;&#125;{{mod=@{npc_performance}&#125;&#125; {{r1=[[1d20+@{npc_performance}]]&#125;&#125;{{r2=[[1d20+@{npc_performance}]]&#125;&#125;|Persuasion,{{rname=Persuasion&#125;&#125;{{mod=@{npc_persuasion}&#125;&#125; {{r1=[[1d20+@{npc_persuasion}]]&#125;&#125;{{r2=[[1d20+@{npc_persuasion}]]&#125;&#125;|Religion,{{rname=Religion&#125;&#125;{{mod=@{npc_religion}&#125;&#125; {{r1=[[1d20+@{npc_religion}]]&#125;&#125;{{r2=[[1d20+@{npc_religion}]]&#125;&#125;|Sleight of Hand,{{rname=Sleight of Hand&#125;&#125;{{mod=@{npc_sleight_of_hand}&#125;&#125; {{r1=[[1d20+@{npc_sleight_of_hand}]]&#125;&#125;{{r2=[[1d20+@{npc_sleight_of_hand}]]&#125;&#125;|Stealth,{{rname=Stealth&#125;&#125;{{mod=@{npc_stealth}&#125;&#125; {{r1=[[1d20+@{npc_stealth}]]&#125;&#125;{{r2=[[1d20+@{npc_stealth}]]&#125;&#125;|Survival,{{rname=Survival&#125;&#125;{{mod=@{npc_survival}&#125;&#125; {{r1=[[1d20+@{npc_survival}]]&#125;&#125;{{r2=[[1d20+@{npc_survival}]]&#125;&#125;}{{charname=@{character_name}}} ";
  20022.         d20plus.actionMacroAbilityCheck = "@{selected|wtype} &{template:simple}{{always=1}}?{Ability?|STR,{{rname=Strength&#125;&#125;{{mod=@{strength_mod}&#125;&#125; {{r1=[[1d20+@{strength_mod}]]&#125;&#125;{{r2=[[1d20+@{strength_mod}]]&#125;&#125;|DEX,{{rname=Dexterity&#125;&#125;{{mod=@{dexterity_mod}&#125;&#125; {{r1=[[1d20+@{dexterity_mod}]]&#125;&#125;{{r2=[[1d20+@{dexterity_mod}]]&#125;&#125;|CON,{{rname=Constitution&#125;&#125;{{mod=@{constitution_mod}&#125;&#125; {{r1=[[1d20+@{constitution_mod}]]&#125;&#125;{{r2=[[1d20+@{constitution_mod}]]&#125;&#125;|INT,{{rname=Intelligence&#125;&#125;{{mod=@{intelligence_mod}&#125;&#125; {{r1=[[1d20+@{intelligence_mod}]]&#125;&#125;{{r2=[[1d20+@{intelligence_mod}]]&#125;&#125;|WIS,{{rname=Wisdom&#125;&#125;{{mod=@{wisdom_mod}&#125;&#125; {{r1=[[1d20+@{wisdom_mod}]]&#125;&#125;{{r2=[[1d20+@{wisdom_mod}]]&#125;&#125;|CHA,{{rname=Charisma&#125;&#125;{{mod=@{charisma_mod}&#125;&#125; {{r1=[[1d20+@{charisma_mod}]]&#125;&#125;{{r2=[[1d20+@{charisma_mod}]]&#125;&#125;}{{charname=@{character_name}}} ";
  20023.  
  20024.         d20plus.actionMacroTrait = function (index) {
  20025.                 return "@{selected|wtype} &{template:npcaction} {{name=@{selected|npc_name}}} {{rname=@{selected|repeating_npctrait_$" + index + "_name}}} {{description=@{selected|repeating_npctrait_$" + index + "_desc} }}";
  20026.         };
  20027.  
  20028.         d20plus.actionMacroAction = function (index) {
  20029.                 return "%{selected|repeating_npcaction_$" + index + "_npc_action}";
  20030.         };
  20031.  
  20032.         d20plus.actionMacroReaction = "@{selected|wtype} &{template:npcaction} {{name=@{selected|npc_name}}} {{rname=@{selected|repeating_npcreaction_$0_name}}} {{description=@{selected|repeating_npcreaction_$0_desc} }} ";
  20033.  
  20034.         d20plus.actionMacroLegendary = function (tokenactiontext) {
  20035.                 return "@{selected|wtype} @{selected|wtype}&{template:npcaction} {{name=@{selected|npc_name}}} {{rname=Legendary Actions}} {{description=The @{selected|npc_name} can take @{selected|npc_legendary_actions} legendary actions, choosing from the options below. Only one legendary option can be used at a time and only at the end of another creature's turn. The @{selected|npc_name} regains spent legendary actions at the start of its turn.\n\r" + tokenactiontext + "}} ";
  20036.         }
  20037. };
  20038.  
  20039. SCRIPT_EXTENSIONS.push(betteR205etoolsMain);
  20040.  
  20041.  
  20042. function d20plusImporter () {
  20043.         d20plus.importer = {};
  20044.  
  20045.         d20plus.importer._playerImports = {};
  20046.         d20plus.importer.storePlayerImport = function (id, data) {
  20047.                 d20plus.importer._playerImports[id] = data;
  20048.         };
  20049.  
  20050.         d20plus.importer.retrievePlayerImport = function (id) {
  20051.                 return d20plus.importer._playerImports[id];
  20052.         };
  20053.  
  20054.         d20plus.importer.clearPlayerImport = function () {
  20055.                 d20plus.importer._playerImports = {};
  20056.         };
  20057.  
  20058.         d20plus.importer.addMeta = function (meta) {
  20059.                 if (!meta) return;
  20060.                 BrewUtil._sourceCache = BrewUtil._sourceCache || {};
  20061.                 if (meta.sources) {
  20062.                         meta.sources.forEach(src => {
  20063.                                 BrewUtil._sourceCache[src.json] = {abbreviation: src.abbreviation, full: src.full};
  20064.                         });
  20065.                 }
  20066.                 const cpy = MiscUtil.copy(meta);
  20067.                 delete cpy.sources;
  20068.                 Object.keys(cpy).forEach(k => {
  20069.                         BrewUtil.homebrewMeta[k] = BrewUtil.homebrewMeta[k] || {};
  20070.                         Object.assign(BrewUtil.homebrewMeta[k], cpy[k]);
  20071.                 });
  20072.         };
  20073.  
  20074.         d20plus.importer.getCleanText = function (str) {
  20075.                 const check = jQuery.parseHTML(str);
  20076.                 if (check.length === 1 && check[0].constructor === Text) {
  20077.                         return str;
  20078.                 }
  20079.                 const $ele = $(str);
  20080.                 $ele.find("td, th").append(" | ");
  20081.                 $ele.find("tr").append("\n");
  20082.                 $ele.find("p, li, br").append("\n\n");
  20083.                 $ele.find("li").prepend(" - ");
  20084.  
  20085.                 return $ele.text()
  20086.                         .trim()
  20087.                         .replace(/\n/g, "<<N>>")
  20088.                         .replace(/\s+/g, " ")
  20089.                         .replace(/<<N>>(<<N>>)+/g, "\n\n")
  20090.                         .replace(/<<N>>/g, "\n")
  20091.                         .replace(/\n +/g, "\n");
  20092.  
  20093.                 /* version which preserves images, and converts dice
  20094.         const IMG_TAG = "R20IMGTAG";
  20095.         let imgIndex = 0;
  20096.         const imgStack = [];
  20097.         str.replace(/(<img.*>)/, (match) => {
  20098.                 imgStack.push(match);
  20099.                 return ` ${IMG_TAG}_${imgIndex++} `;
  20100.         });
  20101.         const $ele = $(str);
  20102.         $ele.find("p, li, br").append("\n\n");
  20103.         let out = $ele.text();
  20104.         out = out.replace(DICE_REGEX, (match) => {
  20105.                 return `[[${match}]]`;
  20106.         });
  20107.         return out.replace(/R20IMGTAG_(\d+)/, (match, g1) => {
  20108.                 return imgStack[Number(g1)];
  20109.         });
  20110.         */
  20111.         };
  20112.  
  20113.         // TODO do a pre-pass _before_ this, attempting to link content tags to already-imported 5etools content (by scanning thru GM notes and attempting to parse as JSON -- cache the results of this scan, as it will presumably be super-expensive (need to then invalidate or add to this cache when importing new stuff))
  20114.         // TODO pass rendered text to this, as a post-processing step
  20115.         /**
  20116.          * Attempt to find + swap links to 5e.tools to links to handouts.
  20117.          *
  20118.          * @param str HTML string, usually output by the renderer.
  20119.          */
  20120.         d20plus.importer.tryReplaceLinks = function (str) {
  20121.                 const $temp = $(`<div/>`);
  20122.                 $temp.append(str);
  20123.                 $temp.find(`a`).filter((i, e) => {
  20124.                         const href = $(e).attr("href");
  20125.                         if (!href || !href.trim()) return false;
  20126.                         return href.toLowerCase().startsWith(BASE_SITE_URL);
  20127.                 }).each((i, e) => {
  20128.                         const txt = $(e).text();
  20129.                         // TODO get text, compare against existing handout/character names, and link them using this:
  20130.             //   `http://journal.roll20.net/${id.type}/${id.roll20Id}`;
  20131.                 });
  20132.         };
  20133.  
  20134.         d20plus.importer.doFakeDrop = function (event, characterView, fakeRoll20Json, outerI) {
  20135.                 const t = event; // needs a "target" property, which should be the `.sheet-compendium-drop-target` element on the sheet
  20136.                 const e = characterView; // AKA character.view
  20137.                 const n = fakeRoll20Json;
  20138.                 // var i = $(outerI.helper[0]).attr("data-pagename"); // always undefined, since we're not using a compendium drag-drop element
  20139.                 const i = d20plus.ut.generateRowId();
  20140.  
  20141.                 $(t.target).find("*[accept]").each(function() {
  20142.                         $(this).val(undefined);
  20143.                 });
  20144.  
  20145.                 // BEGIN ROLL20 CODE
  20146.                 var o = _.clone(n.data);
  20147.                 o.Name = n.name,
  20148.                         o.data = JSON.stringify(n.data),
  20149.                         o.uniqueName = i,
  20150.                         o.Content = n.content,
  20151.                         $(t.target).find("*[accept]").each(function() {
  20152.                                 var t = $(this)
  20153.                                         , n = t.attr("accept");
  20154.                                 o[n] && ("input" === t[0].tagName.toLowerCase() && "checkbox" === t.attr("type") ? t.val() == o[n] ? t.prop("checked", !0) : t.prop("checked", !1) : "input" === t[0].tagName.toLowerCase() && "radio" === t.attr("type") ? t.val() == o[n] ? t.prop("checked", !0) : t.prop("checked", !1) : "select" === t[0].tagName.toLowerCase() ? t.find("option").each(function() {
  20155.                                         var e = $(this);
  20156.                                         e.val() !== o[n] && e.text() !== o[n] || e.prop("selected", !0)
  20157.                                 }) : $(this).val(o[n]),
  20158.                                         e.saveSheetValues(this))
  20159.                         })
  20160.                 // END ROLL20 CODE
  20161.         };
  20162.  
  20163.         // caller should run `$iptFilter.off("keydown").off("keyup");` before calling this
  20164.         d20plus.importer.addListFilter = function ($iptFilter, dataList, listObj, listIndexConverter) {
  20165.                 $iptFilter.val("");
  20166.                 const TYPE_TIMEOUT_MS = 100;
  20167.                 let typeTimer;
  20168.                 $iptFilter.on("keyup", () => {
  20169.                         clearTimeout(typeTimer);
  20170.                         typeTimer = setTimeout(() => {
  20171.                                 const exps = $iptFilter.val().split(";");
  20172.                                 const filters = exps.map(it => it.trim())
  20173.                                         .filter(it => it)
  20174.                                         .map(it => it.toLowerCase().split(":"))
  20175.                                         .filter(it => it.length === 2)
  20176.                                         .map(it => ({field: it[0], value: it[1]}));
  20177.                                 const grouped = [];
  20178.                                 filters.forEach(f => {
  20179.                                         const existing = grouped.find(it => it.field === f.field);
  20180.                                         if (existing) existing.values.push(f.value);
  20181.                                         else grouped.push({field: f.field, values: [f.value]})
  20182.                                 });
  20183.  
  20184.                                 listObj.filter((item) => {
  20185.                                         const it = dataList[$(item.elm).attr("data-listid")];
  20186.                                         it._filterVs = it._filterVs || listIndexConverter(it);
  20187.                                         return !grouped.find(f => {
  20188.                                                 if (it._filterVs[f.field]) {
  20189.                                                         if (it._filterVs[f.field] instanceof Array) {
  20190.                                                                 return !(it._filterVs[f.field].find(v => f.values.includes(v)));
  20191.                                                         } else {
  20192.                                                                 return !f.values.includes(it._filterVs[f.field])
  20193.                                                         }
  20194.                                                 }
  20195.                                                 return false;
  20196.                                         });
  20197.                                 });
  20198.                         }, TYPE_TIMEOUT_MS);
  20199.                 });
  20200.                 $iptFilter.on("keydown", () => {
  20201.                         clearTimeout(typeTimer);
  20202.                 });
  20203.         };
  20204.  
  20205.         d20plus.importer.getSetAvatarImage = async function (character, avatar, portraitUrl) {
  20206.                 var tokensize = 1;
  20207.                 if (character.size === "L") tokensize = 2;
  20208.                 if (character.size === "H") tokensize = 3;
  20209.                 if (character.size === "G") tokensize = 4;
  20210.                 var lightradius = null;
  20211.                 if (character.senses && character.senses.toLowerCase().match(/(darkvision|blindsight|tremorsense|truesight)/)) lightradius = Math.max(...character.senses.match(/\d+/g));
  20212.                 var lightmin = 0;
  20213.                 if (character.senses && character.senses.toLowerCase().match(/(blindsight|tremorsense|truesight)/)) lightmin = lightradius;
  20214.                 const nameSuffix = d20plus.cfg.get("import", "namesuffix");
  20215.                 var defaulttoken = {
  20216.                         represents: character.id,
  20217.                         name: `${character.name}${nameSuffix ? ` ${nameSuffix}` : ""}`,
  20218.                         imgsrc: avatar,
  20219.                         width: 70 * tokensize,
  20220.                         height: 70 * tokensize,
  20221.                         compact_bar: d20plus.cfg.getOrDefault("token", "isCompactBars") ? "compact" : "standard"
  20222.                 };
  20223.                 if (!d20plus.cfg.get("import", "skipSenses")) {
  20224.                         defaulttoken.light_hassight = true;
  20225.                         if (lightradius != null) {
  20226.                                 defaulttoken.light_radius = `${lightradius}`;
  20227.                                 defaulttoken.light_dimradius = `${lightmin}`;
  20228.                         }
  20229.                 }
  20230.                 const barLocation = d20plus.cfg.getOrDefault("token", "barLocation");
  20231.                 switch (barLocation) {
  20232.                         case "Above": defaulttoken.bar_location = "above"; break;
  20233.                         case "Top Overlapping": defaulttoken.bar_location = "overlap_top"; break;
  20234.                         case "Bottom Overlapping": defaulttoken.bar_location = "overlap_bottom"; break;
  20235.                         case "Below": defaulttoken.bar_location = "below"; break;
  20236.                 }
  20237.  
  20238.                 // ensure any portrait URL exists
  20239.                 let outPortraitUrl = portraitUrl || avatar;
  20240.                 if (portraitUrl) {
  20241.                         await new Promise(resolve => {
  20242.                                 $.ajax({
  20243.                                         url: portraitUrl,
  20244.                                         type: 'HEAD',
  20245.                                         error: function () {
  20246.                                                 d20plus.ut.error(`Could not access portrait URL "${portraitUrl}"`);
  20247.                                                 outPortraitUrl = avatar;
  20248.                                                 resolve()
  20249.                                         },
  20250.                                         success: () => resolve()
  20251.                                 });
  20252.                         });
  20253.                 }
  20254.  
  20255.                 character.attributes.avatar = outPortraitUrl;
  20256.                 character.updateBlobs({avatar: outPortraitUrl, defaulttoken: JSON.stringify(defaulttoken)});
  20257.                 character.save({defaulttoken: (new Date()).getTime()});
  20258.         };
  20259.  
  20260.         d20plus.importer.addAction = function (character, name, actionText, index) {
  20261.                 if (d20plus.cfg.getOrDefault("import", "tokenactions")) {
  20262.                         character.abilities.create({
  20263.                                 name: index + ": " + name,
  20264.                                 istokenaction: true,
  20265.                                 action: d20plus.actionMacroAction(index)
  20266.                         }).save();
  20267.                 }
  20268.  
  20269.                 const newRowId = d20plus.ut.generateRowId();
  20270.                 let actionDesc = actionText; // required for later reduction of information dump.
  20271.  
  20272.                 function handleAttack () {
  20273.                         const rollBase = d20plus.importer.rollbase(); // macro
  20274.  
  20275.                         let attackType = "";
  20276.                         let attackType2 = "";
  20277.                         if (actionText.indexOf(" Weapon Attack:") > -1) {
  20278.                                 attackType = actionText.split(" Weapon Attack:")[0];
  20279.                                 attackType2 = " Weapon Attack:";
  20280.                         } else if (actionText.indexOf(" Spell Attack:") > -1) {
  20281.                                 attackType = actionText.split(" Spell Attack:")[0];
  20282.                                 attackType2 = " Spell Attack:";
  20283.                         }
  20284.                         let attackRange = "";
  20285.                         let rangeType = "";
  20286.                         if (attackType.indexOf("Melee") > -1) {
  20287.                                 attackRange = (actionText.match(/reach (.*?),/) || ["", ""])[1];
  20288.                                 rangeType = "Reach";
  20289.                         } else {
  20290.                                 attackRange = (actionText.match(/range (.*?),/) || ["", ""])[1];
  20291.                                 rangeType = "Range";
  20292.                         }
  20293.                         const toHit = (actionText.match(/\+(.*?) to hit/) || ["", ""])[1];
  20294.                         let damage = "";
  20295.                         let damageType = "";
  20296.                         let damage2 = "";
  20297.                         let damageType2 = "";
  20298.                         let onHit = "";
  20299.                         let damageRegex = /\d+ \((\d+d\d+\s?(?:\+|-)?\s?\d*)\) (\S+ )?damage/g;
  20300.                         let damageSearches = damageRegex.exec(actionText);
  20301.                         if (damageSearches) {
  20302.                                 onHit = damageSearches[0];
  20303.                                 damage = damageSearches[1];
  20304.                                 damageType = (damageSearches[2] != null) ? damageSearches[2].trim() : "";
  20305.                                 damageSearches = damageRegex.exec(actionText);
  20306.                                 if (damageSearches) {
  20307.                                         onHit += " plus " + damageSearches[0];
  20308.                                         damage2 = damageSearches[1];
  20309.                                         damageType2 = (damageSearches[2] != null) ? damageSearches[2].trim() : "";
  20310.                                 }
  20311.                         }
  20312.                         onHit = onHit.trim();
  20313.                         const attackTarget = ((actionText.match(/\.,(?!.*\.,)(.*)\. Hit:/) || ["", ""])[1] || "").trim();
  20314.                         // Cut the information dump in the description
  20315.                         const atkDescSimpleRegex = /Hit: \d+ \((\d+d\d+\s?(?:\+|-)?\s?\d*)\) (\S+ )?damage\.([\s\S]*)/gm;
  20316.                         const atkDescComplexRegex = /(Hit:[\s\S]*)/g;
  20317.                         // is it a simple attack (just 1 damage type)?
  20318.                         const match_simple_atk = atkDescSimpleRegex.exec(actionText);
  20319.                         if (match_simple_atk != null) {
  20320.                                 //if yes, then only display special effects, if any
  20321.                                 actionDesc = match_simple_atk[3].trim();
  20322.                         } else {
  20323.                                 //if not, simply cut everything before "Hit:" so there are no details lost.
  20324.                                 const matchCompleteAtk = atkDescComplexRegex.exec(actionText);
  20325.                                 if (matchCompleteAtk != null) actionDesc = matchCompleteAtk[1].trim();
  20326.                         }
  20327.                         const toHitRange = "+" + toHit + ", " + rangeType + " " + attackRange + ", " + attackTarget + ".";
  20328.                         const damageFlags = `{{damage=1}} {{dmg1flag=1}}${damage2 ? ` {{dmg2flag=1}}` : ""}`;
  20329.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_name", current: name}).save();
  20330.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_flag", current: "on"}).save();
  20331.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_npc_options-flag", current: "0"}).save();
  20332.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_display_flag", current: "{{attack=1}}"}).save();
  20333.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_options", current: "{{attack=1}}"}).save();
  20334.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_tohit", current: toHit}).save();
  20335.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_damage", current: damage}).save();
  20336.                         // TODO this might not be necessary on Shaped sheets?
  20337.                         const critDamage = (damage || "").trim().replace(/[-+]\s*\d+$/, "").trim(); // replace any trailing modifiers e.g. "+5"
  20338.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_crit", current: critDamage}).save();
  20339.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_damagetype", current: damageType}).save();
  20340.                         if (damage2) {
  20341.                                 character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_damage2", current: damage2}).save();
  20342.                                 character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_crit2", current: damage2}).save();
  20343.                                 character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_damagetype2", current: damageType2}).save();
  20344.                         }
  20345.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_name_display", current: name}).save();
  20346.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_rollbase", current: rollBase}).save();
  20347.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_type", current: attackType}).save();
  20348.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_type_display", current: attackType + attackType2}).save();
  20349.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_tohitrange", current: toHitRange}).save();
  20350.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_range", current: attackRange}).save();
  20351.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_target", current: attackTarget}).save();
  20352.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_damage_flag", current: damageFlags}).save();
  20353.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_onhit", current: onHit}).save();
  20354.  
  20355.                         const descriptionFlag = Math.max(Math.ceil(actionText.length / 57), 1);
  20356.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_description", current: actionDesc}).save();
  20357.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_description_flag", current: descriptionFlag}).save();
  20358.  
  20359.                         // hidden = a single space
  20360.                         const descVisFlag = d20plus.cfg.getOrDefault("import", "hideActionDescs") ? " " : "@{description}";
  20361.                         character.attribs.create({name: `repeating_npcaction_${newRowId}_show_desc`, current: descVisFlag}).save();
  20362.                 }
  20363.  
  20364.                 function handleOtherAction () {
  20365.                         const rollBase = d20plus.importer.rollbase(false); // macro
  20366.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_name", current: name}).save();
  20367.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_npc_options-flag", current: "0"}).save();
  20368.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_description", current: actionDesc}).save();
  20369.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_tohitrange", current: "+0"}).save();
  20370.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_onhit", current: ""}).save();
  20371.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_damage_flag", current: ""}).save();
  20372.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_crit", current: ""}).save();
  20373.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_attack_crit2", current: ""}).save();
  20374.                         character.attribs.create({name: "repeating_npcaction_" + newRowId + "_rollbase", current: rollBase}).save();
  20375.                 }
  20376.  
  20377.                 // attack parsing
  20378.                 if (actionText.includes(" Attack:")) handleAttack();
  20379.                 else handleOtherAction();
  20380.         };
  20381.  
  20382.         d20plus.importer.findAttrId = function (character, attrName) {
  20383.                 const found = character.attribs.toJSON().find(a => a.name === attrName);
  20384.                 return found ? found.id : undefined;
  20385.         };
  20386.  
  20387.         d20plus.importer.addOrUpdateAttr = function (character, attrName, value) {
  20388.                 const id = d20plus.importer.findAttrId(character, attrName);
  20389.                 if (id) {
  20390.                         const it = character.attribs.get(id).set("current", value);
  20391.                         it.save();
  20392.                 } else {
  20393.                         const it = character.attribs.create({
  20394.                                 "name": attrName,
  20395.                                 "current": value
  20396.                         });
  20397.                         it.save();
  20398.                 }
  20399.         };
  20400.  
  20401.         d20plus.importer.makePlayerDraggable = function (importId, name) {
  20402.                 const $appTo = $(`#d20plus-playerimport`).find(`.Vetools-player-imported`);
  20403.                 const $li = $(`
  20404.                 <li class="journalitem dd-item handout ui-draggable compendium-item Vetools-draggable player-imported" data-playerimportid="${importId}">
  20405.                         <div class="dd-handle dd-sortablehandle">Drag</div>
  20406.                         <div class="dd-content">
  20407.                                 <div class="token"><img src="/images/handout.png" draggable="false"></div>
  20408.                                 <div class="name">
  20409.                                         <div class="namecontainer">${name}</div>
  20410.                                 </div>
  20411.                         </div>
  20412.                 </li>
  20413.         `);
  20414.                 $li.draggable({
  20415.                         revert: true,
  20416.                         distance: 10,
  20417.                         revertDuration: 0,
  20418.                         helper: "clone",
  20419.                         handle: ".namecontainer",
  20420.                         appendTo: "body",
  20421.                         scroll: true,
  20422.                         start: function () {
  20423.                                 console.log("drag start")
  20424.                         },
  20425.                         stop: function () {
  20426.                                 console.log("drag stop")
  20427.                         }
  20428.                 });
  20429.                 $appTo.prepend($li);
  20430.         };
  20431.  
  20432.         d20plus.importer.getTagString = function (data, prefix) {
  20433.                 return JSON.stringify(data.filter(it => it).map(d => `${prefix}-${Parser.stringToSlug(d.toString())}`).concat([prefix]));
  20434.         };
  20435.  
  20436.         // from OGL sheet, Aug 2018
  20437.         d20plus.importer.rollbase = (isAttack = true) => {
  20438.                 const dtype = d20plus.importer.getDesiredDamageType();
  20439.                 if (dtype === "full") {
  20440.                         return `@{wtype}&{template:npcaction} ${isAttack ? `{{attack=1}}` : ""} @{damage_flag} @{npc_name_flag} {{rname=@{name}}} {{r1=[[@{d20}+(@{attack_tohit}+0)]]}} @{rtype}+(@{attack_tohit}+0)]]}} {{dmg1=[[@{attack_damage}+0]]}} {{dmg1type=@{attack_damagetype}}} {{dmg2=[[@{attack_damage2}+0]]}} {{dmg2type=@{attack_damagetype2}}} {{crit1=[[@{attack_crit}+0]]}} {{crit2=[[@{attack_crit2}+0]]}} {{description=@{show_desc}}} @{charname_output}`;
  20441.                 } else {
  20442.                         return `@{wtype}&{template:npcatk} ${isAttack ? `{{attack=1}}` : ""} @{damage_flag} @{npc_name_flag} {{rname=[@{name}](~repeating_npcaction_npc_dmg)}} {{rnamec=[@{name}](~repeating_npcaction_npc_crit)}} {{type=[Attack](~repeating_npcaction_npc_dmg)}} {{typec=[Attack](~repeating_npcaction_npc_crit)}} {{r1=[[@{d20}+(@{attack_tohit}+0)]]}} @{rtype}+(@{attack_tohit}+0)]]}} {{description=@{show_desc}}} @{charname_output}`;
  20443.                 }
  20444.  
  20445.         };
  20446.  
  20447.         d20plus.importer.getDesiredRollType = function () {
  20448.                 // rtype
  20449.                 const toggle = "@{advantagetoggle}";
  20450.                 const never = "{{normal=1}} {{r2=[[0d20";
  20451.                 const always = "{{always=1}} {{r2=[[@{d20}";
  20452.                 const query = "{{query=1}} ?{Advantage?|Normal Roll,&#123&#123normal=1&#125&#125 &#123&#123r2=[[0d20|Advantage,&#123&#123advantage=1&#125&#125 &#123&#123r2=[[@{d20}|Disadvantage,&#123&#123disadvantage=1&#125&#125 &#123&#123r2=[[@{d20}}";
  20453.                 const desired = d20plus.cfg.get("import", "advantagemode");
  20454.                 if (desired) {
  20455.                         switch (desired) {
  20456.                                 case "Toggle (Default Advantage)":
  20457.                                 case "Toggle":
  20458.                                 case "Toggle (Default Disadvantage)":
  20459.                                         return toggle;
  20460.                                 case "Always":
  20461.                                         return always;
  20462.                                 case "Query":
  20463.                                         return query;
  20464.                                 case "Never":
  20465.                                         return never;
  20466.                         }
  20467.                 } else {
  20468.                         return toggle;
  20469.                 }
  20470.         };
  20471.  
  20472.         d20plus.importer.getDesiredAdvantageToggle = function () {
  20473.                 // advantagetoggle
  20474.                 const advantage = "{{query=1}} {{advantage=1}} {{r2=[[@{d20}";
  20475.                 const disadvantage = "{{query=1}} {{disadvantage=1}} {{r2=[[@{d20}";
  20476.                 const desired = d20plus.cfg.get("import", "advantagemode");
  20477.                 const neither = "";
  20478.                 if (desired) {
  20479.                         switch (desired) {
  20480.                                 case "Toggle (Default Advantage)":
  20481.                                         return advantage;
  20482.                                 case "Toggle (Default Disadvantage)":
  20483.                                         return desired;
  20484.                                 case "Toggle":
  20485.                                 case "Always":
  20486.                                 case "Query":
  20487.                                 case "Never":
  20488.                                         return neither;
  20489.                         }
  20490.                 } else {
  20491.                         return neither;
  20492.                 }
  20493.         };
  20494.  
  20495.         d20plus.importer.getDesiredWhisperType = function () {
  20496.                 // wtype
  20497.                 const toggle = "@{whispertoggle}";
  20498.                 const never = " ";
  20499.                 const always = "/w gm ";
  20500.                 const query = "?{Whisper?|Public Roll,|Whisper Roll,/w gm }";
  20501.                 const desired = d20plus.cfg.get("import", "whispermode");
  20502.                 if (desired) {
  20503.                         switch (desired) {
  20504.                                 case "Toggle (Default GM)":
  20505.                                 case "Toggle (Default Public)":
  20506.                                         return toggle;
  20507.                                 case "Always":
  20508.                                         return always;
  20509.                                 case "Query":
  20510.                                         return query;
  20511.                                 case "Never":
  20512.                                         return never;
  20513.                         }
  20514.                 } else {
  20515.                         return toggle;
  20516.                 }
  20517.         };
  20518.  
  20519.         d20plus.importer.getDesiredWhisperToggle = function () {
  20520.                 // whispertoggle
  20521.                 const gm = "/w gm ";
  20522.                 const pblic = " ";
  20523.                 const desired = d20plus.cfg.get("import", "whispermode");
  20524.                 if (desired) {
  20525.                         switch (desired) {
  20526.                                 case "Toggle (Default GM)":
  20527.                                         return gm;
  20528.                                 case "Toggle (Default Public)":
  20529.                                         return pblic;
  20530.                                 case "Always":
  20531.                                         return "";
  20532.                                 case "Query":
  20533.                                         return "";
  20534.                                 case "Never":
  20535.                                         return "";
  20536.                         }
  20537.                 } else {
  20538.                         return gm;
  20539.                 }
  20540.         };
  20541.  
  20542.         d20plus.importer.getDesiredDamageType = function () {
  20543.                 // dtype
  20544.                 const on = "full";
  20545.                 const off = "pick";
  20546.                 const desired = d20plus.cfg.get("import", "damagemode");
  20547.                 if (desired) {
  20548.                         switch (desired) {
  20549.                                 case "Auto Roll":
  20550.                                         return on;
  20551.                                 case "Don't Auto Roll":
  20552.                                         return off;
  20553.                         }
  20554.                 } else {
  20555.                         return on;
  20556.                 }
  20557.         };
  20558.  
  20559.         d20plus.importer.importModeSwitch = function () {
  20560.                 d20plus.importer.clearPlayerImport();
  20561.                 const $winPlayer = $(`#d20plus-playerimport`).find(`.append-list-journal`).empty();
  20562.  
  20563.                 $(`.importer-section`).hide();
  20564.                 const toShow = $(`#import-mode-select`).val();
  20565.                 $(`#betteR20-settings`).find(`.importer-section[data-import-group="${toShow}"]`).show();
  20566.                 const toShowPlayer = $(`#import-mode-select-player`).val();
  20567.                 $(`#d20plus-playerimport`).find(`.importer-section[data-import-group="${toShowPlayer}"]`).show();
  20568.         };
  20569.  
  20570.         d20plus.importer.showImportList = async function (dataType, dataArray, handoutBuilder, options) {
  20571.                 if (!options) options = {};
  20572.                 /*
  20573.                 options = {
  20574.                         groupOptions: ["Source", "CR", "Alphabetical", "Type"],
  20575.                         forcePlayer: true,
  20576.                         callback: () => console.log("hello world"),
  20577.                         saveIdsTo: {}, // object to receive IDs of created handouts/creatures
  20578.                         // these three generally used together
  20579.                         listItemBuilder: (it) => `<span class="name col-8">${it.name}</span><span title="${Parser.sourceJsonToFull(it.source)}" class="source col-4">${it.cr ? `(CR ${it.cr.cr || it.cr}) ` : ""}(${Parser.sourceJsonToAbv(it.source)})</span>`,
  20580.                         listIndex: ["name", "source"],
  20581.                         listIndexConverter: (mon) => {
  20582.                                 name: mon.name.toLowerCase(),
  20583.                                 source: Parser.sourceJsonToAbv(m.source).toLowerCase() // everything is assumed to be lowercase
  20584.                         },
  20585.                         nextStep: (doImport, originalImportQueue) {
  20586.                                 const modifiedImportQueue = originalImportQueue.map(it => JSON.stringify(JSON.parse(it));
  20587.                                 doImport(modifiedImportQueue);
  20588.                         },
  20589.                         builderOptions: {
  20590.                                 (...passed to handoutBuilder depending on requirements...)
  20591.                         }
  20592.                 }
  20593.                  */
  20594.                 $("a.ui-tabs-anchor[href='#journal']").trigger("click");
  20595.  
  20596.                 if (!window.is_gm || options.forcePlayer) {
  20597.                         d20plus.importer.clearPlayerImport();
  20598.                         const $winPlayer = $(`#d20plus-playerimport`);
  20599.                         const $appPlayer = $winPlayer.find(`.append-list-journal`);
  20600.                         $appPlayer.empty();
  20601.                         $appPlayer.append(`<ol class="dd-list Vetools-player-imported" style="max-width: 95%;"/>`);
  20602.                 }
  20603.  
  20604.                 // sort data
  20605.                 dataArray.sort((a, b) => SortUtil.ascSort(a.name, b.name));
  20606.  
  20607.                 // collect available properties
  20608.                 const propSet = {}; // represent this as an object instead of a set, to maintain some semblance of ordering
  20609.                 dataArray.map(it => Object.keys(it)).forEach(keys => keys.forEach(k => propSet[k] = true));
  20610.  
  20611.                 // build checkbox list
  20612.                 const $list = $("#import-list .list");
  20613.                 $list.html("");
  20614.                 dataArray.forEach((it, i) => {
  20615.                         if (it.noDisplay) return;
  20616.  
  20617.                         const inner = options.listItemBuilder
  20618.                                 ? options.listItemBuilder(it)
  20619.                                 :  `<span class="name col-10">${it.name}</span><span class="source" title="${Parser.sourceJsonToFull(it.source)}">${Parser.sourceJsonToAbv(it.source)}</span>`;
  20620.  
  20621.                         $list.append(`
  20622.                         <label class="import-cb-label" data-listid="${i}">
  20623.                                 <input type="checkbox">
  20624.                                 ${inner}
  20625.                         </label>
  20626.                 `);
  20627.                 });
  20628.  
  20629.                 // init list library
  20630.                 const importList = new List("import-list", {
  20631.                         valueNames: options.listIndex || ["name"]
  20632.                 });
  20633.  
  20634.                 // reset the UI and add handlers
  20635.                 $(`#import-list > .search`).val("");
  20636.                 importList.search("");
  20637.                 $("#import-options label").hide();
  20638.                 $("#import-overwrite").parent().show();
  20639.                 $("#import-showplayers").parent().show();
  20640.                 $("#organize-by").parent().show();
  20641.                 $("#d20plus-importlist").dialog("open");
  20642.  
  20643.                 $("#d20plus-importlist button").unbind("click");
  20644.  
  20645.                 $("#importlist-selectall").bind("click", () => {
  20646.                         d20plus.importer._importSelectAll(importList);
  20647.                 });
  20648.                 $("#importlist-deselectall").bind("click", () => {
  20649.                         d20plus.importer._importDeselectAll(importList);
  20650.                 });
  20651.                 $("#importlist-selectvis").bind("click", () => {
  20652.                         d20plus.importer._importSelectVisible(importList);
  20653.                 });
  20654.                 $("#importlist-deselectvis").bind("click", () => {
  20655.                         d20plus.importer._importDeselectVisible(importList);
  20656.                 });
  20657.  
  20658.                 $("#importlist-selectall-published").bind("click", () => {
  20659.                         d20plus.importer._importSelectPublished(importList);
  20660.                 });
  20661.  
  20662.                 if (options.listIndexConverter) {
  20663.                         const $iptFilter = $(`#import-list-filter`).show();
  20664.                         $(`#import-list-filter-help`).show();
  20665.                         $iptFilter.off("keydown").off("keyup");
  20666.                         d20plus.importer.addListFilter($iptFilter, dataArray, importList, options.listIndexConverter);
  20667.                 } else {
  20668.                         $(`#import-list-filter`).hide();
  20669.                         $(`#import-list-filter-help`).hide();
  20670.                 }
  20671.  
  20672.                 const excludedProps = new Set();
  20673.                 const $winProps = $("#d20plus-import-props");
  20674.                 $winProps.find(`button`).bind("click", () => {
  20675.                         excludedProps.clear();
  20676.                         $winProps.find(`.prop-row`).each((i, ele) => {
  20677.                                 if (!$(ele).find(`input`).prop("checked")) excludedProps.add($(ele).find(`span`).text());
  20678.                         });
  20679.                 });
  20680.                 const $btnProps = $(`#save-import-props`);
  20681.                 $btnProps.bind("click", () => {
  20682.                         $winProps.dialog("close");
  20683.                 });
  20684.                 const $props = $winProps.find(`.select-props`);
  20685.                 $props.empty();
  20686.                 $(`#import-open-props`).bind("click", () => {
  20687.                         Object.keys(propSet).forEach(p => {
  20688.                                 const req = REQUIRED_PROPS[dataType] && REQUIRED_PROPS[dataType].includes(p);
  20689.                                 $props.append(`
  20690.                                         <label style="display: block; ${req ? "color: red;" : ""}" class="prop-row">
  20691.                                                 <input type="checkbox" checked="true">
  20692.                                                 <span>${p}</span>
  20693.                                         </label>
  20694.                                 `)
  20695.                         });
  20696.                         $winProps.dialog("open");
  20697.                 });
  20698.  
  20699.                 const $selGroupBy = $(`#organize-by`);
  20700.                 $selGroupBy.html("");
  20701.                 options.groupOptions = (options.groupOptions || ["Alphabetical", "Source"]).concat(["None"]);
  20702.                 options.groupOptions.forEach(g => {
  20703.                         $selGroupBy.append(`<option value="${g}">${g}</option>`);
  20704.                 });
  20705.                 const storageKeyGroupBy = `Veconfig-importer-groupby-${dataType}`;
  20706.                 $selGroupBy.on("change", () => {
  20707.                         StorageUtil.pSet(storageKeyGroupBy, $selGroupBy.val())
  20708.                 })
  20709.                 try {
  20710.                         const savedSelection = await StorageUtil.pGet(storageKeyGroupBy);
  20711.                         if (savedSelection) {
  20712.                                 $selGroupBy.val(savedSelection);
  20713.                         }
  20714.                 } catch (e) {
  20715.                         console.error("Failed to set group from saved!");
  20716.                 }
  20717.  
  20718.                 const $cbShowPlayers = $("#import-showplayers");
  20719.                 $cbShowPlayers.prop("checked", dataType !== "monster");
  20720.  
  20721.                 const $btnImport = $("#d20plus-importlist button#importstart");
  20722.                 $btnImport.text(options.nextStep ? "Next" : "Import");
  20723.                 $btnImport.bind("click", function () {
  20724.                         $("#d20plus-importlist").dialog("close");
  20725.                         const overwrite = $("#import-overwrite").prop("checked");
  20726.                         const inJournals = $cbShowPlayers.prop("checked") ? "all" : "";
  20727.                         const groupBy = $(`#organize-by`).val();
  20728.  
  20729.                         // build list of items to process
  20730.                         const importQueue = [];
  20731.                         importList.items.forEach((e) => {
  20732.                                 if ($(e.elm).find("input").prop("checked")) {
  20733.                                         const dataIndex = parseInt($(e.elm).data("listid"));
  20734.                                         const it = dataArray[dataIndex];
  20735.                                         importQueue.push(it);
  20736.                                 }
  20737.                         });
  20738.  
  20739.                         if (!importQueue.length) return;
  20740.  
  20741.                         const doImport = (importQueue) => {
  20742.                                 const $stsName = $("#import-name");
  20743.                                 const $stsRemain = $("#import-remaining");
  20744.                                 const $title = $stsName.parent().parent().find("span.ui-dialog-title");
  20745.                                 $title.text("Importing");
  20746.  
  20747.                                 let remaining = importQueue.length;
  20748.  
  20749.                                 let interval;
  20750.                                 if (dataType === "monster" || dataType === "object") {
  20751.                                         interval = d20plus.cfg.get("import", "importIntervalCharacter") || d20plus.cfg.getDefault("import", "importIntervalCharacter");
  20752.                                 } else {
  20753.                                         interval = d20plus.cfg.get("import", "importIntervalHandout") || d20plus.cfg.getDefault("import", "importIntervalHandout");
  20754.                                 }
  20755.  
  20756.                                 let cancelWorker = false;
  20757.                                 const $btnCancel = $(`#importcancel`);
  20758.  
  20759.                                 $btnCancel.off();
  20760.                                 $btnCancel.on("click", () => {
  20761.                                         cancelWorker = true;
  20762.                                         handleWorkerComplete();
  20763.                                 });
  20764.  
  20765.                                 const $remainingText = $("#import-remaining-text");
  20766.                                 $btnCancel.removeClass("btn-success");
  20767.                                 $btnCancel.text("Cancel");
  20768.  
  20769.                                 $remainingText.text("remaining");
  20770.  
  20771.                                 // start worker to process list
  20772.                                 $("#d20plus-import").dialog("open");
  20773.  
  20774.                                 // run one immediately
  20775.                                 let worker;
  20776.                                 workerFn();
  20777.                                 worker = setInterval(() => {
  20778.                                         workerFn();
  20779.                                 }, interval);
  20780.  
  20781.                                 function workerFn() {
  20782.                                         if (!importQueue.length) {
  20783.                                                 handleWorkerComplete();
  20784.                                                 return;
  20785.                                         }
  20786.                                         if (cancelWorker) {
  20787.                                                 return;
  20788.                                         }
  20789.  
  20790.                                         // pull items out the queue in LIFO order, for journal ordering (last created will be at the top)
  20791.                                         let it = importQueue.pop();
  20792.                                         it.name = it.name || "(Unknown)";
  20793.  
  20794.                                         $stsName.text(it.name);
  20795.                                         $stsRemain.text(remaining--);
  20796.  
  20797.                                         if (excludedProps.size) {
  20798.                                                 it = JSON.parse(JSON.stringify(it));
  20799.                                                 [...excludedProps].forEach(k => delete it[k]);
  20800.                                         }
  20801.  
  20802.                                         if (!window.is_gm || options.forcePlayer) {
  20803.                                                 handoutBuilder(it, undefined, undefined, undefined, undefined, options.builderOptions);
  20804.                                         } else {
  20805.                                                 const folderName = groupBy === "None" ? "" : d20plus.importer._getHandoutPath(dataType, it, groupBy);
  20806.                                                 const builderOptions = Object.assign({}, options.builderOptions || {});
  20807.                                                 if (dataType === "spell" && groupBy === "Spell Points") builderOptions.isSpellPoints = true;
  20808.                                                 handoutBuilder(it, overwrite, inJournals, folderName, options.saveIdsTo, builderOptions);
  20809.                                         }
  20810.                                 }
  20811.  
  20812.                                 function handleWorkerComplete() {
  20813.                                         if (worker) clearInterval(worker);
  20814.  
  20815.                                         if (cancelWorker) {
  20816.                                                 $title.text("Import cancelled");
  20817.                                                 $stsName.text("");
  20818.                                                 if (~$stsRemain.text().indexOf("(cancelled)")) $stsRemain.text(`${$stsRemain.text()} (cancelled)`);
  20819.                                                 d20plus.ut.log(`Import cancelled`);
  20820.                                                 setTimeout(() => {
  20821.                                                         d20plus.bindDropLocations();
  20822.                                                 }, 250);
  20823.                                         } else {
  20824.                                                 $title.text("Import complete");
  20825.                                                 $stsName.text("");
  20826.                                                 $btnCancel.addClass("btn-success");
  20827.                                                 $btnCancel.prop("title", "");
  20828.  
  20829.                                                 $stsRemain.text("0");
  20830.                                                 d20plus.ut.log(`Import complete`);
  20831.                                                 setTimeout(() => {
  20832.                                                         d20plus.bindDropLocations();
  20833.                                                 }, 250);
  20834.                                                 if (options.callback) options.callback();
  20835.                                         }
  20836.  
  20837.                                         $btnCancel.off();
  20838.                                         $btnCancel.on("click", () => $btnCancel.closest('.ui-dialog-content').dialog('close'));
  20839.  
  20840.                                         $btnCancel.first().text("OK");
  20841.                                         $remainingText.empty();
  20842.                                         $stsRemain.empty();
  20843.                                 }
  20844.                         };
  20845.  
  20846.                         if (options.nextStep) {
  20847.                                 if (importQueue.length) {
  20848.                                         options.nextStep(doImport, importQueue)
  20849.                                 }
  20850.                         } else {
  20851.                                 doImport(importQueue);
  20852.                         }
  20853.                 });
  20854.         };
  20855.  
  20856.         d20plus.importer._getHandoutPath = function (dataType, it, groupBy) {
  20857.                 switch (dataType) {
  20858.                         case "monster": {
  20859.                                 let folderName;
  20860.                                 switch (groupBy) {
  20861.                                         case "Source":
  20862.                                                 folderName = Parser.sourceJsonToFull(it.source);
  20863.                                                 break;
  20864.                                         case "CR":
  20865.                                                 folderName = it.cr ? (it.cr.cr || it.cr) : "Unknown";
  20866.                                                 break;
  20867.                                         case "Alphabetical":
  20868.                                                 folderName = it.name[0].uppercaseFirst();
  20869.                                                 break;
  20870.                                         case "Type (with tags)":
  20871.                                                 folderName = Parser.monTypeToFullObj(it.type).asText.uppercaseFirst();
  20872.                                                 break;
  20873.                                         case "CR → Type":
  20874.                                                 folderName = [it.cr ? (it.cr.cr || it.cr) : "Unknown", Parser.monTypeToFullObj(it.type).type.uppercaseFirst()];
  20875.                                                 break;
  20876.                                         case "Type":
  20877.                                         default:
  20878.                                                 folderName = Parser.monTypeToFullObj(it.type).type.uppercaseFirst();
  20879.                                                 break;
  20880.                                 }
  20881.                                 return folderName;
  20882.                         }
  20883.                         case "spell": {
  20884.                                 let folderName;
  20885.                                 switch (groupBy) {
  20886.                                         case "Source":
  20887.                                                 folderName = Parser.sourceJsonToFull(it.source);
  20888.                                                 break;
  20889.                                         case "Alphabetical":
  20890.                                                 folderName = it.name[0].uppercaseFirst();
  20891.                                                 break;
  20892.                                         case "Spell Points":
  20893.                                                 folderName = `${d20plus.spells.spLevelToSpellPoints(it.level)} spell points`;
  20894.                                                 break;
  20895.                                         case "Level":
  20896.                                         default:
  20897.                                                 folderName = `${Parser.spLevelToFull(it.level)}${it.level ? " level" : ""}`;
  20898.                                                 break;
  20899.                                 }
  20900.                                 return folderName;
  20901.                         }
  20902.                         case "item": {
  20903.                                 let folderName;
  20904.                                 switch (groupBy) {
  20905.                                         case "Source":
  20906.                                                 folderName = Parser.sourceJsonToFull(it.source);
  20907.                                                 break;
  20908.                                         case "Rarity":
  20909.                                                 folderName = it.rarity;
  20910.                                                 break;
  20911.                                         case "Alphabetical":
  20912.                                                 folderName = it.name[0].uppercaseFirst();
  20913.                                                 break;
  20914.                                         case "Type":
  20915.                                         default:
  20916.                                                 if (it.type) {
  20917.                                                         folderName = Parser.itemTypeToFull(it.type);
  20918.                                                 } else if (it._typeListText) {
  20919.                                                         folderName = it._typeListText.join(", ");
  20920.                                                 } else {
  20921.                                                         folderName = "Unknown";
  20922.                                                 }
  20923.                                                 break;
  20924.                                 }
  20925.                                 return folderName;
  20926.                         }
  20927.                         case "psionic": {
  20928.                                 let folderName;
  20929.                                 switch (groupBy) {
  20930.                                         case "Source":
  20931.                                                 folderName = Parser.sourceJsonToFull(it.source);
  20932.                                                 break;
  20933.                                         case "Order":
  20934.                                                 folderName = Parser.psiOrderToFull(it.order);
  20935.                                                 break;
  20936.                                         case "Alphabetical":
  20937.                                         default:
  20938.                                                 folderName = it.name[0].uppercaseFirst();
  20939.                                                 break;
  20940.                                 }
  20941.                                 return folderName;
  20942.                         }
  20943.                         case "feat": {
  20944.                                 let folderName;
  20945.                                 switch (groupBy) {
  20946.                                         case "Source":
  20947.                                                 folderName = Parser.sourceJsonToFull(it.source);
  20948.                                                 break;
  20949.                                         case "Alphabetical":
  20950.                                         default:
  20951.                                                 folderName = it.name[0].uppercaseFirst();
  20952.                                                 break;
  20953.                                 }
  20954.                                 return folderName;
  20955.                         }
  20956.                         case "object": {
  20957.                                 let folderName;
  20958.                                 switch (groupBy) {
  20959.                                         case "Source":
  20960.                                                 folderName = Parser.sourceJsonToFull(it.source);
  20961.                                                 break;
  20962.                                         case "Alphabetical":
  20963.                                         default:
  20964.                                                 folderName = it.name[0].uppercaseFirst();
  20965.                                                 break;
  20966.                                 }
  20967.                                 return folderName;
  20968.                         }
  20969.                         case "class": {
  20970.                                 let folderName;
  20971.                                 switch (groupBy) {
  20972.                                         case "Source":
  20973.                                                 folderName = Parser.sourceJsonToFull(it.source);
  20974.                                                 break;
  20975.                                         case "Alphabetical":
  20976.                                         default:
  20977.                                                 folderName = it.name[0].uppercaseFirst();
  20978.                                                 break;
  20979.                                 }
  20980.                                 return folderName;
  20981.                         }
  20982.                         case "subclass": {
  20983.                                 let folderName;
  20984.                                 switch (groupBy) {
  20985.                                         case "Source":
  20986.                                                 folderName = Parser.sourceJsonToFull(it.source);
  20987.                                                 break;
  20988.                                         case "Alphabetical":
  20989.                                                 folderName = it.name[0].uppercaseFirst();
  20990.                                                 break;
  20991.                                         case "Class":
  20992.                                         default:
  20993.                                                 folderName = it.class;
  20994.                                 }
  20995.                                 return folderName;
  20996.                         }
  20997.                         case "background": {
  20998.                                 let folderName;
  20999.                                 switch (groupBy) {
  21000.                                         case "Source":
  21001.                                                 folderName = Parser.sourceJsonToFull(it.source);
  21002.                                                 break;
  21003.                                         case "Alphabetical":
  21004.                                         default:
  21005.                                                 folderName = it.name[0].uppercaseFirst();
  21006.                                                 break;
  21007.                                 }
  21008.                                 return folderName;
  21009.                         }
  21010.                         case "optionalfeature": {
  21011.                                 let folderName;
  21012.                                 switch (groupBy) {
  21013.                                         case "Source":
  21014.                                                 folderName = Parser.sourceJsonToFull(it.source);
  21015.                                                 break;
  21016.                                         case "Alphabetical":
  21017.                                         default:
  21018.                                                 folderName = it.name[0].uppercaseFirst();
  21019.                                                 break;
  21020.                                 }
  21021.                                 return folderName;
  21022.                         }
  21023.                         case "race": {
  21024.                                 let folderName;
  21025.                                 switch (groupBy) {
  21026.                                         case "Source":
  21027.                                                 folderName = Parser.sourceJsonToFull(it.source);
  21028.                                                 break;
  21029.                                         case "Alphabetical":
  21030.                                         default:
  21031.                                                 folderName = it.name[0].uppercaseFirst();
  21032.                                                 break;
  21033.                                 }
  21034.                                 return folderName;
  21035.                         }
  21036.                         default:
  21037.                                 throw new Error(`Unknown import type '${dataType}'`);
  21038.                 }
  21039.         };
  21040.  
  21041.         d20plus.importer._checkHandleDuplicate = function (path, overwrite) {
  21042.                 const dupe = d20plus.journal.checkFileExistsByPath(path);
  21043.                 if (dupe && !overwrite) return false;
  21044.                 else if (dupe) d20plus.journal.removeFileByPath(path);
  21045.                 return true;
  21046.         };
  21047.  
  21048.         d20plus.importer._importToggleSelectAll = function (importList, selectAllCb) {
  21049.                 const $sa = $(selectAllCb);
  21050.                 const val = $sa.prop("checked");
  21051.                 importList.items.forEach(i => Array.prototype.forEach.call(i.elm.children, (e) => {
  21052.                         if (e.tagName === "INPUT") {
  21053.                                 $(e).prop("checked", val);
  21054.                         }
  21055.                 }));
  21056.         };
  21057.  
  21058.         d20plus.importer._importSelectAll = function (importList) {
  21059.                 importList.items.forEach(i => Array.prototype.forEach.call(i.elm.children, (e) => {
  21060.                         if (e.tagName === "INPUT") {
  21061.                                 $(e).prop("checked", true);
  21062.                         }
  21063.                 }));
  21064.         };
  21065.  
  21066.         d20plus.importer._importSelectVisible = function (importList) {
  21067.                 importList.visibleItems.forEach(i => Array.prototype.forEach.call(i.elm.children, (e) => {
  21068.                         if (e.tagName === "INPUT") {
  21069.                                 $(e).prop("checked", true);
  21070.                         }
  21071.                 }));
  21072.         };
  21073.  
  21074.         d20plus.importer._importDeselectAll = function (importList) {
  21075.                 importList.items.forEach(i => Array.prototype.forEach.call(i.elm.children, (e) => {
  21076.                         if (e.tagName === "INPUT") {
  21077.                                 $(e).prop("checked", false);
  21078.                         }
  21079.                 }));
  21080.         };
  21081.  
  21082.         d20plus.importer._importDeselectVisible = function (importList) {
  21083.                 importList.visibleItems.forEach(i => Array.prototype.forEach.call(i.elm.children, (e) => {
  21084.                         if (e.tagName === "INPUT") {
  21085.                                 $(e).prop("checked", false);
  21086.                         }
  21087.                 }));
  21088.         };
  21089.  
  21090.         d20plus.importer._importSelectPublished = function (importList) {
  21091.                 function setSelection (i, setTo) {
  21092.                         Array.prototype.forEach.call(i.elm.children, (e) => {
  21093.                                 if (e.tagName === "INPUT") {
  21094.                                         $(e).prop("checked", setTo);
  21095.                                 }
  21096.                         })
  21097.                 }
  21098.  
  21099.                 importList.items.forEach(i => {
  21100.                         if (SourceUtil.isNonstandardSource(i.values().source)) {
  21101.                                 setSelection(i, false);
  21102.                         } else {
  21103.                                 setSelection(i, true);
  21104.                         }
  21105.                 });
  21106.         };
  21107. }
  21108.  
  21109. SCRIPT_EXTENSIONS.push(d20plusImporter);
  21110.  
  21111.  
  21112. function d20plusMonsters () {
  21113.         d20plus.monsters = {
  21114.                 TAG_SPELL_OPEN: "#VE_MARK_SPELL_OPEN#",
  21115.                 TAG_SPELL_CLOSE: "#VE_MARK_SPELL_CLOSE#",
  21116.         };
  21117.  
  21118.         d20plus.monsters._groupOptions = ["Type", "Type (with tags)", "CR", "CR → Type", "Alphabetical", "Source"];
  21119.         d20plus.monsters._listCols = ["name", "type", "cr", "source"];
  21120.         d20plus.monsters._listItemBuilder = (it) => `
  21121.                 <span class="name col-4" title="name">${it.name}</span>
  21122.                 <span class="type col-4" title="type">TYP[${Parser.monTypeToFullObj(it.type).asText.uppercaseFirst()}]</span>
  21123.                 <span class="cr col-2" title="cr">${it.cr === undefined ? "CR[Unknown]" : `CR[${(it.cr.cr || it.cr)}]`}</span>
  21124.                 <span title="source [Full source name is ${Parser.sourceJsonToFull(it.source)}]" class="source">SRC[${Parser.sourceJsonToAbv(it.source)}]</span>`;
  21125.         d20plus.monsters._listIndexConverter = (m) => {
  21126.                 m.__pType = m.__pType || Parser.monTypeToFullObj(m.type).type; // only filter using primary type
  21127.                 return {
  21128.                         name: m.name.toLowerCase(),
  21129.                         type: m.__pType.toLowerCase(),
  21130.                         cr: m.cr === undefined ? "unknown" : (m.cr.cr || m.cr).toLowerCase(),
  21131.                         source: Parser.sourceJsonToAbv(m.source).toLowerCase()
  21132.                 };
  21133.         };
  21134.         d20plus.monsters._doScale = (doImport, origImportQueue) => {
  21135.                 const _template = `
  21136.                         <div id="d20plus-monster-import-cr-scale" title="Scale CRs">
  21137.                                 <div id="monster-import-cr-scale-list">
  21138.                                         <input type="search" class="search" placeholder="Search creatures...">
  21139.                                         <input type="search" class="filter" placeholder="Filter...">
  21140.                                         <span title="Filter format example: 'cr:1/4; cr:1/2; type:beast; source:MM'" style="cursor: help;">[?]</span>
  21141.                                
  21142.                                         <div style="margin-top: 10px;">
  21143.                                                 <span class="col-3 ib"><b>Name</b></span>                              
  21144.                                                 <span class="col-1 ib"><b>Source</b></span>                            
  21145.                                                 <span class="col-2 ib"><b>CR</b></span>                        
  21146.                                                 <span class="col-2 ib"><b>Rename To</b></span>
  21147.                                                 <span class="col-3 ib"><b>Scale CR</b></span>                                                          
  21148.                                         </div>
  21149.                                         <div class="list" style="transform: translateZ(0); max-height: 490px; overflow-y: auto; overflow-x: hidden;"><i>Loading...</i></div>
  21150.                                 </div>
  21151.                         <br>
  21152.                         <button class="btn">Import</button>
  21153.                         </div>
  21154.                 `;
  21155.                 if (!$(`#d20plus-monster-import-cr-scale`).length) {
  21156.                         $(`body`).append(_template);
  21157.                         $("#d20plus-monster-import-cr-scale").dialog({
  21158.                                 autoOpen: false,
  21159.                                 resizable: true,
  21160.                                 width: 800,
  21161.                                 height: 650,
  21162.                         });
  21163.                 }
  21164.                 const $win = $("#d20plus-monster-import-cr-scale");
  21165.                 $win.dialog("open");
  21166.  
  21167.                 const $fltr = $win.find(`.filter`);
  21168.                 $fltr.off("keydown").off("keyup");
  21169.                 $win.find(`button`).off("click");
  21170.  
  21171.                 const $lst = $win.find(`.list`);
  21172.                 $lst.empty();
  21173.  
  21174.                 let temp = "";
  21175.                 origImportQueue.forEach((m, i) => {
  21176.                         temp += `
  21177.                                 <div>
  21178.                                         <span class="name col-3 ib">${m.name}</span>
  21179.                                         <span title="${Parser.sourceJsonToFull(m.source)}" class="src col-1 ib">SRC[${Parser.sourceJsonToAbv(m.source)}]</span>
  21180.                                         <span class="cr col-2 ib">${m.cr === undefined ? "CR[Unknown]" : `CR[${(m.cr.cr || m.cr)}]`}</span>
  21181.                                         <span class="col-2 ib"><input class="target-rename" style="max-width: calc(100% - 18px);" placeholder="Rename To..."></span>
  21182.                                         <span class="col-2 ib"><input class="target-cr" placeholder="Adjusted CR (optional; 0-30)"></span>
  21183.                                         <span class="index" style="display: none;">${i}</span>
  21184.                                 </div>
  21185.                         `;
  21186.                 });
  21187.                 $lst.append(temp);
  21188.  
  21189.                 list = new List("monster-import-cr-scale-list", {
  21190.                         valueNames: ["name", "src"]
  21191.                 });
  21192.  
  21193.                 d20plus.importer.addListFilter($fltr, origImportQueue, list, d20plus.monsters._listIndexConverter);
  21194.  
  21195.                 const $btn = $win.find(`.btn`);
  21196.                 $btn.click(() => {
  21197.                         const queueCopy = JSON.parse(JSON.stringify(origImportQueue));
  21198.  
  21199.                         const applyRename = (mon, newName) => {
  21200.                                 const applyTo = (prop) => {
  21201.                                         mon[prop] && mon[prop].forEach(it => {
  21202.                                                 if (it.entries) it.entries = JSON.parse(JSON.stringify(it.entries).replace(new RegExp(mon.name, "gi"), newName));
  21203.                                                 if (it.headerEntries) it.headerEntries = JSON.parse(JSON.stringify(it.headerEntries).replace(new RegExp(mon.name, "gi"), newName));
  21204.                                         })
  21205.                                 };
  21206.  
  21207.                                 applyTo("action");
  21208.                                 applyTo("reaction");
  21209.                                 applyTo("trait");
  21210.                                 applyTo("legendary");
  21211.                                 applyTo("variant");
  21212.  
  21213.                                 mon._displayName = newName;
  21214.                         };
  21215.  
  21216.                         let failed = false;
  21217.                         const promises = [];
  21218.                         for (const it of list.items) {
  21219.                                 const $ele = $(it.elm);
  21220.                                 const ix = Number($ele.find(`.index`).text());
  21221.                                 const m = origImportQueue[ix];
  21222.                                 const origCr = m.cr ? (m.cr.cr || m.cr) : "Unknown";
  21223.                                 const $iptCr = $ele.find(`.target-cr`);
  21224.                                 const rename = ($ele.find(`.target-rename`).val() || "").trim();
  21225.                                 const crValRaw = $iptCr.val();
  21226.                                 let crVal = crValRaw;
  21227.                                 if (crVal && crVal.trim()) {
  21228.                                         crVal = crVal.replace(/\s+/g, "").toLowerCase();
  21229.                                         const mt = /(1\/[248]|\d+)/.exec(crVal);
  21230.                                         if (mt) {
  21231.                                                 const asNum = Parser.crToNumber(mt[0]);
  21232.                                                 if (asNum < 0 || asNum > 30) {
  21233.                                                         alert(`Invalid CR: ${crValRaw} for creature ${m.name} from ${Parser.sourceJsonToAbv(m.source)} (should be between 0 and 30)`);
  21234.                                                         failed = true;
  21235.                                                         break;
  21236.                                                 } else if (asNum !== Parser.crToNumber(origCr)) {
  21237.                                                         promises.push(ScaleCreature.scale(m, asNum).then(scaled => {
  21238.                                                                 queueCopy[ix] = scaled;
  21239.                                                                 if (rename) applyRename(queueCopy[ix], rename);
  21240.                                                                 return Promise.resolve();
  21241.                                                         }));
  21242.                                                 } else {
  21243.                                                         if (rename) applyRename(queueCopy[ix], rename);
  21244.                                                         console.log(`Skipping scaling creature ${m.name} from ${Parser.sourceJsonToAbv(m.source)} -- old CR matched new CR`)
  21245.                                                 }
  21246.                                         } else {
  21247.                                                 alert(`Invalid CR: ${crValRaw} for creature ${m.name} from ${Parser.sourceJsonToAbv(m.source)}`);
  21248.                                                 failed = true;
  21249.                                                 break;
  21250.                                         }
  21251.                                 } else {
  21252.                                         if (rename) applyRename(queueCopy[ix], rename);
  21253.                                 }
  21254.                         }
  21255.  
  21256.                         if (!failed) {
  21257.                                 const pVals = Object.values(promises);
  21258.                                 Promise.all(promises).then(results => {
  21259.                                         doImport(queueCopy);
  21260.                                 });
  21261.                         }
  21262.                 });
  21263.         };
  21264.         // Import Monsters button was clicked
  21265.         d20plus.monsters.button = function () {
  21266.                 const url = $("#import-monster-url").val();
  21267.                 if (url && url.trim()) {
  21268.                         DataUtil.loadJSON(url).then(async data => {
  21269.                                 const doShowList = () => {
  21270.                                         d20plus.importer.addMeta(data._meta);
  21271.                                         d20plus.importer.showImportList(
  21272.                                                 "monster",
  21273.                                                 data.monster,
  21274.                                                 d20plus.monsters.handoutBuilder,
  21275.                                                 {
  21276.                                                         groupOptions: d20plus.monsters._groupOptions,
  21277.                                                         listItemBuilder: d20plus.monsters._listItemBuilder,
  21278.                                                         listIndex: d20plus.monsters._listCols,
  21279.                                                         listIndexConverter: d20plus.monsters._listIndexConverter,
  21280.                                                         nextStep: d20plus.monsters._doScale
  21281.                                                 }
  21282.                                         );
  21283.                                 };
  21284.  
  21285.                                 doShowList();
  21286.                         });
  21287.                 }
  21288.         };
  21289.  
  21290.         // Import All Monsters button was clicked
  21291.         d20plus.monsters.buttonAll = async function () {
  21292.                 const filterUnofficial = !d20plus.cfg.getOrDefault("import", "allSourcesIncludeUnofficial");
  21293.  
  21294.                 const toLoad = Object.keys(monsterDataUrls)
  21295.                         .filter(src => !(SourceUtil.isNonstandardSource(src) && filterUnofficial))
  21296.                         .map(src => d20plus.monsters.formMonsterUrl(monsterDataUrls[src]));
  21297.  
  21298.                 if (d20plus.cfg.getOrDefault("import", "allSourcesIncludeHomebrew")) {
  21299.                         monsterBrewDataUrls.forEach(it => toLoad.push(it.url));
  21300.                 }
  21301.  
  21302.                 if (toLoad.length) {
  21303.                         const dataStack = (await Promise.all(toLoad.map(async url => DataUtil.loadJSON(url)))).flat();
  21304.  
  21305.                         let toShow = [];
  21306.  
  21307.                         const seen = {};
  21308.                         await Promise.all(dataStack.map(async d => {
  21309.                                 const toAdd = d.monster.filter(m => {
  21310.                                         const out = !(seen[m.source] && seen[m.source].has(m.name));
  21311.                                         if (!seen[m.source]) seen[m.source] = new Set();
  21312.                                         seen[m.source].add(m.name);
  21313.                                         return out;
  21314.                                 });
  21315.  
  21316.                                 toShow = toShow.concat(toAdd);
  21317.                         }));
  21318.  
  21319.                         d20plus.importer.showImportList(
  21320.                                 "monster",
  21321.                                 toShow,
  21322.                                 d20plus.monsters.handoutBuilder,
  21323.                                 {
  21324.                                         groupOptions: d20plus.monsters._groupOptions,
  21325.                                         listItemBuilder: d20plus.monsters._listItemBuilder,
  21326.                                         listIndex: d20plus.monsters._listCols,
  21327.                                         listIndexConverter: d20plus.monsters._listIndexConverter,
  21328.                                         nextStep: d20plus.monsters._doScale
  21329.                                 }
  21330.                         );
  21331.                 }
  21332.         };
  21333.  
  21334.         d20plus.monsters.formMonsterUrl = function (fileName) {
  21335.                 return d20plus.formSrcUrl(MONSTER_DATA_DIR, fileName);
  21336.         };
  21337.  
  21338.         // Create monster character from js data object
  21339.         d20plus.monsters.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  21340.                 const doBuild = () => {
  21341.                         if (!options) options = {};
  21342.                         if (inJournals && typeof inJournals === "string") {
  21343.                                 options.charOptions = options.charOptions || {};
  21344.                                 options.charOptions.inplayerjournals = inJournals;
  21345.                         }
  21346.  
  21347.                         // make dir
  21348.                         const folder = d20plus.journal.makeDirTree(`Monsters`, folderName);
  21349.                         const path = ["Monsters", ...folderName, data._displayName || data.name];
  21350.  
  21351.                         // handle duplicates/overwrites
  21352.                         if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  21353.  
  21354.                         const name = data.name;
  21355.                         const pType = Parser.monTypeToFullObj(data.type);
  21356.  
  21357.                         const renderer = new Renderer();
  21358.                         renderer.setBaseUrl(BASE_SITE_URL);
  21359.  
  21360.                         const fluff = Renderer.monster.getFluff(data, monsterMetadata, monsterFluffData[data.source]);
  21361.                         let renderFluff = null;
  21362.                         if (fluff) {
  21363.                                 const depth = fluff.type === "section" ? -1 : 2;
  21364.                                 if (fluff.type !== "section") renderer.setFirstSection(false);
  21365.                                 renderFluff = renderer.render({type: fluff.type, entries: fluff.entries}, depth);
  21366.                         }
  21367.  
  21368.                         d20.Campaign.characters.create(
  21369.                                 {
  21370.                                         name: data._displayName || data.name,
  21371.                                         tags: d20plus.importer.getTagString([
  21372.                                                 pType.type,
  21373.                                                 ...pType.tags,
  21374.                                                 `cr ${(data.cr ? (data.cr.cr || data.cr) : "").replace(/\//g, " over ")}` || "unknown cr",
  21375.                                                 Parser.sourceJsonToFull(data.source),
  21376.                                                 Parser.sizeAbvToFull(data.size),
  21377.                                                 ...(data.environment || []),
  21378.                                                 data.isNPC ? "npc" : undefined
  21379.                                         ], "creature"),
  21380.                                         ...options.charOptions
  21381.                                 },
  21382.                                 {
  21383.                                         success: function (character) {
  21384.                                                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](data)] = {name: data.name, source: data.source, type: "character", roll20Id: character.id};
  21385.                                                 /* OGL Sheet */
  21386.                                                 try {
  21387.                                                         const type = Parser.monTypeToFullObj(data.type).asText;
  21388.                                                         const source = Parser.sourceJsonToAbv(data.source);
  21389.                                                         const avatar = data.tokenUrl || `${IMG_URL}${source}/${name.replace(/"/g, "")}.png`;
  21390.                                                         character.size = data.size;
  21391.                                                         character.name = data._displayName || data.name;
  21392.                                                         character.senses = data.senses ? data.senses instanceof Array ? data.senses.join(", ") : data.senses : null;
  21393.                                                         character.hp = data.hp.average || 0;
  21394.                                                         const firstFluffImage = d20plus.cfg.getOrDefault("import", "importCharAvatar") === "Portrait (where available)" && fluff && fluff.images ? (() => {
  21395.                                                                 const firstImage = fluff.images[0] || {};
  21396.                                                                 return (firstImage.href || {}).type === "internal" ? `${BASE_SITE_URL}/img/${firstImage.href.path}` : (firstImage.href || {}).url;
  21397.                                                         })() : null;
  21398.                                                         $.ajax({
  21399.                                                                 url: avatar,
  21400.                                                                 type: 'HEAD',
  21401.                                                                 error: function () {
  21402.                                                                         d20plus.importer.getSetAvatarImage(character, `${IMG_URL}blank.png`, firstFluffImage);
  21403.                                                                 },
  21404.                                                                 success: function () {
  21405.                                                                         d20plus.importer.getSetAvatarImage(character, `${avatar}${d20plus.ut.getAntiCacheSuffix()}`, firstFluffImage);
  21406.                                                                 }
  21407.                                                         });
  21408.                                                         const parsedAc = typeof data.ac === "string" ? data.ac : $(`<div>${Parser.acToFull(data.ac)}</div>`).text();
  21409.                                                         var ac = parsedAc.match(/^\d+/);
  21410.                                                         var actype = /\(([^)]+)\)/.exec(parsedAc);
  21411.                                                         var hp = data.hp.average || 0;
  21412.                                                         var hpformula = data.hp.formula;
  21413.                                                         var passive = data.passive != null ? data.passive : "";
  21414.                                                         var passiveStr = passive !== "" ? "passive Perception " + passive : "";
  21415.                                                         var senses = data.senses ? data.senses instanceof Array ? data.senses.join(", ") : data.senses : "";
  21416.                                                         var sensesStr = senses !== "" ? senses + ", " + passiveStr : passiveStr;
  21417.                                                         var size = d20plus.getSizeString(data.size || "");
  21418.                                                         var alignment = data.alignment ? Parser.alignmentListToFull(data.alignment).toLowerCase() : "(Unknown Alignment)";
  21419.                                                         var cr = data.cr ? (data.cr.cr || data.cr) : "";
  21420.                                                         var xp = Parser.crToXpNumber(cr) || 0;
  21421.                                                         character.attribs.create({name: "npc", current: 1});
  21422.                                                         character.attribs.create({name: "npc_toggle", current: 1});
  21423.                                                         character.attribs.create({name: "npc_options-flag", current: 0});
  21424.                                                         // region disable charachtermancer
  21425.                                                         character.attribs.create({name: "mancer_confirm_flag", current: ""});
  21426.                                                         character.attribs.create({name: "mancer_cancel", current: "on"});
  21427.                                                         character.attribs.create({name: "l1mancer_status", current: "completed"});
  21428.                                                         // endregion
  21429.                                                         character.attribs.create({name: "wtype", current: d20plus.importer.getDesiredWhisperType()});
  21430.                                                         character.attribs.create({name: "rtype", current: d20plus.importer.getDesiredRollType()});
  21431.                                                         character.attribs.create({
  21432.                                                                 name: "advantagetoggle",
  21433.                                                                 current: d20plus.importer.getDesiredAdvantageToggle()
  21434.                                                         });
  21435.                                                         character.attribs.create({
  21436.                                                                 name: "whispertoggle",
  21437.                                                                 current: d20plus.importer.getDesiredWhisperToggle()
  21438.                                                         });
  21439.                                                         character.attribs.create({name: "dtype", current: d20plus.importer.getDesiredDamageType()});
  21440.                                                         character.attribs.create({name: "npc_name", current: data._displayName || data.name});
  21441.                                                         character.attribs.create({name: "npc_size", current: size});
  21442.                                                         character.attribs.create({name: "type", current: type});
  21443.                                                         character.attribs.create({name: "npc_type", current: size + " " + type + ", " + alignment});
  21444.                                                         character.attribs.create({name: "npc_alignment", current: alignment});
  21445.                                                         character.attribs.create({name: "npc_ac", current: ac != null ? ac[0] : ""});
  21446.                                                         character.attribs.create({name: "npc_actype", current: actype != null ? actype[1] || "" : ""});
  21447.                                                         character.attribs.create({name: "npc_hpbase", current: hp != null ? hp : ""});
  21448.                                                         character.attribs.create({
  21449.                                                                 name: "npc_hpformula",
  21450.                                                                 current: hpformula != null ? hpformula || "" : ""
  21451.                                                         });
  21452.  
  21453.                                                         const hpModId = d20plus.ut.generateRowId();
  21454.                                                         character.attribs.create({name: `repeating_hpmod_${hpModId}_source`, current: "CON"});
  21455.                                                         character.attribs.create({name: `repeating_hpmod_${hpModId}_mod`, current: Parser.getAbilityModNumber(data.con)});
  21456.  
  21457.                                                         const parsedSpeed = Parser.getSpeedString(data);
  21458.                                                         data.npc_speed = parsedSpeed;
  21459.                                                         if (d20plus.sheet === "shaped") {
  21460.                                                                 data.npc_speed = data.npc_speed.toLowerCase();
  21461.                                                                 var match = data.npc_speed.match(/^\s*(\d+)\s?(ft\.?|m\.?)/);
  21462.                                                                 if (match && match[1]) {
  21463.                                                                         data.speed = match[1] + ' ' + match[2];
  21464.                                                                         character.attribs.create({name: "speed", current: match[1] + ' ' + match[2]});
  21465.                                                                 }
  21466.                                                                 data.npc_speed = parsedSpeed;
  21467.                                                                 var regex = /(burrow|climb|fly|swim)\s+(\d+)\s?(ft\.?|m\.?)/g;
  21468.                                                                 var speeds = void 0;
  21469.                                                                 while ((speeds = regex.exec(data.npc_speed)) !== null) character.attribs.create({
  21470.                                                                         name: "speed_" + speeds[1],
  21471.                                                                         current: speeds[2] + ' ' + speeds[3]
  21472.                                                                 });
  21473.                                                                 if (data.npc_speed && data.npc_speed.includes('hover')) character.attribs.create({
  21474.                                                                         name: "speed_fly_hover",
  21475.                                                                         current: 1
  21476.                                                                 });
  21477.                                                                 data.npc_speed = '';
  21478.                                                         }
  21479.  
  21480.                                                         function calcMod (score) {
  21481.                                                                 return Math.floor((Number(score) - 10) / 2);
  21482.                                                         }
  21483.  
  21484.                                                         character.attribs.create({name: "npc_speed", current: parsedSpeed != null ? parsedSpeed : ""});
  21485.  
  21486.                                                         character.attribs.create({name: "strength", current: data.str});
  21487.                                                         character.attribs.create({name: "strength_base", current: `${data.str}`});
  21488.                                                         character.attribs.create({name: "strength_mod", current: calcMod(data.str)});
  21489.                                                         character.attribs.create({name: "npc_str_negative", current: calcMod(data.str) < 0});
  21490.                                                         character.attribs.create({name: "strength_flag", current: 0});
  21491.  
  21492.                                                         character.attribs.create({name: "dexterity", current: data.dex});
  21493.                                                         character.attribs.create({name: "dexterity_base", current: `${data.dex}`});
  21494.                                                         character.attribs.create({name: "dexterity_mod", current: calcMod(data.dex)});
  21495.                                                         character.attribs.create({name: "npc_dex_negative", current: calcMod(data.dex) < 0});
  21496.                                                         character.attribs.create({name: "dexterity_flag", current: 0});
  21497.  
  21498.                                                         character.attribs.create({name: "constitution", current: data.con});
  21499.                                                         character.attribs.create({name: "constitution_base", current: `${data.con}`});
  21500.                                                         character.attribs.create({name: "constitution_mod", current: calcMod(data.con)});
  21501.                                                         character.attribs.create({name: "npc_con_negative", current: calcMod(data.con) < 0});
  21502.                                                         character.attribs.create({name: "constitution_flag", current: 0});
  21503.  
  21504.                                                         character.attribs.create({name: "intelligence", current: data.int});
  21505.                                                         character.attribs.create({name: "intelligence_base", current: `${data.int}`});
  21506.                                                         character.attribs.create({name: "intelligence_mod", current: calcMod(data.int)});
  21507.                                                         character.attribs.create({name: "npc_int_negative", current: calcMod(data.int) < 0});
  21508.                                                         character.attribs.create({name: "intelligence_flag", current: 0});
  21509.  
  21510.                                                         character.attribs.create({name: "wisdom", current: data.wis});
  21511.                                                         character.attribs.create({name: "wisdom_base", current: `${data.wis}`});
  21512.                                                         character.attribs.create({name: "wisdom_mod", current: calcMod(data.wis)});
  21513.                                                         character.attribs.create({name: "npc_wis_negative", current: calcMod(data.wis) < 0});
  21514.                                                         character.attribs.create({name: "wisdom_flag", current: 0});
  21515.  
  21516.                                                         character.attribs.create({name: "charisma", current: data.cha});
  21517.                                                         character.attribs.create({name: "charisma_base", current: `${data.cha}`});
  21518.                                                         character.attribs.create({name: "charisma_mod", current: calcMod(data.cha)});
  21519.                                                         character.attribs.create({name: "npc_cha_negative", current: calcMod(data.cha) < 0});
  21520.                                                         character.attribs.create({name: "charisma_flag", current: 0});
  21521.  
  21522.                                                         character.attribs.create({name: "initiative_bonus", current: calcMod(data.dex)});
  21523.  
  21524.                                                         character.attribs.create({name: "passive", current: passive});
  21525.                                                         character.attribs.create({
  21526.                                                                 name: "npc_languages",
  21527.                                                                 current: data.languages != null ? data.languages instanceof Array ? data.languages.join(", ") : data.languages : ""
  21528.                                                         });
  21529.                                                         const moCn = cr.cr || cr;
  21530.                                                         character.attribs.create({name: "npc_challenge", current: moCn});
  21531.  
  21532.                                                         // set a rough character level for spellcasting calculations
  21533.                                                         const pb = Parser.crToPb(moCn);
  21534.                                                         const charLevel = pb === 2 ? 1 : pb === 3 ? 5 : cr === 4 ? 11 : cr === 6 ? 17 : cr > 6 ? 20 : 1;
  21535.                                                         character.attribs.create({name: "level", current: charLevel}).save();
  21536.  
  21537.                                                         character.attribs.create({name: "npc_xp", current: xp});
  21538.                                                         character.attribs.create({
  21539.                                                                 name: "npc_vulnerabilities",
  21540.                                                                 current: data.vulnerable != null ? d20plus.importer.getCleanText(Parser.monImmResToFull(data.vulnerable)) : ""
  21541.                                                         });
  21542.                                                         character.attribs.create({
  21543.                                                                 name: "damage_vulnerabilities",
  21544.                                                                 current: data.vulnerable != null ? d20plus.importer.getCleanText(Parser.monImmResToFull(data.vulnerable)) : ""
  21545.                                                         });
  21546.                                                         character.attribs.create({
  21547.                                                                 name: "npc_resistances",
  21548.                                                                 current: data.resist != null ? d20plus.importer.getCleanText(Parser.monImmResToFull(data.resist)) : ""
  21549.                                                         });
  21550.                                                         character.attribs.create({
  21551.                                                                 name: "damage_resistances",
  21552.                                                                 current: data.resist != null ? d20plus.importer.getCleanText(Parser.monImmResToFull(data.resist)) : ""
  21553.                                                         });
  21554.                                                         character.attribs.create({name: "npc_immunities", current: data.immune != null ? d20plus.importer.getCleanText(Parser.monImmResToFull(data.immune)) : ""});
  21555.                                                         character.attribs.create({
  21556.                                                                 name: "damage_immunities",
  21557.                                                                 current: data.immune != null ? d20plus.importer.getCleanText(Parser.monImmResToFull(data.immune)) : ""
  21558.                                                         });
  21559.                                                         character.attribs.create({
  21560.                                                                 name: "npc_condition_immunities",
  21561.                                                                 current: data.conditionImmune != null ? d20plus.importer.getCleanText(Parser.monCondImmToFull(data.conditionImmune)) : ""
  21562.                                                         });
  21563.                                                         character.attribs.create({
  21564.                                                                 name: "damage_condition_immunities",
  21565.                                                                 current: data.conditionImmune != null ? d20plus.importer.getCleanText(Parser.monCondImmToFull(data.conditionImmune)) : ""
  21566.                                                         });
  21567.                                                         character.attribs.create({name: "npc_senses", current: sensesStr});
  21568.                                                         if (d20plus.cfg.getOrDefault("import", "dexTiebreaker")) {
  21569.                                                                 character.attribs.create({
  21570.                                                                         name: "init_tiebreaker",
  21571.                                                                         current: "@{dexterity}/100"
  21572.                                                                 });
  21573.                                                         }
  21574.  
  21575.                                                         // add Tokenaction Macros
  21576.                                                         if (d20plus.cfg.getOrDefault("import", "tokenactionsSkills")) {
  21577.                                                                 if (d20plus.sheet === "shaped") {
  21578.  
  21579.                                                                 } else {
  21580.                                                                         character.abilities.create({
  21581.                                                                                 name: "Skill-Check",
  21582.                                                                                 istokenaction: true,
  21583.                                                                                 action: d20plus.actionMacroSkillCheck
  21584.                                                                         });
  21585.                                                                 }
  21586.                                                         }
  21587.                                                         if (d20plus.cfg.getOrDefault("import", "tokenactionsPerception")) {
  21588.                                                                 if (d20plus.sheet === "shaped") {
  21589.  
  21590.                                                                 } else {
  21591.                                                                         character.abilities.create({
  21592.                                                                                 name: "Perception",
  21593.                                                                                 istokenaction: true,
  21594.                                                                                 action: d20plus.actionMacroPerception
  21595.                                                                         });
  21596.                                                                 }
  21597.                                                         }
  21598.                                                         if (d20plus.cfg.getOrDefault("import", "tokenactionsSaves")) {
  21599.                                                                 if (d20plus.sheet === "shaped") {
  21600.                                                                         character.abilities.create({
  21601.                                                                                 name: "Saving Throws",
  21602.                                                                                 istokenaction: true,
  21603.                                                                                 action: `%{${character.id}|shaped_saving_throw_query}`
  21604.                                                                         });
  21605.                                                                 } else {
  21606.                                                                         character.abilities.create({
  21607.                                                                                 name: "Saves",
  21608.                                                                                 istokenaction: true,
  21609.                                                                                 action: d20plus.actionMacroSaves
  21610.                                                                         });
  21611.                                                                 }
  21612.                                                         }
  21613.                                                         if (d20plus.cfg.getOrDefault("import", "tokenactionsInitiative")) {
  21614.                                                                 if (d20plus.sheet === "shaped") {
  21615.                                                                         character.abilities.create({
  21616.                                                                                 name: "Init",
  21617.                                                                                 istokenaction: true,
  21618.                                                                                 action: `%{${character.id}|shaped_initiative}`
  21619.                                                                         });
  21620.                                                                 } else {
  21621.                                                                         character.abilities.create({
  21622.                                                                                 name: "Init",
  21623.                                                                                 istokenaction: true,
  21624.                                                                                 action: d20plus.actionMacroInit
  21625.                                                                         });
  21626.                                                                 }
  21627.                                                         }
  21628.                                                         if (d20plus.cfg.getOrDefault("import", "tokenactionsChecks")) {
  21629.                                                                 if (d20plus.sheet === "shaped") {
  21630.                                                                         character.abilities.create({
  21631.                                                                                 name: "Ability Checks",
  21632.                                                                                 istokenaction: true,
  21633.                                                                                 action: `%{${character.id}|shaped_ability_checks_query}`
  21634.                                                                         });
  21635.                                                                 } else {
  21636.                                                                         character.abilities.create({
  21637.                                                                                 name: "Ability-Check",
  21638.                                                                                 istokenaction: true,
  21639.                                                                                 action: d20plus.actionMacroAbilityCheck
  21640.                                                                         });
  21641.                                                                 }
  21642.                                                         }
  21643.                                                         if (d20plus.cfg.getOrDefault("import", "tokenactionsOther")) {
  21644.                                                                 if (d20plus.sheet === "shaped") {
  21645.  
  21646.                                                                 } else {
  21647.                                                                         character.abilities.create({
  21648.                                                                                 name: "DR/Immunities",
  21649.                                                                                 istokenaction: true,
  21650.                                                                                 action: d20plus.actionMacroDrImmunities
  21651.                                                                         });
  21652.                                                                         character.abilities.create({
  21653.                                                                                 name: "Stats",
  21654.                                                                                 istokenaction: true,
  21655.                                                                                 action: d20plus.actionMacroStats
  21656.                                                                         });
  21657.                                                                 }
  21658.                                                         }
  21659.  
  21660.                                                         if (data.save != null) {
  21661.                                                                 character.attribs.create({name: "npc_saving_flag", current: "1337"}); // value doesn't matter
  21662.                                                                 Object.keys(data.save).forEach(k => {
  21663.                                                                         character.attribs.create({
  21664.                                                                                 name: "npc_" + k + "_save_flag",
  21665.                                                                                 current: Number(data.save[k])
  21666.                                                                         });
  21667.                                                                         character.attribs.create({
  21668.                                                                                 name: "npc_" + k + "_save",
  21669.                                                                                 current: Number(data.save[k])
  21670.                                                                         });
  21671.                                                                 });
  21672.                                                         }
  21673.                                                         if (data.skill != null) {
  21674.                                                                 const skills = data.skill;
  21675.                                                                 const skillsString = Object.keys(skills).map(function (k) {
  21676.                                                                         return k.uppercaseFirst() + ' ' + skills[k];
  21677.                                                                 }).join(', ');
  21678.                                                                 character.attribs.create({name: "npc_skills_flag", current: "1337"}); // value doesn't matter
  21679.                                                                 // character.attribs.create({name: "npc_skills", current: skillsString}); // no longer used
  21680.  
  21681.                                                                 // Shaped Sheet currently doesn't correctly load NPC Skills
  21682.                                                                 // This adds a visual representation as a Trait for reference
  21683.                                                                 if (d20plus.sheet === "shaped") {
  21684.                                                                         var newRowId = d20plus.ut.generateRowId();
  21685.                                                                         character.attribs.create({
  21686.                                                                                 name: "repeating_npctrait_" + newRowId + "_name",
  21687.                                                                                 current: "NPC Skills"
  21688.                                                                         });
  21689.                                                                         character.attribs.create({
  21690.                                                                                 name: "repeating_npctrait_" + newRowId + "_desc",
  21691.                                                                                 current: skillsString
  21692.                                                                         });
  21693.                                                                 }
  21694.  
  21695.                                                                 $.each(skills, function (k, v) {
  21696.                                                                         if (k !== "other") {
  21697.                                                                                 const cleanSkill = $.trim(k).toLowerCase().replace(/ /g, "_");
  21698.                                                                                 character.attribs.create({
  21699.                                                                                         name: "npc_" + cleanSkill + "_base",
  21700.                                                                                         current: String(Number(v))
  21701.                                                                                 });
  21702.                                                                                 character.attribs.create({
  21703.                                                                                         name: "npc_" + cleanSkill,
  21704.                                                                                         current: Number(v)
  21705.                                                                                 });
  21706.                                                                                 character.attribs.create({
  21707.                                                                                         name: "npc_" + cleanSkill + "_flag",
  21708.                                                                                         current: Number(v)
  21709.                                                                                 });
  21710.                                                                         }
  21711.                                                                 });
  21712.                                                         }
  21713.                                                         if (data.spellcasting) { // Spellcasting import 2.0
  21714.                                                                 const charInterval = d20plus.cfg.get("import", "importIntervalCharacter") || d20plus.cfg.getDefault("import", "importIntervalCharacter");
  21715.                                                                 const spAbilsDelayMs = Math.max(350, Math.floor(charInterval / 5));
  21716.  
  21717.                                                                 // figure out the casting ability or spell DC
  21718.                                                                 let spellDc = null;
  21719.                                                                 let spellAbility = null;
  21720.                                                                 let casterLevel = null;
  21721.                                                                 let spellToHit = null;
  21722.                                                                 for (const sc of data.spellcasting) {
  21723.                                                                         if (!sc.headerEntries) continue;
  21724.                                                                         const toCheck = sc.headerEntries.join("");
  21725.  
  21726.                                                                         // use the first ability/DC we find, since roll20 doesn't support multiple
  21727.                                                                         const abM = /(strength|constitution|dexterity|intelligence|wisdom|charisma)/i.exec(toCheck);
  21728.                                                                         const dcM = /DC (\d+)|{@dc (\d+)}/i.exec(toCheck);
  21729.                                                                         const lvlM = /(\d+)(st|nd|rd|th).level\s+spellcaster/i.exec(toCheck);
  21730.                                                                         const spHit = /{@hit (.*?)} to hit with spell attacks/i.exec(toCheck);
  21731.  
  21732.                                                                         if (spellDc == null && dcM) spellDc = dcM[1] || dcM[2];
  21733.                                                                         if (casterLevel == null && lvlM) casterLevel = lvlM[1];
  21734.                                                                         if (spellAbility == null && abM) spellAbility = abM[1].toLowerCase();
  21735.                                                                         if (spellToHit == null && spHit) spellToHit = spHit[1];
  21736.                                                                 }
  21737.  
  21738.                                                                 function setAttrib (k, v) {
  21739.                                                                         d20plus.importer.addOrUpdateAttr(character, k, v);
  21740.                                                                 }
  21741.  
  21742.                                                                 function addInlineRollers (text) {
  21743.                                                                         if (!text) return text;
  21744.                                                                         return text.replace(RollerUtil.DICE_REGEX, (match) => {
  21745.                                                                                 return `[[${match}]]`;
  21746.                                                                         });
  21747.                                                                 }
  21748.  
  21749.                                                                 // the basics
  21750.                                                                 setAttrib("npcspellcastingflag", "1");
  21751.                                                                 if (spellAbility != null) setAttrib("spellcasting_ability", `@{${spellAbility}_mod}+`); else console.warn("No spellAbility!");
  21752.                                                                 // spell_attack_mod -- never used?
  21753.                                                                 setTimeout(() => {
  21754.                                                                         if (spellToHit != null) setAttrib("spell_attack_bonus", Number(spellToHit)); else console.warn("No spellToHit!");
  21755.                                                                         if (spellDc != null) setAttrib("spell_save_dc", Number(spellDc)); else console.warn("No spellDc!");
  21756.                                                                         if (casterLevel != null) {
  21757.                                                                                 setAttrib("caster_level", casterLevel);
  21758.                                                                                 setAttrib("level", Number(casterLevel));
  21759.                                                                         } else console.warn("No casterLevel!");
  21760.                                                                 }, spAbilsDelayMs);
  21761.  
  21762.                                                                 // spell slots
  21763.                                                                 for (let i = 1; i <= 9; ++i) {
  21764.                                                                         const slots = data.spellcasting
  21765.                                                                                 .map(it => ((it.spells || {})[i] || {}).slots)
  21766.                                                                                 .filter(it => it)
  21767.                                                                                 .reduce((a, b) => Math.max(a, b), 0);
  21768.  
  21769.                                                                         // delay this, otherwise they all come out as 0
  21770.                                                                         setTimeout(() => {
  21771.                                                                                 setAttrib(`lvl${i}_slots_total`, slots);
  21772.                                                                         }, spAbilsDelayMs);
  21773.                                                                 }
  21774.  
  21775.                                                                 // add the spellcasting text
  21776.                                                                 const newRowId = d20plus.ut.generateRowId();
  21777.                                                                 const spellTrait = Renderer.monster.getSpellcastingRenderedTraits(data, renderer).map(it => it.rendered).filter(it => it).join("");
  21778.                                                                 const cleanDescription = d20plus.importer.getCleanText(spellTrait);
  21779.                                                                 setAttrib(`repeating_npctrait_${newRowId}_name`, "Spellcasting");
  21780.                                                                 setAttrib(`repeating_npctrait_${newRowId}_desc`, cleanDescription);
  21781.  
  21782.                                                                 // begin building a spells macro
  21783.                                                                 const $temp = $(spellTrait);
  21784.                                                                 $temp.find("a").each((i, e) => {
  21785.                                                                         const $wrp = $(`<div>${d20plus.monsters.TAG_SPELL_OPEN}</div>`);
  21786.                                                                         $wrp.append(e.outerHTML);
  21787.                                                                         $wrp.append(d20plus.monsters.TAG_SPELL_CLOSE);
  21788.                                                                         $(e).replaceWith($wrp)
  21789.                                                                 });
  21790.                                                                 const tokenActionStack = [d20plus.importer.getCleanText($temp[0].outerHTML)];
  21791.  
  21792.                                                                 // collect all the spells
  21793.                                                                 const allSpells = [];
  21794.                                                                 data.spellcasting.forEach(sc => {
  21795.                                                                         const toAdd = ["constant", "will", "rest", "daily", "weekly"];
  21796.                                                                         toAdd.forEach(k => {
  21797.                                                                                 if (sc[k]) {
  21798.                                                                                         Object.values(sc[k]).forEach(spOrSpArr => {
  21799.                                                                                                 if (spOrSpArr instanceof Array) {
  21800.                                                                                                         Array.prototype.push.apply(allSpells, spOrSpArr);
  21801.                                                                                                 } else {
  21802.                                                                                                         allSpells.push(spOrSpArr);
  21803.                                                                                                 }
  21804.                                                                                         });
  21805.                                                                                 }
  21806.                                                                         });
  21807.  
  21808.                                                                         if (sc.spells) {
  21809.                                                                                 Object.keys(sc.spells).forEach(lvl => {
  21810.                                                                                         if (sc.spells[lvl].spells) {
  21811.                                                                                                 Array.prototype.push.apply(allSpells, sc.spells[lvl].spells);
  21812.                                                                                         }
  21813.                                                                                 });
  21814.                                                                         }
  21815.                                                                 });
  21816.  
  21817.                                                                 // add spells to the sheet //////////////////
  21818.                                                                 const toAdd = [];
  21819.                                                                 allSpells.forEach(sp => {
  21820.                                                                         const tagSplit = Renderer.splitByTags(sp);
  21821.                                                                         tagSplit.forEach(s => {
  21822.                                                                                 if (!s || !s.trim()) return;
  21823.                                                                                 if (s.charAt(0) === "@") {
  21824.                                                                                         const [tag, text] = Renderer.splitFirstSpace(s);
  21825.                                                                                         if (tag === "@spell") {
  21826.                                                                                                 toAdd.push(text);
  21827.                                                                                         }
  21828.                                                                                 }
  21829.                                                                         });
  21830.                                                                 });
  21831.  
  21832.                                                                 const addMacroIndex = toAdd.length - 1;
  21833.                                                                 // wait a bit, then start adding spells
  21834.                                                                 setTimeout(() => {
  21835.                                                                         toAdd.forEach((text, i) => {
  21836.                                                                                 let [name, source] = text.split("|");
  21837.                                                                                 if (!source) source = "PHB";
  21838.                                                                                 const rawUrl = spellDataUrls[Object.keys(spellDataUrls).find(src => source.toLowerCase() === src.toLowerCase())];
  21839.                                                                                 const url = d20plus.spells.formSpellUrl(rawUrl);
  21840.                                                                                 // the JSON gets cached by the script, so this is fine
  21841.                                                                                 DataUtil.loadJSON(url).then((data) => {
  21842.                                                                                         const spell = data.spell.find(spell => spell.name.toLowerCase() === name.toLowerCase());
  21843.  
  21844.                                                                                         const [notecontents, gmnotes] = d20plus.spells._getHandoutData(spell);
  21845.  
  21846.                                                                                         addSpell3(JSON.parse(gmnotes), spell, i, addMacroIndex);
  21847.                                                                                 });
  21848.                                                                         });
  21849.                                                                 }, spAbilsDelayMs);
  21850.  
  21851.                                                                 function addSpell3 (data, VeSp, index, addMacroIndex) {
  21852.                                                                         console.log("Adding spell: ", data.name)
  21853.                                                                         // prepare data
  21854.                                                                         data.content = addInlineRollers(data.content);
  21855.                                                                         const DESC_KEY = "data-description";
  21856.                                                                         data.data[DESC_KEY] = addInlineRollers(data.data[DESC_KEY]);
  21857.                                                                         const HL_KEY = "Higher Spell Slot Desc";
  21858.                                                                         if (data.data[HL_KEY]) data.data[HL_KEY] = addInlineRollers(data.data[HL_KEY]);
  21859.  
  21860.                                                                         // populate spell data
  21861.                                                                         // source: https://github.com/Roll20/roll20-character-sheets/blob/master/5th%20Edition%20OGL%20by%20Roll20/5th%20Edition%20OGL%20by%20Roll20.html
  21862.  
  21863.                                                                         // custom code
  21864.                                                                         function setAttrs (attrs, callbacks) {
  21865.                                                                                 Object.entries(attrs).forEach(([a, v]) => {
  21866.                                                                                         character.attribs.create({name: a, current: v}).save();
  21867.                                                                                 });
  21868.                                                                                 if (callbacks) callbacks.forEach(cb => cb());
  21869.                                                                         }
  21870.  
  21871.                                                                         // custom code
  21872.                                                                         function getAttrs (attrs) {
  21873.                                                                                 const all = character.attribs.toJSON();
  21874.                                                                                 const out = {};
  21875.                                                                                 attrs.forEach(k => {
  21876.                                                                                         const found = all.find(it => it.name === k)
  21877.                                                                                         if (found) out[k] = found.current;
  21878.                                                                                 })
  21879.                                                                                 return out;
  21880.                                                                         }
  21881.  
  21882.                                                                         // largely stolen from `update_attack_from_spell`
  21883.                                                                         function update_attack_from_spell (lvl, spellid, attackid, newattack) {
  21884.                                                                                 const v = getAttrs(["repeating_spell-" + lvl + "_" + spellid + "_spellname",
  21885.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spellrange",
  21886.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spelltarget",
  21887.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spellattack",
  21888.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spelldamage",
  21889.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spelldamage2",
  21890.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spelldamagetype",
  21891.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spelldamagetype2",
  21892.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spellhealing",
  21893.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spelldmgmod",
  21894.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spellsave",
  21895.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spellsavesuccess",
  21896.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spellhldie",
  21897.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spellhldietype",
  21898.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spellhlbonus",
  21899.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spelllevel",
  21900.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_includedesc",
  21901.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spelldescription",
  21902.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spellathigherlevels",
  21903.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spell_damage_progression",
  21904.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_innate",
  21905.                                                                                         "repeating_spell-" + lvl + "_" + spellid + "_spell_ability",
  21906.                                                                                         "spellcasting_ability"]);
  21907.  
  21908.                                                                                 var update = {};
  21909.                                                                                 var description = "";
  21910.                                                                                 var spellAbility = v["repeating_spell-" + lvl + "_" + spellid + "_spell_ability"] != "spell" ? v["repeating_spell-" + lvl + "_" + spellid + "_spell_ability"].slice(0, -1) : "spell";
  21911.                                                                                 update["repeating_attack_" + attackid + "_atkattr_base"] = spellAbility;
  21912.  
  21913.                                                                                 if(newattack) {
  21914.                                                                                         update["repeating_attack_" + attackid + "_options-flag"] = "0";
  21915.                                                                                         update["repeating_attack_" + attackid + "_spellid"] = spellid;
  21916.                                                                                         update["repeating_attack_" + attackid + "_spelllevel"] = lvl;
  21917.                                                                                 }
  21918.  
  21919.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spell_ability"] == "spell") {
  21920.                                                                                         update["repeating_attack_" + attackid + "_savedc"] = "(@{spell_save_dc})";
  21921.                                                                                 } else if (v["repeating_spell-" + lvl + "_" + spellid + "_spell_ability"]) {
  21922.                                                                                         update["repeating_attack_" + attackid + "_savedc"] = "(" + spellAbility + "+8+@{spell_dc_mod}+@{pb})";
  21923.                                                                                 }
  21924.  
  21925.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spellname"] && v["repeating_spell-" + lvl + "_" + spellid + "_spellname"] != "") {
  21926.                                                                                         update["repeating_attack_" + attackid + "_atkname"] = v["repeating_spell-" + lvl + "_" + spellid + "_spellname"];
  21927.                                                                                 }
  21928.                                                                                 if(!v["repeating_spell-" + lvl + "_" + spellid + "_spellattack"] || v["repeating_spell-" + lvl + "_" + spellid + "_spellattack"] === "None") {
  21929.                                                                                         update["repeating_attack_" + attackid + "_atkflag"] = "0";
  21930.                                                                                 }
  21931.                                                                                 else {
  21932.                                                                                         update["repeating_attack_" + attackid + "_atkflag"] = "{{attack=1}}";
  21933.                                                                                         description = description + v["repeating_spell-" + lvl + "_" + spellid + "_spellattack"] + " Spell Attack. ";
  21934.                                                                                 }
  21935.  
  21936.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage"] && v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage"] != "") {
  21937.                                                                                         update["repeating_attack_" + attackid + "_dmgflag"] = "{{damage=1}} {{dmg1flag=1}}";
  21938.                                                                                         if(v["repeating_spell-" + lvl + "_" + spellid + "_spell_damage_progression"] && v["repeating_spell-" + lvl + "_" + spellid + "_spell_damage_progression"] === "Cantrip Dice") {
  21939.                                                                                                 update["repeating_attack_" + attackid + "_dmgbase"] = "[[round((@{level} + 1) / 6 + 0.5)]]" + v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage"].substring(1);
  21940.                                                                                         }
  21941.                                                                                         else {
  21942.                                                                                                 update["repeating_attack_" + attackid + "_dmgbase"] = v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage"];
  21943.                                                                                         }
  21944.                                                                                 }
  21945.                                                                                 else {
  21946.                                                                                         update["repeating_attack_" + attackid + "_dmgflag"] = "0"
  21947.                                                                                 }
  21948.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spelldmgmod"] && v["repeating_spell-" + lvl + "_" + spellid + "_spelldmgmod"] === "Yes") {
  21949.                                                                                         update["repeating_attack_" + attackid + "_dmgattr"] = spellAbility;
  21950.                                                                                 }
  21951.                                                                                 else {
  21952.                                                                                         update["repeating_attack_" + attackid + "_dmgattr"] = "0";
  21953.                                                                                 }
  21954.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spelldamagetype"]) {
  21955.                                                                                         update["repeating_attack_" + attackid + "_dmgtype"] = v["repeating_spell-" + lvl + "_" + spellid + "_spelldamagetype"];
  21956.                                                                                 }
  21957.                                                                                 else {
  21958.                                                                                         update["repeating_attack_" + attackid + "_dmgtype"] = "";
  21959.                                                                                 }
  21960.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage2"]) {
  21961.                                                                                         update["repeating_attack_" + attackid + "_dmg2base"] = v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage2"];
  21962.                                                                                         update["repeating_attack_" + attackid + "_dmg2attr"] = 0;
  21963.                                                                                         update["repeating_attack_" + attackid + "_dmg2flag"] = "{{damage=1}} {{dmg2flag=1}}";
  21964.                                                                                 }
  21965.                                                                                 else {
  21966.                                                                                         update["repeating_attack_" + attackid + "_dmg2base"] = "";
  21967.                                                                                         update["repeating_attack_" + attackid + "_dmg2attr"] = 0;
  21968.                                                                                         update["repeating_attack_" + attackid + "_dmg2flag"] = "0";
  21969.                                                                                 }
  21970.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spelldamagetype2"]) {
  21971.                                                                                         update["repeating_attack_" + attackid + "_dmg2type"] = v["repeating_spell-" + lvl + "_" + spellid + "_spelldamagetype2"];
  21972.                                                                                 }
  21973.                                                                                 else {
  21974.                                                                                         update["repeating_attack_" + attackid + "_dmg2type"] = "";
  21975.                                                                                 }
  21976.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spellrange"]) {
  21977.                                                                                         update["repeating_attack_" + attackid + "_atkrange"] = v["repeating_spell-" + lvl + "_" + spellid + "_spellrange"];
  21978.                                                                                 }
  21979.                                                                                 else {
  21980.                                                                                         update["repeating_attack_" + attackid + "_atkrange"] = "";
  21981.                                                                                 }
  21982.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spellrange"]) {
  21983.                                                                                         update["repeating_attack_" + attackid + "_atkrange"] = v["repeating_spell-" + lvl + "_" + spellid + "_spellrange"];
  21984.                                                                                 }
  21985.                                                                                 else {
  21986.                                                                                         update["repeating_attack_" + attackid + "_atkrange"] = "";
  21987.                                                                                 }
  21988.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spellsave"]) {
  21989.                                                                                         update["repeating_attack_" + attackid + "_saveflag"] = "{{save=1}} {{saveattr=@{saveattr}}} {{savedesc=@{saveeffect}}} {{savedc=[[[[@{savedc}]][SAVE]]]}}";
  21990.                                                                                         update["repeating_attack_" + attackid + "_saveattr"] = v["repeating_spell-" + lvl + "_" + spellid + "_spellsave"];
  21991.                                                                                 }
  21992.                                                                                 else {
  21993.                                                                                         update["repeating_attack_" + attackid + "_saveflag"] = "0";
  21994.                                                                                 }
  21995.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spellsavesuccess"]) {
  21996.                                                                                         update["repeating_attack_" + attackid + "_saveeffect"] = v["repeating_spell-" + lvl + "_" + spellid + "_spellsavesuccess"];
  21997.                                                                                 }
  21998.                                                                                 else {
  21999.                                                                                         update["repeating_attack_" + attackid + "_saveeffect"] = "";
  22000.                                                                                 }
  22001.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spellhldie"] && v["repeating_spell-" + lvl + "_" + spellid + "_spellhldie"] != "" && v["repeating_spell-" + lvl + "_" + spellid + "_spellhldietype"] && v["repeating_spell-" + lvl + "_" + spellid + "_spellhldietype"] != "") {
  22002.                                                                                         var bonus = "";
  22003.                                                                                         var spelllevel = v["repeating_spell-" + lvl + "_" + spellid + "_spelllevel"];
  22004.                                                                                         var query = "?{Cast at what level?";
  22005.                                                                                         for(i = 0; i < 10-spelllevel; i++) {
  22006.                                                                                                 query = query + "|Level " + (parseInt(i, 10) + parseInt(spelllevel, 10)) + "," + i;
  22007.                                                                                         }
  22008.                                                                                         query = query + "}";
  22009.                                                                                         if(v["repeating_spell-" + lvl + "_" + spellid + "_spellhlbonus"] && v["repeating_spell-" + lvl + "_" + spellid + "_spellhlbonus"] != "") {
  22010.                                                                                                 bonus = "+(" + v["repeating_spell-" + lvl + "_" + spellid + "_spellhlbonus"] + "*" + query + ")";
  22011.                                                                                         }
  22012.                                                                                         update["repeating_attack_" + attackid + "_hldmg"] = "{{hldmg=[[(" + v["repeating_spell-" + lvl + "_" + spellid + "_spellhldie"] + "*" + query + ")" + v["repeating_spell-" + lvl + "_" + spellid + "_spellhldietype"] + bonus + "]]}}";
  22013.                                                                                 }
  22014.                                                                                 else {
  22015.                                                                                         update["repeating_attack_" + attackid + "_hldmg"] = "";
  22016.                                                                                 }
  22017.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spellhealing"] && v["repeating_spell-" + lvl + "_" + spellid + "_spellhealing"] != "") {
  22018.                                                                                         if(!v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage"] || v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage"] === "") {
  22019.                                                                                                 update["repeating_attack_" + attackid + "_dmgbase"] = v["repeating_spell-" + lvl + "_" + spellid + "_spellhealing"];
  22020.                                                                                                 update["repeating_attack_" + attackid + "_dmgflag"] = "{{damage=1}} {{dmg1flag=1}}";
  22021.                                                                                                 update["repeating_attack_" + attackid + "_dmgtype"] = "Healing";
  22022.                                                                                         }
  22023.                                                                                         else if(!v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage2"] || v["repeating_spell-" + lvl + "_" + spellid + "_spelldamage2"] === "") {
  22024.                                                                                                 update["repeating_attack_" + attackid + "_dmg2base"] = v["repeating_spell-" + lvl + "_" + spellid + "_spellhealing"];
  22025.                                                                                                 update["repeating_attack_" + attackid + "_dmg2flag"] = "{{damage=1}} {{dmg2flag=1}}";
  22026.                                                                                                 update["repeating_attack_" + attackid + "_dmg2type"] = "Healing";
  22027.                                                                                         }
  22028.                                                                                 }
  22029.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_innate"]) {
  22030.                                                                                         update["repeating_attack_" + attackid + "_spell_innate"] = v["repeating_spell-" + lvl + "_" + spellid + "_innate"];
  22031.                                                                                 }
  22032.                                                                                 else {
  22033.                                                                                         update["repeating_attack_" + attackid + "_spell_innate"] = "";
  22034.                                                                                 }
  22035.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_spelltarget"]) {
  22036.                                                                                         description = description + v["repeating_spell-" + lvl + "_" + spellid + "_spelltarget"] + ". ";
  22037.                                                                                 }
  22038.                                                                                 if(v["repeating_spell-" + lvl + "_" + spellid + "_includedesc"] && v["repeating_spell-" + lvl + "_" + spellid + "_includedesc"] === "on") {
  22039.                                                                                         description = v["repeating_spell-" + lvl + "_" + spellid + "_spelldescription"];
  22040.                                                                                         if(v["repeating_spell-" + lvl + "_" + spellid + "_spellathigherlevels"] && v["repeating_spell-" + lvl + "_" + spellid + "_spellathigherlevels"] != "") {
  22041.                                                                                                 description = description + "\n\nAt Higher Levels: " + v["repeating_spell-" + lvl + "_" + spellid + "_spellathigherlevels"];
  22042.                                                                                         }
  22043.                                                                                 }
  22044.                                                                                 else if(v["repeating_spell-" + lvl + "_" + spellid + "_includedesc"] && v["repeating_spell-" + lvl + "_" + spellid + "_includedesc"] === "off") {
  22045.                                                                                         description = "";
  22046.                                                                                 }
  22047.                                                                                 update["repeating_attack_" + attackid + "_atk_desc"] = description;
  22048.  
  22049.                                                                                 // TODO are these necessary?
  22050.                                                                                 // var callback = function() {update_attacks(attackid, "spell")};
  22051.                                                                                 // setAttrs(update, {silent: true}, callback);
  22052.                                                                                 setAttrs(update);
  22053.                                                                         }
  22054.  
  22055.                                                                         // largely stolen from `create_attack_from_spell`
  22056.                                                                         function create_attack_from_spell (lvl, spellid, character_id) {
  22057.                                                                                 var update = {};
  22058.                                                                                 var newrowid = d20plus.ut.generateRowId();
  22059.                                                                                 update["repeating_spell-" + lvl + "_" + spellid + "_spellattackid"] = newrowid;
  22060.                                                                                 update["repeating_spell-" + lvl + "_" + spellid + "_rollcontent"] = "%{" + character_id + "|repeating_attack_" + newrowid + "_attack}";
  22061.                                                                                 setAttrs(update, update_attack_from_spell(lvl, spellid, newrowid, true));
  22062.                                                                         }
  22063.  
  22064.                                                                         // largely stolen from `processDrop`
  22065.                                                                         function processDrop (page) {
  22066.                                                                                 const update = {};
  22067.                                                                                 const callbacks = [];
  22068.                                                                                 const id = d20plus.ut.generateRowId();
  22069.  
  22070.                                                                                 /* eslint-disable block-spacing, no-extra-semi */
  22071.                                                                                 var lvl = page.data["Level"] && page.data["Level"] > 0 ? page.data["Level"] : "cantrip";
  22072.                                                                                 update["repeating_spell-" + lvl + "_" + id + "_spelllevel"] = lvl;
  22073.                                                                                 if(page.data["spellcasting_ability"]) {
  22074.                                                                                         update["repeating_spell-" + lvl + "_" + id + "_spell_ability"] = page.data["spellcasting_ability"];
  22075.                                                                                 } else {
  22076.                                                                                         update["repeating_spell-" + lvl + "_" + id + "_spell_ability"] = "spell";
  22077.                                                                                 }
  22078.                                                                                 if(page.name) {update["repeating_spell-" + lvl + "_" + id + "_spellname"] = page.name};
  22079.                                                                                 if(page.data["Ritual"]) {update["repeating_spell-" + lvl + "_" + id + "_spellritual"] = "{{ritual=1}}"};
  22080.                                                                                 if(page.data["School"]) {update["repeating_spell-" + lvl + "_" + id + "_spellschool"] = page.data["School"].toLowerCase()};
  22081.                                                                                 if(page.data["Casting Time"]) {update["repeating_spell-" + lvl + "_" + id + "_spellcastingtime"] = page.data["Casting Time"]};
  22082.                                                                                 if(page.data["Range"]) {update["repeating_spell-" + lvl + "_" + id + "_spellrange"] = page.data["Range"]};
  22083.                                                                                 if(page.data["Target"]) {update["repeating_spell-" + lvl + "_" + id + "_spelltarget"] = page.data["Target"]};
  22084.                                                                                 if(page.data["Components"]) {
  22085.                                                                                         if(page.data["Components"].toLowerCase().indexOf("v") === -1) {update["repeating_spell-" + lvl + "_" + id + "_spellcomp_v"] = "0"};
  22086.                                                                                         if(page.data["Components"].toLowerCase().indexOf("s") === -1) {update["repeating_spell-" + lvl + "_" + id + "_spellcomp_s"] = "0"};
  22087.                                                                                         if(page.data["Components"].toLowerCase().indexOf("m") === -1) {update["repeating_spell-" + lvl + "_" + id + "_spellcomp_m"] = "0"};
  22088.                                                                                 };
  22089.                                                                                 if(page.data["Material"]) {update["repeating_spell-" + lvl + "_" + id + "_spellcomp_materials"] = page.data["Material"]};
  22090.                                                                                 if(page.data["Concentration"]) {update["repeating_spell-" + lvl + "_" + id + "_spellconcentration"] = "{{concentration=1}}"};
  22091.                                                                                 if(page.data["Duration"]) {update["repeating_spell-" + lvl + "_" + id + "_spellduration"] = page.data["Duration"]};
  22092.                                                                                 if(page.data["Damage"] || page.data["Healing"]) {
  22093.                                                                                         update["repeating_spell-" + lvl + "_" + id + "_spelloutput"] = "ATTACK";
  22094.                                                                                         callbacks.push( function() {create_attack_from_spell(lvl, id, character.id);} );
  22095.                                                                                 }
  22096.                                                                                 else if(page.data["Higher Spell Slot Desc"] && page.data["Higher Spell Slot Desc"] != "") {
  22097.                                                                                         var spelllevel = "?{Cast at what level?";
  22098.                                                                                         for(i = 0; i < 10-lvl; i++) {
  22099.                                                                                                 spelllevel = spelllevel + "|Level " + (parseInt(i, 10) + parseInt(lvl, 10)) + "," + (parseInt(i, 10) + parseInt(lvl, 10));
  22100.                                                                                         }
  22101.                                                                                         spelllevel = spelllevel + "}";
  22102.                                                                                         update["repeating_spell-" + lvl + "_" + id + "_rollcontent"] = "@{wtype}&{template:spell} {{level=@{spellschool} " + spelllevel + "}} {{name=@{spellname}}} {{castingtime=@{spellcastingtime}}} {{range=@{spellrange}}} {{target=@{spelltarget}}} @{spellcomp_v} @{spellcomp_s} @{spellcomp_m} {{material=@{spellcomp_materials}}} {{duration=@{spellduration}}} {{description=@{spelldescription}}} {{athigherlevels=@{spellathigherlevels}}} @{spellritual} {{innate=@{innate}}} @{spellconcentration} @{charname_output}";
  22103.                                                                                 };
  22104.                                                                                 if(page.data["Spell Attack"]) {update["repeating_spell-" + lvl + "_" + id + "_spellattack"] = page.data["Spell Attack"]};
  22105.                                                                                 if(page.data["Damage"]) {update["repeating_spell-" + lvl + "_" + id + "_spelldamage"] = page.data["Damage"]};
  22106.                                                                                 if(page.data["Damage Type"]) {update["repeating_spell-" + lvl + "_" + id + "_spelldamagetype"] = page.data["Damage Type"]};
  22107.                                                                                 if(page.data["Secondary Damage"]) {update["repeating_spell-" + lvl + "_" + id + "_spelldamage2"] = page.data["Secondary Damage"]};
  22108.                                                                                 if(page.data["Secondary Damage Type"]) {update["repeating_spell-" + lvl + "_" + id + "_spelldamagetype2"] = page.data["Secondary Damage Type"]};
  22109.                                                                                 if(page.data["Healing"]) {update["repeating_spell-" + lvl + "_" + id + "_spellhealing"] = page.data["Healing"];};
  22110.                                                                                 if(page.data["Add Casting Modifier"]) {update["repeating_spell-" + lvl + "_" + id + "_spelldmgmod"] = page.data["Add Casting Modifier"]};
  22111.                                                                                 if(page.data["Save"]) {update["repeating_spell-" + lvl + "_" + id + "_spellsave"] = page.data["Save"]};
  22112.                                                                                 if(page.data["Save Success"]) {update["repeating_spell-" + lvl + "_" + id + "_spellsavesuccess"] = page.data["Save Success"]};
  22113.                                                                                 if(page.data["Higher Spell Slot Dice"]) {update["repeating_spell-" + lvl + "_" + id + "_spellhldie"] = page.data["Higher Spell Slot Dice"]};
  22114.                                                                                 if(page.data["Higher Spell Slot Die"]) {update["repeating_spell-" + lvl + "_" + id + "_spellhldietype"] = page.data["Higher Spell Slot Die"]};
  22115.                                                                                 if(page.data["Higher Spell Slot Bonus"]) {update["repeating_spell-" + lvl + "_" + id + "_spellhlbonus"] = page.data["Higher Spell Slot Bonus"]};
  22116.                                                                                 if(page.data["Higher Spell Slot Desc"]) {update["repeating_spell-" + lvl + "_" + id + "_spellathigherlevels"] = page.data["Higher Spell Slot Desc"]};
  22117.                                                                                 if(page.data["data-Cantrip Scaling"] && lvl == "cantrip") {update["repeating_spell-" + lvl + "_" + id + "_spell_damage_progression"] = "Cantrip " + page.data["data-Cantrip Scaling"].charAt(0).toUpperCase() + page.data["data-Cantrip Scaling"].slice(1);};
  22118.                                                                                 if(page.data["data-description"]) { update["repeating_spell-" + lvl + "_" + id + "_spelldescription"] = page.data["data-description"]};
  22119.                                                                                 update["repeating_spell-" + lvl + "_" + id + "_options-flag"] = "0";
  22120.  
  22121.                                                                                 // custom writing:
  22122.                                                                                 setAttrs(update, callbacks);
  22123.                                                                                 /* eslint-enable block-spacing, no-extra-semi */
  22124.                                                                         }
  22125.  
  22126.                                                                         processDrop(data);
  22127.  
  22128.                                                                         // on final item, add macro
  22129.                                                                         if (index === addMacroIndex) {
  22130.                                                                                 if (d20plus.cfg.getOrDefault("import", "tokenactionsSpells")) {
  22131.                                                                                         if (d20plus.sheet === "shaped") {
  22132.                                                                                                 character.abilities.create({
  22133.                                                                                                         name: "Spells",
  22134.                                                                                                         istokenaction: true,
  22135.                                                                                                         action: `%{${character.id}|shaped_spells}`
  22136.                                                                                                 }).save();
  22137.                                                                                         } else {
  22138.                                                                                                 // collect name and identifier for all the character's spells
  22139.                                                                                                 const macroSpells = character.attribs.toJSON()
  22140.                                                                                                         .filter(it => it.name.startsWith("repeating_spell-") && it.name.endsWith("spellname"))
  22141.                                                                                                         .map(it => ({identifier: it.name.replace(/_spellname$/, "_spell"), name: it.current}));
  22142.  
  22143.                                                                                                 // build tokenaction
  22144.                                                                                                 const ixToReplaceIn = tokenActionStack.length - 1;
  22145.                                                                                                 let toReplaceIn = tokenActionStack.last();
  22146.  
  22147.                                                                                                 macroSpells.forEach(mSp => {
  22148.                                                                                                         let didReplace = false;
  22149.                                                                                                         toReplaceIn = toReplaceIn.replace(new RegExp(`${d20plus.monsters.TAG_SPELL_OPEN}\\s*${mSp.name}\\s*${d20plus.monsters.TAG_SPELL_CLOSE}`, "gi"), () => {
  22150.                                                                                                                 didReplace = true;
  22151.                                                                                                                 return `[${mSp.name}](~selected|${mSp.identifier})`
  22152.                                                                                                         });
  22153.  
  22154.                                                                                                         if (!didReplace) {
  22155.                                                                                                                 tokenActionStack.push(`[${mSp.name}](~selected|${mSp.identifier})`)
  22156.                                                                                                         }
  22157.                                                                                                 });
  22158.  
  22159.                                                                                                 // clean e.g.
  22160.                                                                                                 /*
  22161.                                                                                                 Cantrips:
  22162.  
  22163.                                                                                                 [...text...]
  22164.                                                                                                  */
  22165.                                                                                                 // to
  22166.                                                                                                 /*
  22167.                                                                                                 Cantrips:
  22168.                                                                                                 [...text...]
  22169.                                                                                                  */
  22170.                                                                                                 toReplaceIn = toReplaceIn.replace(/: *\n\n+/gi, ":\n");
  22171.  
  22172.                                                                                                 // clean any excess tags
  22173.                                                                                                 toReplaceIn = toReplaceIn
  22174.                                                                                                         .replace(new RegExp(d20plus.monsters.TAG_SPELL_OPEN, "gi"), "")
  22175.                                                                                                         .replace(new RegExp(d20plus.monsters.TAG_SPELL_CLOSE, "gi"), "");
  22176.  
  22177.                                                                                                 tokenActionStack[ixToReplaceIn] = toReplaceIn;
  22178.  
  22179.                                                                                                 character.abilities.create({
  22180.                                                                                                         name: "Spells",
  22181.                                                                                                         istokenaction: true,
  22182.                                                                                                         action: `/w gm @{selected|wtype}&{template:npcaction} {{name=@{selected|npc_name}}} {{rname=Spellcasting}} {{description=${tokenActionStack.join("")}}}`
  22183.                                                                                                 }).save();
  22184.                                                                                         }
  22185.                                                                                 }
  22186.                                                                         }
  22187.                                                                 }
  22188.                                                         }
  22189.                                                         if (data.trait) {
  22190.                                                                 $.each(data.trait, function (i, v) {
  22191.                                                                         var newRowId = d20plus.ut.generateRowId();
  22192.                                                                         character.attribs.create({
  22193.                                                                                 name: "repeating_npctrait_" + newRowId + "_name",
  22194.                                                                                 current: d20plus.importer.getCleanText(renderer.render(v.name))
  22195.                                                                         });
  22196.  
  22197.                                                                         if (d20plus.cfg.getOrDefault("import", "tokenactionsTraits")) {
  22198.                                                                                 const offsetIndex = data.spellcasting ? 1 + i : i;
  22199.                                                                                 character.abilities.create({
  22200.                                                                                         name: "Trait" + offsetIndex + ": " + v.name,
  22201.                                                                                         istokenaction: true,
  22202.                                                                                         action: d20plus.actionMacroTrait(offsetIndex)
  22203.                                                                                 });
  22204.                                                                         }
  22205.  
  22206.                                                                         var text = d20plus.importer.getCleanText(renderer.render({entries: v.entries}, 1));
  22207.                                                                         character.attribs.create({name: "repeating_npctrait_" + newRowId + "_desc", current: text});
  22208.                                                                 });
  22209.                                                         }
  22210.                                                         if (data.action) {
  22211.                                                                 let offset = 0;
  22212.  
  22213.                                                                 $.each(data.action, function (i, action) {
  22214.                                                                         const name = d20plus.importer.getCleanText(renderer.render(action.name));
  22215.                                                                         const text = d20plus.importer.getCleanText(renderer.render({entries: action.entries}, 1));
  22216.  
  22217.                                                                         // special cases for specific creatures
  22218.                                                                         if (data.name === "Hellfire Engine" && data.source === SRC_MTF && name === "Hellfire Weapons") {
  22219.                                                                                 const baseActionEnts = action.entries.filter(it => typeof it === "string");
  22220.                                                                                 baseActionEnts[0] = "The hellfire engine uses one of the options listed below.";
  22221.                                                                                 const baseAction = renderer.render({entries: baseActionEnts}, 1);
  22222.                                                                                 d20plus.importer.addAction(character, name, d20plus.importer.getCleanText(baseAction), i + offset);
  22223.                                                                                 offset++;
  22224.  
  22225.                                                                                 action.entries.find(it => it.type === "list").items.forEach(item => {
  22226.                                                                                         const itemName = d20plus.importer.getCleanText(renderer.render(item.name));
  22227.                                                                                         d20plus.importer.addAction(character, itemName, d20plus.importer.getCleanText(renderer.render({entries: [item.entry]})), i + offset);
  22228.                                                                                         offset++;
  22229.                                                                                 });
  22230.  
  22231.                                                                                 offset++;
  22232.                                                                         } else if (name === "Eye Rays") {
  22233.                                                                                 const [base, ...others] = action.entries;
  22234.  
  22235.                                                                                 const baseAction = renderer.render({entries: [base]}, 1);
  22236.                                                                                 d20plus.importer.addAction(character, name, d20plus.importer.getCleanText(baseAction), i + offset);
  22237.                                                                                 offset++;
  22238.  
  22239.                                                                                 const packedOthers = [];
  22240.                                                                                 others.forEach(it => {
  22241.                                                                                         const m = /^(\d+\.\s*[^.]+?\s*)[.:](.*)$/.exec(it);
  22242.                                                                                         if (m) {
  22243.                                                                                                 const partName = m[1].trim();
  22244.                                                                                                 const text = m[2].trim();
  22245.                                                                                                 packedOthers.push({name: partName, text: text});
  22246.                                                                                         } else packedOthers[packedOthers.length - 1].text += ` ${it}`;
  22247.                                                                                 });
  22248.  
  22249.                                                                                 packedOthers.forEach(it => {
  22250.                                                                                         d20plus.importer.addAction(character, it.name, d20plus.importer.getCleanText(renderer.render(it.text)), i + offset);
  22251.                                                                                         offset++;
  22252.                                                                                 });
  22253.                                                                         } else {
  22254.                                                                                 d20plus.importer.addAction(character, name, text, i + offset);
  22255.                                                                         }
  22256.                                                                 });
  22257.                                                         }
  22258.                                                         if (data.reaction) {
  22259.                                                                 character.attribs.create({name: "reaction_flag", current: 1});
  22260.                                                                 character.attribs.create({name: "npcreactionsflag", current: 1});
  22261.  
  22262.                                                                 if (d20plus.cfg.getOrDefault("import", "tokenactions") && d20plus.sheet === "shaped") {
  22263.                                                                         character.abilities.create({
  22264.                                                                                 name: "Reactions",
  22265.                                                                                 istokenaction: true,
  22266.                                                                                 action: `%{${character.id}|shaped_reactions}`
  22267.                                                                         });
  22268.                                                                 }
  22269.  
  22270.                                                                 $.each(data.reaction, function (i, v) {
  22271.                                                                         var newRowId = d20plus.ut.generateRowId();
  22272.                                                                         let text = "";
  22273.                                                                         character.attribs.create({
  22274.                                                                                 name: "repeating_npcreaction_" + newRowId + "_name",
  22275.                                                                                 current: d20plus.importer.getCleanText(renderer.render(v.name))
  22276.                                                                         });
  22277.  
  22278.                                                                         // roll20 only supports a single reaction, so only use the first
  22279.                                                                         if (d20plus.cfg.getOrDefault("import", "tokenactions") && i === 0 && d20plus.sheet !== "shaped") {
  22280.                                                                                 character.abilities.create({
  22281.                                                                                         name: "Reaction: " + v.name,
  22282.                                                                                         istokenaction: true,
  22283.                                                                                         action: d20plus.actionMacroReaction
  22284.                                                                                 });
  22285.                                                                         }
  22286.  
  22287.                                                                         text = d20plus.importer.getCleanText(renderer.render({entries: v.entries}, 1));
  22288.                                                                         character.attribs.create({
  22289.                                                                                 name: "repeating_npcreaction_" + newRowId + "_desc",
  22290.                                                                                 current: text
  22291.                                                                         });
  22292.                                                                         character.attribs.create({
  22293.                                                                                 name: "repeating_npcreaction_" + newRowId + "_description",
  22294.                                                                                 current: text
  22295.                                                                         });
  22296.                                                                 });
  22297.                                                         }
  22298.                                                         if (data.legendary) {
  22299.                                                                 character.attribs.create({name: "legendary_flag", current: "1"});
  22300.                                                                 let legendaryActions = data.legendaryActions || 3;
  22301.                                                                 character.attribs.create({name: "npc_legendary_actions", current: legendaryActions.toString()});
  22302.  
  22303.                                                                 if (d20plus.cfg.getOrDefault("import", "tokenactions") && d20plus.sheet === "shaped") {
  22304.                                                                         character.abilities.create({
  22305.                                                                                 name: "Legendary Actions",
  22306.                                                                                 istokenaction: true,
  22307.                                                                                 action: `%{${character.id}|shaped_legendaryactions}`
  22308.                                                                         });
  22309.                                                                 }
  22310.  
  22311.                                                                 let tokenactiontext = "";
  22312.                                                                 $.each(data.legendary, function (i, v) {
  22313.                                                                         var newRowId = d20plus.ut.generateRowId();
  22314.  
  22315.                                                                         if (d20plus.cfg.getOrDefault("import", "tokenactions") && d20plus.sheet !== "shaped") {
  22316.                                                                                 tokenactiontext += "[" + v.name + "](~selected|repeating_npcaction-l_$" + i + "_npc_action)\n\r";
  22317.                                                                         }
  22318.  
  22319.                                                                         var rollbase = d20plus.importer.rollbase();
  22320.  
  22321.                                                                         // FIXME v.attack has been removed from the data; create a parser equivalent
  22322.                                                                         if (v.attack != null) {
  22323.                                                                                 if (!(v.attack instanceof Array)) {
  22324.                                                                                         var tmp = v.attack;
  22325.                                                                                         v.attack = [];
  22326.                                                                                         v.attack.push(tmp);
  22327.                                                                                 }
  22328.                                                                                 $.each(v.attack, function (z, x) {
  22329.                                                                                         if (!x) return;
  22330.                                                                                         var attack = x.split("|");
  22331.                                                                                         var name = "";
  22332.                                                                                         if (v.attack.length > 1)
  22333.                                                                                                 name = (attack[0] == v.name) ? v.name : v.name + " - " + attack[0] + "";
  22334.                                                                                         else
  22335.                                                                                                 name = v.name;
  22336.                                                                                         var onhit = "";
  22337.                                                                                         var damagetype = "";
  22338.                                                                                         if (attack.length == 2) {
  22339.                                                                                                 damage = "" + attack[1];
  22340.                                                                                                 tohit = "";
  22341.                                                                                         } else {
  22342.                                                                                                 damage = "" + attack[2];
  22343.                                                                                                 tohit = attack[1] || 0;
  22344.                                                                                         }
  22345.                                                                                         character.attribs.create({
  22346.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_name",
  22347.                                                                                                 current: d20plus.importer.getCleanText(renderer.render(name))
  22348.                                                                                         });
  22349.                                                                                         character.attribs.create({
  22350.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_attack_flag",
  22351.                                                                                                 current: "on"
  22352.                                                                                         });
  22353.                                                                                         character.attribs.create({
  22354.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_npc_options-flag",
  22355.                                                                                                 current: 0
  22356.                                                                                         });
  22357.                                                                                         character.attribs.create({
  22358.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_attack_display_flag",
  22359.                                                                                                 current: "{{attack=1}}"
  22360.                                                                                         });
  22361.                                                                                         character.attribs.create({
  22362.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_attack_options",
  22363.                                                                                                 current: "{{attack=1}}"
  22364.                                                                                         });
  22365.                                                                                         character.attribs.create({
  22366.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_attack_tohit",
  22367.                                                                                                 current: tohit
  22368.                                                                                         });
  22369.                                                                                         character.attribs.create({
  22370.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_attack_damage",
  22371.                                                                                                 current: damage
  22372.                                                                                         });
  22373.                                                                                         character.attribs.create({
  22374.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_name_display",
  22375.                                                                                                 current: name
  22376.                                                                                         });
  22377.                                                                                         character.attribs.create({
  22378.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_rollbase",
  22379.                                                                                                 current: rollbase
  22380.                                                                                         });
  22381.                                                                                         character.attribs.create({
  22382.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_attack_type",
  22383.                                                                                                 current: ""
  22384.                                                                                         });
  22385.                                                                                         character.attribs.create({
  22386.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_attack_tohitrange",
  22387.                                                                                                 current: ""
  22388.                                                                                         });
  22389.                                                                                         character.attribs.create({
  22390.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_damage_flag",
  22391.                                                                                                 current: "{{damage=1}} {{dmg1flag=1}} {{dmg2flag=1}}"
  22392.                                                                                         });
  22393.                                                                                         if (damage !== "") {
  22394.                                                                                                 damage1 = damage.replace(/\s/g, "").split(/d|(?=\+|-)/g);
  22395.                                                                                                 if (damage1[1])
  22396.                                                                                                         damage1[1] = damage1[1].replace(/[^0-9-+]/g, "");
  22397.                                                                                                 damage2 = isNaN(eval(damage1[1])) === false ? eval(damage1[1]) : 0;
  22398.                                                                                                 if (damage1.length < 2) {
  22399.                                                                                                         onhit = onhit + damage1[0] + " (" + damage + ")" + damagetype + " damage";
  22400.                                                                                                 } else if (damage1.length < 3) {
  22401.                                                                                                         onhit = onhit + Math.floor(damage1[0] * ((damage2 / 2) + 0.5)) + " (" + damage + ")" + damagetype + " damage";
  22402.                                                                                                 } else {
  22403.                                                                                                         onhit = onhit + (Math.floor(damage1[0] * ((damage2 / 2) + 0.5)) + parseInt(damage1[2], 10)) + " (" + damage + ")" + damagetype + " damage";
  22404.                                                                                                 }
  22405.                                                                                         }
  22406.                                                                                         character.attribs.create({
  22407.                                                                                                 name: "repeating_npcaction-l_" + newRowId + "_attack_onhit",
  22408.                                                                                                 current: onhit
  22409.                                                                                         });
  22410.                                                                                 });
  22411.                                                                         } else {
  22412.                                                                                 character.attribs.create({
  22413.                                                                                         name: "repeating_npcaction-l_" + newRowId + "_name",
  22414.                                                                                         current: v.name
  22415.                                                                                 });
  22416.                                                                                 character.attribs.create({
  22417.                                                                                         name: "repeating_npcaction-l_" + newRowId + "_npc_options-flag",
  22418.                                                                                         current: 0
  22419.                                                                                 });
  22420.                                                                                 character.attribs.create({
  22421.                                                                                         name: "repeating_npcaction-l_" + newRowId + "_rollbase",
  22422.                                                                                         current: rollbase
  22423.                                                                                 });
  22424.                                                                                 character.attribs.create({
  22425.                                                                                         name: "repeating_npcaction-l_" + newRowId + "_name_display",
  22426.                                                                                         current: v.name
  22427.                                                                                 });
  22428.                                                                         }
  22429.  
  22430.                                                                         var text = d20plus.importer.getCleanText(renderer.render({entries: v.entries}, 1));
  22431.                                                                         var descriptionFlag = Math.max(Math.ceil(text.length / 57), 1);
  22432.                                                                         character.attribs.create({
  22433.                                                                                 name: "repeating_npcaction-l_" + newRowId + "_description",
  22434.                                                                                 current: text
  22435.                                                                         });
  22436.                                                                         character.attribs.create({
  22437.                                                                                 name: "repeating_npcaction-l_" + newRowId + "_description_flag",
  22438.                                                                                 current: descriptionFlag
  22439.                                                                         });
  22440.                                                                 });
  22441.  
  22442.                                                                 if (d20plus.cfg.getOrDefault("import", "tokenactions") && d20plus.sheet !== "shaped") {
  22443.                                                                         character.abilities.create({
  22444.                                                                                 name: "Legendary Actions",
  22445.                                                                                 istokenaction: true,
  22446.                                                                                 action: d20plus.actionMacroLegendary(tokenactiontext)
  22447.                                                                         });
  22448.                                                                 }
  22449.                                                         }
  22450.  
  22451.                                                         // set show/hide NPC names in rolls
  22452.                                                         if (d20plus.cfg.has("import", "showNpcNames") && !d20plus.cfg.get("import", "showNpcNames")) {
  22453.                                                                 character.attribs.create({name: "npc_name_flag", current: 0});
  22454.                                                         }
  22455.  
  22456.                                                         if (d20plus.cfg.getOrDefault("import", "tokenactions") && d20plus.sheet === "shaped") {
  22457.                                                                 character.abilities.create({
  22458.                                                                         name: "Actions",
  22459.                                                                         istokenaction: true,
  22460.                                                                         action: `%{${character.id}|shaped_actions}`
  22461.                                                                 });
  22462.  
  22463.                                                                 // TODO lair action creation is unimplemented
  22464.                                                                 /*
  22465.                                                                 character.abilities.create({
  22466.                                                                         name: "Lair Actions",
  22467.                                                                         istokenaction: true,
  22468.                                                                         action: `%{${character.id}|shaped_lairactions}`
  22469.                                                                 });
  22470.                                                                 */
  22471.                                                         }
  22472.  
  22473.                                                         character.view._updateSheetValues();
  22474.  
  22475.                                                         if (renderFluff) {
  22476.                                                                 setTimeout(() => {
  22477.                                                                         const fluffAs = d20plus.cfg.get("import", "importFluffAs") || d20plus.cfg.getDefault("import", "importFluffAs");
  22478.                                                                         let k = fluffAs === "Bio"? "bio" : "gmnotes";
  22479.                                                                         character.updateBlobs({
  22480.                                                                                 [k]: Markdown.parse(renderFluff)
  22481.                                                                         });
  22482.                                                                         character.save({
  22483.                                                                                 [k]: (new Date).getTime()
  22484.                                                                         });
  22485.                                                                 }, 500);
  22486.                                                         }
  22487.                                                 } catch (e) {
  22488.                                                         d20plus.ut.log("Error loading [" + name + "]");
  22489.                                                         d20plus.addImportError(name);
  22490.                                                         console.log(data);
  22491.                                                         console.log(e);
  22492.                                                 }
  22493.                                                 /* end OGL Sheet */
  22494.                                                 d20.journal.addItemToFolderStructure(character.id, folder.id);
  22495.  
  22496.                                                 if (options.charFunction) {
  22497.                                                         options.charFunction(character);
  22498.                                                 }
  22499.                                         }
  22500.                                 });
  22501.                 };
  22502.  
  22503.                 // pre-load fluff
  22504.                 const src = data.source;
  22505.                 if (src && monsterFluffDataUrls[src]) {
  22506.                         const fluffUrl = d20plus.monsters.formMonsterUrl(monsterFluffDataUrls[src]);
  22507.                         DataUtil.loadJSON(fluffUrl).then((data) => {
  22508.                                 monsterFluffData[src] = data;
  22509.                         }).catch(e => {
  22510.                                 console.error(e);
  22511.                                 monsterFluffData[src] = {monster: []};
  22512.                         }).then(doBuild);
  22513.                 } else {
  22514.                         doBuild();
  22515.                 }
  22516.         };
  22517. }
  22518.  
  22519. SCRIPT_EXTENSIONS.push(d20plusMonsters);
  22520.  
  22521.  
  22522. function d20plusSpells () {
  22523.         d20plus.spells = {};
  22524.  
  22525.         d20plus.spells.formSpellUrl = function (fileName) {
  22526.                 return d20plus.formSrcUrl(SPELL_DATA_DIR, fileName);
  22527.         };
  22528.  
  22529.         d20plus.spells._groupOptions = ["Level", "Spell Points", "Alphabetical", "Source"];
  22530.         d20plus.spells._listCols = ["name", "class", "level", "source"];
  22531.         d20plus.spells._listItemBuilder = (it) => `
  22532.                 <span class="name col-4" title="name">${it.name}</span>
  22533.                 <span class="class col-3" title="class">${((it.classes || {}).fromClassList || []).map(c => `CLS[${c.name}]`).join(", ")}</span>
  22534.                 <span class="level col-3" title="level">LVL[${Parser.spLevelToFull(it.level)}]</span>
  22535.                 <span title="source [Full source name is ${Parser.sourceJsonToFull(it.source)}]" class="source col-2">SRC[${Parser.sourceJsonToAbv(it.source)}]</span>`;
  22536.         d20plus.spells._listIndexConverter = (sp) => {
  22537.                 return {
  22538.                         name: sp.name.toLowerCase(),
  22539.                         class: ((sp.classes || {}).fromClassList || []).map(c => c.name.toLowerCase()),
  22540.                         level: Parser.spLevelToFull(sp.level).toLowerCase(),
  22541.                         source: Parser.sourceJsonToAbv(sp.source).toLowerCase()
  22542.                 };
  22543.         };
  22544.  
  22545.         // Import Spells button was clicked
  22546.         d20plus.spells.button = function (forcePlayer) {
  22547.                 const playerMode = forcePlayer || !window.is_gm;
  22548.                 const url = playerMode ? $("#import-spell-url-player").val() : $("#import-spell-url").val();
  22549.                 if (url && url.trim()) {
  22550.                         const handoutBuilder = playerMode ? d20plus.spells.playerImportBuilder : d20plus.spells.handoutBuilder;
  22551.  
  22552.                         DataUtil.loadJSON(url).then((data) => {
  22553.                                 d20plus.importer.addMeta(data._meta);
  22554.                                 if (data.roll20Spell) spellMetaData.spell = spellMetaData.spell.concat(data.roll20Spell);
  22555.                                 d20plus.importer.showImportList(
  22556.                                         "spell",
  22557.                                         data.spell,
  22558.                                         handoutBuilder,
  22559.                                         {
  22560.                                                 groupOptions: d20plus.spells._groupOptions,
  22561.                                                 forcePlayer,
  22562.                                                 listItemBuilder: d20plus.spells._listItemBuilder,
  22563.                                                 listIndex: d20plus.spells._listCols,
  22564.                                                 listIndexConverter: d20plus.spells._listIndexConverter
  22565.                                         }
  22566.                                 );
  22567.                         });
  22568.                 }
  22569.         };
  22570.  
  22571.         // Import All Spells button was clicked
  22572.         d20plus.spells.buttonAll = async function (forcePlayer) {
  22573.                 const toLoad = Object.keys(spellDataUrls).filter(src => !SourceUtil.isNonstandardSource(src)).map(src => d20plus.spells.formSpellUrl(spellDataUrls[src]));
  22574.  
  22575.                 if (toLoad.length) {
  22576.                         const handoutBuilder = !forcePlayer && window.is_gm ? d20plus.spells.handoutBuilder : d20plus.spells.playerImportBuilder;
  22577.  
  22578.                         const dataStack = (await Promise.all(toLoad.map(async url => DataUtil.loadJSON(url)))).flat();
  22579.  
  22580.                         let toAdd = [];
  22581.                         dataStack.forEach(d => {
  22582.                                 toAdd = toAdd.concat(d.spell);
  22583.                                 if (d.roll20Spell) spellMetaData.spell = spellMetaData.spell.concat(d.roll20Spell);
  22584.                         });
  22585.                         d20plus.importer.showImportList(
  22586.                                 "spell",
  22587.                                 toAdd,
  22588.                                 handoutBuilder,
  22589.                                 {
  22590.                                         groupOptions: d20plus.spells._groupOptions,
  22591.                                         forcePlayer,
  22592.                                         listItemBuilder: d20plus.spells._listItemBuilder,
  22593.                                         listIndex: d20plus.spells._listCols,
  22594.                                         listIndexConverter: d20plus.spells._listIndexConverter
  22595.                                 }
  22596.                         );
  22597.                 }
  22598.         };
  22599.  
  22600.         // Create spell handout from js data object
  22601.         d20plus.spells.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  22602.                 // make dir
  22603.                 const folder = d20plus.journal.makeDirTree(`Spells`, folderName);
  22604.                 const path = ["Spells", ...folderName, data.name];
  22605.  
  22606.                 // handle duplicates/overwrites
  22607.                 if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  22608.  
  22609.                 const name = data.name;
  22610.                 // build spell handout
  22611.                 d20.Campaign.handouts.create({
  22612.                         name: name,
  22613.                         tags: d20plus.importer.getTagString([
  22614.                                 Parser.spSchoolAbvToFull(data.school),
  22615.                                 Parser.spLevelToFull(data.level),
  22616.                                 ...(((data.classes || {}).fromClassList || []).map(c => c.name)),
  22617.                                 Parser.sourceJsonToFull(data.source)
  22618.                         ], "spell")
  22619.                 }, {
  22620.                         success: function (handout) {
  22621.                                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_SPELLS](data)] = {name: data.name, source: data.source, type: "handout", roll20Id: handout.id};
  22622.  
  22623.                                 const [notecontents, gmnotes] = d20plus.spells._getHandoutData(data, options);
  22624.  
  22625.                                 console.log(notecontents);
  22626.                                 handout.updateBlobs({notes: notecontents, gmnotes: gmnotes});
  22627.                                 handout.save({notes: (new Date).getTime(), inplayerjournals: inJournals});
  22628.                                 d20.journal.addItemToFolderStructure(handout.id, folder.id);
  22629.                         }
  22630.                 });
  22631.         };
  22632.  
  22633.         d20plus.spells.playerImportBuilder = function (data) {
  22634.                 const [notecontents, gmnotes] = d20plus.spells._getHandoutData(data);
  22635.  
  22636.                 const importId = d20plus.ut.generateRowId();
  22637.                 d20plus.importer.storePlayerImport(importId, JSON.parse(gmnotes));
  22638.                 d20plus.importer.makePlayerDraggable(importId, data.name);
  22639.         };
  22640.  
  22641.         d20plus.spells._getHandoutData = function (data, builderOptions) {
  22642.                 builderOptions = builderOptions || {};
  22643.                 // merge in roll20 metadata, if available
  22644.                 const spellMeta = spellMetaData.spell.find(sp => sp.name.toLowerCase() === data.name.toLowerCase() && sp.source.toLowerCase() === data.source.toLowerCase());
  22645.                 if (spellMeta) {
  22646.                         data.roll20 = spellMeta.data;
  22647.                 }
  22648.  
  22649.                 if (!data.school) data.school = "A";
  22650.                 if (!data.range) data.range = "Self";
  22651.                 if (!data.duration) data.duration = "Instantaneous";
  22652.                 if (!data.components) data.components = "";
  22653.                 if (!data.time) data.components = "1 action";
  22654.  
  22655.                 const r20Data = {};
  22656.                 if (data.roll20) Object.assign(r20Data, data.roll20);
  22657.                 Object.assign(
  22658.                         r20Data,
  22659.                         {
  22660.                                 "Level": builderOptions.isSpellPoints ? String(Math.min(9, d20plus.spells.spLevelToSpellPoints(data.level))) : String(data.level),
  22661.                                 "Range": Parser.spRangeToFull(data.range),
  22662.                                 "School": Parser.spSchoolAbvToFull(data.school),
  22663.                                 "Source": "5etoolsR20",
  22664.                                 "Classes": d20plus.importer.getCleanText(Parser.spClassesToFull(data.classes)),
  22665.                                 "Category": "Spells",
  22666.                                 "Duration": Parser.spDurationToFull(data.duration).replace(/Concentration,\s*/gi, ""), // prevent double concentration text
  22667.                                 "Material": "",
  22668.                                 "Components": d20plus.spells._parseComponents(data.components),
  22669.                                 "Casting Time": Parser.spTimeListToFull(data.time)
  22670.                         }
  22671.                 );
  22672.  
  22673.                 if (data.range.type === "point" && (data.range.distance.type === UNT_FEET || data.range.distance.type === UNT_MILES)) {
  22674.                         r20Data["data-RangeNum"] = data.range.distance.amount + "";
  22675.                 }
  22676.  
  22677.                 var r20json = {
  22678.                         name: data.name,
  22679.                         content: "",
  22680.                         htmlcontent: "",
  22681.                         data: r20Data
  22682.                 };
  22683.                 if (data.components && data.components.m) {
  22684.                         if (data.components.m.text) r20json.data["Material"] = data.components.m.text;
  22685.                         else if (typeof data.components.m === "string") r20json.data["Material"] = data.components.m;
  22686.                 }
  22687.                 if (data.meta) {
  22688.                         if (data.meta.ritual) r20json.data["Ritual"] = "Yes";
  22689.                 }
  22690.                 if (data.duration.filter(d => d.concentration).length > 0) {
  22691.                         r20json.data["Concentration"] = "Yes";
  22692.                 }
  22693.                 var notecontents = "";
  22694.                 var gmnotes = "";
  22695.                 notecontents += `<p><h3>${data.name}</h3>
  22696. <em>${Parser.spLevelSchoolMetaToFull(data.level, data.school, data.meta)}${builderOptions.isSpellPoints && data.level ? ` (${d20plus.spells.spLevelToSpellPoints(data.level)} spell points)` : ""}</em></p><p>
  22697. <strong>Casting Time:</strong> ${Parser.spTimeListToFull(data.time)}<br>
  22698. <strong>Range:</strong> ${Parser.spRangeToFull(data.range)}<br>
  22699. <strong>Components:</strong> ${Parser.spComponentsToFull(data.components, data.level)}<br>
  22700. <strong>Duration:</strong> ${Parser.spDurationToFull(data.duration)}<br>
  22701. </p>`;
  22702.                 const renderer = new Renderer();
  22703.                 const renderStack = [];
  22704.                 const entryList = {type: "entries", entries: data.entries};
  22705.                 renderer.setBaseUrl(BASE_SITE_URL);
  22706.                 renderer.recursiveRender(entryList, renderStack, {depth: 1});
  22707.                 r20json.content = d20plus.importer.getCleanText(renderStack.join(" "));
  22708.                 r20json.data["data-description"] = r20json.content;
  22709.                 notecontents += renderStack.join("");
  22710.                 if (data.entriesHigherLevel) {
  22711.                         const hLevelRenderStack = [];
  22712.                         const higherLevelsEntryList = {type: "entries", entries: data.entriesHigherLevel};
  22713.                         renderer.recursiveRender(higherLevelsEntryList, hLevelRenderStack, {depth: 2});
  22714.                         const higherLevels = d20plus.importer.getCleanText(hLevelRenderStack.join(" ").replace("At Higher Levels.", ""));
  22715.                         r20json.content += "\n\n\"At Higher Levels: " + higherLevels;
  22716.                         r20json.htmlcontent += "<br><br>\"At Higher Levels: " + higherLevels;
  22717.                         notecontents += hLevelRenderStack.join("");
  22718.                         r20Data["Higher Spell Slot Desc"] = higherLevels;
  22719.                 }
  22720.                 notecontents += `<p><strong>Classes:</strong> ${Parser.spClassesToFull(data.classes)}</p>`;
  22721.                 gmnotes = JSON.stringify(r20json);
  22722.                 notecontents += `<del class="hidden">${gmnotes}</del>`;
  22723.  
  22724.                 return [notecontents, gmnotes];
  22725.         };
  22726.  
  22727.         // parse spell components
  22728.         d20plus.spells._parseComponents = function (components) {
  22729.                 const out = [];
  22730.                 if (components && components.v) out.push("V");
  22731.                 if (components && components.s) out.push("S");
  22732.                 if (components && components.m) out.push("M");
  22733.                 return out.join(" ");
  22734.         };
  22735.  
  22736.         d20plus.spells.spLevelToSpellPoints = function (level) {
  22737.                 switch (level) {
  22738.                         case 1: return 2;
  22739.                         case 2: return 3;
  22740.                         case 3: return 5;
  22741.                         case 4: return 6;
  22742.                         case 5: return 7;
  22743.                         case 6: return 8;
  22744.                         case 7: return 10;
  22745.                         case 8: return 11;
  22746.                         case 9: return 13;
  22747.                         case 0:
  22748.                         default: return 0;
  22749.                 }
  22750.         };
  22751. }
  22752.  
  22753. SCRIPT_EXTENSIONS.push(d20plusSpells);
  22754.  
  22755.  
  22756. function d20plusBackgrounds () {
  22757.         d20plus.backgrounds = {};
  22758.  
  22759.         d20plus.backgrounds.button = function (forcePlayer) {
  22760.                 const playerMode = forcePlayer || !window.is_gm;
  22761.                 const url = playerMode ? $("#import-backgrounds-url-player").val() : $("#import-backgrounds-url").val();
  22762.                 if (url && url.trim()) {
  22763.                         const handoutBuilder = playerMode ? d20plus.backgrounds.playerImportBuilder : d20plus.backgrounds.handoutBuilder;
  22764.  
  22765.                         DataUtil.loadJSON(url).then((data) => {
  22766.                                 d20plus.importer.addMeta(data._meta);
  22767.                                 d20plus.importer.showImportList(
  22768.                                         "background",
  22769.                                         data.background,
  22770.                                         handoutBuilder,
  22771.                                         {
  22772.                                                 forcePlayer
  22773.                                         }
  22774.                                 );
  22775.                         });
  22776.                 }
  22777.         };
  22778.  
  22779.         d20plus.backgrounds.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  22780.                 // make dir
  22781.                 const folder = d20plus.journal.makeDirTree(`Backgrounds`, folderName);
  22782.                 const path = ["Backgrounds", ...folderName, data.name];
  22783.  
  22784.                 // handle duplicates/overwrites
  22785.                 if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  22786.  
  22787.                 const name = data.name;
  22788.                 d20.Campaign.handouts.create({
  22789.                         name: name,
  22790.                         tags: d20plus.importer.getTagString([
  22791.                                 Parser.sourceJsonToFull(data.source)
  22792.                         ], "background")
  22793.                 }, {
  22794.                         success: function (handout) {
  22795.                                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BACKGROUNDS](data)] = {name: data.name, source: data.source, type: "handout", roll20Id: handout.id};
  22796.  
  22797.                                 const [noteContents, gmNotes] = d20plus.backgrounds._getHandoutData(data);
  22798.  
  22799.                                 handout.updateBlobs({notes: noteContents, gmnotes: gmNotes});
  22800.                                 handout.save({notes: (new Date).getTime(), inplayerjournals: inJournals});
  22801.                                 d20.journal.addItemToFolderStructure(handout.id, folder.id);
  22802.                         }
  22803.                 });
  22804.         };
  22805.  
  22806.         d20plus.backgrounds.playerImportBuilder = function (data) {
  22807.                 const [notecontents, gmnotes] = d20plus.backgrounds._getHandoutData(data);
  22808.  
  22809.                 const importId = d20plus.ut.generateRowId();
  22810.                 d20plus.importer.storePlayerImport(importId, JSON.parse(gmnotes));
  22811.                 d20plus.importer.makePlayerDraggable(importId, data.name);
  22812.         };
  22813.  
  22814.         d20plus.backgrounds._getHandoutData = function (data) {
  22815.                 const renderer = new Renderer();
  22816.                 renderer.setBaseUrl(BASE_SITE_URL);
  22817.  
  22818.                 const renderStack = [];
  22819.  
  22820.                 renderer.recursiveRender({entries: data.entries}, renderStack, {depth: 1});
  22821.  
  22822.                 const rendered = renderStack.join("");
  22823.  
  22824.                 const r20json = {
  22825.                         "name": data.name,
  22826.                         "Vetoolscontent": data,
  22827.                         "data": {
  22828.                                 "Category": "Backgrounds"
  22829.                         }
  22830.                 };
  22831.                 const gmNotes = JSON.stringify(r20json);
  22832.                 const noteContents = `${rendered}\n\n<del class="hidden">${gmNotes}</del>`;
  22833.  
  22834.                 return [noteContents, gmNotes];
  22835.         };
  22836. }
  22837.  
  22838. SCRIPT_EXTENSIONS.push(d20plusBackgrounds);
  22839.  
  22840.  
  22841. function d20plusClass () {
  22842.         d20plus.classes = {};
  22843.         d20plus.subclasses = {};
  22844.  
  22845.         // Import Classes button was clicked
  22846.         d20plus.classes.button = function (forcePlayer) {
  22847.                 const playerMode = forcePlayer || !window.is_gm;
  22848.                 const url = playerMode ? $("#import-classes-url-player").val() : $("#import-classes-url").val();
  22849.                 if (url && url.trim()) {
  22850.                         const handoutBuilder = playerMode ? d20plus.classes.playerImportBuilder : d20plus.classes.handoutBuilder;
  22851.  
  22852.                         const officialClassUrls = Object.values(classDataUrls).map(v => d20plus.formSrcUrl(CLASS_DATA_DIR, v));
  22853.  
  22854.                         DataUtil.loadJSON(url).then((data) => {
  22855.                                 d20plus.importer.addMeta(data._meta);
  22856.                                 d20plus.importer.showImportList(
  22857.                                         "class",
  22858.                                         data.class,
  22859.                                         handoutBuilder,
  22860.                                         {
  22861.                                                 forcePlayer,
  22862.                                                 builderOptions: {
  22863.                                                         isHomebrew: !officialClassUrls.includes(url)
  22864.                                                 }
  22865.                                         }
  22866.                                 );
  22867.                         });
  22868.                 }
  22869.         };
  22870.  
  22871.         // Import All Classes button was clicked
  22872.         d20plus.classes.buttonAll = function (forcePlayer) {
  22873.                 const handoutBuilder = !forcePlayer && window.is_gm ? d20plus.classes.handoutBuilder : d20plus.classes.playerImportBuilder;
  22874.  
  22875.                 DataUtil.class.loadJSON(BASE_SITE_URL).then((data) => {
  22876.                         d20plus.importer.showImportList(
  22877.                                 "class",
  22878.                                 data.class,
  22879.                                 handoutBuilder,
  22880.                                 {
  22881.                                         forcePlayer,
  22882.                                         builderOptions: {
  22883.                                                 isHomebrew: false
  22884.                                         }
  22885.                                 }
  22886.                         );
  22887.                 });
  22888.         };
  22889.  
  22890.         d20plus.classes.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  22891.                 options = options || {};
  22892.  
  22893.                 // make dir
  22894.                 const folder = d20plus.journal.makeDirTree(`Classes`, folderName);
  22895.                 const path = ["Classes", ...folderName, data.name];
  22896.  
  22897.                 // handle duplicates/overwrites
  22898.                 if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  22899.  
  22900.                 const name = data.name;
  22901.                 d20.Campaign.handouts.create({
  22902.                         name: name,
  22903.                         tags: d20plus.importer.getTagString([
  22904.                                 Parser.sourceJsonToFull(data.source)
  22905.                         ], "class")
  22906.                 }, {
  22907.                         success: function (handout) {
  22908.                                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](data)] = {name: data.name, source: data.source, type: "handout", roll20Id: handout.id};
  22909.  
  22910.                                 const [noteContents, gmNotes] = d20plus.classes._getHandoutData(data);
  22911.  
  22912.                                 handout.updateBlobs({notes: noteContents, gmnotes: gmNotes});
  22913.                                 handout.save({notes: (new Date).getTime(), inplayerjournals: inJournals});
  22914.                                 d20.journal.addItemToFolderStructure(handout.id, folder.id);
  22915.                         }
  22916.                 });
  22917.  
  22918.                 d20plus.classes._handleSubclasses(data, overwrite, inJournals, folderName, false, options);
  22919.         };
  22920.  
  22921.         d20plus.classes._handleSubclasses = async function (data, overwrite, inJournals, outerFolderName, forcePlayer, options) {
  22922.                 async function chooseSubclassImportStrategy (isUnofficialBaseClass) {
  22923.                         return new Promise((resolve, reject) => {
  22924.                                 const $dialog = $(`
  22925.                                                 <div title="Subclass Import">
  22926.                                                         <label class="flex">
  22927.                                                                 <span>Import ${data.name} ${data.source ? `(${Parser.sourceJsonToAbv(data.source)}) ` : ""}subclasses?</span>
  22928.                                                                  <select title="Note: this does not include homebrew. For homebrew subclasses, use the dedicated subclass importer." style="width: 250px;">
  22929.                                                                         ${isUnofficialBaseClass ? "" : `<option value="1">Official/Published (excludes UA/etc)</option>`}
  22930.                                                                         <option value="2">All</option>
  22931.                                                                         <option value="3">None</option>
  22932.                                                                 </select>
  22933.                                                         </label>
  22934.                                                 </div>
  22935.                                         `).appendTo($("body"));
  22936.                                 const $selStrat = $dialog.find(`select`);
  22937.  
  22938.                                 $dialog.dialog({
  22939.                                         dialogClass: "no-close",
  22940.                                         buttons: [
  22941.                                                 {
  22942.                                                         text: "Cancel",
  22943.                                                         click: function () {
  22944.                                                                 $(this).dialog("close");
  22945.                                                                 $dialog.remove();
  22946.                                                                 reject(`User cancelled the prompt`);
  22947.                                                         }
  22948.                                                 },
  22949.                                                 {
  22950.                                                         text: "OK",
  22951.                                                         click: function () {
  22952.                                                                 const selected = Number($selStrat.val());
  22953.                                                                 $(this).dialog("close");
  22954.                                                                 $dialog.remove();
  22955.                                                                 if (isNaN(selected)) reject(`Value was not a number!`);
  22956.                                                                 resolve(selected);
  22957.                                                         }
  22958.                                                 }
  22959.                                         ]
  22960.                                 })
  22961.                         });
  22962.                 }
  22963.  
  22964.                 const playerMode = forcePlayer || !window.is_gm;
  22965.                 // import subclasses
  22966.                 if (data.subclasses) {
  22967.                         const importStrategy = await chooseSubclassImportStrategy(options.isHomebrew || (data.source && SourceUtil.isNonstandardSource(data.source)));
  22968.                         if (importStrategy === 3) return;
  22969.  
  22970.                         const gainFeatureArray = d20plus.classes._getGainAtLevelArr(data);
  22971.  
  22972.                         data.subclasses.forEach(sc => {
  22973.                                 if (importStrategy === 1 && SourceUtil.isNonstandardSource(sc.source)) return;
  22974.  
  22975.                                 sc.class = data.name;
  22976.                                 sc.classSource = sc.classSource || data.source;
  22977.                                 sc._gainAtLevels = gainFeatureArray;
  22978.                                 if (playerMode) {
  22979.                                         d20plus.subclasses.playerImportBuilder(sc, data);
  22980.                                 } else {
  22981.                                         const folderName = d20plus.importer._getHandoutPath("subclass", sc, "Class");
  22982.                                         const path = [folderName];
  22983.                                         if (outerFolderName) path.push(sc.source || data.source); // if it wasn't None, group by source
  22984.                                         d20plus.subclasses.handoutBuilder(sc, overwrite, inJournals, path, {}, {}, data);
  22985.                                 }
  22986.                         });
  22987.                 }
  22988.         };
  22989.  
  22990.         d20plus.classes._getGainAtLevelArr = function (clazz) {
  22991.                 const gainFeatureArray = [];
  22992.                 outer: for (let i = 0; i < 20; i++) {
  22993.                         const lvlFeatureList = clazz.classFeatures[i];
  22994.                         for (let j = 0; j < lvlFeatureList.length; j++) {
  22995.                                 const feature = lvlFeatureList[j];
  22996.                                 if (feature.gainSubclassFeature) {
  22997.                                         gainFeatureArray.push(true);
  22998.                                         continue outer;
  22999.                                 }
  23000.                         }
  23001.                         gainFeatureArray.push(false);
  23002.                 }
  23003.                 return gainFeatureArray;
  23004.         };
  23005.  
  23006.         d20plus.classes.playerImportBuilder = function (data, _1, _2, _3, _4, options) {
  23007.                 options = options || {};
  23008.  
  23009.                 const [notecontents, gmnotes] = d20plus.classes._getHandoutData(data);
  23010.  
  23011.                 const importId = d20plus.ut.generateRowId();
  23012.                 d20plus.importer.storePlayerImport(importId, JSON.parse(gmnotes));
  23013.                 d20plus.importer.makePlayerDraggable(importId, data.name);
  23014.  
  23015.                 d20plus.classes._handleSubclasses(data, false, false, null, true, options);
  23016.         };
  23017.  
  23018.         d20plus.classes._getHandoutData = function (data) {
  23019.                 const renderer = new Renderer();
  23020.                 renderer.setBaseUrl(BASE_SITE_URL);
  23021.  
  23022.                 const renderStack = [];
  23023.                 // make a copy of the data to modify
  23024.                 const curClass = JSON.parse(JSON.stringify(data));
  23025.                 // render the class text
  23026.                 for (let i = 0; i < 20; i++) {
  23027.                         const lvlFeatureList = curClass.classFeatures[i];
  23028.                         for (let j = 0; j < lvlFeatureList.length; j++) {
  23029.                                 const feature = lvlFeatureList[j];
  23030.                                 renderer.recursiveRender(feature, renderStack);
  23031.                         }
  23032.                 }
  23033.                 const rendered = renderStack.join("");
  23034.  
  23035.                 const r20json = {
  23036.                         "name": data.name,
  23037.                         "Vetoolscontent": data,
  23038.                         "data": {
  23039.                                 "Category": "Classes"
  23040.                         }
  23041.                 };
  23042.                 const gmNotes = JSON.stringify(r20json);
  23043.                 const noteContents = `${rendered}\n\n<del class="hidden">${gmNotes}</del>`;
  23044.  
  23045.                 return [noteContents, gmNotes];
  23046.         };
  23047.  
  23048.         ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  23049.  
  23050.         d20plus.subclasses._groupOptions = ["Class", "Alphabetical", "Source"];
  23051.         d20plus.subclasses._listCols = ["name", "class", "source"];
  23052.         d20plus.subclasses._listItemBuilder = (it) => `
  23053.                 <span class="name col-6">${it.name}</span>
  23054.                 <span class="class col-4">CLS[${it.class}]</span>
  23055.                 <span title="${Parser.sourceJsonToFull(it.source)}" class="source col-2">SRC[${Parser.sourceJsonToAbv(it.source)}]</span>`;
  23056.         d20plus.subclasses._listIndexConverter = (sc) => {
  23057.                 return {
  23058.                         name: sc.name.toLowerCase(),
  23059.                         class: sc.class.toLowerCase(),
  23060.                         source: Parser.sourceJsonToAbv(sc.source).toLowerCase()
  23061.                 };
  23062.         };
  23063.         // Import Subclasses button was clicked
  23064.         d20plus.subclasses.button = function (forcePlayer) {
  23065.                 const playerMode = forcePlayer || !window.is_gm;
  23066.                 const url = playerMode ? $("#import-subclasses-url-player").val() : $("#import-subclasses-url").val();
  23067.                 if (url && url.trim()) {
  23068.                         const handoutBuilder = playerMode ? d20plus.subclasses.playerImportBuilder : d20plus.subclasses.handoutBuilder;
  23069.  
  23070.                         DataUtil.loadJSON(url).then((data) => {
  23071.                                 d20plus.importer.addMeta(data._meta);
  23072.  
  23073.                                 // merge in any subclasses contained in class data
  23074.                                 const allData = MiscUtil.copy(data.subclass || []);
  23075.                                 (data.class || []).map(c => {
  23076.                                         if (c.subclasses) {
  23077.                                                 // make a copy without subclasses to prevent circular references
  23078.                                                 const cpy = MiscUtil.copy(c);
  23079.                                                 delete cpy.subclasses;
  23080.                                                 c.subclasses.forEach(sc => {
  23081.                                                         sc.class = c.name;
  23082.                                                         sc.source = sc.source || c.source;
  23083.                                                         sc._baseClass = cpy;
  23084.                                                 });
  23085.                                                 return c.subclasses;
  23086.                                         } else return false;
  23087.                                 }).filter(Boolean).forEach(sc => allData.push(sc));
  23088.  
  23089.                                 d20plus.importer.showImportList(
  23090.                                         "subclass",
  23091.                                         allData.flat(),
  23092.                                         handoutBuilder,
  23093.                                         {
  23094.                                                 groupOptions: d20plus.subclasses._groupOptions,
  23095.                                                 forcePlayer,
  23096.                                                 listItemBuilder: d20plus.subclasses._listItemBuilder,
  23097.                                                 listIndex: d20plus.subclasses._listCols,
  23098.                                                 listIndexConverter: d20plus.subclasses._listIndexConverter
  23099.                                         }
  23100.                                 );
  23101.                         });
  23102.                 }
  23103.         };
  23104.  
  23105.         /**
  23106.          * @param subclass
  23107.          * @param baseClass Will be defined if importing as part of a class, undefined otherwise.
  23108.          */
  23109.         d20plus.subclasses._preloadClass = function (subclass, baseClass) {
  23110.                 if (!subclass.class) Promise.resolve();
  23111.  
  23112.                 if (baseClass) {
  23113.                         subclass._gainAtLevels = d20plus.classes._getGainAtLevelArr(baseClass);
  23114.                         return Promise.resolve();
  23115.                 } else if(subclass._baseClass) {
  23116.                         subclass._gainAtLevels = d20plus.classes._getGainAtLevelArr(subclass._baseClass);
  23117.                         return Promise.resolve();
  23118.                 } else {
  23119.                         d20plus.ut.log("Preloading class...");
  23120.                         return DataUtil.class.loadJSON(BASE_SITE_URL).then((data) => {
  23121.                                 const clazz = data.class.find(it => it.name.toLowerCase() === subclass.class.toLowerCase() && it.source.toLowerCase() === (subclass.classSource || SRC_PHB).toLowerCase());
  23122.                                 if (!clazz) {
  23123.                                         throw new Error(`Could not find class for subclass ${subclass.name}::${subclass.source} with class ${subclass.class}::${subclass.classSource || SRC_PHB}`);
  23124.                                 }
  23125.  
  23126.                                 subclass._gainAtLevels = d20plus.classes._getGainAtLevelArr(clazz);
  23127.                         });
  23128.                 }
  23129.         };
  23130.  
  23131.         /**
  23132.          * @param data
  23133.          * @param overwrite
  23134.          * @param inJournals
  23135.          * @param folderName
  23136.          * @param saveIdsTo
  23137.          * @param options
  23138.          * @param baseClass Will be defined if importing as part of a class, undefined otherwise.
  23139.          */
  23140.         d20plus.subclasses.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options, baseClass) {
  23141.                 // make dir
  23142.                 const folder = d20plus.journal.makeDirTree(`Subclasses`, folderName);
  23143.                 const path = ["Sublasses", ...folderName, data.name];
  23144.  
  23145.                 // handle duplicates/overwrites
  23146.                 if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  23147.  
  23148.                 d20plus.subclasses._preloadClass(data, baseClass).then(() => {
  23149.                         const name = `${data.shortName} (${data.class})`;
  23150.                         d20.Campaign.handouts.create({
  23151.                                 name: name,
  23152.                                 tags: d20plus.importer.getTagString([
  23153.                                         data.class,
  23154.                                         Parser.sourceJsonToFull(data.source)
  23155.                                 ], "subclass")
  23156.                         }, {
  23157.                                 success: function (handout) {
  23158.                                         if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](data)] = {name: data.name, source: data.source, type: "handout", roll20Id: handout.id};
  23159.  
  23160.                                         const [noteContents, gmNotes] = d20plus.subclasses._getHandoutData(data);
  23161.  
  23162.                                         handout.updateBlobs({notes: noteContents, gmnotes: gmNotes});
  23163.                                         handout.save({notes: (new Date).getTime(), inplayerjournals: inJournals});
  23164.                                         d20.journal.addItemToFolderStructure(handout.id, folder.id);
  23165.                                 }
  23166.                         });
  23167.                 });
  23168.         };
  23169.  
  23170.         /**
  23171.          * @param data
  23172.          * @param baseClass Will be defined if importing as part of a class, undefined otherwise.
  23173.          */
  23174.         d20plus.subclasses.playerImportBuilder = function (data, baseClass) {
  23175.                 d20plus.subclasses._preloadClass(data, baseClass).then(() => {
  23176.                         const [notecontents, gmnotes] = d20plus.subclasses._getHandoutData(data);
  23177.  
  23178.                         const importId = d20plus.ut.generateRowId();
  23179.                         d20plus.importer.storePlayerImport(importId, JSON.parse(gmnotes));
  23180.                         const name = `${data.class ? `${data.class} \u2014 ` : ""}${data.name}`;
  23181.                         d20plus.importer.makePlayerDraggable(importId, name);
  23182.                 });
  23183.         };
  23184.  
  23185.         d20plus.subclasses._getHandoutData = function (data) {
  23186.                 const renderer = new Renderer();
  23187.                 renderer.setBaseUrl(BASE_SITE_URL);
  23188.  
  23189.                 const renderStack = [];
  23190.  
  23191.                 data.subclassFeatures.forEach(lvl => {
  23192.                         lvl.forEach(f => {
  23193.                                 renderer.recursiveRender(f, renderStack);
  23194.                         });
  23195.                 });
  23196.  
  23197.                 const rendered = renderStack.join("");
  23198.  
  23199.                 const r20json = {
  23200.                         "name": data.name,
  23201.                         "Vetoolscontent": data,
  23202.                         "data": {
  23203.                                 "Category": "Subclasses"
  23204.                         }
  23205.                 };
  23206.                 const gmNotes = JSON.stringify(r20json);
  23207.                 const noteContents = `${rendered}\n\n<del class="hidden">${gmNotes}</del>`;
  23208.  
  23209.                 return [noteContents, gmNotes];
  23210.         };
  23211.  
  23212. }
  23213.  
  23214. SCRIPT_EXTENSIONS.push(d20plusClass);
  23215.  
  23216.  
  23217. function d20plusItems () {
  23218.         d20plus.items = {};
  23219.  
  23220.         d20plus.items._groupOptions = ["Type", "Rarity", "Alphabetical", "Source"];
  23221.         d20plus.items._listCols = ["name", "type", "rarity", "source"];
  23222.         d20plus.items._listItemBuilder = (it) => {
  23223.                 if (!it._isEnhanced) Renderer.item.enhanceItem(it);
  23224.  
  23225.                 return `
  23226.                 <span class="name col-3" title="name">${it.name}</span>
  23227.                 <span class="type col-5" title="type">${it._typeListText.map(t => `TYP[${t.trim()}]`).join(", ")}</span>
  23228.                 <span class="rarity col-2" title="rarity">RAR[${it.rarity}]</span>
  23229.                 <span title="source [Full source name is ${Parser.sourceJsonToFull(it.source)}]" class="source col-2">SRC[${Parser.sourceJsonToAbv(it.source)}]</span>`;
  23230.         };
  23231.         d20plus.items._listIndexConverter = (it) => {
  23232.                 if (!it._isEnhanced) Renderer.item.enhanceItem(it);
  23233.                 return {
  23234.                         name: it.name.toLowerCase(),
  23235.                         type: it._typeListText.map(t => t.toLowerCase()),
  23236.                         rarity: it.rarity.toLowerCase(),
  23237.                         source: Parser.sourceJsonToAbv(it.source).toLowerCase()
  23238.                 };
  23239.         };
  23240.         // Import Items button was clicked
  23241.         d20plus.items.button = function (forcePlayer) {
  23242.                 const playerMode = forcePlayer || !window.is_gm;
  23243.                 const url = playerMode ? $("#import-items-url-player").val() : $("#import-items-url").val();
  23244.                 if (url && url.trim()) {
  23245.                         const handoutBuilder = playerMode ? d20plus.items.playerImportBuilder : d20plus.items.handoutBuilder;
  23246.  
  23247.                         if (url.trim() === `${DATA_URL}items.json`) {
  23248.                                 Renderer.item.pBuildList(
  23249.                                         {
  23250.                                                 fnCallback: itemList => {
  23251.                                                         const packNames = new Set([`burglar's pack`, `diplomat's pack`, `dungeoneer's pack`, `entertainer's pack`, `explorer's pack`, `priest's pack`, `scholar's pack`, `monster hunter's pack`]);
  23252.  
  23253.                                                         const packs = itemList.filter(it => packNames.has(it.name.toLowerCase()));
  23254.                                                         packs.forEach(p => {
  23255.                                                                 if (!p._r20SubItemData) {
  23256.                                                                         const contents = p.entries.find(it => it.type === "list").items;
  23257.  
  23258.                                                                         const out = [];
  23259.                                                                         contents.forEach(line => {
  23260.                                                                                 if (line.includes("@item")) {
  23261.                                                                                         const [pre, tag, item] = line.split(/({@item)/g);
  23262.                                                                                         const tagItem = `${tag}${item}`;
  23263.  
  23264.                                                                                         let [n, src] = item.split("}")[0].trim().split("|");
  23265.                                                                                         if (!src) src = "dmg";
  23266.  
  23267.                                                                                         n = n.toLowerCase();
  23268.                                                                                         src = src.toLowerCase();
  23269.  
  23270.  
  23271.                                                                                         const subItem = itemList.find(it => n === it.name.toLowerCase() && src === it.source.toLowerCase());
  23272.  
  23273.                                                                                         let count = 1;
  23274.                                                                                         pre.replace(/\d+/g, (m) => count = Number(m));
  23275.  
  23276.                                                                                         out.push({
  23277.                                                                                                 type: "item",
  23278.                                                                                                 count,
  23279.                                                                                                 data: subItem
  23280.                                                                                         })
  23281.                                                                                 } else {
  23282.                                                                                         out.push({
  23283.                                                                                                 type: "misc",
  23284.                                                                                                 data: {
  23285.                                                                                                         name: line.toTitleCase(),
  23286.                                                                                                         data: {
  23287.                                                                                                                 Category: "Items",
  23288.                                                                                                                 "Item Type": "Adventuring Gear"
  23289.                                                                                                         }
  23290.                                                                                                 }
  23291.                                                                                         })
  23292.                                                                                 }
  23293.                                                                         });
  23294.  
  23295.                                                                         p._r20SubItemData = out;
  23296.                                                                 }
  23297.                                                         });
  23298.  
  23299.                                                         d20plus.importer.showImportList(
  23300.                                                                 "item",
  23301.                                                                 itemList,
  23302.                                                                 handoutBuilder,
  23303.                                                                 {
  23304.                                                                         groupOptions: d20plus.items._groupOptions,
  23305.                                                                         forcePlayer,
  23306.                                                                         listItemBuilder: d20plus.items._listItemBuilder,
  23307.                                                                         listIndex: d20plus.items._listCols,
  23308.                                                                         listIndexConverter: d20plus.items._listIndexConverter
  23309.                                                                 }
  23310.                                                         );
  23311.                                                 },
  23312.                                                 urls: {
  23313.                                                         items: `${DATA_URL}items.json`,
  23314.                                                         baseitems: `${DATA_URL}items-base.json`,
  23315.                                                         magicvariants: `${DATA_URL}magicvariants.json`
  23316.                                                 },
  23317.                                                 isAddGroups: true,
  23318.                                         });
  23319.                         } else {
  23320.                                 // for non-standard URLs, do a generic import
  23321.                                 DataUtil.loadJSON(url).then((data) => {
  23322.                                         (data.itemProperty || []).forEach(p => Renderer.item._addProperty(p));
  23323.                                         (data.itemType || []).forEach(t => Renderer.item._addType(t));
  23324.                                         d20plus.importer.addMeta(data._meta);
  23325.                                         d20plus.importer.showImportList(
  23326.                                                 "item",
  23327.                                                 data.item,
  23328.                                                 handoutBuilder,
  23329.                                                 {
  23330.                                                         groupOptions: d20plus.items._groupOptions,
  23331.                                                         forcePlayer,
  23332.                                                         listItemBuilder: d20plus.items._listItemBuilder,
  23333.                                                         listIndex: d20plus.items._listCols,
  23334.                                                         listIndexConverter: d20plus.items._listIndexConverter
  23335.                                                 }
  23336.                                         );
  23337.                                 });
  23338.                         }
  23339.                 }
  23340.         };
  23341.  
  23342.         // Import individual items
  23343.         d20plus.items.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  23344.                 // make dir
  23345.                 const folder = d20plus.journal.makeDirTree(`Items`, folderName);
  23346.                 const path = ["Items", ...folderName, data.name];
  23347.  
  23348.                 // handle duplicates/overwrites
  23349.                 if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  23350.  
  23351.                 const name = data.name;
  23352.  
  23353.                 if (!data._isEnhanced) Renderer.item.enhanceItem(data); // for homebrew items
  23354.  
  23355.                 // build item handout
  23356.                 d20.Campaign.handouts.create({
  23357.                         name: name,
  23358.                         tags: d20plus.importer.getTagString([
  23359.                                 `rarity ${data.rarity}`,
  23360.                                 ...data._typeListText,
  23361.                                 Parser.sourceJsonToFull(data.source)
  23362.                         ], "item")
  23363.                 }, {
  23364.                         success: function (handout) {
  23365.                                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_ITEMS](data)] = {name: data.name, source: data.source, type: "handout", roll20Id: handout.id};
  23366.  
  23367.                                 const [notecontents, gmnotes] = d20plus.items._getHandoutData(data);
  23368.  
  23369.                                 handout.updateBlobs({notes: notecontents, gmnotes: gmnotes});
  23370.                                 handout.save({
  23371.                                         notes: (new Date).getTime(),
  23372.                                         inplayerjournals: inJournals
  23373.                                 });
  23374.                                 d20.journal.addItemToFolderStructure(handout.id, folder.id);
  23375.                         }
  23376.                 });
  23377.         };
  23378.  
  23379.         d20plus.items.playerImportBuilder = function (data) {
  23380.                 const [notecontents, gmnotes] = d20plus.items._getHandoutData(data);
  23381.  
  23382.                 const importId = d20plus.ut.generateRowId();
  23383.                 d20plus.importer.storePlayerImport(importId, JSON.parse(gmnotes));
  23384.                 d20plus.importer.makePlayerDraggable(importId, data.name);
  23385.         };
  23386.  
  23387.         d20plus.items._getHandoutData = function (data) {
  23388.                 function removeDiceTags (str) {
  23389.                         return str ? Renderer.stripTags(str) : str;
  23390.                 }
  23391.  
  23392.                 var notecontents = "";
  23393.                 const roll20Data = {
  23394.                         name: data.name,
  23395.                         data: {
  23396.                                 Category: "Items"
  23397.                         }
  23398.                 };
  23399.  
  23400.                 const [damage, damageType, propertiesTxt] = Renderer.item.getDamageAndPropertiesText(data);
  23401.                 const typeRarityAttunement = Renderer.item.getTypeRarityAndAttunementText(data);
  23402.  
  23403.                 var type = data.type;
  23404.                 if (data.type) {
  23405.                         roll20Data.data["Item Type"] = d20plus.items.parseType(data.type);
  23406.                 } else if (data._typeListText) {
  23407.                         roll20Data.data["Item Type"] = data._typeListText.join(", ");
  23408.                 }
  23409.  
  23410.                 const cleanDmg1 = removeDiceTags(data.dmg1);
  23411.                 const cleanDmg2 = removeDiceTags(data.dmg2);
  23412.  
  23413.                 var armorclass = "";
  23414.                 if (type === "S") armorclass = "+" + data.ac;
  23415.                 if (type === "LA") armorclass = data.ac + " + Dex";
  23416.                 if (type === "MA") armorclass = data.ac + " + Dex (max 2)";
  23417.                 if (type === "HA") armorclass = data.ac;
  23418.                 var properties = "";
  23419.                 if (data.property) {
  23420.                         var propertieslist = data.property;
  23421.                         for (var i = 0; i < propertieslist.length; i++) {
  23422.                                 var a = d20plus.items.parseProperty(propertieslist[i]);
  23423.                                 var b = propertieslist[i];
  23424.                                 if (b === "V") {
  23425.                                         a = a + " (" + cleanDmg2 + ")";
  23426.                                         roll20Data.data._versatile = cleanDmg2;
  23427.                                 }
  23428.                                 if (b === "T" || b === "A") a = a + " (" + data.range + "ft.)";
  23429.                                 if (b === "RLD") a = a + " (" + data.reload + " shots)";
  23430.                                 if (i > 0) a = ", " + a;
  23431.                                 properties += a;
  23432.                         }
  23433.                 }
  23434.                 notecontents += `<p><h3>${data.name}</h3></p>
  23435.                 <p><em>${typeRarityAttunement}</em></p>
  23436.                 <p><strong>Value/Weight:</strong> ${[Parser.itemValueToFull(data), Parser.itemWeightToFull(data)].filter(Boolean).join(", ")}</p>
  23437.                 <p><strong>Details: </strong>${[damage, damageType, propertiesTxt].filter(Boolean).join(" ")}</p>
  23438.                 `;
  23439.  
  23440.                 if (propertiesTxt) roll20Data.data.Properties = properties;
  23441.                 if (armorclass) roll20Data.data.AC = String(data.ac);
  23442.                 if (data.weight) roll20Data.data.Weight = String(data.weight);
  23443.  
  23444.                 const textString = Renderer.item.getRenderedEntries(data);
  23445.                 if (textString) {
  23446.                         notecontents += `<hr>`;
  23447.                         notecontents += textString;
  23448.  
  23449.                         roll20Data.content = d20plus.importer.getCleanText(textString);
  23450.                         roll20Data.htmlcontent = roll20Data.content;
  23451.                 }
  23452.  
  23453.                 if (data.range) {
  23454.                         roll20Data.data.Range = data.range;
  23455.                 }
  23456.                 if (data.dmg1 && data.dmgType) {
  23457.                         roll20Data.data.Damage = cleanDmg1;
  23458.                         roll20Data.data["Damage Type"] = Parser.dmgTypeToFull(data.dmgType);
  23459.                 }
  23460.                 if (data.stealth) {
  23461.                         roll20Data.data.Stealth = "Disadvantage";
  23462.                 }
  23463.                 // roll20Data.data.Duration = "1 Minute"; // used by e.g. poison; not show in sheet
  23464.                 // roll20Data.data.Save = "Constitution"; // used by e.g. poison, ball bearings; not shown in sheet
  23465.                 // roll20Data.data.Target = "Each creature in a 10-foot square centered on a point within range"; // used by e.g. ball bearings; not shown in sheet
  23466.                 // roll20Data.data["Item Rarity"] = "Wondrous"; // used by Iron Bands of Binding... and nothing else?; not shown in sheet
  23467.                 if (data.reqAttune === true) {
  23468.                         roll20Data.data["Requires Attunement"] = "Yes";
  23469.                 } else {
  23470.                         roll20Data.data["Requires Attunement"] = "No";
  23471.                 }
  23472.  
  23473.                 // load modifiers (e.g. "+1 Armor"); this is a comma-separated string
  23474.                 const itemMeta = (itemMetadata.item || []).find(it => it.name === data.name && it.source === data.source);
  23475.                 if (itemMeta) roll20Data.data.Modifiers = itemMeta.Modifiers;
  23476.  
  23477.                 if (data._r20SubItemData) {
  23478.                         roll20Data._subItems = data._r20SubItemData.map(subItem => {
  23479.                                 if (subItem.type === "item") {
  23480.                                         const [subNote, subGm] = d20plus.items._getHandoutData(subItem.data);
  23481.                                         return {subItem: subGm, count: subItem.count};
  23482.                                 } else {
  23483.                                         return {subItem: subItem.data};
  23484.                                 }
  23485.                         });
  23486.                 }
  23487.  
  23488.                 const gmnotes = JSON.stringify(roll20Data);
  23489.  
  23490.                 return [notecontents, gmnotes];
  23491.         };
  23492.  
  23493.         d20plus.items.parseType = function (type) {
  23494.                 const result = Parser.itemTypeToFull(type);
  23495.                 return result ? result : "n/a";
  23496.         };
  23497.  
  23498.         d20plus.items.parseDamageType = function (damagetype) {
  23499.                 const result = Parser.dmgTypeToFull(damagetype);
  23500.                 return result ? result : false;
  23501.         };
  23502.  
  23503.         d20plus.items.parseProperty = function (property) {
  23504.                 if (Renderer.item.propertyMap[property]) return Renderer.item.propertyMap[property].name;
  23505.                 return "n/a";
  23506.         };
  23507. }
  23508.  
  23509. SCRIPT_EXTENSIONS.push(d20plusItems);
  23510.  
  23511.  
  23512. function d20plusFeats () {
  23513.     d20plus.feats = {};
  23514.  
  23515.     // Import Feats button was clicked
  23516.     d20plus.feats.button = function (forcePlayer) {
  23517.         const playerMode = forcePlayer || !window.is_gm;
  23518.         const url = playerMode ? $("#import-feats-url-player").val() : $("#import-feats-url").val();
  23519.         if (url && url.trim()) {
  23520.             const handoutBuilder = playerMode ? d20plus.feats.playerImportBuilder : d20plus.feats.handoutBuilder;
  23521.  
  23522.             DataUtil.loadJSON(url).then((data) => {
  23523.                 d20plus.importer.addMeta(data._meta);
  23524.                 d20plus.importer.showImportList(
  23525.                     "feat",
  23526.                     data.feat,
  23527.                     handoutBuilder,
  23528.                     {
  23529.                         forcePlayer
  23530.                     }
  23531.                 );
  23532.             });
  23533.         }
  23534.     };
  23535.  
  23536.     d20plus.feats.handoutBuilder = function (data, overwrite, inJournals, folderName, saveIdsTo, options) {
  23537.         // make dir
  23538.         const folder = d20plus.journal.makeDirTree(`Feats`, folderName);
  23539.         const path = ["Feats", ...folderName, data.name];
  23540.  
  23541.         // handle duplicates/overwrites
  23542.         if (!d20plus.importer._checkHandleDuplicate(path, overwrite)) return;
  23543.  
  23544.         const name = data.name;
  23545.         d20.Campaign.handouts.create({
  23546.             name: name,
  23547.             tags: d20plus.importer.getTagString([
  23548.                 Parser.sourceJsonToFull(data.source)
  23549.             ], "feat")
  23550.         }, {
  23551.             success: function (handout) {
  23552.                 if (saveIdsTo) saveIdsTo[UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_FEATS](data)] = {name: data.name, source: data.source, type: "handout", roll20Id: handout.id};
  23553.  
  23554.                 const [noteContents, gmNotes] = d20plus.feats._getHandoutData(data);
  23555.  
  23556.                 handout.updateBlobs({notes: noteContents, gmnotes: gmNotes});
  23557.                 handout.save({notes: (new Date).getTime(), inplayerjournals: inJournals});
  23558.                 d20.journal.addItemToFolderStructure(handout.id, folder.id);
  23559.             }
  23560.         });
  23561.     };
  23562.  
  23563.     d20plus.feats.playerImportBuilder = function (data) {
  23564.         const [notecontents, gmnotes] = d20plus.feats._getHandoutData(data);
  23565.  
  23566.         const importId = d20plus.ut.generateRowId();
  23567.         d20plus.importer.storePlayerImport(importId, JSON.parse(gmnotes));
  23568.         d20plus.importer.makePlayerDraggable(importId, data.name);
  23569.     };
  23570.  
  23571.     d20plus.feats._getHandoutData = function (data) {
  23572.         const renderer = new Renderer();
  23573.         renderer.setBaseUrl(BASE_SITE_URL);
  23574.         const prerequisite = Renderer.utils.getPrerequisiteText(data.prerequisite);
  23575.         Renderer.feat.mergeAbilityIncrease(data);
  23576.  
  23577.         const renderStack = [];
  23578.         renderer.recursiveRender({entries: data.entries}, renderStack, {depth: 2});
  23579.         const rendered = renderStack.join("");
  23580.  
  23581.         const r20json = {
  23582.             "name": data.name,
  23583.             "content": `${prerequisite ? `**Prerequisite**: ${prerequisite}\n\n` : ""}${$(rendered).text()}`,
  23584.             "Vetoolscontent": d20plus.importer.getCleanText(rendered),
  23585.             "htmlcontent": "",
  23586.             "data": {
  23587.                 "Category": "Feats"
  23588.             }
  23589.         };
  23590.         const gmNotes = JSON.stringify(r20json);
  23591.  
  23592.         const baseNoteContents = `${prerequisite ? `<p><i>Prerequisite: ${prerequisite}.</i></p> ` : ""}${rendered}`;
  23593.         const noteContents = `${baseNoteContents}<del class="hidden">${gmNotes}</del>`;
  23594.  
  23595.         return [noteContents, gmNotes];
  23596.     };
  23597. }
  23598.  
  23599. SCRIPT_EXTENSIONS.push(d20plusFeats);
  23600.  
  23601.  
  23602. unsafeWindow.d20plus = {};
  23603.  
  23604. const betteR20Base = function () {
  23605.         CONSOLE_LOG = console.log;
  23606.         console.log = (...args) => {
  23607.                 if (args.length === 1 && typeof args[0] === "string" && args[0].startsWith("Switch mode to ")) {
  23608.                         const mode = args[0].replace("Switch mode to ", "");
  23609.                         if (typeof d20plus !== "undefined" && d20plus.setMode) d20plus.setMode(mode);
  23610.                 }
  23611.                 CONSOLE_LOG(...args);
  23612.         };
  23613.  
  23614.  
  23615.         addConfigOptions("token", {
  23616.                         "_name": "Tokens",
  23617.                         "enhanceStatus": {
  23618.                                 "name": "Use Custom Status Icons",
  23619.                                 "default": true,
  23620.                                 "_type": "boolean"
  23621.                         },
  23622.                         "statusSheetUrl": {
  23623.                                 "name": `Custom Status Spritesheet Url (<a style="color: blue" href="https://app.roll20.net/images/statussheet.png" target="_blank">Original</a>)`,
  23624.                                 "default": "https://raw.githubusercontent.com/TheGiddyLimit/5etoolsR20/master/img/statussheet.png",
  23625.                                 "_type": "String"
  23626.                         },
  23627.                         "statusSheetSmallUrl": {
  23628.                                 "name": `Custom Status Spritesheet (Small) Url (<a style="color: blue" href="https://app.roll20.net/images/statussheet_small.png" target="_blank">Original</a>)`,
  23629.                                 "default": "https://raw.githubusercontent.com/TheGiddyLimit/5etoolsR20/master/img/statussheet_small.png",
  23630.                                 "_type": "String"
  23631.                         },
  23632.                         "massRollWhisperName": {
  23633.                                 "name": "Whisper Token Name to Mass-Rolls",
  23634.                                 "default": false,
  23635.                                 "_type": "boolean"
  23636.                         }
  23637.                 }
  23638.         );
  23639.         addConfigOptions("canvas", {
  23640.                         "_name": "Canvas",
  23641.                         "_player": true,
  23642.                         "gridSnap": {
  23643.                                 "name": "Grid Snap",
  23644.                                 "default": "1",
  23645.                                 "_type": "_enum",
  23646.                                 "__values": ["0.25", "0.5", "1"],
  23647.                                 "_player": true
  23648.                         },
  23649.                         "scaleNamesStatuses": {
  23650.                                 "name": "Scaled Names and Status Icons",
  23651.                                 "default": true,
  23652.                                 "_type": "boolean",
  23653.                                 "_player": true
  23654.                         }
  23655.                 }
  23656.         );
  23657.         addConfigOptions("import", {
  23658.                 "_name": "Import",
  23659.                 "importIntervalMap": {
  23660.                         "name": "Rest Time between Each Map (msec)",
  23661.                         "default": 2500,
  23662.                         "_type": "integer"
  23663.                 },
  23664.         });
  23665.         addConfigOptions("interface", {
  23666.                 "_name": "Interface",
  23667.                 "toolbarOpacity": {
  23668.                         "name": "Horizontal Toolbar Opacity",
  23669.                         "default": 100,
  23670.                         "_type": "_slider",
  23671.                         "__sliderMin": 1,
  23672.                         "__sliderMax": 100,
  23673.                         "__sliderStep": 1
  23674.                 },
  23675.                 "quickLayerButtons": {
  23676.                         "name": "Add Quick Layer Buttons",
  23677.                         "default": true,
  23678.                         "_type": "boolean"
  23679.                 },
  23680.                 "quickInitButtons": {
  23681.                         "name": "Add Quick Initiative Sort Button",
  23682.                         "default": true,
  23683.                         "_type": "boolean"
  23684.                 },
  23685.                 "streamerChatTag": {
  23686.                         "name": "Streamer-Friendly Chat Tags",
  23687.                         "default": false,
  23688.                         "_type": "boolean"
  23689.                 },
  23690.                 "hideDefaultJournalSearch": {
  23691.                         "name": "Hide Default Journal Search Bar",
  23692.                         "default": false,
  23693.                         "_type": "boolean"
  23694.                 },
  23695.         });
  23696. };
  23697.  
  23698. const D20plus = function (version) {
  23699.         d20plus.version = version;
  23700.  
  23701.         // Window loaded
  23702.         function doBootstrap () {
  23703.         d20plus.ut.log("Waiting for enhancement suite...");
  23704.  
  23705.         let timeWaitedForEnhancementSuiteMs = 0;
  23706.  
  23707.                 (function waitForEnhancementSuite () {
  23708.  
  23709.                         let hasRunInit = false;
  23710.                         if (window.enhancementSuiteEnabled) {
  23711.                                 d20plus.ut.log("Bootstrapping...");
  23712.  
  23713.                                 // r20es will expose the d20 variable if we wait
  23714.                                 // this should always trigger after window.onload has fired, but track init state just in case
  23715.                                 (function waitForD20 () {
  23716.                                         if (typeof window.d20 !== "undefined" && !$("#loading-overlay").is(":visible") && !hasRunInit) {
  23717.                                                 hasRunInit = true;
  23718.                                                 d20plus.Init();
  23719.                                         } else {
  23720.                                                 setTimeout(waitForD20, 50);
  23721.                                         }
  23722.                                 })();
  23723.  
  23724.                                 window.d20plus = d20plus;
  23725.                                 d20plus.ut.log("Injected");
  23726.                         } else {
  23727.                                 if(timeWaitedForEnhancementSuiteMs > 2 * 1000) {
  23728.                                         alert("betteR20 requires the VTTES (R20ES) extension to be installed!\nPlease install it from https://ssstormy.github.io/roll20-enhancement-suite/\nClicking ok will take you there.");
  23729.                                         window.open("https://ssstormy.github.io/roll20-enhancement-suite/", "_blank");
  23730.                                 } else {
  23731.                                         timeWaitedForEnhancementSuiteMs += 100;
  23732.                                         setTimeout(waitForEnhancementSuite, 100);
  23733.                                 }
  23734.                         }
  23735.                 })();
  23736.         }
  23737.  
  23738.         (function doCheckDepsLoaded () {
  23739.                 if (typeof $ !== "undefined") {
  23740.                         doBootstrap();
  23741.                 } else {
  23742.                         setTimeout(doCheckDepsLoaded, 50);
  23743.                 }
  23744.         })();
  23745. };
  23746.  
  23747. // if we are the topmost frame, inject
  23748. if (window.top === window.self) {
  23749.         function strip (str) {
  23750.                 return str.replace(/use strict/, "").substring(str.indexOf("\n") + 1, str.lastIndexOf("\n")) + "\n";
  23751.         }
  23752.  
  23753.         let stack = "function (version) {\n";
  23754.         stack += strip(betteR20Base.toString());
  23755.  
  23756.         for (let i = 0; i < SCRIPT_EXTENSIONS.length; ++i) {
  23757.                 stack += strip(SCRIPT_EXTENSIONS[i].toString())
  23758.         }
  23759.         stack += strip(D20plus.toString());
  23760.  
  23761.         stack += "\n}";
  23762.         unsafeWindow.eval("(" + stack + ")('" + GM_info.script.version + "')");
  23763. }
  23764.