Fix bug when copying from a text input field.
[lw2-viewer.git] / www / script.js
blob7f7125406961c96c03d7ba4768e1285fe285083a
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                 // Don't apply copy processors to input fields.
318                 if (({'TEXTAREA': true, 'INPUT': true})[document.activeElement.tagName]) {
319                         return;
320                 }
322                 event.preventDefault();
323                 event.stopPropagation();
325                 let selection = getSelectionAsDocument(doc);
327                 let i = 0;
328                 while (   i < GW.copyProcessors.length
329                           && GW.copyProcessors[i++](event, selection));
331                 // This is necessary for .innerText to work properly.
332                 let wrapper = newElement("DIV");
333                 wrapper.appendChild(selection);
334                 document.body.appendChild(wrapper);
336                 let makeLinksAbsolute = (node) => {
337                         if(node['attributes']) {
338                                 for(attr of ['src', 'href']) {
339                                         if(node[attr])
340                                                 node[attr] = node[attr];
341                                 }
342                         }
343                         node.childNodes.forEach(makeLinksAbsolute);
344                 }
345                 makeLinksAbsolute(wrapper);
347                 event.clipboardData.setData("text/plain", wrapper.innerText);
348                 event.clipboardData.setData("text/html", wrapper.innerHTML);
350                 document.body.removeChild(wrapper);
351         });
354 /*******************************************/
355 /*  Set up copy processors in main document.
356  */
357 registerCopyProcessorsForDocument(document);
359 /*****************************************************************************/
360 /*  Makes it so that copying a rendered equation or other math element copies
361     the LaTeX source, instead of the useless gibberish that is the contents of
362     the text nodes of the HTML representation of the equation.
363  */
364 addCopyProcessor((event, selection) => {
365         if (event.target.closest(".mjx-math")) {
366                 selection.replaceChildren(event.target.closest(".mjx-math").getAttribute("aria-label"));
368                 return false;
369         }
371         selection.querySelectorAll(".mjx-chtml").forEach(mathBlock => {
372                 mathBlock.innerHTML = " " + mathBlock.querySelector(".mjx-math").getAttribute("aria-label") + " ";
373         });
375         return true;
378 /************************************************************************/
379 /*  Remove soft hyphens and other extraneous characters from copied text.
380  */
381 addCopyProcessor((event, selection) => {
382         let replaceText = (node) => {
383                 if(node.nodeType == Node.TEXT_NODE) {
384                         node.nodeValue = node.nodeValue.replace(/\u00AD|\u200b/g, "");
385                 }
387                 node.childNodes.forEach(replaceText);
388         }
389         replaceText(selection);
391         return true;
395 /********************/
396 /* DEBUGGING OUTPUT */
397 /********************/
399 GW.enableLogging = (permanently = false) => {
400         if (permanently)
401                 localStorage.setItem("logging-enabled", "true");
402         else
403                 GW.loggingEnabled = true;
405 GW.disableLogging = (permanently = false) => {
406         if (permanently)
407                 localStorage.removeItem("logging-enabled");
408         else
409                 GW.loggingEnabled = false;
412 /*******************/
413 /* INBOX INDICATOR */
414 /*******************/
416 function processUserStatus(userStatus) {
417         window.userStatus = userStatus;
418         if(userStatus) {
419                 if(userStatus.notifications) {
420                         let element = query('#inbox-indicator');
421                         element.className = 'new-messages';
422                         element.title = 'New messages [o]';
423                 }
424         } else {
425                 location.reload();
426         }
429 /**************/
430 /* COMMENTING */
431 /**************/
433 function toggleMarkdownHintsBox() {
434         GWLog("toggleMarkdownHintsBox");
435         let markdownHintsBox = query("#markdown-hints");
436         markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
438 function hideMarkdownHintsBox() {
439         GWLog("hideMarkdownHintsBox");
440         let markdownHintsBox = query("#markdown-hints");
441         if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
444 Element.prototype.addTextareaFeatures = function() {
445         GWLog("addTextareaFeatures");
446         let textarea = this;
448         textarea.addEventListener("focus", GW.textareaFocused = (event) => {
449                 GWLog("GW.textareaFocused");
450                 event.target.closest("form").scrollIntoViewIfNeeded();
451         });
452         textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
453                 GWLog("GW.textareaInputReceived");
454                 if (window.innerWidth > 520) {
455                         // Expand textarea if needed.
456                         expandTextarea(textarea);
457                 } else {
458                         // Remove markdown hints.
459                         hideMarkdownHintsBox();
460                         query(".guiedit-mobile-help-button").removeClass("active");
461                 }
462                 // User mentions autocomplete
463                 if(!userAutocomplete &&
464                    textarea.value.charAt(textarea.selectionStart - 1) === "@" &&
465                    (textarea.selectionStart === 1 ||
466                     !textarea.value.charAt(textarea.selectionStart - 2).match(/[a-zA-Z0-9]/))) {
467                         beginAutocompletion(textarea, textarea.selectionStart);
468                 }
469         }, false);
470         textarea.addEventListener("click", (event) => {
471                 if(!userAutocomplete) {
472                         let start = textarea.selectionStart, end = textarea.selectionEnd;
473                         let value = textarea.value;
474                         if (start <= 1) return;
475                         for (; value.charAt(start - 1) != "@"; start--) {
476                                 if (start <= 1) return;
477                                 if (value.charAt(start - 1) == " ") return;
478                         }
479                         for(; end < value.length && value.charAt(end) != " "; end++) { true }
480                         beginAutocompletion(textarea, start, end);
481                 }
482         });
484         textarea.addEventListener("paste", (event) => {
485                 let html = event.clipboardData.getData("text/html");
486                 if(html) {
487                         html = html.replace(/\n|\r/gm, "");
488                         let isQuoted = textarea.selectionStart >= 2 &&
489                             textarea.value.substring(textarea.selectionStart - 2, textarea.selectionStart) == "> ";
490                         document.execCommand("insertText", false, MarkdownFromHTML(html, (isQuoted ? "> " : null)));
491                         event.preventDefault();
492                 }
493         });
495         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
496         textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
497         textarea.addEventListener("keydown", (event) => {
498                 // Special case for alt+4
499                 // Generalize this before adding more.
500                 if(event.altKey && event.key === '4') {
501                         insertMarkup(event, "$", "$", "LaTeX formula");
502                         event.stopPropagation();
503                         event.preventDefault();
504                 }
505         });
507         let form = textarea.closest("form");
509         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
510         let textareaContainer = textarea.closest(".textarea-container");
511         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
512         for (var button of GW.guiEditButtons) {
513                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
514                 buttons_container.insertAdjacentHTML("beforeend", 
515                         "<button type='button' class='guiedit guiedit-" 
516                         + name
517                         + "' tabindex='-1'"
518                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
519                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
520                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
521                         + " onclick='insertMarkup(event,"
522                         + ((typeof m_before_or_func == 'function') ?
523                                 m_before_or_func.name : 
524                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
525                         + ");'><div>"
526                         + icon
527                         + "</div></button>"
528                 );
529         }
531         var markdown_hints = 
532         `<input type='checkbox' id='markdown-hints-checkbox'>
533         <label for='markdown-hints-checkbox'></label>
534         <div id='markdown-hints'>` + 
535         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
536                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
537                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
538                 "<span>Heading 1</span><code># Heading 1</code>",
539                 "<span>Heading 2</span><code>## Heading 1</code>",
540                 "<span>Heading 3</span><code>### Heading 1</code>",
541                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
542         `</div>`;
543         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
545         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
546                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
547                         GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
548                         if (button.hasClass("guiedit-mobile-help-button")) {
549                                 toggleMarkdownHintsBox();
550                                 event.target.toggleClass("active");
551                                 query(".posting-controls:focus-within textarea").focus();
552                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
553                                 event.target.blur();
554                                 hideMarkdownHintsBox();
555                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
556                         }
557                 });
558         });
560         // On smartphone (narrow mobile) screens, when a textarea is focused (and
561         // automatically fullscreened), remove all the filters from the page, and 
562         // then apply them *just* to the fixed editor UI elements. This is in order
563         // to get around the “children of elements with a filter applied cannot be
564         // fixed” issue.
565         if (GW.isMobile && window.innerWidth <= 520) {
566                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
567                 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
568                         GWLog("GW.textareaFocusedMobile");
569                         Appearance.savedFilters = Appearance.currentFilters;
570                         Appearance.applyFilters(Appearance.noFilters);
571                         fixedEditorElements.forEach(element => {
572                                 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
573                         });
574                 });
575                 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
576                         GWLog("GW.textareaBlurredMobile");
577                         requestAnimationFrame(() => {
578                                 Appearance.applyFilters(Appearance.savedFilters);
579                                 Appearance.savedFilters = null;
580                                 fixedEditorElements.forEach(element => {
581                                         element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
582                                 });
583                         });
584                 });
585         }
588 Element.prototype.injectReplyForm = function(editMarkdownSource) {
589         GWLog("injectReplyForm");
590         let commentControls = this;
591         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
592         let postId = commentControls.parentElement.dataset["postId"];
593         let tagId = commentControls.parentElement.dataset["tagId"];
594         let withparent = (!editMarkdownSource && commentControls.getCommentId());
595         let answer = commentControls.parentElement.id == "answers";
596         let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
597         let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
598         let parentCommentItem = commentControls.closest(".comment-item");
599         let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
600             (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
601         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
602                 "<form method='post'>" + 
603                 "<div class='textarea-container'>" + 
604                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
605                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
606                 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
607                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
608                 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
609                 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
610                 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
611                 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
612                 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
613                 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
614                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
615                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
616                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
617                 "</div><div>" + 
618                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
619                 "<input type='submit' value='Submit'>" + 
620                 "</div></form>";
621         commentControls.onsubmit = disableBeforeUnload;
623         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
624                 GWLog("GW.cancelCommentButtonClicked");
625                 hideReplyForm(event.target.closest(".comment-controls"));
626         });
627         commentControls.scrollIntoViewIfNeeded();
628         commentControls.query("form").onsubmit = (event) => {
629                 if (!event.target.text.value) {
630                         alert("Please enter a comment.");
631                         return false;
632                 }
633         }
634         let textarea = commentControls.query("textarea");
635         if(editMarkdownSource) textarea.value = editMarkdownSource;
636         textarea.addTextareaFeatures();
637         textarea.focus();
640 function showCommentEditForm(commentItem) {
641         GWLog("showCommentEditForm");
643         let commentBody = commentItem.query(".comment-body");
644         commentBody.style.display = "none";
646         let commentControls = commentItem.query(".comment-controls");
647         commentControls.injectReplyForm(commentBody.dataset.markdownSource);
648         commentControls.query("form").addClass("edit-existing-comment");
649         expandTextarea(commentControls.query("textarea"));
652 function showReplyForm(commentItem) {
653         GWLog("showReplyForm");
655         let commentControls = commentItem.query(".comment-controls");
656         commentControls.injectReplyForm(commentControls.dataset.enteredText);
659 function hideReplyForm(commentControls) {
660         GWLog("hideReplyForm");
661         // Are we editing a comment? If so, un-hide the existing comment body.
662         let containingComment = commentControls.closest(".comment-item");
663         if (containingComment) containingComment.query(".comment-body").style.display = "";
665         let enteredText = commentControls.query("textarea").value;
666         if (enteredText) commentControls.dataset.enteredText = enteredText;
668         disableBeforeUnload();
669         commentControls.constructCommentControls();
672 function expandTextarea(textarea) {
673         GWLog("expandTextarea");
674         if (window.innerWidth <= 520) return;
676         let totalBorderHeight = 30;
677         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
679         requestAnimationFrame(() => {
680                 textarea.style.height = 'auto';
681                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
682                 if (textarea.clientHeight < window.innerHeight) {
683                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
684                 }
685         });
688 function doCommentAction(action, commentItem) {
689         GWLog("doCommentAction");
690         let params = {};
691         params[(action + "-comment-id")] = commentItem.getCommentId();
692         doAjax({
693                 method: "POST",
694                 params: params,
695                 onSuccess: GW.commentActionPostSucceeded = (event) => {
696                         GWLog("GW.commentActionPostSucceeded");
697                         let fn = {
698                                 retract: () => { commentItem.firstChild.addClass("retracted") },
699                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
700                                 delete: () => {
701                                         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>";
702                                         commentItem.removeChild(commentItem.query(".comment-controls"));
703                                 }
704                         }[action];
705                         if(fn) fn();
706                         if(action != "delete")
707                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
708                 }
709         });
712 /**********/
713 /* VOTING */
714 /**********/
716 function parseVoteType(voteType) {
717         GWLog("parseVoteType");
718         let value = {};
719         if (!voteType) return value;
720         value.up = /[Uu]pvote$/.test(voteType);
721         value.down = /[Dd]ownvote$/.test(voteType);
722         value.big = /^big/.test(voteType);
723         return value;
726 function makeVoteType(value) {
727         GWLog("makeVoteType");
728         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
731 function makeVoteClass(vote) {
732         GWLog("makeVoteClass");
733         if (vote.up || vote.down) {
734                 return (vote.big ? 'selected big-vote' : 'selected');
735         } else {
736                 return '';
737         }
740 function findVoteControls(targetType, targetId, voteAxis) {
741         var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
743         if(targetType == "Post") {
744                 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
745         } else if(targetType == "Comment") {
746                 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
747         }
750 function votesEqual(vote1, vote2) {
751         var allKeys = Object.assign({}, vote1);
752         Object.assign(allKeys, vote2);
754         for(k of allKeys.keys()) {
755                 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
756         }
757         return true;
760 function addVoteButtons(element, vote, targetType) {
761         GWLog("addVoteButtons");
762         vote = vote || {};
763         let voteAxis = element.parentElement.dataset.voteAxis || "karma";
764         let voteType = parseVoteType(vote[voteAxis]);
765         let voteClass = makeVoteClass(voteType);
767         element.parentElement.queryAll("button").forEach((button) => {
768                 button.disabled = false;
769                 if (voteType) {
770                         if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
771                                 button.addClass(voteClass);
772                 }
773                 updateVoteButtonVisualState(button);
774                 button.addActivateEvent(voteButtonClicked);
775         });
778 function updateVoteButtonVisualState(button) {
779         GWLog("updateVoteButtonVisualState");
781         button.removeClasses([ "none", "one", "two-temp", "two" ]);
783         if (button.disabled)
784                 button.addClass("none");
785         else if (button.hasClass("big-vote"))
786                 button.addClass("two");
787         else if (button.hasClass("selected"))
788                 button.addClass("one");
789         else
790                 button.addClass("none");
793 function changeVoteButtonVisualState(button) {
794         GWLog("changeVoteButtonVisualState");
796         /*      Interaction states are:
798                 0  0·    (neutral; +1 click)
799                 1  1·    (small vote; +1 click)
800                 2  2·    (big vote; +1 click)
802                 Visual states are (with their state classes in [brackets]) are:
804                 01    (no vote) [none]
805                 02    (small vote active) [one]
806                 12    (small vote active, temporary indicator of big vote) [two-temp]
807                 22    (big vote active) [two]
809                 The following are the 9 possible interaction state transitions (and
810                 the visual state transitions associated with them):
812                                 VIS.    VIS.
813                 FROM    TO      FROM    TO      NOTES
814                 ====    ====    ====    ====    =====
815                 0       0·      01      12      first click
816                 0·      1       12      02      one click without second
817                 0·      2       12      22      second click
819                 1       1·      02      12      first click
820                 1·      0       12      01      one click without second
821                 1·      2       12      22      second click
823                 2       2·      22      12      first click
824                 2·      1       12      02      one click without second
825                 2·      0       12      01      second click
826         */
827         let transitions = [
828                 [ "big-vote two-temp clicked-twice", "none"     ], // 2· => 0
829                 [ "big-vote two-temp clicked-once",  "one"      ], // 2· => 1
830                 [ "big-vote clicked-once",           "two-temp" ], // 2  => 2·
832                 [ "selected two-temp clicked-twice", "two"      ], // 1· => 2
833                 [ "selected two-temp clicked-once",  "none"     ], // 1· => 0
834                 [ "selected clicked-once",           "two-temp" ], // 1  => 1·
836                 [ "two-temp clicked-twice",          "two"      ], // 0· => 2
837                 [ "two-temp clicked-once",           "one"      ], // 0· => 1
838                 [ "clicked-once",                    "two-temp" ], // 0  => 0·
839         ];
840         for (let [ interactionClasses, visualStateClass ] of transitions) {
841                 if (button.hasClasses(interactionClasses.split(" "))) {
842                         button.removeClasses([ "none", "one", "two-temp", "two" ]);
843                         button.addClass(visualStateClass);
844                         break;
845                 }
846         }
849 function voteCompleteEvent(targetType, targetId, response) {
850         GWLog("voteCompleteEvent");
852         var currentVote = voteData[targetType][targetId] || {};
853         var desiredVote = voteDesired[targetType][targetId];
855         var controls = findVoteControls(targetType, targetId);
856         var controlsByAxis = new Object;
858         controls.forEach(control => {
859                 const voteAxis = (control.dataset.voteAxis || "karma");
861                 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
862                         control.removeClass("waiting");
863                         control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
864                 }
866                 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
867                 controlsByAxis[voteAxis].push(control);
869                 const voteType = currentVote[voteAxis];
870                 const vote = parseVoteType(voteType);
871                 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
872                 const voteClass = makeVoteClass(vote);
874                 if (response && response[voteAxis]) {
875                         const [voteType, displayText, titleText] = response[voteAxis];
877                         const displayTarget = control.query(".karma-value");
878                         if (displayTarget.hasClass("redacted")) {
879                                 displayTarget.dataset["trueValue"] = displayText;
880                         } else {
881                                 displayTarget.innerHTML = displayText;
882                         }
883                         displayTarget.setAttribute("title", titleText);
884                 }
886                 control.queryAll("button.vote").forEach(button => {
887                         updateVoteButton(button, voteUpDown, voteClass);
888                 });
889         });
892 function updateVoteButton(button, voteUpDown, voteClass) {
893         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
894         if (button.dataset.voteType == voteUpDown)
895                 button.addClass(voteClass);
896         updateVoteButtonVisualState(button);
899 function makeVoteRequestCompleteEvent(targetType, targetId) {
900         return (event) => {
901                 var currentVote = {};
902                 var response = null;
904                 if (event.target.status == 200) {
905                         response = JSON.parse(event.target.responseText);
906                         for (const voteAxis of response.keys()) {
907                                 currentVote[voteAxis] = response[voteAxis][0];
908                         }
909                         voteData[targetType][targetId] = currentVote;
910                 } else {
911                         delete voteDesired[targetType][targetId];
912                         currentVote = voteData[targetType][targetId];
913                 }
915                 var desiredVote = voteDesired[targetType][targetId];
917                 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
918                         sendVoteRequest(targetType, targetId);
919                 } else {
920                         delete voteDesired[targetType][targetId];
921                         voteCompleteEvent(targetType, targetId, response);
922                 }
923         }
926 function sendVoteRequest(targetType, targetId) {
927         GWLog("sendVoteRequest");
929         doAjax({
930                 method: "POST",
931                 location: "/karma-vote",
932                 params: { "target": targetId,
933                           "target-type": targetType,
934                           "vote": JSON.stringify(voteDesired[targetType][targetId]) },
935                 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
936         });
939 function voteButtonClicked(event) {
940         GWLog("voteButtonClicked");
941         let voteButton = event.target;
943         // 500 ms (0.5 s) double-click timeout.
944         let doubleClickTimeout = 500;
946         if (!voteButton.clickedOnce) {
947                 voteButton.clickedOnce = true;
948                 voteButton.addClass("clicked-once");
949                 changeVoteButtonVisualState(voteButton);
951                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
952                         if (!voteButton.clickedOnce) return;
954                         // Do single-click code.
955                         voteButton.clickedOnce = false;
956                         voteEvent(voteButton, 1);
957                 }, doubleClickTimeout, voteButton);
958         } else {
959                 voteButton.clickedOnce = false;
961                 // Do double-click code.
962                 voteButton.removeClass("clicked-once");
963                 voteButton.addClass("clicked-twice");
964                 voteEvent(voteButton, 2);
965         }
968 function voteEvent(voteButton, numClicks) {
969         GWLog("voteEvent");
970         voteButton.blur();
972         let voteControl = voteButton.parentNode;
974         let targetType = voteButton.dataset.targetType;
975         let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
976         let voteAxis = voteControl.dataset.voteAxis || "karma";
977         let voteUpDown = voteButton.dataset.voteType;
979         let voteType;
980         if (   (numClicks == 2 && voteButton.hasClass("big-vote"))
981                 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
982                 voteType = "neutral";
983         } else {
984                 let vote = parseVoteType(voteUpDown);
985                 vote.big = (numClicks == 2);
986                 voteType = makeVoteType(vote);
987         }
989         let voteControls = findVoteControls(targetType, targetId, voteAxis);
990         for (const voteControl of voteControls) {
991                 voteControl.addClass("waiting");
992                 voteControl.queryAll(".vote").forEach(button => {
993                         button.addClass("waiting");
994                         updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
995                 });
996         }
998         let voteRequestPending = voteDesired[targetType][targetId];
999         let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
1000         voteObject[voteAxis] = voteType;
1001         voteDesired[targetType][targetId] = voteObject;
1003         if (!voteRequestPending) sendVoteRequest(targetType, targetId);
1006 function initializeVoteButtons() {
1007         // Color the upvote/downvote buttons with an embedded style sheet.
1008         insertHeadHTML(`<style id="vote-buttons">
1009                 :root {
1010                         --GW-upvote-button-color: #00d800;
1011                         --GW-downvote-button-color: #eb4c2a;
1012                 }
1013         </style>`);
1016 function processVoteData(voteData) {
1017         window.voteData = voteData;
1019         window.voteDesired = new Object;
1020         for(key of voteData.keys()) {
1021                 voteDesired[key] = new Object;
1022         }
1024         initializeVoteButtons();
1025         
1026         addTriggerListener("postLoaded", {priority: 3000, fn: () => {
1027                 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
1028                         let postID = karmaValue.parentNode.dataset.postId;
1029                         addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
1030                         karmaValue.parentElement.addClass("active-controls");
1031                 });
1032         }});
1034         addTriggerListener("DOMReady", {priority: 3000, fn: () => {
1035                 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
1036                         let commentID = karmaValue.getCommentId();
1037                         addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
1038                         karmaValue.parentElement.addClass("active-controls");
1039                 });
1040         }});
1043 /*****************************************/
1044 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
1045 /*****************************************/
1047 Element.prototype.getCommentDate = function() {
1048         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
1049         let dateElement = item && item.query(".date");
1050         return (dateElement && parseInt(dateElement.dataset["jsDate"]));
1052 function getCurrentVisibleComment() {
1053         let px = window.innerWidth/2, py = 5;
1054         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
1055         let bottomBar = query("#bottom-bar");
1056         let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : document.body.getBoundingClientRect().bottom);
1057         let atbottom =  bottomOffset <= window.innerHeight;
1058         if (atbottom) {
1059                 let hashci = location.hash && query(location.hash);
1060                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
1061                         commentItem = hashci;
1062                 }
1063         }
1064         return commentItem;
1067 function highlightCommentsSince(date) {
1068         GWLog("highlightCommentsSince");
1069         var newCommentsCount = 0;
1070         GW.newComments = [ ];
1071         let oldCommentsStack = [ ];
1072         let prevNewComment;
1073         queryAll(".comment-item").forEach(commentItem => {
1074                 commentItem.prevNewComment = prevNewComment;
1075                 commentItem.nextNewComment = null;
1076                 if (commentItem.getCommentDate() > date) {
1077                         commentItem.addClass("new-comment");
1078                         newCommentsCount++;
1079                         GW.newComments.push(commentItem.getCommentId());
1080                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
1081                         oldCommentsStack = [ commentItem ];
1082                         prevNewComment = commentItem;
1083                 } else {
1084                         commentItem.removeClass("new-comment");
1085                         oldCommentsStack.push(commentItem);
1086                 }
1087         });
1089         GW.newCommentScrollSet = (commentItem) => {
1090                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
1091                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
1092         };
1093         GW.newCommentScrollListener = () => {
1094                 let commentItem = getCurrentVisibleComment();
1095                 GW.newCommentScrollSet(commentItem);
1096         }
1098         addScrollListener(GW.newCommentScrollListener);
1100         if (document.readyState=="complete") {
1101                 GW.newCommentScrollListener();
1102         } else {
1103                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
1104                 GW.newCommentScrollSet(commentItem);
1105         }
1107         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
1109         return newCommentsCount;
1112 function scrollToNewComment(next) {
1113         GWLog("scrollToNewComment");
1114         let commentItem = getCurrentVisibleComment();
1115         let targetComment = null;
1116         let targetCommentID = null;
1117         if (commentItem) {
1118                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
1119                 if (targetComment) {
1120                         targetCommentID = targetComment.getCommentId();
1121                 }
1122         } else {
1123                 if (GW.newComments[0]) {
1124                         targetCommentID = GW.newComments[0];
1125                         targetComment = query("#comment-" + targetCommentID);
1126                 }
1127         }
1128         if (targetComment) {
1129                 expandAncestorsOf(targetCommentID);
1130                 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
1131                 targetComment.scrollIntoView();
1132         }
1134         GW.newCommentScrollListener();
1137 function getPostHash() {
1138         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
1139         return (postHash ? postHash[1] : false);
1141 function setHistoryLastVisitedDate(date) {
1142         window.history.replaceState({ lastVisited: date }, null);
1144 function getLastVisitedDate() {
1145         // Get the last visited date (or, if posting a comment, the previous last visited date).
1146         if(window.history.state) return (window.history.state||{})['lastVisited'];
1147         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1148         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
1149         let currentVisited = localStorage.getItem(storageName);
1150         setHistoryLastVisitedDate(currentVisited);
1151         return currentVisited;
1153 function setLastVisitedDate(date) {
1154         GWLog("setLastVisitedDate");
1155         // If NOT posting a comment, save the previous value for the last-visited-date 
1156         // (to recover it in case of posting a comment).
1157         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1158         if (!aCommentHasJustBeenPosted) {
1159                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
1160                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
1161         }
1163         // Set the new value.
1164         localStorage.setItem("last-visited-date_" + getPostHash(), date);
1167 function updateSavedCommentCount() {
1168         GWLog("updateSavedCommentCount");
1169         let commentCount = queryAll(".comment").length;
1170         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
1172 function badgePostsWithNewComments() {
1173         GWLog("badgePostsWithNewComments");
1174         if (getQueryVariable("show") == "conversations") return;
1176         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
1177                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
1179                 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
1180                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1181                 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1183                 if (currentCommentCount > savedCommentCount)
1184                         commentCountDisplay.addClass("new-comments");
1185                 else
1186                         commentCountDisplay.removeClass("new-comments");
1187                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1188         });
1192 /*****************/
1193 /* MEDIA QUERIES */
1194 /*****************/
1196 GW.mediaQueries = {
1197     systemDarkModeActive:  matchMedia("(prefers-color-scheme: dark)")
1201 /************************/
1202 /* ACTIVE MEDIA QUERIES */
1203 /************************/
1205 /*  This function provides two slightly different versions of its functionality,
1206     depending on how many arguments it gets.
1208     If one function is given (in addition to the media query and its name), it
1209     is called whenever the media query changes (in either direction).
1211     If two functions are given (in addition to the media query and its name),
1212     then the first function is called whenever the media query starts matching,
1213     and the second function is called whenever the media query stops matching.
1215     If you want to call a function for a change in one direction only, pass an
1216     empty closure (NOT null!) as one of the function arguments.
1218     There is also an optional fifth argument. This should be a function to be
1219     called when the active media query is canceled.
1220  */
1221 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1222     if (typeof GW.mediaQueryResponders == "undefined")
1223         GW.mediaQueryResponders = { };
1225     let mediaQueryResponder = (event, canceling = false) => {
1226         if (canceling) {
1227             GWLog(`Canceling media query “${name}”`, "media queries", 1);
1229             if (whenCanceledDo != null)
1230                 whenCanceledDo(mediaQuery);
1231         } else {
1232             let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1234             GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1236             if ((otherwiseDo == null) || matches)
1237                 ifMatchesOrAlwaysDo(mediaQuery);
1238             else
1239                 otherwiseDo(mediaQuery);
1240         }
1241     };
1242     mediaQueryResponder();
1243     mediaQuery.addListener(mediaQueryResponder);
1245     GW.mediaQueryResponders[name] = mediaQueryResponder;
1248 /*  Deactivates and discards an active media query, after calling the function
1249     that was passed as the whenCanceledDo parameter when the media query was
1250     added.
1251  */
1252 function cancelDoWhenMatchMedia(name) {
1253     GW.mediaQueryResponders[name](null, true);
1255     for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1256         mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1258     GW.mediaQueryResponders[name] = null;
1262 /******************************/
1263 /* DARK/LIGHT MODE ADJUSTMENT */
1264 /******************************/
1266 DarkMode = {
1267         /*****************/
1268         /*      Configuration.
1269          */
1270         modeOptions: [
1271                 [ "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)" ],
1272                 [ "light", "&#xe28f;", "Light mode at all times (black-on-white)" ],
1273                 [ "dark", "&#xf186;", "Dark mode at all times (inverted: white-on-black)" ]
1274         ],
1276         selectedModeOptionNote: " [This option is currently selected.]",
1278         /******************/
1279         /*      Infrastructure.
1280          */
1282         modeSelector: null,
1283         modeSelectorInteractable: true,
1285         /******************/
1286         /*      Mode selection.
1287          */
1289     /*  Returns current (saved) mode (light, dark, or auto).
1290      */
1291     getSavedMode: () => {
1292             return (readCookie("dark-mode") || (readCookie("theme") === "dark" && "dark") || "auto");
1293     },
1295         /*      Saves specified mode (light, dark, or auto).
1296          */
1297         saveMode: (mode) => {
1298                 GWLog("DarkMode.setMode");
1300                 if (mode == "auto")
1301                         setCookie("dark-mode", "");
1302                 else
1303                         setCookie("dark-mode", mode);
1304         },
1306         getMediaQuery: (selectedMode = DarkMode.getSavedMode()) => {
1307                 if (selectedMode == "auto") {
1308                         return "all and (prefers-color-scheme: dark)";
1309                 } else if (selectedMode == "dark") {
1310                         return "all";
1311                 } else {
1312                         return "not all";
1313                 }
1314         },
1316         /*  Set specified color mode (light, dark, or auto).
1317          */
1318         setMode: (selectedMode = DarkMode.getSavedMode()) => {
1319                 GWLog("DarkMode.setMode");
1321                 document.body.removeClasses(["force-dark-mode", "force-light-mode"]);
1322                 if(selectedMode === "dark" || selectedMode === "light")
1323                         document.body.addClass("force-" + selectedMode + "-mode");
1325                 let media = DarkMode.getMediaQuery(selectedMode);
1326                 let darkModeStyles = document.querySelector("link.dark-mode");
1327                 if (darkModeStyles) {
1328                         //      Set `media` attribute of style block to match requested mode.
1329                         darkModeStyles.media = media;
1330                 }
1332                 for(elem of document.querySelectorAll("picture.invertible source")) {
1333                         // Update invertible images.
1334                         elem.media = media;
1335                 }
1337                 //      Update state.
1338                 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1339         },
1341         modeSelectorHTML: (inline = false) => {
1342                 let selectorTagName = (inline ? "span" : "div");
1343                 let selectorId = (inline ? `` : ` id="dark-mode-selector"`);
1344                 let selectorClass = (` class="dark-mode-selector mode-selector` + (inline ? ` mode-selector-inline` : ``) + `"`);
1346                 //      Get saved mode setting (or default).
1347                 let currentMode = DarkMode.getSavedMode();
1349                 return `<${selectorTagName}${selectorId}${selectorClass}>`
1350                         + DarkMode.modeOptions.map(modeOption => {
1351                                 let [ name, label, desc ] = modeOption;
1352                                 let selected = (name == currentMode ? " selected" : "");
1353                                 let disabled = (name == currentMode ? " disabled" : "");
1354                                 let active = ((   currentMode == "auto"
1355                                                            && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1356                                                           ? " active"
1357                                                           : "");
1358                                 if (name == currentMode)
1359                                         desc += DarkMode.selectedModeOptionNote;
1360                                 return `<button
1361                                                         type="button"
1362                                                         class="select-mode-${name}${selected}${active}"
1363                                                         ${disabled}
1364                                                         tabindex="-1"
1365                                                         data-name="${name}"
1366                                                         title="${desc}"
1367                                                                 >${label}</button>`;
1368                           }).join("")
1369                         + `</${selectorTagName}>`;
1370         },
1372         injectModeSelector: (replacedElement = null) => {
1373                 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1375                 //      Inject the mode selector widget.
1376                 let modeSelector;
1377                 if (replacedElement) {
1378                         replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1379                         modeSelector = replacedElement.firstElementChild;
1380                         unwrap(replacedElement);
1381                 } else {
1382                         if (GW.isMobile) {
1383                                 if (Appearance.themeSelector == null)
1384                                         return;
1386                                 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", DarkMode.modeSelectorHTML());
1387                         } else {
1388                                 addUIElement(DarkMode.modeSelectorHTML());
1389                         }
1391                         modeSelector = DarkMode.modeSelector = query("#dark-mode-selector");
1392                 }
1394                 //  Add event listeners and update state.
1395                 requestAnimationFrame(() => {
1396                         //      Activate mode selector widget buttons.
1397                         modeSelector.querySelectorAll("button").forEach(button => {
1398                                 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1399                         });
1400                 });
1402                 /*      Add active media query to update mode selector state when system dark
1403                         mode setting changes. (This is relevant only for the ‘auto’ setting.)
1404                  */
1405                 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => { 
1406                         DarkMode.updateModeSelectorState(modeSelector);
1407                 });
1408         },
1410         modeSelectButtonClicked: (event) => {
1411                 GWLog("DarkMode.modeSelectButtonClicked");
1413                 /*      We don’t want clicks to go through if the transition 
1414                         between modes has not completed yet, so we disable the 
1415                         button temporarily while we’re transitioning between 
1416                         modes.
1417                  */
1418                 doIfAllowed(() => {
1419                         // Determine which setting was chosen (ie. which button was clicked).
1420                         let selectedMode = event.target.dataset.name;
1422                         // Save the new setting.
1423                         DarkMode.saveMode(selectedMode);
1425                         // Actually change the mode.
1426                         DarkMode.setMode(selectedMode);
1427                 }, DarkMode, "modeSelectorInteractable");
1429                 event.target.blur();
1430         },
1432         updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1433                 GWLog("DarkMode.updateModeSelectorState");
1435                 /*      If the mode selector has not yet been injected, then do nothing.
1436                  */
1437                 if (modeSelector == null)
1438                         return;
1440                 //      Get saved mode setting (or default).
1441                 let currentMode = DarkMode.getSavedMode();
1443                 //      Clear current buttons state.
1444                 modeSelector.querySelectorAll("button").forEach(button => {
1445                         button.classList.remove("active", "selected");
1446                         button.disabled = false;
1447                         if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1448                                 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1449                 });
1451                 //      Set the correct button to be selected.
1452                 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1453                         button.classList.add("selected");
1454                         button.disabled = true;
1455                         button.title += DarkMode.selectedModeOptionNote;
1456                 });
1458                 /*      Ensure the right button (light or dark) has the “currently active” 
1459                         indicator, if the current mode is ‘auto’.
1460                  */
1461                 if (currentMode == "auto")
1462                         modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1463         }
1467 /****************************/
1468 /* APPEARANCE CUSTOMIZATION */
1469 /****************************/
1471 Appearance = { ...Appearance,
1472         /**************************************************************************/
1473         /* INFRASTRUCTURE
1474          */
1476         noFilters: { },
1478         themeSelector: null,
1479         themeSelectorAuxiliaryControlsContainer: null,
1480         themeSelectorInteractionBlockerOverlay: null,
1481         themeSelectorInteractableTimer: null,
1483         themeTweakerToggle: null,
1485         themeTweakerStyleBlock: null,
1487         themeTweakerUI: null,
1488         themeTweakerUIMainWindow: null,
1489         themeTweakerUIHelpWindow: null,
1490         themeTweakerUISampleTextContainer: null,
1491         themeTweakerUIClippyContainer: null,
1492         themeTweakerUIClippyControl: null,
1494         widthSelector: null,
1496         textSizeAdjustmentWidget: null,
1498         appearanceAdjustUIToggle: null,
1500         /**************************************************************************/
1501         /* FUNCTIONALITY
1502          */
1504         /*      Return a new <link> element linking a style sheet (.css file) for the
1505                 given theme name and color scheme preference (i.e., value for the 
1506                 ‘media’ attribute; may be “light”, “dark”, or “” [empty string]).
1507          */
1508         makeNewStyle: (newThemeName) => {
1509                 let styleSheetNameSuffix = newThemeName == Appearance.defaultTheme
1510                                                                    ? "" 
1511                                                                    : ("-" + newThemeName);
1512                 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1514                 return [["style", "theme"], ["colors", "theme light-mode"], ["inverted", "theme dark-mode", DarkMode.getMediaQuery()]].map(args => {
1515                         let [baseName, className, mediaQuery] = args;
1516                         return newElement("LINK", {
1517                                 "class": className,
1518                                 "rel": "stylesheet",
1519                                 "href": ("/generated-css/" + baseName + styleSheetNameSuffix + currentStyleSheetNameComponents[1]),
1520                                 "media": mediaQuery || null,
1521                                 "blocking": "render"
1522                         });
1523                 });
1524         },
1526         setTheme: (newThemeName, save = true) => {
1527                 GWLog("Appearance.setTheme");
1529                 let oldThemeName = "";
1530                 if (typeof(newThemeName) == "undefined") {
1531                         /*      If no theme name to set is given, that means we’re setting the 
1532                                 theme initially, on page load. The .currentTheme value will have
1533                                 been set by .setup().
1534                          */
1535                         newThemeName = Appearance.currentTheme;
1537                         /*      If the selected (saved) theme is the default theme, then there’s
1538                                 nothing to do.
1539                          */
1540                         if (newThemeName == Appearance.defaultTheme)
1541                                 return;
1542                 } else {
1543                         oldThemeName = Appearance.currentTheme;
1545                         /*      When the unload callback runs, the .currentTheme value is still 
1546                                 that of the old theme.
1547                          */
1548                         let themeUnloadCallback = Appearance.themeUnloadCallbacks[oldThemeName];
1549                         if (themeUnloadCallback != null)
1550                                 themeUnloadCallback(newThemeName);
1552                         /*      The old .currentTheme value is saved in oldThemeName.
1553                          */
1554                         Appearance.currentTheme = newThemeName;
1556                         /*      The ‘save’ parameter might be false if this function is called 
1557                                 from the theme tweaker, in which case we want to switch only 
1558                                 temporarily, and preserve the saved setting until the user 
1559                                 clicks “OK”.
1560                          */
1561                         if (save)
1562                                 Appearance.saveCurrentTheme();
1563                 }
1565                 let newStyles = Appearance.makeNewStyle(newThemeName);
1566                 let loadingStyleCount = newStyles.length;
1568                 let oldStyles = queryAll("head link.theme");
1570                 let onNewStylesLoaded = (event) => {
1571                         loadingStyleCount--;
1572                         if(loadingStyleCount === 0) {
1573                                 for(oldStyle of oldStyles) removeElement(oldStyle);
1574                                 Appearance.postSetThemeHousekeeping(oldThemeName, newThemeName);
1575                         }
1576                 };
1578                 for(newStyle of newStyles) newStyle.addEventListener("load", onNewStylesLoaded);
1580                 if (Appearance.adjustmentTransitions) {
1581                         pageFadeTransition(false);
1582                         setTimeout(() => {
1583                                 document.head.prepend(...newStyles);
1584                         }, 500);
1585                 } else {
1586                         document.head.prepend(...newStyles);
1587                 }
1589                 //      Update UI state of all theme selectors.
1590                 Appearance.updateThemeSelectorsState();
1591         },
1593         postSetThemeHousekeeping: (oldThemeName = "", newThemeName = null) => {
1594                 GWLog("Appearance.postSetThemeHousekeeping");
1596                 if (newThemeName == null)
1597                         newThemeName = Appearance.getSavedTheme();
1599                 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1600                 document.body.addClass("theme-" + newThemeName);
1602                 recomputeUIElementsContainerHeight(true);
1604                 let themeLoadCallback = Appearance.themeLoadCallbacks[newThemeName];
1605                 if (themeLoadCallback != null)
1606                         themeLoadCallback(oldThemeName);
1608                 recomputeUIElementsContainerHeight();
1609                 adjustUIForWindowSize();
1610                 window.addEventListener("resize", GW.windowResized = (event) => {
1611                         GWLog("GW.windowResized");
1612                         adjustUIForWindowSize();
1613                         recomputeUIElementsContainerHeight();
1614                 });
1616                 generateImagesOverlay();
1618                 if (Appearance.adjustmentTransitions)
1619                         pageFadeTransition(true);
1620                 Appearance.updateThemeTweakerSampleText();
1622                 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== "undefined") {
1623                         window.matchMedia("(orientation: portrait)").addListener(generateImagesOverlay);
1624                 }
1625         },
1627         themeLoadCallbacks: {
1628                 brutalist: (fromTheme = "") => {
1629                         GWLog("Appearance.themeLoadCallbacks.brutalist");
1631                         let bottomBarLinks = queryAll("#bottom-bar a");
1632                         if (!GW.isMobile && bottomBarLinks.length == 5) {
1633                                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1634                                 bottomBarLinks.forEach((link, i) => {
1635                                         link.dataset.originalText = link.textContent;
1636                                         link.textContent = newLinkTexts[i];
1637                                 });
1638                         }
1639                 },
1641                 classic: (fromTheme = "") => {
1642                         GWLog("Appearance.themeLoadCallbacks.classic");
1644                         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1645                                 button.innerHTML = "";
1646                         });
1647                 },
1649                 dark: (fromTheme = "") => {
1650                         GWLog("Appearance.themeLoadCallbacks.dark");
1652                         insertHeadHTML(`<style id="dark-theme-adjustments">
1653                                 .markdown-reference-link a { color: #d200cf; filter: invert(100%); }
1654                                 #bottom-bar.decorative::before { filter: invert(100%); }
1655                         </style>`);
1656                         registerInitializer("makeImagesGlow", true, () => query("#images-overlay") != null, () => {
1657                                 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1658                                         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)";
1659                                         image.style.width = parseInt(image.style.width) + 12 + "px";
1660                                         image.style.height = parseInt(image.style.height) + 12 + "px";
1661                                         image.style.top = parseInt(image.style.top) - 6 + "px";
1662                                         image.style.left = parseInt(image.style.left) - 6 + "px";
1663                                 });
1664                         });
1665                 },
1667                 less: (fromTheme = "") => {
1668                         GWLog("Appearance.themeLoadCallbacks.less");
1670                         injectSiteNavUIToggle();
1671                         if (!GW.isMobile) {
1672                                 injectPostNavUIToggle();
1673                                 Appearance.injectAppearanceAdjustUIToggle();
1674                         }
1676                         registerInitializer("shortenDate", true, () => query(".top-post-meta") != null, function () {
1677                                 let dtf = new Intl.DateTimeFormat([], 
1678                                         (window.innerWidth < 1100) ? 
1679                                                 { month: "short", day: "numeric", year: "numeric" } : 
1680                                                         { month: "long", day: "numeric", year: "numeric" });
1681                                 let postDate = query(".top-post-meta .date");
1682                                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1683                         });
1685                         if (GW.isMobile) {
1686                                 query("#content").insertAdjacentHTML("beforeend", `<div id="theme-less-mobile-first-row-placeholder"></div>`);
1687                         }
1689                         if (!GW.isMobile) {
1690                                 registerInitializer("addSpans", true, () => query(".top-post-meta") != null, function () {
1691                                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1692                                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1693                                         });
1694                                 });
1696                                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1697                                         // If state is not set (user has never clicked on the Less theme’s appearance
1698                                         // adjustment UI toggle) then show it, but then hide it after a short time.
1699                                         registerInitializer("engageAppearanceAdjustUI", true, () => query("#ui-elements-container") != null, function () {
1700                                                 Appearance.toggleAppearanceAdjustUI();
1701                                                 setTimeout(Appearance.toggleAppearanceAdjustUI, 3000);
1702                                         });
1703                                 }
1705                                 if (fromTheme != "") {
1706                                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1707                                         setTimeout(function () {
1708                                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1709                                         }, 300);
1710                                         setTimeout(function () {
1711                                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1712                                         }, 1800);
1713                                 }
1715                                 // Unset the height of the #ui-elements-container.
1716                                 query("#ui-elements-container").style.height = "";
1718                                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1719                                 Appearance.filtersExclusionPaths.themeLess = [
1720                                         "#content #secondary-bar",
1721                                         "#content .post .top-post-meta .date",
1722                                         "#content .post .top-post-meta .comment-count",
1723                                 ];
1724                                 Appearance.applyFilters();
1725                         }
1727                         // We pre-query the relevant elements, so we don’t have to run querySelectorAll
1728                         // on every firing of the scroll listener.
1729                         GW.scrollState = {
1730                                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1731                                 "unbrokenDownScrollDistance":           0,
1732                                 "unbrokenUpScrollDistance":                     0,
1733                                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1734                                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1735                                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1736                         };
1737                         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1738                 }
1739         },
1741         themeUnloadCallbacks: {
1742                 brutalist: (toTheme = "") => {
1743                         GWLog("Appearance.themeUnloadCallbacks.brutalist");
1745                         let bottomBarLinks = queryAll("#bottom-bar a");
1746                         if (!GW.isMobile && bottomBarLinks.length == 5) {
1747                                 bottomBarLinks.forEach(link => {
1748                                         link.textContent = link.dataset.originalText;
1749                                 });
1750                         }
1751                 },
1753                 classic: (toTheme = "") => {
1754                         GWLog("Appearance.themeUnloadCallbacks.classic");
1756                         if (GW.isMobile && window.innerWidth <= 900)
1757                                 return;
1759                         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1760                                 button.innerHTML = button.dataset.label;
1761                         });
1762                 },
1764                 dark: (toTheme = "") => {
1765                         GWLog("Appearance.themeUnloadCallbacks.dark");
1767                         removeElement("#dark-theme-adjustments");
1768                 },
1770                 less: (toTheme = "") => {
1771                         GWLog("Appearance.themeUnloadCallbacks.less");
1773                         removeSiteNavUIToggle();
1774                         if (!GW.isMobile) {
1775                                 removePostNavUIToggle();
1776                                 Appearance.removeAppearanceAdjustUIToggle();
1777                         }
1779                         window.removeEventListener("resize", updatePostNavUIVisibility);
1781                         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1783                         removeElement("#theme-less-mobile-first-row-placeholder");
1785                         if (!GW.isMobile) {
1786                                 // Remove spans
1787                                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1788                                         element.innerHTML = element.firstChild.innerHTML;
1789                                 });
1790                         }
1792                         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1794                         //      Reset filtered elements selector to default.
1795                         delete Appearance.filtersExclusionPaths.themeLess;
1796                         Appearance.applyFilters();
1797                 }
1798         },
1800         pageFadeTransition: (fadeIn) => {
1801                 if (fadeIn) {
1802                         document.body.removeClass("transparent");
1803                 } else {
1804                         document.body.addClass("transparent");
1805                 }
1806         },
1808         /*      Set the saved theme setting to the currently active theme.
1809          */
1810         saveCurrentTheme: () => {
1811                 GWLog("Appearance.saveCurrentTheme");
1813                 if (Appearance.currentTheme == Appearance.defaultTheme)
1814                         setCookie("theme", "");
1815                 else
1816                         setCookie("theme", Appearance.currentTheme);
1817         },
1819         /*      Reset theme, theme tweak filters, and text zoom to their saved settings.
1820          */
1821         themeTweakReset: () => {
1822                 GWLog("Appearance.themeTweakReset");
1824                 Appearance.setTheme(Appearance.getSavedTheme());
1825                 Appearance.applyFilters(Appearance.getSavedFilters());
1826                 Appearance.setTextZoom(Appearance.getSavedTextZoom());
1827         },
1829         /*      Set the saved theme, theme tweak filter, and text zoom settings to their
1830                 currently active values.
1831          */
1832         themeTweakSave: () => {
1833                 GWLog("Appearance.themeTweakSave");
1835                 Appearance.saveCurrentTheme();
1836                 Appearance.saveCurrentFilters();
1837                 Appearance.saveCurrentTextZoom();
1838         },
1840         /*      Reset theme, theme tweak filters, and text zoom to their default levels.
1841                 (Do not save the new settings, however.)
1842          */
1843         themeTweakResetDefaults: () => {
1844                 GWLog("Appearance.themeTweakResetDefaults");
1846                 Appearance.setTheme(Appearance.defaultTheme, false);
1847                 Appearance.applyFilters(Appearance.defaultFilters);
1848                 Appearance.setTextZoom(Appearance.defaultTextZoom, false);
1849         },
1851         themeTweakerResetSettings: () => {
1852                 GWLog("Appearance.themeTweakerResetSettings");
1854                 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
1855                 Appearance.themeTweakerUIClippyContainer.style.display = Appearance.themeTweakerUIClippyControl.checked 
1856                                                                                                                                  ? "block" 
1857                                                                                                                                  : "none";
1858         },
1860         themeTweakerSaveSettings: () => {
1861                 GWLog("Appearance.themeTweakerSaveSettings");
1863                 Appearance.saveThemeTweakerClippyState();
1864         },
1866         getSavedThemeTweakerClippyState: () => {
1867                 return (JSON.parse(localStorage.getItem("theme-tweaker-settings") || `{ "showClippy": ${Appearance.defaultThemeTweakerClippyState} }` )["showClippy"]);
1868         },
1870         saveThemeTweakerClippyState: () => {
1871                 GWLog("Appearance.saveThemeTweakerClippyState");
1873                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ "showClippy": Appearance.themeTweakerUIClippyControl.checked }));
1874         },
1876         getSavedAppearanceAdjustUIToggleState: () => {
1877                 return ((localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") || Appearance.defaultAppearanceAdjustUIToggleState);
1878         },
1880         saveAppearanceAdjustUIToggleState: () => {
1881                 GWLog("Appearance.saveAppearanceAdjustUIToggleState");
1883                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", Appearance.appearanceAdjustUIToggle.query("button").hasClass("engaged"));
1884         },
1886         /**************************************************************************/
1887         /* UI CONSTRUCTION & MANIPULATION
1888          */
1890         contentWidthSelectorHTML: () => {
1891                 return ("<div id='width-selector'>"
1892                         + String.prototype.concat.apply("", Appearance.widthOptions.map(widthOption => {
1893                                 let [name, desc, abbr] = widthOption;
1894                                 let selected = (name == Appearance.currentWidth ? " selected" : "");
1895                                 let disabled = (name == Appearance.currentWidth ? " disabled" : "");
1896                                 return `<button type="button" class="select-width-${name}${selected}"${disabled} title="${desc}" tabindex="-1" data-name="${name}">${abbr}</button>`
1897                         }))
1898                 + "</div>");
1899         },
1901         injectContentWidthSelector: () => {
1902                 GWLog("Appearance.injectContentWidthSelector");
1904                 //      Inject the content width selector widget and activate buttons.
1905                 Appearance.widthSelector = addUIElement(Appearance.contentWidthSelectorHTML());
1906                 Appearance.widthSelector.queryAll("button").forEach(button => {
1907                         button.addActivateEvent(Appearance.widthAdjustButtonClicked);
1908                 });
1910                 //      Make sure the accesskey (to cycle to the next width) is on the right button.
1911                 Appearance.setWidthAdjustButtonsAccesskey();
1913                 //      Inject transitions CSS, if animating changes is enabled.
1914                 if (Appearance.adjustmentTransitions) {
1915                         insertHeadHTML(
1916                                 `<style id="width-transition">
1917                                         #content,
1918                                         #ui-elements-container,
1919                                         #images-overlay {
1920                                                 transition:
1921                                                         max-width 0.3s ease;
1922                                         }
1923                                 </style>`);
1924                 }
1925         },
1927         setWidthAdjustButtonsAccesskey: () => {
1928                 GWLog("Appearance.setWidthAdjustButtonsAccesskey");
1930                 Appearance.widthSelector.queryAll("button").forEach(button => {
1931                         button.removeAttribute("accesskey");
1932                         button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1933                 });
1934                 let selectedButton = Appearance.widthSelector.query("button.selected");
1935                 let nextButtonInCycle = selectedButton == selectedButton.parentElement.lastChild
1936                                                                                                   ? selectedButton.parentElement.firstChild 
1937                                                                                                   : selectedButton.nextSibling;
1938                 nextButtonInCycle.accessKey = "'";
1939                 nextButtonInCycle.title += ` [\']`;
1940         },
1942         injectTextSizeAdjustmentUI: () => {
1943                 GWLog("Appearance.injectTextSizeAdjustmentUI");
1945                 if (Appearance.textSizeAdjustmentWidget != null)
1946                         return;
1948                 let inject = () => {
1949                         GWLog("Appearance.injectTextSizeAdjustmentUI [INJECTING]");
1951                         Appearance.textSizeAdjustmentWidget = addUIElement("<div id='text-size-adjustment-ui'>"
1952                                 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
1953                                 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1954                                 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
1955                         + "</div>");
1957                         Appearance.textSizeAdjustmentWidget.queryAll("button").forEach(button => {
1958                                 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1959                         });
1960                 };
1962                 if (query("#content.post-page") != null) {
1963                         inject();
1964                 } else {
1965                         document.addEventListener("DOMContentLoaded", () => {
1966                                 if (!(   query(".post-body") == null 
1967                                           && query(".comment-body") == null))
1968                                         inject();
1969                         }, { once: true });
1970                 }
1971         },
1973         themeSelectorHTML: () => {
1974                 return ("<div id='theme-selector' class='theme-selector'>"
1975                         + String.prototype.concat.apply("", Appearance.themeOptions.map(themeOption => {
1976                                 let [name, desc, letter] = themeOption;
1977                                 let selected = (name == Appearance.currentTheme ? ' selected' : '');
1978                                 let disabled = (name == Appearance.currentTheme ? ' disabled' : '');
1979                                 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1980                                 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>`;
1981                         }))
1982                 + "</div>");
1983         },
1985         injectThemeSelector: () => {
1986                 GWLog("Appearance.injectThemeSelector");
1988                 Appearance.themeSelector = addUIElement(Appearance.themeSelectorHTML());
1989                 Appearance.themeSelector.queryAll("button").forEach(button => {
1990                         button.addActivateEvent(Appearance.themeSelectButtonClicked);
1991                 });
1993                 if (GW.isMobile) {
1994                         //      Add close button.
1995                         let themeSelectorCloseButton = newElement("BUTTON", { "class": "theme-selector-close-button" }, { "innerHTML": "&#xf057;" });
1996                         themeSelectorCloseButton.addActivateEvent(Appearance.themeSelectorCloseButtonClicked);
1997                         Appearance.themeSelector.appendChild(themeSelectorCloseButton);
1999                         //      Inject auxiliary controls container.
2000                         Appearance.themeSelectorAuxiliaryControlsContainer = newElement("DIV", { "class": "auxiliary-controls-container" });
2001                         Appearance.themeSelector.appendChild(Appearance.themeSelectorAuxiliaryControlsContainer);
2003                         //      Inject mobile versions of various UI elements.
2004                         Appearance.injectThemeTweakerToggle();
2005                         injectAntiKibitzerToggle();
2006                         DarkMode.injectModeSelector();
2008                         //      Inject interaction blocker overlay.
2009                         Appearance.themeSelectorInteractionBlockerOverlay = Appearance.themeSelector.appendChild(newElement("DIV", { "class": "interaction-blocker-overlay" }));
2010                         Appearance.themeSelectorInteractionBlockerOverlay.addActivateEvent(event => { event.stopPropagation(); });
2011                 }
2013                 //      Inject transitions CSS, if animating changes is enabled.
2014                 if (Appearance.adjustmentTransitions) {
2015                         insertHeadHTML(`<style id="theme-fade-transition">
2016                                 body {
2017                                         transition:
2018                                                 opacity 0.5s ease-out,
2019                                                 background-color 0.3s ease-out;
2020                                 }
2021                                 body.transparent {
2022                                         background-color: #777;
2023                                         opacity: 0.0;
2024                                         transition:
2025                                                 opacity 0.5s ease-in,
2026                                                 background-color 0.3s ease-in;
2027                                 }
2028                         </style>`);
2029                 }
2030         },
2032         updateThemeSelectorsState: () => {
2033                 GWLog("Appearance.updateThemeSelectorsState");
2035                 queryAll(".theme-selector button.select-theme").forEach(button => {
2036                         button.removeClass("selected");
2037                         button.disabled = false;
2038                 });
2039                 queryAll(".theme-selector button.select-theme-" + Appearance.currentTheme).forEach(button => {
2040                         button.addClass("selected");
2041                         button.disabled = true;
2042                 });
2044                 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.currentTheme;
2045         },
2047         setThemeSelectorInteractable: (interactable) => {
2048                 GWLog("Appearance.setThemeSelectorInteractable");
2050                 Appearance.themeSelectorInteractionBlockerOverlay.classList.toggle("enabled", (interactable == false));
2051         },
2053         themeTweakerUIHTML: () => {
2054                 return (`<div id="theme-tweaker-ui" style="display: none;">\n` 
2055                         + `<div class="theme-tweaker-window main-window">
2056                                 <div class="theme-tweaker-window-title-bar">
2057                                         <div class="theme-tweaker-window-title">
2058                                                 <h1>Customize appearance</h1>
2059                                         </div>
2060                                         <div class="theme-tweaker-window-title-bar-buttons-container">
2061                                                 <button type="button" class="help-button" tabindex="-1"></button>
2062                                                 <button type="button" class="minimize-button minimize" tabindex="-1"></button>
2063                                                 <button type="button" class="close-button" tabindex="-1"></button>
2064                                         </div>
2065                                 </div>
2066                                 <div class="theme-tweaker-window-content-view">
2067                                         <div class="theme-select">
2068                                                 <p class="current-theme">Current theme:
2069                                                         <span>${Appearance.getSavedTheme()}</span>
2070                                                 </p>
2071                                                 <div class="theme-selector"></div>
2072                                         </div>
2073                                         <div class="controls-container">
2074                                                 <div id="theme-tweak-section-sample-text" class="section" data-label="Sample text">
2075                                                         <div class="sample-text-container"><span class="sample-text">
2076                                                                 <p>Less Wrong (text)</p>
2077                                                                 <p><a href="#">Less Wrong (link)</a></p>
2078                                                         </span></div>
2079                                                 </div>
2080                                                 <div id="theme-tweak-section-text-size-adjust" class="section" data-label="Text size">
2081                                                         <button type="button" class="text-size-adjust-button decrease" title="Decrease text size"></button>
2082                                                         <button type="button" class="text-size-adjust-button default" title="Reset to default text size"></button>
2083                                                         <button type="button" class="text-size-adjust-button increase" title="Increase text size"></button>
2084                                                 </div>
2085                                                 <div id="theme-tweak-section-invert" class="section" data-label="Invert (photo-negative)">
2086                                                         <input type="checkbox" id="theme-tweak-control-invert"></input>
2087                                                         <label for="theme-tweak-control-invert">Invert colors</label>
2088                                                 </div>
2089                                                 <div id="theme-tweak-section-saturate" class="section" data-label="Saturation">
2090                                                         <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2091                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
2092                                                         <div class="notch theme-tweak-slider-notch-saturate" title="Reset saturation to default value (100%)"></div>
2093                                                 </div>
2094                                                 <div id="theme-tweak-section-brightness" class="section" data-label="Brightness">
2095                                                         <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2096                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
2097                                                         <div class="notch theme-tweak-slider-notch-brightness" title="Reset brightness to default value (100%)"></div>
2098                                                 </div>
2099                                                 <div id="theme-tweak-section-contrast" class="section" data-label="Contrast">
2100                                                         <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2101                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
2102                                                         <div class="notch theme-tweak-slider-notch-contrast" title="Reset contrast to default value (100%)"></div>
2103                                                 </div>
2104                                                 <div id="theme-tweak-section-hue-rotate" class="section" data-label="Hue rotation">
2105                                                         <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
2106                                                         <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
2107                                                         <div class="notch theme-tweak-slider-notch-hue-rotate" title="Reset hue to default (0° away from standard colors for theme)"></div>
2108                                                 </div>
2109                                         </div>
2110                                         <div class="buttons-container">
2111                                                 <button type="button" class="reset-defaults-button">Reset to defaults</button>
2112                                                 <button type="button" class="ok-button default-button">OK</button>
2113                                                 <button type="button" class="cancel-button">Cancel</button>
2114                                         </div>
2115                                 </div>
2116                         </div>
2117                         <div class="clippy-container">
2118                                 <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>)
2119                                 <div class="clippy"></div>
2120                                 <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>
2121                         </div>
2122                         <div class="theme-tweaker-window help-window" style="display: none;">
2123                                 <div class="theme-tweaker-window-title-bar">
2124                                         <div class="theme-tweaker-window-title">
2125                                                 <h1>Theme tweaker help</h1>
2126                                         </div>
2127                                 </div>
2128                                 <div class="theme-tweaker-window-content-view">
2129                                         <div id="theme-tweak-section-clippy" class="section" data-label="Theme Tweaker Assistant">
2130                                                 <input type="checkbox" id="theme-tweak-control-clippy" checked="checked"></input>
2131                                                 <label for="theme-tweak-control-clippy">Show Bobby the Basilisk</label>
2132                                         </div>
2133                                         <div class="buttons-container">
2134                                                 <button type="button" class="ok-button default-button">OK</button>
2135                                                 <button type="button" class="cancel-button">Cancel</button>
2136                                         </div>
2137                                 </div>
2138                         </div>
2139                 ` + `\n</div>`);
2140         },
2142         injectThemeTweaker: () => {
2143                 GWLog("Appearance.injectThemeTweaker");
2145                 Appearance.themeTweakerUI = addUIElement(Appearance.themeTweakerUIHTML());
2146                 Appearance.themeTweakerUIMainWindow = Appearance.themeTweakerUI.firstElementChild;
2147                 Appearance.themeTweakerUIHelpWindow = Appearance.themeTweakerUI.query(".help-window");
2148                 Appearance.themeTweakerUISampleTextContainer = Appearance.themeTweakerUI.query("#theme-tweak-section-sample-text .sample-text-container");
2149                 Appearance.themeTweakerUIClippyContainer = Appearance.themeTweakerUI.query(".clippy-container");
2150                 Appearance.themeTweakerUIClippyControl = Appearance.themeTweakerUI.query("#theme-tweak-control-clippy");
2152                 //      Clicking the background overlay closes the theme tweaker.
2153                 Appearance.themeTweakerUI.addActivateEvent(Appearance.themeTweakerUIOverlayClicked, true);
2155                 //      Intercept clicks, so they don’t “fall through” the background overlay.
2156                 Array.from(Appearance.themeTweakerUI.children).forEach(themeTweakerUIWindow => {
2157                         themeTweakerUIWindow.addActivateEvent((event) => {
2158                                 event.stopPropagation();
2159                         }, true);
2160                 });
2162                 Appearance.themeTweakerUI.queryAll("input").forEach(field => {
2163                         /*      All input types in the theme tweaker receive a ‘change’ event 
2164                                 when their value is changed. (Range inputs, in particular, 
2165                                 receive this event when the user lets go of the handle.) This 
2166                                 means we should update the filters for the entire page, to match 
2167                                 the new setting.
2168                          */
2169                         field.addEventListener("change", Appearance.themeTweakerUIFieldValueChanged);
2171                         /*      Range inputs receive an ‘input’ event while being scrubbed, 
2172                                 updating “live” as the handle is moved. We don’t want to change 
2173                                 the filters for the actual page while this is happening, but we 
2174                                 do want to change the filters for the *sample text*, so the user
2175                                 can see what effects his changes are having, live, without 
2176                                 having to let go of the handle.
2177                          */
2178                         if (field.type == "range")
2179                                 field.addEventListener("input", Appearance.themeTweakerUIFieldInputReceived);
2180                 });
2182                 Appearance.themeTweakerUI.query(".help-button").addActivateEvent(Appearance.themeTweakerUIHelpButtonClicked);
2183                 Appearance.themeTweakerUI.query(".minimize-button").addActivateEvent(Appearance.themeTweakerUIMinimizeButtonClicked);
2184                 Appearance.themeTweakerUI.query(".close-button").addActivateEvent(Appearance.themeTweakerUICloseButtonClicked);
2185                 Appearance.themeTweakerUI.query(".reset-defaults-button").addActivateEvent(Appearance.themeTweakerUIResetDefaultsButtonClicked);
2186                 Appearance.themeTweakerUI.query(".main-window .cancel-button").addActivateEvent(Appearance.themeTweakerUICancelButtonClicked);
2187                 Appearance.themeTweakerUI.query(".main-window .ok-button").addActivateEvent(Appearance.themeTweakerUIOKButtonClicked);
2188                 Appearance.themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowCancelButtonClicked);
2189                 Appearance.themeTweakerUI.query(".help-window .ok-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowOKButtonClicked);
2191                 Appearance.themeTweakerUI.queryAll(".notch").forEach(notch => {
2192                         notch.addActivateEvent(Appearance.themeTweakerUISliderNotchClicked);
2193                 });
2195                 Appearance.themeTweakerUI.query(".clippy-close-button").addActivateEvent(Appearance.themeTweakerUIClippyCloseButtonClicked);
2197                 insertHeadHTML(`<style id="theme-tweaker-style"></style>`);
2198                 Appearance.themeTweakerStyleBlock = document.head.query("#theme-tweaker-style");
2200                 Appearance.themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
2201                 Appearance.themeTweakerUI.queryAll(".theme-selector > *:not(.select-theme)").forEach(element => {
2202                         element.remove();
2203                 });
2204                 Appearance.themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
2205                         button.addActivateEvent(Appearance.themeSelectButtonClicked);
2206                 });
2208                 Appearance.themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
2209                         button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
2210                 });
2212                 if (GW.isMobile == false)
2213                         Appearance.injectThemeTweakerToggle();
2214         },
2216         themeTweakerToggleHTML: () => {
2217                 return (`<div id="theme-tweaker-toggle">`
2218                                         + `<button 
2219                                                         type="button" 
2220                                                         tabindex="-1" 
2221                                                         title="Customize appearance [;]" 
2222                                                         accesskey=";"
2223                                                                 >&#xf1de;</button>`
2224                                 + `</div>`);
2225         },
2227         injectThemeTweakerToggle: () => {
2228                 GWLog("Appearance.injectThemeTweakerToggle");
2230                 if (GW.isMobile) {
2231                         if (Appearance.themeSelector == null)
2232                                 return;
2234                         Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", Appearance.themeTweakerToggleHTML());
2235                         Appearance.themeTweakerToggle = Appearance.themeSelector.query("#theme-tweaker-toggle");
2236                 } else {
2237                         Appearance.themeTweakerToggle = addUIElement(Appearance.themeTweakerToggleHTML());      
2238                 }
2240                 Appearance.themeTweakerToggle.query("button").addActivateEvent(Appearance.themeTweakerToggleClicked);
2241         },
2243         showThemeTweakerUI: () => {
2244                 GWLog("Appearance.showThemeTweakerUI");
2246                 if (query("link[href^='/css/theme_tweaker.css']") == null) {
2247                         //      Theme tweaker CSS needs to be loaded.
2249                         let themeTweakerStyleSheet = newElement("LINK", {
2250                                 "rel": "stylesheet",
2251                                 "href": "/css/theme_tweaker.css"
2252                         });
2254                         themeTweakerStyleSheet.addEventListener("load", (event) => {
2255                                 requestAnimationFrame(() => {
2256                                         themeTweakerStyleSheet.disabled = false;
2257                                 });
2258                                 Appearance.showThemeTweakerUI();
2259                         }, { once: true });
2261                         document.head.appendChild(themeTweakerStyleSheet);
2263                         return;
2264                 }
2266                 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.getSavedTheme();
2268                 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = (Appearance.currentFilters["invert"] == "100%");
2269                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2270                         let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2271                         slider.value = /^[0-9]+/.exec(Appearance.currentFilters[sliderName]) || slider.dataset["defaultValue"];
2272                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2273                 });
2275                 Appearance.toggleThemeTweakerUI();
2276         },
2278         toggleThemeTweakerUI: () => {
2279                 GWLog("Appearance.toggleThemeTweakerUI");
2281                 let show = (Appearance.themeTweakerUI.style.display == "none");
2283                 Appearance.themeTweakerUI.style.display = show ? "block" : "none";
2284                 Appearance.setThemeTweakerWindowMinimized(false);
2285                 Appearance.themeTweakerStyleBlock.innerHTML = show ? `#content, #ui-elements-container > div:not(#theme-tweaker-ui) { pointer-events: none; user-select: none; }` : "";
2287                 if (show) {
2288                         // Disable button.
2289                         Appearance.themeTweakerToggle.query("button").disabled = true;
2290                         // Focus invert checkbox.
2291                         Appearance.themeTweakerUI.query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
2292                         // Show sample text in appropriate font.
2293                         Appearance.updateThemeTweakerSampleText();
2294                         // Disable tab-selection of the search box.
2295                         setSearchBoxTabSelectable(false);
2296                         // Disable scrolling of the page.
2297                         togglePageScrolling(false);
2298                 } else {
2299                         // Re-enable button.
2300                         Appearance.themeTweakerToggle.query("button").disabled = false;
2301                         // Re-enable tab-selection of the search box.
2302                         setSearchBoxTabSelectable(true);
2303                         // Re-enable scrolling of the page.
2304                         togglePageScrolling(true);
2305                 }
2307                 // Set theme tweaker assistant visibility.
2308                 Appearance.themeTweakerUIClippyContainer.style.display = (Appearance.getSavedThemeTweakerClippyState() == true) ? "block" : "none";
2309         },
2311         setThemeTweakerWindowMinimized: (minimize) => {
2312                 GWLog("Appearance.setThemeTweakerWindowMinimized");
2314                 Appearance.themeTweakerUIMainWindow.query(".minimize-button").swapClasses([ "minimize", "maximize" ], (minimize ? 1 : 0));
2315                 Appearance.themeTweakerUIMainWindow.classList.toggle("minimized", minimize);
2316                 Appearance.themeTweakerUI.classList.toggle("main-window-minimized", minimize);
2317         },
2319         toggleThemeTweakerHelpWindow: () => {
2320                 GWLog("Appearance.toggleThemeTweakerHelpWindow");
2322                 Appearance.themeTweakerUIHelpWindow.style.display = Appearance.themeTweakerUIHelpWindow.style.display == "none" 
2323                                                                                                                 ? "block" 
2324                                                                                                                 : "none";
2325                 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2326                         // Focus theme tweaker assistant checkbox.
2327                         Appearance.themeTweakerUI.query("#theme-tweak-control-clippy").focus();
2328                         // Disable interaction on main theme tweaker window.
2329                         Appearance.themeTweakerUI.style.pointerEvents = "none";
2330                         Appearance.themeTweakerUIMainWindow.style.pointerEvents = "none";
2331                 } else {
2332                         // Re-enable interaction on main theme tweaker window.
2333                         Appearance.themeTweakerUI.style.pointerEvents = "auto";
2334                         Appearance.themeTweakerUIMainWindow.style.pointerEvents = "auto";
2335                 }
2336         },
2338         resetThemeTweakerUIDefaultState: () => {
2339                 GWLog("Appearance.resetThemeTweakerUIDefaultState");
2341                 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
2343                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2344                         let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2345                         slider.value = slider.dataset["defaultValue"];
2346                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2347                 });
2348         },
2350         updateThemeTweakerSampleText: () => {
2351                 GWLog("Appearance.updateThemeTweakerSampleText");
2353                 let sampleText = Appearance.themeTweakerUISampleTextContainer.query("#theme-tweak-section-sample-text .sample-text");
2355                 // This causes the sample text to take on the properties of the body text of a post.
2356                 sampleText.removeClass("body-text");
2357                 let bodyTextElement = query(".post-body") || query(".comment-body");
2358                 sampleText.addClass("body-text");
2359                 sampleText.style.color = bodyTextElement ? 
2360                         getComputedStyle(bodyTextElement).color : 
2361                         getComputedStyle(query("#content")).color;
2363                 // Here we find out what is the actual background color that will be visible behind
2364                 // the body text of posts, and set the sample text’s background to that.
2365                 let findStyleBackground = (selector) => {
2366                         return "#fff"; // FIXME
2367                         let x;
2368                         Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2369                                 if (rule.selectorText == selector)
2370                                         x = rule;
2371                         });
2372                         return x.style.backgroundColor;
2373                 };
2375                 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2376         },
2378         injectAppearanceAdjustUIToggle: () => {
2379                 GWLog("Appearance.injectAppearanceAdjustUIToggle");
2381                 Appearance.appearanceAdjustUIToggle = addUIElement(`<div id="appearance-adjust-ui-toggle"><button type="button" tabindex="-1">&#xf013;</button></div>`);
2382                 Appearance.appearanceAdjustUIToggle.query("button").addActivateEvent(Appearance.appearanceAdjustUIToggleButtonClicked);
2384                 if (  !GW.isMobile 
2385                         && Appearance.getSavedAppearanceAdjustUIToggleState() == true) {
2386                         Appearance.toggleAppearanceAdjustUI();
2387                 }
2388         },
2390         removeAppearanceAdjustUIToggle: () => {
2391                 GWLog("Appearance.removeAppearanceAdjustUIToggle");
2393                 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2394                         element.removeClass("engaged");
2395                 });
2396                 removeElement("#appearance-adjust-ui-toggle");
2397         },
2399         toggleAppearanceAdjustUI: () => {
2400                 GWLog("Appearance.toggleAppearanceAdjustUI");
2402                 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2403                         element.toggleClass("engaged");
2404                 });
2406                 if (GW.isMobile) {
2407                         clearTimeout(Appearance.themeSelectorInteractableTimer);
2408                         Appearance.setThemeSelectorInteractable(false);
2409                         Appearance.themeSelectorInteractableTimer = setTimeout(() => {
2410                                 Appearance.setThemeSelectorInteractable(true);
2411                         }, 200);
2412                 }
2413         },
2415         /**************************************************************************/
2416         /* EVENTS
2417          */
2419         /*      Theme selector close button (on mobile version of theme selector).
2420          */
2421         themeSelectorCloseButtonClicked: (event) => {
2422                 GWLog("Appearance.themeSelectorCloseButtonClicked");
2424                 Appearance.toggleAppearanceAdjustUI();
2425                 Appearance.saveAppearanceAdjustUIToggleState();
2426         },
2428         /*      “Cog” button (to toggle the appearance adjust UI widgets in “less” 
2429                 theme, or theme selector UI on mobile).
2430          */
2431         appearanceAdjustUIToggleButtonClicked: (event) => {
2432                 GWLog("Appearance.appearanceAdjustUIToggleButtonClicked");
2434                 Appearance.toggleAppearanceAdjustUI();
2435                 Appearance.saveAppearanceAdjustUIToggleState();
2436         },
2438         /*      Width adjust buttons (“normal”, “wide”, “fluid”).
2439          */
2440         widthAdjustButtonClicked: (event) => {
2441                 GWLog("Appearance.widthAdjustButtonClicked");
2443                 // Determine which setting was chosen (i.e., which button was clicked).
2444                 let selectedWidth = event.target.dataset.name;
2446                 //      Switch width.
2447                 Appearance.currentWidth = selectedWidth;
2449                 // Save the new setting.
2450                 Appearance.saveCurrentWidth();
2452                 // Save current visible comment
2453                 let visibleComment = getCurrentVisibleComment();
2455                 // Actually change the content width.
2456                 Appearance.setContentWidth(selectedWidth);
2457                 event.target.parentElement.childNodes.forEach(button => {
2458                         button.removeClass("selected");
2459                         button.disabled = false;
2460                 });
2461                 event.target.addClass("selected");
2462                 event.target.disabled = true;
2464                 // Make sure the accesskey (to cycle to the next width) is on the right button.
2465                 Appearance.setWidthAdjustButtonsAccesskey();
2467                 // Regenerate images overlay.
2468                 generateImagesOverlay();
2470                 if (visibleComment)
2471                         visibleComment.scrollIntoView();
2472         },
2474         /*      Theme selector buttons (“A” through “I”).
2475          */
2476         themeSelectButtonClicked: (event) => {
2477                 GWLog("Appearance.themeSelectButtonClicked");
2479                 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
2480                 let save = (Appearance.themeTweakerUI.contains(event.target) == false);
2481                 Appearance.setTheme(themeName, save);
2482                 if (GW.isMobile)
2483                         Appearance.toggleAppearanceAdjustUI();
2484         },
2486         /*      The text size adjust (“-”, “A”, “+”) buttons.
2487          */
2488         textSizeAdjustButtonClicked: (event) => {
2489                 GWLog("Appearance.textSizeAdjustButtonClicked");
2491                 var zoomFactor = Appearance.currentTextZoom;
2492                 if (event.target.hasClass("decrease")) {
2493                         zoomFactor -= 0.05;
2494                 } else if (event.target.hasClass("increase")) {
2495                         zoomFactor += 0.05;
2496                 } else {
2497                         zoomFactor = Appearance.defaultTextZoom;
2498                 }
2500                 let save = (   Appearance.textSizeAdjustmentWidget != null 
2501                                         && Appearance.textSizeAdjustmentWidget.contains(event.target));
2502                 Appearance.setTextZoom(zoomFactor, save);
2503         },
2505         /*      Theme tweaker toggle button.
2506          */
2507         themeTweakerToggleClicked: (event) => {
2508                 GWLog("Appearance.themeTweakerToggleClicked");
2510                 Appearance.showThemeTweakerUI();
2511         },
2513         /***************************/
2514         /*      Theme tweaker UI events.
2515          */
2517         /*      Key pressed while theme tweaker is open.
2518          */
2519         themeTweakerUIKeyPressed: (event) => {
2520                 GWLog("Appearance.themeTweakerUIKeyPressed");
2522                 if (event.key == "Escape") {
2523                         if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2524                                 Appearance.toggleThemeTweakerHelpWindow();
2525                                 Appearance.themeTweakerResetSettings();
2526                         } else if (Appearance.themeTweakerUI.style.display != "none") {
2527                                 Appearance.toggleThemeTweakerUI();
2528                                 Appearance.themeTweakReset();
2529                         }
2530                 } else if (event.key == "Enter") {
2531                         if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2532                                 Appearance.toggleThemeTweakerHelpWindow();
2533                                 Appearance.themeTweakerSaveSettings();
2534                         } else if (Appearance.themeTweakerUI.style.display != "none") {
2535                                 Appearance.toggleThemeTweakerUI();
2536                                 Appearance.themeTweakSave();
2537                         }
2538                 }
2539         },
2541         /*      Theme tweaker overlay clicked.
2542          */
2543         themeTweakerUIOverlayClicked: (event) => {
2544                 GWLog("Appearance.themeTweakerUIOverlayClicked");
2546                 if (event.type == "mousedown") {
2547                         Appearance.themeTweakerUI.style.opacity = "0.01";
2548                 } else {
2549                         Appearance.toggleThemeTweakerUI();
2550                         Appearance.themeTweakerUI.style.opacity = "1.0";
2551                         Appearance.themeTweakReset();
2552                 }
2553         },
2555         /*      In the theme tweaker, a slider clicked, or released after drag; or a
2556                 checkbox clicked (either in the main theme tweaker UI, or in the help
2557                 window).
2558          */
2559         themeTweakerUIFieldValueChanged: (event) => {
2560                 GWLog("Appearance.themeTweakerUIFieldValueChanged");
2562                 if (event.target.id == "theme-tweak-control-invert") {
2563                         Appearance.currentFilters["invert"] = event.target.checked ? "100%" : "0%";
2564                 } else if (event.target.type == "range") {
2565                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2566                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2567                         Appearance.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2568                 } else if (event.target.id == "theme-tweak-control-clippy") {
2569                         Appearance.themeTweakerUIClippyContainer.style.display = event.target.checked ? "block" : "none";
2570                 }
2572                 // Clear the sample text filters.
2573                 Appearance.themeTweakerUISampleTextContainer.style.filter = "";
2575                 // Apply the new filters globally.
2576                 Appearance.applyFilters();
2577         },
2579         /*      Theme tweaker slider dragged (live-update event).
2580          */
2581         themeTweakerUIFieldInputReceived: (event) => {
2582                 GWLog("Appearance.themeTweakerUIFieldInputReceived");
2584                 let sampleTextFilters = Appearance.currentFilters;
2585                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2586                 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2587                 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2589                 Appearance.themeTweakerUISampleTextContainer.style.filter = Appearance.filterStringFromFilters(sampleTextFilters);
2590         },
2592         /*      Close button in main theme tweaker UI (title bar).
2593          */
2594         themeTweakerUICloseButtonClicked: (event) => {
2595                 GWLog("Appearance.themeTweakerUICloseButtonClicked");
2597                 Appearance.toggleThemeTweakerUI();
2598                 Appearance.themeTweakReset();
2599         },
2601         /*      Minimize button in main theme tweaker UI (title bar).
2602          */
2603         themeTweakerUIMinimizeButtonClicked: (event) => {
2604                 GWLog("Appearance.themeTweakerUIMinimizeButtonClicked");
2606                 Appearance.setThemeTweakerWindowMinimized(event.target.hasClass("minimize"));
2607         },
2609         /*      Help (“?”) button in main theme tweaker UI (title bar).
2610          */
2611         themeTweakerUIHelpButtonClicked: (event) => {
2612                 GWLog("Appearance.themeTweakerUIHelpButtonClicked");
2614                 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
2615                 Appearance.toggleThemeTweakerHelpWindow();
2616         },
2618         /*      “Reset Defaults” button in main theme tweaker UI.
2619          */
2620         themeTweakerUIResetDefaultsButtonClicked: (event) => {
2621                 GWLog("Appearance.themeTweakerUIResetDefaultsButtonClicked");
2623                 Appearance.themeTweakResetDefaults();
2624                 Appearance.resetThemeTweakerUIDefaultState();
2625         },
2627         /*      “Cancel” button in main theme tweaker UI.
2628          */
2629         themeTweakerUICancelButtonClicked: (event) => {
2630                 GWLog("Appearance.themeTweakerUICancelButtonClicked");
2632                 Appearance.toggleThemeTweakerUI();
2633                 Appearance.themeTweakReset();
2634         },
2636         /*      “OK” button in main theme tweaker UI.
2637          */
2638         themeTweakerUIOKButtonClicked: (event) => {
2639                 GWLog("Appearance.themeTweakerUIOKButtonClicked");
2641                 Appearance.toggleThemeTweakerUI();
2642                 Appearance.themeTweakSave();
2643         },
2645         /*      “Cancel” button in theme tweaker help window.
2646          */
2647         themeTweakerUIHelpWindowCancelButtonClicked: (event) => {
2648                 GWLog("Appearance.themeTweakerUIHelpWindowCancelButtonClicked");
2650                 Appearance.toggleThemeTweakerHelpWindow();
2651                 Appearance.themeTweakerResetSettings();
2652         },
2654         /*      “OK” button in theme tweaker help window.
2655          */
2656         themeTweakerUIHelpWindowOKButtonClicked: (event) => {
2657                 GWLog("Appearance.themeTweakerUIHelpWindowOKButtonClicked");
2659                 Appearance.toggleThemeTweakerHelpWindow();
2660                 Appearance.themeTweakerSaveSettings();
2661         },
2663         /*      The notch in the theme tweaker sliders (to reset the slider to its
2664                 default value).
2665          */
2666         themeTweakerUISliderNotchClicked: (event) => {
2667                 GWLog("Appearance.themeTweakerUISliderNotchClicked");
2669                 let slider = event.target.parentElement.query("input[type='range']");
2670                 slider.value = slider.dataset["defaultValue"];
2671                 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset["labelSuffix"];
2672                 Appearance.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset["valueSuffix"];
2673                 Appearance.applyFilters();
2674         },
2676         /*      The close button in the “Bobby the Basilisk” help message.
2677          */
2678         themeTweakerUIClippyCloseButtonClicked: (event) => {
2679                 GWLog("Appearance.themeTweakerUIClippyCloseButtonClicked");
2681                 Appearance.themeTweakerUIClippyContainer.style.display = "none";
2682                 Appearance.themeTweakerUIClippyControl.checked = false;
2683                 Appearance.saveThemeTweakerClippyState();
2684         }
2687 function setSearchBoxTabSelectable(selectable) {
2688         GWLog("setSearchBoxTabSelectable");
2689         query("input[type='search']").tabIndex = selectable ? "" : "-1";
2690         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2693 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
2694 // otherwise, show it.
2695 function updatePostNavUIVisibility() {
2696         GWLog("updatePostNavUIVisibility");
2697         var hidePostNavUIToggle = true;
2698         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
2699                 if (getComputedStyle(element).visibility == "visible" ||
2700                         element.style.visibility == "visible" ||
2701                         element.style.visibility == "unset")
2702                         hidePostNavUIToggle = false;
2703         });
2704         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
2705                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
2706         });
2709 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
2710 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
2711 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
2712 function updateSiteNavUIState(event) {
2713         GWLog("updateSiteNavUIState");
2714         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
2715         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
2716                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
2717                                                                                                                 0;
2718         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
2719                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
2720                                                                                                          0;
2721         GW.scrollState.lastScrollTop = newScrollTop;
2723         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
2724         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
2725                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
2726                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) 
2727                         Appearance.toggleAppearanceAdjustUI();
2728         }
2730         // On mobile, make site nav UI translucent on ANY scroll down.
2731         if (GW.isMobile)
2732                 GW.scrollState.siteNavUIElements.forEach(element => {
2733                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
2734                         else element.removeClass("translucent-on-scroll");
2735                 });
2737         // Show site nav UI when scrolling a full page up, or to the top.
2738         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
2739                  GW.scrollState.lastScrollTop == 0) &&
2740                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
2741                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
2743         // On desktop, show appearance adjust UI when scrolling to the top.
2744         if ((!GW.isMobile) && 
2745                 (GW.scrollState.lastScrollTop == 0) &&
2746                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
2747                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) 
2748                         Appearance.toggleAppearanceAdjustUI();
2751 /*********************/
2752 /* PAGE QUICK-NAV UI */
2753 /*********************/
2755 function injectQuickNavUI() {
2756         GWLog("injectQuickNavUI");
2757         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2758         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
2759         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
2760         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
2761         ` + "</div>");
2764 /**********************/
2765 /* NEW COMMENT NAV UI */
2766 /**********************/
2768 function injectNewCommentNavUI(newCommentsCount) {
2769         GWLog("injectNewCommentNavUI");
2770         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
2771         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
2772         <span class='new-comments-count'></span>
2773         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
2774         + "</div>");
2776         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2777                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2778                         GWLog("GW.commentQuicknavButtonClicked");
2779                         scrollToNewComment(/next/.test(event.target.className));
2780                         event.target.blur();
2781                 });
2782         });
2784         document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => { 
2785                 GWLog("GW.commentQuicknavKeyPressed");
2786                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2787                 if (event.key == ",") scrollToNewComment(false);
2788                 if (event.key == ".") scrollToNewComment(true)
2789         });
2791         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2792         + `<span>Since:</span>`
2793         + `<input type='text' class='hns-date'></input>`
2794         + "</div>");
2796         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2797                 GWLog("GW.hnsDatePickerValueChanged");
2798                 let hnsDate = time_fromHuman(event.target.value);
2799                 if(hnsDate) {
2800                         setHistoryLastVisitedDate(hnsDate);
2801                         let newCommentsCount = highlightCommentsSince(hnsDate);
2802                         updateNewCommentNavUI(newCommentsCount);
2803                 }
2804         }, false);
2806         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2807                 GWLog("GW.newCommentsCountClicked");
2808                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2809                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2810         });
2813 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2814 function time_fromHuman(string) {
2815         /* Convert a human-readable date into a JS timestamp */
2816         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2817                 string = string.replace(' ', 'T');  // revert nice spacing
2818                 string += ':00.000Z';  // complete ISO 8601 date
2819                 time = Date.parse(string);  // milliseconds since epoch
2821                 // browsers handle ISO 8601 without explicit timezone differently
2822                 // thus, we have to fix that by hand
2823                 time += (new Date()).getTimezoneOffset() * 60e3;
2824         } else {
2825                 string = string.replace(' at', '');
2826                 time = Date.parse(string);  // milliseconds since epoch
2827         }
2828         return time;
2831 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2832         GWLog("updateNewCommentNavUI");
2833         // Update the new comments count.
2834         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2835         newCommentsCountLabel.innerText = newCommentsCount;
2836         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2838         // Update the date picker field.
2839         if (hnsDate != -1) {
2840                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2841         }
2844 /********************************/
2845 /* COMMENTS VIEW MODE SELECTION */
2846 /********************************/
2848 function injectCommentsViewModeSelector() {
2849         GWLog("injectCommentsViewModeSelector");
2850         let commentsContainer = query("#comments");
2851         if (commentsContainer == null) return;
2853         let currentModeThreaded = (location.href.search("chrono=t") == -1);
2854         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2856         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2857         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
2858         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
2859         + "</div>");
2861 //      commentsViewModeSelector.queryAll("a").forEach(button => {
2862 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2863 //      });
2865         if (!currentModeThreaded) {
2866                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2867                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2868                         commentParentLink.addClass("inline-author");
2869                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2870                 });
2872                 queryAll(".comment-child-links a").forEach(commentChildLink => {
2873                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
2874                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2875                 });
2877                 rectifyChronoModeCommentChildLinks();
2879                 commentsContainer.addClass("chrono");
2880         } else {
2881                 commentsContainer.addClass("threaded");
2882         }
2884         // Remove extraneous top-level comment thread in chrono mode.
2885         let topLevelCommentThread = query("#comments > .comment-thread");
2886         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2889 // function commentsViewModeSelectorButtonClicked(event) {
2890 //      event.preventDefault();
2891 // 
2892 //      var newDocument;
2893 //      let request = new XMLHttpRequest();
2894 //      request.open("GET", event.target.href);
2895 //      request.onreadystatechange = () => {
2896 //              if (request.readyState != 4) return;
2897 //              newDocument = htmlToElement(request.response);
2898 // 
2899 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2900 // 
2901 //              // Update the buttons.
2902 //              event.target.addClass("selected");
2903 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
2904 // 
2905 //              // Update the #comments container.
2906 //              let commentsContainer = query("#comments");
2907 //              commentsContainer.removeClass(classes.old);
2908 //              commentsContainer.addClass(classes.new);
2909 // 
2910 //              // Update the content.
2911 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2912 //      };
2913 //      request.send();
2914 // }
2915 // 
2916 // function htmlToElement(html) {
2917 //     let template = newElement("TEMPLATE", { }, { "innerHTML": html.trim() });
2918 //     return template.content;
2919 // }
2921 function rectifyChronoModeCommentChildLinks() {
2922         GWLog("rectifyChronoModeCommentChildLinks");
2923         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2924                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2925                 let childLinks = commentChildLinksContainer.queryAll("a");
2926                 childLinks.forEach((link, index) => {
2927                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2928                 });
2930                 // Sort by date.
2931                 let childLinksArray = Array.from(childLinks)
2932                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2933                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2934         });
2936 function childrenOfComment(commentID) {
2937         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2938                 let commentParentLink = commentItem.query("a.comment-parent-link");
2939                 return ((commentParentLink||{}).hash == "#" + commentID);
2940         });
2943 /********************************/
2944 /* COMMENTS LIST MODE SELECTION */
2945 /********************************/
2947 function injectCommentsListModeSelector() {
2948         GWLog("injectCommentsListModeSelector");
2949         if (query("#content > .comment-thread") == null) return;
2951         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2952         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2953         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2954         + "</div>";
2956         if (query(".sublevel-nav") || query("#top-nav-bar")) {
2957                 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2958         } else {
2959                 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2960         }
2961         let commentsListModeSelector = query("#comments-list-mode-selector");
2963         commentsListModeSelector.queryAll("button").forEach(button => {
2964                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2965                         GWLog("GW.commentsListModeSelectButtonClicked");
2966                         event.target.parentElement.queryAll("button").forEach(button => {
2967                                 button.removeClass("selected");
2968                                 button.disabled = false;
2969                                 button.accessKey = '`';
2970                         });
2971                         localStorage.setItem("comments-list-mode", event.target.className);
2972                         event.target.addClass("selected");
2973                         event.target.disabled = true;
2974                         event.target.removeAttribute("accesskey");
2976                         if (event.target.hasClass("expanded")) {
2977                                 query("#content").removeClass("compact");
2978                         } else {
2979                                 query("#content").addClass("compact");
2980                         }
2981                 });
2982         });
2984         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2985         if (savedMode == "compact")
2986                 query("#content").addClass("compact");
2987         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2988         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2989         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2991         if (GW.isMobile) {
2992                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2993                         commentParentLink.addActivateEvent(function (event) {
2994                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2995                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2996                         }, false);
2997                 });
2998         }
3001 /**********************/
3002 /* SITE NAV UI TOGGLE */
3003 /**********************/
3005 function injectSiteNavUIToggle() {
3006         GWLog("injectSiteNavUIToggle");
3007         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
3008         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
3009                 GWLog("GW.siteNavUIToggleButtonClicked");
3010                 toggleSiteNavUI();
3011                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
3012         });
3014         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
3016 function removeSiteNavUIToggle() {
3017         GWLog("removeSiteNavUIToggle");
3018         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3019                 element.removeClass("engaged");
3020         });
3021         removeElement("#site-nav-ui-toggle");
3023 function toggleSiteNavUI() {
3024         GWLog("toggleSiteNavUI");
3025         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3026                 element.toggleClass("engaged");
3027                 element.removeClass("translucent-on-scroll");
3028         });
3031 /**********************/
3032 /* POST NAV UI TOGGLE */
3033 /**********************/
3035 function injectPostNavUIToggle() {
3036         GWLog("injectPostNavUIToggle");
3037         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
3038         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
3039                 GWLog("GW.postNavUIToggleButtonClicked");
3040                 togglePostNavUI();
3041                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
3042         });
3044         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
3046 function removePostNavUIToggle() {
3047         GWLog("removePostNavUIToggle");
3048         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3049                 element.removeClass("engaged");
3050         });
3051         removeElement("#post-nav-ui-toggle");
3053 function togglePostNavUI() {
3054         GWLog("togglePostNavUI");
3055         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3056                 element.toggleClass("engaged");
3057         });
3060 /**************************/
3061 /* WORD COUNT & READ TIME */
3062 /**************************/
3064 function toggleReadTimeOrWordCount(addWordCountClass) {
3065         GWLog("toggleReadTimeOrWordCount");
3066         queryAll(".post-meta .read-time").forEach(element => {
3067                 if (addWordCountClass) element.addClass("word-count");
3068                 else element.removeClass("word-count");
3070                 let titleParts = /(\S+)(.+)$/.exec(element.title);
3071                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
3072         });
3075 /**************************/
3076 /* PROMPT TO SAVE CHANGES */
3077 /**************************/
3079 function enableBeforeUnload() {
3080         window.onbeforeunload = function () { return true; };
3082 function disableBeforeUnload() {
3083         window.onbeforeunload = null;
3086 /***************************/
3087 /* ORIGINAL POSTER BADGING */
3088 /***************************/
3090 function markOriginalPosterComments() {
3091         GWLog("markOriginalPosterComments");
3092         let postAuthor = query(".post .author");
3093         if (postAuthor == null) return;
3095         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
3096                 if (author.dataset.userid == postAuthor.dataset.userid ||
3097                         (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
3098                         author.addClass("original-poster");
3099                         author.title += "Original poster";
3100                 }
3101         });
3104 /********************************/
3105 /* EDIT POST PAGE SUBMIT BUTTON */
3106 /********************************/
3108 function setEditPostPageSubmitButtonText() {
3109         GWLog("setEditPostPageSubmitButtonText");
3110         if (!query("#content").hasClass("edit-post-page")) return;
3112         queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
3113                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
3114                         GWLog("GW.postSectionSelectorValueChanged");
3115                         updateEditPostPageSubmitButtonText();
3116                 });
3117         });
3119         updateEditPostPageSubmitButtonText();
3121 function updateEditPostPageSubmitButtonText() {
3122         GWLog("updateEditPostPageSubmitButtonText");
3123         let submitButton = query("input[type='submit']");
3124         if (query("input#drafts").checked == true) 
3125                 submitButton.value = "Save Draft";
3126         else if (query(".posting-controls").hasClass("edit-existing-post"))
3127                 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
3128         else
3129                 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
3132 /*****************/
3133 /* ANTI-KIBITZER */
3134 /*****************/
3136 function numToAlpha(n) {
3137         let ret = "";
3138         do {
3139                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
3140                 n = Math.floor((n / 26) - 1);
3141         } while (n >= 0);
3142         return ret;
3145 function activateAntiKibitzer() {
3146         GWLog("activateAntiKibitzer");
3148         //      Activate anti-kibitzer mode (if needed).
3149         if (localStorage.getItem("antikibitzer") == "true")
3150                 toggleAntiKibitzerMode();
3152         //      Remove temporary CSS that hides the authors and karma values.
3153         removeElement("#antikibitzer-temp");
3155         //      Inject controls (if desktop).
3156         if (GW.isMobile == false)
3157                 injectAntiKibitzerToggle();
3160 function injectAntiKibitzerToggle() {
3161         GWLog("injectAntiKibitzerToggle");
3163         let antiKibitzerHTML = `<div id="anti-kibitzer-toggle">
3164                 <button type="button" tabindex="-1" accesskey="g" title="Toggle anti-kibitzer (show/hide authors & karma values) [g]"></button>
3165         </div>`;
3167         if (GW.isMobile) {
3168                 if (Appearance.themeSelector == null)
3169                         return;
3171                 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", antiKibitzerHTML);
3172         } else {
3173                 addUIElement(antiKibitzerHTML); 
3174         }
3176         //      Activate anti-kibitzer toggle button.
3177         query("#anti-kibitzer-toggle button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
3178                 GWLog("GW.antiKibitzerToggleButtonClicked");
3179                 if (   query("#anti-kibitzer-toggle").hasClass("engaged")
3180                         && !event.shiftKey 
3181                         && !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!)")) {
3182                         event.target.blur();
3183                         return;
3184                 }
3186                 toggleAntiKibitzerMode();
3187                 event.target.blur();
3188         });
3191 function toggleAntiKibitzerMode() {
3192         GWLog("toggleAntiKibitzerMode");
3193         // This will be the URL of the user's own page, if logged in, or the URL of
3194         // the login page otherwise.
3195         let userTabTarget = query("#nav-item-login .nav-inner").href;
3196         let pageHeadingElement = query("h1.page-main-heading");
3198         let userCount = 0;
3199         let userFakeName = { };
3201         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
3203         let postAuthor = query(".post-page .post-meta .author");
3204         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
3206         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
3207         if (antiKibitzerToggle.hasClass("engaged")) {
3208                 localStorage.setItem("antikibitzer", "false");
3210                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
3211                 if (redirectTarget) {
3212                         window.location = redirectTarget;
3213                         return;
3214                 }
3216                 // Individual comment page title and header
3217                 if (query(".individual-thread-page")) {
3218                         let replacer = (node) => {
3219                                 if (!node) return;
3220                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
3221                         }
3222                         replacer(query("title:not(.fake-title)"));
3223                         replacer(query("#content > h1"));
3224                 }
3226                 // Author names/links.
3227                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
3228                         author.textContent = author.dataset["trueName"];
3229                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
3231                         author.removeClass("redacted");
3232                 });
3233                 // Post/comment karma values.
3234                 queryAll(".karma-value.redacted").forEach(karmaValue => {
3235                         karmaValue.innerHTML = karmaValue.dataset["trueValue"];
3237                         karmaValue.removeClass("redacted");
3238                 });
3239                 // Link post domains.
3240                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
3241                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
3243                         linkPostDomain.removeClass("redacted");
3244                 });
3246                 antiKibitzerToggle.removeClass("engaged");
3247         } else {
3248                 localStorage.setItem("antikibitzer", "true");
3250                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
3251                 if (redirectTarget) {
3252                         window.location = redirectTarget;
3253                         return;
3254                 }
3256                 // Individual comment page title and header
3257                 if (query(".individual-thread-page")) {
3258                         let replacer = (node) => {
3259                                 if (!node) return;
3260                                 node.dataset["trueContent"] = node.firstChild.wholeText;
3261                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
3262                                 node.firstChild.replaceWith(newText);
3263                         }
3264                         replacer(query("title:not(.fake-title)"));
3265                         replacer(query("#content > h1"));
3266                 }
3268                 removeElement("title.fake-title");
3270                 // Author names/links.
3271                 queryAll(".author, .inline-author").forEach(author => {
3272                         // Skip own posts/comments.
3273                         if (author.hasClass("own-user-author"))
3274                                 return;
3276                         let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
3278                         if(!userid) return;
3280                         author.dataset["trueName"] = author.textContent;
3281                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
3283                         if (/\/user/.test(author.href)) {
3284                                 author.dataset["trueLink"] = author.pathname;
3285                                 author.href = "/user?id=" + author.dataset["userid"];
3286                         }
3288                         author.addClass("redacted");
3289                 });
3290                 // Post/comment karma values.
3291                 queryAll(".karma-value").forEach(karmaValue => {
3292                         // Skip own posts/comments.
3293                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
3294                                 return;
3296                         karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
3297                         karmaValue.innerHTML = "##<span> points</span>";
3299                         karmaValue.addClass("redacted");
3300                 });
3301                 // Link post domains.
3302                 queryAll(".link-post-domain").forEach(linkPostDomain => {
3303                         // Skip own posts/comments.
3304                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
3305                                 return;
3307                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
3308                         linkPostDomain.textContent = "redacted.domain.tld";
3310                         linkPostDomain.addClass("redacted");
3311                 });
3313                 antiKibitzerToggle.addClass("engaged");
3314         }
3317 /*******************************/
3318 /* COMMENT SORT MODE SELECTION */
3319 /*******************************/
3321 var CommentSortMode = Object.freeze({
3322         TOP:            "top",
3323         NEW:            "new",
3324         OLD:            "old",
3325         HOT:            "hot"
3327 function sortComments(mode) {
3328         GWLog("sortComments");
3329         let commentsContainer = query("#comments");
3331         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
3332         commentsContainer.addClass("sorting");
3334         GW.commentValues = { };
3335         let clonedCommentsContainer = commentsContainer.cloneNode(true);
3336         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
3337                 var comparator;
3338                 switch (mode) {
3339                 case CommentSortMode.NEW:
3340                         comparator = (a,b) => commentDate(b) - commentDate(a);
3341                         break;
3342                 case CommentSortMode.OLD:
3343                         comparator = (a,b) => commentDate(a) - commentDate(b);
3344                         break;
3345                 case CommentSortMode.HOT:
3346                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
3347                         break;
3348                 case CommentSortMode.TOP:
3349                 default:
3350                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
3351                         break;
3352                 }
3353                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
3354         });
3355         removeElement(commentsContainer.lastChild);
3356         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
3357         GW.commentValues = { };
3359         if (loggedInUserId) {
3360                 // Re-activate vote buttons.
3361                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
3362                         voteButton.addActivateEvent(voteButtonClicked);
3363                 });
3365                 // Re-activate comment action buttons.
3366                 commentsContainer.queryAll(".action-button").forEach(button => {
3367                         button.addActivateEvent(GW.commentActionButtonClicked);
3368                 });
3369         }
3371         // Re-activate comment-minimize buttons.
3372         queryAll(".comment-minimize-button").forEach(button => {
3373                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
3374         });
3376         // Re-add comment parent popups.
3377         addCommentParentPopups();
3378         
3379         // Redo new-comments highlighting.
3380         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
3382         requestAnimationFrame(() => {
3383                 commentsContainer.removeClass("sorting");
3384                 commentsContainer.addClass("sorted-" + mode);
3385         });
3387 function commentKarmaValue(commentOrSelector) {
3388         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3389         try {
3390                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
3391         } catch(e) {return null};
3393 function commentDate(commentOrSelector) {
3394         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3395         try {
3396                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
3397         } catch(e) {return null};
3399 function commentVoteCount(commentOrSelector) {
3400         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3401         try {
3402                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
3403         } catch(e) {return null};
3406 function injectCommentsSortModeSelector() {
3407         GWLog("injectCommentsSortModeSelector");
3408         let topCommentThread = query("#comments > .comment-thread");
3409         if (topCommentThread == null) return;
3411         // Do not show sort mode selector if there is no branching in comment tree.
3412         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
3414         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
3415                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
3416                 "</div>";
3417         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
3418         let commentsSortModeSelector = query("#comments-sort-mode-selector");
3420         commentsSortModeSelector.queryAll("button").forEach(button => {
3421                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
3422                         GWLog("GW.commentsSortModeSelectButtonClicked");
3423                         event.target.parentElement.queryAll("button").forEach(button => {
3424                                 button.removeClass("selected");
3425                                 button.disabled = false;
3426                         });
3427                         event.target.addClass("selected");
3428                         event.target.disabled = true;
3430                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
3431                         setCommentsSortModeSelectButtonsAccesskey();
3432                 });
3433         });
3435         // TODO: Make this actually get the current sort mode (if that's saved).
3436         // TODO: Also change the condition here to properly get chrono/threaded mode,
3437         // when that is properly done with cookies.
3438         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
3439         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
3440         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
3441         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
3442         setCommentsSortModeSelectButtonsAccesskey();
3445 function setCommentsSortModeSelectButtonsAccesskey() {
3446         GWLog("setCommentsSortModeSelectButtonsAccesskey");
3447         queryAll("#comments-sort-mode-selector button").forEach(button => {
3448                 button.removeAttribute("accesskey");
3449                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
3450         });
3451         let selectedButton = query("#comments-sort-mode-selector button.selected");
3452         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
3453         nextButtonInCycle.accessKey = "z";
3454         nextButtonInCycle.title += " [z]";
3457 /*************************/
3458 /* COMMENT PARENT POPUPS */
3459 /*************************/
3461 function previewPopupsEnabled() {
3462         let isDisabled = localStorage.getItem("preview-popups-disabled");
3463         return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
3466 function setPreviewPopupsEnabled(state) {
3467         localStorage.setItem("preview-popups-disabled", !state);
3468         updatePreviewPopupToggle();
3471 function updatePreviewPopupToggle() {
3472         let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
3473         query("#preview-popup-toggle").setAttribute("style", style);
3476 function injectPreviewPopupToggle() {
3477         GWLog("injectPreviewPopupToggle");
3479         let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
3480         // This is required because Chrome can't use filters on an externally used SVG element.
3481         fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
3482         updatePreviewPopupToggle();
3483         toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
3486 var currentPreviewPopup = { };
3488 function removePreviewPopup(previewPopup) {
3489         if(previewPopup.element)
3490                 removeElement(previewPopup.element);
3492         if(previewPopup.timeout)
3493                 clearTimeout(previewPopup.timeout);
3495         if(currentPreviewPopup.pointerListener)
3496                 window.removeEventListener("pointermove", previewPopup.pointerListener);
3498         if(currentPreviewPopup.mouseoutListener)
3499                 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3501         if(currentPreviewPopup.scrollListener)
3502                 window.removeEventListener("scroll", previewPopup.scrollListener);
3504         currentPreviewPopup = { };
3507 document.addEventListener("visibilitychange", () => {
3508         if(document.visibilityState != "visible") {
3509                 removePreviewPopup(currentPreviewPopup);
3510         }
3513 function addCommentParentPopups() {
3514         GWLog("addCommentParentPopups");
3515         //if (!query("#content").hasClass("comment-thread-page")) return;
3517         queryAll("a[href]").forEach(linkTag => {
3518                 let linkHref = linkTag.getAttribute("href");
3520                 let url;
3521                 try { url = new URL(linkHref, window.location.href); }
3522                 catch(e) { }
3523                 if(!url) return;
3525                 if(GW.sites[url.host]) {
3526                         let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
3527                         
3528                         if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
3529                                 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
3530                                         if(event.pointerType == "touch") return;
3531                                         GWLog("GW.commentParentLinkMouseOver");
3532                                         removePreviewPopup(currentPreviewPopup);
3533                                         let parentID = linkHref;
3534                                         var parent, popup;
3535                                         if (!(parent = (query(parentID)||{}).firstChild)) return;
3536                                         var highlightClassName;
3537                                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
3538                                                 parentHighlightClassName = "comment-item-highlight-faint";
3539                                                 popup = parent.cloneNode(true);
3540                                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
3541                                                 linkTag.addEventListener("mouseout", (event) => {
3542                                                         removeElement(popup);
3543                                                 }, {once: true});
3544                                                 linkTag.closest(".comments > .comment-thread").appendChild(popup);
3545                                         } else {
3546                                                 parentHighlightClassName = "comment-item-highlight";
3547                                         }
3548                                         parent.parentNode.addClass(parentHighlightClassName);
3549                                         linkTag.addEventListener("mouseout", (event) => {
3550                                                 parent.parentNode.removeClass(parentHighlightClassName);
3551                                         }, {once: true});
3552                                 });
3553                         }
3554                         else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
3555                                 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
3556                                 && !(url.searchParams.get('format'))
3557                                 && !linkTag.closest("nav:not(.post-nav-links)")
3558                                 && (!url.hash || linkCommentId)
3559                                 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
3560                                 linkTag.addEventListener("pointerover", event => {
3561                                         if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
3562                                         if(currentPreviewPopup.linkTag) return;
3563                                         linkTag.createPreviewPopup();
3564                                 });
3565                                 linkTag.createPreviewPopup = function() {
3566                                         removePreviewPopup(currentPreviewPopup);
3568                                         currentPreviewPopup = {linkTag: linkTag};
3569                                         
3570                                         let popup = newElement("IFRAME");
3571                                         currentPreviewPopup.element = popup;
3573                                         let popupTarget = linkHref;
3574                                         if(popupTarget.match(/#comment-/)) {
3575                                                 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
3576                                         }
3577                                         // 'theme' attribute is required for proper caching
3578                                         popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview");
3579                                         popup.addClass("preview-popup");
3580                                         
3581                                         let linkRect = linkTag.getBoundingClientRect();
3583                                         if(linkRect.right + 710 < window.innerWidth)
3584                                                 popup.style.left = linkRect.right + 10 + "px";
3585                                         else
3586                                                 popup.style.right = "10px";
3588                                         popup.style.width = "700px";
3589                                         popup.style.height = "500px";
3590                                         popup.style.visibility = "hidden";
3591                                         popup.style.transition = "none";
3593                                         let recenter = function() {
3594                                                 let popupHeight = 500;
3595                                                 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
3596                                                         let popupContent = popup.contentDocument.querySelector("#content");
3597                                                         if(popupContent) {
3598                                                                 popupHeight = popupContent.clientHeight + 2;
3599                                                                 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
3600                                                                 popup.style.height = popupHeight + "px";
3601                                                         }
3602                                                 }
3603                                                 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
3604                                         }
3606                                         recenter();
3608                                         query('#content').insertAdjacentElement("beforeend", popup);
3610                                         let clickListener = event => {
3611                                                 if(!event.target.closest("a, input, label")
3612                                                    && !event.target.closest("popup-hide-button")) {
3613                                                         window.location = linkHref;
3614                                                 }
3615                                         };
3617                                         popup.addEventListener("load", () => {
3618                                                 let hideButton = newElement("DIV", {
3619                                                         "class": "popup-hide-button"
3620                                                 }, {
3621                                                         "innerHTML": "&#xf070;"
3622                                                 });
3623                                                 hideButton.onclick = (event) => {
3624                                                         removePreviewPopup(currentPreviewPopup);
3625                                                         setPreviewPopupsEnabled(false);
3626                                                         event.stopPropagation();
3627                                                 }
3628                                                 popup.contentDocument.body.appendChild(hideButton);
3629                                                 
3630                                                 let popupBody = popup.contentDocument.body;
3631                                                 popupBody.addEventListener("click", clickListener);
3632                                                 popupBody.style.cursor = "pointer";
3634                                                 recenter();
3635                                         });
3637                                         popup.contentDocument.body.addEventListener("click", clickListener);
3638                                         
3639                                         currentPreviewPopup.timeout = setTimeout(() => {
3640                                                 recenter();
3642                                                 requestIdleCallback(() => {
3643                                                         if(currentPreviewPopup.element === popup) {
3644                                                                 popup.scrolling = "";
3645                                                                 popup.style.visibility = "unset";
3646                                                                 popup.style.transition = null;
3648                                                                 popup.animate([
3649                                                                         { opacity: 0, transform: "translateY(10%)" },
3650                                                                         { opacity: 1, transform: "none" }
3651                                                                 ], { duration: 150, easing: "ease-out" });
3652                                                         }
3653                                                 });
3654                                         }, 1000);
3656                                         let pointerX, pointerY, mousePauseTimeout = null;
3658                                         currentPreviewPopup.pointerListener = (event) => {
3659                                                 pointerX = event.clientX;
3660                                                 pointerY = event.clientY;
3662                                                 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3663                                                 mousePauseTimeout = null;
3665                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3666                                                 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3668                                                 if(mouseIsOverLink || overElement === popup
3669                                                    || (pointerX < popup.getBoundingClientRect().left
3670                                                        && event.movementX >= 0)) {
3671                                                         if(!mouseIsOverLink && overElement !== popup) {
3672                                                                 if(overElement['createPreviewPopup']) {
3673                                                                         mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3674                                                                 } else {
3675                                                                         mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3676                                                                 }
3677                                                         }
3678                                                 } else {
3679                                                         removePreviewPopup(currentPreviewPopup);
3680                                                         if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3681                                                 }
3682                                         };
3683                                         window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3685                                         currentPreviewPopup.mouseoutListener = (event) => {
3686                                                 clearTimeout(mousePauseTimeout);
3687                                                 mousePauseTimeout = null;
3688                                         }
3689                                         document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3691                                         currentPreviewPopup.scrollListener = (event) => {
3692                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3693                                                 linkRect = linkTag.getBoundingClientRect();
3694                                                 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3695                                                 removePreviewPopup(currentPreviewPopup);
3696                                         };
3697                                         window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3698                                 };
3699                         }
3700                 }
3701         });
3702         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3703                 
3704         });
3706         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3707         Appearance.filtersExclusionPaths.commentParentPopups = [
3708                 "#content .comments .comment-thread"
3709         ];
3710         Appearance.applyFilters();
3713 /***************/
3714 /* IMAGE FOCUS */
3715 /***************/
3717 function imageFocusSetup(imagesOverlayOnly = false) {
3718         if (typeof GW.imageFocus == "undefined")
3719                 GW.imageFocus = {
3720                         contentImagesSelector:  "#content img",
3721                         overlayImagesSelector:  "#images-overlay img",
3722                         focusedImageSelector:   "#content img.focused, #images-overlay img.focused",
3723                         pageContentSelector:    "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3724                         shrinkRatio:                    0.975,
3725                         hideUITimerDuration:    1500,
3726                         hideUITimerExpired:             () => {
3727                                 GWLog("GW.imageFocus.hideUITimerExpired");
3728                                 let currentTime = new Date();
3729                                 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3730                                 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3731                                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3732                                 } else {
3733                                         hideImageFocusUI();
3734                                         cancelImageFocusHideUITimer();
3735                                 }
3736                         }
3737                 };
3739         GWLog("imageFocusSetup");
3740         // Create event listener for clicking on images to focus them.
3741         GW.imageClickedToFocus = (event) => {
3742                 GWLog("GW.imageClickedToFocus");
3743                 focusImage(event.target);
3745                 if (!GW.isMobile) {
3746                         // Set timer to hide the image focus UI.
3747                         unhideImageFocusUI();
3748                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3749                 }
3750         };
3751         // Add the listener to each image in the overlay (i.e., those in the post).
3752         queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3753                 image.addActivateEvent(GW.imageClickedToFocus);
3754         });
3755         // Accesskey-L starts the slideshow.
3756         (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3757         // Count how many images there are in the post, and set the "… of X" label to that.
3758         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3759         if (imagesOverlayOnly) return;
3760         // Add the listener to all other content images (including those in comments).
3761         queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3762                 image.addActivateEvent(GW.imageClickedToFocus);
3763         });
3765         // Create the image focus overlay.
3766         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
3767         `<div class='help-overlay'>
3768                  <p><strong>Arrow keys:</strong> Next/previous image</p>
3769                  <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3770                  <p><strong>Space bar:</strong> Reset image size & position</p>
3771                  <p><strong>Scroll</strong> to zoom in/out</p>
3772                  <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3773         </div>
3774         <div class='image-number'></div>
3775         <div class='slideshow-buttons'>
3776                  <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
3777                  <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
3778         </div>
3779         <div class='caption'></div>` + 
3780         "</div>");
3781         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3783         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3784                 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3785                         GWLog("GW.imageFocus.slideshowButtonClicked");
3786                         focusNextImage(event.target.hasClass("next"));
3787                         event.target.blur();
3788                 });
3789         });
3791         // On orientation change, reset the size & position.
3792         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3793                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3794         }
3796         // UI starts out hidden.
3797         hideImageFocusUI();
3800 function focusImage(imageToFocus) {
3801         GWLog("focusImage");
3802         // Clear 'last-focused' class of last focused image.
3803         let lastFocusedImage = query("img.last-focused");
3804         if (lastFocusedImage) {
3805                 lastFocusedImage.removeClass("last-focused");
3806                 lastFocusedImage.removeAttribute("accesskey");
3807         }
3809         // Create the focused version of the image.
3810         imageToFocus.addClass("focused");
3811         let imageFocusOverlay = query("#image-focus-overlay");
3812         let clonedImage = imageToFocus.cloneNode(true);
3813         clonedImage.style = "";
3814         clonedImage.removeAttribute("width");
3815         clonedImage.removeAttribute("height");
3816         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3817         imageFocusOverlay.appendChild(clonedImage);
3818         imageFocusOverlay.addClass("engaged");
3820         // Set image to default size and position.
3821         resetFocusedImagePosition();
3823         // Blur everything else.
3824         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3825                 element.addClass("blurred");
3826         });
3828         // Add listener to zoom image with scroll wheel.
3829         window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3830                 GWLog("GW.imageFocus.scrollEvent");
3831                 event.preventDefault();
3833                 let image = query("#image-focus-overlay img");
3835                 // Remove the filter.
3836                 image.savedFilter = image.style.filter;
3837                 image.style.filter = 'none';
3839                 // Locate point under cursor.
3840                 let imageBoundingBox = image.getBoundingClientRect();
3842                 // Calculate resize factor.
3843                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3844                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3845                                                 1;
3847                 // Resize.
3848                 image.style.width = (event.deltaY < 0 ?
3849                                                         (image.clientWidth * factor) :
3850                                                         (image.clientWidth / factor))
3851                                                         + "px";
3852                 image.style.height = "";
3854                 // Designate zoom origin.
3855                 var zoomOrigin;
3856                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3857                 // the cursor is over the image.
3858                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3859                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3860                                                                 (imageBoundingBox.left <= event.clientX &&
3861                                                                  event.clientX <= imageBoundingBox.right && 
3862                                                                  imageBoundingBox.top <= event.clientY &&
3863                                                                  event.clientY <= imageBoundingBox.bottom);
3864                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
3865                 // zooming IN, zoom from image center.
3866                 let zoomingFromWindowCenter = event.deltaY > 0;
3867                 if (zoomingFromCursor)
3868                         zoomOrigin = { x: event.clientX, 
3869                                                    y: event.clientY };
3870                 else if (zoomingFromWindowCenter)
3871                         zoomOrigin = { x: window.innerWidth / 2, 
3872                                                    y: window.innerHeight / 2 };
3873                 else
3874                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
3875                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3877                 // Calculate offset from zoom origin.
3878                 let offsetOfImageFromZoomOrigin = {
3879                         x: imageBoundingBox.x - zoomOrigin.x,
3880                         y: imageBoundingBox.y - zoomOrigin.y
3881                 }
3882                 // Calculate delta from centered zoom.
3883                 let deltaFromCenteredZoom = {
3884                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3885                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3886                 }
3887                 // Adjust image position appropriately.
3888                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3889                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3890                 // Gradually re-center image, if it's smaller than the window.
3891                 if (!imageSizeExceedsWindowBounds) {
3892                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
3893                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3894                         let windowCenter = { x: window.innerWidth / 2,
3895                                                                  y: window.innerHeight / 2 }
3896                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3897                                                                                   y: windowCenter.y - imageCenter.y }
3898                         // Divide the offset by 10 because we're nudging the image toward center,
3899                         // not jumping it there.
3900                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3901                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3902                 }
3904                 // Put the filter back.
3905                 image.style.filter = image.savedFilter;
3907                 // Set the cursor appropriately.
3908                 setFocusedImageCursor();
3909         });
3910         window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3911                 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3912                 event.preventDefault();
3913         });
3915         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3916         window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3917                 GWLog("GW.imageFocus.mouseUp");
3918                 window.onmousemove = '';
3920                 // We only want to do anything on left-clicks.
3921                 if (event.button != 0) return;
3923                 // Don't unfocus if click was on a slideshow next/prev button!
3924                 if (event.target.hasClass("slideshow-button")) return;
3926                 // We also don't want to do anything if clicked on the help overlay.
3927                 if (event.target.classList.contains("help-overlay") ||
3928                         event.target.closest(".help-overlay"))
3929                         return;
3931                 let focusedImage = query("#image-focus-overlay img");
3932                 if (event.target == focusedImage && 
3933                         (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3934                         // If the mouseup event was the end of a pan of an overside image,
3935                         // put the filter back; do not unfocus.
3936                         focusedImage.style.filter = focusedImage.savedFilter;
3937                 } else {
3938                         unfocusImageOverlay();
3939                         return;
3940                 }
3941         });
3942         window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3943                 GWLog("GW.imageFocus.mouseDown");
3944                 event.preventDefault();
3946                 let focusedImage = query("#image-focus-overlay img");
3947                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3948                         let mouseCoordX = event.clientX;
3949                         let mouseCoordY = event.clientY;
3951                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3952                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3954                         // Save the filter.
3955                         focusedImage.savedFilter = focusedImage.style.filter;
3957                         window.onmousemove = (event) => {
3958                                 // Remove the filter.
3959                                 focusedImage.style.filter = "none";
3960                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3961                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3962                         };
3963                         return false;
3964                 }
3965         });
3967         // Double-click on the image unfocuses.
3968         clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3969                 GWLog("GW.imageFocus.doubleClick");
3970                 if (event.target.hasClass("slideshow-button")) return;
3972                 unfocusImageOverlay();
3973         });
3975         // Escape key unfocuses, spacebar resets.
3976         document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3977                 GWLog("GW.imageFocus.keyUp");
3978                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3979                 if (!allowedKeys.contains(event.key) || 
3980                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3982                 event.preventDefault();
3984                 switch (event.key) {
3985                 case "Escape": 
3986                 case "Esc":
3987                         unfocusImageOverlay();
3988                         break;
3989                 case " ":
3990                 case "Spacebar":
3991                         resetFocusedImagePosition();
3992                         break;
3993                 case "ArrowDown":
3994                 case "Down":
3995                 case "ArrowRight":
3996                 case "Right":
3997                         if (query("#images-overlay img.focused")) focusNextImage(true);
3998                         break;
3999                 case "ArrowUp":
4000                 case "Up":
4001                 case "ArrowLeft":
4002                 case "Left":
4003                         if (query("#images-overlay img.focused")) focusNextImage(false);
4004                         break;
4005                 }
4006         });
4008         // Prevent spacebar or arrow keys from scrolling page when image focused.
4009         togglePageScrolling(false);
4011         // If the image comes from the images overlay, for the main post...
4012         if (imageToFocus.closest("#images-overlay")) {
4013                 // Mark the overlay as being in slide show mode (to show buttons/count).
4014                 imageFocusOverlay.addClass("slideshow");
4016                 // Set state of next/previous buttons.
4017                 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4018                 var indexOfFocusedImage = getIndexOfFocusedImage();
4019                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4020                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4022                 // Set the image number.
4023                 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4025                 // Replace the hash.
4026                 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4027         } else {
4028                 imageFocusOverlay.removeClass("slideshow");
4029         }
4031         // Set the caption.
4032         setImageFocusCaption();
4034         // Moving mouse unhides image focus UI.
4035         window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
4036                 GWLog("GW.imageFocus.mouseMoved");
4037                 let currentDateTime = new Date();
4038                 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
4039                         cancelImageFocusHideUITimer();
4040                 } else {
4041                         if (!GW.imageFocus.hideUITimer) {
4042                                 unhideImageFocusUI();
4043                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4044                         }
4045                         GW.imageFocus.mouseLastMovedAt = currentDateTime;
4046                 }
4047         });
4050 function resetFocusedImagePosition() {
4051         GWLog("resetFocusedImagePosition");
4052         let focusedImage = query("#image-focus-overlay img");
4053         if (!focusedImage) return;
4055         let sourceImage = query(GW.imageFocus.focusedImageSelector);
4057         // Make sure that initially, the image fits into the viewport.
4058         let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
4059         let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
4060         var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
4061         let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
4062         let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
4063         focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
4064         focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
4066         // Remove modifications to position.
4067         focusedImage.style.left = "";
4068         focusedImage.style.top = "";
4070         // Set the cursor appropriately.
4071         setFocusedImageCursor();
4073 function setFocusedImageCursor() {
4074         let focusedImage = query("#image-focus-overlay img");
4075         if (!focusedImage) return;
4076         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
4077                                                                 'move' : '';
4080 function unfocusImageOverlay() {
4081         GWLog("unfocusImageOverlay");
4083         // Remove event listeners.
4084         window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
4085         window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
4086         // NOTE: The double-click listener does not need to be removed manually,
4087         // because the focused (cloned) image will be removed anyway.
4088         document.removeEventListener("keyup", GW.imageFocus.keyUp);
4089         document.removeEventListener("keydown", GW.imageFocus.keyDown);
4090         window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
4091         window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
4092         window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
4094         // Set accesskey of currently focused image (if it's in the images overlay).
4095         let currentlyFocusedImage = query("#images-overlay img.focused");
4096         if (currentlyFocusedImage) {
4097                 currentlyFocusedImage.addClass("last-focused");
4098                 currentlyFocusedImage.accessKey = 'l';
4099         }
4101         // Remove focused image and hide overlay.
4102         let imageFocusOverlay = query("#image-focus-overlay");
4103         imageFocusOverlay.removeClass("engaged");
4104         removeElement(imageFocusOverlay.query("img"));
4106         // Un-blur content/etc.
4107         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
4108                 element.removeClass("blurred");
4109         });
4111         // Unset "focused" class of focused image.
4112         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4114         // Re-enable page scrolling.
4115         togglePageScrolling(true);
4117         // Reset the hash, if needed.
4118         if (location.hash.hasPrefix("#if_slide_"))
4119                 history.replaceState(window.history.state, null, "#");
4122 function getIndexOfFocusedImage() {
4123         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4124         var indexOfFocusedImage = -1;
4125         for (i = 0; i < images.length; i++) {
4126                 if (images[i].hasClass("focused")) {
4127                         indexOfFocusedImage = i;
4128                         break;
4129                 }
4130         }
4131         return indexOfFocusedImage;
4134 function focusNextImage(next = true) {
4135         GWLog("focusNextImage");
4136         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4137         var indexOfFocusedImage = getIndexOfFocusedImage();
4139         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
4141         // Remove existing image.
4142         removeElement("#image-focus-overlay img");
4143         // Unset "focused" class of just-removed image.
4144         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4146         // Create the focused version of the image.
4147         images[indexOfFocusedImage].addClass("focused");
4148         let imageFocusOverlay = query("#image-focus-overlay");
4149         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
4150         clonedImage.style = "";
4151         clonedImage.removeAttribute("width");
4152         clonedImage.removeAttribute("height");
4153         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
4154         imageFocusOverlay.appendChild(clonedImage);
4155         imageFocusOverlay.addClass("engaged");
4156         // Set image to default size and position.
4157         resetFocusedImagePosition();
4158         // Set state of next/previous buttons.
4159         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4160         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4161         // Set the image number display.
4162         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4163         // Set the caption.
4164         setImageFocusCaption();
4165         // Replace the hash.
4166         history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4169 function setImageFocusCaption() {
4170         GWLog("setImageFocusCaption");
4171         var T = { }; // Temporary storage.
4173         // Clear existing caption, if any.
4174         let captionContainer = query("#image-focus-overlay .caption");
4175         Array.from(captionContainer.children).forEach(child => { child.remove(); });
4177         // Determine caption.
4178         let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
4179         var captionHTML;
4180         if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) && 
4181                 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
4182                 captionHTML = (T.figcaption.query("p")) ? 
4183                                           T.figcaption.innerHTML : 
4184                                           "<p>" + T.figcaption.innerHTML + "</p>"; 
4185         } else if (currentlyFocusedImage.title != "") {
4186                 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
4187         }
4188         // Insert the caption, if any.
4189         if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
4192 function hideImageFocusUI() {
4193         GWLog("hideImageFocusUI");
4194         let imageFocusOverlay = query("#image-focus-overlay");
4195         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4196                 element.addClass("hidden");
4197         });
4200 function unhideImageFocusUI() {
4201         GWLog("unhideImageFocusUI");
4202         let imageFocusOverlay = query("#image-focus-overlay");
4203         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4204                 element.removeClass("hidden");
4205         });
4208 function cancelImageFocusHideUITimer() {
4209         clearTimeout(GW.imageFocus.hideUITimer);
4210         GW.imageFocus.hideUITimer = null;
4213 /*****************/
4214 /* KEYBOARD HELP */
4215 /*****************/
4217 function keyboardHelpSetup() {
4218         let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
4219                 <div class='keyboard-help-container'>
4220                         <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'>&#xf00d;</button>
4221                         <h1>Keyboard shortcuts</h1>
4222                         <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>
4223                         <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
4224                         <div class='keyboard-shortcuts-lists'>` + [ [
4225                                 "General",
4226                                 [ [ '?' ], "Show keyboard shortcuts" ],
4227                                 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
4228                         ], [
4229                                 "Site navigation",
4230                                 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
4231                                 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
4232                                 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
4233                                 [ [ 'ak-m' ], "Go to Meta view" ],
4234                                 [ [ 'ak-v' ], "Go to Tags view"],
4235                                 [ [ 'ak-c' ], "Go to Recent Comments view" ],
4236                                 [ [ 'ak-r' ], "Go to Archive view" ],
4237                                 [ [ 'ak-q' ], "Go to Sequences view" ],
4238                                 [ [ 'ak-t' ], "Go to About page" ],
4239                                 [ [ 'ak-u' ], "Go to User or Login page" ],
4240                                 [ [ 'ak-o' ], "Go to Inbox page" ]
4241                         ], [
4242                                 "Page navigation",
4243                                 [ [ 'ak-,' ], "Jump up to top of page" ],
4244                                 [ [ 'ak-.' ], "Jump down to bottom of page" ],
4245                                 [ [ 'ak-/' ], "Jump to top of comments section" ],
4246                                 [ [ 'ak-s' ], "Search" ],
4247                         ], [
4248                                 "Page actions",
4249                                 [ [ 'ak-n' ], "New post or comment" ],
4250                                 [ [ 'ak-e' ], "Edit current post" ]
4251                         ], [
4252                                 "Post/comment list views",
4253                                 [ [ '.' ], "Focus next entry in list" ],
4254                                 [ [ ',' ], "Focus previous entry in list" ],
4255                                 [ [ ';' ], "Cycle between links in focused entry" ],
4256                                 [ [ 'Enter' ], "Go to currently focused entry" ],
4257                                 [ [ 'Esc' ], "Unfocus currently focused entry" ],
4258                                 [ [ 'ak-]' ], "Go to next page" ],
4259                                 [ [ 'ak-[' ], "Go to previous page" ],
4260                                 [ [ 'ak-\\' ], "Go to first page" ],
4261                                 [ [ 'ak-e' ], "Edit currently focused post" ]
4262                         ], [
4263                                 "Editor",
4264                                 [ [ 'ak-k' ], "Bold text" ],
4265                                 [ [ 'ak-i' ], "Italic text" ],
4266                                 [ [ 'ak-l' ], "Insert hyperlink" ],
4267                                 [ [ 'ak-q' ], "Blockquote text" ]
4268                         ], [                            
4269                                 "Appearance",
4270                                 [ [ 'ak-=' ], "Increase text size" ],
4271                                 [ [ 'ak--' ], "Decrease text size" ],
4272                                 [ [ 'ak-0' ], "Reset to default text size" ],
4273                                 [ [ 'ak-′' ], "Cycle through content width settings" ],
4274                                 [ [ 'ak-1' ], "Switch to default theme [A]" ],
4275                                 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
4276                                 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
4277                                 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
4278                                 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
4279                                 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
4280                                 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
4281                                 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
4282                                 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
4283                                 [ [ 'ak-;' ], "Open theme tweaker" ],
4284                                 [ [ 'Enter' ], "Save changes and close theme tweaker "],
4285                                 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
4286                         ], [
4287                                 "Slide shows",
4288                                 [ [ 'ak-l' ], "Start/resume slideshow" ],
4289                                 [ [ 'Esc' ], "Exit slideshow" ],
4290                                 [ [ '&#x2192;', '&#x2193;' ], "Next slide" ],
4291                                 [ [ '&#x2190;', '&#x2191;' ], "Previous slide" ],
4292                                 [ [ 'Space' ], "Reset slide zoom" ]
4293                         ], [
4294                                 "Miscellaneous",
4295                                 [ [ 'ak-x' ], "Switch to next view on user page" ],
4296                                 [ [ 'ak-z' ], "Switch to previous view on user page" ],
4297                                 [ [ 'ak-`&nbsp;' ], "Toggle compact comment list view" ],
4298                                 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
4299                         ] ].map(section => 
4300                         `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
4301                                 `<li>
4302                                         <span class='keys'>` + 
4303                                         entry[0].map(key =>
4304                                                 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
4305                                         ).join("") + 
4306                                         `</span>
4307                                         <span class='action'>${entry[1]}</span>
4308                                 </li>`
4309                         ).join("\n") + `</ul>`).join("\n") + `
4310                         </ul></div>             
4311                 </div>
4312         ` + "</nav>");
4314         // Add listener to show the keyboard help overlay.
4315         document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
4316                 GWLog("GW.keyboardHelpShowKeyPressed");
4317                 if (event.key == '?')
4318                         toggleKeyboardHelpOverlay(true);
4319         });
4321         // Clicking the background overlay closes the keyboard help overlay.
4322         keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
4323                 GWLog("GW.keyboardHelpOverlayClicked");
4324                 if (event.type == "mousedown") {
4325                         keyboardHelpOverlay.style.opacity = "0.01";
4326                 } else {
4327                         toggleKeyboardHelpOverlay(false);
4328                         keyboardHelpOverlay.style.opacity = "1.0";
4329                 }
4330         }, true);
4332         // Intercept clicks, so they don't "fall through" the background overlay.
4333         (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
4335         // Clicking the close button closes the keyboard help overlay.
4336         keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
4337                 toggleKeyboardHelpOverlay(false);
4338         });
4340         // Add button to open keyboard help.
4341         query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'>&#xf11c;</button>");
4342         query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
4343                 GWLog("GW.openKeyboardHelpButtonClicked");
4344                 toggleKeyboardHelpOverlay(true);
4345                 event.target.blur();
4346         });
4349 function toggleKeyboardHelpOverlay(show) {
4350         console.log("toggleKeyboardHelpOverlay");
4352         let keyboardHelpOverlay = query("#keyboard-help-overlay");
4353         show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
4354         keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
4356         // Prevent scrolling the document when the overlay is visible.
4357         togglePageScrolling(!show);
4359         // Focus the close button as soon as we open.
4360         keyboardHelpOverlay.query("button.close-keyboard-help").focus();
4362         if (show) {
4363                 // Add listener to show the keyboard help overlay.
4364                 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
4365                         GWLog("GW.keyboardHelpHideKeyPressed");
4366                         if (event.key == 'Escape')
4367                                 toggleKeyboardHelpOverlay(false);
4368                 });
4369         } else {
4370                 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
4371         }
4373         // Disable / enable tab-selection of the search box.
4374         setSearchBoxTabSelectable(!show);
4377 /**********************/
4378 /* PUSH NOTIFICATIONS */
4379 /**********************/
4381 function pushNotificationsSetup() {
4382         let pushNotificationsButton = query("#enable-push-notifications");
4383         if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
4384                 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
4385                 pushNotificationsButton.style.display = 'unset';
4386         }
4389 function urlBase64ToUint8Array(base64String) {
4390         const padding = '='.repeat((4 - base64String.length % 4) % 4);
4391         const base64 = (base64String + padding)
4392               .replace(/-/g, '+')
4393               .replace(/_/g, '/');
4394         
4395         const rawData = window.atob(base64);
4396         const outputArray = new Uint8Array(rawData.length);
4397         
4398         for (let i = 0; i < rawData.length; ++i) {
4399                 outputArray[i] = rawData.charCodeAt(i);
4400         }
4401         return outputArray;
4404 function pushNotificationsButtonClicked(event) {
4405         event.target.style.opacity = 0.33;
4406         event.target.style.pointerEvents = "none";
4408         let reEnable = (message) => {
4409                 if(message) alert(message);
4410                 event.target.style.opacity = 1;
4411                 event.target.style.pointerEvents = "unset";
4412         }
4414         if(event.target.dataset.enabled) {
4415                 fetch('/push/register', {
4416                         method: 'post',
4417                         headers: { 'Content-type': 'application/json' },
4418                         body: JSON.stringify({
4419                                 cancel: true
4420                         }),
4421                 }).then(() => {
4422                         event.target.innerHTML = "Enable push notifications";
4423                         event.target.dataset.enabled = "";
4424                         reEnable();
4425                 }).catch((err) => reEnable(err.message));
4426         } else {
4427                 Notification.requestPermission().then((permission) => {
4428                         navigator.serviceWorker.ready
4429                                 .then((registration) => {
4430                                         return registration.pushManager.getSubscription()
4431                                                 .then(async function(subscription) {
4432                                                         if (subscription) {
4433                                                                 return subscription;
4434                                                         }
4435                                                         return registration.pushManager.subscribe({
4436                                                                 userVisibleOnly: true,
4437                                                                 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
4438                                                         });
4439                                                 })
4440                                                 .catch((err) => reEnable(err.message));
4441                                 })
4442                                 .then((subscription) => {
4443                                         fetch('/push/register', {
4444                                                 method: 'post',
4445                                                 headers: {
4446                                                         'Content-type': 'application/json'
4447                                                 },
4448                                                 body: JSON.stringify({
4449                                                         subscription: subscription
4450                                                 }),
4451                                         });
4452                                 })
4453                                 .then(() => {
4454                                         event.target.innerHTML = "Disable push notifications";
4455                                         event.target.dataset.enabled = "true";
4456                                         reEnable();
4457                                 })
4458                                 .catch(function(err){ reEnable(err.message) });
4459                         
4460                 });
4461         }
4464 /*******************************/
4465 /* HTML TO MARKDOWN CONVERSION */
4466 /*******************************/
4468 function MarkdownFromHTML(text, linePrefix) {
4469         GWLog("MarkdownFromHTML");
4471         let docFrag = document.createRange().createContextualFragment(text);
4472         let output = "";
4473         let owedLines = -1;
4474         let atLineBeginning = true;
4475         linePrefix = linePrefix || "";
4477         let out = text => {
4478                 if(owedLines > 0) {
4479                         output += ("\n" + linePrefix).repeat(owedLines);
4480                 }
4481                 output += text;
4482                 owedLines = 0;
4483                 atLineBeginning = false;
4484         }
4485         let outText = text => {
4486                 if(atLineBeginning) text = text.trimStart();
4487                 text = text.replace(/\s+/gm, " ");
4488                 if(text.length > 0)
4489                         out(text);
4490         }
4491         let forceLine = n => {
4492                 n = n || 1;
4493                 out(("\n" + linePrefix).repeat(n));
4494                 atLineBeginning = true;
4495         }
4496         let newLine = (n) => {
4497                 n = n || 1;
4498                 if(owedLines >= 0 && owedLines < n) {
4499                         owedLines = n;
4500                 }
4501                 atLineBeginning = true;
4502         };
4503         let newParagraph = () => {
4504                 newLine(2);
4505         };
4506         let withPrefix = (prefix, fn) => {
4507                 let oldPrefix = linePrefix;
4508                 linePrefix += prefix;
4509                 owedLines = -1;
4510                 fn();
4511                 owedLines = 0;
4512                 linePrefix = oldPrefix;
4513         };
4515         let doConversion = (node) => {
4516                 if(node.nodeType == Node.TEXT_NODE) {
4517                         outText(node.nodeValue.replace(/[\][*\\#<>]/g, "\\$&"));
4518                 }
4519                 else if(node.nodeType == Node.ELEMENT_NODE) {
4520                         switch(node.tagName) {
4521                         case "P":
4522                         case "DIV":
4523                         case "UL":
4524                         case "OL":
4525                                 newParagraph();
4526                                 node.childNodes.forEach(doConversion);
4527                                 newParagraph();
4528                                 break;
4529                         case "BR":
4530                                 forceLine();
4531                                 break;
4532                         case "HR":
4533                                 newLine();
4534                                 out("---");
4535                                 newLine();
4536                                 break;
4537                         case "B":
4538                         case "STRONG":
4539                                 out("**");
4540                                 node.childNodes.forEach(doConversion);
4541                                 out("**");
4542                                 break;
4543                         case "I":
4544                         case "EM":
4545                                 out("*");
4546                                 node.childNodes.forEach(doConversion);
4547                                 out("*");
4548                                 break;
4549                         case "LI":
4550                                 newLine();
4551                                 let listPrefix;
4552                                 if(node.parentElement.tagName == "OL") {
4553                                         let i = 1;
4554                                         for(let e = node; e = e.previousElementSibling;) { i++ }
4555                                         listPrefix = "" + i + ". ";
4556                                 } else {
4557                                         listPrefix = "* ";
4558                                 }
4559                                 out(listPrefix);
4560                                 owedLines = -1;
4561                                 withPrefix(" ".repeat(listPrefix.length), () => node.childNodes.forEach(doConversion));
4562                                 newLine();
4563                                 break;
4564                         case "H1":
4565                         case "H2":
4566                         case "H3":
4567                         case "H4":
4568                         case "H5":
4569                         case "H6":
4570                                 newParagraph();
4571                                 out("#".repeat(node.tagName.charAt(1)) + " ");
4572                                 node.childNodes.forEach(doConversion);
4573                                 newParagraph();
4574                                 break;
4575                         case "A":
4576                                 let href = node.getAttribute("href");
4577                                 out('[');
4578                                 node.childNodes.forEach(doConversion);
4579                                 out(`](${href})`);
4580                                 break;
4581                         case "IMG":
4582                                 let src = node.getAttribute("src");
4583                                 let alt = node.alt || "";
4584                                 out(`![${alt}](${src})`);
4585                                 break;
4586                         case "BLOCKQUOTE":
4587                                 newParagraph();
4588                                 out("> ");
4589                                 withPrefix("> ", () => node.childNodes.forEach(doConversion));
4590                                 newParagraph();
4591                                 break;
4592                         case "PRE":
4593                                 newParagraph();
4594                                 out('```');
4595                                 forceLine();
4596                                 out(node.innerText);
4597                                 forceLine();
4598                                 out('```');
4599                                 newParagraph();
4600                                 break;
4601                         case "CODE":
4602                                 out('`');
4603                                 node.childNodes.forEach(doConversion);
4604                                 out('`');
4605                                 break;
4606                         case "STYLE":
4607                         case "SCRIPT":
4608                                 break;
4609                         default:
4610                                 node.childNodes.forEach(doConversion);
4611                         }
4612                 } else {
4613                         node.childNodes.forEach(doConversion);
4614                 }
4615         }
4616         doConversion(docFrag);
4618         return output;
4621 /************************************/
4622 /* ANCHOR LINK SCROLLING WORKAROUND */
4623 /************************************/
4625 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
4626         let hash = location.hash;
4627         if(hash && hash !== "#top" && !document.query(hash)) {
4628                 let content = document.query("#content");
4629                 content.style.display = "none";
4630                 addTriggerListener("DOMReady", {priority: -1, fn: () => {
4631                         content.style.visibility = "hidden";
4632                         content.style.display = null;
4633                         requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
4634                 }});
4635         }
4636 }});
4638 /******************/
4639 /* INITIALIZATION */
4640 /******************/
4642 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
4643         GWLog("INITIALIZER earlyInitialize");
4644         // Check to see whether we're on a mobile device (which we define as a narrow screen)
4645         GW.isMobile = (window.innerWidth <= 1160);
4646         GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
4648         // Backward compatibility
4649         let storedTheme = localStorage.getItem("selected-theme");
4650         if (storedTheme) {
4651                 Appearance.setTheme(storedTheme);
4652                 localStorage.removeItem("selected-theme");
4653         }
4655         // Animate width & theme adjustments?
4656         Appearance.adjustmentTransitions = false;
4657         // Add the content width selector.
4658         Appearance.injectContentWidthSelector();
4659         // Add the text size adjustment widget.
4660         Appearance.injectTextSizeAdjustmentUI();
4661         // Add the theme selector.
4662         Appearance.injectThemeSelector();
4663         // Add the theme tweaker.
4664         Appearance.injectThemeTweaker();
4666         // Add the dark mode selector (if desktop).
4667         if (GW.isMobile == false)
4668                 DarkMode.injectModeSelector();
4670         // Add the quick-nav UI.
4671         injectQuickNavUI();
4673         // Finish initializing when ready.
4674         addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4675 }});
4677 function mainInitializer() {
4678         GWLog("INITIALIZER initialize");
4680         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4681         // "link without nav bars".
4682         if (getQueryVariable("hide-nav-bars") == "true") {
4683                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
4684         }
4686         // If the page cannot have comments, remove the accesskey from the #comments
4687         // quick-nav button; and if the page can have comments, but does not, simply 
4688         // disable the #comments quick nav button.
4689         let content = query("#content");
4690         if (content.query("#comments") == null) {
4691                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4692         } else if (content.query("#comments .comment-thread") == null) {
4693                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4694         }
4696         // On edit post pages and conversation pages, add GUIEdit buttons to the 
4697         // textarea and expand it.
4698         queryAll(".with-markdown-editor textarea").forEach(textarea => {
4699                 textarea.addTextareaFeatures();
4700                 expandTextarea(textarea);
4701         });
4702         // Focus the textarea.
4703         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4705         // If we're on a comment thread page...
4706         if (query(".comments") != null) {
4707                 // Add comment-minimize buttons to every comment.
4708                 queryAll(".comment-meta").forEach(commentMeta => {
4709                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4710                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
4711                 });
4712                 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4713                         // Format and activate comment-minimize buttons.
4714                         queryAll(".comment-minimize-button").forEach(button => {
4715                                 button.closest(".comment-item").setCommentThreadMaximized(false);
4716                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4717                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
4718                                 });
4719                         });
4720                 }
4721         }
4722         if (getQueryVariable("chrono") == "t") {
4723                 insertHeadHTML(`<style> .comment-minimize-button::after { display: none; } </style>`);
4724         }
4726         // On mobile, replace the labels for the checkboxes on the edit post form
4727         // with icons, to save space.
4728         if (GW.isMobile && query(".edit-post-page")) {
4729                 query("label[for='link-post']").innerHTML = "&#xf0c1";
4730                 query("label[for='question']").innerHTML = "&#xf128";
4731         }
4733         // Add error message (as placeholder) if user tries to click Search with
4734         // an empty search field.
4735         searchForm: {
4736                 let searchForm = query("#nav-item-search form");
4737                 if(!searchForm) break searchForm;
4738                 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4739                         let searchField = event.target.query("input");
4740                         if (searchField.value == "") {
4741                                 event.preventDefault();
4742                                 event.target.blur();
4743                                 searchField.placeholder = "Enter a search string!";
4744                                 searchField.focus();
4745                         }
4746                 });
4747                 // Remove the placeholder / error on any input.
4748                 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4749                         event.target.placeholder = "";
4750                 });
4751         }
4753         // Prevent conflict between various single-hotkey listeners and text fields
4754         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4755                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4756                 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4757         });
4759         if (content.hasClass("post-page")) {
4760                 // Read and update last-visited-date.
4761                 let lastVisitedDate = getLastVisitedDate();
4762                 setLastVisitedDate(Date.now());
4764                 // Save the number of comments this post has when it's visited.
4765                 updateSavedCommentCount();
4767                 if (content.query(".comments .comment-thread") != null) {
4768                         // Add the new comments count & navigator.
4769                         injectNewCommentNavUI();
4771                         // Get the highlight-new-since date (as specified by URL parameter, if 
4772                         // present, or otherwise the date of the last visit).
4773                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4775                         // Highlight new comments since the specified date.                      
4776                         let newCommentsCount = highlightCommentsSince(hnsDate);
4778                         // Update the comment count display.
4779                         updateNewCommentNavUI(newCommentsCount, hnsDate);
4780                 }
4781         } else {
4782                 // On listing pages, make comment counts more informative.
4783                 badgePostsWithNewComments();
4784         }
4786         // Add the comments list mode selector widget (expanded vs. compact).
4787         injectCommentsListModeSelector();
4789         // Add the comments view selector widget (threaded vs. chrono).
4790 //      injectCommentsViewModeSelector();
4792         // Add the comments sort mode selector (top, hot, new, old).
4793         if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4795         // Add the toggle for the post nav UI elements on mobile.
4796         if (GW.isMobile) injectPostNavUIToggle();
4798         // Add the toggle for the appearance adjustment UI elements on mobile.
4799         if (GW.isMobile)
4800                 Appearance.injectAppearanceAdjustUIToggle();
4802         // Activate the antikibitzer.
4803         if (GW.useFancyFeatures)
4804                 activateAntiKibitzer();
4806         // Add comment parent popups.
4807         injectPreviewPopupToggle();
4808         addCommentParentPopups();
4810         // Mark original poster's comments with a special class.
4811         markOriginalPosterComments();
4812         
4813         // On the All view, mark posts with non-positive karma with a special class.
4814         if (query("#content").hasClass("all-index-page")) {
4815                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4816                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4818                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4819                 });
4820         }
4822         // Set the "submit" button on the edit post page to something more helpful.
4823         setEditPostPageSubmitButtonText();
4825         // Compute the text of the pagination UI tooltip text.
4826         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4827                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4828         });
4830         // Add event listeners for Escape and Enter, for the theme tweaker.
4831         document.addEventListener("keyup", Appearance.themeTweakerUIKeyPressed);
4833         // Add event listener for . , ; (for navigating listings pages).
4834         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4835         if (!query(".comments") && listings.length > 0) {
4836                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
4837                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4839                         if (event.key == "Escape") {
4840                                 if (document.activeElement.parentElement.hasClass("listing"))
4841                                         document.activeElement.blur();
4842                                 return;
4843                         }
4845                         if (event.key == ';') {
4846                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4847                                         let links = document.activeElement.parentElement.queryAll("a");
4848                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4849                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4850                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4851                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4852                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4853                                 }
4854                                 return;
4855                         }
4857                         var indexOfActiveListing = -1;
4858                         for (i = 0; i < listings.length; i++) {
4859                                 if (document.activeElement.parentElement.hasClass("listing") && 
4860                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4861                                         indexOfActiveListing = i;
4862                                         break;
4863                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
4864                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
4865                                         indexOfActiveListing = i;
4866                                         break;
4867                                 }
4868                         }
4869                         // Remove edit accesskey from currently highlighted post by active user, if applicable.
4870                         if (indexOfActiveListing > -1) {
4871                                 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4872                         }
4873                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4874                         if (indexOfNextListing < listings.length) {
4875                                 listings[indexOfNextListing].focus();
4877                                 if (listings[indexOfNextListing].closest(".comment-item")) {
4878                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4879                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4880                                 }
4881                         } else {
4882                                 document.activeElement.blur();
4883                         }
4884                         // Add edit accesskey to newly highlighted post by active user, if applicable.
4885                         (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4886                 });
4887                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4888                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4889                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4890                         });
4891                 });
4892         }
4893         // Add event listener for ; (to focus the link on link posts).
4894         if (query("#content").hasClass("post-page") && 
4895                 query(".post").hasClass("link-post")) {
4896                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4897                         if (event.key == ';') query("a.link-post-link").focus();
4898                 });
4899         }
4901         // Add accesskeys to user page view selector.
4902         let viewSelector = query("#content.user-page > .sublevel-nav");
4903         if (viewSelector) {
4904                 let currentView = viewSelector.query("span");
4905                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4906                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4907         }
4909         // Add accesskey to index page sort selector.
4910         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4912         // Move MathJax style tags to <head>.
4913         var aggregatedStyles = "";
4914         queryAll("#content style").forEach(styleTag => {
4915                 aggregatedStyles += styleTag.innerHTML;
4916                 removeElement("style", styleTag.parentElement);
4917         });
4918         if (aggregatedStyles != "") {
4919                 insertHeadHTML(`<style id="mathjax-styles"> ${aggregatedStyles} </style>`);
4920         }
4922         /*  Makes double-clicking on a math element select the entire math element.
4923                 (This actually makes no difference to the behavior of the copy listener
4924                  which copies the entire LaTeX source of the full equation no matter how 
4925                  much of said equation is selected when the copy command is sent; 
4926                  however, it ensures that the UI communicates the actual behavior in a 
4927                  more accurate and understandable way.)
4928          */
4929         query("#content").querySelectorAll(".mjpage").forEach(mathBlock => {
4930                 mathBlock.addEventListener("dblclick", (event) => {
4931                         document.getSelection().selectAllChildren(mathBlock.querySelector(".mjx-chtml"));
4932                 });
4933                 mathBlock.title = mathBlock.classList.contains("mjpage__block")
4934                                                   ? "Double-click to select equation, then copy, to get LaTeX source"
4935                                                   : "Double-click to select equation; copy to get LaTeX source";
4936         });
4938         // Add listeners to switch between word count and read time.
4939         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4940         queryAll(".post-meta .read-time").forEach(element => {
4941                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4942                         let displayWordCount = localStorage.getItem("display-word-count");
4943                         toggleReadTimeOrWordCount(!displayWordCount);
4944                         if (displayWordCount) localStorage.removeItem("display-word-count");
4945                         else localStorage.setItem("display-word-count", true);
4946                 });
4947         });
4949         // Set up Image Focus feature.
4950         imageFocusSetup();
4952         // Set up keyboard shortcuts guide overlay.
4953         keyboardHelpSetup();
4955         // Show push notifications button if supported
4956         pushNotificationsSetup();
4958         // Show elements now that javascript is ready.
4959         removeElement("#hide-until-init");
4961         activateTrigger("pageLayoutFinished");
4964 /*************************/
4965 /* POST-LOAD ADJUSTMENTS */
4966 /*************************/
4968 window.addEventListener("pageshow", badgePostsWithNewComments);
4970 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4971         GWLog("INITIALIZER pageLayoutFinished");
4973         Appearance.postSetThemeHousekeeping();
4975         focusImageSpecifiedByURL();
4977         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4978 //      query("input[type='search']").value = GW.isMobile;
4979 //      insertHeadHTML(`<style>
4980 //              @media only screen and (hover:none) { #nav-item-search input { background-color: red; }}
4981 //              @media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}
4982 //      </style>`);
4983 }});
4985 function generateImagesOverlay() {
4986         GWLog("generateImagesOverlay");
4987         // Don’t do this on the about page.
4988         if (query(".about-page") != null) return;
4989         return;
4991         // Remove existing, if any.
4992         removeElement("#images-overlay");
4994         // Create new.
4995         document.body.insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4996         let imagesOverlay = query("#images-overlay");
4997         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4998         queryAll(".post-body img").forEach(image => {
4999                 let clonedImageContainer = newElement("DIV");
5001                 let clonedImage = image.cloneNode(true);
5002                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
5003                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
5004                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
5005                 clonedImageContainer.appendChild(clonedImage);
5007                 let zoomLevel = Appearance.currentTextZoom;
5009                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
5010                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
5011                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
5012                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
5014                 imagesOverlay.appendChild(clonedImageContainer);
5015         });
5017         // Add the event listeners to focus each image.
5018         imageFocusSetup(true);
5021 function adjustUIForWindowSize() {
5022         GWLog("adjustUIForWindowSize");
5023         var bottomBarOffset;
5025         // Adjust bottom bar state.
5026         let bottomBar = query("#bottom-bar");
5027         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5028         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
5029                 bottomBar.removeClass("decorative");
5031                 bottomBar.query("#nav-item-top").style.display = "";
5032         } else if (bottomBar) {
5033                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
5034                 else bottomBar.addClass("decorative");
5036                 bottomBar.query("#nav-item-top").style.display = "none";
5037         }
5039         // Show quick-nav UI up/down buttons if content is taller than window.
5040         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5041         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
5042                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
5043         });
5045         // Move anti-kibitzer toggle if content is very short.
5046         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
5048         // Update the visibility of the post nav UI.
5049         updatePostNavUIVisibility();
5052 function recomputeUIElementsContainerHeight(force = false) {
5053         GWLog("recomputeUIElementsContainerHeight");
5054         if (!GW.isMobile &&
5055                 (force || query("#ui-elements-container").style.height != "")) {
5056                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
5057                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
5058                                                                                                                 query("#content").clientHeight + "px" :
5059                                                                                                                 "100vh";
5060         }
5063 function focusImageSpecifiedByURL() {
5064         GWLog("focusImageSpecifiedByURL");
5065         if (location.hash.hasPrefix("#if_slide_")) {
5066                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
5067                         let images = queryAll(GW.imageFocus.overlayImagesSelector);
5068                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
5069                         if (imageToFocus > 0 && imageToFocus <= images.length) {
5070                                 focusImage(images[imageToFocus - 1]);
5072                                 // Set timer to hide the image focus UI.
5073                                 unhideImageFocusUI();
5074                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
5075                         }
5076                 });
5077         }
5080 /***********/
5081 /* GUIEDIT */
5082 /***********/
5084 function insertMarkup(event) {
5085         var mopen = '', mclose = '', mtext = '', func = false;
5086         if (typeof arguments[1] == 'function') {
5087                 func = arguments[1];
5088         } else {
5089                 mopen = arguments[1];
5090                 mclose = arguments[2];
5091                 mtext = arguments[3];
5092         }
5094         var textarea = event.target.closest("form").query("textarea");
5095         textarea.focus();
5096         var p0 = textarea.selectionStart;
5097         var p1 = textarea.selectionEnd;
5098         var cur0 = cur1 = p0;
5100         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
5101         str = func ? func(str, p0) : (mopen + str + mclose);
5103         // Determine selection.
5104         if (!func) {
5105                 cur0 += (p0 == p1) ? mopen.length : str.length;
5106                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
5107         } else {
5108                 cur0 = str[1];
5109                 cur1 = str[2];
5110                 str = str[0];
5111         }
5113         // Update textarea contents.
5114         document.execCommand("insertText", false, str);
5116         // Expand textarea, if needed.
5117         expandTextarea(textarea);
5119         // Set selection.
5120         textarea.selectionStart = cur0;
5121         textarea.selectionEnd = cur1;
5123         return;
5126 GW.guiEditButtons = [
5127         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
5128         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
5129         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
5130         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
5131         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
5132         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
5133         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
5134         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
5135         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
5136         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
5137         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
5138         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
5139         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
5140         [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
5141         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
5144 function blockquote(text, startpos) {
5145         if (text == '') {
5146                 text = "> Quoted text";
5147                 return [ text, startpos + 2, startpos + text.length ];
5148         } else {
5149                 text = "> " + text.split("\n").join("\n> ") + "\n";
5150                 return [ text, startpos + text.length, startpos + text.length ];
5151         }
5154 function hyperlink(text, startpos) {
5155         var url = '', link_text = text, endpos = startpos;
5156         if (text.search(/^https?/) != -1) {
5157                 url = text;
5158                 link_text = "link text";
5159                 startpos = startpos + 1;
5160                 endpos = startpos + link_text.length;
5161         } else {
5162                 url = prompt("Link address (URL):");
5163                 if (!url) {
5164                         endpos = startpos + text.length;
5165                         return [ text, startpos, endpos ];
5166                 }
5167                 startpos = startpos + text.length + url.length + 4;
5168                 endpos = startpos;
5169         }
5171         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
5174 /******************/
5175 /* SERVICE WORKER */
5176 /******************/
5178 if(navigator.serviceWorker) {
5179         navigator.serviceWorker.register('/service-worker.js');
5180         setCookie("push", "t");
5183 /*********************/
5184 /* USER AUTOCOMPLETE */
5185 /*********************/
5187 function zLowerUIElements() {
5188         let uiElementsContainer = query("#ui-elements-container");
5189         if (uiElementsContainer)
5190                 uiElementsContainer.style.zIndex = "1";
5193 function zRaiseUIElements() {
5194         let uiElementsContainer = query("#ui-elements-container");
5195         if (uiElementsContainer)
5196                 uiElementsContainer.style.zIndex = "";
5199 var userAutocomplete = null;
5201 function abbreviatedInterval(date) {
5202         let seconds = Math.floor((new Date() - date) / 1000);
5203         let days = Math.floor(seconds / (60 * 60 * 24));
5204         let years = Math.floor(days / 365);
5205         if (years)
5206                 return years + "y";
5207         else if (days)
5208                 return days + "d";
5209         else
5210                 return "today";
5213 function beginAutocompletion(control, startIndex, endIndex) {
5214         if(userAutocomplete) abortAutocompletion(userAutocomplete);
5216         let complete = { control: control,
5217                          abortController: new AbortController(),
5218                          fetchAbortController: new AbortController(),
5219                          container: document.createElement("div") };
5221         endIndex = endIndex || control.selectionEnd;
5222         let valueLength = control.value.length;
5224         complete.container.className = "autocomplete-container "
5225                                                                  + "right "
5226                                                                  + (window.innerWidth > 1280
5227                                                                         ? "outside"
5228                                                                         : "inside");
5229         control.insertAdjacentElement("afterend", complete.container);
5230         zLowerUIElements();
5232         let makeReplacer = (userSlug, displayName) => {
5233                 return () => {
5234                         let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
5235                         control.value = control.value.substring(0, startIndex - 1) +
5236                                 replacement +
5237                                 control.value.substring(endIndex);
5238                         abortAutocompletion(complete);
5239                         complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
5240                         complete.control.focus();
5241                 };
5242         };
5244         let switchHighlight = (newHighlight) => {
5245                 if (!newHighlight)
5246                         return;
5248                 complete.highlighted.removeClass("highlighted");
5249                 newHighlight.addClass("highlighted");
5250                 complete.highlighted = newHighlight;
5252                 //      Scroll newly highlighted item into view, if need be.
5253                 if (  complete.highlighted.offsetTop + complete.highlighted.offsetHeight 
5254                         > complete.container.scrollTop + complete.container.clientHeight) {
5255                         complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
5256                 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
5257                         complete.container.scrollTo(0, complete.highlighted.offsetTop);
5258                 }
5259         };
5260         let highlightNext = () => {
5261                 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
5262         };
5263         let highlightPrev = () => {
5264                 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
5265         };
5267         let updateCompletions = () => {
5268                 let fragment = control.value.substring(startIndex, endIndex);
5270                 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
5271                       {signal: complete.fetchAbortController.signal})
5272                         .then((res) => res.json())
5273                         .then((res) => {
5274                                 if(res.error) return;
5275                                 if(res.length == 0) return abortAutocompletion(complete);
5277                                 complete.container.innerHTML = "";
5278                                 res.forEach(entry => {
5279                                         let entryContainer = document.createElement("div");
5280                                         [ [ entry.displayName, "name" ],
5281                                           [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
5282                                           [ (entry.karma || 0) + " karma", "karma" ]
5283                                         ].forEach(x => {
5284                                                 let e = document.createElement("span");
5285                                                 e.append(x[0]);
5286                                                 e.className = x[1];
5287                                                 entryContainer.append(e);
5288                                         });
5289                                         entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
5290                                         complete.container.append(entryContainer);
5291                                 });
5292                                 complete.highlighted = complete.container.children[0];
5293                                 complete.highlighted.classList.add("highlighted");
5294                                 complete.container.scrollTo(0, 0);
5295                                 })
5296                         .catch((e) => {});
5297         };
5299         document.body.addEventListener("click", (event) => {
5300                 if (!complete.container.contains(event.target)) {
5301                         abortAutocompletion(complete);
5302                         event.preventDefault();
5303                         event.stopPropagation();
5304                 }
5305         }, {signal: complete.abortController.signal,
5306             capture: true});
5307         
5308         control.addEventListener("keydown", (event) => {
5309                 switch (event.key) {
5310                 case "Escape":
5311                         abortAutocompletion(complete);
5312                         event.preventDefault();
5313                         return;
5314                 case "ArrowUp":
5315                         highlightPrev();
5316                         event.preventDefault();
5317                         return;
5318                 case "ArrowDown":
5319                         highlightNext();
5320                         event.preventDefault();
5321                         return;
5322                 case "Tab":
5323                         if (event.shiftKey)
5324                                 highlightPrev();
5325                         else
5326                                 highlightNext();
5327                         event.preventDefault();
5328                         return;
5329                 case "Enter":
5330                         complete.highlighted.onclick();
5331                         event.preventDefault();
5332                         return;
5333                 }
5334         }, {signal: complete.abortController.signal});
5336         control.addEventListener("selectionchange", (event) => {
5337                 if (control.selectionStart < startIndex ||
5338                     control.selectionEnd > endIndex) {
5339                         abortAutocompletion(complete);
5340                 }
5341         }, {signal: complete.abortController.signal});
5342         
5343         control.addEventListener("input", (event) => {
5344                 complete.fetchAbortController.abort();
5345                 complete.fetchAbortController = new AbortController();
5347                 endIndex += control.value.length - valueLength;
5348                 valueLength = control.value.length;
5350                 if (endIndex < startIndex) {
5351                         abortAutocompletion(complete);
5352                         return;
5353                 }
5354                 
5355                 updateCompletions();
5356         }, {signal: complete.abortController.signal});
5358         userAutocomplete = complete;
5360         if(startIndex != endIndex) updateCompletions();
5363 function abortAutocompletion(complete) {
5364         complete.fetchAbortController.abort();
5365         complete.abortController.abort();
5366         complete.container.remove();
5367         userAutocomplete = null;
5368         zRaiseUIElements();