diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..eca740c543cbcfbf5729fa9fbd766671d7152e05 --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,299 @@ +/* This file contains only JS functions without dependencies on FC specific variables/conventions and do not rely on + * custom functions outside this file + */ + +/** + * Returns whether x is undefined. Port of SugarCube's def. + * @param {any} x + * @returns {boolean} + */ +window.jsDef = function(x) { + return (typeof x !== "undefined" && x !== null && x !== undefined); +}; + +/** + * @param {number} n + * @returns {boolean} + */ +window.isFloat = function(n) { + return Number.isFinite(n) && Math.floor(n) !== n; +}; + +/** + * Determines if a is between low and high + * @param {number} a + * @param {number} low + * @param {number} high + * @returns {boolean} + */ +window.between = function(a, low, high) { + if (low === null) { low = -Infinity; } + if (high === null) { high = Infinity; } + return (a > low && a < high); +}; + +/** + * @param {number[]} obj + * @returns {number} + */ +window.hashChoice = function hashChoice(obj) { + let randint = Math.floor(Math.random() * hashSum(obj)); + let ret; + Object.keys(obj).some((key) => { + if (randint < obj[key]) { + ret = key; + return true; + } else { + randint -= obj[key]; + return false; + } + }); + return ret; +}; + +/** + * @param {number[]} obj + * @returns {number} + */ +window.hashSum = function hashSum(obj) { + let sum = 0; + Object.keys(obj).forEach((key) => { + sum += obj[key]; + }); + return sum; +}; + +/** + * @param {Array} arr + * @returns {Object} + */ +window.arr2obj = function arr2obj(arr) { + const obj = {}; + arr.forEach((item) => { + obj[item] = 1; + }); + return obj; +}; + +/** + * @param {{}} object + * @param rest + */ +window.hashPush = function hashPush(object, ...rest) { + rest.forEach((item) => { + if (object[item] === undefined) { + object[item] = 1; + } else { + object[item] += 1; + } + }); +}; + +/** + * @param {[]} array + * @return {{}} + */ +window.weightedArray2HashMap = function weightedArray2HashMap(array) { + const obj = {}; + array.forEach((item) => { + if (obj[item] === undefined) { + obj[item] = 1; + } else { + obj[item] += 1; + } + }); + return obj; +}; + +/** + * generate a random, almost unique ID that is compliant (possibly) with RFC 4122 + * + * @return {string} + */ +window.generateNewID = function generateNewID() { + let date = Date.now(); // high-precision timer + let uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { + let r = (date + Math.random() * 16) % 16 | 0; + date = Math.floor(date / 16); + return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16); + }); + return uuid; +}; + +/** + * @param {Array} array + * @param {number} indexA + * @param {number} indexB + */ +window.arraySwap = function arraySwap(array, indexA, indexB) { + const tmp = array[indexA]; + array[indexA] = array[indexB]; + array[indexB] = tmp; +}; + +/** + * @param {string} string + * @returns {string} + */ +window.capFirstChar = function capFirstChar(string) { + return string.charAt(0).toUpperCase() + string.substr(1); +}; + +/** + * @param {string} word + * @returns {string} + */ +window.addA = function(word) { + let vocal = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U']; + if (vocal.includes(word.charAt(0))) { + return `an ${word}`; + } + return `a ${word}`; +}; + +/** + * @param {number} i + * @returns {string} + */ +window.ordinalSuffix = function ordinalSuffix(i) { + let j = i % 10; + let k = i % 100; + if (j === 1 && k !== 11) { + return `${i}st`; + } + if (j === 2 && k !== 12) { + return `${i}nd`; + } + if (j === 3 && k !== 13) { + return `${i}rd`; + } + return `${i}th`; +}; + +/** + * @param {number} i + * @returns {string} + */ +window.ordinalSuffixWords = function(i) { + const text = ["zeroth", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth", "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth", "nineteenth"]; + if (i < text.length) { + return text[i]; + } + return ordinalSuffix(i); +}; + +/** + * @param {Iterable<any>} array + * @returns {any[]} + */ +window.removeDuplicates = function removeDuplicates(array) { + return [...new Set(array)]; +}; + +/** + * Maps an index from one list onto a matching index on the other. + * The first and last indexes will be matched to the first and last indexes of the other list, + * while indexes in between will go to the nearest index. + * @param {number} index The index in original list to map to new list. + * @param {*} originalList The original list the index refers into. + * @param {*} newList The new list which we want an index for + * @returns {number} The new index into newList + */ +App.Utils.mapIndexBetweenLists = function(index, originalList, newList) { + if (index === 0) { return 0; } + if (index === originalList.length - 1) { return newList.length - 1; } + index--; + const originalLimitedLength = originalList.length - 2; + const newLimitedLength = newList.length - 2; + return Math.round((index / originalLimitedLength) * newLimitedLength) + 1; +}; + +/** + * replaces special HTML characters with their '&xxx' forms + * @param {string} text + * @returns {string} + */ +App.Utils.escapeHtml = function(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); +}; + +/** + * Creates an object where the items are accessible via their ids. + * + * @param {Iterable} list + * @return {{}} + */ +window.mapIdList = function(list) { + let mappedList = {}; + for (const item of list) { + mappedList[item.id] = item; + } + return mappedList; +}; + +/** + * Topological sorting algorithm + * https://gist.github.com/shinout/1232505 + * Added keys parameter since it better suits our needs and updated to project code style. + * + * @param {[]} keys + * @param {[[]]} edges: list of edges. each edge forms Array<ID,ID> e.g. [12 , 3] + * @returns Array: topological sorted list of IDs + * @throws Error: in case there is a closed chain. + **/ +App.Utils.topologicalSort = function(keys, edges) { + let nodes = {}; // hash: stringified id of the node => { id: id, afters: list of ids } + let sorted = []; // sorted list of IDs ( returned value ) + let visited = {}; // hash: id of already visited node => true + + const Node = function(id) { + this.id = id; + this.afters = []; + }; + + // 1. build data structures + keys.forEach(key => { + nodes[key] = new Node(key); + }); + + edges.forEach(edge => { + const from = edge[0], to = edge[1]; + if (!nodes[from]) { nodes[from] = new Node(from); } + if (!nodes[to]) { nodes[to] = new Node(to); } + nodes[from].afters.push(to); + }); + + // 2. topological sort + Object.keys(nodes).forEach(function visit(idstr, ancestors) { + let node = nodes[idstr]; + let id = node.id; + + // if already exists, do nothing + if (visited[idstr]) { return; } + + if (!Array.isArray(ancestors)) { ancestors = []; } + + ancestors.push(id); + + visited[idstr] = true; + + node.afters.forEach((afterID) => { + if (ancestors.indexOf(afterID) >= 0) { // if already in ancestors, a closed chain exists. + throw new Error('closed chain : ' + afterID + ' is in ' + id); + } + + visit(afterID.toString(), ancestors.map(v => v)); // recursive call + }); + + sorted.unshift(id); + }); + + return sorted; +}; diff --git a/src/js/utilJS.js b/src/js/utilsFC.js similarity index 88% rename from src/js/utilJS.js rename to src/js/utilsFC.js index 21bf38bdb6474c8c1edada40f8f0f19a5f90a604..779c29ea515ba0353508e31633d4e05d4b6d8665 100644 --- a/src/js/utilJS.js +++ b/src/js/utilsFC.js @@ -1,3 +1,4 @@ +/* contains functions that rely on FC specific variables/conventions */ /* eslint-disable no-unused-vars */ /* * Height.mean(nationality, race, genes, age) - returns the mean height for the given combination and age in years (>=2) @@ -1187,9 +1188,6 @@ window.years = function(weeks) { return r; }; window.asDate = function(weeks, bonusDay = 0) { - if (weeks == null) { - weeks = State.variables.week; - } let d = new Date(2037, 0, 12); d.setDate(d.getDate() + weeks * 7 + bonusDay); return d; @@ -1197,14 +1195,6 @@ window.asDate = function(weeks, bonusDay = 0) { window.asDateString = function(weeks, bonusDay = 0) { return asDate(weeks, bonusDay).toLocaleString(undefined, {year: 'numeric', month: 'long', day: 'numeric'}); }; -/** - * Returns whether x is undefined - * @param {any} x - * @returns {boolean} - */ -window.jsDef = function(x) { - return (typeof x !== "undefined" && x !== null && x !== undefined); -}; /** * @param {number} s @@ -1341,14 +1331,6 @@ window.budgetLine = function(category, title) { } }; -/** - * @param {number} n - * @returns {boolean} - */ -window.isFloat = function(n) { - return Number.isFinite(n) && Math.floor(n) !== n; -}; - /* Make everything waiting for this execute. Usage: @@ -1363,150 +1345,6 @@ if(typeof Categorizer === 'function') { */ jQuery(document).trigger("categorizer.ready"); -/** - * @param {number[]} obj - * @returns {number} - */ -window.hashChoice = function hashChoice(obj) { - let randint = Math.floor(Math.random() * hashSum(obj)); - let ret; - Object.keys(obj).some((key) => { - if (randint < obj[key]) { - ret = key; - return true; - } else { - randint -= obj[key]; - return false; - } - }); - return ret; -}; - -/** - * @param {number[]} obj - * @returns {number} - */ -window.hashSum = function hashSum(obj) { - let sum = 0; - Object.keys(obj).forEach((key) => { - sum += obj[key]; - }); - return sum; -}; - -/** - * @param {Array} arr - * @returns {Object} - */ -window.arr2obj = function arr2obj(arr) { - const obj = {}; - arr.forEach((item) => { - obj[item] = 1; - }); - return obj; -}; - -window.hashPush = function hashPush(obj, ...rest) { - rest.forEach((item) => { - if (obj[item] === undefined) { - obj[item] = 1; - } else { - obj[item] += 1; - } - }); -}; - -window.weightedArray2HashMap = function weightedArray2HashMap(arr) { - const obj = {}; - arr.forEach((item) => { - if (obj[item] === undefined) { - obj[item] = 1; - } else { - obj[item] += 1; - } - }); - return obj; -}; - -/** - * Determines if a is between low and high - * @param {number} a - * @param {number} low - * @param {number} high - * @returns {boolean} - */ -window.between = function between(a, low, high) { - if (low === null) { low = -Infinity; } - if (high === null) { high = Infinity; } - return (a > low && a < high); -}; - -// generate a random, almost unique ID that is compliant (possibly) with RFC 4122 -window.generateNewID = function generateNewID() { - let date = Date.now(); // high-precision timer - let uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { - let r = (date + Math.random() * 16) % 16 | 0; - date = Math.floor(date / 16); - return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16); - }); - return uuid; -}; - -/** - * @param {Array} array - * @param {number} a - * @param {number} b - */ -window.arraySwap = function arraySwap(array, a, b) { - const tmp = array[a]; - array[a] = array[b]; - array[b] = tmp; -}; - -// circumvents sugarcube, allowing a plain HTML5 UI within it -window.html5passage = function html5passage(passageFunction) { - $(document).one(":passagedisplay", (ev) => { - const element = document.createElement("div"); - element.classList.add("passage"); - document.getElementById("passages").appendChild(element); - passageFunction(element); - $(document).off(":passagedisplay"); - }); -}; - -/** - * If you want to include a SugarCube passage in a JS function use this. The result must be printed using the <<print>> macro. - * @param {string} passageTitle - * @returns {string} - */ -window.jsInclude = function(passageTitle) { - if (Story.has(passageTitle)) { - return Story.get(passageTitle).processText(); - } else { - return `<span class="red">Error: Passage ${passageTitle} does not exist.</span>`; - } -}; - -/** - * @param {string} string - * @returns {string} - */ -window.capFirstChar = function capFirstChar(string) { - return string.charAt(0).toUpperCase() + string.substr(1); -}; - -/** - * @param {string} word - * @returns {string} - */ -window.addA = function(word) { - let vocal = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U']; - if (vocal.includes(word.charAt(0))) { - return `an ${word}`; - } - return `a ${word}`; -}; - /** * @param {App.Entity.SlaveState} slave * @returns {string} @@ -1688,45 +1526,6 @@ window.lengthToEitherUnit = function(s) { return `${s}cm`; }; -/** - * @param {number} i - * @returns {string} - */ -window.ordinalSuffix = function ordinalSuffix(i) { - let j = i % 10; - let k = i % 100; - if (j === 1 && k !== 11) { - return `${i}st`; - } - if (j === 2 && k !== 12) { - return `${i}nd`; - } - if (j === 3 && k !== 13) { - return `${i}rd`; - } - return `${i}th`; -}; - -/** - * @param {number} i - * @returns {string} - */ -window.ordinalSuffixWords = function(i) { - const text = ["zeroth", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth", "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth", "nineteenth"]; - if (i < text.length) { - return text[i]; - } - return ordinalSuffix(i); -}; - -/** - * @param {Iterable<any>} array - * @returns {any[]} - */ -window.removeDuplicates = function removeDuplicates(array) { - return [...new Set(array)]; -}; - /** * @param {App.Entity.SlaveState} slave * @returns {string} @@ -2331,220 +2130,6 @@ window.convertCareer = function(slave) { return job; }; -App.UI.tabbar = function() { - return { - openTab: openTab, - tabButton: tabButton, - makeTab: makeTab, - handlePreSelectedTab: handlePreSelectedTab, - tabChoiceVarName: tabChoiceVarName - }; - - function openTab(evt, tabName) { - /* var passage = passage().trim().replace(/ /g,"+");*/ - const tabcontent = document.getElementsByClassName("tabcontent"); - for (let i = 0; i < tabcontent.length; i++) { - tabcontent[i].style.display = "none"; - } - const tablinks = document.getElementsByClassName("tablinks"); - for (let i = 0; i < tablinks.length; i++) { - tablinks[i].className = tablinks[i].className.replace(" active", ""); - } - V.tabChoice[tabChoiceVarName()] = tabName; /* The regex strips spaces and " ' " from passage names, making "Servants' Quarters" into "ServantsQuarters" and allowing it to be used as a label in this object. */ - document.getElementById(tabName).style.display = "block"; - evt.currentTarget.className += " active"; - } - - /** - * @param {string} name - * @param {string} text - * @returns {string} - */ - function tabButton(name, text) { - return `<button class="tablinks" onclick="App.UI.tabbar.openTab(event, '${name}')" id="tab ${name}">${text}</button>`; - } - - /** - * @param {string} name - * @param {string} content - * @returns {string} - */ - function makeTab(name, content) { - return `<div id="${name}" class="tabcontent"><div class="content">${content}</div></div>`; - } - - function handlePreSelectedTab(defaultTab = "assign", immidiate = false) { - let selectedTab = State.variables.tabChoice[tabChoiceVarName()]; - if (!selectedTab) { selectedTab = defaultTab; } - function selectTab() { - let tabBtn = document.getElementById(`tab ${selectedTab}`); - if (!tabBtn) { - tabBtn = document.getElementsByClassName('tablinks').item(0); - } - if (tabBtn) { tabBtn.click(); } - } - if (immidiate) { - selectTab(); - } else { - $(document).one(':passagedisplay', selectTab); - } - } - - function tabChoiceVarName() { - return passage().trim().replace(/ |'/g, ''); - } -}(); - - -/** - * replaces special HTML characters with their '&xxx' forms - * @param {string} text - * @returns {string} - */ -App.Utils.escapeHtml = function(text) { - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - return text.replace(/[&<>"']/g, m => map[m]); -}; -/** - * Maps an index from one list onto a matching index on the other. - * The first and last indexes will be matched to the first and last indexes of the other list, - * while indexes in between will go to the nearest index. - * @param {number} index The index in original list to map to new list. - * @param {*} originalList The original list the index refers into. - * @param {*} newList The new list which we want an index for - * @returns {number} The new index into newList - */ -App.Utils.mapIndexBetweenLists = function(index, originalList, newList) { - if (index === 0) { return 0; } - if (index === originalList.length - 1) { return newList.length - 1; } - index--; - const originalLimitedLength = originalList.length - 2; - const newLimitedLength = newList.length - 2; - return Math.round((index / originalLimitedLength) * newLimitedLength) + 1; -}; -/** - * Creates a HTML element with custom SugarCube attributes which works as a passage link - * - * The result works in the same way as the wiki markup in the SugarCube - * @see https://www.motoslave.net/sugarcube/2/docs/#markup-html-attribute - * @param {string} linkText link text - * @param {string} passage the passage name to link to - * @param {string} [setter=''] setter text (optional) - * @param {string} [tooltip=''] tooltip text (optional) - * @param {string} [elementType='a'] element type (optional) default is 'a'. - * Could be any of 'a', 'audio', img', 'source', 'video' - * @returns {string} element text - * - * @example - * // equal to [[Go to town|Town]] - * App.UI.passageLink("Go to town", "Town") - */ -App.UI.passageLink = function(linkText, passage, setter, tooltip = '', elementType = 'a') { - let res = `<${elementType} data-passage="${passage}"`; - if (setter) { - res += ` data-setter="${App.Utils.escapeHtml(setter)}"`; - } - if (tooltip) { - res += ` title="${tooltip}"`; - } - res += `>${linkText}</${elementType}>`; - return res; -}; - -App.UI.link = function() { - let counter = 0; - - // reset all handlers for each passage - $(document).on(':passageinit', function() { - State.temporary.linkHandlers = {}; - counter = 0; - }); - - return makeLink; - - /** - * Creates a markup for a SugarCube link which executes given function with given arguments - * - * @param {string} linkText link text - * @param {*} handler callable object - * @param {*} args arguments - * @param {string} [passage] the passage name to link to - * @returns {string} link in SC markup - */ - function makeLink(linkText, handler, args = [], passage = '', tooltip = '') { - // pack handler and data - State.temporary.linkHandlers[counter] = { - f: handler, - args: Array.isArray(args) ? args : [args] - }; - - // can't say _linkHandlers here because SC does not recognize its own notation in "..._varName" - let SCHandlerText = - `State.temporary.linkHandlers[${counter}].f(...State.temporary.linkHandlers[${counter}].args);`; - ++counter; - - if (passage) { - return App.UI.passageLink(linkText, passage, SCHandlerText, tooltip); - } else { - if (tooltip) { - throw "Tooltips are not supported by the <<link>> markup."; - } - // data-passage scheme does not work with empty passage name - return `<<link "${linkText}">><<run ${SCHandlerText}>><</link>>`; - } - } -}(); - -/** - * Replaces contents of the element, identified by the given selector, with wiki'ed new content - * - * The function is an analogue to the SugarCube <<replace>> macro (and is a simplified version of it) - * @param {string} selector - * @param {string} newContent - */ -App.UI.replace = function(selector, newContent) { - let ins = jQuery(document.createDocumentFragment()); - ins.wiki(newContent); - const target = $(selector); - target.empty(); - target.append(ins); -}; - -/** - * A simple macro which allows to create wrapping html elements with dynamic IDs. - * - * idea blatantly robbed from the spanMacroJS.tw but expanded to a more generic case, allowing <div>, - * <button> or whatever you want elements, default is for the div though. - * In addition, you can pass an object in as the first argument instead of an id, and each of the - * object's attributes will become attributes of the generate tag. - * - * @example - * htag('test', "red") // <div id="red">test</div> - * htag('test', {class: red}); // <div class="red">test</div> - * htag('test', {class: red, id: green}); // <div class="red" id="green">test</div> - * @param {string} text - * @param {string|object} attributes - * @param {string} [tag='div'] - * @returns {string} - */ -App.UI.htag = function(text, attributes, tag = 'div') { - const payload = text.replace(/(^\n+|\n+$)/, ""); - - if (typeof attributes === "object") { - attributes = $.map(attributes, (val, key) => `${key}="${val}"`).join(" "); - } else { - attributes = `id="${attributes.trim()}"`; - } - - return `<${tag} ${attributes}>${payload}</${tag}>`; -}; - window.SkillIncrease = (function() { return { Oral: OralSkillIncrease, @@ -3079,19 +2664,6 @@ window.changeSkinTone = function(skin, value) { return prop; }; -/** - * Creates a span for an link with tooltip containing the reasons why it is disabled - * @param {string} link - * @param {string[]} reasons - * @returns {string} - */ -App.UI.disabledLink = function(link, reasons) { - const tooltips = reasons.length === 1 ? - `<span class="tooltip">${reasons}</span>` : - `<div class="tooltip"><ul>${reasons.map(e => `<li>${e}</li>`).join('')}</ul></div>`; - return `<span class="textWithTooltip">${link}${tooltips}</span>`; -}; - /** * Expression for SugarCube for referencing a slave by index * @param {number} i slave array index or -1 for activeSlave @@ -3209,15 +2781,6 @@ window.moreNational = function(nation) { return country; }; -//Creates an object where the items are accessible via their ids. -window.mapIdList = function(list) { - let mappedList = {}; - for (const item of list) { - mappedList[item.id] = item; - } - return mappedList; -}; - /** * Returns a "disobedience factor" between 0 (perfectly obedient) and 100 (completely defiant) * @param {App.Entity.SlaveState} slave @@ -3265,63 +2828,3 @@ window.randomRapeRivalryTarget = function(slave, predicate) { const arr = V.slaves.filter((s) => { return canBeARapeRival(s) && canRape(slave, s); }).shuffle(); return arr.find(predicate); }; - -/** - * Topological sorting algorithm - * https://gist.github.com/shinout/1232505 - * Added keys parameter since it better suits our needs and updated to project code style. - * - * @param {[]} keys - * @param {[[]]} edges: list of edges. each edge forms Array<ID,ID> e.g. [12 , 3] - * @returns Array: topological sorted list of IDs - * @throws Error: in case there is a closed chain. - **/ -App.Utils.topologicalSort = function(keys, edges) { - let nodes = {}; // hash: stringified id of the node => { id: id, afters: list of ids } - let sorted = []; // sorted list of IDs ( returned value ) - let visited = {}; // hash: id of already visited node => true - - const Node = function(id) { - this.id = id; - this.afters = []; - }; - - // 1. build data structures - keys.forEach(key => { - nodes[key] = new Node(key); - }); - - edges.forEach(edge => { - const from = edge[0], to = edge[1]; - if (!nodes[from]) { nodes[from] = new Node(from); } - if (!nodes[to]) { nodes[to] = new Node(to); } - nodes[from].afters.push(to); - }); - - // 2. topological sort - Object.keys(nodes).forEach(function visit(idstr, ancestors) { - let node = nodes[idstr]; - let id = node.id; - - // if already exists, do nothing - if (visited[idstr]) { return; } - - if (!Array.isArray(ancestors)) { ancestors = []; } - - ancestors.push(id); - - visited[idstr] = true; - - node.afters.forEach((afterID) => { - if (ancestors.indexOf(afterID) >= 0) { // if already in ancestors, a closed chain exists. - throw new Error('closed chain : ' + afterID + ' is in ' + id); - } - - visit(afterID.toString(), ancestors.map(v => v)); // recursive call - }); - - sorted.unshift(id); - }); - - return sorted; -}; diff --git a/src/js/utilsSC.js b/src/js/utilsSC.js new file mode 100644 index 0000000000000000000000000000000000000000..44adff3c742af50971702ebc3ca999706085c351 --- /dev/null +++ b/src/js/utilsSC.js @@ -0,0 +1,221 @@ +/** + * circumvents SugarCube, allowing a plain HTML5 UI within it + * + * @param passageFunction + */ +window.html5passage = function html5passage(passageFunction) { + $(document).one(":passagedisplay", (ev) => { + const element = document.createElement("div"); + element.classList.add("passage"); + document.getElementById("passages").appendChild(element); + passageFunction(element); + $(document).off(":passagedisplay"); + }); +}; + +/** + * If you want to include a SugarCube passage in a JS function use this. The result must be printed using the <<print>> macro. + * @param {string} passageTitle + * @returns {string} + */ +window.jsInclude = function(passageTitle) { + if (Story.has(passageTitle)) { + return Story.get(passageTitle).processText(); + } else { + return `<span class="red">Error: Passage ${passageTitle} does not exist.</span>`; + } +}; + +/** + * Creates a HTML element with custom SugarCube attributes which works as a passage link + * + * The result works in the same way as the wiki markup in the SugarCube + * @see https://www.motoslave.net/sugarcube/2/docs/#markup-html-attribute + * @param {string} linkText link text + * @param {string} passage the passage name to link to + * @param {string} [setter=''] setter text (optional) + * @param {string} [tooltip=''] tooltip text (optional) + * @param {string} [elementType='a'] element type (optional) default is 'a'. + * Could be any of 'a', 'audio', img', 'source', 'video' + * @returns {string} element text + * + * @example + * // equal to [[Go to town|Town]] + * App.UI.passageLink("Go to town", "Town") + */ +App.UI.passageLink = function(linkText, passage, setter, tooltip = '', elementType = 'a') { + let res = `<${elementType} data-passage="${passage}"`; + if (setter) { + res += ` data-setter="${App.Utils.escapeHtml(setter)}"`; + } + if (tooltip) { + res += ` title="${tooltip}"`; + } + res += `>${linkText}</${elementType}>`; + return res; +}; + +App.UI.link = function() { + let counter = 0; + + // reset all handlers for each passage + $(document).on(':passageinit', function() { + State.temporary.linkHandlers = {}; + counter = 0; + }); + + return makeLink; + + /** + * Creates a markup for a SugarCube link which executes given function with given arguments + * + * @param {string} linkText link text + * @param {*} handler callable object + * @param {*} args arguments + * @param {string} [passage] the passage name to link to + * @returns {string} link in SC markup + */ + function makeLink(linkText, handler, args = [], passage = '', tooltip = '') { + // pack handler and data + State.temporary.linkHandlers[counter] = { + f: handler, + args: Array.isArray(args) ? args : [args] + }; + + // can't say _linkHandlers here because SC does not recognize its own notation in "..._varName" + let SCHandlerText = + `State.temporary.linkHandlers[${counter}].f(...State.temporary.linkHandlers[${counter}].args);`; + ++counter; + + if (passage) { + return App.UI.passageLink(linkText, passage, SCHandlerText, tooltip); + } else { + if (tooltip) { + throw "Tooltips are not supported by the <<link>> markup."; + } + // data-passage scheme does not work with empty passage name + return `<<link "${linkText}">><<run ${SCHandlerText}>><</link>>`; + } + } +}(); + +/** + * Replaces contents of the element, identified by the given selector, with wiki'ed new content + * + * The function is an analogue to the SugarCube <<replace>> macro (and is a simplified version of it) + * @param {string} selector + * @param {string} newContent + */ +App.UI.replace = function(selector, newContent) { + let ins = jQuery(document.createDocumentFragment()); + ins.wiki(newContent); + const target = $(selector); + target.empty(); + target.append(ins); +}; + +/** + * A simple macro which allows to create wrapping html elements with dynamic IDs. + * + * idea blatantly robbed from the spanMacroJS.tw but expanded to a more generic case, allowing <div>, + * <button> or whatever you want elements, default is for the div though. + * In addition, you can pass an object in as the first argument instead of an id, and each of the + * object's attributes will become attributes of the generate tag. + * + * @example + * htag('test', "red") // <div id="red">test</div> + * htag('test', {class: red}); // <div class="red">test</div> + * htag('test', {class: red, id: green}); // <div class="red" id="green">test</div> + * @param {string} text + * @param {string|object} attributes + * @param {string} [tag='div'] + * @returns {string} + */ +App.UI.htag = function(text, attributes, tag = 'div') { + const payload = text.replace(/(^\n+|\n+$)/, ""); + + if (typeof attributes === "object") { + attributes = $.map(attributes, (val, key) => `${key}="${val}"`).join(" "); + } else { + attributes = `id="${attributes.trim()}"`; + } + + return `<${tag} ${attributes}>${payload}</${tag}>`; +}; + +App.UI.tabbar = function() { + return { + openTab: openTab, + tabButton: tabButton, + makeTab: makeTab, + handlePreSelectedTab: handlePreSelectedTab, + tabChoiceVarName: tabChoiceVarName + }; + + function openTab(evt, tabName) { + /* var passage = passage().trim().replace(/ /g,"+");*/ + const tabcontent = document.getElementsByClassName("tabcontent"); + for (let i = 0; i < tabcontent.length; i++) { + tabcontent[i].style.display = "none"; + } + const tablinks = document.getElementsByClassName("tablinks"); + for (let i = 0; i < tablinks.length; i++) { + tablinks[i].className = tablinks[i].className.replace(" active", ""); + } + V.tabChoice[tabChoiceVarName()] = tabName; /* The regex strips spaces and " ' " from passage names, making "Servants' Quarters" into "ServantsQuarters" and allowing it to be used as a label in this object. */ + document.getElementById(tabName).style.display = "block"; + evt.currentTarget.className += " active"; + } + + /** + * @param {string} name + * @param {string} text + * @returns {string} + */ + function tabButton(name, text) { + return `<button class="tablinks" onclick="App.UI.tabbar.openTab(event, '${name}')" id="tab ${name}">${text}</button>`; + } + + /** + * @param {string} name + * @param {string} content + * @returns {string} + */ + function makeTab(name, content) { + return `<div id="${name}" class="tabcontent"><div class="content">${content}</div></div>`; + } + + function handlePreSelectedTab(defaultTab = "assign", immidiate = false) { + let selectedTab = State.variables.tabChoice[tabChoiceVarName()]; + if (!selectedTab) { selectedTab = defaultTab; } + function selectTab() { + let tabBtn = document.getElementById(`tab ${selectedTab}`); + if (!tabBtn) { + tabBtn = document.getElementsByClassName('tablinks').item(0); + } + if (tabBtn) { tabBtn.click(); } + } + if (immidiate) { + selectTab(); + } else { + $(document).one(':passagedisplay', selectTab); + } + } + + function tabChoiceVarName() { + return passage().trim().replace(/ |'/g, ''); + } +}(); + +/** + * Creates a span for an link with tooltip containing the reasons why it is disabled + * @param {string} link + * @param {string[]} reasons + * @returns {string} + */ +App.UI.disabledLink = function(link, reasons) { + const tooltips = reasons.length === 1 ? + `<span class="tooltip">${reasons}</span>` : + `<div class="tooltip"><ul>${reasons.map(e => `<li>${e}</li>`).join('')}</ul></div>`; + return `<span class="textWithTooltip">${link}${tooltips}</span>`; +};