New dark mode implementation, part 1.
[lw2-viewer.git] / www / head.js
blob3ee4cfc6bc87c156c50e144653378c8cfd8bdcf0
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                 [ "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" ]
219         ],
221         defaultFiltersExclusionTree: [ "#content", [ ] ],
223         defaultThemeTweakerClippyState: true,
225         defaultAppearanceAdjustUIToggleState: false,
227         themeLessAppearanceAdjustUIElementsSelector: [
228                 "#comments-view-mode-selector", 
229                 "#theme-selector", 
230                 "#dark-mode-selector",
231                 "#width-selector", 
232                 "#text-size-adjustment-ui", 
233                 "#theme-tweaker-toggle", 
234                 "#appearance-adjust-ui-toggle button"
235         ].join(", "),
237         /******************/
238         /*      Infrastructure.
239          */
241         currentTheme: null,
242         currentFilters: null,
243         currentWidth: null,
244         currentTextZoom: null,
246         filtersExclusionPaths: { },
248         themeTweakStyleBlock: null,
249         textZoomStyleBlock: null,
251         /*****************/
252         /*      Functionality.
253          */
255         /*      Themes.
256          */
258         getSavedTheme: () => {
259                 return (readCookie("theme") || Appearance.defaultTheme);
260         },
262         /*      Filters (theme tweaks).
263          */
265         getSavedFilters: () => {
266                 return (JSON.parse(localStorage.getItem("theme-tweaks")) || Appearance.defaultFilters);
267         },
269         saveCurrentFilters: () => {
270                 GWLog("Appearance.saveCurrentFilters");
272                 if (Appearance.currentFilters == { })
273                         localStorage.removeItem("theme-tweaks");
274                 else
275                         localStorage.setItem("theme-tweaks", JSON.stringify(Appearance.currentFilters));
276         },
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)}; }`;
291                 }
292         
293                 //      Update the style tag.
294                 Appearance.themeTweakStyleBlock.innerHTML = fullStyleString;
296                 //      Update the current filters.
297                 Appearance.currentFilters = filters;
298         },
300         exclusionTreeFromExclusionPaths: (paths) => {
301                 if (!paths)
302                         return null;
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;
315                                 }
317                                 currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
318                         });
319                 });
321                 return tree;
322         },
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"
327                 ];
328         
329                 function selectorFromExclusionTreeNode(node, path = [ ]) {
330                         let [ value, children ] = node;
332                         let newPath = path.clone();
333                         newPath.push(value);
335                         if (!children) {
336                                 return value;
337                         } else if (children.length == 0) {
338                                 return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
339                         } else {
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(", ");
341                         }
342                 }
343         
344                 return selectorParts + ", " + selectorFromExclusionTreeNode(tree);
345         },
347         filterStringFromFilters: (filters) => {
348                 let filterString = "";
349                 for (key of Object.keys(filters)) {
350                         let value = filters[key];
351                         filterString += ` ${key}(${value})`;
352                 }
353                 return filterString;
354         },
356         /*      Content column width.
357          */
359         getSavedWidth: () => {
360                 return (localStorage.getItem("selected-width") || Appearance.defaultWidth);
361         },
363         saveCurrentWidth: () => {
364                 GWLog("Appearance.saveCurrentWidth");
366                 if (Appearance.currentWidth == "normal")
367                         localStorage.removeItem("selected-width");
368                 else
369                         localStorage.setItem("selected-width", Appearance.currentWidth);
370         },
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));
377         },
379         /*      Text zoom.
380          */
382         getSavedTextZoom: () => {
383                 return (parseFloat(localStorage.getItem("text-zoom")) || Appearance.defaultTextZoom);
384         },
386         saveCurrentTextZoom: () => {
387                 GWLog("Appearance.saveCurrentTextZoom");
389                 if (Appearance.currentTextZoom == 1.0)
390                         localStorage.removeItem("text-zoom");
391                 else
392                         localStorage.setItem("text-zoom", Appearance.currentTextZoom);
393         },
395         setTextZoom: (zoomFactor, save = true) => {
396                 GWLog("Appearance.setTextZoom");
398                 if (!zoomFactor)
399                         return;
401                 if (zoomFactor <= Appearance.minTextZoom) {
402                         zoomFactor = Appearance.minTextZoom;
403                         queryAll(".text-size-adjust-button.decrease").forEach(button => {
404                                 button.disabled = true;
405                         });
406                 } else if (zoomFactor >= Appearance.maxTextZoom) {
407                         zoomFactor = Appearance.maxTextZoom;
408                         queryAll(".text-size-adjust-button.increase").forEach(button => {
409                                 button.disabled = true;
410                         });
411                 } else {
412                         queryAll(".text-size-adjust-button").forEach(button => {
413                                 button.disabled = false;
414                         });
415                 }
417                 Appearance.currentTextZoom = zoomFactor;
419                 Appearance.textZoomStyleBlock.innerHTML = `${Appearance.textSizeAdjustTargetElementsSelector} { zoom: ${zoomFactor}; }`;
421                 if (window.generateImagesOverlay) {
422                         requestAnimationFrame(() => {
423                                 generateImagesOverlay();
424                         });
425                 }
427                 if (save)
428                         Appearance.saveCurrentTextZoom();
429         },
431         /*********/
432         /*      Setup.
433          */
435         //      Set up appearance system and apply saved settings.
436         setup: () => {
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();
453         }
456 Appearance.setup();
458 /*****************/
459 /* ANTI-KIBITZER */
460 /*****************/
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; }` + 
466         "</style>");
468         if (document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
469                 insertHeadHTML("<"+"title class='fake-title'></title>");
470         }
473 /****************/
474 /* DEBUG OUTPUT */
475 /****************/
477 function GWLog (string) {
478         if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
479                 console.log(string);
482 /****************/
483 /* MISC HELPERS */
484 /****************/
486 /*      Return the value of a GET (i.e., URL) parameter.
487         */
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)
494                         return pair[1];
495         }
497         return false;
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).
502         */
503 Element.prototype.getCommentId = function() {
504         let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
505         if (item) {
506                 return (/^comment-(.*)/.exec(item.id)||[])[1];
507         } else {
508                 return false;
509         }
512 /*******/
513 /* TOC */
514 /*******/
516 function setTOCCollapseState(collapsed = false) {
517         let TOC = query("nav.contents");
518         if (!TOC)
519                 return;
521         TOC.classList.toggle("collapsed", collapsed);
523         let button = TOC.query(".toc-collapse-toggle-button");
524         button.innerHTML = collapsed ? "&#xf0fe;" : "&#xf146;";
525         button.title = collapsed ? "Expand table of contents" : "Collapse table of contents";
528 function injectTOCCollapseToggleButton() {
529         let TOC = document.currentScript.parentElement;
530         if (!TOC)
531                 return;
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"));
541         });
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) {
555                 if (maximize) {
556                         localStorage.removeItem(storageName);
557                 } else {
558                         localStorage.setItem(storageName, true);
559                 }
560         }
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 ? "&#xf146;" : "&#xf0fe;";
567         minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
568         if (getQueryVariable("chrono") != "t") {
569                 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
570         }
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);
582         }
583         if (!comment) {
584                 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
585                 return;
586         }
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" ];
617                 else
618                         return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
619         };
620         let classMap = {
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" ] }
625         };
626         classMap.keys().forEach((testClass) => {
627                 if (this.hasClass(testClass)) {
628                         let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
629                         this.className = "";
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';
635                         return;
636                 }
637         });
640 Element.prototype.constructCommentControls = function() {
641         GWLog("constructCommentControls");
642         let commentControls = this;
644         if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
645                 return;
646         }
647         
648         let commentType = commentControls.parentElement.id.replace(/s$/, "");
649         commentControls.innerHTML = "";
650         let replyButton;
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]" : "")),
656                         "tabindex": "-1"
657                 }, {
658                         "innerHTML": (commentType == "nomination" ? "Add nomination" : "Post new " + commentType)
659                 })
660         } else {
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();
669                         });
670                 }
671                 replyButton = newElement("BUTTON", {
672                         "class": "reply-button action-button",
673                         "data-label": "Reply",
674                         "tabindex": "-1"
675                 }, {
676                         "innerHTML": "Reply"
677                 });
678         }
679         commentControls.appendChild(replyButton);
681         // Activate buttons.
682         commentControls.queryAll(".action-button").forEach(button => {
683                 button.addActivateEvent(GW.commentActionButtonClicked);
684         });
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);
693         });
694         
695         if(commentControls.query(".active-controls")) {
696                 commentControls.queryAll("button.vote").forEach(voteButton => {
697                         voteButton.addActivateEvent(voteButtonClicked);
698                 });
699         }
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);
710                 });
711         }
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"));
730         }
732         event.target.blur();
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);
744         }
747 /****************/
748 /* PRETTY DATES */
749 /****************/
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([], 
755                                   ( useLongDate ? 
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;
762         if (jsDate) {
763                 let pretty = dtf.format(new Date(+ jsDate));
764                 window.requestAnimationFrame(() => {
765                         dateElement.innerHTML = pretty;
766                         dateElement.removeClass('hide-until-init');
767                 });
768         }
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>");
775 /****************/
776 /* SERVER CALLS */
777 /****************/
779 let deferredCalls = [];
781 function callWithServerData(fname, uri) {
782         deferredCalls.push([fname, uri]);
785 /************/
786 /* TRIGGERS */
787 /************/
789 /*      Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
790 if (!window.requestIdleCallback) {
791         window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
794 GW.triggers = {};
796 function invokeTrigger(args) {
797         if(args.priority < 0) {
798                 args.fn();
799         } else if(args.priority > 0) {
800                 requestIdleCallback(args.fn, {timeout: args.priority});
801         } else {
802                 setTimeout(args.fn, 0);
803         }
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);
815         }
816         GW.triggers[name] = "done";
819 function addMultiTriggerListener(triggers, args) {
820         if(triggers.length == 1) {
821                 addTriggerListener(triggers[0], args);
822         } else {
823                 let trigger = triggers.pop();
824                 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});
825         }