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 [ "grey", "Grey theme (more subdued than default theme)", "C" ],
212 [ "ultramodern", "Ultramodern theme (very hip)", "D" ],
213 [ "zero", "Theme zero (plain and simple)", "E" ],
214 [ "brutalist", "Brutalist theme (the Motherland calls!)", "F" ],
215 [ "rts", "ReadTheSequences.com theme", "G" ],
216 [ "classic", "Classic Less Wrong theme", "H" ],
217 [ "less", "Less theme (serenity now)", "I" ]
220 defaultFiltersExclusionTree: [ "#content", [ ] ],
222 defaultThemeTweakerClippyState: true,
224 defaultAppearanceAdjustUIToggleState: false,
226 themeLessAppearanceAdjustUIElementsSelector: [
227 "#comments-view-mode-selector",
229 "#dark-mode-selector",
231 "#text-size-adjustment-ui",
232 "#theme-tweaker-toggle",
233 "#appearance-adjust-ui-toggle button"
241 currentFilters: null,
243 currentTextZoom: null,
245 filtersExclusionPaths: { },
247 themeTweakStyleBlock: null,
248 textZoomStyleBlock: null,
257 getSavedTheme: () => {
258 return (readCookie("theme") || Appearance.defaultTheme);
261 /* Filters (theme tweaks).
264 getSavedFilters: () => {
265 return (JSON.parse(localStorage.getItem("theme-tweaks")) || Appearance.defaultFilters);
268 saveCurrentFilters: () => {
269 GWLog("Appearance.saveCurrentFilters");
271 if (Appearance.currentFilters == { })
272 localStorage.removeItem("theme-tweaks");
274 localStorage.setItem("theme-tweaks", JSON.stringify(Appearance.currentFilters));
277 applyFilters: (filters) => {
278 GWLog("Appearance.applyFilters");
280 if (typeof filters == "undefined")
281 filters = Appearance.currentFilters;
283 let fullStyleString = "";
284 if (!filters.isEmpty()) {
285 let filtersExclusionTree = ( Appearance.exclusionTreeFromExclusionPaths(Appearance.filtersExclusionPaths)
286 || Appearance.defaultFiltersExclusionTree);
287 fullStyleString = `body::before { content: ""; } body > #content::before { z-index: 0; }`
288 + ` ${Appearance.selectorFromExclusionTree(filtersExclusionTree)}`
289 + ` { filter: ${Appearance.filterStringFromFilters(filters)}; }`;
292 // Update the style tag.
293 Appearance.themeTweakStyleBlock.innerHTML = fullStyleString;
295 // Update the current filters.
296 Appearance.currentFilters = filters;
299 exclusionTreeFromExclusionPaths: (paths) => {
303 let tree = Appearance.defaultFiltersExclusionTree.clone();
304 paths.keys().flatMap(key => paths[key]).forEach(path => {
305 var currentNodeInTree = tree;
306 path.split(" ").slice(1).forEach(step => {
307 if (currentNodeInTree[1] == null)
308 currentNodeInTree[1] = [ ];
310 var indexOfMatchingChild = currentNodeInTree[1].findIndex(child => { return child[0] == step; });
311 if (indexOfMatchingChild == -1) {
312 currentNodeInTree[1].push([ step, [ ] ]);
313 indexOfMatchingChild = currentNodeInTree[1].length - 1;
316 currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
323 selectorFromExclusionTree: (tree) => {
324 let selectorParts = [
325 "body::before, #ui-elements-container > div:not(#theme-tweaker-ui), #theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container"
328 function selectorFromExclusionTreeNode(node, path = [ ]) {
329 let [ value, children ] = node;
331 let newPath = path.clone();
336 } else if (children.length == 0) {
337 return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
339 return `${newPath.join(" > ")} > *:not(${children.map(child => child[0]).join("):not(")}), ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after, ` + children.map(child => selectorFromExclusionTreeNode(child, newPath)).join(", ");
343 return selectorParts + ", " + selectorFromExclusionTreeNode(tree);
346 filterStringFromFilters: (filters) => {
347 let filterString = "";
348 for (key of Object.keys(filters)) {
349 let value = filters[key];
350 filterString += ` ${key}(${value})`;
355 /* Content column width.
358 getSavedWidth: () => {
359 return (localStorage.getItem("selected-width") || Appearance.defaultWidth);
362 saveCurrentWidth: () => {
363 GWLog("Appearance.saveCurrentWidth");
365 if (Appearance.currentWidth == "normal")
366 localStorage.removeItem("selected-width");
368 localStorage.setItem("selected-width", Appearance.currentWidth);
371 setContentWidth: (widthOption) => {
372 GWLog("Appearance.setContentWidth");
374 document.head.removeClasses(Appearance.widthOptions.map(wo => "content-width-" + wo[0]));
375 document.head.addClass("content-width-" + (widthOption || Appearance.currentWidth));
381 getSavedTextZoom: () => {
382 return (parseFloat(localStorage.getItem("text-zoom")) || Appearance.defaultTextZoom);
385 saveCurrentTextZoom: () => {
386 GWLog("Appearance.saveCurrentTextZoom");
388 if (Appearance.currentTextZoom == 1.0)
389 localStorage.removeItem("text-zoom");
391 localStorage.setItem("text-zoom", Appearance.currentTextZoom);
394 setTextZoom: (zoomFactor, save = true) => {
395 GWLog("Appearance.setTextZoom");
400 if (zoomFactor <= Appearance.minTextZoom) {
401 zoomFactor = Appearance.minTextZoom;
402 queryAll(".text-size-adjust-button.decrease").forEach(button => {
403 button.disabled = true;
405 } else if (zoomFactor >= Appearance.maxTextZoom) {
406 zoomFactor = Appearance.maxTextZoom;
407 queryAll(".text-size-adjust-button.increase").forEach(button => {
408 button.disabled = true;
411 queryAll(".text-size-adjust-button").forEach(button => {
412 button.disabled = false;
416 Appearance.currentTextZoom = zoomFactor;
418 Appearance.textZoomStyleBlock.innerHTML = `${Appearance.textSizeAdjustTargetElementsSelector} { zoom: ${zoomFactor}; }`;
420 if (window.generateImagesOverlay) {
421 requestAnimationFrame(() => {
422 generateImagesOverlay();
427 Appearance.saveCurrentTextZoom();
434 // Set up appearance system and apply saved settings.
436 GWLog("Appearance.setup");
438 Appearance.currentTheme = Appearance.getSavedTheme();
439 Appearance.currentFilters = Appearance.getSavedFilters();
440 Appearance.currentWidth = Appearance.getSavedWidth();
441 Appearance.currentTextZoom = Appearance.getSavedTextZoom();
443 insertHeadHTML("<style id='theme-tweak'></style>");
444 insertHeadHTML("<style id='text-zoom'></style>");
446 Appearance.themeTweakStyleBlock = document.head.query("#theme-tweak");
447 Appearance.textZoomStyleBlock = document.head.query("#text-zoom");
449 Appearance.applyFilters();
450 Appearance.setContentWidth();
451 Appearance.setTextZoom();
461 // While everything's being loaded, hide the authors and karma values.
462 if (localStorage.getItem("antikibitzer") == "true") {
463 insertHeadHTML("<style id='antikibitzer-temp'>" +
464 `.author, .inline-author, .karma-value, .individual-thread-page > h1 { visibility: hidden; }` +
467 if (document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
468 insertHeadHTML("<"+"title class='fake-title'></title>");
476 function GWLog (string) {
477 if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
485 /* Return the value of a GET (i.e., URL) parameter.
487 function getQueryVariable(variable) {
488 var query = window.location.search.substring(1);
489 var vars = query.split("&");
490 for (var i = 0; i < vars.length; i++) {
491 var pair = vars[i].split("=");
492 if (pair[0] == variable)
499 /* Get the comment ID of the item (if it's a comment) or of its containing
500 comment (if it's a child of a comment).
502 Element.prototype.getCommentId = function() {
503 let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
505 return (/^comment-(.*)/.exec(item.id)||[])[1];
515 function setTOCCollapseState(collapsed = false) {
516 let TOC = query("nav.contents");
520 TOC.classList.toggle("collapsed", collapsed);
522 let button = TOC.query(".toc-collapse-toggle-button");
523 button.innerHTML = collapsed ? "" : "";
524 button.title = collapsed ? "Expand table of contents" : "Collapse table of contents";
527 function injectTOCCollapseToggleButton() {
528 let TOC = document.currentScript.parentElement;
532 TOC.insertAdjacentHTML("afterbegin", "<button type='button' class='toc-collapse-toggle-button'></button>");
534 let defaultTOCCollapseState = (window.innerWidth <= 520) ? "true" : "false";
535 setTOCCollapseState((localStorage.getItem("toc-collapsed") ?? defaultTOCCollapseState) == "true");
537 TOC.query(".toc-collapse-toggle-button").addActivateEvent(GW.tocCollapseToggleButtonClicked = (event) => {
538 setTOCCollapseState(TOC.classList.contains("collapsed") == false);
539 localStorage.setItem("toc-collapsed", TOC.classList.contains("collapsed"));
543 /***********************************/
544 /* COMMENT THREAD MINIMIZE BUTTONS */
545 /***********************************/
547 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
548 GWLog("setCommentThreadMaximized");
549 let commentItem = this;
550 let storageName = "thread-minimized-" + commentItem.getCommentId();
551 let minimize_button = commentItem.query(".comment-minimize-button");
552 let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !(localStorage.getItem(storageName) || commentItem.hasClass("ignored")));
553 if (userOriginated) {
555 localStorage.removeItem(storageName);
557 localStorage.setItem(storageName, true);
561 commentItem.style.height = maximize ? 'auto' : '38px';
562 commentItem.style.overflow = maximize ? 'visible' : 'hidden';
564 minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
565 minimize_button.innerHTML = maximize ? "" : "";
566 minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
567 if (getQueryVariable("chrono") != "t") {
568 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
572 /*****************************/
573 /* MINIMIZED THREAD HANDLING */
574 /*****************************/
576 function expandAncestorsOf(comment) {
577 GWLog("expandAncestorsOf");
578 if (typeof comment == "string") {
579 comment = /(?:comment-)?(.+)/.exec(comment)[1];
580 comment = query("#comment-" + comment);
583 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
587 // Expand collapsed comment threads.
588 let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
589 if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
591 // Expand collapsed comments.
592 let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
593 if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
596 /********************/
597 /* COMMENT CONTROLS */
598 /********************/
600 /* Adds an event listener to a button (or other clickable element), attaching
601 it to both "click" and "keyup" events (for use with keyboard navigation).
602 Optionally also attaches the listener to the 'mousedown' event, making the
603 element activate on mouse down instead of mouse up. */
604 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
605 let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
606 if (includeMouseDown) this.addEventListener("mousedown", ael);
607 this.addEventListener("click", ael);
608 this.addEventListener("keyup", ael);
611 Element.prototype.updateCommentControlButton = function() {
612 GWLog("updateCommentControlButton");
613 let retractFn = () => {
614 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
615 return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
617 return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
620 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
621 "retract-button": retractFn,
622 "unretract-button": retractFn,
623 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
625 classMap.keys().forEach((testClass) => {
626 if (this.hasClass(testClass)) {
627 let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
629 this.addClasses([ buttonClass, "action-button" ]);
630 if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
631 this.dataset.label = buttonLabel;
632 this.title = buttonAltText;
633 this.tabIndex = '-1';
639 Element.prototype.constructCommentControls = function() {
640 GWLog("constructCommentControls");
641 let commentControls = this;
643 if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
647 let commentType = commentControls.parentElement.id.replace(/s$/, "");
648 commentControls.innerHTML = "";
650 if (commentControls.parentElement.hasClass("comments")) {
651 replyButton = newElement("BUTTON", {
652 "class": "new-comment-button action-button",
653 "accesskey": (commentType == "comment" ? "n" : ""),
654 "title": ("Post new " + commentType + (commentType == "comment" ? " [n]" : "")),
657 "innerHTML": (commentType == "nomination" ? "Add nomination" : "Post new " + commentType)
660 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
661 let buttonsList = [];
662 if (!commentControls.parentElement.query(".comment-thread"))
663 buttonsList.push("delete-button");
664 buttonsList.push("retract-button", "edit-button");
665 buttonsList.forEach(buttonClass => {
666 let button = commentControls.appendChild(newElement("BUTTON", { "class": buttonClass }));
667 button.updateCommentControlButton();
670 replyButton = newElement("BUTTON", {
671 "class": "reply-button action-button",
672 "data-label": "Reply",
678 commentControls.appendChild(replyButton);
681 commentControls.queryAll(".action-button").forEach(button => {
682 button.addActivateEvent(GW.commentActionButtonClicked);
685 // Replicate voting controls at the bottom of comments.
686 if (commentControls.parentElement.hasClass("comments")) return;
687 let votingControls = commentControls.parentElement.queryAll(".comment-meta .voting-controls");
688 if (!votingControls) return;
689 votingControls.forEach(control => {
690 let controlCloned = control.cloneNode(true);
691 commentControls.appendChild(controlCloned);
694 if(commentControls.query(".active-controls")) {
695 commentControls.queryAll("button.vote").forEach(voteButton => {
696 voteButton.addActivateEvent(voteButtonClicked);
701 GW.commentActionButtonClicked = (event) => {
702 GWLog("commentActionButtonClicked");
703 if (event.target.hasClass("edit-button") ||
704 event.target.hasClass("reply-button") ||
705 event.target.hasClass("new-comment-button")) {
706 queryAll("textarea").forEach(textarea => {
707 let commentControls = textarea.closest(".comment-controls");
708 if(commentControls) hideReplyForm(commentControls);
712 if (event.target.hasClass("delete-button")) {
713 let commentItem = event.target.closest(".comment-item");
714 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
715 "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" +
716 "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" +
717 "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
718 doCommentAction("delete", commentItem);
719 } else if (event.target.hasClass("retract-button")) {
720 doCommentAction("retract", event.target.closest(".comment-item"));
721 } else if (event.target.hasClass("unretract-button")) {
722 doCommentAction("unretract", event.target.closest(".comment-item"));
723 } else if (event.target.hasClass("edit-button")) {
724 showCommentEditForm(event.target.closest(".comment-item"));
725 } else if (event.target.hasClass("reply-button")) {
726 showReplyForm(event.target.closest(".comment-item"));
727 } else if (event.target.hasClass("new-comment-button")) {
728 showReplyForm(event.target.closest(".comments"));
734 function initializeCommentControls() {
735 e = newElement("DIV", { "class": "comment-controls posting-controls" });
736 document.currentScript.insertAdjacentElement("afterend", e);
737 e.constructCommentControls();
739 if (window.location.hash) {
740 let comment = e.closest(".comment-item");
741 if(comment && window.location.hash == "#" + comment.id)
742 expandAncestorsOf(comment);
750 // If the viewport is wide enough to fit the desktop-size content column,
751 // use a long date format; otherwise, a short one.
752 let useLongDate = window.innerWidth > 900;
753 let dtf = new Intl.DateTimeFormat([],
755 { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
756 : { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' } ));
758 function prettyDate() {
759 let dateElement = document.currentScript.parentElement;
760 let jsDate = dateElement.dataset.jsDate;
762 let pretty = dtf.format(new Date(+ jsDate));
763 window.requestAnimationFrame(() => {
764 dateElement.innerHTML = pretty;
765 dateElement.removeClass('hide-until-init');
771 // Hide elements that require javascript until ready.
772 insertHeadHTML("<style>.only-without-js { display: none; }</style><style id='hide-until-init'>.hide-until-init { visibility: hidden; }</style>");
778 let deferredCalls = [];
780 function callWithServerData(fname, uri) {
781 deferredCalls.push([fname, uri]);
788 /* Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
789 if (!window.requestIdleCallback) {
790 window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
795 function invokeTrigger(args) {
796 if(args.priority < 0) {
798 } else if(args.priority > 0) {
799 requestIdleCallback(args.fn, {timeout: args.priority});
801 setTimeout(args.fn, 0);
805 function addTriggerListener(name, args) {
806 if(typeof(GW.triggers[name])=="string") return invokeTrigger(args);
807 if(!GW.triggers[name]) GW.triggers[name] = [];
808 GW.triggers[name].push(args);
811 function activateTrigger(name) {
812 if(Array.isArray(GW.triggers[name])) {
813 GW.triggers[name].forEach(invokeTrigger);
815 GW.triggers[name] = "done";
818 function addMultiTriggerListener(triggers, args) {
819 if(triggers.length == 1) {
820 addTriggerListener(triggers[0], args);
822 let trigger = triggers.pop();
823 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});