Update theme lettering to remove dark theme.
[lw2-viewer.git] / www / script.js
blobd987288c77cac072913076da8c2c4a527e54dee0
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         doAjax({
151                 location: uri,
152                 onSuccess: (event) => {
153                         let response = JSON.parse(event.target.responseText);
154                         window[fname](response);
155                 }
156         });
159 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
160 deferredCalls = null;
162 /*      Return the currently selected text, as HTML (rather than unstyled text).
163         */
164 function getSelectionHTML() {
165         let container = newElement("DIV");
166         container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
167         return container.innerHTML;
170 /*      Given an HTML string, creates an element from that HTML, adds it to 
171         #ui-elements-container (creating the latter if it does not exist), and 
172         returns the created element.
173         */
174 function addUIElement(element_html) {
175         let ui_elements_container = query("#ui-elements-container");
176         if (ui_elements_container == null)
177                 ui_elements_container = document.body.appendChild(newElement("NAV", { "id": "ui-elements-container" }));
179         ui_elements_container.insertAdjacentHTML("beforeend", element_html);
180         return ui_elements_container.lastElementChild;
183 /*      Given an element or a selector, removes that element (or the element 
184         identified by the selector).
185         If multiple elements match the selector, only the first is removed.
186         */
187 function removeElement(elementOrSelector, ancestor = document) {
188         if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
189         if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
192 /*      Returns true if the string begins with the given prefix.
193         */
194 String.prototype.hasPrefix = function (prefix) {
195         return (this.lastIndexOf(prefix, 0) === 0);
198 /*      Toggles whether the page is scrollable.
199         */
200 function togglePageScrolling(enable) {
201         if (!enable) {
202                 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
203                 document.body.addClass("no-scroll");
204                 document.body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
205         } else {
206                 document.body.removeClass("no-scroll");
207                 document.body.removeAttribute("style");
208                 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
209         }
212 DOMRectReadOnly.prototype.isInside = function (x, y) {
213         return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
216 /*      Simple mutex mechanism.
217  */
218 function doIfAllowed(f, passHolder, passName, releaseImmediately = false) {
219         if (passHolder[passName] == false)
220                 return;
222         passHolder[passName] = false;
224         f();
226         if (releaseImmediately) {
227                 passHolder[passName] = true;
228         } else {
229                 requestAnimationFrame(() => {
230                         passHolder[passName] = true;
231                 });
232         }
235 /*******************/
236 /* COPY PROCESSORS */
237 /*******************/
239 /*********************************************************************/
240 /*  Workaround for Firefox weirdness, based on more Firefox weirdness.
241  */
242 DocumentFragment.prototype.getSelection = function () {
243         return document.getSelection();
246 /******************************************************************************/
247 /*  Returns true if the node contains only whitespace and/or other empty nodes.
248  */
249 function isNodeEmpty(node) {
250         if (node.nodeType == Node.TEXT_NODE)
251                 return (node.textContent.match(/\S/) == null);
253         if (   node.nodeType == Node.ELEMENT_NODE
254                 && [ "IMG", "VIDEO", "AUDIO", "IFRAME", "OBJECT" ].includes(node.tagName))
255                 return false;
257         if (node.childNodes.length == 0)
258                 return true;
260         for (childNode of node.childNodes)
261                 if (isNodeEmpty(childNode) == false)
262                         return false;
264         return true;
267 /***************************************************************/
268 /*  Returns a DocumentFragment containing the current selection.
269  */
270 function getSelectionAsDocument(doc = document) {
271         let docFrag = doc.getSelection().getRangeAt(0).cloneContents();
273         //      Strip whitespace (remove top-level empty nodes).
274         let nodesToRemove = [ ];
275         docFrag.childNodes.forEach(node => {
276                 if (isNodeEmpty(node))
277                         nodesToRemove.push(node);
278         });
279         nodesToRemove.forEach(node => {
280                 docFrag.removeChild(node);
281         });
283         return docFrag;
286 /*****************************************************************************/
287 /*  Adds the given copy processor, appending it to the existing array thereof.
289     Each copy processor should take two arguments: the copy event, and the
290     DocumentFragment which holds the selection as it is being processed by each
291     successive copy processor.
293     A copy processor should return true if processing should continue after it’s
294     done, false otherwise (e.g. if it has entirely replaced the contents of the
295     selection object with what the final clipboard contents should be).
296  */
297 function addCopyProcessor(processor) {
298         if (GW.copyProcessors == null)
299                 GW.copyProcessors = [ ];
301         GW.copyProcessors.push(processor);
304 /******************************************************************************/
305 /*  Set up the copy processor system by registering a ‘copy’ event handler to
306     call copy processors. (Must be set up for the main document, and separately
307     for any shadow roots.)
308  */
309 function registerCopyProcessorsForDocument(doc) {
310         GWLog("registerCopyProcessorsForDocument", "rewrite.js", 1);
312         doc.addEventListener("copy", (event) => {
313                 if (   GW.copyProcessors == null
314                         || GW.copyProcessors.length == 0)
315                         return;
317                 event.preventDefault();
318                 event.stopPropagation();
320                 let selection = getSelectionAsDocument(doc);
322                 let i = 0;
323                 while (   i < GW.copyProcessors.length
324                           && GW.copyProcessors[i++](event, selection));
326                 // This is necessary for .innerText to work properly.
327                 let wrapper = newElement("DIV");
328                 wrapper.appendChild(selection);
329                 document.body.appendChild(wrapper);
331                 let makeLinksAbsolute = (node) => {
332                         if(node['attributes']) {
333                                 for(attr of ['src', 'href']) {
334                                         if(node[attr])
335                                                 node[attr] = node[attr];
336                                 }
337                         }
338                         node.childNodes.forEach(makeLinksAbsolute);
339                 }
340                 makeLinksAbsolute(wrapper);
342                 event.clipboardData.setData("text/plain", wrapper.innerText);
343                 event.clipboardData.setData("text/html", wrapper.innerHTML);
345                 document.body.removeChild(wrapper);
346         });
349 /*******************************************/
350 /*  Set up copy processors in main document.
351  */
352 registerCopyProcessorsForDocument(document);
354 /*****************************************************************************/
355 /*  Makes it so that copying a rendered equation or other math element copies
356     the LaTeX source, instead of the useless gibberish that is the contents of
357     the text nodes of the HTML representation of the equation.
358  */
359 addCopyProcessor((event, selection) => {
360         if (event.target.closest(".mjx-math")) {
361                 selection.replaceChildren(event.target.closest(".mjx-math").getAttribute("aria-label"));
363                 return false;
364         }
366         selection.querySelectorAll(".mjx-chtml").forEach(mathBlock => {
367                 mathBlock.innerHTML = " " + mathBlock.querySelector(".mjx-math").getAttribute("aria-label") + " ";
368         });
370         return true;
373 /************************************************************************/
374 /*  Remove soft hyphens and other extraneous characters from copied text.
375  */
376 addCopyProcessor((event, selection) => {
377         let replaceText = (node) => {
378                 if(node.nodeType == Node.TEXT_NODE) {
379                         node.nodeValue = node.nodeValue.replace(/\u00AD|\u200b/g, "");
380                 }
382                 node.childNodes.forEach(replaceText);
383         }
384         replaceText(selection);
386         return true;
390 /********************/
391 /* DEBUGGING OUTPUT */
392 /********************/
394 GW.enableLogging = (permanently = false) => {
395         if (permanently)
396                 localStorage.setItem("logging-enabled", "true");
397         else
398                 GW.loggingEnabled = true;
400 GW.disableLogging = (permanently = false) => {
401         if (permanently)
402                 localStorage.removeItem("logging-enabled");
403         else
404                 GW.loggingEnabled = false;
407 /*******************/
408 /* INBOX INDICATOR */
409 /*******************/
411 function processUserStatus(userStatus) {
412         window.userStatus = userStatus;
413         if(userStatus) {
414                 if(userStatus.notifications) {
415                         let element = query('#inbox-indicator');
416                         element.className = 'new-messages';
417                         element.title = 'New messages [o]';
418                 }
419         } else {
420                 location.reload();
421         }
424 /**************/
425 /* COMMENTING */
426 /**************/
428 function toggleMarkdownHintsBox() {
429         GWLog("toggleMarkdownHintsBox");
430         let markdownHintsBox = query("#markdown-hints");
431         markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
433 function hideMarkdownHintsBox() {
434         GWLog("hideMarkdownHintsBox");
435         let markdownHintsBox = query("#markdown-hints");
436         if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
439 Element.prototype.addTextareaFeatures = function() {
440         GWLog("addTextareaFeatures");
441         let textarea = this;
443         textarea.addEventListener("focus", GW.textareaFocused = (event) => {
444                 GWLog("GW.textareaFocused");
445                 event.target.closest("form").scrollIntoViewIfNeeded();
446         });
447         textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
448                 GWLog("GW.textareaInputReceived");
449                 if (window.innerWidth > 520) {
450                         // Expand textarea if needed.
451                         expandTextarea(textarea);
452                 } else {
453                         // Remove markdown hints.
454                         hideMarkdownHintsBox();
455                         query(".guiedit-mobile-help-button").removeClass("active");
456                 }
457                 // User mentions autocomplete
458                 if(!userAutocomplete &&
459                    textarea.value.charAt(textarea.selectionStart - 1) === "@" &&
460                    (textarea.selectionStart === 1 ||
461                     !textarea.value.charAt(textarea.selectionStart - 2).match(/[a-zA-Z0-9]/))) {
462                         beginAutocompletion(textarea, textarea.selectionStart);
463                 }
464         }, false);
465         textarea.addEventListener("click", (event) => {
466                 if(!userAutocomplete) {
467                         let start = textarea.selectionStart, end = textarea.selectionEnd;
468                         let value = textarea.value;
469                         if (start <= 1) return;
470                         for (; value.charAt(start - 1) != "@"; start--) {
471                                 if (start <= 1) return;
472                                 if (value.charAt(start - 1) == " ") return;
473                         }
474                         for(; end < value.length && value.charAt(end) != " "; end++) { true }
475                         beginAutocompletion(textarea, start, end);
476                 }
477         });
479         textarea.addEventListener("paste", (event) => {
480                 let html = event.clipboardData.getData("text/html");
481                 if(html) {
482                         html = html.replace(/\n|\r/gm, "");
483                         let isQuoted = textarea.selectionStart >= 2 &&
484                             textarea.value.substring(textarea.selectionStart - 2, textarea.selectionStart) == "> ";
485                         document.execCommand("insertText", false, MarkdownFromHTML(html, (isQuoted ? "> " : null)));
486                         event.preventDefault();
487                 }
488         });
490         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
491         textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
492         textarea.addEventListener("keydown", (event) => {
493                 // Special case for alt+4
494                 // Generalize this before adding more.
495                 if(event.altKey && event.key === '4') {
496                         insertMarkup(event, "$", "$", "LaTeX formula");
497                         event.stopPropagation();
498                         event.preventDefault();
499                 }
500         });
502         let form = textarea.closest("form");
504         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
505         let textareaContainer = textarea.closest(".textarea-container");
506         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
507         for (var button of GW.guiEditButtons) {
508                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
509                 buttons_container.insertAdjacentHTML("beforeend", 
510                         "<button type='button' class='guiedit guiedit-" 
511                         + name
512                         + "' tabindex='-1'"
513                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
514                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
515                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
516                         + " onclick='insertMarkup(event,"
517                         + ((typeof m_before_or_func == 'function') ?
518                                 m_before_or_func.name : 
519                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
520                         + ");'><div>"
521                         + icon
522                         + "</div></button>"
523                 );
524         }
526         var markdown_hints = 
527         `<input type='checkbox' id='markdown-hints-checkbox'>
528         <label for='markdown-hints-checkbox'></label>
529         <div id='markdown-hints'>` + 
530         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
531                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
532                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
533                 "<span>Heading 1</span><code># Heading 1</code>",
534                 "<span>Heading 2</span><code>## Heading 1</code>",
535                 "<span>Heading 3</span><code>### Heading 1</code>",
536                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
537         `</div>`;
538         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
540         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
541                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
542                         GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
543                         if (button.hasClass("guiedit-mobile-help-button")) {
544                                 toggleMarkdownHintsBox();
545                                 event.target.toggleClass("active");
546                                 query(".posting-controls:focus-within textarea").focus();
547                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
548                                 event.target.blur();
549                                 hideMarkdownHintsBox();
550                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
551                         }
552                 });
553         });
555         // On smartphone (narrow mobile) screens, when a textarea is focused (and
556         // automatically fullscreened), remove all the filters from the page, and 
557         // then apply them *just* to the fixed editor UI elements. This is in order
558         // to get around the “children of elements with a filter applied cannot be
559         // fixed” issue.
560         if (GW.isMobile && window.innerWidth <= 520) {
561                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
562                 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
563                         GWLog("GW.textareaFocusedMobile");
564                         Appearance.savedFilters = Appearance.currentFilters;
565                         Appearance.applyFilters(Appearance.noFilters);
566                         fixedEditorElements.forEach(element => {
567                                 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
568                         });
569                 });
570                 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
571                         GWLog("GW.textareaBlurredMobile");
572                         requestAnimationFrame(() => {
573                                 Appearance.applyFilters(Appearance.savedFilters);
574                                 Appearance.savedFilters = null;
575                                 fixedEditorElements.forEach(element => {
576                                         element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
577                                 });
578                         });
579                 });
580         }
583 Element.prototype.injectReplyForm = function(editMarkdownSource) {
584         GWLog("injectReplyForm");
585         let commentControls = this;
586         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
587         let postId = commentControls.parentElement.dataset["postId"];
588         let tagId = commentControls.parentElement.dataset["tagId"];
589         let withparent = (!editMarkdownSource && commentControls.getCommentId());
590         let answer = commentControls.parentElement.id == "answers";
591         let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
592         let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
593         let parentCommentItem = commentControls.closest(".comment-item");
594         let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
595             (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
596         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
597                 "<form method='post'>" + 
598                 "<div class='textarea-container'>" + 
599                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
600                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
601                 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
602                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
603                 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
604                 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
605                 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
606                 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
607                 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
608                 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
609                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
610                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
611                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
612                 "</div><div>" + 
613                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
614                 "<input type='submit' value='Submit'>" + 
615                 "</div></form>";
616         commentControls.onsubmit = disableBeforeUnload;
618         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
619                 GWLog("GW.cancelCommentButtonClicked");
620                 hideReplyForm(event.target.closest(".comment-controls"));
621         });
622         commentControls.scrollIntoViewIfNeeded();
623         commentControls.query("form").onsubmit = (event) => {
624                 if (!event.target.text.value) {
625                         alert("Please enter a comment.");
626                         return false;
627                 }
628         }
629         let textarea = commentControls.query("textarea");
630         textarea.addTextareaFeatures();
631         textarea.focus();
634 function showCommentEditForm(commentItem) {
635         GWLog("showCommentEditForm");
637         let commentBody = commentItem.query(".comment-body");
638         commentBody.style.display = "none";
640         let commentControls = commentItem.query(".comment-controls");
641         commentControls.injectReplyForm(commentBody.dataset.markdownSource);
642         commentControls.query("form").addClass("edit-existing-comment");
643         expandTextarea(commentControls.query("textarea"));
646 function showReplyForm(commentItem) {
647         GWLog("showReplyForm");
649         let commentControls = commentItem.query(".comment-controls");
650         commentControls.injectReplyForm(commentControls.dataset.enteredText);
653 function hideReplyForm(commentControls) {
654         GWLog("hideReplyForm");
655         // Are we editing a comment? If so, un-hide the existing comment body.
656         let containingComment = commentControls.closest(".comment-item");
657         if (containingComment) containingComment.query(".comment-body").style.display = "";
659         let enteredText = commentControls.query("textarea").value;
660         if (enteredText) commentControls.dataset.enteredText = enteredText;
662         disableBeforeUnload();
663         commentControls.constructCommentControls();
666 function expandTextarea(textarea) {
667         GWLog("expandTextarea");
668         if (window.innerWidth <= 520) return;
670         let totalBorderHeight = 30;
671         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
673         requestAnimationFrame(() => {
674                 textarea.style.height = 'auto';
675                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
676                 if (textarea.clientHeight < window.innerHeight) {
677                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
678                 }
679         });
682 function doCommentAction(action, commentItem) {
683         GWLog("doCommentAction");
684         let params = {};
685         params[(action + "-comment-id")] = commentItem.getCommentId();
686         doAjax({
687                 method: "POST",
688                 params: params,
689                 onSuccess: GW.commentActionPostSucceeded = (event) => {
690                         GWLog("GW.commentActionPostSucceeded");
691                         let fn = {
692                                 retract: () => { commentItem.firstChild.addClass("retracted") },
693                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
694                                 delete: () => {
695                                         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>";
696                                         commentItem.removeChild(commentItem.query(".comment-controls"));
697                                 }
698                         }[action];
699                         if(fn) fn();
700                         if(action != "delete")
701                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
702                 }
703         });
706 /**********/
707 /* VOTING */
708 /**********/
710 function parseVoteType(voteType) {
711         GWLog("parseVoteType");
712         let value = {};
713         if (!voteType) return value;
714         value.up = /[Uu]pvote$/.test(voteType);
715         value.down = /[Dd]ownvote$/.test(voteType);
716         value.big = /^big/.test(voteType);
717         return value;
720 function makeVoteType(value) {
721         GWLog("makeVoteType");
722         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
725 function makeVoteClass(vote) {
726         GWLog("makeVoteClass");
727         if (vote.up || vote.down) {
728                 return (vote.big ? 'selected big-vote' : 'selected');
729         } else {
730                 return '';
731         }
734 function findVoteControls(targetType, targetId, voteAxis) {
735         var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
737         if(targetType == "Post") {
738                 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
739         } else if(targetType == "Comment") {
740                 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
741         }
744 function votesEqual(vote1, vote2) {
745         var allKeys = Object.assign({}, vote1);
746         Object.assign(allKeys, vote2);
748         for(k of allKeys.keys()) {
749                 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
750         }
751         return true;
754 function addVoteButtons(element, vote, targetType) {
755         GWLog("addVoteButtons");
756         vote = vote || {};
757         let voteAxis = element.parentElement.dataset.voteAxis || "karma";
758         let voteType = parseVoteType(vote[voteAxis]);
759         let voteClass = makeVoteClass(voteType);
761         element.parentElement.queryAll("button").forEach((button) => {
762                 button.disabled = false;
763                 if (voteType) {
764                         if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
765                                 button.addClass(voteClass);
766                 }
767                 updateVoteButtonVisualState(button);
768                 button.addActivateEvent(voteButtonClicked);
769         });
772 function updateVoteButtonVisualState(button) {
773         GWLog("updateVoteButtonVisualState");
775         button.removeClasses([ "none", "one", "two-temp", "two" ]);
777         if (button.disabled)
778                 button.addClass("none");
779         else if (button.hasClass("big-vote"))
780                 button.addClass("two");
781         else if (button.hasClass("selected"))
782                 button.addClass("one");
783         else
784                 button.addClass("none");
787 function changeVoteButtonVisualState(button) {
788         GWLog("changeVoteButtonVisualState");
790         /*      Interaction states are:
792                 0  0·    (neutral; +1 click)
793                 1  1·    (small vote; +1 click)
794                 2  2·    (big vote; +1 click)
796                 Visual states are (with their state classes in [brackets]) are:
798                 01    (no vote) [none]
799                 02    (small vote active) [one]
800                 12    (small vote active, temporary indicator of big vote) [two-temp]
801                 22    (big vote active) [two]
803                 The following are the 9 possible interaction state transitions (and
804                 the visual state transitions associated with them):
806                                 VIS.    VIS.
807                 FROM    TO      FROM    TO      NOTES
808                 ====    ====    ====    ====    =====
809                 0       0·      01      12      first click
810                 0·      1       12      02      one click without second
811                 0·      2       12      22      second click
813                 1       1·      02      12      first click
814                 1·      0       12      01      one click without second
815                 1·      2       12      22      second click
817                 2       2·      22      12      first click
818                 2·      1       12      02      one click without second
819                 2·      0       12      01      second click
820         */
821         let transitions = [
822                 [ "big-vote two-temp clicked-twice", "none"     ], // 2· => 0
823                 [ "big-vote two-temp clicked-once",  "one"      ], // 2· => 1
824                 [ "big-vote clicked-once",           "two-temp" ], // 2  => 2·
826                 [ "selected two-temp clicked-twice", "two"      ], // 1· => 2
827                 [ "selected two-temp clicked-once",  "none"     ], // 1· => 0
828                 [ "selected clicked-once",           "two-temp" ], // 1  => 1·
830                 [ "two-temp clicked-twice",          "two"      ], // 0· => 2
831                 [ "two-temp clicked-once",           "one"      ], // 0· => 1
832                 [ "clicked-once",                    "two-temp" ], // 0  => 0·
833         ];
834         for (let [ interactionClasses, visualStateClass ] of transitions) {
835                 if (button.hasClasses(interactionClasses.split(" "))) {
836                         button.removeClasses([ "none", "one", "two-temp", "two" ]);
837                         button.addClass(visualStateClass);
838                         break;
839                 }
840         }
843 function voteCompleteEvent(targetType, targetId, response) {
844         GWLog("voteCompleteEvent");
846         var currentVote = voteData[targetType][targetId] || {};
847         var desiredVote = voteDesired[targetType][targetId];
849         var controls = findVoteControls(targetType, targetId);
850         var controlsByAxis = new Object;
852         controls.forEach(control => {
853                 const voteAxis = (control.dataset.voteAxis || "karma");
855                 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
856                         control.removeClass("waiting");
857                         control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
858                 }
860                 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
861                 controlsByAxis[voteAxis].push(control);
863                 const voteType = currentVote[voteAxis];
864                 const vote = parseVoteType(voteType);
865                 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
866                 const voteClass = makeVoteClass(vote);
868                 if (response && response[voteAxis]) {
869                         const [voteType, displayText, titleText] = response[voteAxis];
871                         const displayTarget = control.query(".karma-value");
872                         if (displayTarget.hasClass("redacted")) {
873                                 displayTarget.dataset["trueValue"] = displayText;
874                         } else {
875                                 displayTarget.innerHTML = displayText;
876                         }
877                         displayTarget.setAttribute("title", titleText);
878                 }
880                 control.queryAll("button.vote").forEach(button => {
881                         updateVoteButton(button, voteUpDown, voteClass);
882                 });
883         });
886 function updateVoteButton(button, voteUpDown, voteClass) {
887         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
888         if (button.dataset.voteType == voteUpDown)
889                 button.addClass(voteClass);
890         updateVoteButtonVisualState(button);
893 function makeVoteRequestCompleteEvent(targetType, targetId) {
894         return (event) => {
895                 var currentVote = {};
896                 var response = null;
898                 if (event.target.status == 200) {
899                         response = JSON.parse(event.target.responseText);
900                         for (const voteAxis of response.keys()) {
901                                 currentVote[voteAxis] = response[voteAxis][0];
902                         }
903                         voteData[targetType][targetId] = currentVote;
904                 } else {
905                         delete voteDesired[targetType][targetId];
906                         currentVote = voteData[targetType][targetId];
907                 }
909                 var desiredVote = voteDesired[targetType][targetId];
911                 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
912                         sendVoteRequest(targetType, targetId);
913                 } else {
914                         delete voteDesired[targetType][targetId];
915                         voteCompleteEvent(targetType, targetId, response);
916                 }
917         }
920 function sendVoteRequest(targetType, targetId) {
921         GWLog("sendVoteRequest");
923         doAjax({
924                 method: "POST",
925                 location: "/karma-vote",
926                 params: { "target": targetId,
927                           "target-type": targetType,
928                           "vote": JSON.stringify(voteDesired[targetType][targetId]) },
929                 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
930         });
933 function voteButtonClicked(event) {
934         GWLog("voteButtonClicked");
935         let voteButton = event.target;
937         // 500 ms (0.5 s) double-click timeout.
938         let doubleClickTimeout = 500;
940         if (!voteButton.clickedOnce) {
941                 voteButton.clickedOnce = true;
942                 voteButton.addClass("clicked-once");
943                 changeVoteButtonVisualState(voteButton);
945                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
946                         if (!voteButton.clickedOnce) return;
948                         // Do single-click code.
949                         voteButton.clickedOnce = false;
950                         voteEvent(voteButton, 1);
951                 }, doubleClickTimeout, voteButton);
952         } else {
953                 voteButton.clickedOnce = false;
955                 // Do double-click code.
956                 voteButton.removeClass("clicked-once");
957                 voteButton.addClass("clicked-twice");
958                 voteEvent(voteButton, 2);
959         }
962 function voteEvent(voteButton, numClicks) {
963         GWLog("voteEvent");
964         voteButton.blur();
966         let voteControl = voteButton.parentNode;
968         let targetType = voteButton.dataset.targetType;
969         let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
970         let voteAxis = voteControl.dataset.voteAxis || "karma";
971         let voteUpDown = voteButton.dataset.voteType;
973         let voteType;
974         if (   (numClicks == 2 && voteButton.hasClass("big-vote"))
975                 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
976                 voteType = "neutral";
977         } else {
978                 let vote = parseVoteType(voteUpDown);
979                 vote.big = (numClicks == 2);
980                 voteType = makeVoteType(vote);
981         }
983         let voteControls = findVoteControls(targetType, targetId, voteAxis);
984         for (const voteControl of voteControls) {
985                 voteControl.addClass("waiting");
986                 voteControl.queryAll(".vote").forEach(button => {
987                         button.addClass("waiting");
988                         updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
989                 });
990         }
992         let voteRequestPending = voteDesired[targetType][targetId];
993         let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
994         voteObject[voteAxis] = voteType;
995         voteDesired[targetType][targetId] = voteObject;
997         if (!voteRequestPending) sendVoteRequest(targetType, targetId);
1000 function initializeVoteButtons() {
1001         // Color the upvote/downvote buttons with an embedded style sheet.
1002         insertHeadHTML(`<style id="vote-buttons">
1003                 :root {
1004                         --GW-upvote-button-color: #00d800;
1005                         --GW-downvote-button-color: #eb4c2a;
1006                 }
1007         </style>`);
1010 function processVoteData(voteData) {
1011         window.voteData = voteData;
1013         window.voteDesired = new Object;
1014         for(key of voteData.keys()) {
1015                 voteDesired[key] = new Object;
1016         }
1018         initializeVoteButtons();
1019         
1020         addTriggerListener("postLoaded", {priority: 3000, fn: () => {
1021                 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
1022                         let postID = karmaValue.parentNode.dataset.postId;
1023                         addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
1024                         karmaValue.parentElement.addClass("active-controls");
1025                 });
1026         }});
1028         addTriggerListener("DOMReady", {priority: 3000, fn: () => {
1029                 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
1030                         let commentID = karmaValue.getCommentId();
1031                         addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
1032                         karmaValue.parentElement.addClass("active-controls");
1033                 });
1034         }});
1037 /*****************************************/
1038 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
1039 /*****************************************/
1041 Element.prototype.getCommentDate = function() {
1042         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
1043         let dateElement = item && item.query(".date");
1044         return (dateElement && parseInt(dateElement.dataset["jsDate"]));
1046 function getCurrentVisibleComment() {
1047         let px = window.innerWidth/2, py = 5;
1048         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
1049         let bottomBar = query("#bottom-bar");
1050         let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : document.body.getBoundingClientRect().bottom);
1051         let atbottom =  bottomOffset <= window.innerHeight;
1052         if (atbottom) {
1053                 let hashci = location.hash && query(location.hash);
1054                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
1055                         commentItem = hashci;
1056                 }
1057         }
1058         return commentItem;
1061 function highlightCommentsSince(date) {
1062         GWLog("highlightCommentsSince");
1063         var newCommentsCount = 0;
1064         GW.newComments = [ ];
1065         let oldCommentsStack = [ ];
1066         let prevNewComment;
1067         queryAll(".comment-item").forEach(commentItem => {
1068                 commentItem.prevNewComment = prevNewComment;
1069                 commentItem.nextNewComment = null;
1070                 if (commentItem.getCommentDate() > date) {
1071                         commentItem.addClass("new-comment");
1072                         newCommentsCount++;
1073                         GW.newComments.push(commentItem.getCommentId());
1074                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
1075                         oldCommentsStack = [ commentItem ];
1076                         prevNewComment = commentItem;
1077                 } else {
1078                         commentItem.removeClass("new-comment");
1079                         oldCommentsStack.push(commentItem);
1080                 }
1081         });
1083         GW.newCommentScrollSet = (commentItem) => {
1084                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
1085                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
1086         };
1087         GW.newCommentScrollListener = () => {
1088                 let commentItem = getCurrentVisibleComment();
1089                 GW.newCommentScrollSet(commentItem);
1090         }
1092         addScrollListener(GW.newCommentScrollListener);
1094         if (document.readyState=="complete") {
1095                 GW.newCommentScrollListener();
1096         } else {
1097                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
1098                 GW.newCommentScrollSet(commentItem);
1099         }
1101         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
1103         return newCommentsCount;
1106 function scrollToNewComment(next) {
1107         GWLog("scrollToNewComment");
1108         let commentItem = getCurrentVisibleComment();
1109         let targetComment = null;
1110         let targetCommentID = null;
1111         if (commentItem) {
1112                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
1113                 if (targetComment) {
1114                         targetCommentID = targetComment.getCommentId();
1115                 }
1116         } else {
1117                 if (GW.newComments[0]) {
1118                         targetCommentID = GW.newComments[0];
1119                         targetComment = query("#comment-" + targetCommentID);
1120                 }
1121         }
1122         if (targetComment) {
1123                 expandAncestorsOf(targetCommentID);
1124                 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
1125                 targetComment.scrollIntoView();
1126         }
1128         GW.newCommentScrollListener();
1131 function getPostHash() {
1132         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
1133         return (postHash ? postHash[1] : false);
1135 function setHistoryLastVisitedDate(date) {
1136         window.history.replaceState({ lastVisited: date }, null);
1138 function getLastVisitedDate() {
1139         // Get the last visited date (or, if posting a comment, the previous last visited date).
1140         if(window.history.state) return (window.history.state||{})['lastVisited'];
1141         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1142         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
1143         let currentVisited = localStorage.getItem(storageName);
1144         setHistoryLastVisitedDate(currentVisited);
1145         return currentVisited;
1147 function setLastVisitedDate(date) {
1148         GWLog("setLastVisitedDate");
1149         // If NOT posting a comment, save the previous value for the last-visited-date 
1150         // (to recover it in case of posting a comment).
1151         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1152         if (!aCommentHasJustBeenPosted) {
1153                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
1154                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
1155         }
1157         // Set the new value.
1158         localStorage.setItem("last-visited-date_" + getPostHash(), date);
1161 function updateSavedCommentCount() {
1162         GWLog("updateSavedCommentCount");
1163         let commentCount = queryAll(".comment").length;
1164         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
1166 function badgePostsWithNewComments() {
1167         GWLog("badgePostsWithNewComments");
1168         if (getQueryVariable("show") == "conversations") return;
1170         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
1171                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
1173                 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
1174                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1175                 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1177                 if (currentCommentCount > savedCommentCount)
1178                         commentCountDisplay.addClass("new-comments");
1179                 else
1180                         commentCountDisplay.removeClass("new-comments");
1181                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1182         });
1186 /*****************/
1187 /* MEDIA QUERIES */
1188 /*****************/
1190 GW.mediaQueries = {
1191     systemDarkModeActive:  matchMedia("(prefers-color-scheme: dark)")
1195 /************************/
1196 /* ACTIVE MEDIA QUERIES */
1197 /************************/
1199 /*  This function provides two slightly different versions of its functionality,
1200     depending on how many arguments it gets.
1202     If one function is given (in addition to the media query and its name), it
1203     is called whenever the media query changes (in either direction).
1205     If two functions are given (in addition to the media query and its name),
1206     then the first function is called whenever the media query starts matching,
1207     and the second function is called whenever the media query stops matching.
1209     If you want to call a function for a change in one direction only, pass an
1210     empty closure (NOT null!) as one of the function arguments.
1212     There is also an optional fifth argument. This should be a function to be
1213     called when the active media query is canceled.
1214  */
1215 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1216     if (typeof GW.mediaQueryResponders == "undefined")
1217         GW.mediaQueryResponders = { };
1219     let mediaQueryResponder = (event, canceling = false) => {
1220         if (canceling) {
1221             GWLog(`Canceling media query “${name}”`, "media queries", 1);
1223             if (whenCanceledDo != null)
1224                 whenCanceledDo(mediaQuery);
1225         } else {
1226             let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1228             GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1230             if ((otherwiseDo == null) || matches)
1231                 ifMatchesOrAlwaysDo(mediaQuery);
1232             else
1233                 otherwiseDo(mediaQuery);
1234         }
1235     };
1236     mediaQueryResponder();
1237     mediaQuery.addListener(mediaQueryResponder);
1239     GW.mediaQueryResponders[name] = mediaQueryResponder;
1242 /*  Deactivates and discards an active media query, after calling the function
1243     that was passed as the whenCanceledDo parameter when the media query was
1244     added.
1245  */
1246 function cancelDoWhenMatchMedia(name) {
1247     GW.mediaQueryResponders[name](null, true);
1249     for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1250         mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1252     GW.mediaQueryResponders[name] = null;
1256 /******************************/
1257 /* DARK/LIGHT MODE ADJUSTMENT */
1258 /******************************/
1260 DarkMode = {
1261         /*****************/
1262         /*      Configuration.
1263          */
1264         modeOptions: [
1265                 [ "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)" ],
1266                 [ "light", "&#xe28f;", "Light mode at all times (black-on-white)" ],
1267                 [ "dark", "&#xf186;", "Dark mode at all times (inverted: white-on-black)" ]
1268         ],
1270         selectedModeOptionNote: " [This option is currently selected.]",
1272         /******************/
1273         /*      Infrastructure.
1274          */
1276         modeSelector: null,
1277         modeSelectorInteractable: true,
1279         /******************/
1280         /*      Mode selection.
1281          */
1283     /*  Returns current (saved) mode (light, dark, or auto).
1284      */
1285     getSavedMode: () => {
1286         return (readCookie("dark-mode") || "auto");
1287     },
1289         /*      Saves specified mode (light, dark, or auto).
1290          */
1291         saveMode: (mode) => {
1292                 GWLog("DarkMode.setMode");
1294                 if (mode == "auto")
1295                         setCookie("dark-mode", "");
1296                 else
1297                         setCookie("dark-mode", mode);
1298         },
1300         getMediaQuery: (selectedMode = DarkMode.getSavedMode()) => {
1301                 if (selectedMode == "auto") {
1302                         return "all and (prefers-color-scheme: dark)";
1303                 } else if (selectedMode == "dark") {
1304                         return "all";
1305                 } else {
1306                         return "not all";
1307                 }
1308         },
1310         /*  Set specified color mode (light, dark, or auto).
1311          */
1312         setMode: (selectedMode = DarkMode.getSavedMode()) => {
1313                 GWLog("DarkMode.setMode");
1315                 document.body.removeClasses(["force-dark-mode", "force-light-mode"]);
1316                 if(selectedMode === "dark" || selectedMode === "light")
1317                         document.body.addClass("force-" + selectedMode + "-mode");
1319                 let media = DarkMode.getMediaQuery(selectedMode);
1320                 let darkModeStyles = document.querySelector("link.dark-mode");
1321                 if (darkModeStyles) {
1322                         //      Set `media` attribute of style block to match requested mode.
1323                         darkModeStyles.media = media;
1324                 }
1326                 for(elem of document.querySelectorAll("picture.invertible source")) {
1327                         // Update invertible images.
1328                         elem.media = media;
1329                 }
1331                 //      Update state.
1332                 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1333         },
1335         modeSelectorHTML: (inline = false) => {
1336                 let selectorTagName = (inline ? "span" : "div");
1337                 let selectorId = (inline ? `` : ` id="dark-mode-selector"`);
1338                 let selectorClass = (` class="dark-mode-selector mode-selector` + (inline ? ` mode-selector-inline` : ``) + `"`);
1340                 //      Get saved mode setting (or default).
1341                 let currentMode = DarkMode.getSavedMode();
1343                 return `<${selectorTagName}${selectorId}${selectorClass}>`
1344                         + DarkMode.modeOptions.map(modeOption => {
1345                                 let [ name, label, desc ] = modeOption;
1346                                 let selected = (name == currentMode ? " selected" : "");
1347                                 let disabled = (name == currentMode ? " disabled" : "");
1348                                 let active = ((   currentMode == "auto"
1349                                                            && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1350                                                           ? " active"
1351                                                           : "");
1352                                 if (name == currentMode)
1353                                         desc += DarkMode.selectedModeOptionNote;
1354                                 return `<button
1355                                                         type="button"
1356                                                         class="select-mode-${name}${selected}${active}"
1357                                                         ${disabled}
1358                                                         tabindex="-1"
1359                                                         data-name="${name}"
1360                                                         title="${desc}"
1361                                                                 >${label}</button>`;
1362                           }).join("")
1363                         + `</${selectorTagName}>`;
1364         },
1366         injectModeSelector: (replacedElement = null) => {
1367                 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1369                 //      Inject the mode selector widget.
1370                 let modeSelector;
1371                 if (replacedElement) {
1372                         replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1373                         modeSelector = replacedElement.firstElementChild;
1374                         unwrap(replacedElement);
1375                 } else {
1376                         if (GW.isMobile) {
1377                                 if (Appearance.themeSelector == null)
1378                                         return;
1380                                 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", DarkMode.modeSelectorHTML());
1381                         } else {
1382                                 addUIElement(DarkMode.modeSelectorHTML());
1383                         }
1385                         modeSelector = DarkMode.modeSelector = query("#dark-mode-selector");
1386                 }
1388                 //  Add event listeners and update state.
1389                 requestAnimationFrame(() => {
1390                         //      Activate mode selector widget buttons.
1391                         modeSelector.querySelectorAll("button").forEach(button => {
1392                                 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1393                         });
1394                 });
1396                 /*      Add active media query to update mode selector state when system dark
1397                         mode setting changes. (This is relevant only for the ‘auto’ setting.)
1398                  */
1399                 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => { 
1400                         DarkMode.updateModeSelectorState(modeSelector);
1401                 });
1402         },
1404         modeSelectButtonClicked: (event) => {
1405                 GWLog("DarkMode.modeSelectButtonClicked");
1407                 /*      We don’t want clicks to go through if the transition 
1408                         between modes has not completed yet, so we disable the 
1409                         button temporarily while we’re transitioning between 
1410                         modes.
1411                  */
1412                 doIfAllowed(() => {
1413                         // Determine which setting was chosen (ie. which button was clicked).
1414                         let selectedMode = event.target.dataset.name;
1416                         // Save the new setting.
1417                         DarkMode.saveMode(selectedMode);
1419                         // Actually change the mode.
1420                         DarkMode.setMode(selectedMode);
1421                 }, DarkMode, "modeSelectorInteractable");
1423                 event.target.blur();
1424         },
1426         updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1427                 GWLog("DarkMode.updateModeSelectorState");
1429                 /*      If the mode selector has not yet been injected, then do nothing.
1430                  */
1431                 if (modeSelector == null)
1432                         return;
1434                 //      Get saved mode setting (or default).
1435                 let currentMode = DarkMode.getSavedMode();
1437                 //      Clear current buttons state.
1438                 modeSelector.querySelectorAll("button").forEach(button => {
1439                         button.classList.remove("active", "selected");
1440                         button.disabled = false;
1441                         if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1442                                 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1443                 });
1445                 //      Set the correct button to be selected.
1446                 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1447                         button.classList.add("selected");
1448                         button.disabled = true;
1449                         button.title += DarkMode.selectedModeOptionNote;
1450                 });
1452                 /*      Ensure the right button (light or dark) has the “currently active” 
1453                         indicator, if the current mode is ‘auto’.
1454                  */
1455                 if (currentMode == "auto")
1456                         modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1457         }
1461 /****************************/
1462 /* APPEARANCE CUSTOMIZATION */
1463 /****************************/
1465 Appearance = { ...Appearance,
1466         /**************************************************************************/
1467         /* INFRASTRUCTURE
1468          */
1470         noFilters: { },
1472         themeSelector: null,
1473         themeSelectorAuxiliaryControlsContainer: null,
1474         themeSelectorInteractionBlockerOverlay: null,
1475         themeSelectorInteractableTimer: null,
1477         themeTweakerToggle: null,
1479         themeTweakerStyleBlock: null,
1481         themeTweakerUI: null,
1482         themeTweakerUIMainWindow: null,
1483         themeTweakerUIHelpWindow: null,
1484         themeTweakerUISampleTextContainer: null,
1485         themeTweakerUIClippyContainer: null,
1486         themeTweakerUIClippyControl: null,
1488         widthSelector: null,
1490         textSizeAdjustmentWidget: null,
1492         appearanceAdjustUIToggle: null,
1494         /**************************************************************************/
1495         /* FUNCTIONALITY
1496          */
1498         /*      Return a new <link> element linking a style sheet (.css file) for the
1499                 given theme name and color scheme preference (i.e., value for the 
1500                 ‘media’ attribute; may be “light”, “dark”, or “” [empty string]).
1501          */
1502         makeNewStyle: (newThemeName) => {
1503                 let styleSheetNameSuffix = newThemeName == Appearance.defaultTheme
1504                                                                    ? "" 
1505                                                                    : ("-" + newThemeName);
1506                 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1508                 return [["style", "theme"], ["colors", "theme light-mode"], ["inverted", "theme dark-mode", DarkMode.getMediaQuery()]].map(args => {
1509                         let [baseName, className, mediaQuery] = args;
1510                         return newElement("LINK", {
1511                                 "class": className,
1512                                 "rel": "stylesheet",
1513                                 "href": ("/generated-css/" + baseName + styleSheetNameSuffix + currentStyleSheetNameComponents[1]),
1514                                 "media": mediaQuery || null,
1515                                 "blocking": "render"
1516                         });
1517                 });
1518         },
1520         setTheme: (newThemeName, save = true) => {
1521                 GWLog("Appearance.setTheme");
1523                 let oldThemeName = "";
1524                 if (typeof(newThemeName) == "undefined") {
1525                         /*      If no theme name to set is given, that means we’re setting the 
1526                                 theme initially, on page load. The .currentTheme value will have
1527                                 been set by .setup().
1528                          */
1529                         newThemeName = Appearance.currentTheme;
1531                         /*      If the selected (saved) theme is the default theme, then there’s
1532                                 nothing to do.
1533                          */
1534                         if (newThemeName == Appearance.defaultTheme)
1535                                 return;
1536                 } else {
1537                         oldThemeName = Appearance.currentTheme;
1539                         /*      When the unload callback runs, the .currentTheme value is still 
1540                                 that of the old theme.
1541                          */
1542                         let themeUnloadCallback = Appearance.themeUnloadCallbacks[oldThemeName];
1543                         if (themeUnloadCallback != null)
1544                                 themeUnloadCallback(newThemeName);
1546                         /*      The old .currentTheme value is saved in oldThemeName.
1547                          */
1548                         Appearance.currentTheme = newThemeName;
1550                         /*      The ‘save’ parameter might be false if this function is called 
1551                                 from the theme tweaker, in which case we want to switch only 
1552                                 temporarily, and preserve the saved setting until the user 
1553                                 clicks “OK”.
1554                          */
1555                         if (save)
1556                                 Appearance.saveCurrentTheme();
1557                 }
1559                 let newStyles = Appearance.makeNewStyle(newThemeName);
1560                 let loadingStyleCount = newStyles.length;
1562                 let oldStyles = queryAll("head link.theme");
1564                 let onNewStylesLoaded = (event) => {
1565                         loadingStyleCount--;
1566                         if(loadingStyleCount === 0) {
1567                                 for(oldStyle of oldStyles) removeElement(oldStyle);
1568                                 Appearance.postSetThemeHousekeeping(oldThemeName, newThemeName);
1569                         }
1570                 };
1572                 for(newStyle of newStyles) newStyle.addEventListener("load", onNewStylesLoaded);
1574                 if (Appearance.adjustmentTransitions) {
1575                         pageFadeTransition(false);
1576                         setTimeout(() => {
1577                                 document.head.prepend(...newStyles);
1578                         }, 500);
1579                 } else {
1580                         document.head.prepend(...newStyles);
1581                 }
1583                 //      Update UI state of all theme selectors.
1584                 Appearance.updateThemeSelectorsState();
1585         },
1587         postSetThemeHousekeeping: (oldThemeName = "", newThemeName = null) => {
1588                 GWLog("Appearance.postSetThemeHousekeeping");
1590                 if (newThemeName == null)
1591                         newThemeName = Appearance.getSavedTheme();
1593                 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1594                 document.body.addClass("theme-" + newThemeName);
1596                 recomputeUIElementsContainerHeight(true);
1598                 let themeLoadCallback = Appearance.themeLoadCallbacks[newThemeName];
1599                 if (themeLoadCallback != null)
1600                         themeLoadCallback(oldThemeName);
1602                 recomputeUIElementsContainerHeight();
1603                 adjustUIForWindowSize();
1604                 window.addEventListener("resize", GW.windowResized = (event) => {
1605                         GWLog("GW.windowResized");
1606                         adjustUIForWindowSize();
1607                         recomputeUIElementsContainerHeight();
1608                 });
1610                 generateImagesOverlay();
1612                 if (Appearance.adjustmentTransitions)
1613                         pageFadeTransition(true);
1614                 Appearance.updateThemeTweakerSampleText();
1616                 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== "undefined") {
1617                         window.matchMedia("(orientation: portrait)").addListener(generateImagesOverlay);
1618                 }
1619         },
1621         themeLoadCallbacks: {
1622                 brutalist: (fromTheme = "") => {
1623                         GWLog("Appearance.themeLoadCallbacks.brutalist");
1625                         let bottomBarLinks = queryAll("#bottom-bar a");
1626                         if (!GW.isMobile && bottomBarLinks.length == 5) {
1627                                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1628                                 bottomBarLinks.forEach((link, i) => {
1629                                         link.dataset.originalText = link.textContent;
1630                                         link.textContent = newLinkTexts[i];
1631                                 });
1632                         }
1633                 },
1635                 classic: (fromTheme = "") => {
1636                         GWLog("Appearance.themeLoadCallbacks.classic");
1638                         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1639                                 button.innerHTML = "";
1640                         });
1641                 },
1643                 dark: (fromTheme = "") => {
1644                         GWLog("Appearance.themeLoadCallbacks.dark");
1646                         insertHeadHTML(`<style id="dark-theme-adjustments">
1647                                 .markdown-reference-link a { color: #d200cf; filter: invert(100%); }
1648                                 #bottom-bar.decorative::before { filter: invert(100%); }
1649                         </style>`);
1650                         registerInitializer("makeImagesGlow", true, () => query("#images-overlay") != null, () => {
1651                                 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1652                                         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)";
1653                                         image.style.width = parseInt(image.style.width) + 12 + "px";
1654                                         image.style.height = parseInt(image.style.height) + 12 + "px";
1655                                         image.style.top = parseInt(image.style.top) - 6 + "px";
1656                                         image.style.left = parseInt(image.style.left) - 6 + "px";
1657                                 });
1658                         });
1659                 },
1661                 less: (fromTheme = "") => {
1662                         GWLog("Appearance.themeLoadCallbacks.less");
1664                         injectSiteNavUIToggle();
1665                         if (!GW.isMobile) {
1666                                 injectPostNavUIToggle();
1667                                 Appearance.injectAppearanceAdjustUIToggle();
1668                         }
1670                         registerInitializer("shortenDate", true, () => query(".top-post-meta") != null, function () {
1671                                 let dtf = new Intl.DateTimeFormat([], 
1672                                         (window.innerWidth < 1100) ? 
1673                                                 { month: "short", day: "numeric", year: "numeric" } : 
1674                                                         { month: "long", day: "numeric", year: "numeric" });
1675                                 let postDate = query(".top-post-meta .date");
1676                                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1677                         });
1679                         if (GW.isMobile) {
1680                                 query("#content").insertAdjacentHTML("beforeend", `<div id="theme-less-mobile-first-row-placeholder"></div>`);
1681                         }
1683                         if (!GW.isMobile) {
1684                                 registerInitializer("addSpans", true, () => query(".top-post-meta") != null, function () {
1685                                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1686                                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1687                                         });
1688                                 });
1690                                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1691                                         // If state is not set (user has never clicked on the Less theme’s appearance
1692                                         // adjustment UI toggle) then show it, but then hide it after a short time.
1693                                         registerInitializer("engageAppearanceAdjustUI", true, () => query("#ui-elements-container") != null, function () {
1694                                                 Appearance.toggleAppearanceAdjustUI();
1695                                                 setTimeout(Appearance.toggleAppearanceAdjustUI, 3000);
1696                                         });
1697                                 }
1699                                 if (fromTheme != "") {
1700                                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1701                                         setTimeout(function () {
1702                                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1703                                         }, 300);
1704                                         setTimeout(function () {
1705                                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1706                                         }, 1800);
1707                                 }
1709                                 // Unset the height of the #ui-elements-container.
1710                                 query("#ui-elements-container").style.height = "";
1712                                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1713                                 Appearance.filtersExclusionPaths.themeLess = [
1714                                         "#content #secondary-bar",
1715                                         "#content .post .top-post-meta .date",
1716                                         "#content .post .top-post-meta .comment-count",
1717                                 ];
1718                                 Appearance.applyFilters();
1719                         }
1721                         // We pre-query the relevant elements, so we don’t have to run querySelectorAll
1722                         // on every firing of the scroll listener.
1723                         GW.scrollState = {
1724                                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1725                                 "unbrokenDownScrollDistance":           0,
1726                                 "unbrokenUpScrollDistance":                     0,
1727                                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1728                                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1729                                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1730                         };
1731                         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1732                 }
1733         },
1735         themeUnloadCallbacks: {
1736                 brutalist: (toTheme = "") => {
1737                         GWLog("Appearance.themeUnloadCallbacks.brutalist");
1739                         let bottomBarLinks = queryAll("#bottom-bar a");
1740                         if (!GW.isMobile && bottomBarLinks.length == 5) {
1741                                 bottomBarLinks.forEach(link => {
1742                                         link.textContent = link.dataset.originalText;
1743                                 });
1744                         }
1745                 },
1747                 classic: (toTheme = "") => {
1748                         GWLog("Appearance.themeUnloadCallbacks.classic");
1750                         if (GW.isMobile && window.innerWidth <= 900)
1751                                 return;
1753                         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1754                                 button.innerHTML = button.dataset.label;
1755                         });
1756                 },
1758                 dark: (toTheme = "") => {
1759                         GWLog("Appearance.themeUnloadCallbacks.dark");
1761                         removeElement("#dark-theme-adjustments");
1762                 },
1764                 less: (toTheme = "") => {
1765                         GWLog("Appearance.themeUnloadCallbacks.less");
1767                         removeSiteNavUIToggle();
1768                         if (!GW.isMobile) {
1769                                 removePostNavUIToggle();
1770                                 Appearance.removeAppearanceAdjustUIToggle();
1771                         }
1773                         window.removeEventListener("resize", updatePostNavUIVisibility);
1775                         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1777                         removeElement("#theme-less-mobile-first-row-placeholder");
1779                         if (!GW.isMobile) {
1780                                 // Remove spans
1781                                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1782                                         element.innerHTML = element.firstChild.innerHTML;
1783                                 });
1784                         }
1786                         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1788                         //      Reset filtered elements selector to default.
1789                         delete Appearance.filtersExclusionPaths.themeLess;
1790                         Appearance.applyFilters();
1791                 }
1792         },
1794         pageFadeTransition: (fadeIn) => {
1795                 if (fadeIn) {
1796                         document.body.removeClass("transparent");
1797                 } else {
1798                         document.body.addClass("transparent");
1799                 }
1800         },
1802         /*      Set the saved theme setting to the currently active theme.
1803          */
1804         saveCurrentTheme: () => {
1805                 GWLog("Appearance.saveCurrentTheme");
1807                 if (Appearance.currentTheme == Appearance.defaultTheme)
1808                         setCookie("theme", "");
1809                 else
1810                         setCookie("theme", Appearance.currentTheme);
1811         },
1813         /*      Reset theme, theme tweak filters, and text zoom to their saved settings.
1814          */
1815         themeTweakReset: () => {
1816                 GWLog("Appearance.themeTweakReset");
1818                 Appearance.setTheme(Appearance.getSavedTheme());
1819                 Appearance.applyFilters(Appearance.getSavedFilters());
1820                 Appearance.setTextZoom(Appearance.getSavedTextZoom());
1821         },
1823         /*      Set the saved theme, theme tweak filter, and text zoom settings to their
1824                 currently active values.
1825          */
1826         themeTweakSave: () => {
1827                 GWLog("Appearance.themeTweakSave");
1829                 Appearance.saveCurrentTheme();
1830                 Appearance.saveCurrentFilters();
1831                 Appearance.saveCurrentTextZoom();
1832         },
1834         /*      Reset theme, theme tweak filters, and text zoom to their default levels.
1835                 (Do not save the new settings, however.)
1836          */
1837         themeTweakResetDefaults: () => {
1838                 GWLog("Appearance.themeTweakResetDefaults");
1840                 Appearance.setTheme(Appearance.defaultTheme, false);
1841                 Appearance.applyFilters(Appearance.defaultFilters);
1842                 Appearance.setTextZoom(Appearance.defaultTextZoom, false);
1843         },
1845         themeTweakerResetSettings: () => {
1846                 GWLog("Appearance.themeTweakerResetSettings");
1848                 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
1849                 Appearance.themeTweakerUIClippyContainer.style.display = Appearance.themeTweakerUIClippyControl.checked 
1850                                                                                                                                  ? "block" 
1851                                                                                                                                  : "none";
1852         },
1854         themeTweakerSaveSettings: () => {
1855                 GWLog("Appearance.themeTweakerSaveSettings");
1857                 Appearance.saveThemeTweakerClippyState();
1858         },
1860         getSavedThemeTweakerClippyState: () => {
1861                 return (JSON.parse(localStorage.getItem("theme-tweaker-settings") || `{ "showClippy": ${Appearance.defaultThemeTweakerClippyState} }` )["showClippy"]);
1862         },
1864         saveThemeTweakerClippyState: () => {
1865                 GWLog("Appearance.saveThemeTweakerClippyState");
1867                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ "showClippy": Appearance.themeTweakerUIClippyControl.checked }));
1868         },
1870         getSavedAppearanceAdjustUIToggleState: () => {
1871                 return ((localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") || Appearance.defaultAppearanceAdjustUIToggleState);
1872         },
1874         saveAppearanceAdjustUIToggleState: () => {
1875                 GWLog("Appearance.saveAppearanceAdjustUIToggleState");
1877                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", Appearance.appearanceAdjustUIToggle.query("button").hasClass("engaged"));
1878         },
1880         /**************************************************************************/
1881         /* UI CONSTRUCTION & MANIPULATION
1882          */
1884         contentWidthSelectorHTML: () => {
1885                 return ("<div id='width-selector'>"
1886                         + String.prototype.concat.apply("", Appearance.widthOptions.map(widthOption => {
1887                                 let [name, desc, abbr] = widthOption;
1888                                 let selected = (name == Appearance.currentWidth ? " selected" : "");
1889                                 let disabled = (name == Appearance.currentWidth ? " disabled" : "");
1890                                 return `<button type="button" class="select-width-${name}${selected}"${disabled} title="${desc}" tabindex="-1" data-name="${name}">${abbr}</button>`
1891                         }))
1892                 + "</div>");
1893         },
1895         injectContentWidthSelector: () => {
1896                 GWLog("Appearance.injectContentWidthSelector");
1898                 //      Inject the content width selector widget and activate buttons.
1899                 Appearance.widthSelector = addUIElement(Appearance.contentWidthSelectorHTML());
1900                 Appearance.widthSelector.queryAll("button").forEach(button => {
1901                         button.addActivateEvent(Appearance.widthAdjustButtonClicked);
1902                 });
1904                 //      Make sure the accesskey (to cycle to the next width) is on the right button.
1905                 Appearance.setWidthAdjustButtonsAccesskey();
1907                 //      Inject transitions CSS, if animating changes is enabled.
1908                 if (Appearance.adjustmentTransitions) {
1909                         insertHeadHTML(
1910                                 `<style id="width-transition">
1911                                         #content,
1912                                         #ui-elements-container,
1913                                         #images-overlay {
1914                                                 transition:
1915                                                         max-width 0.3s ease;
1916                                         }
1917                                 </style>`);
1918                 }
1919         },
1921         setWidthAdjustButtonsAccesskey: () => {
1922                 GWLog("Appearance.setWidthAdjustButtonsAccesskey");
1924                 Appearance.widthSelector.queryAll("button").forEach(button => {
1925                         button.removeAttribute("accesskey");
1926                         button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1927                 });
1928                 let selectedButton = Appearance.widthSelector.query("button.selected");
1929                 let nextButtonInCycle = selectedButton == selectedButton.parentElement.lastChild
1930                                                                                                   ? selectedButton.parentElement.firstChild 
1931                                                                                                   : selectedButton.nextSibling;
1932                 nextButtonInCycle.accessKey = "'";
1933                 nextButtonInCycle.title += ` [\']`;
1934         },
1936         injectTextSizeAdjustmentUI: () => {
1937                 GWLog("Appearance.injectTextSizeAdjustmentUI");
1939                 if (Appearance.textSizeAdjustmentWidget != null)
1940                         return;
1942                 let inject = () => {
1943                         GWLog("Appearance.injectTextSizeAdjustmentUI [INJECTING]");
1945                         Appearance.textSizeAdjustmentWidget = addUIElement("<div id='text-size-adjustment-ui'>"
1946                                 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
1947                                 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1948                                 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
1949                         + "</div>");
1951                         Appearance.textSizeAdjustmentWidget.queryAll("button").forEach(button => {
1952                                 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1953                         });
1954                 };
1956                 if (query("#content.post-page") != null) {
1957                         inject();
1958                 } else {
1959                         document.addEventListener("DOMContentLoaded", () => {
1960                                 if (!(   query(".post-body") == null 
1961                                           && query(".comment-body") == null))
1962                                         inject();
1963                         }, { once: true });
1964                 }
1965         },
1967         themeSelectorHTML: () => {
1968                 return ("<div id='theme-selector' class='theme-selector'>"
1969                         + String.prototype.concat.apply("", Appearance.themeOptions.map(themeOption => {
1970                                 let [name, desc, letter] = themeOption;
1971                                 let selected = (name == Appearance.currentTheme ? ' selected' : '');
1972                                 let disabled = (name == Appearance.currentTheme ? ' disabled' : '');
1973                                 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1974                                 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>`;
1975                         }))
1976                 + "</div>");
1977         },
1979         injectThemeSelector: () => {
1980                 GWLog("Appearance.injectThemeSelector");
1982                 Appearance.themeSelector = addUIElement(Appearance.themeSelectorHTML());
1983                 Appearance.themeSelector.queryAll("button").forEach(button => {
1984                         button.addActivateEvent(Appearance.themeSelectButtonClicked);
1985                 });
1987                 if (GW.isMobile) {
1988                         //      Add close button.
1989                         let themeSelectorCloseButton = newElement("BUTTON", { "class": "theme-selector-close-button" }, { "innerHTML": "&#xf057;" });
1990                         themeSelectorCloseButton.addActivateEvent(Appearance.themeSelectorCloseButtonClicked);
1991                         Appearance.themeSelector.appendChild(themeSelectorCloseButton);
1993                         //      Inject auxiliary controls container.
1994                         Appearance.themeSelectorAuxiliaryControlsContainer = newElement("DIV", { "class": "auxiliary-controls-container" });
1995                         Appearance.themeSelector.appendChild(Appearance.themeSelectorAuxiliaryControlsContainer);
1997                         //      Inject mobile versions of various UI elements.
1998                         Appearance.injectThemeTweakerToggle();
1999                         injectAntiKibitzerToggle();
2000                         DarkMode.injectModeSelector();
2002                         //      Inject interaction blocker overlay.
2003                         Appearance.themeSelectorInteractionBlockerOverlay = Appearance.themeSelector.appendChild(newElement("DIV", { "class": "interaction-blocker-overlay" }));
2004                         Appearance.themeSelectorInteractionBlockerOverlay.addActivateEvent(event => { event.stopPropagation(); });
2005                 }
2007                 //      Inject transitions CSS, if animating changes is enabled.
2008                 if (Appearance.adjustmentTransitions) {
2009                         insertHeadHTML(`<style id="theme-fade-transition">
2010                                 body {
2011                                         transition:
2012                                                 opacity 0.5s ease-out,
2013                                                 background-color 0.3s ease-out;
2014                                 }
2015                                 body.transparent {
2016                                         background-color: #777;
2017                                         opacity: 0.0;
2018                                         transition:
2019                                                 opacity 0.5s ease-in,
2020                                                 background-color 0.3s ease-in;
2021                                 }
2022                         </style>`);
2023                 }
2024         },
2026         updateThemeSelectorsState: () => {
2027                 GWLog("Appearance.updateThemeSelectorsState");
2029                 queryAll(".theme-selector button.select-theme").forEach(button => {
2030                         button.removeClass("selected");
2031                         button.disabled = false;
2032                 });
2033                 queryAll(".theme-selector button.select-theme-" + Appearance.currentTheme).forEach(button => {
2034                         button.addClass("selected");
2035                         button.disabled = true;
2036                 });
2038                 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.currentTheme;
2039         },
2041         setThemeSelectorInteractable: (interactable) => {
2042                 GWLog("Appearance.setThemeSelectorInteractable");
2044                 Appearance.themeSelectorInteractionBlockerOverlay.classList.toggle("enabled", (interactable == false));
2045         },
2047         themeTweakerUIHTML: () => {
2048                 return (`<div id="theme-tweaker-ui" style="display: none;">\n` 
2049                         + `<div class="theme-tweaker-window main-window">
2050                                 <div class="theme-tweaker-window-title-bar">
2051                                         <div class="theme-tweaker-window-title">
2052                                                 <h1>Customize appearance</h1>
2053                                         </div>
2054                                         <div class="theme-tweaker-window-title-bar-buttons-container">
2055                                                 <button type="button" class="help-button" tabindex="-1"></button>
2056                                                 <button type="button" class="minimize-button minimize" tabindex="-1"></button>
2057                                                 <button type="button" class="close-button" tabindex="-1"></button>
2058                                         </div>
2059                                 </div>
2060                                 <div class="theme-tweaker-window-content-view">
2061                                         <div class="theme-select">
2062                                                 <p class="current-theme">Current theme:
2063                                                         <span>${Appearance.getSavedTheme()}</span>
2064                                                 </p>
2065                                                 <div class="theme-selector"></div>
2066                                         </div>
2067                                         <div class="controls-container">
2068                                                 <div id="theme-tweak-section-sample-text" class="section" data-label="Sample text">
2069                                                         <div class="sample-text-container"><span class="sample-text">
2070                                                                 <p>Less Wrong (text)</p>
2071                                                                 <p><a href="#">Less Wrong (link)</a></p>
2072                                                         </span></div>
2073                                                 </div>
2074                                                 <div id="theme-tweak-section-text-size-adjust" class="section" data-label="Text size">
2075                                                         <button type="button" class="text-size-adjust-button decrease" title="Decrease text size"></button>
2076                                                         <button type="button" class="text-size-adjust-button default" title="Reset to default text size"></button>
2077                                                         <button type="button" class="text-size-adjust-button increase" title="Increase text size"></button>
2078                                                 </div>
2079                                                 <div id="theme-tweak-section-invert" class="section" data-label="Invert (photo-negative)">
2080                                                         <input type="checkbox" id="theme-tweak-control-invert"></input>
2081                                                         <label for="theme-tweak-control-invert">Invert colors</label>
2082                                                 </div>
2083                                                 <div id="theme-tweak-section-saturate" class="section" data-label="Saturation">
2084                                                         <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2085                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
2086                                                         <div class="notch theme-tweak-slider-notch-saturate" title="Reset saturation to default value (100%)"></div>
2087                                                 </div>
2088                                                 <div id="theme-tweak-section-brightness" class="section" data-label="Brightness">
2089                                                         <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2090                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
2091                                                         <div class="notch theme-tweak-slider-notch-brightness" title="Reset brightness to default value (100%)"></div>
2092                                                 </div>
2093                                                 <div id="theme-tweak-section-contrast" class="section" data-label="Contrast">
2094                                                         <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2095                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
2096                                                         <div class="notch theme-tweak-slider-notch-contrast" title="Reset contrast to default value (100%)"></div>
2097                                                 </div>
2098                                                 <div id="theme-tweak-section-hue-rotate" class="section" data-label="Hue rotation">
2099                                                         <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
2100                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
2101                                                         <div class="notch theme-tweak-slider-notch-hue-rotate" title="Reset hue to default (0° away from standard colors for theme)"></div>
2102                                                 </div>
2103                                         </div>
2104                                         <div class="buttons-container">
2105                                                 <button type="button" class="reset-defaults-button">Reset to defaults</button>
2106                                                 <button type="button" class="ok-button default-button">OK</button>
2107                                                 <button type="button" class="cancel-button">Cancel</button>
2108                                         </div>
2109                                 </div>
2110                         </div>
2111                         <div class="clippy-container">
2112                                 <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>)
2113                                 <div class="clippy"></div>
2114                                 <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>
2115                         </div>
2116                         <div class="theme-tweaker-window help-window" style="display: none;">
2117                                 <div class="theme-tweaker-window-title-bar">
2118                                         <div class="theme-tweaker-window-title">
2119                                                 <h1>Theme tweaker help</h1>
2120                                         </div>
2121                                 </div>
2122                                 <div class="theme-tweaker-window-content-view">
2123                                         <div id="theme-tweak-section-clippy" class="section" data-label="Theme Tweaker Assistant">
2124                                                 <input type="checkbox" id="theme-tweak-control-clippy" checked="checked"></input>
2125                                                 <label for="theme-tweak-control-clippy">Show Bobby the Basilisk</label>
2126                                         </div>
2127                                         <div class="buttons-container">
2128                                                 <button type="button" class="ok-button default-button">OK</button>
2129                                                 <button type="button" class="cancel-button">Cancel</button>
2130                                         </div>
2131                                 </div>
2132                         </div>
2133                 ` + `\n</div>`);
2134         },
2136         injectThemeTweaker: () => {
2137                 GWLog("Appearance.injectThemeTweaker");
2139                 Appearance.themeTweakerUI = addUIElement(Appearance.themeTweakerUIHTML());
2140                 Appearance.themeTweakerUIMainWindow = Appearance.themeTweakerUI.firstElementChild;
2141                 Appearance.themeTweakerUIHelpWindow = Appearance.themeTweakerUI.query(".help-window");
2142                 Appearance.themeTweakerUISampleTextContainer = Appearance.themeTweakerUI.query("#theme-tweak-section-sample-text .sample-text-container");
2143                 Appearance.themeTweakerUIClippyContainer = Appearance.themeTweakerUI.query(".clippy-container");
2144                 Appearance.themeTweakerUIClippyControl = Appearance.themeTweakerUI.query("#theme-tweak-control-clippy");
2146                 //      Clicking the background overlay closes the theme tweaker.
2147                 Appearance.themeTweakerUI.addActivateEvent(Appearance.themeTweakerUIOverlayClicked, true);
2149                 //      Intercept clicks, so they don’t “fall through” the background overlay.
2150                 Array.from(Appearance.themeTweakerUI.children).forEach(themeTweakerUIWindow => {
2151                         themeTweakerUIWindow.addActivateEvent((event) => {
2152                                 event.stopPropagation();
2153                         }, true);
2154                 });
2156                 Appearance.themeTweakerUI.queryAll("input").forEach(field => {
2157                         /*      All input types in the theme tweaker receive a ‘change’ event 
2158                                 when their value is changed. (Range inputs, in particular, 
2159                                 receive this event when the user lets go of the handle.) This 
2160                                 means we should update the filters for the entire page, to match 
2161                                 the new setting.
2162                          */
2163                         field.addEventListener("change", Appearance.themeTweakerUIFieldValueChanged);
2165                         /*      Range inputs receive an ‘input’ event while being scrubbed, 
2166                                 updating “live” as the handle is moved. We don’t want to change 
2167                                 the filters for the actual page while this is happening, but we 
2168                                 do want to change the filters for the *sample text*, so the user
2169                                 can see what effects his changes are having, live, without 
2170                                 having to let go of the handle.
2171                          */
2172                         if (field.type == "range")
2173                                 field.addEventListener("input", Appearance.themeTweakerUIFieldInputReceived);
2174                 });
2176                 Appearance.themeTweakerUI.query(".help-button").addActivateEvent(Appearance.themeTweakerUIHelpButtonClicked);
2177                 Appearance.themeTweakerUI.query(".minimize-button").addActivateEvent(Appearance.themeTweakerUIMinimizeButtonClicked);
2178                 Appearance.themeTweakerUI.query(".close-button").addActivateEvent(Appearance.themeTweakerUICloseButtonClicked);
2179                 Appearance.themeTweakerUI.query(".reset-defaults-button").addActivateEvent(Appearance.themeTweakerUIResetDefaultsButtonClicked);
2180                 Appearance.themeTweakerUI.query(".main-window .cancel-button").addActivateEvent(Appearance.themeTweakerUICancelButtonClicked);
2181                 Appearance.themeTweakerUI.query(".main-window .ok-button").addActivateEvent(Appearance.themeTweakerUIOKButtonClicked);
2182                 Appearance.themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowCancelButtonClicked);
2183                 Appearance.themeTweakerUI.query(".help-window .ok-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowOKButtonClicked);
2185                 Appearance.themeTweakerUI.queryAll(".notch").forEach(notch => {
2186                         notch.addActivateEvent(Appearance.themeTweakerUISliderNotchClicked);
2187                 });
2189                 Appearance.themeTweakerUI.query(".clippy-close-button").addActivateEvent(Appearance.themeTweakerUIClippyCloseButtonClicked);
2191                 insertHeadHTML(`<style id="theme-tweaker-style"></style>`);
2192                 Appearance.themeTweakerStyleBlock = document.head.query("#theme-tweaker-style");
2194                 Appearance.themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
2195                 Appearance.themeTweakerUI.queryAll(".theme-selector > *:not(.select-theme)").forEach(element => {
2196                         element.remove();
2197                 });
2198                 Appearance.themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
2199                         button.addActivateEvent(Appearance.themeSelectButtonClicked);
2200                 });
2202                 Appearance.themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
2203                         button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
2204                 });
2206                 if (GW.isMobile == false)
2207                         Appearance.injectThemeTweakerToggle();
2208         },
2210         themeTweakerToggleHTML: () => {
2211                 return (`<div id="theme-tweaker-toggle">`
2212                                         + `<button 
2213                                                         type="button" 
2214                                                         tabindex="-1" 
2215                                                         title="Customize appearance [;]" 
2216                                                         accesskey=";"
2217                                                                 >&#xf1de;</button>`
2218                                 + `</div>`);
2219         },
2221         injectThemeTweakerToggle: () => {
2222                 GWLog("Appearance.injectThemeTweakerToggle");
2224                 if (GW.isMobile) {
2225                         if (Appearance.themeSelector == null)
2226                                 return;
2228                         Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", Appearance.themeTweakerToggleHTML());
2229                         Appearance.themeTweakerToggle = Appearance.themeSelector.query("#theme-tweaker-toggle");
2230                 } else {
2231                         Appearance.themeTweakerToggle = addUIElement(Appearance.themeTweakerToggleHTML());      
2232                 }
2234                 Appearance.themeTweakerToggle.query("button").addActivateEvent(Appearance.themeTweakerToggleClicked);
2235         },
2237         showThemeTweakerUI: () => {
2238                 GWLog("Appearance.showThemeTweakerUI");
2240                 if (query("link[href^='/css/theme_tweaker.css']") == null) {
2241                         //      Theme tweaker CSS needs to be loaded.
2243                         let themeTweakerStyleSheet = newElement("LINK", {
2244                                 "rel": "stylesheet",
2245                                 "href": "/css/theme_tweaker.css"
2246                         });
2248                         themeTweakerStyleSheet.addEventListener("load", (event) => {
2249                                 requestAnimationFrame(() => {
2250                                         themeTweakerStyleSheet.disabled = false;
2251                                 });
2252                                 Appearance.showThemeTweakerUI();
2253                         }, { once: true });
2255                         document.head.appendChild(themeTweakerStyleSheet);
2257                         return;
2258                 }
2260                 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.getSavedTheme();
2262                 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = (Appearance.currentFilters["invert"] == "100%");
2263                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2264                         let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2265                         slider.value = /^[0-9]+/.exec(Appearance.currentFilters[sliderName]) || slider.dataset["defaultValue"];
2266                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2267                 });
2269                 Appearance.toggleThemeTweakerUI();
2270         },
2272         toggleThemeTweakerUI: () => {
2273                 GWLog("Appearance.toggleThemeTweakerUI");
2275                 let show = (Appearance.themeTweakerUI.style.display == "none");
2277                 Appearance.themeTweakerUI.style.display = show ? "block" : "none";
2278                 Appearance.setThemeTweakerWindowMinimized(false);
2279                 Appearance.themeTweakerStyleBlock.innerHTML = show ? `#content, #ui-elements-container > div:not(#theme-tweaker-ui) { pointer-events: none; user-select: none; }` : "";
2281                 if (show) {
2282                         // Disable button.
2283                         Appearance.themeTweakerToggle.query("button").disabled = true;
2284                         // Focus invert checkbox.
2285                         Appearance.themeTweakerUI.query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
2286                         // Show sample text in appropriate font.
2287                         Appearance.updateThemeTweakerSampleText();
2288                         // Disable tab-selection of the search box.
2289                         setSearchBoxTabSelectable(false);
2290                         // Disable scrolling of the page.
2291                         togglePageScrolling(false);
2292                 } else {
2293                         // Re-enable button.
2294                         Appearance.themeTweakerToggle.query("button").disabled = false;
2295                         // Re-enable tab-selection of the search box.
2296                         setSearchBoxTabSelectable(true);
2297                         // Re-enable scrolling of the page.
2298                         togglePageScrolling(true);
2299                 }
2301                 // Set theme tweaker assistant visibility.
2302                 Appearance.themeTweakerUIClippyContainer.style.display = (Appearance.getSavedThemeTweakerClippyState() == true) ? "block" : "none";
2303         },
2305         setThemeTweakerWindowMinimized: (minimize) => {
2306                 GWLog("Appearance.setThemeTweakerWindowMinimized");
2308                 Appearance.themeTweakerUIMainWindow.query(".minimize-button").swapClasses([ "minimize", "maximize" ], (minimize ? 1 : 0));
2309                 Appearance.themeTweakerUIMainWindow.classList.toggle("minimized", minimize);
2310                 Appearance.themeTweakerUI.classList.toggle("main-window-minimized", minimize);
2311         },
2313         toggleThemeTweakerHelpWindow: () => {
2314                 GWLog("Appearance.toggleThemeTweakerHelpWindow");
2316                 Appearance.themeTweakerUIHelpWindow.style.display = Appearance.themeTweakerUIHelpWindow.style.display == "none" 
2317                                                                                                                 ? "block" 
2318                                                                                                                 : "none";
2319                 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2320                         // Focus theme tweaker assistant checkbox.
2321                         Appearance.themeTweakerUI.query("#theme-tweak-control-clippy").focus();
2322                         // Disable interaction on main theme tweaker window.
2323                         Appearance.themeTweakerUI.style.pointerEvents = "none";
2324                         Appearance.themeTweakerUIMainWindow.style.pointerEvents = "none";
2325                 } else {
2326                         // Re-enable interaction on main theme tweaker window.
2327                         Appearance.themeTweakerUI.style.pointerEvents = "auto";
2328                         Appearance.themeTweakerUIMainWindow.style.pointerEvents = "auto";
2329                 }
2330         },
2332         resetThemeTweakerUIDefaultState: () => {
2333                 GWLog("Appearance.resetThemeTweakerUIDefaultState");
2335                 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
2337                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2338                         let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2339                         slider.value = slider.dataset["defaultValue"];
2340                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2341                 });
2342         },
2344         updateThemeTweakerSampleText: () => {
2345                 GWLog("Appearance.updateThemeTweakerSampleText");
2347                 let sampleText = Appearance.themeTweakerUISampleTextContainer.query("#theme-tweak-section-sample-text .sample-text");
2349                 // This causes the sample text to take on the properties of the body text of a post.
2350                 sampleText.removeClass("body-text");
2351                 let bodyTextElement = query(".post-body") || query(".comment-body");
2352                 sampleText.addClass("body-text");
2353                 sampleText.style.color = bodyTextElement ? 
2354                         getComputedStyle(bodyTextElement).color : 
2355                         getComputedStyle(query("#content")).color;
2357                 // Here we find out what is the actual background color that will be visible behind
2358                 // the body text of posts, and set the sample text’s background to that.
2359                 let findStyleBackground = (selector) => {
2360                         return "#fff"; // FIXME
2361                         let x;
2362                         Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2363                                 if (rule.selectorText == selector)
2364                                         x = rule;
2365                         });
2366                         return x.style.backgroundColor;
2367                 };
2369                 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2370         },
2372         injectAppearanceAdjustUIToggle: () => {
2373                 GWLog("Appearance.injectAppearanceAdjustUIToggle");
2375                 Appearance.appearanceAdjustUIToggle = addUIElement(`<div id="appearance-adjust-ui-toggle"><button type="button" tabindex="-1">&#xf013;</button></div>`);
2376                 Appearance.appearanceAdjustUIToggle.query("button").addActivateEvent(Appearance.appearanceAdjustUIToggleButtonClicked);
2378                 if (  !GW.isMobile 
2379                         && Appearance.getSavedAppearanceAdjustUIToggleState() == true) {
2380                         Appearance.toggleAppearanceAdjustUI();
2381                 }
2382         },
2384         removeAppearanceAdjustUIToggle: () => {
2385                 GWLog("Appearance.removeAppearanceAdjustUIToggle");
2387                 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2388                         element.removeClass("engaged");
2389                 });
2390                 removeElement("#appearance-adjust-ui-toggle");
2391         },
2393         toggleAppearanceAdjustUI: () => {
2394                 GWLog("Appearance.toggleAppearanceAdjustUI");
2396                 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2397                         element.toggleClass("engaged");
2398                 });
2400                 if (GW.isMobile) {
2401                         clearTimeout(Appearance.themeSelectorInteractableTimer);
2402                         Appearance.setThemeSelectorInteractable(false);
2403                         Appearance.themeSelectorInteractableTimer = setTimeout(() => {
2404                                 Appearance.setThemeSelectorInteractable(true);
2405                         }, 200);
2406                 }
2407         },
2409         /**************************************************************************/
2410         /* EVENTS
2411          */
2413         /*      Theme selector close button (on mobile version of theme selector).
2414          */
2415         themeSelectorCloseButtonClicked: (event) => {
2416                 GWLog("Appearance.themeSelectorCloseButtonClicked");
2418                 Appearance.toggleAppearanceAdjustUI();
2419                 Appearance.saveAppearanceAdjustUIToggleState();
2420         },
2422         /*      “Cog” button (to toggle the appearance adjust UI widgets in “less” 
2423                 theme, or theme selector UI on mobile).
2424          */
2425         appearanceAdjustUIToggleButtonClicked: (event) => {
2426                 GWLog("Appearance.appearanceAdjustUIToggleButtonClicked");
2428                 Appearance.toggleAppearanceAdjustUI();
2429                 Appearance.saveAppearanceAdjustUIToggleState();
2430         },
2432         /*      Width adjust buttons (“normal”, “wide”, “fluid”).
2433          */
2434         widthAdjustButtonClicked: (event) => {
2435                 GWLog("Appearance.widthAdjustButtonClicked");
2437                 // Determine which setting was chosen (i.e., which button was clicked).
2438                 let selectedWidth = event.target.dataset.name;
2440                 //      Switch width.
2441                 Appearance.currentWidth = selectedWidth;
2443                 // Save the new setting.
2444                 Appearance.saveCurrentWidth();
2446                 // Save current visible comment
2447                 let visibleComment = getCurrentVisibleComment();
2449                 // Actually change the content width.
2450                 Appearance.setContentWidth(selectedWidth);
2451                 event.target.parentElement.childNodes.forEach(button => {
2452                         button.removeClass("selected");
2453                         button.disabled = false;
2454                 });
2455                 event.target.addClass("selected");
2456                 event.target.disabled = true;
2458                 // Make sure the accesskey (to cycle to the next width) is on the right button.
2459                 Appearance.setWidthAdjustButtonsAccesskey();
2461                 // Regenerate images overlay.
2462                 generateImagesOverlay();
2464                 if (visibleComment)
2465                         visibleComment.scrollIntoView();
2466         },
2468         /*      Theme selector buttons (“A” through “I”).
2469          */
2470         themeSelectButtonClicked: (event) => {
2471                 GWLog("Appearance.themeSelectButtonClicked");
2473                 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
2474                 let save = (Appearance.themeTweakerUI.contains(event.target) == false);
2475                 Appearance.setTheme(themeName, save);
2476                 if (GW.isMobile)
2477                         Appearance.toggleAppearanceAdjustUI();
2478         },
2480         /*      The text size adjust (“-”, “A”, “+”) buttons.
2481          */
2482         textSizeAdjustButtonClicked: (event) => {
2483                 GWLog("Appearance.textSizeAdjustButtonClicked");
2485                 var zoomFactor = Appearance.currentTextZoom;
2486                 if (event.target.hasClass("decrease")) {
2487                         zoomFactor -= 0.05;
2488                 } else if (event.target.hasClass("increase")) {
2489                         zoomFactor += 0.05;
2490                 } else {
2491                         zoomFactor = Appearance.defaultTextZoom;
2492                 }
2494                 let save = (   Appearance.textSizeAdjustmentWidget != null 
2495                                         && Appearance.textSizeAdjustmentWidget.contains(event.target));
2496                 Appearance.setTextZoom(zoomFactor, save);
2497         },
2499         /*      Theme tweaker toggle button.
2500          */
2501         themeTweakerToggleClicked: (event) => {
2502                 GWLog("Appearance.themeTweakerToggleClicked");
2504                 Appearance.showThemeTweakerUI();
2505         },
2507         /***************************/
2508         /*      Theme tweaker UI events.
2509          */
2511         /*      Key pressed while theme tweaker is open.
2512          */
2513         themeTweakerUIKeyPressed: (event) => {
2514                 GWLog("Appearance.themeTweakerUIKeyPressed");
2516                 if (event.key == "Escape") {
2517                         if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2518                                 Appearance.toggleThemeTweakerHelpWindow();
2519                                 Appearance.themeTweakerResetSettings();
2520                         } else if (Appearance.themeTweakerUI.style.display != "none") {
2521                                 Appearance.toggleThemeTweakerUI();
2522                                 Appearance.themeTweakReset();
2523                         }
2524                 } else if (event.key == "Enter") {
2525                         if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2526                                 Appearance.toggleThemeTweakerHelpWindow();
2527                                 Appearance.themeTweakerSaveSettings();
2528                         } else if (Appearance.themeTweakerUI.style.display != "none") {
2529                                 Appearance.toggleThemeTweakerUI();
2530                                 Appearance.themeTweakSave();
2531                         }
2532                 }
2533         },
2535         /*      Theme tweaker overlay clicked.
2536          */
2537         themeTweakerUIOverlayClicked: (event) => {
2538                 GWLog("Appearance.themeTweakerUIOverlayClicked");
2540                 if (event.type == "mousedown") {
2541                         Appearance.themeTweakerUI.style.opacity = "0.01";
2542                 } else {
2543                         Appearance.toggleThemeTweakerUI();
2544                         Appearance.themeTweakerUI.style.opacity = "1.0";
2545                         Appearance.themeTweakReset();
2546                 }
2547         },
2549         /*      In the theme tweaker, a slider clicked, or released after drag; or a
2550                 checkbox clicked (either in the main theme tweaker UI, or in the help
2551                 window).
2552          */
2553         themeTweakerUIFieldValueChanged: (event) => {
2554                 GWLog("Appearance.themeTweakerUIFieldValueChanged");
2556                 if (event.target.id == "theme-tweak-control-invert") {
2557                         Appearance.currentFilters["invert"] = event.target.checked ? "100%" : "0%";
2558                 } else if (event.target.type == "range") {
2559                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2560                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2561                         Appearance.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2562                 } else if (event.target.id == "theme-tweak-control-clippy") {
2563                         Appearance.themeTweakerUIClippyContainer.style.display = event.target.checked ? "block" : "none";
2564                 }
2566                 // Clear the sample text filters.
2567                 Appearance.themeTweakerUISampleTextContainer.style.filter = "";
2569                 // Apply the new filters globally.
2570                 Appearance.applyFilters();
2571         },
2573         /*      Theme tweaker slider dragged (live-update event).
2574          */
2575         themeTweakerUIFieldInputReceived: (event) => {
2576                 GWLog("Appearance.themeTweakerUIFieldInputReceived");
2578                 let sampleTextFilters = Appearance.currentFilters;
2579                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2580                 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2581                 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2583                 Appearance.themeTweakerUISampleTextContainer.style.filter = Appearance.filterStringFromFilters(sampleTextFilters);
2584         },
2586         /*      Close button in main theme tweaker UI (title bar).
2587          */
2588         themeTweakerUICloseButtonClicked: (event) => {
2589                 GWLog("Appearance.themeTweakerUICloseButtonClicked");
2591                 Appearance.toggleThemeTweakerUI();
2592                 Appearance.themeTweakReset();
2593         },
2595         /*      Minimize button in main theme tweaker UI (title bar).
2596          */
2597         themeTweakerUIMinimizeButtonClicked: (event) => {
2598                 GWLog("Appearance.themeTweakerUIMinimizeButtonClicked");
2600                 Appearance.setThemeTweakerWindowMinimized(event.target.hasClass("minimize"));
2601         },
2603         /*      Help (“?”) button in main theme tweaker UI (title bar).
2604          */
2605         themeTweakerUIHelpButtonClicked: (event) => {
2606                 GWLog("Appearance.themeTweakerUIHelpButtonClicked");
2608                 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
2609                 Appearance.toggleThemeTweakerHelpWindow();
2610         },
2612         /*      “Reset Defaults” button in main theme tweaker UI.
2613          */
2614         themeTweakerUIResetDefaultsButtonClicked: (event) => {
2615                 GWLog("Appearance.themeTweakerUIResetDefaultsButtonClicked");
2617                 Appearance.themeTweakResetDefaults();
2618                 Appearance.resetThemeTweakerUIDefaultState();
2619         },
2621         /*      “Cancel” button in main theme tweaker UI.
2622          */
2623         themeTweakerUICancelButtonClicked: (event) => {
2624                 GWLog("Appearance.themeTweakerUICancelButtonClicked");
2626                 Appearance.toggleThemeTweakerUI();
2627                 Appearance.themeTweakReset();
2628         },
2630         /*      “OK” button in main theme tweaker UI.
2631          */
2632         themeTweakerUIOKButtonClicked: (event) => {
2633                 GWLog("Appearance.themeTweakerUIOKButtonClicked");
2635                 Appearance.toggleThemeTweakerUI();
2636                 Appearance.themeTweakSave();
2637         },
2639         /*      “Cancel” button in theme tweaker help window.
2640          */
2641         themeTweakerUIHelpWindowCancelButtonClicked: (event) => {
2642                 GWLog("Appearance.themeTweakerUIHelpWindowCancelButtonClicked");
2644                 Appearance.toggleThemeTweakerHelpWindow();
2645                 Appearance.themeTweakerResetSettings();
2646         },
2648         /*      “OK” button in theme tweaker help window.
2649          */
2650         themeTweakerUIHelpWindowOKButtonClicked: (event) => {
2651                 GWLog("Appearance.themeTweakerUIHelpWindowOKButtonClicked");
2653                 Appearance.toggleThemeTweakerHelpWindow();
2654                 Appearance.themeTweakerSaveSettings();
2655         },
2657         /*      The notch in the theme tweaker sliders (to reset the slider to its
2658                 default value).
2659          */
2660         themeTweakerUISliderNotchClicked: (event) => {
2661                 GWLog("Appearance.themeTweakerUISliderNotchClicked");
2663                 let slider = event.target.parentElement.query("input[type='range']");
2664                 slider.value = slider.dataset["defaultValue"];
2665                 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset["labelSuffix"];
2666                 Appearance.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset["valueSuffix"];
2667                 Appearance.applyFilters();
2668         },
2670         /*      The close button in the “Bobby the Basilisk” help message.
2671          */
2672         themeTweakerUIClippyCloseButtonClicked: (event) => {
2673                 GWLog("Appearance.themeTweakerUIClippyCloseButtonClicked");
2675                 Appearance.themeTweakerUIClippyContainer.style.display = "none";
2676                 Appearance.themeTweakerUIClippyControl.checked = false;
2677                 Appearance.saveThemeTweakerClippyState();
2678         }
2681 function setSearchBoxTabSelectable(selectable) {
2682         GWLog("setSearchBoxTabSelectable");
2683         query("input[type='search']").tabIndex = selectable ? "" : "-1";
2684         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2687 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
2688 // otherwise, show it.
2689 function updatePostNavUIVisibility() {
2690         GWLog("updatePostNavUIVisibility");
2691         var hidePostNavUIToggle = true;
2692         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
2693                 if (getComputedStyle(element).visibility == "visible" ||
2694                         element.style.visibility == "visible" ||
2695                         element.style.visibility == "unset")
2696                         hidePostNavUIToggle = false;
2697         });
2698         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
2699                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
2700         });
2703 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
2704 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
2705 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
2706 function updateSiteNavUIState(event) {
2707         GWLog("updateSiteNavUIState");
2708         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
2709         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
2710                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
2711                                                                                                                 0;
2712         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
2713                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
2714                                                                                                          0;
2715         GW.scrollState.lastScrollTop = newScrollTop;
2717         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
2718         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
2719                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
2720                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) 
2721                         Appearance.toggleAppearanceAdjustUI();
2722         }
2724         // On mobile, make site nav UI translucent on ANY scroll down.
2725         if (GW.isMobile)
2726                 GW.scrollState.siteNavUIElements.forEach(element => {
2727                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
2728                         else element.removeClass("translucent-on-scroll");
2729                 });
2731         // Show site nav UI when scrolling a full page up, or to the top.
2732         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
2733                  GW.scrollState.lastScrollTop == 0) &&
2734                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
2735                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
2737         // On desktop, show appearance adjust UI when scrolling to the top.
2738         if ((!GW.isMobile) && 
2739                 (GW.scrollState.lastScrollTop == 0) &&
2740                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
2741                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) 
2742                         Appearance.toggleAppearanceAdjustUI();
2745 /*********************/
2746 /* PAGE QUICK-NAV UI */
2747 /*********************/
2749 function injectQuickNavUI() {
2750         GWLog("injectQuickNavUI");
2751         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2752         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
2753         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
2754         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
2755         ` + "</div>");
2758 /**********************/
2759 /* NEW COMMENT NAV UI */
2760 /**********************/
2762 function injectNewCommentNavUI(newCommentsCount) {
2763         GWLog("injectNewCommentNavUI");
2764         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
2765         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
2766         <span class='new-comments-count'></span>
2767         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
2768         + "</div>");
2770         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2771                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2772                         GWLog("GW.commentQuicknavButtonClicked");
2773                         scrollToNewComment(/next/.test(event.target.className));
2774                         event.target.blur();
2775                 });
2776         });
2778         document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => { 
2779                 GWLog("GW.commentQuicknavKeyPressed");
2780                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2781                 if (event.key == ",") scrollToNewComment(false);
2782                 if (event.key == ".") scrollToNewComment(true)
2783         });
2785         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2786         + `<span>Since:</span>`
2787         + `<input type='text' class='hns-date'></input>`
2788         + "</div>");
2790         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2791                 GWLog("GW.hnsDatePickerValueChanged");
2792                 let hnsDate = time_fromHuman(event.target.value);
2793                 if(hnsDate) {
2794                         setHistoryLastVisitedDate(hnsDate);
2795                         let newCommentsCount = highlightCommentsSince(hnsDate);
2796                         updateNewCommentNavUI(newCommentsCount);
2797                 }
2798         }, false);
2800         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2801                 GWLog("GW.newCommentsCountClicked");
2802                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2803                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2804         });
2807 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2808 function time_fromHuman(string) {
2809         /* Convert a human-readable date into a JS timestamp */
2810         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2811                 string = string.replace(' ', 'T');  // revert nice spacing
2812                 string += ':00.000Z';  // complete ISO 8601 date
2813                 time = Date.parse(string);  // milliseconds since epoch
2815                 // browsers handle ISO 8601 without explicit timezone differently
2816                 // thus, we have to fix that by hand
2817                 time += (new Date()).getTimezoneOffset() * 60e3;
2818         } else {
2819                 string = string.replace(' at', '');
2820                 time = Date.parse(string);  // milliseconds since epoch
2821         }
2822         return time;
2825 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2826         GWLog("updateNewCommentNavUI");
2827         // Update the new comments count.
2828         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2829         newCommentsCountLabel.innerText = newCommentsCount;
2830         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2832         // Update the date picker field.
2833         if (hnsDate != -1) {
2834                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2835         }
2838 /********************************/
2839 /* COMMENTS VIEW MODE SELECTION */
2840 /********************************/
2842 function injectCommentsViewModeSelector() {
2843         GWLog("injectCommentsViewModeSelector");
2844         let commentsContainer = query("#comments");
2845         if (commentsContainer == null) return;
2847         let currentModeThreaded = (location.href.search("chrono=t") == -1);
2848         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2850         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2851         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
2852         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
2853         + "</div>");
2855 //      commentsViewModeSelector.queryAll("a").forEach(button => {
2856 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2857 //      });
2859         if (!currentModeThreaded) {
2860                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2861                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2862                         commentParentLink.addClass("inline-author");
2863                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2864                 });
2866                 queryAll(".comment-child-links a").forEach(commentChildLink => {
2867                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
2868                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2869                 });
2871                 rectifyChronoModeCommentChildLinks();
2873                 commentsContainer.addClass("chrono");
2874         } else {
2875                 commentsContainer.addClass("threaded");
2876         }
2878         // Remove extraneous top-level comment thread in chrono mode.
2879         let topLevelCommentThread = query("#comments > .comment-thread");
2880         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2883 // function commentsViewModeSelectorButtonClicked(event) {
2884 //      event.preventDefault();
2885 // 
2886 //      var newDocument;
2887 //      let request = new XMLHttpRequest();
2888 //      request.open("GET", event.target.href);
2889 //      request.onreadystatechange = () => {
2890 //              if (request.readyState != 4) return;
2891 //              newDocument = htmlToElement(request.response);
2892 // 
2893 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2894 // 
2895 //              // Update the buttons.
2896 //              event.target.addClass("selected");
2897 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
2898 // 
2899 //              // Update the #comments container.
2900 //              let commentsContainer = query("#comments");
2901 //              commentsContainer.removeClass(classes.old);
2902 //              commentsContainer.addClass(classes.new);
2903 // 
2904 //              // Update the content.
2905 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2906 //      };
2907 //      request.send();
2908 // }
2909 // 
2910 // function htmlToElement(html) {
2911 //     let template = newElement("TEMPLATE", { }, { "innerHTML": html.trim() });
2912 //     return template.content;
2913 // }
2915 function rectifyChronoModeCommentChildLinks() {
2916         GWLog("rectifyChronoModeCommentChildLinks");
2917         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2918                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2919                 let childLinks = commentChildLinksContainer.queryAll("a");
2920                 childLinks.forEach((link, index) => {
2921                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2922                 });
2924                 // Sort by date.
2925                 let childLinksArray = Array.from(childLinks)
2926                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2927                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2928         });
2930 function childrenOfComment(commentID) {
2931         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2932                 let commentParentLink = commentItem.query("a.comment-parent-link");
2933                 return ((commentParentLink||{}).hash == "#" + commentID);
2934         });
2937 /********************************/
2938 /* COMMENTS LIST MODE SELECTION */
2939 /********************************/
2941 function injectCommentsListModeSelector() {
2942         GWLog("injectCommentsListModeSelector");
2943         if (query("#content > .comment-thread") == null) return;
2945         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2946         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2947         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2948         + "</div>";
2950         if (query(".sublevel-nav") || query("#top-nav-bar")) {
2951                 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2952         } else {
2953                 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2954         }
2955         let commentsListModeSelector = query("#comments-list-mode-selector");
2957         commentsListModeSelector.queryAll("button").forEach(button => {
2958                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2959                         GWLog("GW.commentsListModeSelectButtonClicked");
2960                         event.target.parentElement.queryAll("button").forEach(button => {
2961                                 button.removeClass("selected");
2962                                 button.disabled = false;
2963                                 button.accessKey = '`';
2964                         });
2965                         localStorage.setItem("comments-list-mode", event.target.className);
2966                         event.target.addClass("selected");
2967                         event.target.disabled = true;
2968                         event.target.removeAttribute("accesskey");
2970                         if (event.target.hasClass("expanded")) {
2971                                 query("#content").removeClass("compact");
2972                         } else {
2973                                 query("#content").addClass("compact");
2974                         }
2975                 });
2976         });
2978         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2979         if (savedMode == "compact")
2980                 query("#content").addClass("compact");
2981         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2982         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2983         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2985         if (GW.isMobile) {
2986                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2987                         commentParentLink.addActivateEvent(function (event) {
2988                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2989                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2990                         }, false);
2991                 });
2992         }
2995 /**********************/
2996 /* SITE NAV UI TOGGLE */
2997 /**********************/
2999 function injectSiteNavUIToggle() {
3000         GWLog("injectSiteNavUIToggle");
3001         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
3002         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
3003                 GWLog("GW.siteNavUIToggleButtonClicked");
3004                 toggleSiteNavUI();
3005                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
3006         });
3008         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
3010 function removeSiteNavUIToggle() {
3011         GWLog("removeSiteNavUIToggle");
3012         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3013                 element.removeClass("engaged");
3014         });
3015         removeElement("#site-nav-ui-toggle");
3017 function toggleSiteNavUI() {
3018         GWLog("toggleSiteNavUI");
3019         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3020                 element.toggleClass("engaged");
3021                 element.removeClass("translucent-on-scroll");
3022         });
3025 /**********************/
3026 /* POST NAV UI TOGGLE */
3027 /**********************/
3029 function injectPostNavUIToggle() {
3030         GWLog("injectPostNavUIToggle");
3031         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
3032         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
3033                 GWLog("GW.postNavUIToggleButtonClicked");
3034                 togglePostNavUI();
3035                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
3036         });
3038         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
3040 function removePostNavUIToggle() {
3041         GWLog("removePostNavUIToggle");
3042         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3043                 element.removeClass("engaged");
3044         });
3045         removeElement("#post-nav-ui-toggle");
3047 function togglePostNavUI() {
3048         GWLog("togglePostNavUI");
3049         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3050                 element.toggleClass("engaged");
3051         });
3054 /**************************/
3055 /* WORD COUNT & READ TIME */
3056 /**************************/
3058 function toggleReadTimeOrWordCount(addWordCountClass) {
3059         GWLog("toggleReadTimeOrWordCount");
3060         queryAll(".post-meta .read-time").forEach(element => {
3061                 if (addWordCountClass) element.addClass("word-count");
3062                 else element.removeClass("word-count");
3064                 let titleParts = /(\S+)(.+)$/.exec(element.title);
3065                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
3066         });
3069 /**************************/
3070 /* PROMPT TO SAVE CHANGES */
3071 /**************************/
3073 function enableBeforeUnload() {
3074         window.onbeforeunload = function () { return true; };
3076 function disableBeforeUnload() {
3077         window.onbeforeunload = null;
3080 /***************************/
3081 /* ORIGINAL POSTER BADGING */
3082 /***************************/
3084 function markOriginalPosterComments() {
3085         GWLog("markOriginalPosterComments");
3086         let postAuthor = query(".post .author");
3087         if (postAuthor == null) return;
3089         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
3090                 if (author.dataset.userid == postAuthor.dataset.userid ||
3091                         (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
3092                         author.addClass("original-poster");
3093                         author.title += "Original poster";
3094                 }
3095         });
3098 /********************************/
3099 /* EDIT POST PAGE SUBMIT BUTTON */
3100 /********************************/
3102 function setEditPostPageSubmitButtonText() {
3103         GWLog("setEditPostPageSubmitButtonText");
3104         if (!query("#content").hasClass("edit-post-page")) return;
3106         queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
3107                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
3108                         GWLog("GW.postSectionSelectorValueChanged");
3109                         updateEditPostPageSubmitButtonText();
3110                 });
3111         });
3113         updateEditPostPageSubmitButtonText();
3115 function updateEditPostPageSubmitButtonText() {
3116         GWLog("updateEditPostPageSubmitButtonText");
3117         let submitButton = query("input[type='submit']");
3118         if (query("input#drafts").checked == true) 
3119                 submitButton.value = "Save Draft";
3120         else if (query(".posting-controls").hasClass("edit-existing-post"))
3121                 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
3122         else
3123                 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
3126 /*****************/
3127 /* ANTI-KIBITZER */
3128 /*****************/
3130 function numToAlpha(n) {
3131         let ret = "";
3132         do {
3133                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
3134                 n = Math.floor((n / 26) - 1);
3135         } while (n >= 0);
3136         return ret;
3139 function activateAntiKibitzer() {
3140         GWLog("activateAntiKibitzer");
3142         //      Activate anti-kibitzer mode (if needed).
3143         if (localStorage.getItem("antikibitzer") == "true")
3144                 toggleAntiKibitzerMode();
3146         //      Remove temporary CSS that hides the authors and karma values.
3147         removeElement("#antikibitzer-temp");
3149         //      Inject controls (if desktop).
3150         if (GW.isMobile == false)
3151                 injectAntiKibitzerToggle();
3154 function injectAntiKibitzerToggle() {
3155         GWLog("injectAntiKibitzerToggle");
3157         let antiKibitzerHTML = `<div id="anti-kibitzer-toggle">
3158                 <button type="button" tabindex="-1" accesskey="g" title="Toggle anti-kibitzer (show/hide authors & karma values) [g]"></button>
3159         </div>`;
3161         if (GW.isMobile) {
3162                 if (Appearance.themeSelector == null)
3163                         return;
3165                 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", antiKibitzerHTML);
3166         } else {
3167                 addUIElement(antiKibitzerHTML); 
3168         }
3170         //      Activate anti-kibitzer toggle button.
3171         query("#anti-kibitzer-toggle button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
3172                 GWLog("GW.antiKibitzerToggleButtonClicked");
3173                 if (   query("#anti-kibitzer-toggle").hasClass("engaged")
3174                         && !event.shiftKey 
3175                         && !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!)")) {
3176                         event.target.blur();
3177                         return;
3178                 }
3180                 toggleAntiKibitzerMode();
3181                 event.target.blur();
3182         });
3185 function toggleAntiKibitzerMode() {
3186         GWLog("toggleAntiKibitzerMode");
3187         // This will be the URL of the user's own page, if logged in, or the URL of
3188         // the login page otherwise.
3189         let userTabTarget = query("#nav-item-login .nav-inner").href;
3190         let pageHeadingElement = query("h1.page-main-heading");
3192         let userCount = 0;
3193         let userFakeName = { };
3195         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
3197         let postAuthor = query(".post-page .post-meta .author");
3198         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
3200         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
3201         if (antiKibitzerToggle.hasClass("engaged")) {
3202                 localStorage.setItem("antikibitzer", "false");
3204                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
3205                 if (redirectTarget) {
3206                         window.location = redirectTarget;
3207                         return;
3208                 }
3210                 // Individual comment page title and header
3211                 if (query(".individual-thread-page")) {
3212                         let replacer = (node) => {
3213                                 if (!node) return;
3214                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
3215                         }
3216                         replacer(query("title:not(.fake-title)"));
3217                         replacer(query("#content > h1"));
3218                 }
3220                 // Author names/links.
3221                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
3222                         author.textContent = author.dataset["trueName"];
3223                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
3225                         author.removeClass("redacted");
3226                 });
3227                 // Post/comment karma values.
3228                 queryAll(".karma-value.redacted").forEach(karmaValue => {
3229                         karmaValue.innerHTML = karmaValue.dataset["trueValue"];
3231                         karmaValue.removeClass("redacted");
3232                 });
3233                 // Link post domains.
3234                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
3235                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
3237                         linkPostDomain.removeClass("redacted");
3238                 });
3240                 antiKibitzerToggle.removeClass("engaged");
3241         } else {
3242                 localStorage.setItem("antikibitzer", "true");
3244                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
3245                 if (redirectTarget) {
3246                         window.location = redirectTarget;
3247                         return;
3248                 }
3250                 // Individual comment page title and header
3251                 if (query(".individual-thread-page")) {
3252                         let replacer = (node) => {
3253                                 if (!node) return;
3254                                 node.dataset["trueContent"] = node.firstChild.wholeText;
3255                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
3256                                 node.firstChild.replaceWith(newText);
3257                         }
3258                         replacer(query("title:not(.fake-title)"));
3259                         replacer(query("#content > h1"));
3260                 }
3262                 removeElement("title.fake-title");
3264                 // Author names/links.
3265                 queryAll(".author, .inline-author").forEach(author => {
3266                         // Skip own posts/comments.
3267                         if (author.hasClass("own-user-author"))
3268                                 return;
3270                         let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
3272                         if(!userid) return;
3274                         author.dataset["trueName"] = author.textContent;
3275                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
3277                         if (/\/user/.test(author.href)) {
3278                                 author.dataset["trueLink"] = author.pathname;
3279                                 author.href = "/user?id=" + author.dataset["userid"];
3280                         }
3282                         author.addClass("redacted");
3283                 });
3284                 // Post/comment karma values.
3285                 queryAll(".karma-value").forEach(karmaValue => {
3286                         // Skip own posts/comments.
3287                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
3288                                 return;
3290                         karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
3291                         karmaValue.innerHTML = "##<span> points</span>";
3293                         karmaValue.addClass("redacted");
3294                 });
3295                 // Link post domains.
3296                 queryAll(".link-post-domain").forEach(linkPostDomain => {
3297                         // Skip own posts/comments.
3298                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
3299                                 return;
3301                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
3302                         linkPostDomain.textContent = "redacted.domain.tld";
3304                         linkPostDomain.addClass("redacted");
3305                 });
3307                 antiKibitzerToggle.addClass("engaged");
3308         }
3311 /*******************************/
3312 /* COMMENT SORT MODE SELECTION */
3313 /*******************************/
3315 var CommentSortMode = Object.freeze({
3316         TOP:            "top",
3317         NEW:            "new",
3318         OLD:            "old",
3319         HOT:            "hot"
3321 function sortComments(mode) {
3322         GWLog("sortComments");
3323         let commentsContainer = query("#comments");
3325         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
3326         commentsContainer.addClass("sorting");
3328         GW.commentValues = { };
3329         let clonedCommentsContainer = commentsContainer.cloneNode(true);
3330         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
3331                 var comparator;
3332                 switch (mode) {
3333                 case CommentSortMode.NEW:
3334                         comparator = (a,b) => commentDate(b) - commentDate(a);
3335                         break;
3336                 case CommentSortMode.OLD:
3337                         comparator = (a,b) => commentDate(a) - commentDate(b);
3338                         break;
3339                 case CommentSortMode.HOT:
3340                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
3341                         break;
3342                 case CommentSortMode.TOP:
3343                 default:
3344                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
3345                         break;
3346                 }
3347                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
3348         });
3349         removeElement(commentsContainer.lastChild);
3350         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
3351         GW.commentValues = { };
3353         if (loggedInUserId) {
3354                 // Re-activate vote buttons.
3355                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
3356                         voteButton.addActivateEvent(voteButtonClicked);
3357                 });
3359                 // Re-activate comment action buttons.
3360                 commentsContainer.queryAll(".action-button").forEach(button => {
3361                         button.addActivateEvent(GW.commentActionButtonClicked);
3362                 });
3363         }
3365         // Re-activate comment-minimize buttons.
3366         queryAll(".comment-minimize-button").forEach(button => {
3367                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
3368         });
3370         // Re-add comment parent popups.
3371         addCommentParentPopups();
3372         
3373         // Redo new-comments highlighting.
3374         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
3376         requestAnimationFrame(() => {
3377                 commentsContainer.removeClass("sorting");
3378                 commentsContainer.addClass("sorted-" + mode);
3379         });
3381 function commentKarmaValue(commentOrSelector) {
3382         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3383         try {
3384                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
3385         } catch(e) {return null};
3387 function commentDate(commentOrSelector) {
3388         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3389         try {
3390                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
3391         } catch(e) {return null};
3393 function commentVoteCount(commentOrSelector) {
3394         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3395         try {
3396                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
3397         } catch(e) {return null};
3400 function injectCommentsSortModeSelector() {
3401         GWLog("injectCommentsSortModeSelector");
3402         let topCommentThread = query("#comments > .comment-thread");
3403         if (topCommentThread == null) return;
3405         // Do not show sort mode selector if there is no branching in comment tree.
3406         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
3408         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
3409                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
3410                 "</div>";
3411         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
3412         let commentsSortModeSelector = query("#comments-sort-mode-selector");
3414         commentsSortModeSelector.queryAll("button").forEach(button => {
3415                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
3416                         GWLog("GW.commentsSortModeSelectButtonClicked");
3417                         event.target.parentElement.queryAll("button").forEach(button => {
3418                                 button.removeClass("selected");
3419                                 button.disabled = false;
3420                         });
3421                         event.target.addClass("selected");
3422                         event.target.disabled = true;
3424                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
3425                         setCommentsSortModeSelectButtonsAccesskey();
3426                 });
3427         });
3429         // TODO: Make this actually get the current sort mode (if that's saved).
3430         // TODO: Also change the condition here to properly get chrono/threaded mode,
3431         // when that is properly done with cookies.
3432         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
3433         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
3434         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
3435         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
3436         setCommentsSortModeSelectButtonsAccesskey();
3439 function setCommentsSortModeSelectButtonsAccesskey() {
3440         GWLog("setCommentsSortModeSelectButtonsAccesskey");
3441         queryAll("#comments-sort-mode-selector button").forEach(button => {
3442                 button.removeAttribute("accesskey");
3443                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
3444         });
3445         let selectedButton = query("#comments-sort-mode-selector button.selected");
3446         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
3447         nextButtonInCycle.accessKey = "z";
3448         nextButtonInCycle.title += " [z]";
3451 /*************************/
3452 /* COMMENT PARENT POPUPS */
3453 /*************************/
3455 function previewPopupsEnabled() {
3456         let isDisabled = localStorage.getItem("preview-popups-disabled");
3457         return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
3460 function setPreviewPopupsEnabled(state) {
3461         localStorage.setItem("preview-popups-disabled", !state);
3462         updatePreviewPopupToggle();
3465 function updatePreviewPopupToggle() {
3466         let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
3467         query("#preview-popup-toggle").setAttribute("style", style);
3470 function injectPreviewPopupToggle() {
3471         GWLog("injectPreviewPopupToggle");
3473         let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
3474         // This is required because Chrome can't use filters on an externally used SVG element.
3475         fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
3476         updatePreviewPopupToggle();
3477         toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
3480 var currentPreviewPopup = { };
3482 function removePreviewPopup(previewPopup) {
3483         if(previewPopup.element)
3484                 removeElement(previewPopup.element);
3486         if(previewPopup.timeout)
3487                 clearTimeout(previewPopup.timeout);
3489         if(currentPreviewPopup.pointerListener)
3490                 window.removeEventListener("pointermove", previewPopup.pointerListener);
3492         if(currentPreviewPopup.mouseoutListener)
3493                 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3495         if(currentPreviewPopup.scrollListener)
3496                 window.removeEventListener("scroll", previewPopup.scrollListener);
3498         currentPreviewPopup = { };
3501 document.addEventListener("visibilitychange", () => {
3502         if(document.visibilityState != "visible") {
3503                 removePreviewPopup(currentPreviewPopup);
3504         }
3507 function addCommentParentPopups() {
3508         GWLog("addCommentParentPopups");
3509         //if (!query("#content").hasClass("comment-thread-page")) return;
3511         queryAll("a[href]").forEach(linkTag => {
3512                 let linkHref = linkTag.getAttribute("href");
3514                 let url;
3515                 try { url = new URL(linkHref, window.location.href); }
3516                 catch(e) { }
3517                 if(!url) return;
3519                 if(GW.sites[url.host]) {
3520                         let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
3521                         
3522                         if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
3523                                 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
3524                                         if(event.pointerType == "touch") return;
3525                                         GWLog("GW.commentParentLinkMouseOver");
3526                                         removePreviewPopup(currentPreviewPopup);
3527                                         let parentID = linkHref;
3528                                         var parent, popup;
3529                                         if (!(parent = (query(parentID)||{}).firstChild)) return;
3530                                         var highlightClassName;
3531                                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
3532                                                 parentHighlightClassName = "comment-item-highlight-faint";
3533                                                 popup = parent.cloneNode(true);
3534                                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
3535                                                 linkTag.addEventListener("mouseout", (event) => {
3536                                                         removeElement(popup);
3537                                                 }, {once: true});
3538                                                 linkTag.closest(".comments > .comment-thread").appendChild(popup);
3539                                         } else {
3540                                                 parentHighlightClassName = "comment-item-highlight";
3541                                         }
3542                                         parent.parentNode.addClass(parentHighlightClassName);
3543                                         linkTag.addEventListener("mouseout", (event) => {
3544                                                 parent.parentNode.removeClass(parentHighlightClassName);
3545                                         }, {once: true});
3546                                 });
3547                         }
3548                         else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
3549                                 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
3550                                 && !(url.searchParams.get('format'))
3551                                 && !linkTag.closest("nav:not(.post-nav-links)")
3552                                 && (!url.hash || linkCommentId)
3553                                 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
3554                                 linkTag.addEventListener("pointerover", event => {
3555                                         if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
3556                                         if(currentPreviewPopup.linkTag) return;
3557                                         linkTag.createPreviewPopup();
3558                                 });
3559                                 linkTag.createPreviewPopup = function() {
3560                                         removePreviewPopup(currentPreviewPopup);
3562                                         currentPreviewPopup = {linkTag: linkTag};
3563                                         
3564                                         let popup = newElement("IFRAME");
3565                                         currentPreviewPopup.element = popup;
3567                                         let popupTarget = linkHref;
3568                                         if(popupTarget.match(/#comment-/)) {
3569                                                 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
3570                                         }
3571                                         // 'theme' attribute is required for proper caching
3572                                         popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview");
3573                                         popup.addClass("preview-popup");
3574                                         
3575                                         let linkRect = linkTag.getBoundingClientRect();
3577                                         if(linkRect.right + 710 < window.innerWidth)
3578                                                 popup.style.left = linkRect.right + 10 + "px";
3579                                         else
3580                                                 popup.style.right = "10px";
3582                                         popup.style.width = "700px";
3583                                         popup.style.height = "500px";
3584                                         popup.style.visibility = "hidden";
3585                                         popup.style.transition = "none";
3587                                         let recenter = function() {
3588                                                 let popupHeight = 500;
3589                                                 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
3590                                                         let popupContent = popup.contentDocument.querySelector("#content");
3591                                                         if(popupContent) {
3592                                                                 popupHeight = popupContent.clientHeight + 2;
3593                                                                 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
3594                                                                 popup.style.height = popupHeight + "px";
3595                                                         }
3596                                                 }
3597                                                 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
3598                                         }
3600                                         recenter();
3602                                         query('#content').insertAdjacentElement("beforeend", popup);
3604                                         let clickListener = event => {
3605                                                 if(!event.target.closest("a, input, label")
3606                                                    && !event.target.closest("popup-hide-button")) {
3607                                                         window.location = linkHref;
3608                                                 }
3609                                         };
3611                                         popup.addEventListener("load", () => {
3612                                                 let hideButton = newElement("DIV", {
3613                                                         "class": "popup-hide-button"
3614                                                 }, {
3615                                                         "innerHTML": "&#xf070;"
3616                                                 });
3617                                                 hideButton.onclick = (event) => {
3618                                                         removePreviewPopup(currentPreviewPopup);
3619                                                         setPreviewPopupsEnabled(false);
3620                                                         event.stopPropagation();
3621                                                 }
3622                                                 popup.contentDocument.body.appendChild(hideButton);
3623                                                 
3624                                                 let popupBody = popup.contentDocument.body;
3625                                                 popupBody.addEventListener("click", clickListener);
3626                                                 popupBody.style.cursor = "pointer";
3628                                                 recenter();
3629                                         });
3631                                         popup.contentDocument.body.addEventListener("click", clickListener);
3632                                         
3633                                         currentPreviewPopup.timeout = setTimeout(() => {
3634                                                 recenter();
3636                                                 requestIdleCallback(() => {
3637                                                         if(currentPreviewPopup.element === popup) {
3638                                                                 popup.scrolling = "";
3639                                                                 popup.style.visibility = "unset";
3640                                                                 popup.style.transition = null;
3642                                                                 popup.animate([
3643                                                                         { opacity: 0, transform: "translateY(10%)" },
3644                                                                         { opacity: 1, transform: "none" }
3645                                                                 ], { duration: 150, easing: "ease-out" });
3646                                                         }
3647                                                 });
3648                                         }, 1000);
3650                                         let pointerX, pointerY, mousePauseTimeout = null;
3652                                         currentPreviewPopup.pointerListener = (event) => {
3653                                                 pointerX = event.clientX;
3654                                                 pointerY = event.clientY;
3656                                                 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3657                                                 mousePauseTimeout = null;
3659                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3660                                                 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3662                                                 if(mouseIsOverLink || overElement === popup
3663                                                    || (pointerX < popup.getBoundingClientRect().left
3664                                                        && event.movementX >= 0)) {
3665                                                         if(!mouseIsOverLink && overElement !== popup) {
3666                                                                 if(overElement['createPreviewPopup']) {
3667                                                                         mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3668                                                                 } else {
3669                                                                         mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3670                                                                 }
3671                                                         }
3672                                                 } else {
3673                                                         removePreviewPopup(currentPreviewPopup);
3674                                                         if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3675                                                 }
3676                                         };
3677                                         window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3679                                         currentPreviewPopup.mouseoutListener = (event) => {
3680                                                 clearTimeout(mousePauseTimeout);
3681                                                 mousePauseTimeout = null;
3682                                         }
3683                                         document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3685                                         currentPreviewPopup.scrollListener = (event) => {
3686                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3687                                                 linkRect = linkTag.getBoundingClientRect();
3688                                                 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3689                                                 removePreviewPopup(currentPreviewPopup);
3690                                         };
3691                                         window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3692                                 };
3693                         }
3694                 }
3695         });
3696         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3697                 
3698         });
3700         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3701         Appearance.filtersExclusionPaths.commentParentPopups = [
3702                 "#content .comments .comment-thread"
3703         ];
3704         Appearance.applyFilters();
3707 /***************/
3708 /* IMAGE FOCUS */
3709 /***************/
3711 function imageFocusSetup(imagesOverlayOnly = false) {
3712         if (typeof GW.imageFocus == "undefined")
3713                 GW.imageFocus = {
3714                         contentImagesSelector:  "#content img",
3715                         overlayImagesSelector:  "#images-overlay img",
3716                         focusedImageSelector:   "#content img.focused, #images-overlay img.focused",
3717                         pageContentSelector:    "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3718                         shrinkRatio:                    0.975,
3719                         hideUITimerDuration:    1500,
3720                         hideUITimerExpired:             () => {
3721                                 GWLog("GW.imageFocus.hideUITimerExpired");
3722                                 let currentTime = new Date();
3723                                 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3724                                 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3725                                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3726                                 } else {
3727                                         hideImageFocusUI();
3728                                         cancelImageFocusHideUITimer();
3729                                 }
3730                         }
3731                 };
3733         GWLog("imageFocusSetup");
3734         // Create event listener for clicking on images to focus them.
3735         GW.imageClickedToFocus = (event) => {
3736                 GWLog("GW.imageClickedToFocus");
3737                 focusImage(event.target);
3739                 if (!GW.isMobile) {
3740                         // Set timer to hide the image focus UI.
3741                         unhideImageFocusUI();
3742                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3743                 }
3744         };
3745         // Add the listener to each image in the overlay (i.e., those in the post).
3746         queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3747                 image.addActivateEvent(GW.imageClickedToFocus);
3748         });
3749         // Accesskey-L starts the slideshow.
3750         (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3751         // Count how many images there are in the post, and set the "… of X" label to that.
3752         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3753         if (imagesOverlayOnly) return;
3754         // Add the listener to all other content images (including those in comments).
3755         queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3756                 image.addActivateEvent(GW.imageClickedToFocus);
3757         });
3759         // Create the image focus overlay.
3760         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
3761         `<div class='help-overlay'>
3762                  <p><strong>Arrow keys:</strong> Next/previous image</p>
3763                  <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3764                  <p><strong>Space bar:</strong> Reset image size & position</p>
3765                  <p><strong>Scroll</strong> to zoom in/out</p>
3766                  <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3767         </div>
3768         <div class='image-number'></div>
3769         <div class='slideshow-buttons'>
3770                  <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
3771                  <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
3772         </div>
3773         <div class='caption'></div>` + 
3774         "</div>");
3775         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3777         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3778                 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3779                         GWLog("GW.imageFocus.slideshowButtonClicked");
3780                         focusNextImage(event.target.hasClass("next"));
3781                         event.target.blur();
3782                 });
3783         });
3785         // On orientation change, reset the size & position.
3786         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3787                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3788         }
3790         // UI starts out hidden.
3791         hideImageFocusUI();
3794 function focusImage(imageToFocus) {
3795         GWLog("focusImage");
3796         // Clear 'last-focused' class of last focused image.
3797         let lastFocusedImage = query("img.last-focused");
3798         if (lastFocusedImage) {
3799                 lastFocusedImage.removeClass("last-focused");
3800                 lastFocusedImage.removeAttribute("accesskey");
3801         }
3803         // Create the focused version of the image.
3804         imageToFocus.addClass("focused");
3805         let imageFocusOverlay = query("#image-focus-overlay");
3806         let clonedImage = imageToFocus.cloneNode(true);
3807         clonedImage.style = "";
3808         clonedImage.removeAttribute("width");
3809         clonedImage.removeAttribute("height");
3810         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3811         imageFocusOverlay.appendChild(clonedImage);
3812         imageFocusOverlay.addClass("engaged");
3814         // Set image to default size and position.
3815         resetFocusedImagePosition();
3817         // Blur everything else.
3818         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3819                 element.addClass("blurred");
3820         });
3822         // Add listener to zoom image with scroll wheel.
3823         window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3824                 GWLog("GW.imageFocus.scrollEvent");
3825                 event.preventDefault();
3827                 let image = query("#image-focus-overlay img");
3829                 // Remove the filter.
3830                 image.savedFilter = image.style.filter;
3831                 image.style.filter = 'none';
3833                 // Locate point under cursor.
3834                 let imageBoundingBox = image.getBoundingClientRect();
3836                 // Calculate resize factor.
3837                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3838                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3839                                                 1;
3841                 // Resize.
3842                 image.style.width = (event.deltaY < 0 ?
3843                                                         (image.clientWidth * factor) :
3844                                                         (image.clientWidth / factor))
3845                                                         + "px";
3846                 image.style.height = "";
3848                 // Designate zoom origin.
3849                 var zoomOrigin;
3850                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3851                 // the cursor is over the image.
3852                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3853                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3854                                                                 (imageBoundingBox.left <= event.clientX &&
3855                                                                  event.clientX <= imageBoundingBox.right && 
3856                                                                  imageBoundingBox.top <= event.clientY &&
3857                                                                  event.clientY <= imageBoundingBox.bottom);
3858                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
3859                 // zooming IN, zoom from image center.
3860                 let zoomingFromWindowCenter = event.deltaY > 0;
3861                 if (zoomingFromCursor)
3862                         zoomOrigin = { x: event.clientX, 
3863                                                    y: event.clientY };
3864                 else if (zoomingFromWindowCenter)
3865                         zoomOrigin = { x: window.innerWidth / 2, 
3866                                                    y: window.innerHeight / 2 };
3867                 else
3868                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
3869                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3871                 // Calculate offset from zoom origin.
3872                 let offsetOfImageFromZoomOrigin = {
3873                         x: imageBoundingBox.x - zoomOrigin.x,
3874                         y: imageBoundingBox.y - zoomOrigin.y
3875                 }
3876                 // Calculate delta from centered zoom.
3877                 let deltaFromCenteredZoom = {
3878                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3879                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3880                 }
3881                 // Adjust image position appropriately.
3882                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3883                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3884                 // Gradually re-center image, if it's smaller than the window.
3885                 if (!imageSizeExceedsWindowBounds) {
3886                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
3887                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3888                         let windowCenter = { x: window.innerWidth / 2,
3889                                                                  y: window.innerHeight / 2 }
3890                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3891                                                                                   y: windowCenter.y - imageCenter.y }
3892                         // Divide the offset by 10 because we're nudging the image toward center,
3893                         // not jumping it there.
3894                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3895                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3896                 }
3898                 // Put the filter back.
3899                 image.style.filter = image.savedFilter;
3901                 // Set the cursor appropriately.
3902                 setFocusedImageCursor();
3903         });
3904         window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3905                 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3906                 event.preventDefault();
3907         });
3909         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3910         window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3911                 GWLog("GW.imageFocus.mouseUp");
3912                 window.onmousemove = '';
3914                 // We only want to do anything on left-clicks.
3915                 if (event.button != 0) return;
3917                 // Don't unfocus if click was on a slideshow next/prev button!
3918                 if (event.target.hasClass("slideshow-button")) return;
3920                 // We also don't want to do anything if clicked on the help overlay.
3921                 if (event.target.classList.contains("help-overlay") ||
3922                         event.target.closest(".help-overlay"))
3923                         return;
3925                 let focusedImage = query("#image-focus-overlay img");
3926                 if (event.target == focusedImage && 
3927                         (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3928                         // If the mouseup event was the end of a pan of an overside image,
3929                         // put the filter back; do not unfocus.
3930                         focusedImage.style.filter = focusedImage.savedFilter;
3931                 } else {
3932                         unfocusImageOverlay();
3933                         return;
3934                 }
3935         });
3936         window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3937                 GWLog("GW.imageFocus.mouseDown");
3938                 event.preventDefault();
3940                 let focusedImage = query("#image-focus-overlay img");
3941                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3942                         let mouseCoordX = event.clientX;
3943                         let mouseCoordY = event.clientY;
3945                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3946                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3948                         // Save the filter.
3949                         focusedImage.savedFilter = focusedImage.style.filter;
3951                         window.onmousemove = (event) => {
3952                                 // Remove the filter.
3953                                 focusedImage.style.filter = "none";
3954                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3955                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3956                         };
3957                         return false;
3958                 }
3959         });
3961         // Double-click on the image unfocuses.
3962         clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3963                 GWLog("GW.imageFocus.doubleClick");
3964                 if (event.target.hasClass("slideshow-button")) return;
3966                 unfocusImageOverlay();
3967         });
3969         // Escape key unfocuses, spacebar resets.
3970         document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3971                 GWLog("GW.imageFocus.keyUp");
3972                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3973                 if (!allowedKeys.contains(event.key) || 
3974                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3976                 event.preventDefault();
3978                 switch (event.key) {
3979                 case "Escape": 
3980                 case "Esc":
3981                         unfocusImageOverlay();
3982                         break;
3983                 case " ":
3984                 case "Spacebar":
3985                         resetFocusedImagePosition();
3986                         break;
3987                 case "ArrowDown":
3988                 case "Down":
3989                 case "ArrowRight":
3990                 case "Right":
3991                         if (query("#images-overlay img.focused")) focusNextImage(true);
3992                         break;
3993                 case "ArrowUp":
3994                 case "Up":
3995                 case "ArrowLeft":
3996                 case "Left":
3997                         if (query("#images-overlay img.focused")) focusNextImage(false);
3998                         break;
3999                 }
4000         });
4002         // Prevent spacebar or arrow keys from scrolling page when image focused.
4003         togglePageScrolling(false);
4005         // If the image comes from the images overlay, for the main post...
4006         if (imageToFocus.closest("#images-overlay")) {
4007                 // Mark the overlay as being in slide show mode (to show buttons/count).
4008                 imageFocusOverlay.addClass("slideshow");
4010                 // Set state of next/previous buttons.
4011                 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4012                 var indexOfFocusedImage = getIndexOfFocusedImage();
4013                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4014                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4016                 // Set the image number.
4017                 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4019                 // Replace the hash.
4020                 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4021         } else {
4022                 imageFocusOverlay.removeClass("slideshow");
4023         }
4025         // Set the caption.
4026         setImageFocusCaption();
4028         // Moving mouse unhides image focus UI.
4029         window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
4030                 GWLog("GW.imageFocus.mouseMoved");
4031                 let currentDateTime = new Date();
4032                 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
4033                         cancelImageFocusHideUITimer();
4034                 } else {
4035                         if (!GW.imageFocus.hideUITimer) {
4036                                 unhideImageFocusUI();
4037                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4038                         }
4039                         GW.imageFocus.mouseLastMovedAt = currentDateTime;
4040                 }
4041         });
4044 function resetFocusedImagePosition() {
4045         GWLog("resetFocusedImagePosition");
4046         let focusedImage = query("#image-focus-overlay img");
4047         if (!focusedImage) return;
4049         let sourceImage = query(GW.imageFocus.focusedImageSelector);
4051         // Make sure that initially, the image fits into the viewport.
4052         let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
4053         let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
4054         var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
4055         let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
4056         let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
4057         focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
4058         focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
4060         // Remove modifications to position.
4061         focusedImage.style.left = "";
4062         focusedImage.style.top = "";
4064         // Set the cursor appropriately.
4065         setFocusedImageCursor();
4067 function setFocusedImageCursor() {
4068         let focusedImage = query("#image-focus-overlay img");
4069         if (!focusedImage) return;
4070         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
4071                                                                 'move' : '';
4074 function unfocusImageOverlay() {
4075         GWLog("unfocusImageOverlay");
4077         // Remove event listeners.
4078         window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
4079         window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
4080         // NOTE: The double-click listener does not need to be removed manually,
4081         // because the focused (cloned) image will be removed anyway.
4082         document.removeEventListener("keyup", GW.imageFocus.keyUp);
4083         document.removeEventListener("keydown", GW.imageFocus.keyDown);
4084         window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
4085         window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
4086         window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
4088         // Set accesskey of currently focused image (if it's in the images overlay).
4089         let currentlyFocusedImage = query("#images-overlay img.focused");
4090         if (currentlyFocusedImage) {
4091                 currentlyFocusedImage.addClass("last-focused");
4092                 currentlyFocusedImage.accessKey = 'l';
4093         }
4095         // Remove focused image and hide overlay.
4096         let imageFocusOverlay = query("#image-focus-overlay");
4097         imageFocusOverlay.removeClass("engaged");
4098         removeElement(imageFocusOverlay.query("img"));
4100         // Un-blur content/etc.
4101         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
4102                 element.removeClass("blurred");
4103         });
4105         // Unset "focused" class of focused image.
4106         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4108         // Re-enable page scrolling.
4109         togglePageScrolling(true);
4111         // Reset the hash, if needed.
4112         if (location.hash.hasPrefix("#if_slide_"))
4113                 history.replaceState(window.history.state, null, "#");
4116 function getIndexOfFocusedImage() {
4117         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4118         var indexOfFocusedImage = -1;
4119         for (i = 0; i < images.length; i++) {
4120                 if (images[i].hasClass("focused")) {
4121                         indexOfFocusedImage = i;
4122                         break;
4123                 }
4124         }
4125         return indexOfFocusedImage;
4128 function focusNextImage(next = true) {
4129         GWLog("focusNextImage");
4130         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4131         var indexOfFocusedImage = getIndexOfFocusedImage();
4133         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
4135         // Remove existing image.
4136         removeElement("#image-focus-overlay img");
4137         // Unset "focused" class of just-removed image.
4138         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4140         // Create the focused version of the image.
4141         images[indexOfFocusedImage].addClass("focused");
4142         let imageFocusOverlay = query("#image-focus-overlay");
4143         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
4144         clonedImage.style = "";
4145         clonedImage.removeAttribute("width");
4146         clonedImage.removeAttribute("height");
4147         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
4148         imageFocusOverlay.appendChild(clonedImage);
4149         imageFocusOverlay.addClass("engaged");
4150         // Set image to default size and position.
4151         resetFocusedImagePosition();
4152         // Set state of next/previous buttons.
4153         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4154         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4155         // Set the image number display.
4156         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4157         // Set the caption.
4158         setImageFocusCaption();
4159         // Replace the hash.
4160         history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4163 function setImageFocusCaption() {
4164         GWLog("setImageFocusCaption");
4165         var T = { }; // Temporary storage.
4167         // Clear existing caption, if any.
4168         let captionContainer = query("#image-focus-overlay .caption");
4169         Array.from(captionContainer.children).forEach(child => { child.remove(); });
4171         // Determine caption.
4172         let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
4173         var captionHTML;
4174         if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) && 
4175                 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
4176                 captionHTML = (T.figcaption.query("p")) ? 
4177                                           T.figcaption.innerHTML : 
4178                                           "<p>" + T.figcaption.innerHTML + "</p>"; 
4179         } else if (currentlyFocusedImage.title != "") {
4180                 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
4181         }
4182         // Insert the caption, if any.
4183         if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
4186 function hideImageFocusUI() {
4187         GWLog("hideImageFocusUI");
4188         let imageFocusOverlay = query("#image-focus-overlay");
4189         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4190                 element.addClass("hidden");
4191         });
4194 function unhideImageFocusUI() {
4195         GWLog("unhideImageFocusUI");
4196         let imageFocusOverlay = query("#image-focus-overlay");
4197         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4198                 element.removeClass("hidden");
4199         });
4202 function cancelImageFocusHideUITimer() {
4203         clearTimeout(GW.imageFocus.hideUITimer);
4204         GW.imageFocus.hideUITimer = null;
4207 /*****************/
4208 /* KEYBOARD HELP */
4209 /*****************/
4211 function keyboardHelpSetup() {
4212         let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
4213                 <div class='keyboard-help-container'>
4214                         <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'>&#xf00d;</button>
4215                         <h1>Keyboard shortcuts</h1>
4216                         <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>
4217                         <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
4218                         <div class='keyboard-shortcuts-lists'>` + [ [
4219                                 "General",
4220                                 [ [ '?' ], "Show keyboard shortcuts" ],
4221                                 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
4222                         ], [
4223                                 "Site navigation",
4224                                 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
4225                                 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
4226                                 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
4227                                 [ [ 'ak-m' ], "Go to Meta view" ],
4228                                 [ [ 'ak-v' ], "Go to Tags view"],
4229                                 [ [ 'ak-c' ], "Go to Recent Comments view" ],
4230                                 [ [ 'ak-r' ], "Go to Archive view" ],
4231                                 [ [ 'ak-q' ], "Go to Sequences view" ],
4232                                 [ [ 'ak-t' ], "Go to About page" ],
4233                                 [ [ 'ak-u' ], "Go to User or Login page" ],
4234                                 [ [ 'ak-o' ], "Go to Inbox page" ]
4235                         ], [
4236                                 "Page navigation",
4237                                 [ [ 'ak-,' ], "Jump up to top of page" ],
4238                                 [ [ 'ak-.' ], "Jump down to bottom of page" ],
4239                                 [ [ 'ak-/' ], "Jump to top of comments section" ],
4240                                 [ [ 'ak-s' ], "Search" ],
4241                         ], [
4242                                 "Page actions",
4243                                 [ [ 'ak-n' ], "New post or comment" ],
4244                                 [ [ 'ak-e' ], "Edit current post" ]
4245                         ], [
4246                                 "Post/comment list views",
4247                                 [ [ '.' ], "Focus next entry in list" ],
4248                                 [ [ ',' ], "Focus previous entry in list" ],
4249                                 [ [ ';' ], "Cycle between links in focused entry" ],
4250                                 [ [ 'Enter' ], "Go to currently focused entry" ],
4251                                 [ [ 'Esc' ], "Unfocus currently focused entry" ],
4252                                 [ [ 'ak-]' ], "Go to next page" ],
4253                                 [ [ 'ak-[' ], "Go to previous page" ],
4254                                 [ [ 'ak-\\' ], "Go to first page" ],
4255                                 [ [ 'ak-e' ], "Edit currently focused post" ]
4256                         ], [
4257                                 "Editor",
4258                                 [ [ 'ak-k' ], "Bold text" ],
4259                                 [ [ 'ak-i' ], "Italic text" ],
4260                                 [ [ 'ak-l' ], "Insert hyperlink" ],
4261                                 [ [ 'ak-q' ], "Blockquote text" ]
4262                         ], [                            
4263                                 "Appearance",
4264                                 [ [ 'ak-=' ], "Increase text size" ],
4265                                 [ [ 'ak--' ], "Decrease text size" ],
4266                                 [ [ 'ak-0' ], "Reset to default text size" ],
4267                                 [ [ 'ak-′' ], "Cycle through content width settings" ],
4268                                 [ [ 'ak-1' ], "Switch to default theme [A]" ],
4269                                 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
4270                                 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
4271                                 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
4272                                 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
4273                                 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
4274                                 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
4275                                 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
4276                                 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
4277                                 [ [ 'ak-;' ], "Open theme tweaker" ],
4278                                 [ [ 'Enter' ], "Save changes and close theme tweaker "],
4279                                 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
4280                         ], [
4281                                 "Slide shows",
4282                                 [ [ 'ak-l' ], "Start/resume slideshow" ],
4283                                 [ [ 'Esc' ], "Exit slideshow" ],
4284                                 [ [ '&#x2192;', '&#x2193;' ], "Next slide" ],
4285                                 [ [ '&#x2190;', '&#x2191;' ], "Previous slide" ],
4286                                 [ [ 'Space' ], "Reset slide zoom" ]
4287                         ], [
4288                                 "Miscellaneous",
4289                                 [ [ 'ak-x' ], "Switch to next view on user page" ],
4290                                 [ [ 'ak-z' ], "Switch to previous view on user page" ],
4291                                 [ [ 'ak-`&nbsp;' ], "Toggle compact comment list view" ],
4292                                 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
4293                         ] ].map(section => 
4294                         `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
4295                                 `<li>
4296                                         <span class='keys'>` + 
4297                                         entry[0].map(key =>
4298                                                 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
4299                                         ).join("") + 
4300                                         `</span>
4301                                         <span class='action'>${entry[1]}</span>
4302                                 </li>`
4303                         ).join("\n") + `</ul>`).join("\n") + `
4304                         </ul></div>             
4305                 </div>
4306         ` + "</nav>");
4308         // Add listener to show the keyboard help overlay.
4309         document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
4310                 GWLog("GW.keyboardHelpShowKeyPressed");
4311                 if (event.key == '?')
4312                         toggleKeyboardHelpOverlay(true);
4313         });
4315         // Clicking the background overlay closes the keyboard help overlay.
4316         keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
4317                 GWLog("GW.keyboardHelpOverlayClicked");
4318                 if (event.type == "mousedown") {
4319                         keyboardHelpOverlay.style.opacity = "0.01";
4320                 } else {
4321                         toggleKeyboardHelpOverlay(false);
4322                         keyboardHelpOverlay.style.opacity = "1.0";
4323                 }
4324         }, true);
4326         // Intercept clicks, so they don't "fall through" the background overlay.
4327         (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
4329         // Clicking the close button closes the keyboard help overlay.
4330         keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
4331                 toggleKeyboardHelpOverlay(false);
4332         });
4334         // Add button to open keyboard help.
4335         query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'>&#xf11c;</button>");
4336         query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
4337                 GWLog("GW.openKeyboardHelpButtonClicked");
4338                 toggleKeyboardHelpOverlay(true);
4339                 event.target.blur();
4340         });
4343 function toggleKeyboardHelpOverlay(show) {
4344         console.log("toggleKeyboardHelpOverlay");
4346         let keyboardHelpOverlay = query("#keyboard-help-overlay");
4347         show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
4348         keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
4350         // Prevent scrolling the document when the overlay is visible.
4351         togglePageScrolling(!show);
4353         // Focus the close button as soon as we open.
4354         keyboardHelpOverlay.query("button.close-keyboard-help").focus();
4356         if (show) {
4357                 // Add listener to show the keyboard help overlay.
4358                 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
4359                         GWLog("GW.keyboardHelpHideKeyPressed");
4360                         if (event.key == 'Escape')
4361                                 toggleKeyboardHelpOverlay(false);
4362                 });
4363         } else {
4364                 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
4365         }
4367         // Disable / enable tab-selection of the search box.
4368         setSearchBoxTabSelectable(!show);
4371 /**********************/
4372 /* PUSH NOTIFICATIONS */
4373 /**********************/
4375 function pushNotificationsSetup() {
4376         let pushNotificationsButton = query("#enable-push-notifications");
4377         if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
4378                 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
4379                 pushNotificationsButton.style.display = 'unset';
4380         }
4383 function urlBase64ToUint8Array(base64String) {
4384         const padding = '='.repeat((4 - base64String.length % 4) % 4);
4385         const base64 = (base64String + padding)
4386               .replace(/-/g, '+')
4387               .replace(/_/g, '/');
4388         
4389         const rawData = window.atob(base64);
4390         const outputArray = new Uint8Array(rawData.length);
4391         
4392         for (let i = 0; i < rawData.length; ++i) {
4393                 outputArray[i] = rawData.charCodeAt(i);
4394         }
4395         return outputArray;
4398 function pushNotificationsButtonClicked(event) {
4399         event.target.style.opacity = 0.33;
4400         event.target.style.pointerEvents = "none";
4402         let reEnable = (message) => {
4403                 if(message) alert(message);
4404                 event.target.style.opacity = 1;
4405                 event.target.style.pointerEvents = "unset";
4406         }
4408         if(event.target.dataset.enabled) {
4409                 fetch('/push/register', {
4410                         method: 'post',
4411                         headers: { 'Content-type': 'application/json' },
4412                         body: JSON.stringify({
4413                                 cancel: true
4414                         }),
4415                 }).then(() => {
4416                         event.target.innerHTML = "Enable push notifications";
4417                         event.target.dataset.enabled = "";
4418                         reEnable();
4419                 }).catch((err) => reEnable(err.message));
4420         } else {
4421                 Notification.requestPermission().then((permission) => {
4422                         navigator.serviceWorker.ready
4423                                 .then((registration) => {
4424                                         return registration.pushManager.getSubscription()
4425                                                 .then(async function(subscription) {
4426                                                         if (subscription) {
4427                                                                 return subscription;
4428                                                         }
4429                                                         return registration.pushManager.subscribe({
4430                                                                 userVisibleOnly: true,
4431                                                                 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
4432                                                         });
4433                                                 })
4434                                                 .catch((err) => reEnable(err.message));
4435                                 })
4436                                 .then((subscription) => {
4437                                         fetch('/push/register', {
4438                                                 method: 'post',
4439                                                 headers: {
4440                                                         'Content-type': 'application/json'
4441                                                 },
4442                                                 body: JSON.stringify({
4443                                                         subscription: subscription
4444                                                 }),
4445                                         });
4446                                 })
4447                                 .then(() => {
4448                                         event.target.innerHTML = "Disable push notifications";
4449                                         event.target.dataset.enabled = "true";
4450                                         reEnable();
4451                                 })
4452                                 .catch(function(err){ reEnable(err.message) });
4453                         
4454                 });
4455         }
4458 /*******************************/
4459 /* HTML TO MARKDOWN CONVERSION */
4460 /*******************************/
4462 function MarkdownFromHTML(text, linePrefix) {
4463         GWLog("MarkdownFromHTML");
4464         console.log(text);
4466         let docFrag = document.createRange().createContextualFragment(text);
4467         let output = "";
4468         let owedLines = -1;
4469         linePrefix = linePrefix || "";
4471         let out = text => {
4472                 if(owedLines > 0) {
4473                         output += ("\n" + linePrefix).repeat(owedLines);
4474                 }
4475                 output += text;
4476                 owedLines = 0;
4477         }
4478         let forceLine = n => {
4479                 n = n || 1;
4480                 out(("\n" + linePrefix).repeat(n));
4481         }
4482         let newLine = (n) => {
4483                 n = n || 1;
4484                 if(owedLines >= 0 && owedLines < n) {
4485                         owedLines = n;
4486                 }
4487         };
4488         let newParagraph = () => {
4489                 newLine(2);
4490         };
4491         let withPrefix = (prefix, fn) => {
4492                 let oldPrefix = linePrefix;
4493                 linePrefix += prefix;
4494                 owedLines = -1;
4495                 fn();
4496                 owedLines = 0;
4497                 linePrefix = oldPrefix;
4498         };
4500         let doConversion = (node) => {
4501                 if(node.nodeType == Node.TEXT_NODE) {
4502                         let lines = node.nodeValue.split(/\r|\n/m);
4503                         for(text of lines.slice(0, -1)) {
4504                                 out(text);
4505                                 newLine();
4506                         }
4507                         out(lines.at(-1));
4508                 } else if(node.nodeType == Node.ELEMENT_NODE) {
4509                         switch(node.tagName) {
4510                         case "P":
4511                         case "DIV":
4512                         case "UL":
4513                         case "OL":
4514                                 newParagraph();
4515                                 node.childNodes.forEach(doConversion);
4516                                 newParagraph();
4517                                 break;
4518                         case "BR":
4519                                 forceLine();
4520                                 break;
4521                         case "HR":
4522                                 newLine();
4523                                 out("---");
4524                                 newLine();
4525                                 break;
4526                         case "B":
4527                         case "STRONG":
4528                                 out("**");
4529                                 node.childNodes.forEach(doConversion);
4530                                 out("**");
4531                                 break;
4532                         case "I":
4533                         case "EM":
4534                                 out("*");
4535                                 node.childNodes.forEach(doConversion);
4536                                 out("*");
4537                                 break;
4538                         case "LI":
4539                                 newLine();
4540                                 let listPrefix;
4541                                 if(node.parentElement.tagName == "OL") {
4542                                         let i = 1;
4543                                         for(let e = node; e = e.previousElementSibling;) { i++ }
4544                                         listPrefix = "" + i + ". ";
4545                                 } else {
4546                                         listPrefix = "* ";
4547                                 }
4548                                 out(listPrefix);
4549                                 owedLines = -1;
4550                                 withPrefix(" ".repeat(listPrefix.length), () => node.childNodes.forEach(doConversion));
4551                                 newLine();
4552                                 break;
4553                         case "H1":
4554                         case "H2":
4555                         case "H3":
4556                         case "H4":
4557                         case "H5":
4558                         case "H6":
4559                                 newParagraph();
4560                                 out("#".repeat(node.tagName.charAt(1)) + " ");
4561                                 node.childNodes.forEach(doConversion);
4562                                 newParagraph();
4563                                 break;
4564                         case "A":
4565                                 let href = node.href;
4566                                 out('[');
4567                                 node.childNodes.forEach(doConversion);
4568                                 out(`](${href})`);
4569                                 break;
4570                         case "IMG":
4571                                 let src = node.src;
4572                                 let alt = node.alt || "";
4573                                 out(`![${alt}](${src})`);
4574                                 break;
4575                         case "BLOCKQUOTE":
4576                                 newParagraph();
4577                                 out("> ");
4578                                 withPrefix("> ", () => node.childNodes.forEach(doConversion));
4579                                 newParagraph();
4580                                 break;
4581                         case "PRE":
4582                                 newParagraph();
4583                                 out('```');
4584                                 forceLine();
4585                                 out(node.innerText);
4586                                 forceLine();
4587                                 out('```');
4588                                 newParagraph();
4589                                 break;
4590                         case "CODE":
4591                                 out('`');
4592                                 node.childNodes.forEach(doConversion);
4593                                 out('`');
4594                                 break;
4595                         default:
4596                                 node.childNodes.forEach(doConversion);
4597                         }
4598                 } else {
4599                         node.childNodes.forEach(doConversion);
4600                 }
4601         }
4602         doConversion(docFrag);
4604         return output;
4607 /************************************/
4608 /* ANCHOR LINK SCROLLING WORKAROUND */
4609 /************************************/
4611 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
4612         let hash = location.hash;
4613         if(hash && hash !== "#top" && !document.query(hash)) {
4614                 let content = document.query("#content");
4615                 content.style.display = "none";
4616                 addTriggerListener("DOMReady", {priority: -1, fn: () => {
4617                         content.style.visibility = "hidden";
4618                         content.style.display = null;
4619                         requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
4620                 }});
4621         }
4622 }});
4624 /******************/
4625 /* INITIALIZATION */
4626 /******************/
4628 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
4629         GWLog("INITIALIZER earlyInitialize");
4630         // Check to see whether we're on a mobile device (which we define as a narrow screen)
4631         GW.isMobile = (window.innerWidth <= 1160);
4632         GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
4634         // Backward compatibility
4635         let storedTheme = localStorage.getItem("selected-theme");
4636         if (storedTheme) {
4637                 Appearance.setTheme(storedTheme);
4638                 localStorage.removeItem("selected-theme");
4639         }
4641         // Animate width & theme adjustments?
4642         Appearance.adjustmentTransitions = false;
4643         // Add the content width selector.
4644         Appearance.injectContentWidthSelector();
4645         // Add the text size adjustment widget.
4646         Appearance.injectTextSizeAdjustmentUI();
4647         // Add the theme selector.
4648         Appearance.injectThemeSelector();
4649         // Add the theme tweaker.
4650         Appearance.injectThemeTweaker();
4652         // Add the dark mode selector (if desktop).
4653         if (GW.isMobile == false)
4654                 DarkMode.injectModeSelector();
4656         // Add the quick-nav UI.
4657         injectQuickNavUI();
4659         // Finish initializing when ready.
4660         addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4661 }});
4663 function mainInitializer() {
4664         GWLog("INITIALIZER initialize");
4666         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4667         // "link without nav bars".
4668         if (getQueryVariable("hide-nav-bars") == "true") {
4669                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
4670         }
4672         // If the page cannot have comments, remove the accesskey from the #comments
4673         // quick-nav button; and if the page can have comments, but does not, simply 
4674         // disable the #comments quick nav button.
4675         let content = query("#content");
4676         if (content.query("#comments") == null) {
4677                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4678         } else if (content.query("#comments .comment-thread") == null) {
4679                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4680         }
4682         // On edit post pages and conversation pages, add GUIEdit buttons to the 
4683         // textarea and expand it.
4684         queryAll(".with-markdown-editor textarea").forEach(textarea => {
4685                 textarea.addTextareaFeatures();
4686                 expandTextarea(textarea);
4687         });
4688         // Focus the textarea.
4689         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4691         // If we're on a comment thread page...
4692         if (query(".comments") != null) {
4693                 // Add comment-minimize buttons to every comment.
4694                 queryAll(".comment-meta").forEach(commentMeta => {
4695                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4696                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
4697                 });
4698                 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4699                         // Format and activate comment-minimize buttons.
4700                         queryAll(".comment-minimize-button").forEach(button => {
4701                                 button.closest(".comment-item").setCommentThreadMaximized(false);
4702                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4703                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
4704                                 });
4705                         });
4706                 }
4707         }
4708         if (getQueryVariable("chrono") == "t") {
4709                 insertHeadHTML(`<style> .comment-minimize-button::after { display: none; } </style>`);
4710         }
4712         // On mobile, replace the labels for the checkboxes on the edit post form
4713         // with icons, to save space.
4714         if (GW.isMobile && query(".edit-post-page")) {
4715                 query("label[for='link-post']").innerHTML = "&#xf0c1";
4716                 query("label[for='question']").innerHTML = "&#xf128";
4717         }
4719         // Add error message (as placeholder) if user tries to click Search with
4720         // an empty search field.
4721         searchForm: {
4722                 let searchForm = query("#nav-item-search form");
4723                 if(!searchForm) break searchForm;
4724                 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4725                         let searchField = event.target.query("input");
4726                         if (searchField.value == "") {
4727                                 event.preventDefault();
4728                                 event.target.blur();
4729                                 searchField.placeholder = "Enter a search string!";
4730                                 searchField.focus();
4731                         }
4732                 });
4733                 // Remove the placeholder / error on any input.
4734                 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4735                         event.target.placeholder = "";
4736                 });
4737         }
4739         // Prevent conflict between various single-hotkey listeners and text fields
4740         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4741                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4742                 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4743         });
4745         if (content.hasClass("post-page")) {
4746                 // Read and update last-visited-date.
4747                 let lastVisitedDate = getLastVisitedDate();
4748                 setLastVisitedDate(Date.now());
4750                 // Save the number of comments this post has when it's visited.
4751                 updateSavedCommentCount();
4753                 if (content.query(".comments .comment-thread") != null) {
4754                         // Add the new comments count & navigator.
4755                         injectNewCommentNavUI();
4757                         // Get the highlight-new-since date (as specified by URL parameter, if 
4758                         // present, or otherwise the date of the last visit).
4759                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4761                         // Highlight new comments since the specified date.                      
4762                         let newCommentsCount = highlightCommentsSince(hnsDate);
4764                         // Update the comment count display.
4765                         updateNewCommentNavUI(newCommentsCount, hnsDate);
4766                 }
4767         } else {
4768                 // On listing pages, make comment counts more informative.
4769                 badgePostsWithNewComments();
4770         }
4772         // Add the comments list mode selector widget (expanded vs. compact).
4773         injectCommentsListModeSelector();
4775         // Add the comments view selector widget (threaded vs. chrono).
4776 //      injectCommentsViewModeSelector();
4778         // Add the comments sort mode selector (top, hot, new, old).
4779         if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4781         // Add the toggle for the post nav UI elements on mobile.
4782         if (GW.isMobile) injectPostNavUIToggle();
4784         // Add the toggle for the appearance adjustment UI elements on mobile.
4785         if (GW.isMobile)
4786                 Appearance.injectAppearanceAdjustUIToggle();
4788         // Activate the antikibitzer.
4789         if (GW.useFancyFeatures)
4790                 activateAntiKibitzer();
4792         // Add comment parent popups.
4793         injectPreviewPopupToggle();
4794         addCommentParentPopups();
4796         // Mark original poster's comments with a special class.
4797         markOriginalPosterComments();
4798         
4799         // On the All view, mark posts with non-positive karma with a special class.
4800         if (query("#content").hasClass("all-index-page")) {
4801                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4802                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4804                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4805                 });
4806         }
4808         // Set the "submit" button on the edit post page to something more helpful.
4809         setEditPostPageSubmitButtonText();
4811         // Compute the text of the pagination UI tooltip text.
4812         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4813                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4814         });
4816         // Add event listeners for Escape and Enter, for the theme tweaker.
4817         document.addEventListener("keyup", Appearance.themeTweakerUIKeyPressed);
4819         // Add event listener for . , ; (for navigating listings pages).
4820         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4821         if (!query(".comments") && listings.length > 0) {
4822                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
4823                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4825                         if (event.key == "Escape") {
4826                                 if (document.activeElement.parentElement.hasClass("listing"))
4827                                         document.activeElement.blur();
4828                                 return;
4829                         }
4831                         if (event.key == ';') {
4832                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4833                                         let links = document.activeElement.parentElement.queryAll("a");
4834                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4835                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4836                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4837                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4838                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4839                                 }
4840                                 return;
4841                         }
4843                         var indexOfActiveListing = -1;
4844                         for (i = 0; i < listings.length; i++) {
4845                                 if (document.activeElement.parentElement.hasClass("listing") && 
4846                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4847                                         indexOfActiveListing = i;
4848                                         break;
4849                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
4850                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
4851                                         indexOfActiveListing = i;
4852                                         break;
4853                                 }
4854                         }
4855                         // Remove edit accesskey from currently highlighted post by active user, if applicable.
4856                         if (indexOfActiveListing > -1) {
4857                                 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4858                         }
4859                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4860                         if (indexOfNextListing < listings.length) {
4861                                 listings[indexOfNextListing].focus();
4863                                 if (listings[indexOfNextListing].closest(".comment-item")) {
4864                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4865                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4866                                 }
4867                         } else {
4868                                 document.activeElement.blur();
4869                         }
4870                         // Add edit accesskey to newly highlighted post by active user, if applicable.
4871                         (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4872                 });
4873                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4874                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4875                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4876                         });
4877                 });
4878         }
4879         // Add event listener for ; (to focus the link on link posts).
4880         if (query("#content").hasClass("post-page") && 
4881                 query(".post").hasClass("link-post")) {
4882                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4883                         if (event.key == ';') query("a.link-post-link").focus();
4884                 });
4885         }
4887         // Add accesskeys to user page view selector.
4888         let viewSelector = query("#content.user-page > .sublevel-nav");
4889         if (viewSelector) {
4890                 let currentView = viewSelector.query("span");
4891                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4892                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4893         }
4895         // Add accesskey to index page sort selector.
4896         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4898         // Move MathJax style tags to <head>.
4899         var aggregatedStyles = "";
4900         queryAll("#content style").forEach(styleTag => {
4901                 aggregatedStyles += styleTag.innerHTML;
4902                 removeElement("style", styleTag.parentElement);
4903         });
4904         if (aggregatedStyles != "") {
4905                 insertHeadHTML(`<style id="mathjax-styles"> ${aggregatedStyles} </style>`);
4906         }
4908         /*  Makes double-clicking on a math element select the entire math element.
4909                 (This actually makes no difference to the behavior of the copy listener
4910                  which copies the entire LaTeX source of the full equation no matter how 
4911                  much of said equation is selected when the copy command is sent; 
4912                  however, it ensures that the UI communicates the actual behavior in a 
4913                  more accurate and understandable way.)
4914          */
4915         query("#content").querySelectorAll(".mjpage").forEach(mathBlock => {
4916                 mathBlock.addEventListener("dblclick", (event) => {
4917                         document.getSelection().selectAllChildren(mathBlock.querySelector(".mjx-chtml"));
4918                 });
4919                 mathBlock.title = mathBlock.classList.contains("mjpage__block")
4920                                                   ? "Double-click to select equation, then copy, to get LaTeX source"
4921                                                   : "Double-click to select equation; copy to get LaTeX source";
4922         });
4924         // Add listeners to switch between word count and read time.
4925         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4926         queryAll(".post-meta .read-time").forEach(element => {
4927                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4928                         let displayWordCount = localStorage.getItem("display-word-count");
4929                         toggleReadTimeOrWordCount(!displayWordCount);
4930                         if (displayWordCount) localStorage.removeItem("display-word-count");
4931                         else localStorage.setItem("display-word-count", true);
4932                 });
4933         });
4935         // Set up Image Focus feature.
4936         imageFocusSetup();
4938         // Set up keyboard shortcuts guide overlay.
4939         keyboardHelpSetup();
4941         // Show push notifications button if supported
4942         pushNotificationsSetup();
4944         // Show elements now that javascript is ready.
4945         removeElement("#hide-until-init");
4947         activateTrigger("pageLayoutFinished");
4950 /*************************/
4951 /* POST-LOAD ADJUSTMENTS */
4952 /*************************/
4954 window.addEventListener("pageshow", badgePostsWithNewComments);
4956 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4957         GWLog("INITIALIZER pageLayoutFinished");
4959         Appearance.postSetThemeHousekeeping();
4961         focusImageSpecifiedByURL();
4963         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4964 //      query("input[type='search']").value = GW.isMobile;
4965 //      insertHeadHTML(`<style>
4966 //              @media only screen and (hover:none) { #nav-item-search input { background-color: red; }}
4967 //              @media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}
4968 //      </style>`);
4969 }});
4971 function generateImagesOverlay() {
4972         GWLog("generateImagesOverlay");
4973         // Don’t do this on the about page.
4974         if (query(".about-page") != null) return;
4975         return;
4977         // Remove existing, if any.
4978         removeElement("#images-overlay");
4980         // Create new.
4981         document.body.insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4982         let imagesOverlay = query("#images-overlay");
4983         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4984         queryAll(".post-body img").forEach(image => {
4985                 let clonedImageContainer = newElement("DIV");
4987                 let clonedImage = image.cloneNode(true);
4988                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4989                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4990                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4991                 clonedImageContainer.appendChild(clonedImage);
4993                 let zoomLevel = Appearance.currentTextZoom;
4995                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4996                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4997                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4998                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
5000                 imagesOverlay.appendChild(clonedImageContainer);
5001         });
5003         // Add the event listeners to focus each image.
5004         imageFocusSetup(true);
5007 function adjustUIForWindowSize() {
5008         GWLog("adjustUIForWindowSize");
5009         var bottomBarOffset;
5011         // Adjust bottom bar state.
5012         let bottomBar = query("#bottom-bar");
5013         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5014         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
5015                 bottomBar.removeClass("decorative");
5017                 bottomBar.query("#nav-item-top").style.display = "";
5018         } else if (bottomBar) {
5019                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
5020                 else bottomBar.addClass("decorative");
5022                 bottomBar.query("#nav-item-top").style.display = "none";
5023         }
5025         // Show quick-nav UI up/down buttons if content is taller than window.
5026         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5027         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
5028                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
5029         });
5031         // Move anti-kibitzer toggle if content is very short.
5032         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
5034         // Update the visibility of the post nav UI.
5035         updatePostNavUIVisibility();
5038 function recomputeUIElementsContainerHeight(force = false) {
5039         GWLog("recomputeUIElementsContainerHeight");
5040         if (!GW.isMobile &&
5041                 (force || query("#ui-elements-container").style.height != "")) {
5042                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
5043                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
5044                                                                                                                 query("#content").clientHeight + "px" :
5045                                                                                                                 "100vh";
5046         }
5049 function focusImageSpecifiedByURL() {
5050         GWLog("focusImageSpecifiedByURL");
5051         if (location.hash.hasPrefix("#if_slide_")) {
5052                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
5053                         let images = queryAll(GW.imageFocus.overlayImagesSelector);
5054                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
5055                         if (imageToFocus > 0 && imageToFocus <= images.length) {
5056                                 focusImage(images[imageToFocus - 1]);
5058                                 // Set timer to hide the image focus UI.
5059                                 unhideImageFocusUI();
5060                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
5061                         }
5062                 });
5063         }
5066 /***********/
5067 /* GUIEDIT */
5068 /***********/
5070 function insertMarkup(event) {
5071         var mopen = '', mclose = '', mtext = '', func = false;
5072         if (typeof arguments[1] == 'function') {
5073                 func = arguments[1];
5074         } else {
5075                 mopen = arguments[1];
5076                 mclose = arguments[2];
5077                 mtext = arguments[3];
5078         }
5080         var textarea = event.target.closest("form").query("textarea");
5081         textarea.focus();
5082         var p0 = textarea.selectionStart;
5083         var p1 = textarea.selectionEnd;
5084         var cur0 = cur1 = p0;
5086         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
5087         str = func ? func(str, p0) : (mopen + str + mclose);
5089         // Determine selection.
5090         if (!func) {
5091                 cur0 += (p0 == p1) ? mopen.length : str.length;
5092                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
5093         } else {
5094                 cur0 = str[1];
5095                 cur1 = str[2];
5096                 str = str[0];
5097         }
5099         // Update textarea contents.
5100         document.execCommand("insertText", false, str);
5102         // Expand textarea, if needed.
5103         expandTextarea(textarea);
5105         // Set selection.
5106         textarea.selectionStart = cur0;
5107         textarea.selectionEnd = cur1;
5109         return;
5112 GW.guiEditButtons = [
5113         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
5114         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
5115         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
5116         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
5117         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
5118         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
5119         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
5120         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
5121         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
5122         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
5123         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
5124         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
5125         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
5126         [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
5127         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
5130 function blockquote(text, startpos) {
5131         if (text == '') {
5132                 text = "> Quoted text";
5133                 return [ text, startpos + 2, startpos + text.length ];
5134         } else {
5135                 text = "> " + text.split("\n").join("\n> ") + "\n";
5136                 return [ text, startpos + text.length, startpos + text.length ];
5137         }
5140 function hyperlink(text, startpos) {
5141         var url = '', link_text = text, endpos = startpos;
5142         if (text.search(/^https?/) != -1) {
5143                 url = text;
5144                 link_text = "link text";
5145                 startpos = startpos + 1;
5146                 endpos = startpos + link_text.length;
5147         } else {
5148                 url = prompt("Link address (URL):");
5149                 if (!url) {
5150                         endpos = startpos + text.length;
5151                         return [ text, startpos, endpos ];
5152                 }
5153                 startpos = startpos + text.length + url.length + 4;
5154                 endpos = startpos;
5155         }
5157         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
5160 /******************/
5161 /* SERVICE WORKER */
5162 /******************/
5164 if(navigator.serviceWorker) {
5165         navigator.serviceWorker.register('/service-worker.js');
5166         setCookie("push", "t");
5169 /*********************/
5170 /* USER AUTOCOMPLETE */
5171 /*********************/
5173 function zLowerUIElements() {
5174         let uiElementsContainer = query("#ui-elements-container");
5175         if (uiElementsContainer)
5176                 uiElementsContainer.style.zIndex = "1";
5179 function zRaiseUIElements() {
5180         let uiElementsContainer = query("#ui-elements-container");
5181         if (uiElementsContainer)
5182                 uiElementsContainer.style.zIndex = "";
5185 var userAutocomplete = null;
5187 function abbreviatedInterval(date) {
5188         let seconds = Math.floor((new Date() - date) / 1000);
5189         let days = Math.floor(seconds / (60 * 60 * 24));
5190         let years = Math.floor(days / 365);
5191         if (years)
5192                 return years + "y";
5193         else if (days)
5194                 return days + "d";
5195         else
5196                 return "today";
5199 function beginAutocompletion(control, startIndex, endIndex) {
5200         if(userAutocomplete) abortAutocompletion(userAutocomplete);
5202         let complete = { control: control,
5203                          abortController: new AbortController(),
5204                          fetchAbortController: new AbortController(),
5205                          container: document.createElement("div") };
5207         endIndex = endIndex || control.selectionEnd;
5208         let valueLength = control.value.length;
5210         complete.container.className = "autocomplete-container "
5211                                                                  + "right "
5212                                                                  + (window.innerWidth > 1280
5213                                                                         ? "outside"
5214                                                                         : "inside");
5215         control.insertAdjacentElement("afterend", complete.container);
5216         zLowerUIElements();
5218         let makeReplacer = (userSlug, displayName) => {
5219                 return () => {
5220                         let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
5221                         control.value = control.value.substring(0, startIndex - 1) +
5222                                 replacement +
5223                                 control.value.substring(endIndex);
5224                         abortAutocompletion(complete);
5225                         complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
5226                         complete.control.focus();
5227                 };
5228         };
5230         let switchHighlight = (newHighlight) => {
5231                 if (!newHighlight)
5232                         return;
5234                 complete.highlighted.removeClass("highlighted");
5235                 newHighlight.addClass("highlighted");
5236                 complete.highlighted = newHighlight;
5238                 //      Scroll newly highlighted item into view, if need be.
5239                 if (  complete.highlighted.offsetTop + complete.highlighted.offsetHeight 
5240                         > complete.container.scrollTop + complete.container.clientHeight) {
5241                         complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
5242                 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
5243                         complete.container.scrollTo(0, complete.highlighted.offsetTop);
5244                 }
5245         };
5246         let highlightNext = () => {
5247                 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
5248         };
5249         let highlightPrev = () => {
5250                 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
5251         };
5253         let updateCompletions = () => {
5254                 let fragment = control.value.substring(startIndex, endIndex);
5256                 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
5257                       {signal: complete.fetchAbortController.signal})
5258                         .then((res) => res.json())
5259                         .then((res) => {
5260                                 if(res.error) return;
5261                                 if(res.length == 0) return abortAutocompletion(complete);
5263                                 complete.container.innerHTML = "";
5264                                 res.forEach(entry => {
5265                                         let entryContainer = document.createElement("div");
5266                                         [ [ entry.displayName, "name" ],
5267                                           [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
5268                                           [ (entry.karma || 0) + " karma", "karma" ]
5269                                         ].forEach(x => {
5270                                                 let e = document.createElement("span");
5271                                                 e.append(x[0]);
5272                                                 e.className = x[1];
5273                                                 entryContainer.append(e);
5274                                         });
5275                                         entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
5276                                         complete.container.append(entryContainer);
5277                                 });
5278                                 complete.highlighted = complete.container.children[0];
5279                                 complete.highlighted.classList.add("highlighted");
5280                                 complete.container.scrollTo(0, 0);
5281                                 })
5282                         .catch((e) => {});
5283         };
5285         document.body.addEventListener("click", (event) => {
5286                 if (!complete.container.contains(event.target)) {
5287                         abortAutocompletion(complete);
5288                         event.preventDefault();
5289                         event.stopPropagation();
5290                 }
5291         }, {signal: complete.abortController.signal,
5292             capture: true});
5293         
5294         control.addEventListener("keydown", (event) => {
5295                 switch (event.key) {
5296                 case "Escape":
5297                         abortAutocompletion(complete);
5298                         event.preventDefault();
5299                         return;
5300                 case "ArrowUp":
5301                         highlightPrev();
5302                         event.preventDefault();
5303                         return;
5304                 case "ArrowDown":
5305                         highlightNext();
5306                         event.preventDefault();
5307                         return;
5308                 case "Tab":
5309                         if (event.shiftKey)
5310                                 highlightPrev();
5311                         else
5312                                 highlightNext();
5313                         event.preventDefault();
5314                         return;
5315                 case "Enter":
5316                         complete.highlighted.onclick();
5317                         event.preventDefault();
5318                         return;
5319                 }
5320         }, {signal: complete.abortController.signal});
5322         control.addEventListener("selectionchange", (event) => {
5323                 if (control.selectionStart < startIndex ||
5324                     control.selectionEnd > endIndex) {
5325                         abortAutocompletion(complete);
5326                 }
5327         }, {signal: complete.abortController.signal});
5328         
5329         control.addEventListener("input", (event) => {
5330                 complete.fetchAbortController.abort();
5331                 complete.fetchAbortController = new AbortController();
5333                 endIndex += control.value.length - valueLength;
5334                 valueLength = control.value.length;
5336                 if (endIndex < startIndex) {
5337                         abortAutocompletion(complete);
5338                         return;
5339                 }
5340                 
5341                 updateCompletions();
5342         }, {signal: complete.abortController.signal});
5344         userAutocomplete = complete;
5346         if(startIndex != endIndex) updateCompletions();
5349 function abortAutocompletion(complete) {
5350         complete.fetchAbortController.abort();
5351         complete.abortController.abort();
5352         complete.container.remove();
5353         userAutocomplete = null;
5354         zRaiseUIElements();