Fix JS error when Reply is clicked before userStatus is loaded.
[lw2-viewer.git] / www / script.js
blobffeebe784da2ed73a2e60ebf897a193532203d11
1 /***************************/
2 /* INITIALIZATION REGISTRY */
3 /***************************/
5 /*      TBC. */
6 GW.initializersDone = { };
7 GW.initializers = { };
8 function registerInitializer(name, tryEarly, precondition, fn) {
9         GW.initializersDone[name] = false;
10         GW.initializers[name] = fn;
11         let wrapper = function () {
12                 if (GW.initializersDone[name]) return;
13                 if (!precondition()) {
14                         if (tryEarly) {
15                                 setTimeout(() => requestIdleCallback(wrapper, {timeout: 1000}), 50);
16                         } else {
17                                 document.addEventListener("readystatechange", wrapper, {once: true});
18                         }
19                         return;
20                 }
21                 GW.initializersDone[name] = true;
22                 fn();
23         };
24         if (tryEarly) {
25                 requestIdleCallback(wrapper, {timeout: 1000});
26         } else {
27                 document.addEventListener("readystatechange", wrapper, {once: true});
28                 requestIdleCallback(wrapper);
29         }
31 function forceInitializer(name) {
32         if (GW.initializersDone[name]) return;
33         GW.initializersDone[name] = true;
34         GW.initializers[name]();
37 /***********/
38 /* COOKIES */
39 /***********/
41 /*      Sets a cookie. */
42 function setCookie(name, value, days) {
43         var expires = "";
44         if (!days) days = 36500;
45         if (days) {
46                 var date = new Date();
47                 date.setTime(date.getTime() + (days*24*60*60*1000));
48                 expires = "; expires=" + date.toUTCString();
49         }
50         document.cookie = name + "=" + (value || "")  + expires + "; path=/; SameSite=Lax" + (GW.secureCookies ? "; Secure" : "");
53 /*******************************/
54 /* EVENT LISTENER MANIPULATION */
55 /*******************************/
57 /*      Removes event listener from a clickable element, automatically detaching it
58         from all relevant event types. */
59 Element.prototype.removeActivateEvent = function() {
60         let ael = this.activateEventListener;
61         this.removeEventListener("mousedown", ael);
62         this.removeEventListener("click", ael);
63         this.removeEventListener("keyup", ael);
66 /*      Adds a scroll event listener to the page. */
67 function addScrollListener(fn, name) {
68         let wrapper = (event) => {
69                 requestAnimationFrame(() => {
70                         fn(event);
71                         document.addEventListener("scroll", wrapper, {once: true, passive: true});
72                 });
73         }
74         document.addEventListener("scroll", wrapper, {once: true, passive: true});
76         // Retain a reference to the scroll listener, if a name is provided.
77         if (typeof name != "undefined")
78                 GW[name] = wrapper;
81 /****************/
82 /* MISC HELPERS */
83 /****************/
85 // Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=325942
86 Element.prototype.scrollIntoView = function(realSIV) {
87         return function(bottom) {
88                 realSIV.call(this, bottom);
89                 if(fixTarget = this.closest("input[id^='expand'] ~ .comment-thread")) {
90                         window.scrollBy(0, fixTarget.scrollTop);
91                         fixTarget.scrollTop = 0;
92                 }
93         }
94 }(Element.prototype.scrollIntoView);
96 /*      If top of element is not at or above the top of the screen, scroll it into
97         view. */
98 Element.prototype.scrollIntoViewIfNeeded = function() {
99         GWLog("scrollIntoViewIfNeeded");
100         if (this.getBoundingClientRect().bottom > window.innerHeight && 
101                 this.getBoundingClientRect().top > 0) {
102                 this.scrollIntoView(false);
103         }
106 function urlEncodeQuery(params) {
107         return params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params[x])}).join("&");
110 function handleAjaxError(event) {
111         if(event.target.getResponseHeader("Content-Type") === "application/json") console.log("doAjax error: " + JSON.parse(event.target.responseText)["error"]);
112         else console.log("doAjax error: Something bad happened :(");
115 function doAjax(params) {
116         let req = new XMLHttpRequest();
117         let requestMethod = params["method"] || "GET";
118         req.addEventListener("load", (event) => {
119                 if(event.target.status < 400) {
120                         if(params["onSuccess"]) params.onSuccess(event);
121                 } else {
122                         if(params["onFailure"]) params.onFailure(event);
123                         else handleAjaxError(event);
124                 }
125                 if(params["onFinish"]) params.onFinish(event);
126         });
127         req.open(requestMethod, (params.location || document.location) + ((requestMethod == "GET" && params.params) ? "?" + urlEncodeQuery(params.params) : ""));
128         if(requestMethod == "POST") {
129                 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
130                 params["params"]["csrf-token"] = GW.csrfToken;
131                 req.send(urlEncodeQuery(params.params));
132         } else {
133                 req.send();
134         }
137 function activateReadyStateTriggers() {
138         if(document.readyState == 'interactive') {
139                 activateTrigger('DOMReady');
140         } else if(document.readyState == 'complete') {
141                 activateTrigger('DOMReady');
142                 activateTrigger('DOMComplete');
143         }
146 document.addEventListener('readystatechange', activateReadyStateTriggers);
147 activateReadyStateTriggers();
149 function callWithServerData(fname, uri) {
150         setTimeout(function () {
151                 doAjax({
152                         location: uri,
153                         onSuccess: (event) => {
154                                 let response = JSON.parse(event.target.responseText);
155                                 window[fname](response);
156                         }
157                 });
158         });
161 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
162 deferredCalls = null;
164 /*      Return the currently selected text, as HTML (rather than unstyled text).
165         */
166 function getSelectionHTML() {
167         let container = newElement("DIV");
168         container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
169         return container.innerHTML;
172 /*      Given an HTML string, creates an element from that HTML, adds it to 
173         #ui-elements-container (creating the latter if it does not exist), and 
174         returns the created element.
175         */
176 function addUIElement(element_html) {
177         let ui_elements_container = query("#ui-elements-container");
178         if (ui_elements_container == null)
179                 ui_elements_container = document.body.appendChild(newElement("NAV", { "id": "ui-elements-container" }));
181         ui_elements_container.insertAdjacentHTML("beforeend", element_html);
182         return ui_elements_container.lastElementChild;
185 /*      Given an element or a selector, removes that element (or the element 
186         identified by the selector).
187         If multiple elements match the selector, only the first is removed.
188         */
189 function removeElement(elementOrSelector, ancestor = document) {
190         if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
191         if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
194 /*      Returns true if the string begins with the given prefix.
195         */
196 String.prototype.hasPrefix = function (prefix) {
197         return (this.lastIndexOf(prefix, 0) === 0);
200 /*      Toggles whether the page is scrollable.
201         */
202 function togglePageScrolling(enable) {
203         if (!enable) {
204                 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
205                 document.body.addClass("no-scroll");
206                 document.body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
207         } else {
208                 document.body.removeClass("no-scroll");
209                 document.body.removeAttribute("style");
210                 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
211         }
214 DOMRectReadOnly.prototype.isInside = function (x, y) {
215         return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
218 /*      Simple mutex mechanism.
219  */
220 function doIfAllowed(f, passHolder, passName, releaseImmediately = false) {
221         if (passHolder[passName] == false)
222                 return;
224         passHolder[passName] = false;
226         f();
228         if (releaseImmediately) {
229                 passHolder[passName] = true;
230         } else {
231                 requestAnimationFrame(() => {
232                         passHolder[passName] = true;
233                 });
234         }
237 /*******************/
238 /* COPY PROCESSORS */
239 /*******************/
241 /*********************************************************************/
242 /*  Workaround for Firefox weirdness, based on more Firefox weirdness.
243  */
244 DocumentFragment.prototype.getSelection = function () {
245         return document.getSelection();
248 /******************************************************************************/
249 /*  Returns true if the node contains only whitespace and/or other empty nodes.
250  */
251 function isNodeEmpty(node) {
252         if (node.nodeType == Node.TEXT_NODE)
253                 return (node.textContent.match(/\S/) == null);
255         if (   node.nodeType == Node.ELEMENT_NODE
256                 && [ "IMG", "VIDEO", "AUDIO", "IFRAME", "OBJECT" ].includes(node.tagName))
257                 return false;
259         if (node.childNodes.length == 0)
260                 return true;
262         for (childNode of node.childNodes)
263                 if (isNodeEmpty(childNode) == false)
264                         return false;
266         return true;
269 /***************************************************************/
270 /*  Returns a DocumentFragment containing the current selection.
271  */
272 function getSelectionAsDocument(doc = document) {
273         let docFrag = doc.getSelection().getRangeAt(0).cloneContents();
275         //      Strip whitespace (remove top-level empty nodes).
276         let nodesToRemove = [ ];
277         docFrag.childNodes.forEach(node => {
278                 if (isNodeEmpty(node))
279                         nodesToRemove.push(node);
280         });
281         nodesToRemove.forEach(node => {
282                 docFrag.removeChild(node);
283         });
285         return docFrag;
288 /*****************************************************************************/
289 /*  Adds the given copy processor, appending it to the existing array thereof.
291     Each copy processor should take two arguments: the copy event, and the
292     DocumentFragment which holds the selection as it is being processed by each
293     successive copy processor.
295     A copy processor should return true if processing should continue after it’s
296     done, false otherwise (e.g. if it has entirely replaced the contents of the
297     selection object with what the final clipboard contents should be).
298  */
299 function addCopyProcessor(processor) {
300         if (GW.copyProcessors == null)
301                 GW.copyProcessors = [ ];
303         GW.copyProcessors.push(processor);
306 /******************************************************************************/
307 /*  Set up the copy processor system by registering a ‘copy’ event handler to
308     call copy processors. (Must be set up for the main document, and separately
309     for any shadow roots.)
310  */
311 function registerCopyProcessorsForDocument(doc) {
312         GWLog("registerCopyProcessorsForDocument", "rewrite.js", 1);
314         doc.addEventListener("copy", (event) => {
315                 if (   GW.copyProcessors == null
316                         || GW.copyProcessors.length == 0)
317                         return;
319                 // Don't apply copy processors to input fields.
320                 if (({'TEXTAREA': true, 'INPUT': true})[document.activeElement.tagName]) {
321                         return;
322                 }
324                 event.preventDefault();
325                 event.stopPropagation();
327                 let selection = getSelectionAsDocument(doc);
329                 let i = 0;
330                 while (   i < GW.copyProcessors.length
331                           && GW.copyProcessors[i++](event, selection));
333                 // This is necessary for .innerText to work properly.
334                 let wrapper = newElement("DIV");
335                 wrapper.appendChild(selection);
336                 document.body.appendChild(wrapper);
338                 let makeLinksAbsolute = (node) => {
339                         if(node['attributes']) {
340                                 for(attr of ['src', 'href']) {
341                                         if(node[attr])
342                                                 node[attr] = node[attr];
343                                 }
344                         }
345                         node.childNodes.forEach(makeLinksAbsolute);
346                 }
347                 makeLinksAbsolute(wrapper);
349                 event.clipboardData.setData("text/plain", wrapper.innerText);
350                 event.clipboardData.setData("text/html", wrapper.innerHTML);
352                 document.body.removeChild(wrapper);
353         });
356 /*******************************************/
357 /*  Set up copy processors in main document.
358  */
359 registerCopyProcessorsForDocument(document);
361 /*****************************************************************************/
362 /*  Makes it so that copying a rendered equation or other math element copies
363     the LaTeX source, instead of the useless gibberish that is the contents of
364     the text nodes of the HTML representation of the equation.
365  */
366 addCopyProcessor((event, selection) => {
367         if (event.target.closest(".mjx-math")) {
368                 selection.replaceChildren(event.target.closest(".mjx-math").getAttribute("aria-label"));
370                 return false;
371         }
373         selection.querySelectorAll(".mjx-chtml").forEach(mathBlock => {
374                 mathBlock.innerHTML = " " + mathBlock.querySelector(".mjx-math").getAttribute("aria-label") + " ";
375         });
377         return true;
380 /************************************************************************/
381 /*  Remove soft hyphens and other extraneous characters from copied text.
382  */
383 addCopyProcessor((event, selection) => {
384         let replaceText = (node) => {
385                 if(node.nodeType == Node.TEXT_NODE) {
386                         node.nodeValue = node.nodeValue.replace(/\u00AD|\u200b/g, "");
387                 }
389                 node.childNodes.forEach(replaceText);
390         }
391         replaceText(selection);
393         return true;
397 /********************/
398 /* DEBUGGING OUTPUT */
399 /********************/
401 GW.enableLogging = (permanently = false) => {
402         if (permanently)
403                 localStorage.setItem("logging-enabled", "true");
404         else
405                 GW.loggingEnabled = true;
407 GW.disableLogging = (permanently = false) => {
408         if (permanently)
409                 localStorage.removeItem("logging-enabled");
410         else
411                 GW.loggingEnabled = false;
414 /*******************/
415 /* INBOX INDICATOR */
416 /*******************/
418 function processUserStatus(userStatus) {
419         window.userStatus = userStatus;
420         if(userStatus) {
421                 if(userStatus.notifications) {
422                         let element = query('#inbox-indicator');
423                         element.className = 'new-messages';
424                         element.title = 'New messages [o]';
425                 }
426         } else {
427                 location.reload();
428         }
429         activateTrigger("userStatusReady");
432 /**************/
433 /* COMMENTING */
434 /**************/
436 function toggleMarkdownHintsBox() {
437         GWLog("toggleMarkdownHintsBox");
438         let markdownHintsBox = query("#markdown-hints");
439         markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
441 function hideMarkdownHintsBox() {
442         GWLog("hideMarkdownHintsBox");
443         let markdownHintsBox = query("#markdown-hints");
444         if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
447 Element.prototype.addTextareaFeatures = function() {
448         GWLog("addTextareaFeatures");
449         let textarea = this;
451         textarea.addEventListener("focus", GW.textareaFocused = (event) => {
452                 GWLog("GW.textareaFocused");
453                 event.target.closest("form").scrollIntoViewIfNeeded();
454         });
455         textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
456                 GWLog("GW.textareaInputReceived");
457                 if (window.innerWidth > 520) {
458                         // Expand textarea if needed.
459                         expandTextarea(textarea);
460                 } else {
461                         // Remove markdown hints.
462                         hideMarkdownHintsBox();
463                         query(".guiedit-mobile-help-button").removeClass("active");
464                 }
465                 // User mentions autocomplete
466                 if(!userAutocomplete &&
467                    textarea.value.charAt(textarea.selectionStart - 1) === "@" &&
468                    (textarea.selectionStart === 1 ||
469                     !textarea.value.charAt(textarea.selectionStart - 2).match(/[a-zA-Z0-9]/))) {
470                         beginAutocompletion(textarea, textarea.selectionStart);
471                 }
472         }, false);
473         textarea.addEventListener("click", (event) => {
474                 if(!userAutocomplete) {
475                         let start = textarea.selectionStart, end = textarea.selectionEnd;
476                         let value = textarea.value;
477                         if (start <= 1) return;
478                         for (; value.charAt(start - 1) != "@"; start--) {
479                                 if (start <= 1) return;
480                                 if (value.charAt(start - 1) == " ") return;
481                         }
482                         for(; end < value.length && value.charAt(end) != " "; end++) { true }
483                         beginAutocompletion(textarea, start, end);
484                 }
485         });
487         textarea.addEventListener("paste", (event) => {
488                 let html = event.clipboardData.getData("text/html");
489                 if(html) {
490                         html = html.replace(/\n|\r/gm, "");
491                         let isQuoted = textarea.selectionStart >= 2 &&
492                             textarea.value.substring(textarea.selectionStart - 2, textarea.selectionStart) == "> ";
493                         document.execCommand("insertText", false, MarkdownFromHTML(html, (isQuoted ? "> " : null)));
494                         event.preventDefault();
495                 }
496         });
498         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
499         textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
500         textarea.addEventListener("keydown", (event) => {
501                 // Special case for alt+4
502                 // Generalize this before adding more.
503                 if(event.altKey && event.key === '4') {
504                         insertMarkup(event, "$", "$", "LaTeX formula");
505                         event.stopPropagation();
506                         event.preventDefault();
507                 }
508         });
510         let form = textarea.closest("form");
512         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
513         let textareaContainer = textarea.closest(".textarea-container");
514         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
515         for (var button of GW.guiEditButtons) {
516                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
517                 buttons_container.insertAdjacentHTML("beforeend", 
518                         "<button type='button' class='guiedit guiedit-" 
519                         + name
520                         + "' tabindex='-1'"
521                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
522                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
523                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
524                         + " onclick='insertMarkup(event,"
525                         + ((typeof m_before_or_func == 'function') ?
526                                 m_before_or_func.name : 
527                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
528                         + ");'><div>"
529                         + icon
530                         + "</div></button>"
531                 );
532         }
534         var markdown_hints = 
535         `<input type='checkbox' id='markdown-hints-checkbox'>
536         <label for='markdown-hints-checkbox'></label>
537         <div id='markdown-hints'>` + 
538         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
539                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
540                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
541                 "<span>Heading 1</span><code># Heading 1</code>",
542                 "<span>Heading 2</span><code>## Heading 1</code>",
543                 "<span>Heading 3</span><code>### Heading 1</code>",
544                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
545         `</div>`;
546         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
548         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
549                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
550                         GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
551                         if (button.hasClass("guiedit-mobile-help-button")) {
552                                 toggleMarkdownHintsBox();
553                                 event.target.toggleClass("active");
554                                 query(".posting-controls:focus-within textarea").focus();
555                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
556                                 event.target.blur();
557                                 hideMarkdownHintsBox();
558                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
559                         }
560                 });
561         });
563         // On smartphone (narrow mobile) screens, when a textarea is focused (and
564         // automatically fullscreened), remove all the filters from the page, and 
565         // then apply them *just* to the fixed editor UI elements. This is in order
566         // to get around the “children of elements with a filter applied cannot be
567         // fixed” issue.
568         if (GW.isMobile && window.innerWidth <= 520) {
569                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
570                 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
571                         GWLog("GW.textareaFocusedMobile");
572                         Appearance.savedFilters = Appearance.currentFilters;
573                         Appearance.applyFilters(Appearance.noFilters);
574                         fixedEditorElements.forEach(element => {
575                                 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
576                         });
577                 });
578                 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
579                         GWLog("GW.textareaBlurredMobile");
580                         requestAnimationFrame(() => {
581                                 Appearance.applyFilters(Appearance.savedFilters);
582                                 Appearance.savedFilters = null;
583                                 fixedEditorElements.forEach(element => {
584                                         element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
585                                 });
586                         });
587                 });
588         }
591 Element.prototype.injectReplyForm = function(editMarkdownSource) {
592         GWLog("injectReplyForm");
593         let commentControls = this;
594         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
595         let postId = commentControls.parentElement.dataset["postId"];
596         let tagId = commentControls.parentElement.dataset["tagId"];
597         let withparent = (!editMarkdownSource && commentControls.getCommentId());
598         let answer = commentControls.parentElement.id == "answers";
599         let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
600         let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
601         let parentCommentItem = commentControls.closest(".comment-item");
602         let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
603             (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
604         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
605                 "<form method='post'>" + 
606                 "<div class='textarea-container'>" + 
607                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
608                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
609                 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
610                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
611                 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
612                 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
613                 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
614                 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
615                 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
616                 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
617                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
618                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
619                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
620                 "</div><div>" + 
621                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
622                 "<input type='submit' value='Submit'>" + 
623                 "</div></form>";
624         commentControls.onsubmit = disableBeforeUnload;
626         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
627                 GWLog("GW.cancelCommentButtonClicked");
628                 hideReplyForm(event.target.closest(".comment-controls"));
629         });
630         commentControls.scrollIntoViewIfNeeded();
631         commentControls.query("form").onsubmit = (event) => {
632                 if (!event.target.text.value) {
633                         alert("Please enter a comment.");
634                         return false;
635                 }
636         }
637         let textarea = commentControls.query("textarea");
638         if(editMarkdownSource) textarea.value = editMarkdownSource;
639         textarea.addTextareaFeatures();
640         textarea.focus();
643 function showCommentEditForm(commentItem) {
644         GWLog("showCommentEditForm");
646         addTriggerListener("userStatusReady", {priority: -1, fn: () => {
647                 let commentBody = commentItem.query(".comment-body");
648                 commentBody.style.display = "none";
650                 let commentControls = commentItem.query(".comment-controls");
651                 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
652                 commentControls.query("form").addClass("edit-existing-comment");
653                 expandTextarea(commentControls.query("textarea"));
654         }});
657 function showReplyForm(commentItem) {
658         GWLog("showReplyForm");
660         addTriggerListener("userStatusReady", {priority: -1, fn: () => {
661                 let commentControls = commentItem.query(".comment-controls");
662                 commentControls.injectReplyForm(commentControls.dataset.enteredText);
663         }});
666 function hideReplyForm(commentControls) {
667         GWLog("hideReplyForm");
668         // Are we editing a comment? If so, un-hide the existing comment body.
669         let containingComment = commentControls.closest(".comment-item");
670         if (containingComment) containingComment.query(".comment-body").style.display = "";
672         let enteredText = commentControls.query("textarea").value;
673         if (enteredText) commentControls.dataset.enteredText = enteredText;
675         disableBeforeUnload();
676         commentControls.constructCommentControls();
679 function expandTextarea(textarea) {
680         GWLog("expandTextarea");
681         if (window.innerWidth <= 520) return;
683         let totalBorderHeight = 30;
684         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
686         requestAnimationFrame(() => {
687                 textarea.style.height = 'auto';
688                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
689                 if (textarea.clientHeight < window.innerHeight) {
690                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
691                 }
692         });
695 function doCommentAction(action, commentItem) {
696         GWLog("doCommentAction");
697         let params = {};
698         params[(action + "-comment-id")] = commentItem.getCommentId();
699         doAjax({
700                 method: "POST",
701                 params: params,
702                 onSuccess: GW.commentActionPostSucceeded = (event) => {
703                         GWLog("GW.commentActionPostSucceeded");
704                         let fn = {
705                                 retract: () => { commentItem.firstChild.addClass("retracted") },
706                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
707                                 delete: () => {
708                                         commentItem.firstChild.outerHTML = "<div class=\"comment deleted-comment\"><div class=\"comment-meta\"><span class=\"deleted-meta\">[ ]</span></div><div class=\"comment-body\">[deleted]</div></div>";
709                                         commentItem.removeChild(commentItem.query(".comment-controls"));
710                                 }
711                         }[action];
712                         if(fn) fn();
713                         if(action != "delete")
714                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
715                 }
716         });
719 /**********/
720 /* VOTING */
721 /**********/
723 function parseVoteType(voteType) {
724         GWLog("parseVoteType");
725         let value = {};
726         if (!voteType) return value;
727         value.up = /[Uu]pvote$/.test(voteType);
728         value.down = /[Dd]ownvote$/.test(voteType);
729         value.big = /^big/.test(voteType);
730         return value;
733 function makeVoteType(value) {
734         GWLog("makeVoteType");
735         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
738 function makeVoteClass(vote) {
739         GWLog("makeVoteClass");
740         if (vote.up || vote.down) {
741                 return (vote.big ? 'selected big-vote' : 'selected');
742         } else {
743                 return '';
744         }
747 function findVoteControls(targetType, targetId, voteAxis) {
748         var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
750         if(targetType == "Post") {
751                 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
752         } else if(targetType == "Comment") {
753                 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
754         }
757 function votesEqual(vote1, vote2) {
758         var allKeys = Object.assign({}, vote1);
759         Object.assign(allKeys, vote2);
761         for(k of allKeys.keys()) {
762                 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
763         }
764         return true;
767 function addVoteButtons(element, vote, targetType) {
768         GWLog("addVoteButtons");
769         vote = vote || {};
770         let voteAxis = element.parentElement.dataset.voteAxis || "karma";
771         let voteType = parseVoteType(vote[voteAxis]);
772         let voteClass = makeVoteClass(voteType);
774         element.parentElement.queryAll("button").forEach((button) => {
775                 button.disabled = false;
776                 if (voteType) {
777                         if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
778                                 button.addClass(voteClass);
779                 }
780                 updateVoteButtonVisualState(button);
781                 button.addActivateEvent(voteButtonClicked);
782         });
785 function updateVoteButtonVisualState(button) {
786         GWLog("updateVoteButtonVisualState");
788         button.removeClasses([ "none", "one", "two-temp", "two" ]);
790         if (button.disabled)
791                 button.addClass("none");
792         else if (button.hasClass("big-vote"))
793                 button.addClass("two");
794         else if (button.hasClass("selected"))
795                 button.addClass("one");
796         else
797                 button.addClass("none");
800 function changeVoteButtonVisualState(button) {
801         GWLog("changeVoteButtonVisualState");
803         /*      Interaction states are:
805                 0  0·    (neutral; +1 click)
806                 1  1·    (small vote; +1 click)
807                 2  2·    (big vote; +1 click)
809                 Visual states are (with their state classes in [brackets]) are:
811                 01    (no vote) [none]
812                 02    (small vote active) [one]
813                 12    (small vote active, temporary indicator of big vote) [two-temp]
814                 22    (big vote active) [two]
816                 The following are the 9 possible interaction state transitions (and
817                 the visual state transitions associated with them):
819                                 VIS.    VIS.
820                 FROM    TO      FROM    TO      NOTES
821                 ====    ====    ====    ====    =====
822                 0       0·      01      12      first click
823                 0·      1       12      02      one click without second
824                 0·      2       12      22      second click
826                 1       1·      02      12      first click
827                 1·      0       12      01      one click without second
828                 1·      2       12      22      second click
830                 2       2·      22      12      first click
831                 2·      1       12      02      one click without second
832                 2·      0       12      01      second click
833         */
834         let transitions = [
835                 [ "big-vote two-temp clicked-twice", "none"     ], // 2· => 0
836                 [ "big-vote two-temp clicked-once",  "one"      ], // 2· => 1
837                 [ "big-vote clicked-once",           "two-temp" ], // 2  => 2·
839                 [ "selected two-temp clicked-twice", "two"      ], // 1· => 2
840                 [ "selected two-temp clicked-once",  "none"     ], // 1· => 0
841                 [ "selected clicked-once",           "two-temp" ], // 1  => 1·
843                 [ "two-temp clicked-twice",          "two"      ], // 0· => 2
844                 [ "two-temp clicked-once",           "one"      ], // 0· => 1
845                 [ "clicked-once",                    "two-temp" ], // 0  => 0·
846         ];
847         for (let [ interactionClasses, visualStateClass ] of transitions) {
848                 if (button.hasClasses(interactionClasses.split(" "))) {
849                         button.removeClasses([ "none", "one", "two-temp", "two" ]);
850                         button.addClass(visualStateClass);
851                         break;
852                 }
853         }
856 function voteCompleteEvent(targetType, targetId, response) {
857         GWLog("voteCompleteEvent");
859         var currentVote = voteData[targetType][targetId] || {};
860         var desiredVote = voteDesired[targetType][targetId];
862         var controls = findVoteControls(targetType, targetId);
863         var controlsByAxis = new Object;
865         controls.forEach(control => {
866                 const voteAxis = (control.dataset.voteAxis || "karma");
868                 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
869                         control.removeClass("waiting");
870                         control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
871                 }
873                 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
874                 controlsByAxis[voteAxis].push(control);
876                 const voteType = currentVote[voteAxis];
877                 const vote = parseVoteType(voteType);
878                 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
879                 const voteClass = makeVoteClass(vote);
881                 if (response && response[voteAxis]) {
882                         const [voteType, displayText, titleText] = response[voteAxis];
884                         const displayTarget = control.query(".karma-value");
885                         if (displayTarget.hasClass("redacted")) {
886                                 displayTarget.dataset["trueValue"] = displayText;
887                         } else {
888                                 displayTarget.innerHTML = displayText;
889                         }
890                         displayTarget.setAttribute("title", titleText);
891                 }
893                 control.queryAll("button.vote").forEach(button => {
894                         updateVoteButton(button, voteUpDown, voteClass);
895                 });
896         });
899 function updateVoteButton(button, voteUpDown, voteClass) {
900         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
901         if (button.dataset.voteType == voteUpDown)
902                 button.addClass(voteClass);
903         updateVoteButtonVisualState(button);
906 function makeVoteRequestCompleteEvent(targetType, targetId) {
907         return (event) => {
908                 var currentVote = {};
909                 var response = null;
911                 if (event.target.status == 200) {
912                         response = JSON.parse(event.target.responseText);
913                         for (const voteAxis of response.keys()) {
914                                 currentVote[voteAxis] = response[voteAxis][0];
915                         }
916                         voteData[targetType][targetId] = currentVote;
917                 } else {
918                         delete voteDesired[targetType][targetId];
919                         currentVote = voteData[targetType][targetId];
920                 }
922                 var desiredVote = voteDesired[targetType][targetId];
924                 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
925                         sendVoteRequest(targetType, targetId);
926                 } else {
927                         delete voteDesired[targetType][targetId];
928                         voteCompleteEvent(targetType, targetId, response);
929                 }
930         }
933 function sendVoteRequest(targetType, targetId) {
934         GWLog("sendVoteRequest");
936         doAjax({
937                 method: "POST",
938                 location: "/karma-vote",
939                 params: { "target": targetId,
940                           "target-type": targetType,
941                           "vote": JSON.stringify(voteDesired[targetType][targetId]) },
942                 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
943         });
946 function voteButtonClicked(event) {
947         GWLog("voteButtonClicked");
948         let voteButton = event.target;
950         // 500 ms (0.5 s) double-click timeout.
951         let doubleClickTimeout = 500;
953         if (!voteButton.clickedOnce) {
954                 voteButton.clickedOnce = true;
955                 voteButton.addClass("clicked-once");
956                 changeVoteButtonVisualState(voteButton);
958                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
959                         if (!voteButton.clickedOnce) return;
961                         // Do single-click code.
962                         voteButton.clickedOnce = false;
963                         voteEvent(voteButton, 1);
964                 }, doubleClickTimeout, voteButton);
965         } else {
966                 voteButton.clickedOnce = false;
968                 // Do double-click code.
969                 voteButton.removeClass("clicked-once");
970                 voteButton.addClass("clicked-twice");
971                 voteEvent(voteButton, 2);
972         }
975 function voteEvent(voteButton, numClicks) {
976         GWLog("voteEvent");
977         voteButton.blur();
979         let voteControl = voteButton.parentNode;
981         let targetType = voteButton.dataset.targetType;
982         let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
983         let voteAxis = voteControl.dataset.voteAxis || "karma";
984         let voteUpDown = voteButton.dataset.voteType;
986         let voteType;
987         if (   (numClicks == 2 && voteButton.hasClass("big-vote"))
988                 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
989                 voteType = "neutral";
990         } else {
991                 let vote = parseVoteType(voteUpDown);
992                 vote.big = (numClicks == 2);
993                 voteType = makeVoteType(vote);
994         }
996         let voteControls = findVoteControls(targetType, targetId, voteAxis);
997         for (const voteControl of voteControls) {
998                 voteControl.addClass("waiting");
999                 voteControl.queryAll(".vote").forEach(button => {
1000                         button.addClass("waiting");
1001                         updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
1002                 });
1003         }
1005         let voteRequestPending = voteDesired[targetType][targetId];
1006         let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
1007         voteObject[voteAxis] = voteType;
1008         voteDesired[targetType][targetId] = voteObject;
1010         if (!voteRequestPending) sendVoteRequest(targetType, targetId);
1013 function initializeVoteButtons() {
1014         // Color the upvote/downvote buttons with an embedded style sheet.
1015         insertHeadHTML(`<style id="vote-buttons">
1016                 :root {
1017                         --GW-upvote-button-color: #00d800;
1018                         --GW-downvote-button-color: #eb4c2a;
1019                 }
1020         </style>`);
1023 function processVoteData(voteData) {
1024         window.voteData = voteData;
1026         window.voteDesired = new Object;
1027         for(key of voteData.keys()) {
1028                 voteDesired[key] = new Object;
1029         }
1031         initializeVoteButtons();
1032         
1033         addTriggerListener("postLoaded", {priority: 3000, fn: () => {
1034                 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
1035                         let postID = karmaValue.parentNode.dataset.postId;
1036                         addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
1037                         karmaValue.parentElement.addClass("active-controls");
1038                 });
1039         }});
1041         addTriggerListener("DOMReady", {priority: 3000, fn: () => {
1042                 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
1043                         let commentID = karmaValue.getCommentId();
1044                         addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
1045                         karmaValue.parentElement.addClass("active-controls");
1046                 });
1047         }});
1050 /*****************************************/
1051 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
1052 /*****************************************/
1054 Element.prototype.getCommentDate = function() {
1055         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
1056         let dateElement = item && item.query(".date");
1057         return (dateElement && parseInt(dateElement.dataset["jsDate"]));
1059 function getCurrentVisibleComment() {
1060         let px = window.innerWidth/2, py = 5;
1061         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
1062         let bottomBar = query("#bottom-bar");
1063         let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : document.body.getBoundingClientRect().bottom);
1064         let atbottom =  bottomOffset <= window.innerHeight;
1065         if (atbottom) {
1066                 let hashci = location.hash && query(location.hash);
1067                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
1068                         commentItem = hashci;
1069                 }
1070         }
1071         return commentItem;
1074 function highlightCommentsSince(date) {
1075         GWLog("highlightCommentsSince");
1076         var newCommentsCount = 0;
1077         GW.newComments = [ ];
1078         let oldCommentsStack = [ ];
1079         let prevNewComment;
1080         queryAll(".comment-item").forEach(commentItem => {
1081                 commentItem.prevNewComment = prevNewComment;
1082                 commentItem.nextNewComment = null;
1083                 if (commentItem.getCommentDate() > date) {
1084                         commentItem.addClass("new-comment");
1085                         newCommentsCount++;
1086                         GW.newComments.push(commentItem.getCommentId());
1087                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
1088                         oldCommentsStack = [ commentItem ];
1089                         prevNewComment = commentItem;
1090                 } else {
1091                         commentItem.removeClass("new-comment");
1092                         oldCommentsStack.push(commentItem);
1093                 }
1094         });
1096         GW.newCommentScrollSet = (commentItem) => {
1097                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
1098                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
1099         };
1100         GW.newCommentScrollListener = () => {
1101                 let commentItem = getCurrentVisibleComment();
1102                 GW.newCommentScrollSet(commentItem);
1103         }
1105         addScrollListener(GW.newCommentScrollListener);
1107         if (document.readyState=="complete") {
1108                 GW.newCommentScrollListener();
1109         } else {
1110                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
1111                 GW.newCommentScrollSet(commentItem);
1112         }
1114         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
1116         return newCommentsCount;
1119 function scrollToNewComment(next) {
1120         GWLog("scrollToNewComment");
1121         let commentItem = getCurrentVisibleComment();
1122         let targetComment = null;
1123         let targetCommentID = null;
1124         if (commentItem) {
1125                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
1126                 if (targetComment) {
1127                         targetCommentID = targetComment.getCommentId();
1128                 }
1129         } else {
1130                 if (GW.newComments[0]) {
1131                         targetCommentID = GW.newComments[0];
1132                         targetComment = query("#comment-" + targetCommentID);
1133                 }
1134         }
1135         if (targetComment) {
1136                 expandAncestorsOf(targetCommentID);
1137                 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
1138                 targetComment.scrollIntoView();
1139         }
1141         GW.newCommentScrollListener();
1144 function getPostHash() {
1145         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
1146         return (postHash ? postHash[1] : false);
1148 function setHistoryLastVisitedDate(date) {
1149         window.history.replaceState({ lastVisited: date }, null);
1151 function getLastVisitedDate() {
1152         // Get the last visited date (or, if posting a comment, the previous last visited date).
1153         if(window.history.state) return (window.history.state||{})['lastVisited'];
1154         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1155         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
1156         let currentVisited = localStorage.getItem(storageName);
1157         setHistoryLastVisitedDate(currentVisited);
1158         return currentVisited;
1160 function setLastVisitedDate(date) {
1161         GWLog("setLastVisitedDate");
1162         // If NOT posting a comment, save the previous value for the last-visited-date 
1163         // (to recover it in case of posting a comment).
1164         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1165         if (!aCommentHasJustBeenPosted) {
1166                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
1167                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
1168         }
1170         // Set the new value.
1171         localStorage.setItem("last-visited-date_" + getPostHash(), date);
1174 function updateSavedCommentCount() {
1175         GWLog("updateSavedCommentCount");
1176         let commentCount = queryAll(".comment").length;
1177         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
1179 function badgePostsWithNewComments() {
1180         GWLog("badgePostsWithNewComments");
1181         if (getQueryVariable("show") == "conversations") return;
1183         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
1184                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
1186                 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
1187                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1188                 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1190                 if (currentCommentCount > savedCommentCount)
1191                         commentCountDisplay.addClass("new-comments");
1192                 else
1193                         commentCountDisplay.removeClass("new-comments");
1194                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1195         });
1199 /*****************/
1200 /* MEDIA QUERIES */
1201 /*****************/
1203 GW.mediaQueries = {
1204     systemDarkModeActive:  matchMedia("(prefers-color-scheme: dark)")
1208 /************************/
1209 /* ACTIVE MEDIA QUERIES */
1210 /************************/
1212 /*  This function provides two slightly different versions of its functionality,
1213     depending on how many arguments it gets.
1215     If one function is given (in addition to the media query and its name), it
1216     is called whenever the media query changes (in either direction).
1218     If two functions are given (in addition to the media query and its name),
1219     then the first function is called whenever the media query starts matching,
1220     and the second function is called whenever the media query stops matching.
1222     If you want to call a function for a change in one direction only, pass an
1223     empty closure (NOT null!) as one of the function arguments.
1225     There is also an optional fifth argument. This should be a function to be
1226     called when the active media query is canceled.
1227  */
1228 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1229     if (typeof GW.mediaQueryResponders == "undefined")
1230         GW.mediaQueryResponders = { };
1232     let mediaQueryResponder = (event, canceling = false) => {
1233         if (canceling) {
1234             GWLog(`Canceling media query “${name}”`, "media queries", 1);
1236             if (whenCanceledDo != null)
1237                 whenCanceledDo(mediaQuery);
1238         } else {
1239             let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1241             GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1243             if ((otherwiseDo == null) || matches)
1244                 ifMatchesOrAlwaysDo(mediaQuery);
1245             else
1246                 otherwiseDo(mediaQuery);
1247         }
1248     };
1249     mediaQueryResponder();
1250     mediaQuery.addListener(mediaQueryResponder);
1252     GW.mediaQueryResponders[name] = mediaQueryResponder;
1255 /*  Deactivates and discards an active media query, after calling the function
1256     that was passed as the whenCanceledDo parameter when the media query was
1257     added.
1258  */
1259 function cancelDoWhenMatchMedia(name) {
1260     GW.mediaQueryResponders[name](null, true);
1262     for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1263         mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1265     GW.mediaQueryResponders[name] = null;
1269 /******************************/
1270 /* DARK/LIGHT MODE ADJUSTMENT */
1271 /******************************/
1273 DarkMode = {
1274         /*****************/
1275         /*      Configuration.
1276          */
1277         modeOptions: [
1278                 [ "auto", "&#xf042;", "Set light or dark mode automatically, according to system-wide setting (Win: Start → Personalization → Colors; Mac: Apple → System-Preferences → General → Appearance; iOS: Settings → Display-and-Brightness; Android: Settings → Display)" ],
1279                 [ "light", "&#xe28f;", "Light mode at all times (black-on-white)" ],
1280                 [ "dark", "&#xf186;", "Dark mode at all times (inverted: white-on-black)" ]
1281         ],
1283         selectedModeOptionNote: " [This option is currently selected.]",
1285         /******************/
1286         /*      Infrastructure.
1287          */
1289         modeSelector: null,
1290         modeSelectorInteractable: true,
1292         /******************/
1293         /*      Mode selection.
1294          */
1296     /*  Returns current (saved) mode (light, dark, or auto).
1297      */
1298     getSavedMode: () => {
1299             return (readCookie("dark-mode") || (readCookie("theme") === "dark" && "dark") || "auto");
1300     },
1302         /*      Saves specified mode (light, dark, or auto).
1303          */
1304         saveMode: (mode) => {
1305                 GWLog("DarkMode.setMode");
1307                 if (mode == "auto")
1308                         setCookie("dark-mode", "");
1309                 else
1310                         setCookie("dark-mode", mode);
1311         },
1313         getMediaQuery: (selectedMode = DarkMode.getSavedMode()) => {
1314                 if (selectedMode == "auto") {
1315                         return "all and (prefers-color-scheme: dark)";
1316                 } else if (selectedMode == "dark") {
1317                         return "all";
1318                 } else {
1319                         return "not all";
1320                 }
1321         },
1323         /*  Set specified color mode (light, dark, or auto).
1324          */
1325         setMode: (selectedMode = DarkMode.getSavedMode()) => {
1326                 GWLog("DarkMode.setMode");
1328                 document.body.removeClasses(["force-dark-mode", "force-light-mode"]);
1329                 if(selectedMode === "dark" || selectedMode === "light")
1330                         document.body.addClass("force-" + selectedMode + "-mode");
1332                 let media = DarkMode.getMediaQuery(selectedMode);
1333                 let darkModeStyles = document.querySelector("link.dark-mode");
1334                 if (darkModeStyles) {
1335                         //      Set `media` attribute of style block to match requested mode.
1336                         darkModeStyles.media = media;
1337                 }
1339                 for(elem of document.querySelectorAll("picture.invertible source")) {
1340                         // Update invertible images.
1341                         elem.media = media;
1342                 }
1344                 //      Update state.
1345                 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1346         },
1348         modeSelectorHTML: (inline = false) => {
1349                 let selectorTagName = (inline ? "span" : "div");
1350                 let selectorId = (inline ? `` : ` id="dark-mode-selector"`);
1351                 let selectorClass = (` class="dark-mode-selector mode-selector` + (inline ? ` mode-selector-inline` : ``) + `"`);
1353                 //      Get saved mode setting (or default).
1354                 let currentMode = DarkMode.getSavedMode();
1356                 return `<${selectorTagName}${selectorId}${selectorClass}>`
1357                         + DarkMode.modeOptions.map(modeOption => {
1358                                 let [ name, label, desc ] = modeOption;
1359                                 let selected = (name == currentMode ? " selected" : "");
1360                                 let disabled = (name == currentMode ? " disabled" : "");
1361                                 let active = ((   currentMode == "auto"
1362                                                            && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1363                                                           ? " active"
1364                                                           : "");
1365                                 if (name == currentMode)
1366                                         desc += DarkMode.selectedModeOptionNote;
1367                                 return `<button
1368                                                         type="button"
1369                                                         class="select-mode-${name}${selected}${active}"
1370                                                         ${disabled}
1371                                                         tabindex="-1"
1372                                                         data-name="${name}"
1373                                                         title="${desc}"
1374                                                                 >${label}</button>`;
1375                           }).join("")
1376                         + `</${selectorTagName}>`;
1377         },
1379         injectModeSelector: (replacedElement = null) => {
1380                 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1382                 //      Inject the mode selector widget.
1383                 let modeSelector;
1384                 if (replacedElement) {
1385                         replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1386                         modeSelector = replacedElement.firstElementChild;
1387                         unwrap(replacedElement);
1388                 } else {
1389                         if (GW.isMobile) {
1390                                 if (Appearance.themeSelector == null)
1391                                         return;
1393                                 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", DarkMode.modeSelectorHTML());
1394                         } else {
1395                                 addUIElement(DarkMode.modeSelectorHTML());
1396                         }
1398                         modeSelector = DarkMode.modeSelector = query("#dark-mode-selector");
1399                 }
1401                 //  Add event listeners and update state.
1402                 requestAnimationFrame(() => {
1403                         //      Activate mode selector widget buttons.
1404                         modeSelector.querySelectorAll("button").forEach(button => {
1405                                 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1406                         });
1407                 });
1409                 /*      Add active media query to update mode selector state when system dark
1410                         mode setting changes. (This is relevant only for the ‘auto’ setting.)
1411                  */
1412                 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => { 
1413                         DarkMode.updateModeSelectorState(modeSelector);
1414                 });
1415         },
1417         modeSelectButtonClicked: (event) => {
1418                 GWLog("DarkMode.modeSelectButtonClicked");
1420                 /*      We don’t want clicks to go through if the transition 
1421                         between modes has not completed yet, so we disable the 
1422                         button temporarily while we’re transitioning between 
1423                         modes.
1424                  */
1425                 doIfAllowed(() => {
1426                         // Determine which setting was chosen (ie. which button was clicked).
1427                         let selectedMode = event.target.dataset.name;
1429                         // Save the new setting.
1430                         DarkMode.saveMode(selectedMode);
1432                         // Actually change the mode.
1433                         DarkMode.setMode(selectedMode);
1434                 }, DarkMode, "modeSelectorInteractable");
1436                 event.target.blur();
1437         },
1439         updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1440                 GWLog("DarkMode.updateModeSelectorState");
1442                 /*      If the mode selector has not yet been injected, then do nothing.
1443                  */
1444                 if (modeSelector == null)
1445                         return;
1447                 //      Get saved mode setting (or default).
1448                 let currentMode = DarkMode.getSavedMode();
1450                 //      Clear current buttons state.
1451                 modeSelector.querySelectorAll("button").forEach(button => {
1452                         button.classList.remove("active", "selected");
1453                         button.disabled = false;
1454                         if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1455                                 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1456                 });
1458                 //      Set the correct button to be selected.
1459                 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1460                         button.classList.add("selected");
1461                         button.disabled = true;
1462                         button.title += DarkMode.selectedModeOptionNote;
1463                 });
1465                 /*      Ensure the right button (light or dark) has the “currently active” 
1466                         indicator, if the current mode is ‘auto’.
1467                  */
1468                 if (currentMode == "auto")
1469                         modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1470         }
1474 /****************************/
1475 /* APPEARANCE CUSTOMIZATION */
1476 /****************************/
1478 Appearance = { ...Appearance,
1479         /**************************************************************************/
1480         /* INFRASTRUCTURE
1481          */
1483         noFilters: { },
1485         themeSelector: null,
1486         themeSelectorAuxiliaryControlsContainer: null,
1487         themeSelectorInteractionBlockerOverlay: null,
1488         themeSelectorInteractableTimer: null,
1490         themeTweakerToggle: null,
1492         themeTweakerStyleBlock: null,
1494         themeTweakerUI: null,
1495         themeTweakerUIMainWindow: null,
1496         themeTweakerUIHelpWindow: null,
1497         themeTweakerUISampleTextContainer: null,
1498         themeTweakerUIClippyContainer: null,
1499         themeTweakerUIClippyControl: null,
1501         widthSelector: null,
1503         textSizeAdjustmentWidget: null,
1505         appearanceAdjustUIToggle: null,
1507         /**************************************************************************/
1508         /* FUNCTIONALITY
1509          */
1511         /*      Return a new <link> element linking a style sheet (.css file) for the
1512                 given theme name and color scheme preference (i.e., value for the 
1513                 ‘media’ attribute; may be “light”, “dark”, or “” [empty string]).
1514          */
1515         makeNewStyle: (newThemeName) => {
1516                 let styleSheetNameSuffix = newThemeName == Appearance.defaultTheme
1517                                                                    ? "" 
1518                                                                    : ("-" + newThemeName);
1519                 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1521                 return [["style", "theme"], ["colors", "theme light-mode"], ["inverted", "theme dark-mode", DarkMode.getMediaQuery()]].map(args => {
1522                         let [baseName, className, mediaQuery] = args;
1523                         return newElement("LINK", {
1524                                 "class": className,
1525                                 "rel": "stylesheet",
1526                                 "href": ("/generated-css/" + baseName + styleSheetNameSuffix + currentStyleSheetNameComponents[1]),
1527                                 "media": mediaQuery || null,
1528                                 "blocking": "render"
1529                         });
1530                 });
1531         },
1533         setTheme: (newThemeName, save = true) => {
1534                 GWLog("Appearance.setTheme");
1536                 let oldThemeName = "";
1537                 if (typeof(newThemeName) == "undefined") {
1538                         /*      If no theme name to set is given, that means we’re setting the 
1539                                 theme initially, on page load. The .currentTheme value will have
1540                                 been set by .setup().
1541                          */
1542                         newThemeName = Appearance.currentTheme;
1544                         /*      If the selected (saved) theme is the default theme, then there’s
1545                                 nothing to do.
1546                          */
1547                         if (newThemeName == Appearance.defaultTheme)
1548                                 return;
1549                 } else {
1550                         oldThemeName = Appearance.currentTheme;
1552                         /*      When the unload callback runs, the .currentTheme value is still 
1553                                 that of the old theme.
1554                          */
1555                         let themeUnloadCallback = Appearance.themeUnloadCallbacks[oldThemeName];
1556                         if (themeUnloadCallback != null)
1557                                 themeUnloadCallback(newThemeName);
1559                         /*      The old .currentTheme value is saved in oldThemeName.
1560                          */
1561                         Appearance.currentTheme = newThemeName;
1563                         /*      The ‘save’ parameter might be false if this function is called 
1564                                 from the theme tweaker, in which case we want to switch only 
1565                                 temporarily, and preserve the saved setting until the user 
1566                                 clicks “OK”.
1567                          */
1568                         if (save)
1569                                 Appearance.saveCurrentTheme();
1570                 }
1572                 let newStyles = Appearance.makeNewStyle(newThemeName);
1573                 let loadingStyleCount = newStyles.length;
1575                 let oldStyles = queryAll("head link.theme");
1577                 let onNewStylesLoaded = (event) => {
1578                         loadingStyleCount--;
1579                         if(loadingStyleCount === 0) {
1580                                 for(oldStyle of oldStyles) removeElement(oldStyle);
1581                                 Appearance.postSetThemeHousekeeping(oldThemeName, newThemeName);
1582                         }
1583                 };
1585                 for(newStyle of newStyles) newStyle.addEventListener("load", onNewStylesLoaded);
1587                 if (Appearance.adjustmentTransitions) {
1588                         pageFadeTransition(false);
1589                         setTimeout(() => {
1590                                 document.head.prepend(...newStyles);
1591                         }, 500);
1592                 } else {
1593                         document.head.prepend(...newStyles);
1594                 }
1596                 //      Update UI state of all theme selectors.
1597                 Appearance.updateThemeSelectorsState();
1598         },
1600         postSetThemeHousekeeping: (oldThemeName = "", newThemeName = null) => {
1601                 GWLog("Appearance.postSetThemeHousekeeping");
1603                 if (newThemeName == null)
1604                         newThemeName = Appearance.getSavedTheme();
1606                 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1607                 document.body.addClass("theme-" + newThemeName);
1609                 recomputeUIElementsContainerHeight(true);
1611                 let themeLoadCallback = Appearance.themeLoadCallbacks[newThemeName];
1612                 if (themeLoadCallback != null)
1613                         themeLoadCallback(oldThemeName);
1615                 recomputeUIElementsContainerHeight();
1616                 adjustUIForWindowSize();
1617                 window.addEventListener("resize", GW.windowResized = (event) => {
1618                         GWLog("GW.windowResized");
1619                         adjustUIForWindowSize();
1620                         recomputeUIElementsContainerHeight();
1621                 });
1623                 generateImagesOverlay();
1625                 if (Appearance.adjustmentTransitions)
1626                         pageFadeTransition(true);
1627                 Appearance.updateThemeTweakerSampleText();
1629                 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== "undefined") {
1630                         window.matchMedia("(orientation: portrait)").addListener(generateImagesOverlay);
1631                 }
1632         },
1634         themeLoadCallbacks: {
1635                 brutalist: (fromTheme = "") => {
1636                         GWLog("Appearance.themeLoadCallbacks.brutalist");
1638                         let bottomBarLinks = queryAll("#bottom-bar a");
1639                         if (!GW.isMobile && bottomBarLinks.length == 5) {
1640                                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1641                                 bottomBarLinks.forEach((link, i) => {
1642                                         link.dataset.originalText = link.textContent;
1643                                         link.textContent = newLinkTexts[i];
1644                                 });
1645                         }
1646                 },
1648                 classic: (fromTheme = "") => {
1649                         GWLog("Appearance.themeLoadCallbacks.classic");
1651                         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1652                                 button.innerHTML = "";
1653                         });
1654                 },
1656                 dark: (fromTheme = "") => {
1657                         GWLog("Appearance.themeLoadCallbacks.dark");
1659                         insertHeadHTML(`<style id="dark-theme-adjustments">
1660                                 .markdown-reference-link a { color: #d200cf; filter: invert(100%); }
1661                                 #bottom-bar.decorative::before { filter: invert(100%); }
1662                         </style>`);
1663                         registerInitializer("makeImagesGlow", true, () => query("#images-overlay") != null, () => {
1664                                 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1665                                         image.style.filter = "drop-shadow(0 0 0 #000) drop-shadow(0 0 0.5px #fff) drop-shadow(0 0 1px #fff) drop-shadow(0 0 2px #fff)";
1666                                         image.style.width = parseInt(image.style.width) + 12 + "px";
1667                                         image.style.height = parseInt(image.style.height) + 12 + "px";
1668                                         image.style.top = parseInt(image.style.top) - 6 + "px";
1669                                         image.style.left = parseInt(image.style.left) - 6 + "px";
1670                                 });
1671                         });
1672                 },
1674                 less: (fromTheme = "") => {
1675                         GWLog("Appearance.themeLoadCallbacks.less");
1677                         injectSiteNavUIToggle();
1678                         if (!GW.isMobile) {
1679                                 injectPostNavUIToggle();
1680                                 Appearance.injectAppearanceAdjustUIToggle();
1681                         }
1683                         registerInitializer("shortenDate", true, () => query(".top-post-meta") != null, function () {
1684                                 let dtf = new Intl.DateTimeFormat([], 
1685                                         (window.innerWidth < 1100) ? 
1686                                                 { month: "short", day: "numeric", year: "numeric" } : 
1687                                                         { month: "long", day: "numeric", year: "numeric" });
1688                                 let postDate = query(".top-post-meta .date");
1689                                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1690                         });
1692                         if (GW.isMobile) {
1693                                 query("#content").insertAdjacentHTML("beforeend", `<div id="theme-less-mobile-first-row-placeholder"></div>`);
1694                         }
1696                         if (!GW.isMobile) {
1697                                 registerInitializer("addSpans", true, () => query(".top-post-meta") != null, function () {
1698                                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1699                                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1700                                         });
1701                                 });
1703                                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1704                                         // If state is not set (user has never clicked on the Less theme’s appearance
1705                                         // adjustment UI toggle) then show it, but then hide it after a short time.
1706                                         registerInitializer("engageAppearanceAdjustUI", true, () => query("#ui-elements-container") != null, function () {
1707                                                 Appearance.toggleAppearanceAdjustUI();
1708                                                 setTimeout(Appearance.toggleAppearanceAdjustUI, 3000);
1709                                         });
1710                                 }
1712                                 if (fromTheme != "") {
1713                                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1714                                         setTimeout(function () {
1715                                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1716                                         }, 300);
1717                                         setTimeout(function () {
1718                                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1719                                         }, 1800);
1720                                 }
1722                                 // Unset the height of the #ui-elements-container.
1723                                 query("#ui-elements-container").style.height = "";
1725                                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1726                                 Appearance.filtersExclusionPaths.themeLess = [
1727                                         "#content #secondary-bar",
1728                                         "#content .post .top-post-meta .date",
1729                                         "#content .post .top-post-meta .comment-count",
1730                                 ];
1731                                 Appearance.applyFilters();
1732                         }
1734                         // We pre-query the relevant elements, so we don’t have to run querySelectorAll
1735                         // on every firing of the scroll listener.
1736                         GW.scrollState = {
1737                                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1738                                 "unbrokenDownScrollDistance":           0,
1739                                 "unbrokenUpScrollDistance":                     0,
1740                                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1741                                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1742                                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1743                         };
1744                         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1745                 }
1746         },
1748         themeUnloadCallbacks: {
1749                 brutalist: (toTheme = "") => {
1750                         GWLog("Appearance.themeUnloadCallbacks.brutalist");
1752                         let bottomBarLinks = queryAll("#bottom-bar a");
1753                         if (!GW.isMobile && bottomBarLinks.length == 5) {
1754                                 bottomBarLinks.forEach(link => {
1755                                         link.textContent = link.dataset.originalText;
1756                                 });
1757                         }
1758                 },
1760                 classic: (toTheme = "") => {
1761                         GWLog("Appearance.themeUnloadCallbacks.classic");
1763                         if (GW.isMobile && window.innerWidth <= 900)
1764                                 return;
1766                         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1767                                 button.innerHTML = button.dataset.label;
1768                         });
1769                 },
1771                 dark: (toTheme = "") => {
1772                         GWLog("Appearance.themeUnloadCallbacks.dark");
1774                         removeElement("#dark-theme-adjustments");
1775                 },
1777                 less: (toTheme = "") => {
1778                         GWLog("Appearance.themeUnloadCallbacks.less");
1780                         removeSiteNavUIToggle();
1781                         if (!GW.isMobile) {
1782                                 removePostNavUIToggle();
1783                                 Appearance.removeAppearanceAdjustUIToggle();
1784                         }
1786                         window.removeEventListener("resize", updatePostNavUIVisibility);
1788                         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1790                         removeElement("#theme-less-mobile-first-row-placeholder");
1792                         if (!GW.isMobile) {
1793                                 // Remove spans
1794                                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1795                                         element.innerHTML = element.firstChild.innerHTML;
1796                                 });
1797                         }
1799                         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1801                         //      Reset filtered elements selector to default.
1802                         delete Appearance.filtersExclusionPaths.themeLess;
1803                         Appearance.applyFilters();
1804                 }
1805         },
1807         pageFadeTransition: (fadeIn) => {
1808                 if (fadeIn) {
1809                         document.body.removeClass("transparent");
1810                 } else {
1811                         document.body.addClass("transparent");
1812                 }
1813         },
1815         /*      Set the saved theme setting to the currently active theme.
1816          */
1817         saveCurrentTheme: () => {
1818                 GWLog("Appearance.saveCurrentTheme");
1820                 if (Appearance.currentTheme == Appearance.defaultTheme)
1821                         setCookie("theme", "");
1822                 else
1823                         setCookie("theme", Appearance.currentTheme);
1824         },
1826         /*      Reset theme, theme tweak filters, and text zoom to their saved settings.
1827          */
1828         themeTweakReset: () => {
1829                 GWLog("Appearance.themeTweakReset");
1831                 Appearance.setTheme(Appearance.getSavedTheme());
1832                 Appearance.applyFilters(Appearance.getSavedFilters());
1833                 Appearance.setTextZoom(Appearance.getSavedTextZoom());
1834         },
1836         /*      Set the saved theme, theme tweak filter, and text zoom settings to their
1837                 currently active values.
1838          */
1839         themeTweakSave: () => {
1840                 GWLog("Appearance.themeTweakSave");
1842                 Appearance.saveCurrentTheme();
1843                 Appearance.saveCurrentFilters();
1844                 Appearance.saveCurrentTextZoom();
1845         },
1847         /*      Reset theme, theme tweak filters, and text zoom to their default levels.
1848                 (Do not save the new settings, however.)
1849          */
1850         themeTweakResetDefaults: () => {
1851                 GWLog("Appearance.themeTweakResetDefaults");
1853                 Appearance.setTheme(Appearance.defaultTheme, false);
1854                 Appearance.applyFilters(Appearance.defaultFilters);
1855                 Appearance.setTextZoom(Appearance.defaultTextZoom, false);
1856         },
1858         themeTweakerResetSettings: () => {
1859                 GWLog("Appearance.themeTweakerResetSettings");
1861                 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
1862                 Appearance.themeTweakerUIClippyContainer.style.display = Appearance.themeTweakerUIClippyControl.checked 
1863                                                                                                                                  ? "block" 
1864                                                                                                                                  : "none";
1865         },
1867         themeTweakerSaveSettings: () => {
1868                 GWLog("Appearance.themeTweakerSaveSettings");
1870                 Appearance.saveThemeTweakerClippyState();
1871         },
1873         getSavedThemeTweakerClippyState: () => {
1874                 return (JSON.parse(localStorage.getItem("theme-tweaker-settings") || `{ "showClippy": ${Appearance.defaultThemeTweakerClippyState} }` )["showClippy"]);
1875         },
1877         saveThemeTweakerClippyState: () => {
1878                 GWLog("Appearance.saveThemeTweakerClippyState");
1880                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ "showClippy": Appearance.themeTweakerUIClippyControl.checked }));
1881         },
1883         getSavedAppearanceAdjustUIToggleState: () => {
1884                 return ((localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") || Appearance.defaultAppearanceAdjustUIToggleState);
1885         },
1887         saveAppearanceAdjustUIToggleState: () => {
1888                 GWLog("Appearance.saveAppearanceAdjustUIToggleState");
1890                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", Appearance.appearanceAdjustUIToggle.query("button").hasClass("engaged"));
1891         },
1893         /**************************************************************************/
1894         /* UI CONSTRUCTION & MANIPULATION
1895          */
1897         contentWidthSelectorHTML: () => {
1898                 return ("<div id='width-selector'>"
1899                         + String.prototype.concat.apply("", Appearance.widthOptions.map(widthOption => {
1900                                 let [name, desc, abbr] = widthOption;
1901                                 let selected = (name == Appearance.currentWidth ? " selected" : "");
1902                                 let disabled = (name == Appearance.currentWidth ? " disabled" : "");
1903                                 return `<button type="button" class="select-width-${name}${selected}"${disabled} title="${desc}" tabindex="-1" data-name="${name}">${abbr}</button>`
1904                         }))
1905                 + "</div>");
1906         },
1908         injectContentWidthSelector: () => {
1909                 GWLog("Appearance.injectContentWidthSelector");
1911                 //      Inject the content width selector widget and activate buttons.
1912                 Appearance.widthSelector = addUIElement(Appearance.contentWidthSelectorHTML());
1913                 Appearance.widthSelector.queryAll("button").forEach(button => {
1914                         button.addActivateEvent(Appearance.widthAdjustButtonClicked);
1915                 });
1917                 //      Make sure the accesskey (to cycle to the next width) is on the right button.
1918                 Appearance.setWidthAdjustButtonsAccesskey();
1920                 //      Inject transitions CSS, if animating changes is enabled.
1921                 if (Appearance.adjustmentTransitions) {
1922                         insertHeadHTML(
1923                                 `<style id="width-transition">
1924                                         #content,
1925                                         #ui-elements-container,
1926                                         #images-overlay {
1927                                                 transition:
1928                                                         max-width 0.3s ease;
1929                                         }
1930                                 </style>`);
1931                 }
1932         },
1934         setWidthAdjustButtonsAccesskey: () => {
1935                 GWLog("Appearance.setWidthAdjustButtonsAccesskey");
1937                 Appearance.widthSelector.queryAll("button").forEach(button => {
1938                         button.removeAttribute("accesskey");
1939                         button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1940                 });
1941                 let selectedButton = Appearance.widthSelector.query("button.selected");
1942                 let nextButtonInCycle = selectedButton == selectedButton.parentElement.lastChild
1943                                                                                                   ? selectedButton.parentElement.firstChild 
1944                                                                                                   : selectedButton.nextSibling;
1945                 nextButtonInCycle.accessKey = "'";
1946                 nextButtonInCycle.title += ` [\']`;
1947         },
1949         injectTextSizeAdjustmentUI: () => {
1950                 GWLog("Appearance.injectTextSizeAdjustmentUI");
1952                 if (Appearance.textSizeAdjustmentWidget != null)
1953                         return;
1955                 let inject = () => {
1956                         GWLog("Appearance.injectTextSizeAdjustmentUI [INJECTING]");
1958                         Appearance.textSizeAdjustmentWidget = addUIElement("<div id='text-size-adjustment-ui'>"
1959                                 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
1960                                 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1961                                 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
1962                         + "</div>");
1964                         Appearance.textSizeAdjustmentWidget.queryAll("button").forEach(button => {
1965                                 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1966                         });
1967                 };
1969                 if (query("#content.post-page") != null) {
1970                         inject();
1971                 } else {
1972                         document.addEventListener("DOMContentLoaded", () => {
1973                                 if (!(   query(".post-body") == null 
1974                                           && query(".comment-body") == null))
1975                                         inject();
1976                         }, { once: true });
1977                 }
1978         },
1980         themeSelectorHTML: () => {
1981                 return ("<div id='theme-selector' class='theme-selector'>"
1982                         + String.prototype.concat.apply("", Appearance.themeOptions.map(themeOption => {
1983                                 let [name, desc, letter] = themeOption;
1984                                 let selected = (name == Appearance.currentTheme ? ' selected' : '');
1985                                 let disabled = (name == Appearance.currentTheme ? ' disabled' : '');
1986                                 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1987                                 return `<button type='button' class='select-theme select-theme-${name}${selected}'${disabled} title="${desc} [${accesskey}]" data-theme-name="${name}" data-theme-description="${desc}" accesskey='${accesskey}' tabindex='-1'>${letter}</button>`;
1988                         }))
1989                 + "</div>");
1990         },
1992         injectThemeSelector: () => {
1993                 GWLog("Appearance.injectThemeSelector");
1995                 Appearance.themeSelector = addUIElement(Appearance.themeSelectorHTML());
1996                 Appearance.themeSelector.queryAll("button").forEach(button => {
1997                         button.addActivateEvent(Appearance.themeSelectButtonClicked);
1998                 });
2000                 if (GW.isMobile) {
2001                         //      Add close button.
2002                         let themeSelectorCloseButton = newElement("BUTTON", { "class": "theme-selector-close-button" }, { "innerHTML": "&#xf057;" });
2003                         themeSelectorCloseButton.addActivateEvent(Appearance.themeSelectorCloseButtonClicked);
2004                         Appearance.themeSelector.appendChild(themeSelectorCloseButton);
2006                         //      Inject auxiliary controls container.
2007                         Appearance.themeSelectorAuxiliaryControlsContainer = newElement("DIV", { "class": "auxiliary-controls-container" });
2008                         Appearance.themeSelector.appendChild(Appearance.themeSelectorAuxiliaryControlsContainer);
2010                         //      Inject mobile versions of various UI elements.
2011                         Appearance.injectThemeTweakerToggle();
2012                         injectAntiKibitzerToggle();
2013                         DarkMode.injectModeSelector();
2015                         //      Inject interaction blocker overlay.
2016                         Appearance.themeSelectorInteractionBlockerOverlay = Appearance.themeSelector.appendChild(newElement("DIV", { "class": "interaction-blocker-overlay" }));
2017                         Appearance.themeSelectorInteractionBlockerOverlay.addActivateEvent(event => { event.stopPropagation(); });
2018                 }
2020                 //      Inject transitions CSS, if animating changes is enabled.
2021                 if (Appearance.adjustmentTransitions) {
2022                         insertHeadHTML(`<style id="theme-fade-transition">
2023                                 body {
2024                                         transition:
2025                                                 opacity 0.5s ease-out,
2026                                                 background-color 0.3s ease-out;
2027                                 }
2028                                 body.transparent {
2029                                         background-color: #777;
2030                                         opacity: 0.0;
2031                                         transition:
2032                                                 opacity 0.5s ease-in,
2033                                                 background-color 0.3s ease-in;
2034                                 }
2035                         </style>`);
2036                 }
2037         },
2039         updateThemeSelectorsState: () => {
2040                 GWLog("Appearance.updateThemeSelectorsState");
2042                 queryAll(".theme-selector button.select-theme").forEach(button => {
2043                         button.removeClass("selected");
2044                         button.disabled = false;
2045                 });
2046                 queryAll(".theme-selector button.select-theme-" + Appearance.currentTheme).forEach(button => {
2047                         button.addClass("selected");
2048                         button.disabled = true;
2049                 });
2051                 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.currentTheme;
2052         },
2054         setThemeSelectorInteractable: (interactable) => {
2055                 GWLog("Appearance.setThemeSelectorInteractable");
2057                 Appearance.themeSelectorInteractionBlockerOverlay.classList.toggle("enabled", (interactable == false));
2058         },
2060         themeTweakerUIHTML: () => {
2061                 return (`<div id="theme-tweaker-ui" style="display: none;">\n` 
2062                         + `<div class="theme-tweaker-window main-window">
2063                                 <div class="theme-tweaker-window-title-bar">
2064                                         <div class="theme-tweaker-window-title">
2065                                                 <h1>Customize appearance</h1>
2066                                         </div>
2067                                         <div class="theme-tweaker-window-title-bar-buttons-container">
2068                                                 <button type="button" class="help-button" tabindex="-1"></button>
2069                                                 <button type="button" class="minimize-button minimize" tabindex="-1"></button>
2070                                                 <button type="button" class="close-button" tabindex="-1"></button>
2071                                         </div>
2072                                 </div>
2073                                 <div class="theme-tweaker-window-content-view">
2074                                         <div class="theme-select">
2075                                                 <p class="current-theme">Current theme:
2076                                                         <span>${Appearance.getSavedTheme()}</span>
2077                                                 </p>
2078                                                 <div class="theme-selector"></div>
2079                                         </div>
2080                                         <div class="controls-container">
2081                                                 <div id="theme-tweak-section-sample-text" class="section" data-label="Sample text">
2082                                                         <div class="sample-text-container"><span class="sample-text">
2083                                                                 <p>Less Wrong (text)</p>
2084                                                                 <p><a href="#">Less Wrong (link)</a></p>
2085                                                         </span></div>
2086                                                 </div>
2087                                                 <div id="theme-tweak-section-text-size-adjust" class="section" data-label="Text size">
2088                                                         <button type="button" class="text-size-adjust-button decrease" title="Decrease text size"></button>
2089                                                         <button type="button" class="text-size-adjust-button default" title="Reset to default text size"></button>
2090                                                         <button type="button" class="text-size-adjust-button increase" title="Increase text size"></button>
2091                                                 </div>
2092                                                 <div id="theme-tweak-section-invert" class="section" data-label="Invert (photo-negative)">
2093                                                         <input type="checkbox" id="theme-tweak-control-invert"></input>
2094                                                         <label for="theme-tweak-control-invert">Invert colors</label>
2095                                                 </div>
2096                                                 <div id="theme-tweak-section-saturate" class="section" data-label="Saturation">
2097                                                         <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2098                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
2099                                                         <div class="notch theme-tweak-slider-notch-saturate" title="Reset saturation to default value (100%)"></div>
2100                                                 </div>
2101                                                 <div id="theme-tweak-section-brightness" class="section" data-label="Brightness">
2102                                                         <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2103                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
2104                                                         <div class="notch theme-tweak-slider-notch-brightness" title="Reset brightness to default value (100%)"></div>
2105                                                 </div>
2106                                                 <div id="theme-tweak-section-contrast" class="section" data-label="Contrast">
2107                                                         <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2108                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
2109                                                         <div class="notch theme-tweak-slider-notch-contrast" title="Reset contrast to default value (100%)"></div>
2110                                                 </div>
2111                                                 <div id="theme-tweak-section-hue-rotate" class="section" data-label="Hue rotation">
2112                                                         <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
2113                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
2114                                                         <div class="notch theme-tweak-slider-notch-hue-rotate" title="Reset hue to default (0° away from standard colors for theme)"></div>
2115                                                 </div>
2116                                         </div>
2117                                         <div class="buttons-container">
2118                                                 <button type="button" class="reset-defaults-button">Reset to defaults</button>
2119                                                 <button type="button" class="ok-button default-button">OK</button>
2120                                                 <button type="button" class="cancel-button">Cancel</button>
2121                                         </div>
2122                                 </div>
2123                         </div>
2124                         <div class="clippy-container">
2125                                 <span class="hint">Hi, I’m Bobby the Basilisk! Click on the minimize button (<img src="data:image/gif;base64,R0lGODlhFgAUAPIAMQAAAAMDA394f7+4v9/Y3//4/2JX/38AACwAAAAAFgAUAAADRki63B6kyEkrFbCMzbvnWPSNXqiRqImm2Uqq7gfH3Uxv9p3TNuD/wFqLAywChCKi8Yc83XDD52AXCwmu2KxWG+h6v+BwNwEAOw==" />) to minimize the theme tweaker window, so that you can see what the page looks like with the current tweaked values. (But remember, <span>the changes won’t be saved until you click “OK”!</span>)
2126                                 <div class="clippy"></div>
2127                                 <button type="button" class="clippy-close-button" tabindex="-1" title="Hide theme tweaker assistant (you can bring him back by clicking the ? button in the title bar)"></button>
2128                         </div>
2129                         <div class="theme-tweaker-window help-window" style="display: none;">
2130                                 <div class="theme-tweaker-window-title-bar">
2131                                         <div class="theme-tweaker-window-title">
2132                                                 <h1>Theme tweaker help</h1>
2133                                         </div>
2134                                 </div>
2135                                 <div class="theme-tweaker-window-content-view">
2136                                         <div id="theme-tweak-section-clippy" class="section" data-label="Theme Tweaker Assistant">
2137                                                 <input type="checkbox" id="theme-tweak-control-clippy" checked="checked"></input>
2138                                                 <label for="theme-tweak-control-clippy">Show Bobby the Basilisk</label>
2139                                         </div>
2140                                         <div class="buttons-container">
2141                                                 <button type="button" class="ok-button default-button">OK</button>
2142                                                 <button type="button" class="cancel-button">Cancel</button>
2143                                         </div>
2144                                 </div>
2145                         </div>
2146                 ` + `\n</div>`);
2147         },
2149         injectThemeTweaker: () => {
2150                 GWLog("Appearance.injectThemeTweaker");
2152                 Appearance.themeTweakerUI = addUIElement(Appearance.themeTweakerUIHTML());
2153                 Appearance.themeTweakerUIMainWindow = Appearance.themeTweakerUI.firstElementChild;
2154                 Appearance.themeTweakerUIHelpWindow = Appearance.themeTweakerUI.query(".help-window");
2155                 Appearance.themeTweakerUISampleTextContainer = Appearance.themeTweakerUI.query("#theme-tweak-section-sample-text .sample-text-container");
2156                 Appearance.themeTweakerUIClippyContainer = Appearance.themeTweakerUI.query(".clippy-container");
2157                 Appearance.themeTweakerUIClippyControl = Appearance.themeTweakerUI.query("#theme-tweak-control-clippy");
2159                 //      Clicking the background overlay closes the theme tweaker.
2160                 Appearance.themeTweakerUI.addActivateEvent(Appearance.themeTweakerUIOverlayClicked, true);
2162                 //      Intercept clicks, so they don’t “fall through” the background overlay.
2163                 Array.from(Appearance.themeTweakerUI.children).forEach(themeTweakerUIWindow => {
2164                         themeTweakerUIWindow.addActivateEvent((event) => {
2165                                 event.stopPropagation();
2166                         }, true);
2167                 });
2169                 Appearance.themeTweakerUI.queryAll("input").forEach(field => {
2170                         /*      All input types in the theme tweaker receive a ‘change’ event 
2171                                 when their value is changed. (Range inputs, in particular, 
2172                                 receive this event when the user lets go of the handle.) This 
2173                                 means we should update the filters for the entire page, to match 
2174                                 the new setting.
2175                          */
2176                         field.addEventListener("change", Appearance.themeTweakerUIFieldValueChanged);
2178                         /*      Range inputs receive an ‘input’ event while being scrubbed, 
2179                                 updating “live” as the handle is moved. We don’t want to change 
2180                                 the filters for the actual page while this is happening, but we 
2181                                 do want to change the filters for the *sample text*, so the user
2182                                 can see what effects his changes are having, live, without 
2183                                 having to let go of the handle.
2184                          */
2185                         if (field.type == "range")
2186                                 field.addEventListener("input", Appearance.themeTweakerUIFieldInputReceived);
2187                 });
2189                 Appearance.themeTweakerUI.query(".help-button").addActivateEvent(Appearance.themeTweakerUIHelpButtonClicked);
2190                 Appearance.themeTweakerUI.query(".minimize-button").addActivateEvent(Appearance.themeTweakerUIMinimizeButtonClicked);
2191                 Appearance.themeTweakerUI.query(".close-button").addActivateEvent(Appearance.themeTweakerUICloseButtonClicked);
2192                 Appearance.themeTweakerUI.query(".reset-defaults-button").addActivateEvent(Appearance.themeTweakerUIResetDefaultsButtonClicked);
2193                 Appearance.themeTweakerUI.query(".main-window .cancel-button").addActivateEvent(Appearance.themeTweakerUICancelButtonClicked);
2194                 Appearance.themeTweakerUI.query(".main-window .ok-button").addActivateEvent(Appearance.themeTweakerUIOKButtonClicked);
2195                 Appearance.themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowCancelButtonClicked);
2196                 Appearance.themeTweakerUI.query(".help-window .ok-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowOKButtonClicked);
2198                 Appearance.themeTweakerUI.queryAll(".notch").forEach(notch => {
2199                         notch.addActivateEvent(Appearance.themeTweakerUISliderNotchClicked);
2200                 });
2202                 Appearance.themeTweakerUI.query(".clippy-close-button").addActivateEvent(Appearance.themeTweakerUIClippyCloseButtonClicked);
2204                 insertHeadHTML(`<style id="theme-tweaker-style"></style>`);
2205                 Appearance.themeTweakerStyleBlock = document.head.query("#theme-tweaker-style");
2207                 Appearance.themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
2208                 Appearance.themeTweakerUI.queryAll(".theme-selector > *:not(.select-theme)").forEach(element => {
2209                         element.remove();
2210                 });
2211                 Appearance.themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
2212                         button.addActivateEvent(Appearance.themeSelectButtonClicked);
2213                 });
2215                 Appearance.themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
2216                         button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
2217                 });
2219                 if (GW.isMobile == false)
2220                         Appearance.injectThemeTweakerToggle();
2221         },
2223         themeTweakerToggleHTML: () => {
2224                 return (`<div id="theme-tweaker-toggle">`
2225                                         + `<button 
2226                                                         type="button" 
2227                                                         tabindex="-1" 
2228                                                         title="Customize appearance [;]" 
2229                                                         accesskey=";"
2230                                                                 >&#xf1de;</button>`
2231                                 + `</div>`);
2232         },
2234         injectThemeTweakerToggle: () => {
2235                 GWLog("Appearance.injectThemeTweakerToggle");
2237                 if (GW.isMobile) {
2238                         if (Appearance.themeSelector == null)
2239                                 return;
2241                         Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", Appearance.themeTweakerToggleHTML());
2242                         Appearance.themeTweakerToggle = Appearance.themeSelector.query("#theme-tweaker-toggle");
2243                 } else {
2244                         Appearance.themeTweakerToggle = addUIElement(Appearance.themeTweakerToggleHTML());      
2245                 }
2247                 Appearance.themeTweakerToggle.query("button").addActivateEvent(Appearance.themeTweakerToggleClicked);
2248         },
2250         showThemeTweakerUI: () => {
2251                 GWLog("Appearance.showThemeTweakerUI");
2253                 if (query("link[href^='/css/theme_tweaker.css']") == null) {
2254                         //      Theme tweaker CSS needs to be loaded.
2256                         let themeTweakerStyleSheet = newElement("LINK", {
2257                                 "rel": "stylesheet",
2258                                 "href": "/css/theme_tweaker.css"
2259                         });
2261                         themeTweakerStyleSheet.addEventListener("load", (event) => {
2262                                 requestAnimationFrame(() => {
2263                                         themeTweakerStyleSheet.disabled = false;
2264                                 });
2265                                 Appearance.showThemeTweakerUI();
2266                         }, { once: true });
2268                         document.head.appendChild(themeTweakerStyleSheet);
2270                         return;
2271                 }
2273                 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.getSavedTheme();
2275                 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = (Appearance.currentFilters["invert"] == "100%");
2276                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2277                         let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2278                         slider.value = /^[0-9]+/.exec(Appearance.currentFilters[sliderName]) || slider.dataset["defaultValue"];
2279                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2280                 });
2282                 Appearance.toggleThemeTweakerUI();
2283         },
2285         toggleThemeTweakerUI: () => {
2286                 GWLog("Appearance.toggleThemeTweakerUI");
2288                 let show = (Appearance.themeTweakerUI.style.display == "none");
2290                 Appearance.themeTweakerUI.style.display = show ? "block" : "none";
2291                 Appearance.setThemeTweakerWindowMinimized(false);
2292                 Appearance.themeTweakerStyleBlock.innerHTML = show ? `#content, #ui-elements-container > div:not(#theme-tweaker-ui) { pointer-events: none; user-select: none; }` : "";
2294                 if (show) {
2295                         // Disable button.
2296                         Appearance.themeTweakerToggle.query("button").disabled = true;
2297                         // Focus invert checkbox.
2298                         Appearance.themeTweakerUI.query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
2299                         // Show sample text in appropriate font.
2300                         Appearance.updateThemeTweakerSampleText();
2301                         // Disable tab-selection of the search box.
2302                         setSearchBoxTabSelectable(false);
2303                         // Disable scrolling of the page.
2304                         togglePageScrolling(false);
2305                 } else {
2306                         // Re-enable button.
2307                         Appearance.themeTweakerToggle.query("button").disabled = false;
2308                         // Re-enable tab-selection of the search box.
2309                         setSearchBoxTabSelectable(true);
2310                         // Re-enable scrolling of the page.
2311                         togglePageScrolling(true);
2312                 }
2314                 // Set theme tweaker assistant visibility.
2315                 Appearance.themeTweakerUIClippyContainer.style.display = (Appearance.getSavedThemeTweakerClippyState() == true) ? "block" : "none";
2316         },
2318         setThemeTweakerWindowMinimized: (minimize) => {
2319                 GWLog("Appearance.setThemeTweakerWindowMinimized");
2321                 Appearance.themeTweakerUIMainWindow.query(".minimize-button").swapClasses([ "minimize", "maximize" ], (minimize ? 1 : 0));
2322                 Appearance.themeTweakerUIMainWindow.classList.toggle("minimized", minimize);
2323                 Appearance.themeTweakerUI.classList.toggle("main-window-minimized", minimize);
2324         },
2326         toggleThemeTweakerHelpWindow: () => {
2327                 GWLog("Appearance.toggleThemeTweakerHelpWindow");
2329                 Appearance.themeTweakerUIHelpWindow.style.display = Appearance.themeTweakerUIHelpWindow.style.display == "none" 
2330                                                                                                                 ? "block" 
2331                                                                                                                 : "none";
2332                 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2333                         // Focus theme tweaker assistant checkbox.
2334                         Appearance.themeTweakerUI.query("#theme-tweak-control-clippy").focus();
2335                         // Disable interaction on main theme tweaker window.
2336                         Appearance.themeTweakerUI.style.pointerEvents = "none";
2337                         Appearance.themeTweakerUIMainWindow.style.pointerEvents = "none";
2338                 } else {
2339                         // Re-enable interaction on main theme tweaker window.
2340                         Appearance.themeTweakerUI.style.pointerEvents = "auto";
2341                         Appearance.themeTweakerUIMainWindow.style.pointerEvents = "auto";
2342                 }
2343         },
2345         resetThemeTweakerUIDefaultState: () => {
2346                 GWLog("Appearance.resetThemeTweakerUIDefaultState");
2348                 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
2350                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2351                         let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2352                         slider.value = slider.dataset["defaultValue"];
2353                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2354                 });
2355         },
2357         updateThemeTweakerSampleText: () => {
2358                 GWLog("Appearance.updateThemeTweakerSampleText");
2360                 let sampleText = Appearance.themeTweakerUISampleTextContainer.query("#theme-tweak-section-sample-text .sample-text");
2362                 // This causes the sample text to take on the properties of the body text of a post.
2363                 sampleText.removeClass("body-text");
2364                 let bodyTextElement = query(".post-body") || query(".comment-body");
2365                 sampleText.addClass("body-text");
2366                 sampleText.style.color = bodyTextElement ? 
2367                         getComputedStyle(bodyTextElement).color : 
2368                         getComputedStyle(query("#content")).color;
2370                 // Here we find out what is the actual background color that will be visible behind
2371                 // the body text of posts, and set the sample text’s background to that.
2372                 let findStyleBackground = (selector) => {
2373                         return "#fff"; // FIXME
2374                         let x;
2375                         Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2376                                 if (rule.selectorText == selector)
2377                                         x = rule;
2378                         });
2379                         return x.style.backgroundColor;
2380                 };
2382                 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2383         },
2385         injectAppearanceAdjustUIToggle: () => {
2386                 GWLog("Appearance.injectAppearanceAdjustUIToggle");
2388                 Appearance.appearanceAdjustUIToggle = addUIElement(`<div id="appearance-adjust-ui-toggle"><button type="button" tabindex="-1">&#xf013;</button></div>`);
2389                 Appearance.appearanceAdjustUIToggle.query("button").addActivateEvent(Appearance.appearanceAdjustUIToggleButtonClicked);
2391                 if (  !GW.isMobile 
2392                         && Appearance.getSavedAppearanceAdjustUIToggleState() == true) {
2393                         Appearance.toggleAppearanceAdjustUI();
2394                 }
2395         },
2397         removeAppearanceAdjustUIToggle: () => {
2398                 GWLog("Appearance.removeAppearanceAdjustUIToggle");
2400                 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2401                         element.removeClass("engaged");
2402                 });
2403                 removeElement("#appearance-adjust-ui-toggle");
2404         },
2406         toggleAppearanceAdjustUI: () => {
2407                 GWLog("Appearance.toggleAppearanceAdjustUI");
2409                 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2410                         element.toggleClass("engaged");
2411                 });
2413                 if (GW.isMobile) {
2414                         clearTimeout(Appearance.themeSelectorInteractableTimer);
2415                         Appearance.setThemeSelectorInteractable(false);
2416                         Appearance.themeSelectorInteractableTimer = setTimeout(() => {
2417                                 Appearance.setThemeSelectorInteractable(true);
2418                         }, 200);
2419                 }
2420         },
2422         /**************************************************************************/
2423         /* EVENTS
2424          */
2426         /*      Theme selector close button (on mobile version of theme selector).
2427          */
2428         themeSelectorCloseButtonClicked: (event) => {
2429                 GWLog("Appearance.themeSelectorCloseButtonClicked");
2431                 Appearance.toggleAppearanceAdjustUI();
2432                 Appearance.saveAppearanceAdjustUIToggleState();
2433         },
2435         /*      “Cog” button (to toggle the appearance adjust UI widgets in “less” 
2436                 theme, or theme selector UI on mobile).
2437          */
2438         appearanceAdjustUIToggleButtonClicked: (event) => {
2439                 GWLog("Appearance.appearanceAdjustUIToggleButtonClicked");
2441                 Appearance.toggleAppearanceAdjustUI();
2442                 Appearance.saveAppearanceAdjustUIToggleState();
2443         },
2445         /*      Width adjust buttons (“normal”, “wide”, “fluid”).
2446          */
2447         widthAdjustButtonClicked: (event) => {
2448                 GWLog("Appearance.widthAdjustButtonClicked");
2450                 // Determine which setting was chosen (i.e., which button was clicked).
2451                 let selectedWidth = event.target.dataset.name;
2453                 //      Switch width.
2454                 Appearance.currentWidth = selectedWidth;
2456                 // Save the new setting.
2457                 Appearance.saveCurrentWidth();
2459                 // Save current visible comment
2460                 let visibleComment = getCurrentVisibleComment();
2462                 // Actually change the content width.
2463                 Appearance.setContentWidth(selectedWidth);
2464                 event.target.parentElement.childNodes.forEach(button => {
2465                         button.removeClass("selected");
2466                         button.disabled = false;
2467                 });
2468                 event.target.addClass("selected");
2469                 event.target.disabled = true;
2471                 // Make sure the accesskey (to cycle to the next width) is on the right button.
2472                 Appearance.setWidthAdjustButtonsAccesskey();
2474                 // Regenerate images overlay.
2475                 generateImagesOverlay();
2477                 if (visibleComment)
2478                         visibleComment.scrollIntoView();
2479         },
2481         /*      Theme selector buttons (“A” through “I”).
2482          */
2483         themeSelectButtonClicked: (event) => {
2484                 GWLog("Appearance.themeSelectButtonClicked");
2486                 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
2487                 let save = (Appearance.themeTweakerUI.contains(event.target) == false);
2488                 Appearance.setTheme(themeName, save);
2489                 if (GW.isMobile)
2490                         Appearance.toggleAppearanceAdjustUI();
2491         },
2493         /*      The text size adjust (“-”, “A”, “+”) buttons.
2494          */
2495         textSizeAdjustButtonClicked: (event) => {
2496                 GWLog("Appearance.textSizeAdjustButtonClicked");
2498                 var zoomFactor = Appearance.currentTextZoom;
2499                 if (event.target.hasClass("decrease")) {
2500                         zoomFactor -= 0.05;
2501                 } else if (event.target.hasClass("increase")) {
2502                         zoomFactor += 0.05;
2503                 } else {
2504                         zoomFactor = Appearance.defaultTextZoom;
2505                 }
2507                 let save = (   Appearance.textSizeAdjustmentWidget != null 
2508                                         && Appearance.textSizeAdjustmentWidget.contains(event.target));
2509                 Appearance.setTextZoom(zoomFactor, save);
2510         },
2512         /*      Theme tweaker toggle button.
2513          */
2514         themeTweakerToggleClicked: (event) => {
2515                 GWLog("Appearance.themeTweakerToggleClicked");
2517                 Appearance.showThemeTweakerUI();
2518         },
2520         /***************************/
2521         /*      Theme tweaker UI events.
2522          */
2524         /*      Key pressed while theme tweaker is open.
2525          */
2526         themeTweakerUIKeyPressed: (event) => {
2527                 GWLog("Appearance.themeTweakerUIKeyPressed");
2529                 if (event.key == "Escape") {
2530                         if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2531                                 Appearance.toggleThemeTweakerHelpWindow();
2532                                 Appearance.themeTweakerResetSettings();
2533                         } else if (Appearance.themeTweakerUI.style.display != "none") {
2534                                 Appearance.toggleThemeTweakerUI();
2535                                 Appearance.themeTweakReset();
2536                         }
2537                 } else if (event.key == "Enter") {
2538                         if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2539                                 Appearance.toggleThemeTweakerHelpWindow();
2540                                 Appearance.themeTweakerSaveSettings();
2541                         } else if (Appearance.themeTweakerUI.style.display != "none") {
2542                                 Appearance.toggleThemeTweakerUI();
2543                                 Appearance.themeTweakSave();
2544                         }
2545                 }
2546         },
2548         /*      Theme tweaker overlay clicked.
2549          */
2550         themeTweakerUIOverlayClicked: (event) => {
2551                 GWLog("Appearance.themeTweakerUIOverlayClicked");
2553                 if (event.type == "mousedown") {
2554                         Appearance.themeTweakerUI.style.opacity = "0.01";
2555                 } else {
2556                         Appearance.toggleThemeTweakerUI();
2557                         Appearance.themeTweakerUI.style.opacity = "1.0";
2558                         Appearance.themeTweakReset();
2559                 }
2560         },
2562         /*      In the theme tweaker, a slider clicked, or released after drag; or a
2563                 checkbox clicked (either in the main theme tweaker UI, or in the help
2564                 window).
2565          */
2566         themeTweakerUIFieldValueChanged: (event) => {
2567                 GWLog("Appearance.themeTweakerUIFieldValueChanged");
2569                 if (event.target.id == "theme-tweak-control-invert") {
2570                         Appearance.currentFilters["invert"] = event.target.checked ? "100%" : "0%";
2571                 } else if (event.target.type == "range") {
2572                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2573                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2574                         Appearance.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2575                 } else if (event.target.id == "theme-tweak-control-clippy") {
2576                         Appearance.themeTweakerUIClippyContainer.style.display = event.target.checked ? "block" : "none";
2577                 }
2579                 // Clear the sample text filters.
2580                 Appearance.themeTweakerUISampleTextContainer.style.filter = "";
2582                 // Apply the new filters globally.
2583                 Appearance.applyFilters();
2584         },
2586         /*      Theme tweaker slider dragged (live-update event).
2587          */
2588         themeTweakerUIFieldInputReceived: (event) => {
2589                 GWLog("Appearance.themeTweakerUIFieldInputReceived");
2591                 let sampleTextFilters = Appearance.currentFilters;
2592                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2593                 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2594                 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2596                 Appearance.themeTweakerUISampleTextContainer.style.filter = Appearance.filterStringFromFilters(sampleTextFilters);
2597         },
2599         /*      Close button in main theme tweaker UI (title bar).
2600          */
2601         themeTweakerUICloseButtonClicked: (event) => {
2602                 GWLog("Appearance.themeTweakerUICloseButtonClicked");
2604                 Appearance.toggleThemeTweakerUI();
2605                 Appearance.themeTweakReset();
2606         },
2608         /*      Minimize button in main theme tweaker UI (title bar).
2609          */
2610         themeTweakerUIMinimizeButtonClicked: (event) => {
2611                 GWLog("Appearance.themeTweakerUIMinimizeButtonClicked");
2613                 Appearance.setThemeTweakerWindowMinimized(event.target.hasClass("minimize"));
2614         },
2616         /*      Help (“?”) button in main theme tweaker UI (title bar).
2617          */
2618         themeTweakerUIHelpButtonClicked: (event) => {
2619                 GWLog("Appearance.themeTweakerUIHelpButtonClicked");
2621                 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
2622                 Appearance.toggleThemeTweakerHelpWindow();
2623         },
2625         /*      “Reset Defaults” button in main theme tweaker UI.
2626          */
2627         themeTweakerUIResetDefaultsButtonClicked: (event) => {
2628                 GWLog("Appearance.themeTweakerUIResetDefaultsButtonClicked");
2630                 Appearance.themeTweakResetDefaults();
2631                 Appearance.resetThemeTweakerUIDefaultState();
2632         },
2634         /*      “Cancel” button in main theme tweaker UI.
2635          */
2636         themeTweakerUICancelButtonClicked: (event) => {
2637                 GWLog("Appearance.themeTweakerUICancelButtonClicked");
2639                 Appearance.toggleThemeTweakerUI();
2640                 Appearance.themeTweakReset();
2641         },
2643         /*      “OK” button in main theme tweaker UI.
2644          */
2645         themeTweakerUIOKButtonClicked: (event) => {
2646                 GWLog("Appearance.themeTweakerUIOKButtonClicked");
2648                 Appearance.toggleThemeTweakerUI();
2649                 Appearance.themeTweakSave();
2650         },
2652         /*      “Cancel” button in theme tweaker help window.
2653          */
2654         themeTweakerUIHelpWindowCancelButtonClicked: (event) => {
2655                 GWLog("Appearance.themeTweakerUIHelpWindowCancelButtonClicked");
2657                 Appearance.toggleThemeTweakerHelpWindow();
2658                 Appearance.themeTweakerResetSettings();
2659         },
2661         /*      “OK” button in theme tweaker help window.
2662          */
2663         themeTweakerUIHelpWindowOKButtonClicked: (event) => {
2664                 GWLog("Appearance.themeTweakerUIHelpWindowOKButtonClicked");
2666                 Appearance.toggleThemeTweakerHelpWindow();
2667                 Appearance.themeTweakerSaveSettings();
2668         },
2670         /*      The notch in the theme tweaker sliders (to reset the slider to its
2671                 default value).
2672          */
2673         themeTweakerUISliderNotchClicked: (event) => {
2674                 GWLog("Appearance.themeTweakerUISliderNotchClicked");
2676                 let slider = event.target.parentElement.query("input[type='range']");
2677                 slider.value = slider.dataset["defaultValue"];
2678                 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset["labelSuffix"];
2679                 Appearance.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset["valueSuffix"];
2680                 Appearance.applyFilters();
2681         },
2683         /*      The close button in the “Bobby the Basilisk” help message.
2684          */
2685         themeTweakerUIClippyCloseButtonClicked: (event) => {
2686                 GWLog("Appearance.themeTweakerUIClippyCloseButtonClicked");
2688                 Appearance.themeTweakerUIClippyContainer.style.display = "none";
2689                 Appearance.themeTweakerUIClippyControl.checked = false;
2690                 Appearance.saveThemeTweakerClippyState();
2691         }
2694 function setSearchBoxTabSelectable(selectable) {
2695         GWLog("setSearchBoxTabSelectable");
2696         query("input[type='search']").tabIndex = selectable ? "" : "-1";
2697         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2700 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
2701 // otherwise, show it.
2702 function updatePostNavUIVisibility() {
2703         GWLog("updatePostNavUIVisibility");
2704         var hidePostNavUIToggle = true;
2705         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
2706                 if (getComputedStyle(element).visibility == "visible" ||
2707                         element.style.visibility == "visible" ||
2708                         element.style.visibility == "unset")
2709                         hidePostNavUIToggle = false;
2710         });
2711         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
2712                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
2713         });
2716 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
2717 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
2718 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
2719 function updateSiteNavUIState(event) {
2720         GWLog("updateSiteNavUIState");
2721         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
2722         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
2723                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
2724                                                                                                                 0;
2725         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
2726                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
2727                                                                                                          0;
2728         GW.scrollState.lastScrollTop = newScrollTop;
2730         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
2731         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
2732                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
2733                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) 
2734                         Appearance.toggleAppearanceAdjustUI();
2735         }
2737         // On mobile, make site nav UI translucent on ANY scroll down.
2738         if (GW.isMobile)
2739                 GW.scrollState.siteNavUIElements.forEach(element => {
2740                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
2741                         else element.removeClass("translucent-on-scroll");
2742                 });
2744         // Show site nav UI when scrolling a full page up, or to the top.
2745         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
2746                  GW.scrollState.lastScrollTop == 0) &&
2747                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
2748                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
2750         // On desktop, show appearance adjust UI when scrolling to the top.
2751         if ((!GW.isMobile) && 
2752                 (GW.scrollState.lastScrollTop == 0) &&
2753                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
2754                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) 
2755                         Appearance.toggleAppearanceAdjustUI();
2758 /*********************/
2759 /* PAGE QUICK-NAV UI */
2760 /*********************/
2762 function injectQuickNavUI() {
2763         GWLog("injectQuickNavUI");
2764         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2765         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
2766         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
2767         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
2768         ` + "</div>");
2771 /**********************/
2772 /* NEW COMMENT NAV UI */
2773 /**********************/
2775 function injectNewCommentNavUI(newCommentsCount) {
2776         GWLog("injectNewCommentNavUI");
2777         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
2778         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
2779         <span class='new-comments-count'></span>
2780         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
2781         + "</div>");
2783         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2784                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2785                         GWLog("GW.commentQuicknavButtonClicked");
2786                         scrollToNewComment(/next/.test(event.target.className));
2787                         event.target.blur();
2788                 });
2789         });
2791         document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => { 
2792                 GWLog("GW.commentQuicknavKeyPressed");
2793                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2794                 if (event.key == ",") scrollToNewComment(false);
2795                 if (event.key == ".") scrollToNewComment(true)
2796         });
2798         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2799         + `<span>Since:</span>`
2800         + `<input type='text' class='hns-date'></input>`
2801         + "</div>");
2803         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2804                 GWLog("GW.hnsDatePickerValueChanged");
2805                 let hnsDate = time_fromHuman(event.target.value);
2806                 if(hnsDate) {
2807                         setHistoryLastVisitedDate(hnsDate);
2808                         let newCommentsCount = highlightCommentsSince(hnsDate);
2809                         updateNewCommentNavUI(newCommentsCount);
2810                 }
2811         }, false);
2813         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2814                 GWLog("GW.newCommentsCountClicked");
2815                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2816                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2817         });
2820 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2821 function time_fromHuman(string) {
2822         /* Convert a human-readable date into a JS timestamp */
2823         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2824                 string = string.replace(' ', 'T');  // revert nice spacing
2825                 string += ':00.000Z';  // complete ISO 8601 date
2826                 time = Date.parse(string);  // milliseconds since epoch
2828                 // browsers handle ISO 8601 without explicit timezone differently
2829                 // thus, we have to fix that by hand
2830                 time += (new Date()).getTimezoneOffset() * 60e3;
2831         } else {
2832                 string = string.replace(' at', '');
2833                 time = Date.parse(string);  // milliseconds since epoch
2834         }
2835         return time;
2838 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2839         GWLog("updateNewCommentNavUI");
2840         // Update the new comments count.
2841         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2842         newCommentsCountLabel.innerText = newCommentsCount;
2843         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2845         // Update the date picker field.
2846         if (hnsDate != -1) {
2847                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2848         }
2851 /********************************/
2852 /* COMMENTS VIEW MODE SELECTION */
2853 /********************************/
2855 function injectCommentsViewModeSelector() {
2856         GWLog("injectCommentsViewModeSelector");
2857         let commentsContainer = query("#comments");
2858         if (commentsContainer == null) return;
2860         let currentModeThreaded = (location.href.search("chrono=t") == -1);
2861         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2863         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2864         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
2865         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
2866         + "</div>");
2868 //      commentsViewModeSelector.queryAll("a").forEach(button => {
2869 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2870 //      });
2872         if (!currentModeThreaded) {
2873                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2874                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2875                         commentParentLink.addClass("inline-author");
2876                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2877                 });
2879                 queryAll(".comment-child-links a").forEach(commentChildLink => {
2880                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
2881                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2882                 });
2884                 rectifyChronoModeCommentChildLinks();
2886                 commentsContainer.addClass("chrono");
2887         } else {
2888                 commentsContainer.addClass("threaded");
2889         }
2891         // Remove extraneous top-level comment thread in chrono mode.
2892         let topLevelCommentThread = query("#comments > .comment-thread");
2893         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2896 // function commentsViewModeSelectorButtonClicked(event) {
2897 //      event.preventDefault();
2898 // 
2899 //      var newDocument;
2900 //      let request = new XMLHttpRequest();
2901 //      request.open("GET", event.target.href);
2902 //      request.onreadystatechange = () => {
2903 //              if (request.readyState != 4) return;
2904 //              newDocument = htmlToElement(request.response);
2905 // 
2906 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2907 // 
2908 //              // Update the buttons.
2909 //              event.target.addClass("selected");
2910 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
2911 // 
2912 //              // Update the #comments container.
2913 //              let commentsContainer = query("#comments");
2914 //              commentsContainer.removeClass(classes.old);
2915 //              commentsContainer.addClass(classes.new);
2916 // 
2917 //              // Update the content.
2918 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2919 //      };
2920 //      request.send();
2921 // }
2922 // 
2923 // function htmlToElement(html) {
2924 //     let template = newElement("TEMPLATE", { }, { "innerHTML": html.trim() });
2925 //     return template.content;
2926 // }
2928 function rectifyChronoModeCommentChildLinks() {
2929         GWLog("rectifyChronoModeCommentChildLinks");
2930         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2931                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2932                 let childLinks = commentChildLinksContainer.queryAll("a");
2933                 childLinks.forEach((link, index) => {
2934                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2935                 });
2937                 // Sort by date.
2938                 let childLinksArray = Array.from(childLinks)
2939                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2940                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2941         });
2943 function childrenOfComment(commentID) {
2944         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2945                 let commentParentLink = commentItem.query("a.comment-parent-link");
2946                 return ((commentParentLink||{}).hash == "#" + commentID);
2947         });
2950 /********************************/
2951 /* COMMENTS LIST MODE SELECTION */
2952 /********************************/
2954 function injectCommentsListModeSelector() {
2955         GWLog("injectCommentsListModeSelector");
2956         if (query("#content > .comment-thread") == null) return;
2958         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2959         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2960         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2961         + "</div>";
2963         if (query(".sublevel-nav") || query("#top-nav-bar")) {
2964                 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2965         } else {
2966                 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2967         }
2968         let commentsListModeSelector = query("#comments-list-mode-selector");
2970         commentsListModeSelector.queryAll("button").forEach(button => {
2971                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2972                         GWLog("GW.commentsListModeSelectButtonClicked");
2973                         event.target.parentElement.queryAll("button").forEach(button => {
2974                                 button.removeClass("selected");
2975                                 button.disabled = false;
2976                                 button.accessKey = '`';
2977                         });
2978                         localStorage.setItem("comments-list-mode", event.target.className);
2979                         event.target.addClass("selected");
2980                         event.target.disabled = true;
2981                         event.target.removeAttribute("accesskey");
2983                         if (event.target.hasClass("expanded")) {
2984                                 query("#content").removeClass("compact");
2985                         } else {
2986                                 query("#content").addClass("compact");
2987                         }
2988                 });
2989         });
2991         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2992         if (savedMode == "compact")
2993                 query("#content").addClass("compact");
2994         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2995         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2996         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2998         if (GW.isMobile) {
2999                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
3000                         commentParentLink.addActivateEvent(function (event) {
3001                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
3002                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
3003                         }, false);
3004                 });
3005         }
3008 /**********************/
3009 /* SITE NAV UI TOGGLE */
3010 /**********************/
3012 function injectSiteNavUIToggle() {
3013         GWLog("injectSiteNavUIToggle");
3014         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
3015         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
3016                 GWLog("GW.siteNavUIToggleButtonClicked");
3017                 toggleSiteNavUI();
3018                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
3019         });
3021         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
3023 function removeSiteNavUIToggle() {
3024         GWLog("removeSiteNavUIToggle");
3025         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3026                 element.removeClass("engaged");
3027         });
3028         removeElement("#site-nav-ui-toggle");
3030 function toggleSiteNavUI() {
3031         GWLog("toggleSiteNavUI");
3032         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3033                 element.toggleClass("engaged");
3034                 element.removeClass("translucent-on-scroll");
3035         });
3038 /**********************/
3039 /* POST NAV UI TOGGLE */
3040 /**********************/
3042 function injectPostNavUIToggle() {
3043         GWLog("injectPostNavUIToggle");
3044         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
3045         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
3046                 GWLog("GW.postNavUIToggleButtonClicked");
3047                 togglePostNavUI();
3048                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
3049         });
3051         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
3053 function removePostNavUIToggle() {
3054         GWLog("removePostNavUIToggle");
3055         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3056                 element.removeClass("engaged");
3057         });
3058         removeElement("#post-nav-ui-toggle");
3060 function togglePostNavUI() {
3061         GWLog("togglePostNavUI");
3062         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3063                 element.toggleClass("engaged");
3064         });
3067 /**************************/
3068 /* WORD COUNT & READ TIME */
3069 /**************************/
3071 function toggleReadTimeOrWordCount(addWordCountClass) {
3072         GWLog("toggleReadTimeOrWordCount");
3073         queryAll(".post-meta .read-time").forEach(element => {
3074                 if (addWordCountClass) element.addClass("word-count");
3075                 else element.removeClass("word-count");
3077                 let titleParts = /(\S+)(.+)$/.exec(element.title);
3078                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
3079         });
3082 /**************************/
3083 /* PROMPT TO SAVE CHANGES */
3084 /**************************/
3086 function enableBeforeUnload() {
3087         window.onbeforeunload = function () { return true; };
3089 function disableBeforeUnload() {
3090         window.onbeforeunload = null;
3093 /***************************/
3094 /* ORIGINAL POSTER BADGING */
3095 /***************************/
3097 function markOriginalPosterComments() {
3098         GWLog("markOriginalPosterComments");
3099         let postAuthor = query(".post .author");
3100         if (postAuthor == null) return;
3102         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
3103                 if (author.dataset.userid == postAuthor.dataset.userid ||
3104                         (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
3105                         author.addClass("original-poster");
3106                         author.title += "Original poster";
3107                 }
3108         });
3111 /********************************/
3112 /* EDIT POST PAGE SUBMIT BUTTON */
3113 /********************************/
3115 function setEditPostPageSubmitButtonText() {
3116         GWLog("setEditPostPageSubmitButtonText");
3117         if (!query("#content").hasClass("edit-post-page")) return;
3119         queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
3120                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
3121                         GWLog("GW.postSectionSelectorValueChanged");
3122                         updateEditPostPageSubmitButtonText();
3123                 });
3124         });
3126         updateEditPostPageSubmitButtonText();
3128 function updateEditPostPageSubmitButtonText() {
3129         GWLog("updateEditPostPageSubmitButtonText");
3130         let submitButton = query("input[type='submit']");
3131         if (query("input#drafts").checked == true) 
3132                 submitButton.value = "Save Draft";
3133         else if (query(".posting-controls").hasClass("edit-existing-post"))
3134                 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
3135         else
3136                 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
3139 /*****************/
3140 /* ANTI-KIBITZER */
3141 /*****************/
3143 function numToAlpha(n) {
3144         let ret = "";
3145         do {
3146                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
3147                 n = Math.floor((n / 26) - 1);
3148         } while (n >= 0);
3149         return ret;
3152 function activateAntiKibitzer() {
3153         GWLog("activateAntiKibitzer");
3155         //      Activate anti-kibitzer mode (if needed).
3156         if (localStorage.getItem("antikibitzer") == "true")
3157                 toggleAntiKibitzerMode();
3159         //      Remove temporary CSS that hides the authors and karma values.
3160         removeElement("#antikibitzer-temp");
3162         //      Inject controls (if desktop).
3163         if (GW.isMobile == false)
3164                 injectAntiKibitzerToggle();
3167 function injectAntiKibitzerToggle() {
3168         GWLog("injectAntiKibitzerToggle");
3170         let antiKibitzerHTML = `<div id="anti-kibitzer-toggle">
3171                 <button type="button" tabindex="-1" accesskey="g" title="Toggle anti-kibitzer (show/hide authors & karma values) [g]"></button>
3172         </div>`;
3174         if (GW.isMobile) {
3175                 if (Appearance.themeSelector == null)
3176                         return;
3178                 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", antiKibitzerHTML);
3179         } else {
3180                 addUIElement(antiKibitzerHTML); 
3181         }
3183         //      Activate anti-kibitzer toggle button.
3184         query("#anti-kibitzer-toggle button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
3185                 GWLog("GW.antiKibitzerToggleButtonClicked");
3186                 if (   query("#anti-kibitzer-toggle").hasClass("engaged")
3187                         && !event.shiftKey 
3188                         && !confirm("Are you sure you want to turn OFF the anti-kibitzer?\n\n(This will reveal the authors and karma values of all posts and comments!)")) {
3189                         event.target.blur();
3190                         return;
3191                 }
3193                 toggleAntiKibitzerMode();
3194                 event.target.blur();
3195         });
3198 function toggleAntiKibitzerMode() {
3199         GWLog("toggleAntiKibitzerMode");
3200         // This will be the URL of the user's own page, if logged in, or the URL of
3201         // the login page otherwise.
3202         let userTabTarget = query("#nav-item-login .nav-inner").href;
3203         let pageHeadingElement = query("h1.page-main-heading");
3205         let userCount = 0;
3206         let userFakeName = { };
3208         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
3210         let postAuthor = query(".post-page .post-meta .author");
3211         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
3213         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
3214         if (antiKibitzerToggle.hasClass("engaged")) {
3215                 localStorage.setItem("antikibitzer", "false");
3217                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
3218                 if (redirectTarget) {
3219                         window.location = redirectTarget;
3220                         return;
3221                 }
3223                 // Individual comment page title and header
3224                 if (query(".individual-thread-page")) {
3225                         let replacer = (node) => {
3226                                 if (!node) return;
3227                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
3228                         }
3229                         replacer(query("title:not(.fake-title)"));
3230                         replacer(query("#content > h1"));
3231                 }
3233                 // Author names/links.
3234                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
3235                         author.textContent = author.dataset["trueName"];
3236                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
3238                         author.removeClass("redacted");
3239                 });
3240                 // Post/comment karma values.
3241                 queryAll(".karma-value.redacted").forEach(karmaValue => {
3242                         karmaValue.innerHTML = karmaValue.dataset["trueValue"];
3244                         karmaValue.removeClass("redacted");
3245                 });
3246                 // Link post domains.
3247                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
3248                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
3250                         linkPostDomain.removeClass("redacted");
3251                 });
3253                 antiKibitzerToggle.removeClass("engaged");
3254         } else {
3255                 localStorage.setItem("antikibitzer", "true");
3257                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
3258                 if (redirectTarget) {
3259                         window.location = redirectTarget;
3260                         return;
3261                 }
3263                 // Individual comment page title and header
3264                 if (query(".individual-thread-page")) {
3265                         let replacer = (node) => {
3266                                 if (!node) return;
3267                                 node.dataset["trueContent"] = node.firstChild.wholeText;
3268                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
3269                                 node.firstChild.replaceWith(newText);
3270                         }
3271                         replacer(query("title:not(.fake-title)"));
3272                         replacer(query("#content > h1"));
3273                 }
3275                 removeElement("title.fake-title");
3277                 // Author names/links.
3278                 queryAll(".author, .inline-author").forEach(author => {
3279                         // Skip own posts/comments.
3280                         if (author.hasClass("own-user-author"))
3281                                 return;
3283                         let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
3285                         if(!userid) return;
3287                         author.dataset["trueName"] = author.textContent;
3288                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
3290                         if (/\/user/.test(author.href)) {
3291                                 author.dataset["trueLink"] = author.pathname;
3292                                 author.href = "/user?id=" + author.dataset["userid"];
3293                         }
3295                         author.addClass("redacted");
3296                 });
3297                 // Post/comment karma values.
3298                 queryAll(".karma-value").forEach(karmaValue => {
3299                         // Skip own posts/comments.
3300                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
3301                                 return;
3303                         karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
3304                         karmaValue.innerHTML = "##<span> points</span>";
3306                         karmaValue.addClass("redacted");
3307                 });
3308                 // Link post domains.
3309                 queryAll(".link-post-domain").forEach(linkPostDomain => {
3310                         // Skip own posts/comments.
3311                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
3312                                 return;
3314                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
3315                         linkPostDomain.textContent = "redacted.domain.tld";
3317                         linkPostDomain.addClass("redacted");
3318                 });
3320                 antiKibitzerToggle.addClass("engaged");
3321         }
3324 /*******************************/
3325 /* COMMENT SORT MODE SELECTION */
3326 /*******************************/
3328 var CommentSortMode = Object.freeze({
3329         TOP:            "top",
3330         NEW:            "new",
3331         OLD:            "old",
3332         HOT:            "hot"
3334 function sortComments(mode) {
3335         GWLog("sortComments");
3336         let commentsContainer = query("#comments");
3338         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
3339         commentsContainer.addClass("sorting");
3341         GW.commentValues = { };
3342         let clonedCommentsContainer = commentsContainer.cloneNode(true);
3343         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
3344                 var comparator;
3345                 switch (mode) {
3346                 case CommentSortMode.NEW:
3347                         comparator = (a,b) => commentDate(b) - commentDate(a);
3348                         break;
3349                 case CommentSortMode.OLD:
3350                         comparator = (a,b) => commentDate(a) - commentDate(b);
3351                         break;
3352                 case CommentSortMode.HOT:
3353                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
3354                         break;
3355                 case CommentSortMode.TOP:
3356                 default:
3357                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
3358                         break;
3359                 }
3360                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
3361         });
3362         removeElement(commentsContainer.lastChild);
3363         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
3364         GW.commentValues = { };
3366         if (loggedInUserId) {
3367                 // Re-activate vote buttons.
3368                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
3369                         voteButton.addActivateEvent(voteButtonClicked);
3370                 });
3372                 // Re-activate comment action buttons.
3373                 commentsContainer.queryAll(".action-button").forEach(button => {
3374                         button.addActivateEvent(GW.commentActionButtonClicked);
3375                 });
3376         }
3378         // Re-activate comment-minimize buttons.
3379         queryAll(".comment-minimize-button").forEach(button => {
3380                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
3381         });
3383         // Re-add comment parent popups.
3384         addCommentParentPopups();
3385         
3386         // Redo new-comments highlighting.
3387         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
3389         requestAnimationFrame(() => {
3390                 commentsContainer.removeClass("sorting");
3391                 commentsContainer.addClass("sorted-" + mode);
3392         });
3394 function commentKarmaValue(commentOrSelector) {
3395         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3396         try {
3397                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
3398         } catch(e) {return null};
3400 function commentDate(commentOrSelector) {
3401         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3402         try {
3403                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
3404         } catch(e) {return null};
3406 function commentVoteCount(commentOrSelector) {
3407         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3408         try {
3409                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
3410         } catch(e) {return null};
3413 function injectCommentsSortModeSelector() {
3414         GWLog("injectCommentsSortModeSelector");
3415         let topCommentThread = query("#comments > .comment-thread");
3416         if (topCommentThread == null) return;
3418         // Do not show sort mode selector if there is no branching in comment tree.
3419         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
3421         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
3422                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
3423                 "</div>";
3424         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
3425         let commentsSortModeSelector = query("#comments-sort-mode-selector");
3427         commentsSortModeSelector.queryAll("button").forEach(button => {
3428                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
3429                         GWLog("GW.commentsSortModeSelectButtonClicked");
3430                         event.target.parentElement.queryAll("button").forEach(button => {
3431                                 button.removeClass("selected");
3432                                 button.disabled = false;
3433                         });
3434                         event.target.addClass("selected");
3435                         event.target.disabled = true;
3437                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
3438                         setCommentsSortModeSelectButtonsAccesskey();
3439                 });
3440         });
3442         // TODO: Make this actually get the current sort mode (if that's saved).
3443         // TODO: Also change the condition here to properly get chrono/threaded mode,
3444         // when that is properly done with cookies.
3445         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
3446         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
3447         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
3448         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
3449         setCommentsSortModeSelectButtonsAccesskey();
3452 function setCommentsSortModeSelectButtonsAccesskey() {
3453         GWLog("setCommentsSortModeSelectButtonsAccesskey");
3454         queryAll("#comments-sort-mode-selector button").forEach(button => {
3455                 button.removeAttribute("accesskey");
3456                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
3457         });
3458         let selectedButton = query("#comments-sort-mode-selector button.selected");
3459         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
3460         nextButtonInCycle.accessKey = "z";
3461         nextButtonInCycle.title += " [z]";
3464 /*************************/
3465 /* COMMENT PARENT POPUPS */
3466 /*************************/
3468 function previewPopupsEnabled() {
3469         let isDisabled = localStorage.getItem("preview-popups-disabled");
3470         return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
3473 function setPreviewPopupsEnabled(state) {
3474         localStorage.setItem("preview-popups-disabled", !state);
3475         updatePreviewPopupToggle();
3478 function updatePreviewPopupToggle() {
3479         let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
3480         query("#preview-popup-toggle").setAttribute("style", style);
3483 function injectPreviewPopupToggle() {
3484         GWLog("injectPreviewPopupToggle");
3486         let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
3487         // This is required because Chrome can't use filters on an externally used SVG element.
3488         fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
3489         updatePreviewPopupToggle();
3490         toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
3493 var currentPreviewPopup = { };
3495 function removePreviewPopup(previewPopup) {
3496         if(previewPopup.element)
3497                 removeElement(previewPopup.element);
3499         if(previewPopup.timeout)
3500                 clearTimeout(previewPopup.timeout);
3502         if(currentPreviewPopup.pointerListener)
3503                 window.removeEventListener("pointermove", previewPopup.pointerListener);
3505         if(currentPreviewPopup.mouseoutListener)
3506                 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3508         if(currentPreviewPopup.scrollListener)
3509                 window.removeEventListener("scroll", previewPopup.scrollListener);
3511         currentPreviewPopup = { };
3514 document.addEventListener("visibilitychange", () => {
3515         if(document.visibilityState != "visible") {
3516                 removePreviewPopup(currentPreviewPopup);
3517         }
3520 function addCommentParentPopups() {
3521         GWLog("addCommentParentPopups");
3522         //if (!query("#content").hasClass("comment-thread-page")) return;
3524         queryAll("a[href]").forEach(linkTag => {
3525                 let linkHref = linkTag.getAttribute("href");
3527                 let url;
3528                 try { url = new URL(linkHref, window.location.href); }
3529                 catch(e) { }
3530                 if(!url) return;
3532                 if(GW.sites[url.host]) {
3533                         let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
3534                         
3535                         if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
3536                                 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
3537                                         if(event.pointerType == "touch") return;
3538                                         GWLog("GW.commentParentLinkMouseOver");
3539                                         removePreviewPopup(currentPreviewPopup);
3540                                         let parentID = linkHref;
3541                                         var parent, popup;
3542                                         if (!(parent = (query(parentID)||{}).firstChild)) return;
3543                                         var highlightClassName;
3544                                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
3545                                                 parentHighlightClassName = "comment-item-highlight-faint";
3546                                                 popup = parent.cloneNode(true);
3547                                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
3548                                                 linkTag.addEventListener("mouseout", (event) => {
3549                                                         removeElement(popup);
3550                                                 }, {once: true});
3551                                                 linkTag.closest(".comments > .comment-thread").appendChild(popup);
3552                                         } else {
3553                                                 parentHighlightClassName = "comment-item-highlight";
3554                                         }
3555                                         parent.parentNode.addClass(parentHighlightClassName);
3556                                         linkTag.addEventListener("mouseout", (event) => {
3557                                                 parent.parentNode.removeClass(parentHighlightClassName);
3558                                         }, {once: true});
3559                                 });
3560                         }
3561                         else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
3562                                 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
3563                                 && !(url.searchParams.get('format'))
3564                                 && !linkTag.closest("nav:not(.post-nav-links)")
3565                                 && (!url.hash || linkCommentId)
3566                                 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
3567                                 linkTag.addEventListener("pointerover", event => {
3568                                         if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
3569                                         if(currentPreviewPopup.linkTag) return;
3570                                         linkTag.createPreviewPopup();
3571                                 });
3572                                 linkTag.createPreviewPopup = function() {
3573                                         removePreviewPopup(currentPreviewPopup);
3575                                         currentPreviewPopup = {linkTag: linkTag};
3576                                         
3577                                         let popup = newElement("IFRAME");
3578                                         currentPreviewPopup.element = popup;
3580                                         let popupTarget = linkHref;
3581                                         if(popupTarget.match(/#comment-/)) {
3582                                                 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
3583                                         }
3584                                         // 'theme' attribute is required for proper caching
3585                                         popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview");
3586                                         popup.addClass("preview-popup");
3587                                         
3588                                         let linkRect = linkTag.getBoundingClientRect();
3590                                         if(linkRect.right + 710 < window.innerWidth)
3591                                                 popup.style.left = linkRect.right + 10 + "px";
3592                                         else
3593                                                 popup.style.right = "10px";
3595                                         popup.style.width = "700px";
3596                                         popup.style.height = "500px";
3597                                         popup.style.visibility = "hidden";
3598                                         popup.style.transition = "none";
3600                                         let recenter = function() {
3601                                                 let popupHeight = 500;
3602                                                 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
3603                                                         let popupContent = popup.contentDocument.querySelector("#content");
3604                                                         if(popupContent) {
3605                                                                 popupHeight = popupContent.clientHeight + 2;
3606                                                                 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
3607                                                                 popup.style.height = popupHeight + "px";
3608                                                         }
3609                                                 }
3610                                                 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
3611                                         }
3613                                         recenter();
3615                                         query('#content').insertAdjacentElement("beforeend", popup);
3617                                         let clickListener = event => {
3618                                                 if(!event.target.closest("a, input, label")
3619                                                    && !event.target.closest("popup-hide-button")) {
3620                                                         window.location = linkHref;
3621                                                 }
3622                                         };
3624                                         popup.addEventListener("load", () => {
3625                                                 let hideButton = newElement("DIV", {
3626                                                         "class": "popup-hide-button"
3627                                                 }, {
3628                                                         "innerHTML": "&#xf070;"
3629                                                 });
3630                                                 hideButton.onclick = (event) => {
3631                                                         removePreviewPopup(currentPreviewPopup);
3632                                                         setPreviewPopupsEnabled(false);
3633                                                         event.stopPropagation();
3634                                                 }
3635                                                 popup.contentDocument.body.appendChild(hideButton);
3636                                                 
3637                                                 let popupBody = popup.contentDocument.body;
3638                                                 popupBody.addEventListener("click", clickListener);
3639                                                 popupBody.style.cursor = "pointer";
3641                                                 recenter();
3642                                         });
3644                                         popup.contentDocument.body.addEventListener("click", clickListener);
3645                                         
3646                                         currentPreviewPopup.timeout = setTimeout(() => {
3647                                                 recenter();
3649                                                 requestIdleCallback(() => {
3650                                                         if(currentPreviewPopup.element === popup) {
3651                                                                 popup.scrolling = "";
3652                                                                 popup.style.visibility = "unset";
3653                                                                 popup.style.transition = null;
3655                                                                 popup.animate([
3656                                                                         { opacity: 0, transform: "translateY(10%)" },
3657                                                                         { opacity: 1, transform: "none" }
3658                                                                 ], { duration: 150, easing: "ease-out" });
3659                                                         }
3660                                                 });
3661                                         }, 1000);
3663                                         let pointerX, pointerY, mousePauseTimeout = null;
3665                                         currentPreviewPopup.pointerListener = (event) => {
3666                                                 pointerX = event.clientX;
3667                                                 pointerY = event.clientY;
3669                                                 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3670                                                 mousePauseTimeout = null;
3672                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3673                                                 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3675                                                 if(mouseIsOverLink || overElement === popup
3676                                                    || (pointerX < popup.getBoundingClientRect().left
3677                                                        && event.movementX >= 0)) {
3678                                                         if(!mouseIsOverLink && overElement !== popup) {
3679                                                                 if(overElement['createPreviewPopup']) {
3680                                                                         mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3681                                                                 } else {
3682                                                                         mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3683                                                                 }
3684                                                         }
3685                                                 } else {
3686                                                         removePreviewPopup(currentPreviewPopup);
3687                                                         if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3688                                                 }
3689                                         };
3690                                         window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3692                                         currentPreviewPopup.mouseoutListener = (event) => {
3693                                                 clearTimeout(mousePauseTimeout);
3694                                                 mousePauseTimeout = null;
3695                                         }
3696                                         document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3698                                         currentPreviewPopup.scrollListener = (event) => {
3699                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3700                                                 linkRect = linkTag.getBoundingClientRect();
3701                                                 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3702                                                 removePreviewPopup(currentPreviewPopup);
3703                                         };
3704                                         window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3705                                 };
3706                         }
3707                 }
3708         });
3709         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3710                 
3711         });
3713         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3714         Appearance.filtersExclusionPaths.commentParentPopups = [
3715                 "#content .comments .comment-thread"
3716         ];
3717         Appearance.applyFilters();
3720 /***************/
3721 /* IMAGE FOCUS */
3722 /***************/
3724 function imageFocusSetup(imagesOverlayOnly = false) {
3725         if (typeof GW.imageFocus == "undefined")
3726                 GW.imageFocus = {
3727                         contentImagesSelector:  "#content img",
3728                         overlayImagesSelector:  "#images-overlay img",
3729                         focusedImageSelector:   "#content img.focused, #images-overlay img.focused",
3730                         pageContentSelector:    "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3731                         shrinkRatio:                    0.975,
3732                         hideUITimerDuration:    1500,
3733                         hideUITimerExpired:             () => {
3734                                 GWLog("GW.imageFocus.hideUITimerExpired");
3735                                 let currentTime = new Date();
3736                                 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3737                                 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3738                                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3739                                 } else {
3740                                         hideImageFocusUI();
3741                                         cancelImageFocusHideUITimer();
3742                                 }
3743                         }
3744                 };
3746         GWLog("imageFocusSetup");
3747         // Create event listener for clicking on images to focus them.
3748         GW.imageClickedToFocus = (event) => {
3749                 GWLog("GW.imageClickedToFocus");
3750                 focusImage(event.target);
3752                 if (!GW.isMobile) {
3753                         // Set timer to hide the image focus UI.
3754                         unhideImageFocusUI();
3755                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3756                 }
3757         };
3758         // Add the listener to each image in the overlay (i.e., those in the post).
3759         queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3760                 image.addActivateEvent(GW.imageClickedToFocus);
3761         });
3762         // Accesskey-L starts the slideshow.
3763         (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3764         // Count how many images there are in the post, and set the "… of X" label to that.
3765         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3766         if (imagesOverlayOnly) return;
3767         // Add the listener to all other content images (including those in comments).
3768         queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3769                 image.addActivateEvent(GW.imageClickedToFocus);
3770         });
3772         // Create the image focus overlay.
3773         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
3774         `<div class='help-overlay'>
3775                  <p><strong>Arrow keys:</strong> Next/previous image</p>
3776                  <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3777                  <p><strong>Space bar:</strong> Reset image size & position</p>
3778                  <p><strong>Scroll</strong> to zoom in/out</p>
3779                  <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3780         </div>
3781         <div class='image-number'></div>
3782         <div class='slideshow-buttons'>
3783                  <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
3784                  <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
3785         </div>
3786         <div class='caption'></div>` + 
3787         "</div>");
3788         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3790         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3791                 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3792                         GWLog("GW.imageFocus.slideshowButtonClicked");
3793                         focusNextImage(event.target.hasClass("next"));
3794                         event.target.blur();
3795                 });
3796         });
3798         // On orientation change, reset the size & position.
3799         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3800                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3801         }
3803         // UI starts out hidden.
3804         hideImageFocusUI();
3807 function focusImage(imageToFocus) {
3808         GWLog("focusImage");
3809         // Clear 'last-focused' class of last focused image.
3810         let lastFocusedImage = query("img.last-focused");
3811         if (lastFocusedImage) {
3812                 lastFocusedImage.removeClass("last-focused");
3813                 lastFocusedImage.removeAttribute("accesskey");
3814         }
3816         // Create the focused version of the image.
3817         imageToFocus.addClass("focused");
3818         let imageFocusOverlay = query("#image-focus-overlay");
3819         let clonedImage = imageToFocus.cloneNode(true);
3820         clonedImage.style = "";
3821         clonedImage.removeAttribute("width");
3822         clonedImage.removeAttribute("height");
3823         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3824         imageFocusOverlay.appendChild(clonedImage);
3825         imageFocusOverlay.addClass("engaged");
3827         // Set image to default size and position.
3828         resetFocusedImagePosition();
3830         // Blur everything else.
3831         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3832                 element.addClass("blurred");
3833         });
3835         // Add listener to zoom image with scroll wheel.
3836         window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3837                 GWLog("GW.imageFocus.scrollEvent");
3838                 event.preventDefault();
3840                 let image = query("#image-focus-overlay img");
3842                 // Remove the filter.
3843                 image.savedFilter = image.style.filter;
3844                 image.style.filter = 'none';
3846                 // Locate point under cursor.
3847                 let imageBoundingBox = image.getBoundingClientRect();
3849                 // Calculate resize factor.
3850                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3851                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3852                                                 1;
3854                 // Resize.
3855                 image.style.width = (event.deltaY < 0 ?
3856                                                         (image.clientWidth * factor) :
3857                                                         (image.clientWidth / factor))
3858                                                         + "px";
3859                 image.style.height = "";
3861                 // Designate zoom origin.
3862                 var zoomOrigin;
3863                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3864                 // the cursor is over the image.
3865                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3866                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3867                                                                 (imageBoundingBox.left <= event.clientX &&
3868                                                                  event.clientX <= imageBoundingBox.right && 
3869                                                                  imageBoundingBox.top <= event.clientY &&
3870                                                                  event.clientY <= imageBoundingBox.bottom);
3871                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
3872                 // zooming IN, zoom from image center.
3873                 let zoomingFromWindowCenter = event.deltaY > 0;
3874                 if (zoomingFromCursor)
3875                         zoomOrigin = { x: event.clientX, 
3876                                                    y: event.clientY };
3877                 else if (zoomingFromWindowCenter)
3878                         zoomOrigin = { x: window.innerWidth / 2, 
3879                                                    y: window.innerHeight / 2 };
3880                 else
3881                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
3882                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3884                 // Calculate offset from zoom origin.
3885                 let offsetOfImageFromZoomOrigin = {
3886                         x: imageBoundingBox.x - zoomOrigin.x,
3887                         y: imageBoundingBox.y - zoomOrigin.y
3888                 }
3889                 // Calculate delta from centered zoom.
3890                 let deltaFromCenteredZoom = {
3891                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3892                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3893                 }
3894                 // Adjust image position appropriately.
3895                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3896                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3897                 // Gradually re-center image, if it's smaller than the window.
3898                 if (!imageSizeExceedsWindowBounds) {
3899                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
3900                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3901                         let windowCenter = { x: window.innerWidth / 2,
3902                                                                  y: window.innerHeight / 2 }
3903                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3904                                                                                   y: windowCenter.y - imageCenter.y }
3905                         // Divide the offset by 10 because we're nudging the image toward center,
3906                         // not jumping it there.
3907                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3908                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3909                 }
3911                 // Put the filter back.
3912                 image.style.filter = image.savedFilter;
3914                 // Set the cursor appropriately.
3915                 setFocusedImageCursor();
3916         });
3917         window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3918                 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3919                 event.preventDefault();
3920         });
3922         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3923         window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3924                 GWLog("GW.imageFocus.mouseUp");
3925                 window.onmousemove = '';
3927                 // We only want to do anything on left-clicks.
3928                 if (event.button != 0) return;
3930                 // Don't unfocus if click was on a slideshow next/prev button!
3931                 if (event.target.hasClass("slideshow-button")) return;
3933                 // We also don't want to do anything if clicked on the help overlay.
3934                 if (event.target.classList.contains("help-overlay") ||
3935                         event.target.closest(".help-overlay"))
3936                         return;
3938                 let focusedImage = query("#image-focus-overlay img");
3939                 if (event.target == focusedImage && 
3940                         (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3941                         // If the mouseup event was the end of a pan of an overside image,
3942                         // put the filter back; do not unfocus.
3943                         focusedImage.style.filter = focusedImage.savedFilter;
3944                 } else {
3945                         unfocusImageOverlay();
3946                         return;
3947                 }
3948         });
3949         window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3950                 GWLog("GW.imageFocus.mouseDown");
3951                 event.preventDefault();
3953                 let focusedImage = query("#image-focus-overlay img");
3954                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3955                         let mouseCoordX = event.clientX;
3956                         let mouseCoordY = event.clientY;
3958                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3959                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3961                         // Save the filter.
3962                         focusedImage.savedFilter = focusedImage.style.filter;
3964                         window.onmousemove = (event) => {
3965                                 // Remove the filter.
3966                                 focusedImage.style.filter = "none";
3967                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3968                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3969                         };
3970                         return false;
3971                 }
3972         });
3974         // Double-click on the image unfocuses.
3975         clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3976                 GWLog("GW.imageFocus.doubleClick");
3977                 if (event.target.hasClass("slideshow-button")) return;
3979                 unfocusImageOverlay();
3980         });
3982         // Escape key unfocuses, spacebar resets.
3983         document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3984                 GWLog("GW.imageFocus.keyUp");
3985                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3986                 if (!allowedKeys.contains(event.key) || 
3987                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3989                 event.preventDefault();
3991                 switch (event.key) {
3992                 case "Escape": 
3993                 case "Esc":
3994                         unfocusImageOverlay();
3995                         break;
3996                 case " ":
3997                 case "Spacebar":
3998                         resetFocusedImagePosition();
3999                         break;
4000                 case "ArrowDown":
4001                 case "Down":
4002                 case "ArrowRight":
4003                 case "Right":
4004                         if (query("#images-overlay img.focused")) focusNextImage(true);
4005                         break;
4006                 case "ArrowUp":
4007                 case "Up":
4008                 case "ArrowLeft":
4009                 case "Left":
4010                         if (query("#images-overlay img.focused")) focusNextImage(false);
4011                         break;
4012                 }
4013         });
4015         // Prevent spacebar or arrow keys from scrolling page when image focused.
4016         togglePageScrolling(false);
4018         // If the image comes from the images overlay, for the main post...
4019         if (imageToFocus.closest("#images-overlay")) {
4020                 // Mark the overlay as being in slide show mode (to show buttons/count).
4021                 imageFocusOverlay.addClass("slideshow");
4023                 // Set state of next/previous buttons.
4024                 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4025                 var indexOfFocusedImage = getIndexOfFocusedImage();
4026                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4027                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4029                 // Set the image number.
4030                 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4032                 // Replace the hash.
4033                 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4034         } else {
4035                 imageFocusOverlay.removeClass("slideshow");
4036         }
4038         // Set the caption.
4039         setImageFocusCaption();
4041         // Moving mouse unhides image focus UI.
4042         window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
4043                 GWLog("GW.imageFocus.mouseMoved");
4044                 let currentDateTime = new Date();
4045                 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
4046                         cancelImageFocusHideUITimer();
4047                 } else {
4048                         if (!GW.imageFocus.hideUITimer) {
4049                                 unhideImageFocusUI();
4050                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4051                         }
4052                         GW.imageFocus.mouseLastMovedAt = currentDateTime;
4053                 }
4054         });
4057 function resetFocusedImagePosition() {
4058         GWLog("resetFocusedImagePosition");
4059         let focusedImage = query("#image-focus-overlay img");
4060         if (!focusedImage) return;
4062         let sourceImage = query(GW.imageFocus.focusedImageSelector);
4064         // Make sure that initially, the image fits into the viewport.
4065         let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
4066         let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
4067         var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
4068         let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
4069         let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
4070         focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
4071         focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
4073         // Remove modifications to position.
4074         focusedImage.style.left = "";
4075         focusedImage.style.top = "";
4077         // Set the cursor appropriately.
4078         setFocusedImageCursor();
4080 function setFocusedImageCursor() {
4081         let focusedImage = query("#image-focus-overlay img");
4082         if (!focusedImage) return;
4083         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
4084                                                                 'move' : '';
4087 function unfocusImageOverlay() {
4088         GWLog("unfocusImageOverlay");
4090         // Remove event listeners.
4091         window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
4092         window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
4093         // NOTE: The double-click listener does not need to be removed manually,
4094         // because the focused (cloned) image will be removed anyway.
4095         document.removeEventListener("keyup", GW.imageFocus.keyUp);
4096         document.removeEventListener("keydown", GW.imageFocus.keyDown);
4097         window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
4098         window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
4099         window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
4101         // Set accesskey of currently focused image (if it's in the images overlay).
4102         let currentlyFocusedImage = query("#images-overlay img.focused");
4103         if (currentlyFocusedImage) {
4104                 currentlyFocusedImage.addClass("last-focused");
4105                 currentlyFocusedImage.accessKey = 'l';
4106         }
4108         // Remove focused image and hide overlay.
4109         let imageFocusOverlay = query("#image-focus-overlay");
4110         imageFocusOverlay.removeClass("engaged");
4111         removeElement(imageFocusOverlay.query("img"));
4113         // Un-blur content/etc.
4114         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
4115                 element.removeClass("blurred");
4116         });
4118         // Unset "focused" class of focused image.
4119         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4121         // Re-enable page scrolling.
4122         togglePageScrolling(true);
4124         // Reset the hash, if needed.
4125         if (location.hash.hasPrefix("#if_slide_"))
4126                 history.replaceState(window.history.state, null, "#");
4129 function getIndexOfFocusedImage() {
4130         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4131         var indexOfFocusedImage = -1;
4132         for (i = 0; i < images.length; i++) {
4133                 if (images[i].hasClass("focused")) {
4134                         indexOfFocusedImage = i;
4135                         break;
4136                 }
4137         }
4138         return indexOfFocusedImage;
4141 function focusNextImage(next = true) {
4142         GWLog("focusNextImage");
4143         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4144         var indexOfFocusedImage = getIndexOfFocusedImage();
4146         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
4148         // Remove existing image.
4149         removeElement("#image-focus-overlay img");
4150         // Unset "focused" class of just-removed image.
4151         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4153         // Create the focused version of the image.
4154         images[indexOfFocusedImage].addClass("focused");
4155         let imageFocusOverlay = query("#image-focus-overlay");
4156         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
4157         clonedImage.style = "";
4158         clonedImage.removeAttribute("width");
4159         clonedImage.removeAttribute("height");
4160         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
4161         imageFocusOverlay.appendChild(clonedImage);
4162         imageFocusOverlay.addClass("engaged");
4163         // Set image to default size and position.
4164         resetFocusedImagePosition();
4165         // Set state of next/previous buttons.
4166         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4167         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4168         // Set the image number display.
4169         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4170         // Set the caption.
4171         setImageFocusCaption();
4172         // Replace the hash.
4173         history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4176 function setImageFocusCaption() {
4177         GWLog("setImageFocusCaption");
4178         var T = { }; // Temporary storage.
4180         // Clear existing caption, if any.
4181         let captionContainer = query("#image-focus-overlay .caption");
4182         Array.from(captionContainer.children).forEach(child => { child.remove(); });
4184         // Determine caption.
4185         let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
4186         var captionHTML;
4187         if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) && 
4188                 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
4189                 captionHTML = (T.figcaption.query("p")) ? 
4190                                           T.figcaption.innerHTML : 
4191                                           "<p>" + T.figcaption.innerHTML + "</p>"; 
4192         } else if (currentlyFocusedImage.title != "") {
4193                 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
4194         }
4195         // Insert the caption, if any.
4196         if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
4199 function hideImageFocusUI() {
4200         GWLog("hideImageFocusUI");
4201         let imageFocusOverlay = query("#image-focus-overlay");
4202         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4203                 element.addClass("hidden");
4204         });
4207 function unhideImageFocusUI() {
4208         GWLog("unhideImageFocusUI");
4209         let imageFocusOverlay = query("#image-focus-overlay");
4210         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4211                 element.removeClass("hidden");
4212         });
4215 function cancelImageFocusHideUITimer() {
4216         clearTimeout(GW.imageFocus.hideUITimer);
4217         GW.imageFocus.hideUITimer = null;
4220 /*****************/
4221 /* KEYBOARD HELP */
4222 /*****************/
4224 function keyboardHelpSetup() {
4225         let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
4226                 <div class='keyboard-help-container'>
4227                         <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'>&#xf00d;</button>
4228                         <h1>Keyboard shortcuts</h1>
4229                         <p class='note'>Keys shown in yellow (e.g., <code class='ak'>]</code>) are <a href='https://en.wikipedia.org/wiki/Access_key#Access_in_different_browsers' target='_blank'>accesskeys</a>, and require a browser-specific modifier key (or keys).</p>
4230                         <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
4231                         <div class='keyboard-shortcuts-lists'>` + [ [
4232                                 "General",
4233                                 [ [ '?' ], "Show keyboard shortcuts" ],
4234                                 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
4235                         ], [
4236                                 "Site navigation",
4237                                 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
4238                                 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
4239                                 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
4240                                 [ [ 'ak-m' ], "Go to Meta view" ],
4241                                 [ [ 'ak-v' ], "Go to Tags view"],
4242                                 [ [ 'ak-c' ], "Go to Recent Comments view" ],
4243                                 [ [ 'ak-r' ], "Go to Archive view" ],
4244                                 [ [ 'ak-q' ], "Go to Sequences view" ],
4245                                 [ [ 'ak-t' ], "Go to About page" ],
4246                                 [ [ 'ak-u' ], "Go to User or Login page" ],
4247                                 [ [ 'ak-o' ], "Go to Inbox page" ]
4248                         ], [
4249                                 "Page navigation",
4250                                 [ [ 'ak-,' ], "Jump up to top of page" ],
4251                                 [ [ 'ak-.' ], "Jump down to bottom of page" ],
4252                                 [ [ 'ak-/' ], "Jump to top of comments section" ],
4253                                 [ [ 'ak-s' ], "Search" ],
4254                         ], [
4255                                 "Page actions",
4256                                 [ [ 'ak-n' ], "New post or comment" ],
4257                                 [ [ 'ak-e' ], "Edit current post" ]
4258                         ], [
4259                                 "Post/comment list views",
4260                                 [ [ '.' ], "Focus next entry in list" ],
4261                                 [ [ ',' ], "Focus previous entry in list" ],
4262                                 [ [ ';' ], "Cycle between links in focused entry" ],
4263                                 [ [ 'Enter' ], "Go to currently focused entry" ],
4264                                 [ [ 'Esc' ], "Unfocus currently focused entry" ],
4265                                 [ [ 'ak-]' ], "Go to next page" ],
4266                                 [ [ 'ak-[' ], "Go to previous page" ],
4267                                 [ [ 'ak-\\' ], "Go to first page" ],
4268                                 [ [ 'ak-e' ], "Edit currently focused post" ]
4269                         ], [
4270                                 "Editor",
4271                                 [ [ 'ak-k' ], "Bold text" ],
4272                                 [ [ 'ak-i' ], "Italic text" ],
4273                                 [ [ 'ak-l' ], "Insert hyperlink" ],
4274                                 [ [ 'ak-q' ], "Blockquote text" ]
4275                         ], [                            
4276                                 "Appearance",
4277                                 [ [ 'ak-=' ], "Increase text size" ],
4278                                 [ [ 'ak--' ], "Decrease text size" ],
4279                                 [ [ 'ak-0' ], "Reset to default text size" ],
4280                                 [ [ 'ak-′' ], "Cycle through content width settings" ],
4281                                 [ [ 'ak-1' ], "Switch to default theme [A]" ],
4282                                 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
4283                                 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
4284                                 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
4285                                 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
4286                                 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
4287                                 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
4288                                 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
4289                                 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
4290                                 [ [ 'ak-;' ], "Open theme tweaker" ],
4291                                 [ [ 'Enter' ], "Save changes and close theme tweaker "],
4292                                 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
4293                         ], [
4294                                 "Slide shows",
4295                                 [ [ 'ak-l' ], "Start/resume slideshow" ],
4296                                 [ [ 'Esc' ], "Exit slideshow" ],
4297                                 [ [ '&#x2192;', '&#x2193;' ], "Next slide" ],
4298                                 [ [ '&#x2190;', '&#x2191;' ], "Previous slide" ],
4299                                 [ [ 'Space' ], "Reset slide zoom" ]
4300                         ], [
4301                                 "Miscellaneous",
4302                                 [ [ 'ak-x' ], "Switch to next view on user page" ],
4303                                 [ [ 'ak-z' ], "Switch to previous view on user page" ],
4304                                 [ [ 'ak-`&nbsp;' ], "Toggle compact comment list view" ],
4305                                 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
4306                         ] ].map(section => 
4307                         `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
4308                                 `<li>
4309                                         <span class='keys'>` + 
4310                                         entry[0].map(key =>
4311                                                 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
4312                                         ).join("") + 
4313                                         `</span>
4314                                         <span class='action'>${entry[1]}</span>
4315                                 </li>`
4316                         ).join("\n") + `</ul>`).join("\n") + `
4317                         </ul></div>             
4318                 </div>
4319         ` + "</nav>");
4321         // Add listener to show the keyboard help overlay.
4322         document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
4323                 GWLog("GW.keyboardHelpShowKeyPressed");
4324                 if (event.key == '?')
4325                         toggleKeyboardHelpOverlay(true);
4326         });
4328         // Clicking the background overlay closes the keyboard help overlay.
4329         keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
4330                 GWLog("GW.keyboardHelpOverlayClicked");
4331                 if (event.type == "mousedown") {
4332                         keyboardHelpOverlay.style.opacity = "0.01";
4333                 } else {
4334                         toggleKeyboardHelpOverlay(false);
4335                         keyboardHelpOverlay.style.opacity = "1.0";
4336                 }
4337         }, true);
4339         // Intercept clicks, so they don't "fall through" the background overlay.
4340         (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
4342         // Clicking the close button closes the keyboard help overlay.
4343         keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
4344                 toggleKeyboardHelpOverlay(false);
4345         });
4347         // Add button to open keyboard help.
4348         query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'>&#xf11c;</button>");
4349         query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
4350                 GWLog("GW.openKeyboardHelpButtonClicked");
4351                 toggleKeyboardHelpOverlay(true);
4352                 event.target.blur();
4353         });
4356 function toggleKeyboardHelpOverlay(show) {
4357         console.log("toggleKeyboardHelpOverlay");
4359         let keyboardHelpOverlay = query("#keyboard-help-overlay");
4360         show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
4361         keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
4363         // Prevent scrolling the document when the overlay is visible.
4364         togglePageScrolling(!show);
4366         // Focus the close button as soon as we open.
4367         keyboardHelpOverlay.query("button.close-keyboard-help").focus();
4369         if (show) {
4370                 // Add listener to show the keyboard help overlay.
4371                 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
4372                         GWLog("GW.keyboardHelpHideKeyPressed");
4373                         if (event.key == 'Escape')
4374                                 toggleKeyboardHelpOverlay(false);
4375                 });
4376         } else {
4377                 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
4378         }
4380         // Disable / enable tab-selection of the search box.
4381         setSearchBoxTabSelectable(!show);
4384 /**********************/
4385 /* PUSH NOTIFICATIONS */
4386 /**********************/
4388 function pushNotificationsSetup() {
4389         let pushNotificationsButton = query("#enable-push-notifications");
4390         if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
4391                 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
4392                 pushNotificationsButton.style.display = 'unset';
4393         }
4396 function urlBase64ToUint8Array(base64String) {
4397         const padding = '='.repeat((4 - base64String.length % 4) % 4);
4398         const base64 = (base64String + padding)
4399               .replace(/-/g, '+')
4400               .replace(/_/g, '/');
4401         
4402         const rawData = window.atob(base64);
4403         const outputArray = new Uint8Array(rawData.length);
4404         
4405         for (let i = 0; i < rawData.length; ++i) {
4406                 outputArray[i] = rawData.charCodeAt(i);
4407         }
4408         return outputArray;
4411 function pushNotificationsButtonClicked(event) {
4412         event.target.style.opacity = 0.33;
4413         event.target.style.pointerEvents = "none";
4415         let reEnable = (message) => {
4416                 if(message) alert(message);
4417                 event.target.style.opacity = 1;
4418                 event.target.style.pointerEvents = "unset";
4419         }
4421         if(event.target.dataset.enabled) {
4422                 fetch('/push/register', {
4423                         method: 'post',
4424                         headers: { 'Content-type': 'application/json' },
4425                         body: JSON.stringify({
4426                                 cancel: true
4427                         }),
4428                 }).then(() => {
4429                         event.target.innerHTML = "Enable push notifications";
4430                         event.target.dataset.enabled = "";
4431                         reEnable();
4432                 }).catch((err) => reEnable(err.message));
4433         } else {
4434                 Notification.requestPermission().then((permission) => {
4435                         navigator.serviceWorker.ready
4436                                 .then((registration) => {
4437                                         return registration.pushManager.getSubscription()
4438                                                 .then(async function(subscription) {
4439                                                         if (subscription) {
4440                                                                 return subscription;
4441                                                         }
4442                                                         return registration.pushManager.subscribe({
4443                                                                 userVisibleOnly: true,
4444                                                                 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
4445                                                         });
4446                                                 })
4447                                                 .catch((err) => reEnable(err.message));
4448                                 })
4449                                 .then((subscription) => {
4450                                         fetch('/push/register', {
4451                                                 method: 'post',
4452                                                 headers: {
4453                                                         'Content-type': 'application/json'
4454                                                 },
4455                                                 body: JSON.stringify({
4456                                                         subscription: subscription
4457                                                 }),
4458                                         });
4459                                 })
4460                                 .then(() => {
4461                                         event.target.innerHTML = "Disable push notifications";
4462                                         event.target.dataset.enabled = "true";
4463                                         reEnable();
4464                                 })
4465                                 .catch(function(err){ reEnable(err.message) });
4466                         
4467                 });
4468         }
4471 /*******************************/
4472 /* HTML TO MARKDOWN CONVERSION */
4473 /*******************************/
4475 function MarkdownFromHTML(text, linePrefix) {
4476         GWLog("MarkdownFromHTML");
4478         let docFrag = document.createRange().createContextualFragment(text);
4479         let output = "";
4480         let owedLines = -1;
4481         let atLineBeginning = true;
4482         linePrefix = linePrefix || "";
4484         let out = text => {
4485                 if(owedLines > 0) {
4486                         output += ("\n" + linePrefix).repeat(owedLines);
4487                 }
4488                 output += text;
4489                 owedLines = 0;
4490                 atLineBeginning = false;
4491         }
4492         let outText = text => {
4493                 if(atLineBeginning) text = text.trimStart();
4494                 text = text.replace(/\s+/gm, " ");
4495                 if(text.length > 0)
4496                         out(text);
4497         }
4498         let forceLine = n => {
4499                 n = n || 1;
4500                 out(("\n" + linePrefix).repeat(n));
4501                 atLineBeginning = true;
4502         }
4503         let newLine = (n) => {
4504                 n = n || 1;
4505                 if(owedLines >= 0 && owedLines < n) {
4506                         owedLines = n;
4507                 }
4508                 atLineBeginning = true;
4509         };
4510         let newParagraph = () => {
4511                 newLine(2);
4512         };
4513         let withPrefix = (prefix, fn) => {
4514                 let oldPrefix = linePrefix;
4515                 linePrefix += prefix;
4516                 owedLines = -1;
4517                 fn();
4518                 owedLines = 0;
4519                 linePrefix = oldPrefix;
4520         };
4522         let doConversion = (node) => {
4523                 if(node.nodeType == Node.TEXT_NODE) {
4524                         outText(node.nodeValue.replace(/[\][*\\#<>]/g, "\\$&"));
4525                 }
4526                 else if(node.nodeType == Node.ELEMENT_NODE) {
4527                         switch(node.tagName) {
4528                         case "P":
4529                         case "DIV":
4530                         case "UL":
4531                         case "OL":
4532                                 newParagraph();
4533                                 node.childNodes.forEach(doConversion);
4534                                 newParagraph();
4535                                 break;
4536                         case "BR":
4537                                 forceLine();
4538                                 break;
4539                         case "HR":
4540                                 newLine();
4541                                 out("---");
4542                                 newLine();
4543                                 break;
4544                         case "B":
4545                         case "STRONG":
4546                                 out("**");
4547                                 node.childNodes.forEach(doConversion);
4548                                 out("**");
4549                                 break;
4550                         case "I":
4551                         case "EM":
4552                                 out("*");
4553                                 node.childNodes.forEach(doConversion);
4554                                 out("*");
4555                                 break;
4556                         case "LI":
4557                                 newLine();
4558                                 let listPrefix;
4559                                 if(node.parentElement.tagName == "OL") {
4560                                         let i = 1;
4561                                         for(let e = node; e = e.previousElementSibling;) { i++ }
4562                                         listPrefix = "" + i + ". ";
4563                                 } else {
4564                                         listPrefix = "* ";
4565                                 }
4566                                 out(listPrefix);
4567                                 owedLines = -1;
4568                                 withPrefix(" ".repeat(listPrefix.length), () => node.childNodes.forEach(doConversion));
4569                                 newLine();
4570                                 break;
4571                         case "H1":
4572                         case "H2":
4573                         case "H3":
4574                         case "H4":
4575                         case "H5":
4576                         case "H6":
4577                                 newParagraph();
4578                                 out("#".repeat(node.tagName.charAt(1)) + " ");
4579                                 node.childNodes.forEach(doConversion);
4580                                 newParagraph();
4581                                 break;
4582                         case "A":
4583                                 let href = node.getAttribute("href");
4584                                 out('[');
4585                                 node.childNodes.forEach(doConversion);
4586                                 out(`](${href})`);
4587                                 break;
4588                         case "IMG":
4589                                 let src = node.getAttribute("src");
4590                                 let alt = node.alt || "";
4591                                 out(`![${alt}](${src})`);
4592                                 break;
4593                         case "BLOCKQUOTE":
4594                                 newParagraph();
4595                                 out("> ");
4596                                 withPrefix("> ", () => node.childNodes.forEach(doConversion));
4597                                 newParagraph();
4598                                 break;
4599                         case "PRE":
4600                                 newParagraph();
4601                                 out('```');
4602                                 forceLine();
4603                                 out(node.innerText);
4604                                 forceLine();
4605                                 out('```');
4606                                 newParagraph();
4607                                 break;
4608                         case "CODE":
4609                                 out('`');
4610                                 node.childNodes.forEach(doConversion);
4611                                 out('`');
4612                                 break;
4613                         case "STYLE":
4614                         case "SCRIPT":
4615                                 break;
4616                         default:
4617                                 node.childNodes.forEach(doConversion);
4618                         }
4619                 } else {
4620                         node.childNodes.forEach(doConversion);
4621                 }
4622         }
4623         doConversion(docFrag);
4625         return output;
4628 /************************************/
4629 /* ANCHOR LINK SCROLLING WORKAROUND */
4630 /************************************/
4632 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
4633         let hash = location.hash;
4634         if(hash && hash !== "#top" && !document.query(hash)) {
4635                 let content = document.query("#content");
4636                 content.style.display = "none";
4637                 addTriggerListener("DOMReady", {priority: -1, fn: () => {
4638                         content.style.visibility = "hidden";
4639                         content.style.display = null;
4640                         requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
4641                 }});
4642         }
4643 }});
4645 /******************/
4646 /* INITIALIZATION */
4647 /******************/
4649 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
4650         GWLog("INITIALIZER earlyInitialize");
4651         // Check to see whether we're on a mobile device (which we define as a narrow screen)
4652         GW.isMobile = (window.innerWidth <= 1160);
4653         GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
4655         // Backward compatibility
4656         let storedTheme = localStorage.getItem("selected-theme");
4657         if (storedTheme) {
4658                 Appearance.setTheme(storedTheme);
4659                 localStorage.removeItem("selected-theme");
4660         }
4662         // Animate width & theme adjustments?
4663         Appearance.adjustmentTransitions = false;
4664         // Add the content width selector.
4665         Appearance.injectContentWidthSelector();
4666         // Add the text size adjustment widget.
4667         Appearance.injectTextSizeAdjustmentUI();
4668         // Add the theme selector.
4669         Appearance.injectThemeSelector();
4670         // Add the theme tweaker.
4671         Appearance.injectThemeTweaker();
4673         // Add the dark mode selector (if desktop).
4674         if (GW.isMobile == false)
4675                 DarkMode.injectModeSelector();
4677         // Add the quick-nav UI.
4678         injectQuickNavUI();
4680         // Finish initializing when ready.
4681         addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4682 }});
4684 function mainInitializer() {
4685         GWLog("INITIALIZER initialize");
4687         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4688         // "link without nav bars".
4689         if (getQueryVariable("hide-nav-bars") == "true") {
4690                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
4691         }
4693         // If the page cannot have comments, remove the accesskey from the #comments
4694         // quick-nav button; and if the page can have comments, but does not, simply 
4695         // disable the #comments quick nav button.
4696         let content = query("#content");
4697         if (content.query("#comments") == null) {
4698                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4699         } else if (content.query("#comments .comment-thread") == null) {
4700                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4701         }
4703         // On edit post pages and conversation pages, add GUIEdit buttons to the 
4704         // textarea and expand it.
4705         queryAll(".with-markdown-editor textarea").forEach(textarea => {
4706                 textarea.addTextareaFeatures();
4707                 expandTextarea(textarea);
4708         });
4709         // Focus the textarea.
4710         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4712         // If we're on a comment thread page...
4713         if (query(".comments") != null) {
4714                 // Add comment-minimize buttons to every comment.
4715                 queryAll(".comment-meta").forEach(commentMeta => {
4716                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4717                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
4718                 });
4719                 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4720                         // Format and activate comment-minimize buttons.
4721                         queryAll(".comment-minimize-button").forEach(button => {
4722                                 button.closest(".comment-item").setCommentThreadMaximized(false);
4723                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4724                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
4725                                 });
4726                         });
4727                 }
4728         }
4729         if (getQueryVariable("chrono") == "t") {
4730                 insertHeadHTML(`<style> .comment-minimize-button::after { display: none; } </style>`);
4731         }
4733         // On mobile, replace the labels for the checkboxes on the edit post form
4734         // with icons, to save space.
4735         if (GW.isMobile && query(".edit-post-page")) {
4736                 query("label[for='link-post']").innerHTML = "&#xf0c1";
4737                 query("label[for='question']").innerHTML = "&#xf128";
4738         }
4740         // Add error message (as placeholder) if user tries to click Search with
4741         // an empty search field.
4742         searchForm: {
4743                 let searchForm = query("#nav-item-search form");
4744                 if(!searchForm) break searchForm;
4745                 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4746                         let searchField = event.target.query("input");
4747                         if (searchField.value == "") {
4748                                 event.preventDefault();
4749                                 event.target.blur();
4750                                 searchField.placeholder = "Enter a search string!";
4751                                 searchField.focus();
4752                         }
4753                 });
4754                 // Remove the placeholder / error on any input.
4755                 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4756                         event.target.placeholder = "";
4757                 });
4758         }
4760         // Prevent conflict between various single-hotkey listeners and text fields
4761         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4762                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4763                 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4764         });
4766         if (content.hasClass("post-page")) {
4767                 // Read and update last-visited-date.
4768                 let lastVisitedDate = getLastVisitedDate();
4769                 setLastVisitedDate(Date.now());
4771                 // Save the number of comments this post has when it's visited.
4772                 updateSavedCommentCount();
4774                 if (content.query(".comments .comment-thread") != null) {
4775                         // Add the new comments count & navigator.
4776                         injectNewCommentNavUI();
4778                         // Get the highlight-new-since date (as specified by URL parameter, if 
4779                         // present, or otherwise the date of the last visit).
4780                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4782                         // Highlight new comments since the specified date.                      
4783                         let newCommentsCount = highlightCommentsSince(hnsDate);
4785                         // Update the comment count display.
4786                         updateNewCommentNavUI(newCommentsCount, hnsDate);
4787                 }
4788         } else {
4789                 // On listing pages, make comment counts more informative.
4790                 badgePostsWithNewComments();
4791         }
4793         // Add the comments list mode selector widget (expanded vs. compact).
4794         injectCommentsListModeSelector();
4796         // Add the comments view selector widget (threaded vs. chrono).
4797 //      injectCommentsViewModeSelector();
4799         // Add the comments sort mode selector (top, hot, new, old).
4800         if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4802         // Add the toggle for the post nav UI elements on mobile.
4803         if (GW.isMobile) injectPostNavUIToggle();
4805         // Add the toggle for the appearance adjustment UI elements on mobile.
4806         if (GW.isMobile)
4807                 Appearance.injectAppearanceAdjustUIToggle();
4809         // Activate the antikibitzer.
4810         if (GW.useFancyFeatures)
4811                 activateAntiKibitzer();
4813         // Add comment parent popups.
4814         injectPreviewPopupToggle();
4815         addCommentParentPopups();
4817         // Mark original poster's comments with a special class.
4818         markOriginalPosterComments();
4819         
4820         // On the All view, mark posts with non-positive karma with a special class.
4821         if (query("#content").hasClass("all-index-page")) {
4822                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4823                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4825                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4826                 });
4827         }
4829         // Set the "submit" button on the edit post page to something more helpful.
4830         setEditPostPageSubmitButtonText();
4832         // Compute the text of the pagination UI tooltip text.
4833         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4834                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4835         });
4837         // Add event listeners for Escape and Enter, for the theme tweaker.
4838         document.addEventListener("keyup", Appearance.themeTweakerUIKeyPressed);
4840         // Add event listener for . , ; (for navigating listings pages).
4841         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4842         if (!query(".comments") && listings.length > 0) {
4843                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
4844                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4846                         if (event.key == "Escape") {
4847                                 if (document.activeElement.parentElement.hasClass("listing"))
4848                                         document.activeElement.blur();
4849                                 return;
4850                         }
4852                         if (event.key == ';') {
4853                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4854                                         let links = document.activeElement.parentElement.queryAll("a");
4855                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4856                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4857                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4858                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4859                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4860                                 }
4861                                 return;
4862                         }
4864                         var indexOfActiveListing = -1;
4865                         for (i = 0; i < listings.length; i++) {
4866                                 if (document.activeElement.parentElement.hasClass("listing") && 
4867                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4868                                         indexOfActiveListing = i;
4869                                         break;
4870                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
4871                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
4872                                         indexOfActiveListing = i;
4873                                         break;
4874                                 }
4875                         }
4876                         // Remove edit accesskey from currently highlighted post by active user, if applicable.
4877                         if (indexOfActiveListing > -1) {
4878                                 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4879                         }
4880                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4881                         if (indexOfNextListing < listings.length) {
4882                                 listings[indexOfNextListing].focus();
4884                                 if (listings[indexOfNextListing].closest(".comment-item")) {
4885                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4886                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4887                                 }
4888                         } else {
4889                                 document.activeElement.blur();
4890                         }
4891                         // Add edit accesskey to newly highlighted post by active user, if applicable.
4892                         (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4893                 });
4894                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4895                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4896                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4897                         });
4898                 });
4899         }
4900         // Add event listener for ; (to focus the link on link posts).
4901         if (query("#content").hasClass("post-page") && 
4902                 query(".post").hasClass("link-post")) {
4903                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4904                         if (event.key == ';') query("a.link-post-link").focus();
4905                 });
4906         }
4908         // Add accesskeys to user page view selector.
4909         let viewSelector = query("#content.user-page > .sublevel-nav");
4910         if (viewSelector) {
4911                 let currentView = viewSelector.query("span");
4912                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4913                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4914         }
4916         // Add accesskey to index page sort selector.
4917         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4919         // Move MathJax style tags to <head>.
4920         var aggregatedStyles = "";
4921         queryAll("#content style").forEach(styleTag => {
4922                 aggregatedStyles += styleTag.innerHTML;
4923                 removeElement("style", styleTag.parentElement);
4924         });
4925         if (aggregatedStyles != "") {
4926                 insertHeadHTML(`<style id="mathjax-styles"> ${aggregatedStyles} </style>`);
4927         }
4929         /*  Makes double-clicking on a math element select the entire math element.
4930                 (This actually makes no difference to the behavior of the copy listener
4931                  which copies the entire LaTeX source of the full equation no matter how 
4932                  much of said equation is selected when the copy command is sent; 
4933                  however, it ensures that the UI communicates the actual behavior in a 
4934                  more accurate and understandable way.)
4935          */
4936         query("#content").querySelectorAll(".mjpage").forEach(mathBlock => {
4937                 mathBlock.addEventListener("dblclick", (event) => {
4938                         document.getSelection().selectAllChildren(mathBlock.querySelector(".mjx-chtml"));
4939                 });
4940                 mathBlock.title = mathBlock.classList.contains("mjpage__block")
4941                                                   ? "Double-click to select equation, then copy, to get LaTeX source"
4942                                                   : "Double-click to select equation; copy to get LaTeX source";
4943         });
4945         // Add listeners to switch between word count and read time.
4946         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4947         queryAll(".post-meta .read-time").forEach(element => {
4948                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4949                         let displayWordCount = localStorage.getItem("display-word-count");
4950                         toggleReadTimeOrWordCount(!displayWordCount);
4951                         if (displayWordCount) localStorage.removeItem("display-word-count");
4952                         else localStorage.setItem("display-word-count", true);
4953                 });
4954         });
4956         // Set up Image Focus feature.
4957         imageFocusSetup();
4959         // Set up keyboard shortcuts guide overlay.
4960         keyboardHelpSetup();
4962         // Show push notifications button if supported
4963         pushNotificationsSetup();
4965         // Show elements now that javascript is ready.
4966         removeElement("#hide-until-init");
4968         activateTrigger("pageLayoutFinished");
4971 /*************************/
4972 /* POST-LOAD ADJUSTMENTS */
4973 /*************************/
4975 window.addEventListener("pageshow", badgePostsWithNewComments);
4977 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4978         GWLog("INITIALIZER pageLayoutFinished");
4980         Appearance.postSetThemeHousekeeping();
4982         focusImageSpecifiedByURL();
4984         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4985 //      query("input[type='search']").value = GW.isMobile;
4986 //      insertHeadHTML(`<style>
4987 //              @media only screen and (hover:none) { #nav-item-search input { background-color: red; }}
4988 //              @media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}
4989 //      </style>`);
4990 }});
4992 function generateImagesOverlay() {
4993         GWLog("generateImagesOverlay");
4994         // Don’t do this on the about page.
4995         if (query(".about-page") != null) return;
4996         return;
4998         // Remove existing, if any.
4999         removeElement("#images-overlay");
5001         // Create new.
5002         document.body.insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
5003         let imagesOverlay = query("#images-overlay");
5004         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
5005         queryAll(".post-body img").forEach(image => {
5006                 let clonedImageContainer = newElement("DIV");
5008                 let clonedImage = image.cloneNode(true);
5009                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
5010                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
5011                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
5012                 clonedImageContainer.appendChild(clonedImage);
5014                 let zoomLevel = Appearance.currentTextZoom;
5016                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
5017                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
5018                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
5019                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
5021                 imagesOverlay.appendChild(clonedImageContainer);
5022         });
5024         // Add the event listeners to focus each image.
5025         imageFocusSetup(true);
5028 function adjustUIForWindowSize() {
5029         GWLog("adjustUIForWindowSize");
5030         var bottomBarOffset;
5032         // Adjust bottom bar state.
5033         let bottomBar = query("#bottom-bar");
5034         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5035         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
5036                 bottomBar.removeClass("decorative");
5038                 bottomBar.query("#nav-item-top").style.display = "";
5039         } else if (bottomBar) {
5040                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
5041                 else bottomBar.addClass("decorative");
5043                 bottomBar.query("#nav-item-top").style.display = "none";
5044         }
5046         // Show quick-nav UI up/down buttons if content is taller than window.
5047         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5048         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
5049                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
5050         });
5052         // Move anti-kibitzer toggle if content is very short.
5053         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
5055         // Update the visibility of the post nav UI.
5056         updatePostNavUIVisibility();
5059 function recomputeUIElementsContainerHeight(force = false) {
5060         GWLog("recomputeUIElementsContainerHeight");
5061         if (!GW.isMobile &&
5062                 (force || query("#ui-elements-container").style.height != "")) {
5063                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
5064                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
5065                                                                                                                 query("#content").clientHeight + "px" :
5066                                                                                                                 "100vh";
5067         }
5070 function focusImageSpecifiedByURL() {
5071         GWLog("focusImageSpecifiedByURL");
5072         if (location.hash.hasPrefix("#if_slide_")) {
5073                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
5074                         let images = queryAll(GW.imageFocus.overlayImagesSelector);
5075                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
5076                         if (imageToFocus > 0 && imageToFocus <= images.length) {
5077                                 focusImage(images[imageToFocus - 1]);
5079                                 // Set timer to hide the image focus UI.
5080                                 unhideImageFocusUI();
5081                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
5082                         }
5083                 });
5084         }
5087 /***********/
5088 /* GUIEDIT */
5089 /***********/
5091 function insertMarkup(event) {
5092         var mopen = '', mclose = '', mtext = '', func = false;
5093         if (typeof arguments[1] == 'function') {
5094                 func = arguments[1];
5095         } else {
5096                 mopen = arguments[1];
5097                 mclose = arguments[2];
5098                 mtext = arguments[3];
5099         }
5101         var textarea = event.target.closest("form").query("textarea");
5102         textarea.focus();
5103         var p0 = textarea.selectionStart;
5104         var p1 = textarea.selectionEnd;
5105         var cur0 = cur1 = p0;
5107         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
5108         str = func ? func(str, p0) : (mopen + str + mclose);
5110         // Determine selection.
5111         if (!func) {
5112                 cur0 += (p0 == p1) ? mopen.length : str.length;
5113                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
5114         } else {
5115                 cur0 = str[1];
5116                 cur1 = str[2];
5117                 str = str[0];
5118         }
5120         // Update textarea contents.
5121         document.execCommand("insertText", false, str);
5123         // Expand textarea, if needed.
5124         expandTextarea(textarea);
5126         // Set selection.
5127         textarea.selectionStart = cur0;
5128         textarea.selectionEnd = cur1;
5130         return;
5133 GW.guiEditButtons = [
5134         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
5135         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
5136         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
5137         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
5138         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
5139         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
5140         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
5141         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
5142         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
5143         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
5144         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
5145         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
5146         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
5147         [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
5148         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
5151 function blockquote(text, startpos) {
5152         if (text == '') {
5153                 text = "> Quoted text";
5154                 return [ text, startpos + 2, startpos + text.length ];
5155         } else {
5156                 text = "> " + text.split("\n").join("\n> ") + "\n";
5157                 return [ text, startpos + text.length, startpos + text.length ];
5158         }
5161 function hyperlink(text, startpos) {
5162         var url = '', link_text = text, endpos = startpos;
5163         if (text.search(/^https?/) != -1) {
5164                 url = text;
5165                 link_text = "link text";
5166                 startpos = startpos + 1;
5167                 endpos = startpos + link_text.length;
5168         } else {
5169                 url = prompt("Link address (URL):");
5170                 if (!url) {
5171                         endpos = startpos + text.length;
5172                         return [ text, startpos, endpos ];
5173                 }
5174                 startpos = startpos + text.length + url.length + 4;
5175                 endpos = startpos;
5176         }
5178         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
5181 /******************/
5182 /* SERVICE WORKER */
5183 /******************/
5185 if(navigator.serviceWorker) {
5186         navigator.serviceWorker.register('/service-worker.js');
5187         setCookie("push", "t");
5190 /*********************/
5191 /* USER AUTOCOMPLETE */
5192 /*********************/
5194 function zLowerUIElements() {
5195         let uiElementsContainer = query("#ui-elements-container");
5196         if (uiElementsContainer)
5197                 uiElementsContainer.style.zIndex = "1";
5200 function zRaiseUIElements() {
5201         let uiElementsContainer = query("#ui-elements-container");
5202         if (uiElementsContainer)
5203                 uiElementsContainer.style.zIndex = "";
5206 var userAutocomplete = null;
5208 function abbreviatedInterval(date) {
5209         let seconds = Math.floor((new Date() - date) / 1000);
5210         let days = Math.floor(seconds / (60 * 60 * 24));
5211         let years = Math.floor(days / 365);
5212         if (years)
5213                 return years + "y";
5214         else if (days)
5215                 return days + "d";
5216         else
5217                 return "today";
5220 function beginAutocompletion(control, startIndex, endIndex) {
5221         if(userAutocomplete) abortAutocompletion(userAutocomplete);
5223         let complete = { control: control,
5224                          abortController: new AbortController(),
5225                          fetchAbortController: new AbortController(),
5226                          container: document.createElement("div") };
5228         endIndex = endIndex || control.selectionEnd;
5229         let valueLength = control.value.length;
5231         complete.container.className = "autocomplete-container "
5232                                                                  + "right "
5233                                                                  + (window.innerWidth > 1280
5234                                                                         ? "outside"
5235                                                                         : "inside");
5236         control.insertAdjacentElement("afterend", complete.container);
5237         zLowerUIElements();
5239         let makeReplacer = (userSlug, displayName) => {
5240                 return () => {
5241                         let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
5242                         control.value = control.value.substring(0, startIndex - 1) +
5243                                 replacement +
5244                                 control.value.substring(endIndex);
5245                         abortAutocompletion(complete);
5246                         complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
5247                         complete.control.focus();
5248                 };
5249         };
5251         let switchHighlight = (newHighlight) => {
5252                 if (!newHighlight)
5253                         return;
5255                 complete.highlighted.removeClass("highlighted");
5256                 newHighlight.addClass("highlighted");
5257                 complete.highlighted = newHighlight;
5259                 //      Scroll newly highlighted item into view, if need be.
5260                 if (  complete.highlighted.offsetTop + complete.highlighted.offsetHeight 
5261                         > complete.container.scrollTop + complete.container.clientHeight) {
5262                         complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
5263                 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
5264                         complete.container.scrollTo(0, complete.highlighted.offsetTop);
5265                 }
5266         };
5267         let highlightNext = () => {
5268                 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
5269         };
5270         let highlightPrev = () => {
5271                 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
5272         };
5274         let updateCompletions = () => {
5275                 let fragment = control.value.substring(startIndex, endIndex);
5277                 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
5278                       {signal: complete.fetchAbortController.signal})
5279                         .then((res) => res.json())
5280                         .then((res) => {
5281                                 if(res.error) return;
5282                                 if(res.length == 0) return abortAutocompletion(complete);
5284                                 complete.container.innerHTML = "";
5285                                 res.forEach(entry => {
5286                                         let entryContainer = document.createElement("div");
5287                                         [ [ entry.displayName, "name" ],
5288                                           [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
5289                                           [ (entry.karma || 0) + " karma", "karma" ]
5290                                         ].forEach(x => {
5291                                                 let e = document.createElement("span");
5292                                                 e.append(x[0]);
5293                                                 e.className = x[1];
5294                                                 entryContainer.append(e);
5295                                         });
5296                                         entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
5297                                         complete.container.append(entryContainer);
5298                                 });
5299                                 complete.highlighted = complete.container.children[0];
5300                                 complete.highlighted.classList.add("highlighted");
5301                                 complete.container.scrollTo(0, 0);
5302                                 })
5303                         .catch((e) => {});
5304         };
5306         document.body.addEventListener("click", (event) => {
5307                 if (!complete.container.contains(event.target)) {
5308                         abortAutocompletion(complete);
5309                         event.preventDefault();
5310                         event.stopPropagation();
5311                 }
5312         }, {signal: complete.abortController.signal,
5313             capture: true});
5314         
5315         control.addEventListener("keydown", (event) => {
5316                 switch (event.key) {
5317                 case "Escape":
5318                         abortAutocompletion(complete);
5319                         event.preventDefault();
5320                         return;
5321                 case "ArrowUp":
5322                         highlightPrev();
5323                         event.preventDefault();
5324                         return;
5325                 case "ArrowDown":
5326                         highlightNext();
5327                         event.preventDefault();
5328                         return;
5329                 case "Tab":
5330                         if (event.shiftKey)
5331                                 highlightPrev();
5332                         else
5333                                 highlightNext();
5334                         event.preventDefault();
5335                         return;
5336                 case "Enter":
5337                         complete.highlighted.onclick();
5338                         event.preventDefault();
5339                         return;
5340                 }
5341         }, {signal: complete.abortController.signal});
5343         control.addEventListener("selectionchange", (event) => {
5344                 if (control.selectionStart < startIndex ||
5345                     control.selectionEnd > endIndex) {
5346                         abortAutocompletion(complete);
5347                 }
5348         }, {signal: complete.abortController.signal});
5349         
5350         control.addEventListener("input", (event) => {
5351                 complete.fetchAbortController.abort();
5352                 complete.fetchAbortController = new AbortController();
5354                 endIndex += control.value.length - valueLength;
5355                 valueLength = control.value.length;
5357                 if (endIndex < startIndex) {
5358                         abortAutocompletion(complete);
5359                         return;
5360                 }
5361                 
5362                 updateCompletions();
5363         }, {signal: complete.abortController.signal});
5365         userAutocomplete = complete;
5367         if(startIndex != endIndex) updateCompletions();
5370 function abortAutocompletion(complete) {
5371         complete.fetchAbortController.abort();
5372         complete.abortController.abort();
5373         complete.container.remove();
5374         userAutocomplete = null;
5375         zRaiseUIElements();