2 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
3 // GW.loggingEnabled = true;
5 // Links to comments generated by LW have a hash that consists of just the
6 // comment ID, which can start with a number. Prefix it with "comment-".
7 if (location.hash.length == 18) {
8 location.hash = "#comment-" + location.hash.substring(1);
11 /****************************************************/
12 /* CSS CLASS MANIPULATION (polyfill for .classList) */
13 /****************************************************/
15 Element.prototype.addClass = function(className) {
16 if (!this.hasClass(className))
17 this.className = (this.className + " " + className).trim();
19 Element.prototype.addClasses = function(classNames) {
20 let elementClassNames = this.className.trim().split(/\s/);
22 classNames.forEach(className => {
23 if (!this.hasClass(className))
24 elementClassNames.push(className);
27 this.className = elementClassNames.join(" ");
29 Element.prototype.removeClass = function(className) {
30 this.className = this.className.replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), "$1").trim();
31 if (this.className == "") this.removeAttribute("class");
33 Element.prototype.removeClasses = function(classNames) {
34 classNames.forEach(className => {
35 this.className = this.className.replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), "$1").trim();
37 if (this.className == "") this.removeAttribute("class");
39 Element.prototype.hasClass = function(className) {
40 return (new RegExp("(^|\\s+)" + className + "(\\s+|$)")).test(this.className);
42 /* True if the element has _all_ of the classes in the argument (which may be
43 a space-separated string or an array); false otherwise.
45 Element.prototype.hasClasses = function (classes) {
46 if (typeof classes == "string")
47 classes = classes.split(" ");
49 for (let aClass of classes)
50 if (false == this.hasClass(aClass))
55 Element.prototype.toggleClass = function(className) {
56 if (this.hasClass(className))
57 this.removeClass(className);
59 this.addClass(className);
62 /* Swap classes on the given element.
64 First argument is an array with two string elements (the classes).
65 Second argument is 0 or 1 (index of class to add; the other is removed).
67 Note that the first class in the array is always removed/added first, and
68 then the second class in the array is added/removed; thus these two calls
69 have different effects:
71 anElement.swapClasses([ "foo", "bar" ], 1);
72 anElement.swapClasses([ "bar", "foo" ], 0);
74 The first call removes "foo" and then adds "bar"; the second call adds "bar"
75 and then removes "foo". (This can have different visual or other side
76 effects in many circumstances. It also results in a different end state in
77 the cases where the two classes are the same.)
79 Element.prototype.swapClasses = function (classes, whichToAdd) {
80 let op1 = whichToAdd ? "removeClass" : "addClass";
81 let op2 = whichToAdd ? "addClass" : "removeClass";
83 this[op1](classes[0]);
84 this[op2](classes[1]);
89 function insertHeadHTML(html) {
90 document.head.insertAdjacentHTML("beforeend", html.replace(/\s+/, " "));
93 /********************/
94 /* QUERYING THE DOM */
95 /********************/
97 function queryAll(selector, context) {
98 context = context || document;
99 // Redirect simple selectors to the more performant function
100 if (/^(#?[\w-]+|\.[\w-.]+)$/.test(selector)) {
101 switch (selector.charAt(0)) {
103 // Handle ID-based selectors
104 let element = document.getElementById(selector.substr(1));
105 return element ? [ element ] : [ ];
107 // Handle class-based selectors
108 // Query by multiple classes by converting the selector
109 // string into single spaced class names
110 var classes = selector.substr(1).replace(/\./g, ' ');
111 return [].slice.call(context.getElementsByClassName(classes));
113 // Handle tag-based selectors
114 return [].slice.call(context.getElementsByTagName(selector));
117 // Default to `querySelectorAll`
118 return [].slice.call(context.querySelectorAll(selector));
120 function query(selector, context) {
121 let all = queryAll(selector, context);
122 return (all.length > 0) ? all[0] : null;
124 Object.prototype.queryAll = function (selector) {
125 return queryAll(selector, this);
127 Object.prototype.query = function (selector) {
128 return query(selector, this);
135 Object.prototype.isEmpty = function () {
136 for (var prop in this) if (this.hasOwnProperty(prop)) return false;
139 Object.prototype.keys = function () {
140 return Object.keys(this);
142 Array.prototype.contains = function (element) {
143 return (this.indexOf(element) !== -1);
145 Array.prototype.clone = function() {
146 return JSON.parse(JSON.stringify(this));
149 /********************************************************************/
150 /* Reads the value of named cookie.
151 Returns the cookie as a string, or null if no such cookie exists.
153 function readCookie(name) {
154 var nameEQ = name + "=";
155 var ca = document.cookie.split(';');
156 for(var i = 0; i < ca.length; i++) {
158 while (c.charAt(0)==' ') c = c.substring(1, c.length);
159 if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
164 /***************************************************************************/
165 /* Create and return a new element with the specified tag name, attributes,
166 and object properties.
168 function newElement(tagName, attributes = { }, properties = { }) {
169 let element = document.createElement(tagName);
170 for (const attrName in attributes)
171 if (attributes.hasOwnProperty(attrName) && attributes[attrName] !== null)
172 element.setAttribute(attrName, attributes[attrName]);
173 for (const propName in properties)
174 if (properties.hasOwnProperty(propName))
175 element[propName] = properties[propName];
179 /****************************/
180 /* APPEARANCE CUSTOMIZATION */
181 /****************************/
188 defaultTheme: "default",
190 defaultWidth: "normal",
191 defaultTextZoom: 1.0,
197 [ "normal", "Narrow (fixed-width) content column", "N" ],
198 [ "wide", "Wide (fixed-width) content column", "W" ],
199 [ "fluid", "Full-width (fluid) content column", "F" ]
202 textSizeAdjustTargetElementsSelector: [
210 [ "default", "Default theme (dark text on light background)", "A" ],
211 [ "dark", "Dark theme (light text on dark background)", "B" ],
212 [ "grey", "Grey theme (more subdued than default theme)", "C" ],
213 [ "ultramodern", "Ultramodern theme (very hip)", "D" ],
214 [ "zero", "Theme zero (plain and simple)", "E" ],
215 [ "brutalist", "Brutalist theme (the Motherland calls!)", "F" ],
216 [ "rts", "ReadTheSequences.com theme", "G" ],
217 [ "classic", "Classic Less Wrong theme", "H" ],
218 [ "less", "Less theme (serenity now)", "I" ]
221 defaultFiltersExclusionTree: [ "#content", [ ] ],
223 defaultThemeTweakerClippyState: true,
225 defaultAppearanceAdjustUIToggleState: false,
227 themeLessAppearanceAdjustUIElementsSelector: [
228 "#comments-view-mode-selector",
230 "#dark-mode-selector",
232 "#text-size-adjustment-ui",
233 "#theme-tweaker-toggle",
234 "#appearance-adjust-ui-toggle button"
242 currentFilters: null,
244 currentTextZoom: null,
246 filtersExclusionPaths: { },
248 themeTweakStyleBlock: null,
249 textZoomStyleBlock: null,
258 getSavedTheme: () => {
259 return (readCookie("theme") || Appearance.defaultTheme);
262 /* Filters (theme tweaks).
265 getSavedFilters: () => {
266 return (JSON.parse(localStorage.getItem("theme-tweaks")) || Appearance.defaultFilters);
269 saveCurrentFilters: () => {
270 GWLog("Appearance.saveCurrentFilters");
272 if (Appearance.currentFilters == { })
273 localStorage.removeItem("theme-tweaks");
275 localStorage.setItem("theme-tweaks", JSON.stringify(Appearance.currentFilters));
278 applyFilters: (filters) => {
279 GWLog("Appearance.applyFilters");
281 if (typeof filters == "undefined")
282 filters = Appearance.currentFilters;
284 let fullStyleString = "";
285 if (!filters.isEmpty()) {
286 let filtersExclusionTree = ( Appearance.exclusionTreeFromExclusionPaths(Appearance.filtersExclusionPaths)
287 || Appearance.defaultFiltersExclusionTree);
288 fullStyleString = `body::before { content: ""; } body > #content::before { z-index: 0; }`
289 + ` ${Appearance.selectorFromExclusionTree(filtersExclusionTree)}`
290 + ` { filter: ${Appearance.filterStringFromFilters(filters)}; }`;
293 // Update the style tag.
294 Appearance.themeTweakStyleBlock.innerHTML = fullStyleString;
296 // Update the current filters.
297 Appearance.currentFilters = filters;
300 exclusionTreeFromExclusionPaths: (paths) => {
304 let tree = Appearance.defaultFiltersExclusionTree.clone();
305 paths.keys().flatMap(key => paths[key]).forEach(path => {
306 var currentNodeInTree = tree;
307 path.split(" ").slice(1).forEach(step => {
308 if (currentNodeInTree[1] == null)
309 currentNodeInTree[1] = [ ];
311 var indexOfMatchingChild = currentNodeInTree[1].findIndex(child => { return child[0] == step; });
312 if (indexOfMatchingChild == -1) {
313 currentNodeInTree[1].push([ step, [ ] ]);
314 indexOfMatchingChild = currentNodeInTree[1].length - 1;
317 currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
324 selectorFromExclusionTree: (tree) => {
325 let selectorParts = [
326 "body::before, #ui-elements-container > div:not(#theme-tweaker-ui), #theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container"
329 function selectorFromExclusionTreeNode(node, path = [ ]) {
330 let [ value, children ] = node;
332 let newPath = path.clone();
337 } else if (children.length == 0) {
338 return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
340 return `${newPath.join(" > ")} > *:not(${children.map(child => child[0]).join("):not(")}), ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after, ` + children.map(child => selectorFromExclusionTreeNode(child, newPath)).join(", ");
344 return selectorParts + ", " + selectorFromExclusionTreeNode(tree);
347 filterStringFromFilters: (filters) => {
348 let filterString = "";
349 for (key of Object.keys(filters)) {
350 let value = filters[key];
351 filterString += ` ${key}(${value})`;
356 /* Content column width.
359 getSavedWidth: () => {
360 return (localStorage.getItem("selected-width") || Appearance.defaultWidth);
363 saveCurrentWidth: () => {
364 GWLog("Appearance.saveCurrentWidth");
366 if (Appearance.currentWidth == "normal")
367 localStorage.removeItem("selected-width");
369 localStorage.setItem("selected-width", Appearance.currentWidth);
372 setContentWidth: (widthOption) => {
373 GWLog("Appearance.setContentWidth");
375 document.head.removeClasses(Appearance.widthOptions.map(wo => "content-width-" + wo[0]));
376 document.head.addClass("content-width-" + (widthOption || Appearance.currentWidth));
382 getSavedTextZoom: () => {
383 return (parseFloat(localStorage.getItem("text-zoom")) || Appearance.defaultTextZoom);
386 saveCurrentTextZoom: () => {
387 GWLog("Appearance.saveCurrentTextZoom");
389 if (Appearance.currentTextZoom == 1.0)
390 localStorage.removeItem("text-zoom");
392 localStorage.setItem("text-zoom", Appearance.currentTextZoom);
395 setTextZoom: (zoomFactor, save = true) => {
396 GWLog("Appearance.setTextZoom");
401 if (zoomFactor <= Appearance.minTextZoom) {
402 zoomFactor = Appearance.minTextZoom;
403 queryAll(".text-size-adjust-button.decrease").forEach(button => {
404 button.disabled = true;
406 } else if (zoomFactor >= Appearance.maxTextZoom) {
407 zoomFactor = Appearance.maxTextZoom;
408 queryAll(".text-size-adjust-button.increase").forEach(button => {
409 button.disabled = true;
412 queryAll(".text-size-adjust-button").forEach(button => {
413 button.disabled = false;
417 Appearance.currentTextZoom = zoomFactor;
419 Appearance.textZoomStyleBlock.innerHTML = `${Appearance.textSizeAdjustTargetElementsSelector} { zoom: ${zoomFactor}; }`;
421 if (window.generateImagesOverlay) {
422 requestAnimationFrame(() => {
423 generateImagesOverlay();
428 Appearance.saveCurrentTextZoom();
435 // Set up appearance system and apply saved settings.
437 GWLog("Appearance.setup");
439 Appearance.currentTheme = Appearance.getSavedTheme();
440 Appearance.currentFilters = Appearance.getSavedFilters();
441 Appearance.currentWidth = Appearance.getSavedWidth();
442 Appearance.currentTextZoom = Appearance.getSavedTextZoom();
444 insertHeadHTML("<style id='theme-tweak'></style>");
445 insertHeadHTML("<style id='text-zoom'></style>");
447 Appearance.themeTweakStyleBlock = document.head.query("#theme-tweak");
448 Appearance.textZoomStyleBlock = document.head.query("#text-zoom");
450 Appearance.applyFilters();
451 Appearance.setContentWidth();
452 Appearance.setTextZoom();
462 // While everything's being loaded, hide the authors and karma values.
463 if (localStorage.getItem("antikibitzer") == "true") {
464 insertHeadHTML("<style id='antikibitzer-temp'>" +
465 `.author, .inline-author, .karma-value, .individual-thread-page > h1 { visibility: hidden; }` +
468 if (document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
469 insertHeadHTML("<"+"title class='fake-title'></title>");
477 function GWLog (string) {
478 if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
486 /* Return the value of a GET (i.e., URL) parameter.
488 function getQueryVariable(variable) {
489 var query = window.location.search.substring(1);
490 var vars = query.split("&");
491 for (var i = 0; i < vars.length; i++) {
492 var pair = vars[i].split("=");
493 if (pair[0] == variable)
500 /* Get the comment ID of the item (if it's a comment) or of its containing
501 comment (if it's a child of a comment).
503 Element.prototype.getCommentId = function() {
504 let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
506 return (/^comment-(.*)/.exec(item.id)||[])[1];
516 function setTOCCollapseState(collapsed = false) {
517 let TOC = query("nav.contents");
521 TOC.classList.toggle("collapsed", collapsed);
523 let button = TOC.query(".toc-collapse-toggle-button");
524 button.innerHTML = collapsed ? "" : "";
525 button.title = collapsed ? "Expand table of contents" : "Collapse table of contents";
528 function injectTOCCollapseToggleButton() {
529 let TOC = document.currentScript.parentElement;
533 TOC.insertAdjacentHTML("afterbegin", "<button type='button' class='toc-collapse-toggle-button'></button>");
535 let defaultTOCCollapseState = (window.innerWidth <= 520) ? "true" : "false";
536 setTOCCollapseState((localStorage.getItem("toc-collapsed") ?? defaultTOCCollapseState) == "true");
538 TOC.query(".toc-collapse-toggle-button").addActivateEvent(GW.tocCollapseToggleButtonClicked = (event) => {
539 setTOCCollapseState(TOC.classList.contains("collapsed") == false);
540 localStorage.setItem("toc-collapsed", TOC.classList.contains("collapsed"));
544 /***********************************/
545 /* COMMENT THREAD MINIMIZE BUTTONS */
546 /***********************************/
548 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
549 GWLog("setCommentThreadMaximized");
550 let commentItem = this;
551 let storageName = "thread-minimized-" + commentItem.getCommentId();
552 let minimize_button = commentItem.query(".comment-minimize-button");
553 let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !(localStorage.getItem(storageName) || commentItem.hasClass("ignored")));
554 if (userOriginated) {
556 localStorage.removeItem(storageName);
558 localStorage.setItem(storageName, true);
562 commentItem.style.height = maximize ? 'auto' : '38px';
563 commentItem.style.overflow = maximize ? 'visible' : 'hidden';
565 minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
566 minimize_button.innerHTML = maximize ? "" : "";
567 minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
568 if (getQueryVariable("chrono") != "t") {
569 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
573 /*****************************/
574 /* MINIMIZED THREAD HANDLING */
575 /*****************************/
577 function expandAncestorsOf(comment) {
578 GWLog("expandAncestorsOf");
579 if (typeof comment == "string") {
580 comment = /(?:comment-)?(.+)/.exec(comment)[1];
581 comment = query("#comment-" + comment);
584 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
588 // Expand collapsed comment threads.
589 let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
590 if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
592 // Expand collapsed comments.
593 let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
594 if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
597 /********************/
598 /* COMMENT CONTROLS */
599 /********************/
601 /* Adds an event listener to a button (or other clickable element), attaching
602 it to both "click" and "keyup" events (for use with keyboard navigation).
603 Optionally also attaches the listener to the 'mousedown' event, making the
604 element activate on mouse down instead of mouse up. */
605 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
606 let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
607 if (includeMouseDown) this.addEventListener("mousedown", ael);
608 this.addEventListener("click", ael);
609 this.addEventListener("keyup", ael);
612 Element.prototype.updateCommentControlButton = function() {
613 GWLog("updateCommentControlButton");
614 let retractFn = () => {
615 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
616 return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
618 return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
621 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
622 "retract-button": retractFn,
623 "unretract-button": retractFn,
624 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
626 classMap.keys().forEach((testClass) => {
627 if (this.hasClass(testClass)) {
628 let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
630 this.addClasses([ buttonClass, "action-button" ]);
631 if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
632 this.dataset.label = buttonLabel;
633 this.title = buttonAltText;
634 this.tabIndex = '-1';
640 Element.prototype.constructCommentControls = function() {
641 GWLog("constructCommentControls");
642 let commentControls = this;
644 if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
648 let commentType = commentControls.parentElement.id.replace(/s$/, "");
649 commentControls.innerHTML = "";
651 if (commentControls.parentElement.hasClass("comments")) {
652 replyButton = newElement("BUTTON", {
653 "class": "new-comment-button action-button",
654 "accesskey": (commentType == "comment" ? "n" : ""),
655 "title": ("Post new " + commentType + (commentType == "comment" ? " [n]" : "")),
658 "innerHTML": (commentType == "nomination" ? "Add nomination" : "Post new " + commentType)
661 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
662 let buttonsList = [];
663 if (!commentControls.parentElement.query(".comment-thread"))
664 buttonsList.push("delete-button");
665 buttonsList.push("retract-button", "edit-button");
666 buttonsList.forEach(buttonClass => {
667 let button = commentControls.appendChild(newElement("BUTTON", { "class": buttonClass }));
668 button.updateCommentControlButton();
671 replyButton = newElement("BUTTON", {
672 "class": "reply-button action-button",
673 "data-label": "Reply",
679 commentControls.appendChild(replyButton);
682 commentControls.queryAll(".action-button").forEach(button => {
683 button.addActivateEvent(GW.commentActionButtonClicked);
686 // Replicate voting controls at the bottom of comments.
687 if (commentControls.parentElement.hasClass("comments")) return;
688 let votingControls = commentControls.parentElement.queryAll(".comment-meta .voting-controls");
689 if (!votingControls) return;
690 votingControls.forEach(control => {
691 let controlCloned = control.cloneNode(true);
692 commentControls.appendChild(controlCloned);
695 if(commentControls.query(".active-controls")) {
696 commentControls.queryAll("button.vote").forEach(voteButton => {
697 voteButton.addActivateEvent(voteButtonClicked);
702 GW.commentActionButtonClicked = (event) => {
703 GWLog("commentActionButtonClicked");
704 if (event.target.hasClass("edit-button") ||
705 event.target.hasClass("reply-button") ||
706 event.target.hasClass("new-comment-button")) {
707 queryAll("textarea").forEach(textarea => {
708 let commentControls = textarea.closest(".comment-controls");
709 if(commentControls) hideReplyForm(commentControls);
713 if (event.target.hasClass("delete-button")) {
714 let commentItem = event.target.closest(".comment-item");
715 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
716 "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" +
717 "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" +
718 "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
719 doCommentAction("delete", commentItem);
720 } else if (event.target.hasClass("retract-button")) {
721 doCommentAction("retract", event.target.closest(".comment-item"));
722 } else if (event.target.hasClass("unretract-button")) {
723 doCommentAction("unretract", event.target.closest(".comment-item"));
724 } else if (event.target.hasClass("edit-button")) {
725 showCommentEditForm(event.target.closest(".comment-item"));
726 } else if (event.target.hasClass("reply-button")) {
727 showReplyForm(event.target.closest(".comment-item"));
728 } else if (event.target.hasClass("new-comment-button")) {
729 showReplyForm(event.target.closest(".comments"));
735 function initializeCommentControls() {
736 e = newElement("DIV", { "class": "comment-controls posting-controls" });
737 document.currentScript.insertAdjacentElement("afterend", e);
738 e.constructCommentControls();
740 if (window.location.hash) {
741 let comment = e.closest(".comment-item");
742 if(comment && window.location.hash == "#" + comment.id)
743 expandAncestorsOf(comment);
751 // If the viewport is wide enough to fit the desktop-size content column,
752 // use a long date format; otherwise, a short one.
753 let useLongDate = window.innerWidth > 900;
754 let dtf = new Intl.DateTimeFormat([],
756 { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
757 : { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' } ));
759 function prettyDate() {
760 let dateElement = document.currentScript.parentElement;
761 let jsDate = dateElement.dataset.jsDate;
763 let pretty = dtf.format(new Date(+ jsDate));
764 window.requestAnimationFrame(() => {
765 dateElement.innerHTML = pretty;
766 dateElement.removeClass('hide-until-init');
772 // Hide elements that require javascript until ready.
773 insertHeadHTML("<style>.only-without-js { display: none; }</style><style id='hide-until-init'>.hide-until-init { visibility: hidden; }</style>");
779 let deferredCalls = [];
781 function callWithServerData(fname, uri) {
782 deferredCalls.push([fname, uri]);
789 /* Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
790 if (!window.requestIdleCallback) {
791 window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
796 function invokeTrigger(args) {
797 if(args.priority < 0) {
799 } else if(args.priority > 0) {
800 requestIdleCallback(args.fn, {timeout: args.priority});
802 setTimeout(args.fn, 0);
806 function addTriggerListener(name, args) {
807 if(typeof(GW.triggers[name])=="string") return invokeTrigger(args);
808 if(!GW.triggers[name]) GW.triggers[name] = [];
809 GW.triggers[name].push(args);
812 function activateTrigger(name) {
813 if(Array.isArray(GW.triggers[name])) {
814 GW.triggers[name].forEach(invokeTrigger);
816 GW.triggers[name] = "done";
819 function addMultiTriggerListener(triggers, args) {
820 if(triggers.length == 1) {
821 addTriggerListener(triggers[0], args);
823 let trigger = triggers.pop();
824 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});