Fix content width code bug
[lw2-viewer.git] / www / head.js
blob5bf06d9ead1e3532b02295fbd70d2809dcf0eba2
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))
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 /* COMMENT THREAD MINIMIZE BUTTONS */
514 /***********************************/
516 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
517         GWLog("setCommentThreadMaximized");
518         let commentItem = this;
519         let storageName = "thread-minimized-" + commentItem.getCommentId();
520         let minimize_button = commentItem.query(".comment-minimize-button");
521         let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !(localStorage.getItem(storageName) || commentItem.hasClass("ignored")));
522         if (userOriginated) {
523                 if (maximize) {
524                         localStorage.removeItem(storageName);
525                 } else {
526                         localStorage.setItem(storageName, true);
527                 }
528         }
530         commentItem.style.height = maximize ? 'auto' : '38px';
531         commentItem.style.overflow = maximize ? 'visible' : 'hidden';
533         minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
534         minimize_button.innerHTML = maximize ? "&#xf146;" : "&#xf0fe;";
535         minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
536         if (getQueryVariable("chrono") != "t") {
537                 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
538         }
541 /*****************************/
542 /* MINIMIZED THREAD HANDLING */
543 /*****************************/
545 function expandAncestorsOf(comment) {
546         GWLog("expandAncestorsOf");
547         if (typeof comment == "string") {
548                 comment = /(?:comment-)?(.+)/.exec(comment)[1];
549                 comment = query("#comment-" + comment);
550         }
551         if (!comment) {
552                 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
553                 return;
554         }
556         // Expand collapsed comment threads.
557         let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
558         if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
560         // Expand collapsed comments.
561         let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
562         if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
565 /********************/
566 /* COMMENT CONTROLS */
567 /********************/
569 /*      Adds an event listener to a button (or other clickable element), attaching 
570         it to both "click" and "keyup" events (for use with keyboard navigation).
571         Optionally also attaches the listener to the 'mousedown' event, making the 
572         element activate on mouse down instead of mouse up. */
573 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
574         let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
575         if (includeMouseDown) this.addEventListener("mousedown", ael);
576         this.addEventListener("click", ael);
577         this.addEventListener("keyup", ael);
580 Element.prototype.updateCommentControlButton = function() {
581         GWLog("updateCommentControlButton");
582         let retractFn = () => {
583                 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
584                         return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
585                 else
586                         return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
587         };
588         let classMap = {
589                 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
590                 "retract-button": retractFn,
591                 "unretract-button": retractFn,
592                 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
593         };
594         classMap.keys().forEach((testClass) => {
595                 if (this.hasClass(testClass)) {
596                         let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
597                         this.className = "";
598                         this.addClasses([ buttonClass, "action-button" ]);
599                         if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
600                         this.dataset.label = buttonLabel;
601                         this.title = buttonAltText;
602                         this.tabIndex = '-1';
603                         return;
604                 }
605         });
608 Element.prototype.constructCommentControls = function() {
609         GWLog("constructCommentControls");
610         let commentControls = this;
612         if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
613                 return;
614         }
615         
616         let commentType = commentControls.parentElement.id.replace(/s$/, "");
617         commentControls.innerHTML = "";
618         let replyButton;
619         if (commentControls.parentElement.hasClass("comments")) {
620                 replyButton = newElement("BUTTON", {
621                         "class": "new-comment-button action-button",
622                         "accesskey": (commentType == "comment" ? "n" : ""),
623                         "title": ("Post new " + commentType + (commentType == "comment" ? " [n]" : "")),
624                         "tabindex": "-1"
625                 }, {
626                         "innerHTML": (commentType == "nomination" ? "Add nomination" : "Post new " + commentType)
627                 })
628         } else {
629                 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
630                         let buttonsList = [];
631                         if (!commentControls.parentElement.query(".comment-thread"))
632                                 buttonsList.push("delete-button");
633                         buttonsList.push("retract-button", "edit-button");
634                         buttonsList.forEach(buttonClass => {
635                                 let button = commentControls.appendChild(newElement("BUTTON", { "class": buttonClass }));
636                                 button.updateCommentControlButton();
637                         });
638                 }
639                 replyButton = newElement("BUTTON", {
640                         "class": "reply-button action-button",
641                         "data-label": "Reply",
642                         "tabindex": "-1"
643                 }, {
644                         "innerHTML": "Reply"
645                 });
646         }
647         commentControls.appendChild(replyButton);
649         // Activate buttons.
650         commentControls.queryAll(".action-button").forEach(button => {
651                 button.addActivateEvent(GW.commentActionButtonClicked);
652         });
654         // Replicate voting controls at the bottom of comments.
655         if (commentControls.parentElement.hasClass("comments")) return;
656         let votingControls = commentControls.parentElement.queryAll(".comment-meta .voting-controls");
657         if (!votingControls) return;
658         votingControls.forEach(control => {
659                 let controlCloned = control.cloneNode(true);
660                 commentControls.appendChild(controlCloned);
661         });
662         
663         if(commentControls.query(".active-controls")) {
664                 commentControls.queryAll("button.vote").forEach(voteButton => {
665                         voteButton.addActivateEvent(voteButtonClicked);
666                 });
667         }
670 GW.commentActionButtonClicked = (event) => {
671         GWLog("commentActionButtonClicked");
672         if (event.target.hasClass("edit-button") ||
673                 event.target.hasClass("reply-button") ||
674                 event.target.hasClass("new-comment-button")) {
675                 queryAll("textarea").forEach(textarea => {
676                         let commentControls = textarea.closest(".comment-controls");
677                         if(commentControls) hideReplyForm(commentControls);
678                 });
679         }
681         if (event.target.hasClass("delete-button")) {
682                 let commentItem = event.target.closest(".comment-item");
683                 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
684                                         "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" + 
685                                         "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" + 
686                                         "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
687                         doCommentAction("delete", commentItem);
688         } else if (event.target.hasClass("retract-button")) {
689                 doCommentAction("retract", event.target.closest(".comment-item"));
690         } else if (event.target.hasClass("unretract-button")) {
691                 doCommentAction("unretract", event.target.closest(".comment-item"));
692         } else if (event.target.hasClass("edit-button")) {
693                 showCommentEditForm(event.target.closest(".comment-item"));
694         } else if (event.target.hasClass("reply-button")) {
695                 showReplyForm(event.target.closest(".comment-item"));
696         } else if (event.target.hasClass("new-comment-button")) {
697                 showReplyForm(event.target.closest(".comments"));
698         }
700         event.target.blur();
703 function initializeCommentControls() {
704         e = newElement("DIV", { "class": "comment-controls posting-controls" });
705         document.currentScript.insertAdjacentElement("afterend", e);
706         e.constructCommentControls();
708         if (window.location.hash) {
709                 let comment = e.closest(".comment-item");
710                 if(comment && window.location.hash == "#" + comment.id)
711                         expandAncestorsOf(comment);
712         }
715 /****************/
716 /* PRETTY DATES */
717 /****************/
719 // If the viewport is wide enough to fit the desktop-size content column,
720 // use a long date format; otherwise, a short one.
721 let useLongDate = window.innerWidth > 900;
722 let dtf = new Intl.DateTimeFormat([], 
723                                   ( useLongDate ? 
724                                     { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
725                                     : { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' } ));
727 function prettyDate() {
728         let dateElement = document.currentScript.parentElement;
729         let jsDate = dateElement.dataset.jsDate;
730         if (jsDate) {
731                 let pretty = dtf.format(new Date(+ jsDate));
732                 window.requestAnimationFrame(() => {
733                         dateElement.innerHTML = pretty;
734                         dateElement.removeClass('hide-until-init');
735                 });
736         }
740 // Hide elements that require javascript until ready.
741 insertHeadHTML("<style>.only-without-js { display: none; }</style><style id='hide-until-init'>.hide-until-init { visibility: hidden; }</style>");
743 /****************/
744 /* SERVER CALLS */
745 /****************/
747 let deferredCalls = [];
749 function callWithServerData(fname, uri) {
750         deferredCalls.push([fname, uri]);
753 /************/
754 /* TRIGGERS */
755 /************/
757 /*      Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
758 if (!window.requestIdleCallback) {
759         window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
762 GW.triggers = {};
764 function invokeTrigger(args) {
765         if(args.priority < 0) {
766                 args.fn();
767         } else if(args.priority > 0) {
768                 requestIdleCallback(args.fn, {timeout: args.priority});
769         } else {
770                 setTimeout(args.fn, 0);
771         }
774 function addTriggerListener(name, args) {
775         if(typeof(GW.triggers[name])=="string") return invokeTrigger(args);
776         if(!GW.triggers[name]) GW.triggers[name] = [];
777         GW.triggers[name].push(args);
780 function activateTrigger(name) {
781         if(Array.isArray(GW.triggers[name])) {
782                 GW.triggers[name].forEach(invokeTrigger);
783         }
784         GW.triggers[name] = "done";
787 function addMultiTriggerListener(triggers, args) {
788         if(triggers.length == 1) {
789                 addTriggerListener(triggers[0], args);
790         } else {
791                 let trigger = triggers.pop();
792                 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});
793         }