Update theme lettering to remove dark theme.
[lw2-viewer.git] / www / head.js
blobcbb93f6dcc6c541cfc66d4da77011e0ba0789c57
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/);
21         
22         classNames.forEach(className => {
23                 if (!this.hasClass(className))
24                         elementClassNames.push(className);
25         });
26         
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();
36         });
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.
44  */
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))
51                         return false;
53         return true;
55 Element.prototype.toggleClass = function(className) {
56         if (this.hasClass(className))
57                 this.removeClass(className);
58         else
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:
70     
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.)
78  */
79 Element.prototype.swapClasses = function (classes, whichToAdd) {
80         let op1 = whichToAdd ? "removeClass" : "addClass";
81         let op2 = whichToAdd ? "addClass" : "removeClass";
82         
83         this[op1](classes[0]);
84         this[op2](classes[1]);
87 /* DOM helpers */
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)) {
102             case '#':
103                 // Handle ID-based selectors
104                 let element = document.getElementById(selector.substr(1));
105                 return element ? [ element ] : [ ];
106             case '.':
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));
112             default:
113                 // Handle tag-based selectors
114                 return [].slice.call(context.getElementsByTagName(selector));
115         }
116     }
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);
131 /********/
132 /* MISC */
133 /********/
135 Object.prototype.isEmpty = function () {
136     for (var prop in this) if (this.hasOwnProperty(prop)) return false;
137     return true;
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.
152  */
153 function readCookie(name) {
154         var nameEQ = name + "=";
155         var ca = document.cookie.split(';');
156         for(var i = 0; i < ca.length; i++) {
157                 var c = ca[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);
160         }
161         return null;
164 /***************************************************************************/
165 /*      Create and return a new element with the specified tag name, attributes,
166         and object properties.
167  */
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];
176         return element;
179 /****************************/
180 /* APPEARANCE CUSTOMIZATION */
181 /****************************/
183 Appearance = {
184         /*****************/
185         /*      Configuration.
186          */
188         defaultTheme: "default",
189         defaultFilters: { },
190         defaultWidth: "normal",
191         defaultTextZoom: 1.0,
193         minTextZoom: 0.5,
194         maxTextZoom: 1.5,
196         widthOptions: [
197                 [ "normal",     "Narrow (fixed-width) content column",  "N" ],
198                 [ "wide",       "Wide (fixed-width) content column",    "W" ],
199                 [ "fluid",      "Full-width (fluid) content column",    "F" ]
200         ],
202         textSizeAdjustTargetElementsSelector: [
203                 ".post", 
204                 ".comment", 
205                 ".posting-controls", 
206                 ".sample-text"
207         ].join(", "),
209         themeOptions: [
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" ]
218         ],
220         defaultFiltersExclusionTree: [ "#content", [ ] ],
222         defaultThemeTweakerClippyState: true,
224         defaultAppearanceAdjustUIToggleState: false,
226         themeLessAppearanceAdjustUIElementsSelector: [
227                 "#comments-view-mode-selector", 
228                 "#theme-selector", 
229                 "#dark-mode-selector",
230                 "#width-selector", 
231                 "#text-size-adjustment-ui", 
232                 "#theme-tweaker-toggle", 
233                 "#appearance-adjust-ui-toggle button"
234         ].join(", "),
236         /******************/
237         /*      Infrastructure.
238          */
240         currentTheme: null,
241         currentFilters: null,
242         currentWidth: null,
243         currentTextZoom: null,
245         filtersExclusionPaths: { },
247         themeTweakStyleBlock: null,
248         textZoomStyleBlock: null,
250         /*****************/
251         /*      Functionality.
252          */
254         /*      Themes.
255          */
257         getSavedTheme: () => {
258                 return (readCookie("theme") || Appearance.defaultTheme);
259         },
261         /*      Filters (theme tweaks).
262          */
264         getSavedFilters: () => {
265                 return (JSON.parse(localStorage.getItem("theme-tweaks")) || Appearance.defaultFilters);
266         },
268         saveCurrentFilters: () => {
269                 GWLog("Appearance.saveCurrentFilters");
271                 if (Appearance.currentFilters == { })
272                         localStorage.removeItem("theme-tweaks");
273                 else
274                         localStorage.setItem("theme-tweaks", JSON.stringify(Appearance.currentFilters));
275         },
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)}; }`;
290                 }
291         
292                 //      Update the style tag.
293                 Appearance.themeTweakStyleBlock.innerHTML = fullStyleString;
295                 //      Update the current filters.
296                 Appearance.currentFilters = filters;
297         },
299         exclusionTreeFromExclusionPaths: (paths) => {
300                 if (!paths)
301                         return null;
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;
314                                 }
316                                 currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
317                         });
318                 });
320                 return tree;
321         },
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"
326                 ];
327         
328                 function selectorFromExclusionTreeNode(node, path = [ ]) {
329                         let [ value, children ] = node;
331                         let newPath = path.clone();
332                         newPath.push(value);
334                         if (!children) {
335                                 return value;
336                         } else if (children.length == 0) {
337                                 return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
338                         } else {
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(", ");
340                         }
341                 }
342         
343                 return selectorParts + ", " + selectorFromExclusionTreeNode(tree);
344         },
346         filterStringFromFilters: (filters) => {
347                 let filterString = "";
348                 for (key of Object.keys(filters)) {
349                         let value = filters[key];
350                         filterString += ` ${key}(${value})`;
351                 }
352                 return filterString;
353         },
355         /*      Content column width.
356          */
358         getSavedWidth: () => {
359                 return (localStorage.getItem("selected-width") || Appearance.defaultWidth);
360         },
362         saveCurrentWidth: () => {
363                 GWLog("Appearance.saveCurrentWidth");
365                 if (Appearance.currentWidth == "normal")
366                         localStorage.removeItem("selected-width");
367                 else
368                         localStorage.setItem("selected-width", Appearance.currentWidth);
369         },
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));
376         },
378         /*      Text zoom.
379          */
381         getSavedTextZoom: () => {
382                 return (parseFloat(localStorage.getItem("text-zoom")) || Appearance.defaultTextZoom);
383         },
385         saveCurrentTextZoom: () => {
386                 GWLog("Appearance.saveCurrentTextZoom");
388                 if (Appearance.currentTextZoom == 1.0)
389                         localStorage.removeItem("text-zoom");
390                 else
391                         localStorage.setItem("text-zoom", Appearance.currentTextZoom);
392         },
394         setTextZoom: (zoomFactor, save = true) => {
395                 GWLog("Appearance.setTextZoom");
397                 if (!zoomFactor)
398                         return;
400                 if (zoomFactor <= Appearance.minTextZoom) {
401                         zoomFactor = Appearance.minTextZoom;
402                         queryAll(".text-size-adjust-button.decrease").forEach(button => {
403                                 button.disabled = true;
404                         });
405                 } else if (zoomFactor >= Appearance.maxTextZoom) {
406                         zoomFactor = Appearance.maxTextZoom;
407                         queryAll(".text-size-adjust-button.increase").forEach(button => {
408                                 button.disabled = true;
409                         });
410                 } else {
411                         queryAll(".text-size-adjust-button").forEach(button => {
412                                 button.disabled = false;
413                         });
414                 }
416                 Appearance.currentTextZoom = zoomFactor;
418                 Appearance.textZoomStyleBlock.innerHTML = `${Appearance.textSizeAdjustTargetElementsSelector} { zoom: ${zoomFactor}; }`;
420                 if (window.generateImagesOverlay) {
421                         requestAnimationFrame(() => {
422                                 generateImagesOverlay();
423                         });
424                 }
426                 if (save)
427                         Appearance.saveCurrentTextZoom();
428         },
430         /*********/
431         /*      Setup.
432          */
434         //      Set up appearance system and apply saved settings.
435         setup: () => {
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();
452         }
455 Appearance.setup();
457 /*****************/
458 /* ANTI-KIBITZER */
459 /*****************/
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; }` + 
465         "</style>");
467         if (document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
468                 insertHeadHTML("<"+"title class='fake-title'></title>");
469         }
472 /****************/
473 /* DEBUG OUTPUT */
474 /****************/
476 function GWLog (string) {
477         if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
478                 console.log(string);
481 /****************/
482 /* MISC HELPERS */
483 /****************/
485 /*      Return the value of a GET (i.e., URL) parameter.
486         */
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)
493                         return pair[1];
494         }
496         return false;
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).
501         */
502 Element.prototype.getCommentId = function() {
503         let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
504         if (item) {
505                 return (/^comment-(.*)/.exec(item.id)||[])[1];
506         } else {
507                 return false;
508         }
511 /*******/
512 /* TOC */
513 /*******/
515 function setTOCCollapseState(collapsed = false) {
516         let TOC = query("nav.contents");
517         if (!TOC)
518                 return;
520         TOC.classList.toggle("collapsed", collapsed);
522         let button = TOC.query(".toc-collapse-toggle-button");
523         button.innerHTML = collapsed ? "&#xf0fe;" : "&#xf146;";
524         button.title = collapsed ? "Expand table of contents" : "Collapse table of contents";
527 function injectTOCCollapseToggleButton() {
528         let TOC = document.currentScript.parentElement;
529         if (!TOC)
530                 return;
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"));
540         });
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) {
554                 if (maximize) {
555                         localStorage.removeItem(storageName);
556                 } else {
557                         localStorage.setItem(storageName, true);
558                 }
559         }
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 ? "&#xf146;" : "&#xf0fe;";
566         minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
567         if (getQueryVariable("chrono") != "t") {
568                 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
569         }
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);
581         }
582         if (!comment) {
583                 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
584                 return;
585         }
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" ];
616                 else
617                         return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
618         };
619         let classMap = {
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" ] }
624         };
625         classMap.keys().forEach((testClass) => {
626                 if (this.hasClass(testClass)) {
627                         let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
628                         this.className = "";
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';
634                         return;
635                 }
636         });
639 Element.prototype.constructCommentControls = function() {
640         GWLog("constructCommentControls");
641         let commentControls = this;
643         if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
644                 return;
645         }
646         
647         let commentType = commentControls.parentElement.id.replace(/s$/, "");
648         commentControls.innerHTML = "";
649         let replyButton;
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]" : "")),
655                         "tabindex": "-1"
656                 }, {
657                         "innerHTML": (commentType == "nomination" ? "Add nomination" : "Post new " + commentType)
658                 })
659         } else {
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();
668                         });
669                 }
670                 replyButton = newElement("BUTTON", {
671                         "class": "reply-button action-button",
672                         "data-label": "Reply",
673                         "tabindex": "-1"
674                 }, {
675                         "innerHTML": "Reply"
676                 });
677         }
678         commentControls.appendChild(replyButton);
680         // Activate buttons.
681         commentControls.queryAll(".action-button").forEach(button => {
682                 button.addActivateEvent(GW.commentActionButtonClicked);
683         });
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);
692         });
693         
694         if(commentControls.query(".active-controls")) {
695                 commentControls.queryAll("button.vote").forEach(voteButton => {
696                         voteButton.addActivateEvent(voteButtonClicked);
697                 });
698         }
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);
709                 });
710         }
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"));
729         }
731         event.target.blur();
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);
743         }
746 /****************/
747 /* PRETTY DATES */
748 /****************/
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([], 
754                                   ( useLongDate ? 
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;
761         if (jsDate) {
762                 let pretty = dtf.format(new Date(+ jsDate));
763                 window.requestAnimationFrame(() => {
764                         dateElement.innerHTML = pretty;
765                         dateElement.removeClass('hide-until-init');
766                 });
767         }
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>");
774 /****************/
775 /* SERVER CALLS */
776 /****************/
778 let deferredCalls = [];
780 function callWithServerData(fname, uri) {
781         deferredCalls.push([fname, uri]);
784 /************/
785 /* TRIGGERS */
786 /************/
788 /*      Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
789 if (!window.requestIdleCallback) {
790         window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
793 GW.triggers = {};
795 function invokeTrigger(args) {
796         if(args.priority < 0) {
797                 args.fn();
798         } else if(args.priority > 0) {
799                 requestIdleCallback(args.fn, {timeout: args.priority});
800         } else {
801                 setTimeout(args.fn, 0);
802         }
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);
814         }
815         GW.triggers[name] = "done";
818 function addMultiTriggerListener(triggers, args) {
819         if(triggers.length == 1) {
820                 addTriggerListener(triggers[0], args);
821         } else {
822                 let trigger = triggers.pop();
823                 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});
824         }