Dark mode selector, part III
[lw2-viewer.git] / www / script.js
blob0d10d8afff73f847a425df189017b99b7c4e5a77
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 /*      Reads the value of named cookie.
54         Returns the cookie as a string, or null if no such cookie exists. */
55 function readCookie(name) {
56         var nameEQ = name + "=";
57         var ca = document.cookie.split(';');
58         for(var i = 0; i < ca.length; i++) {
59                 var c = ca[i];
60                 while (c.charAt(0)==' ') c = c.substring(1, c.length);
61                 if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
62         }
63         return null;
66 /*******************************/
67 /* EVENT LISTENER MANIPULATION */
68 /*******************************/
70 /*      Removes event listener from a clickable element, automatically detaching it
71         from all relevant event types. */
72 Element.prototype.removeActivateEvent = function() {
73         let ael = this.activateEventListener;
74         this.removeEventListener("mousedown", ael);
75         this.removeEventListener("click", ael);
76         this.removeEventListener("keyup", ael);
79 /*      Adds a scroll event listener to the page. */
80 function addScrollListener(fn, name) {
81         let wrapper = (event) => {
82                 requestAnimationFrame(() => {
83                         fn(event);
84                         document.addEventListener("scroll", wrapper, {once: true, passive: true});
85                 });
86         }
87         document.addEventListener("scroll", wrapper, {once: true, passive: true});
89         // Retain a reference to the scroll listener, if a name is provided.
90         if (typeof name != "undefined")
91                 GW[name] = wrapper;
94 /****************/
95 /* MISC HELPERS */
96 /****************/
98 // Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=325942
99 Element.prototype.scrollIntoView = function(realSIV) {
100         return function(bottom) {
101                 realSIV.call(this, bottom);
102                 if(fixTarget = this.closest("input[id^='expand'] ~ .comment-thread")) {
103                         window.scrollBy(0, fixTarget.scrollTop);
104                         fixTarget.scrollTop = 0;
105                 }
106         }
107 }(Element.prototype.scrollIntoView);
109 /*      If top of element is not at or above the top of the screen, scroll it into
110         view. */
111 Element.prototype.scrollIntoViewIfNeeded = function() {
112         GWLog("scrollIntoViewIfNeeded");
113         if (this.getBoundingClientRect().bottom > window.innerHeight && 
114                 this.getBoundingClientRect().top > 0) {
115                 this.scrollIntoView(false);
116         }
119 function urlEncodeQuery(params) {
120         return params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params[x])}).join("&");
123 function handleAjaxError(event) {
124         if(event.target.getResponseHeader("Content-Type") === "application/json") alert("Error: " + JSON.parse(event.target.responseText)["error"]);
125         else alert("Error: Something bad happened :(");
128 function doAjax(params) {
129         let req = new XMLHttpRequest();
130         let requestMethod = params["method"] || "GET";
131         req.addEventListener("load", (event) => {
132                 if(event.target.status < 400) {
133                         if(params["onSuccess"]) params.onSuccess(event);
134                 } else {
135                         if(params["onFailure"]) params.onFailure(event);
136                         else handleAjaxError(event);
137                 }
138                 if(params["onFinish"]) params.onFinish(event);
139         });
140         req.open(requestMethod, (params.location || document.location) + ((requestMethod == "GET" && params.params) ? "?" + urlEncodeQuery(params.params) : ""));
141         if(requestMethod == "POST") {
142                 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
143                 params["params"]["csrf-token"] = GW.csrfToken;
144                 req.send(urlEncodeQuery(params.params));
145         } else {
146                 req.send();
147         }
150 function activateReadyStateTriggers() {
151         if(document.readyState == 'interactive') {
152                 activateTrigger('DOMReady');
153         } else if(document.readyState == 'complete') {
154                 activateTrigger('DOMReady');
155                 activateTrigger('DOMComplete');
156         }
159 document.addEventListener('readystatechange', activateReadyStateTriggers);
160 activateReadyStateTriggers();
162 function callWithServerData(fname, uri) {
163         doAjax({
164                 location: uri,
165                 onSuccess: (event) => {
166                         let response = JSON.parse(event.target.responseText);
167                         window[fname](response);
168                 }
169         });
172 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
173 deferredCalls = null;
175 /*      Return the currently selected text, as HTML (rather than unstyled text).
176         */
177 function getSelectionHTML() {
178         var container = document.createElement("div");
179         container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
180         return container.innerHTML;
183 /*      Given an HTML string, creates an element from that HTML, adds it to 
184         #ui-elements-container (creating the latter if it does not exist), and 
185         returns the created element.
186         */
187 function addUIElement(element_html) {
188         var ui_elements_container = query("#ui-elements-container");
189         if (!ui_elements_container) {
190                 ui_elements_container = document.createElement("nav");
191                 ui_elements_container.id = "ui-elements-container";
192                 query("body").appendChild(ui_elements_container);
193         }
195         ui_elements_container.insertAdjacentHTML("beforeend", element_html);
196         return ui_elements_container.lastElementChild;
199 /*      Given an element or a selector, removes that element (or the element 
200         identified by the selector).
201         If multiple elements match the selector, only the first is removed.
202         */
203 function removeElement(elementOrSelector, ancestor = document) {
204         if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
205         if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
208 /*      Returns true if the string begins with the given prefix.
209         */
210 String.prototype.hasPrefix = function (prefix) {
211         return (this.lastIndexOf(prefix, 0) === 0);
214 /*      Toggles whether the page is scrollable.
215         */
216 function togglePageScrolling(enable) {
217         let body = query("body");
218         if (!enable) {
219                 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
220                 body.addClass("no-scroll");
221                 body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
222         } else {
223                 body.removeClass("no-scroll");
224                 body.removeAttribute("style");
225                 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
226         }
229 DOMRectReadOnly.prototype.isInside = function (x, y) {
230         return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
233 /*      Simple mutex mechanism.
234  */
235 function doIfAllowed(f, passHolder, passName, releaseImmediately = false) {
236         if (passHolder[passName] == false)
237                 return;
239         passHolder[passName] = false;
241         f();
243         if (releaseImmediately) {
244                 passHolder[passName] = true;
245         } else {
246                 requestAnimationFrame(() => {
247                         passHolder[passName] = true;
248                 });
249         }
253 /********************/
254 /* DEBUGGING OUTPUT */
255 /********************/
257 GW.enableLogging = (permanently = false) => {
258         if (permanently)
259                 localStorage.setItem("logging-enabled", "true");
260         else
261                 GW.loggingEnabled = true;
263 GW.disableLogging = (permanently = false) => {
264         if (permanently)
265                 localStorage.removeItem("logging-enabled");
266         else
267                 GW.loggingEnabled = false;
270 /*******************/
271 /* INBOX INDICATOR */
272 /*******************/
274 function processUserStatus(userStatus) {
275         window.userStatus = userStatus;
276         if(userStatus) {
277                 if(userStatus.notifications) {
278                         let element = query('#inbox-indicator');
279                         element.className = 'new-messages';
280                         element.title = 'New messages [o]';
281                 }
282         } else {
283                 location.reload();
284         }
287 /**************/
288 /* COMMENTING */
289 /**************/
291 function toggleMarkdownHintsBox() {
292         GWLog("toggleMarkdownHintsBox");
293         let markdownHintsBox = query("#markdown-hints");
294         markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
296 function hideMarkdownHintsBox() {
297         GWLog("hideMarkdownHintsBox");
298         let markdownHintsBox = query("#markdown-hints");
299         if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
302 Element.prototype.addTextareaFeatures = function() {
303         GWLog("addTextareaFeatures");
304         let textarea = this;
306         textarea.addEventListener("focus", GW.textareaFocused = (event) => {
307                 GWLog("GW.textareaFocused");
308                 event.target.closest("form").scrollIntoViewIfNeeded();
309         });
310         textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
311                 GWLog("GW.textareaInputReceived");
312                 if (window.innerWidth > 520) {
313                         // Expand textarea if needed.
314                         expandTextarea(textarea);
315                 } else {
316                         // Remove markdown hints.
317                         hideMarkdownHintsBox();
318                         query(".guiedit-mobile-help-button").removeClass("active");
319                 }
320         }, false);
321         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
322         textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
324         let form = textarea.closest("form");
325         if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
327         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
328         let textareaContainer = textarea.closest(".textarea-container");
329         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
330         for (var button of GW.guiEditButtons) {
331                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
332                 buttons_container.insertAdjacentHTML("beforeend", 
333                         "<button type='button' class='guiedit guiedit-" 
334                         + name
335                         + "' tabindex='-1'"
336                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
337                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
338                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
339                         + " onclick='insertMarkup(event,"
340                         + ((typeof m_before_or_func == 'function') ?
341                                 m_before_or_func.name : 
342                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
343                         + ");'><div>"
344                         + icon
345                         + "</div></button>"
346                 );
347         }
349         var markdown_hints = 
350         `<input type='checkbox' id='markdown-hints-checkbox'>
351         <label for='markdown-hints-checkbox'></label>
352         <div id='markdown-hints'>` + 
353         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
354                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
355                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
356                 "<span>Heading 1</span><code># Heading 1</code>",
357                 "<span>Heading 2</span><code>## Heading 1</code>",
358                 "<span>Heading 3</span><code>### Heading 1</code>",
359                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
360         `</div>`;
361         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
363         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
364                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
365                         GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
366                         if (button.hasClass("guiedit-mobile-help-button")) {
367                                 toggleMarkdownHintsBox();
368                                 event.target.toggleClass("active");
369                                 query(".posting-controls:focus-within textarea").focus();
370                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
371                                 event.target.blur();
372                                 hideMarkdownHintsBox();
373                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
374                         }
375                 });
376         });
378         // On smartphone (narrow mobile) screens, when a textarea is focused (and
379         // automatically fullscreened), remove all the filters from the page, and 
380         // then apply them *just* to the fixed editor UI elements. This is in order
381         // to get around the "children of elements with a filter applied cannot be
382         // fixed" issue".
383         if (GW.isMobile && window.innerWidth <= 520) {
384                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
385                 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
386                         GWLog("GW.textareaFocusedMobile");
387                         GW.savedFilters = GW.currentFilters;
388                         GW.currentFilters = { };
389                         applyFilters(GW.currentFilters);
390                         fixedEditorElements.forEach(element => {
391                                 element.style.filter = filterStringFromFilters(GW.savedFilters);
392                         });
393                 });
394                 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
395                         GWLog("GW.textareaBlurredMobile");
396                         GW.currentFilters = GW.savedFilters;
397                         GW.savedFilters = { };
398                         requestAnimationFrame(() => {
399                                 applyFilters(GW.currentFilters);
400                                 fixedEditorElements.forEach(element => {
401                                         element.style.filter = filterStringFromFilters(GW.savedFilters);
402                                 });
403                         });
404                 });
405         }
408 Element.prototype.injectReplyForm = function(editMarkdownSource) {
409         GWLog("injectReplyForm");
410         let commentControls = this;
411         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
412         let postId = commentControls.parentElement.dataset["postId"];
413         let tagId = commentControls.parentElement.dataset["tagId"];
414         let withparent = (!editMarkdownSource && commentControls.getCommentId());
415         let answer = commentControls.parentElement.id == "answers";
416         let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
417         let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
418         let parentCommentItem = commentControls.closest(".comment-item");
419         let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
420             (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
421         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
422                 "<form method='post'>" + 
423                 "<div class='textarea-container'>" + 
424                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
425                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
426                 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
427                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
428                 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
429                 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
430                 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
431                 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
432                 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
433                 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
434                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
435                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
436                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
437                 "</div><div>" + 
438                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
439                 "<input type='submit' value='Submit'>" + 
440                 "</div></form>";
441         commentControls.onsubmit = disableBeforeUnload;
443         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
444                 GWLog("GW.cancelCommentButtonClicked");
445                 hideReplyForm(event.target.closest(".comment-controls"));
446         });
447         commentControls.scrollIntoViewIfNeeded();
448         commentControls.query("form").onsubmit = (event) => {
449                 if (!event.target.text.value) {
450                         alert("Please enter a comment.");
451                         return false;
452                 }
453         }
454         let textarea = commentControls.query("textarea");
455         textarea.value = MarkdownFromHTML(editMarkdownSource || "");
456         textarea.addTextareaFeatures();
457         textarea.focus();
460 function showCommentEditForm(commentItem) {
461         GWLog("showCommentEditForm");
463         let commentBody = commentItem.query(".comment-body");
464         commentBody.style.display = "none";
466         let commentControls = commentItem.query(".comment-controls");
467         commentControls.injectReplyForm(commentBody.dataset.markdownSource);
468         commentControls.query("form").addClass("edit-existing-comment");
469         expandTextarea(commentControls.query("textarea"));
472 function showReplyForm(commentItem) {
473         GWLog("showReplyForm");
475         let commentControls = commentItem.query(".comment-controls");
476         commentControls.injectReplyForm(commentControls.dataset.enteredText);
479 function hideReplyForm(commentControls) {
480         GWLog("hideReplyForm");
481         // Are we editing a comment? If so, un-hide the existing comment body.
482         let containingComment = commentControls.closest(".comment-item");
483         if (containingComment) containingComment.query(".comment-body").style.display = "";
485         let enteredText = commentControls.query("textarea").value;
486         if (enteredText) commentControls.dataset.enteredText = enteredText;
488         disableBeforeUnload();
489         commentControls.constructCommentControls();
492 function expandTextarea(textarea) {
493         GWLog("expandTextarea");
494         if (window.innerWidth <= 520) return;
496         let totalBorderHeight = 30;
497         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
499         requestAnimationFrame(() => {
500                 textarea.style.height = 'auto';
501                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
502                 if (textarea.clientHeight < window.innerHeight) {
503                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
504                 }
505         });
508 function doCommentAction(action, commentItem) {
509         GWLog("doCommentAction");
510         let params = {};
511         params[(action + "-comment-id")] = commentItem.getCommentId();
512         doAjax({
513                 method: "POST",
514                 params: params,
515                 onSuccess: GW.commentActionPostSucceeded = (event) => {
516                         GWLog("GW.commentActionPostSucceeded");
517                         let fn = {
518                                 retract: () => { commentItem.firstChild.addClass("retracted") },
519                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
520                                 delete: () => {
521                                         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>";
522                                         commentItem.removeChild(commentItem.query(".comment-controls"));
523                                 }
524                         }[action];
525                         if(fn) fn();
526                         if(action != "delete")
527                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
528                 }
529         });
532 /**********/
533 /* VOTING */
534 /**********/
536 function parseVoteType(voteType) {
537         GWLog("parseVoteType");
538         let value = {};
539         if (!voteType) return value;
540         value.up = /[Uu]pvote$/.test(voteType);
541         value.down = /[Dd]ownvote$/.test(voteType);
542         value.big = /^big/.test(voteType);
543         return value;
546 function makeVoteType(value) {
547         GWLog("makeVoteType");
548         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
551 function makeVoteClass(vote) {
552         GWLog("makeVoteClass");
553         if (vote.up || vote.down) {
554                 return (vote.big ? 'selected big-vote' : 'selected');
555         } else {
556                 return '';
557         }
560 function findVoteControls(targetType, targetId, voteAxis) {
561         var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
563         if(targetType == "Post") {
564                 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
565         } else if(targetType == "Comment") {
566                 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
567         }
570 function votesEqual(vote1, vote2) {
571         var allKeys = Object.assign({}, vote1);
572         Object.assign(allKeys, vote2);
574         for(k of allKeys.keys()) {
575                 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
576         }
577         return true;
580 function addVoteButtons(element, vote, targetType) {
581         GWLog("addVoteButtons");
582         vote = vote || {};
583         let voteAxis = element.parentElement.dataset.voteAxis || "karma";
584         let voteType = parseVoteType(vote[voteAxis]);
585         let voteClass = makeVoteClass(voteType);
587         element.parentElement.queryAll("button").forEach((button) => {
588                 button.disabled = false;
589                 if (voteType) {
590                         if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
591                                 button.addClass(voteClass);
592                 }
593                 updateVoteButtonVisualState(button);
594                 button.addActivateEvent(voteButtonClicked);
595         });
598 function updateVoteButtonVisualState(button) {
599         GWLog("updateVoteButtonVisualState");
601         button.removeClasses([ "none", "one", "two-temp", "two" ]);
603         if (button.disabled)
604                 button.addClass("none");
605         else if (button.hasClass("big-vote"))
606                 button.addClass("two");
607         else if (button.hasClass("selected"))
608                 button.addClass("one");
609         else
610                 button.addClass("none");
613 function changeVoteButtonVisualState(button) {
614         GWLog("changeVoteButtonVisualState");
616         /*      Interaction states are:
618                 0  0·    (neutral; +1 click)
619                 1  1·    (small vote; +1 click)
620                 2  2·    (big vote; +1 click)
622                 Visual states are (with their state classes in [brackets]) are:
624                 01    (no vote) [none]
625                 02    (small vote active) [one]
626                 12    (small vote active, temporary indicator of big vote) [two-temp]
627                 22    (big vote active) [two]
629                 The following are the 9 possible interaction state transitions (and
630                 the visual state transitions associated with them):
632                                 VIS.    VIS.
633                 FROM    TO      FROM    TO      NOTES
634                 ====    ====    ====    ====    =====
635                 0       0·      01      12      first click
636                 0·      1       12      02      one click without second
637                 0·      2       12      22      second click
639                 1       1·      02      12      first click
640                 1·      0       12      01      one click without second
641                 1·      2       12      22      second click
643                 2       2·      22      12      first click
644                 2·      1       12      02      one click without second
645                 2·      0       12      01      second click
646         */
647         let transitions = [
648                 [ "big-vote two-temp clicked-twice", "none"     ], // 2· => 0
649                 [ "big-vote two-temp clicked-once",  "one"      ], // 2· => 1
650                 [ "big-vote clicked-once",           "two-temp" ], // 2  => 2·
652                 [ "selected two-temp clicked-twice", "two"      ], // 1· => 2
653                 [ "selected two-temp clicked-once",  "none"     ], // 1· => 0
654                 [ "selected clicked-once",           "two-temp" ], // 1  => 1·
656                 [ "two-temp clicked-twice",          "two"      ], // 0· => 2
657                 [ "two-temp clicked-once",           "one"      ], // 0· => 1
658                 [ "clicked-once",                    "two-temp" ], // 0  => 0·
659         ];
660         for (let [ interactionClasses, visualStateClass ] of transitions) {
661                 if (button.hasClasses(interactionClasses.split(" "))) {
662                         button.removeClasses([ "none", "one", "two-temp", "two" ]);
663                         button.addClass(visualStateClass);
664                         break;
665                 }
666         }
669 function voteCompleteEvent(targetType, targetId, response) {
670         GWLog("voteCompleteEvent");
672         var currentVote = voteData[targetType][targetId] || {};
673         var desiredVote = voteDesired[targetType][targetId];
675         var controls = findVoteControls(targetType, targetId);
676         var controlsByAxis = new Object;
678         controls.forEach(control => {
679                 const voteAxis = (control.dataset.voteAxis || "karma");
681                 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
682                         control.removeClass("waiting");
683                         control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
684                 }
686                 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
687                 controlsByAxis[voteAxis].push(control);
689                 const voteType = currentVote[voteAxis];
690                 const vote = parseVoteType(voteType);
691                 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
692                 const voteClass = makeVoteClass(vote);
694                 if (response && response[voteAxis]) {
695                         const [voteType, displayText, titleText] = response[voteAxis];
697                         const displayTarget = control.query(".karma-value");
698                         if (displayTarget.hasClass("redacted")) {
699                                 displayTarget.dataset["trueValue"] = displayText;
700                         } else {
701                                 displayTarget.innerHTML = displayText;
702                         }
703                         displayTarget.setAttribute("title", titleText);
704                 }
706                 control.queryAll("button.vote").forEach(button => {
707                         updateVoteButton(button, voteUpDown, voteClass);
708                 });
709         });
712 function updateVoteButton(button, voteUpDown, voteClass) {
713         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
714         if (button.dataset.voteType == voteUpDown)
715                 button.addClass(voteClass);
716         updateVoteButtonVisualState(button);
719 function makeVoteRequestCompleteEvent(targetType, targetId) {
720         return (event) => {
721                 var currentVote = {};
722                 var response = null;
724                 if (event.target.status == 200) {
725                         response = JSON.parse(event.target.responseText);
726                         for (const voteAxis of response.keys()) {
727                                 currentVote[voteAxis] = response[voteAxis][0];
728                         }
729                         voteData[targetType][targetId] = currentVote;
730                 } else {
731                         delete voteDesired[targetType][targetId];
732                         currentVote = voteData[targetType][targetId];
733                 }
735                 var desiredVote = voteDesired[targetType][targetId];
737                 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
738                         sendVoteRequest(targetType, targetId);
739                 } else {
740                         delete voteDesired[targetType][targetId];
741                         voteCompleteEvent(targetType, targetId, response);
742                 }
743         }
746 function sendVoteRequest(targetType, targetId) {
747         GWLog("sendVoteRequest");
749         doAjax({
750                 method: "POST",
751                 location: "/karma-vote",
752                 params: { "target": targetId,
753                           "target-type": targetType,
754                           "vote": JSON.stringify(voteDesired[targetType][targetId]) },
755                 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
756         });
759 function voteButtonClicked(event) {
760         GWLog("voteButtonClicked");
761         let voteButton = event.target;
763         // 500 ms (0.5 s) double-click timeout.
764         let doubleClickTimeout = 500;
766         if (!voteButton.clickedOnce) {
767                 voteButton.clickedOnce = true;
768                 voteButton.addClass("clicked-once");
769                 changeVoteButtonVisualState(voteButton);
771                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
772                         if (!voteButton.clickedOnce) return;
774                         // Do single-click code.
775                         voteButton.clickedOnce = false;
776                         voteEvent(voteButton, 1);
777                 }, doubleClickTimeout, voteButton);
778         } else {
779                 voteButton.clickedOnce = false;
781                 // Do double-click code.
782                 voteButton.removeClass("clicked-once");
783                 voteButton.addClass("clicked-twice");
784                 voteEvent(voteButton, 2);
785         }
788 function voteEvent(voteButton, numClicks) {
789         GWLog("voteEvent");
790         voteButton.blur();
792         let voteControl = voteButton.parentNode;
794         let targetType = voteButton.dataset.targetType;
795         let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
796         let voteAxis = voteControl.dataset.voteAxis || "karma";
797         let voteUpDown = voteButton.dataset.voteType;
799         let voteType;
800         if (   (numClicks == 2 && voteButton.hasClass("big-vote"))
801                 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
802                 voteType = "neutral";
803         } else {
804                 let vote = parseVoteType(voteUpDown);
805                 vote.big = (numClicks == 2);
806                 voteType = makeVoteType(vote);
807         }
809         let voteControls = findVoteControls(targetType, targetId, voteAxis);
810         for (const voteControl of voteControls) {
811                 voteControl.addClass("waiting");
812                 voteControl.queryAll(".vote").forEach(button => {
813                         button.addClass("waiting");
814                         updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
815                 });
816         }
818         let voteRequestPending = voteDesired[targetType][targetId];
819         let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
820         voteObject[voteAxis] = voteType;
821         voteDesired[targetType][targetId] = voteObject;
823         if (!voteRequestPending) sendVoteRequest(targetType, targetId);
826 function initializeVoteButtons() {
827         // Color the upvote/downvote buttons with an embedded style sheet.
828         query("head").insertAdjacentHTML("beforeend", "<style id='vote-buttons'>" + `
829                 :root {
830                         --GW-upvote-button-color: #00d800;
831                         --GW-downvote-button-color: #eb4c2a;
832                 }\n` + "</style>");
835 function processVoteData(voteData) {
836         window.voteData = voteData;
838         window.voteDesired = new Object;
839         for(key of voteData.keys()) {
840                 voteDesired[key] = new Object;
841         }
843         initializeVoteButtons();
844         
845         addTriggerListener("postLoaded", {priority: 3000, fn: () => {
846                 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
847                         let postID = karmaValue.parentNode.dataset.postId;
848                         addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
849                         karmaValue.parentElement.addClass("active-controls");
850                 });
851         }});
853         addTriggerListener("DOMReady", {priority: 3000, fn: () => {
854                 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
855                         let commentID = karmaValue.getCommentId();
856                         addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
857                         karmaValue.parentElement.addClass("active-controls");
858                 });
859         }});
862 /*****************************************/
863 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
864 /*****************************************/
866 Element.prototype.getCommentDate = function() {
867         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
868         let dateElement = item && item.query(".date");
869         return (dateElement && parseInt(dateElement.dataset["jsDate"]));
871 function getCurrentVisibleComment() {
872         let px = window.innerWidth/2, py = 5;
873         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
874         let bottomBar = query("#bottom-bar");
875         let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
876         let atbottom =  bottomOffset <= window.innerHeight;
877         if (atbottom) {
878                 let hashci = location.hash && query(location.hash);
879                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
880                         commentItem = hashci;
881                 }
882         }
883         return commentItem;
886 function highlightCommentsSince(date) {
887         GWLog("highlightCommentsSince");
888         var newCommentsCount = 0;
889         GW.newComments = [ ];
890         let oldCommentsStack = [ ];
891         let prevNewComment;
892         queryAll(".comment-item").forEach(commentItem => {
893                 commentItem.prevNewComment = prevNewComment;
894                 commentItem.nextNewComment = null;
895                 if (commentItem.getCommentDate() > date) {
896                         commentItem.addClass("new-comment");
897                         newCommentsCount++;
898                         GW.newComments.push(commentItem.getCommentId());
899                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
900                         oldCommentsStack = [ commentItem ];
901                         prevNewComment = commentItem;
902                 } else {
903                         commentItem.removeClass("new-comment");
904                         oldCommentsStack.push(commentItem);
905                 }
906         });
908         GW.newCommentScrollSet = (commentItem) => {
909                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
910                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
911         };
912         GW.newCommentScrollListener = () => {
913                 let commentItem = getCurrentVisibleComment();
914                 GW.newCommentScrollSet(commentItem);
915         }
917         addScrollListener(GW.newCommentScrollListener);
919         if (document.readyState=="complete") {
920                 GW.newCommentScrollListener();
921         } else {
922                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
923                 GW.newCommentScrollSet(commentItem);
924         }
926         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
928         return newCommentsCount;
931 function scrollToNewComment(next) {
932         GWLog("scrollToNewComment");
933         let commentItem = getCurrentVisibleComment();
934         let targetComment = null;
935         let targetCommentID = null;
936         if (commentItem) {
937                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
938                 if (targetComment) {
939                         targetCommentID = targetComment.getCommentId();
940                 }
941         } else {
942                 if (GW.newComments[0]) {
943                         targetCommentID = GW.newComments[0];
944                         targetComment = query("#comment-" + targetCommentID);
945                 }
946         }
947         if (targetComment) {
948                 expandAncestorsOf(targetCommentID);
949                 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
950                 targetComment.scrollIntoView();
951         }
953         GW.newCommentScrollListener();
956 function getPostHash() {
957         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
958         return (postHash ? postHash[1] : false);
960 function setHistoryLastVisitedDate(date) {
961         window.history.replaceState({ lastVisited: date }, null);
963 function getLastVisitedDate() {
964         // Get the last visited date (or, if posting a comment, the previous last visited date).
965         if(window.history.state) return (window.history.state||{})['lastVisited'];
966         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
967         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
968         let currentVisited = localStorage.getItem(storageName);
969         setHistoryLastVisitedDate(currentVisited);
970         return currentVisited;
972 function setLastVisitedDate(date) {
973         GWLog("setLastVisitedDate");
974         // If NOT posting a comment, save the previous value for the last-visited-date 
975         // (to recover it in case of posting a comment).
976         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
977         if (!aCommentHasJustBeenPosted) {
978                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
979                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
980         }
982         // Set the new value.
983         localStorage.setItem("last-visited-date_" + getPostHash(), date);
986 function updateSavedCommentCount() {
987         GWLog("updateSavedCommentCount");
988         let commentCount = queryAll(".comment").length;
989         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
991 function badgePostsWithNewComments() {
992         GWLog("badgePostsWithNewComments");
993         if (getQueryVariable("show") == "conversations") return;
995         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
996                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
998                 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
999                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1000                 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1002                 if (currentCommentCount > savedCommentCount)
1003                         commentCountDisplay.addClass("new-comments");
1004                 else
1005                         commentCountDisplay.removeClass("new-comments");
1006                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1007         });
1011 /*****************/
1012 /* MEDIA QUERIES */
1013 /*****************/
1015 GW.mediaQueries = {
1016     systemDarkModeActive:  matchMedia("(prefers-color-scheme: dark)")
1020 /************************/
1021 /* ACTIVE MEDIA QUERIES */
1022 /************************/
1024 /*  This function provides two slightly different versions of its functionality,
1025     depending on how many arguments it gets.
1027     If one function is given (in addition to the media query and its name), it
1028     is called whenever the media query changes (in either direction).
1030     If two functions are given (in addition to the media query and its name),
1031     then the first function is called whenever the media query starts matching,
1032     and the second function is called whenever the media query stops matching.
1034     If you want to call a function for a change in one direction only, pass an
1035     empty closure (NOT null!) as one of the function arguments.
1037     There is also an optional fifth argument. This should be a function to be
1038     called when the active media query is canceled.
1039  */
1040 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1041     if (typeof GW.mediaQueryResponders == "undefined")
1042         GW.mediaQueryResponders = { };
1044     let mediaQueryResponder = (event, canceling = false) => {
1045         if (canceling) {
1046             GWLog(`Canceling media query “${name}”`, "media queries", 1);
1048             if (whenCanceledDo != null)
1049                 whenCanceledDo(mediaQuery);
1050         } else {
1051             let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1053             GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1055             if ((otherwiseDo == null) || matches)
1056                 ifMatchesOrAlwaysDo(mediaQuery);
1057             else
1058                 otherwiseDo(mediaQuery);
1059         }
1060     };
1061     mediaQueryResponder();
1062     mediaQuery.addListener(mediaQueryResponder);
1064     GW.mediaQueryResponders[name] = mediaQueryResponder;
1067 /*  Deactivates and discards an active media query, after calling the function
1068     that was passed as the whenCanceledDo parameter when the media query was
1069     added.
1070  */
1071 function cancelDoWhenMatchMedia(name) {
1072     GW.mediaQueryResponders[name](null, true);
1074     for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1075         mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1077     GW.mediaQueryResponders[name] = null;
1081 /******************************/
1082 /* DARK/LIGHT MODE ADJUSTMENT */
1083 /******************************/
1085 DarkMode = {
1086         /*****************/
1087         /*      Configuration.
1088          */
1089         modeOptions: [
1090                 [ '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)' ],
1091                 [ 'light', '&#xf185;', 'Light mode at all times (black-on-white)' ],
1092                 [ 'dark', '&#xf186;', 'Dark mode at all times (inverted: white-on-black)' ]
1093         ],
1095         selectedModeOptionNote: " [This option is currently selected.]",
1097         /******************/
1098         /*      Infrastructure.
1099          */
1101         modeSelector: null,
1102         modeSelectorInteractable: true,
1104         /******************/
1105         /*      Mode selection.
1106          */
1108     /*  Returns current (saved) mode (light, dark, or auto).
1109      */
1110     getSavedMode: () => {
1111         //      NOTE: For testing only!
1112                 return "auto";
1114         return (localStorage.getItem("dark-mode-setting") || "auto");
1115     },
1117         /*      Saves specified mode (light, dark, or auto).
1118          */
1119         saveMode: (mode) => {
1120         //      NOTE: For testing only!
1121                 return;
1123                 if (mode == "auto")
1124                         localStorage.removeItem("dark-mode-setting");
1125                 else
1126                         localStorage.setItem("dark-mode-setting", mode);
1127         },
1129         /*  Set specified color mode (light, dark, or auto).
1130          */
1131         setMode: (selectedMode = DarkMode.getSavedMode()) => {
1132                 GWLog("DarkMode.setMode");
1134                 //      The style block should be inlined (and already loaded).
1135                 let darkModeStyles = document.querySelector("#inlined-dark-mode-styles");
1136                 if (darkModeStyles == null)
1137                         return;
1139                 //      Set `media` attribute of style block to match requested mode.
1140                 if (selectedMode == 'auto') {
1141                         darkModeStyles.media = "all and (prefers-color-scheme: dark)";
1142                 } else if (selectedMode == 'dark') {
1143                         darkModeStyles.media = "all";
1144                 } else {
1145                         darkModeStyles.media = "not all";
1146                 }
1148                 //      Fire event.
1149                 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1150         },
1152         modeSelectorHTML: (inline = false) => {
1153                 let selectorTagName = (inline ? "span" : "div");
1154                 let selectorId = (inline ? "" : " id='dark-mode-selector'");
1155                 let selectorClass = (" class='dark-mode-selector mode-selector" + (inline ? " mode-selector-inline" : "") + "'");
1157                 //      Get saved mode setting (or default).
1158                 let currentMode = DarkMode.getSavedMode();
1160                 return `<${selectorTagName}${selectorId}${selectorClass}>`
1161                         + DarkMode.modeOptions.map(modeOption => {
1162                                 let [ name, label, desc ] = modeOption;
1163                                 let selected = (name == currentMode ? " selected" : "");
1164                                 let disabled = (name == currentMode ? " disabled" : "");
1165                                 let active = ((   currentMode == "auto"
1166                                                            && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1167                                                           ? " active"
1168                                                           : "");
1169                                 if (name == currentMode)
1170                                         desc += DarkMode.selectedModeOptionNote;
1171                                 return `<button
1172                                                         type="button"
1173                                                         class="select-mode-${name}${selected}${active}"
1174                                                         ${disabled}
1175                                                         tabindex="-1"
1176                                                         data-name="${name}"
1177                                                         title="${desc}"
1178                                                                 >${label}</button>`;
1179                           }).join("")
1180                         + `</${selectorTagName}>`;
1181         },
1183         injectModeSelector: (replacedElement = null) => {
1184                 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1186                 //      Inject the mode selector widget.
1187                 let modeSelector;
1188                 if (replacedElement) {
1189                         replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1190                         modeSelector = replacedElement.firstElementChild;
1191                         unwrap(replacedElement);
1192                 } else {
1193                         modeSelector = DarkMode.modeSelector = addUIElement(DarkMode.modeSelectorHTML());
1194                 }
1196                 //  Add event listeners and update state.
1197                 requestAnimationFrame(() => {
1198                         //      Activate mode selector widget buttons.
1199                         modeSelector.querySelectorAll("button").forEach(button => {
1200                                 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1201                         });
1202                 });
1204                 /*      Add active media query to update mode selector state when system dark
1205                         mode setting changes. (This is relevant only for the ‘auto’ setting.)
1206                  */
1207                 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => { 
1208                         DarkMode.updateModeSelectorState(modeSelector);
1209                 });
1210         },
1212         modeSelectButtonClicked: (event) => {
1213                 GWLog("DarkMode.modeSelectButtonClicked");
1215                 /*      We don’t want clicks to go through if the transition 
1216                         between modes has not completed yet, so we disable the 
1217                         button temporarily while we’re transitioning between 
1218                         modes.
1219                  */
1220                 doIfAllowed(() => {
1221                         // Determine which setting was chosen (ie. which button was clicked).
1222                         let selectedMode = event.target.dataset.name;
1224                         // Save the new setting.
1225                         DarkMode.saveMode(selectedMode);
1227                         // Actually change the mode.
1228                         DarkMode.setMode(selectedMode);
1229                 }, DarkMode, "modeSelectorInteractable");
1231                 event.target.blur();
1232         },
1234         updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1235                 GWLog("DarkMode.updateModeSelectorState");
1237                 /*      If the mode selector has not yet been injected, then do nothing.
1238                  */
1239                 if (modeSelector == null)
1240                         return;
1242                 //      Get saved mode setting (or default).
1243                 let currentMode = DarkMode.getSavedMode();
1245                 //      Clear current buttons state.
1246                 modeSelector.querySelectorAll("button").forEach(button => {
1247                         button.classList.remove("active", "selected");
1248                         button.disabled = false;
1249                         if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1250                                 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1251                 });
1253                 //      Set the correct button to be selected.
1254                 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1255                         button.classList.add("selected");
1256                         button.disabled = true;
1257                         button.title += DarkMode.selectedModeOptionNote;
1258                 });
1260                 /*      Ensure the right button (light or dark) has the “currently active” 
1261                         indicator, if the current mode is ‘auto’.
1262                  */
1263                 if (currentMode == "auto")
1264                         modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1265         }
1269 /***********************************/
1270 /* CONTENT COLUMN WIDTH ADJUSTMENT */
1271 /***********************************/
1273 function injectContentWidthSelector() {
1274         GWLog("injectContentWidthSelector");
1275         // Get saved width setting (or default).
1276         let currentWidth = localStorage.getItem("selected-width") || 'normal';
1278         // Inject the content width selector widget and activate buttons.
1279         let widthSelector = addUIElement(
1280                 "<div id='width-selector'>" +
1281                 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
1282                         let [name, desc, abbr] = widthOption;
1283                         let selected = (name == currentWidth ? ' selected' : '');
1284                         let disabled = (name == currentWidth ? ' disabled' : '');
1285                         return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
1286                 "</div>");
1287         widthSelector.queryAll("button").forEach(button => {
1288                 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
1289                         GWLog("GW.widthAdjustButtonClicked");
1291                         // Determine which setting was chosen (i.e., which button was clicked).
1292                         let selectedWidth = event.target.dataset.name;
1294                         // Save the new setting.
1295                         if (selectedWidth == "normal") localStorage.removeItem("selected-width");
1296                         else localStorage.setItem("selected-width", selectedWidth);
1298                         // Save current visible comment
1299                         let visibleComment = getCurrentVisibleComment();
1301                         // Actually change the content width.
1302                         setContentWidth(selectedWidth);
1303                         event.target.parentElement.childNodes.forEach(button => {
1304                                 button.removeClass("selected");
1305                                 button.disabled = false;
1306                         });
1307                         event.target.addClass("selected");
1308                         event.target.disabled = true;
1310                         // Make sure the accesskey (to cycle to the next width) is on the right button.
1311                         setWidthAdjustButtonsAccesskey();
1313                         // Regenerate images overlay.
1314                         generateImagesOverlay();
1316                         if(visibleComment) visibleComment.scrollIntoView();
1317                 });
1318         });
1320         // Make sure the accesskey (to cycle to the next width) is on the right button.
1321         setWidthAdjustButtonsAccesskey();
1323         // Inject transitions CSS, if animating changes is enabled.
1324         if (GW.adjustmentTransitions) {
1325                 insertHeadHTML(
1326                         "<style id='width-transition'>" + 
1327                         `#content,
1328                         #ui-elements-container,
1329                         #images-overlay {
1330                                 transition:
1331                                         max-width 0.3s ease;
1332                         }` + 
1333                         "</style>");
1334         }
1336 function setWidthAdjustButtonsAccesskey() {
1337         GWLog("setWidthAdjustButtonsAccesskey");
1338         let widthSelector = query("#width-selector");
1339         widthSelector.queryAll("button").forEach(button => {
1340                 button.removeAttribute("accesskey");
1341                 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1342         });
1343         let selectedButton = widthSelector.query("button.selected");
1344         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
1345         nextButtonInCycle.accessKey = "'";
1346         nextButtonInCycle.title += ` [\']`;
1349 /*******************/
1350 /* THEME SELECTION */
1351 /*******************/
1353 function injectThemeSelector() {
1354         GWLog("injectThemeSelector");
1355         let currentTheme = readCookie("theme") || "default";
1356         let themeSelector = addUIElement(
1357                 "<div id='theme-selector' class='theme-selector'>" +
1358                 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
1359                         let [name, desc, letter] = themeOption;
1360                         let selected = (name == currentTheme ? ' selected' : '');
1361                         let disabled = (name == currentTheme ? ' disabled' : '');
1362                         let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1363                         return `<button type='button' class='select-theme-${name}${selected}'${disabled} title="${desc} [${accesskey}]" data-theme-name="${name}" data-theme-description="${desc}" accesskey='${accesskey}' tabindex='-1'>${letter}</button>`;})) +
1364                 "</div>");
1365         themeSelector.queryAll("button").forEach(button => {
1366                 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
1367                         GWLog("GW.themeSelectButtonClicked");
1368                         let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
1369                         setSelectedTheme(themeName);
1370                         if (GW.isMobile) toggleAppearanceAdjustUI();
1371                 });
1372         });
1374         // Inject transitions CSS, if animating changes is enabled.
1375         if (GW.adjustmentTransitions) {
1376                 insertHeadHTML(
1377                         "<style id='theme-fade-transition'>" + 
1378                         `body {
1379                                 transition:
1380                                         opacity 0.5s ease-out,
1381                                         background-color 0.3s ease-out;
1382                         }
1383                         body.transparent {
1384                                 background-color: #777;
1385                                 opacity: 0.0;
1386                                 transition:
1387                                         opacity 0.5s ease-in,
1388                                         background-color 0.3s ease-in;
1389                         }` + 
1390                         "</style>");
1391         }
1393 function setSelectedTheme(themeName) {
1394         GWLog("setSelectedTheme");
1395         queryAll(".theme-selector button").forEach(button => {
1396                 button.removeClass("selected");
1397                 button.disabled = false;
1398         });
1399         queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
1400                 button.addClass("selected");
1401                 button.disabled = true;
1402         });
1403         setTheme(themeName);
1404         query("#theme-tweaker-ui .current-theme span").innerText = themeName;
1406 function setTheme(newThemeName) {
1407         var themeUnloadCallback = '';
1408         var oldThemeName = '';
1409         if (typeof(newThemeName) == 'undefined') {
1410                 newThemeName = readCookie('theme');
1411                 if (!newThemeName) return;
1412         } else {
1413                 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
1414                 oldThemeName = readCookie('theme') || 'default';
1416                 if (newThemeName == 'default') setCookie('theme', '');
1417                 else setCookie('theme', newThemeName);
1418         }
1419         if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
1421         let makeNewStyle = function(newThemeName, colorSchemePreference) {
1422                 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
1423                 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1425                 let newStyle = document.createElement('link');
1426                 newStyle.setAttribute('class', 'theme');
1427                 if(colorSchemePreference)
1428                         newStyle.setAttribute('media', '(prefers-color-scheme: ' + colorSchemePreference + ')');
1429                 newStyle.setAttribute('rel', 'stylesheet');
1430                 newStyle.setAttribute('href', '/css/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1431                 return newStyle;
1432         }
1434         let newMainStyle, newStyles;
1435         if(newThemeName === 'default') {
1436                 newStyles = [makeNewStyle('dark', 'dark'), makeNewStyle('default', 'light')];
1437                 newMainStyle = (window.matchMedia('prefers-color-scheme: dark').matches ? newStyles[0] : newStyles[1]);
1438         } else {
1439                 newStyles = [makeNewStyle(newThemeName)];
1440                 newMainStyle = newStyles[0];
1441         }
1443         let oldStyles = queryAll("head link.theme");
1444         newMainStyle.addEventListener('load', () => { oldStyles.forEach(x => removeElement(x)); });
1445         newMainStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
1447         if (GW.adjustmentTransitions) {
1448                 pageFadeTransition(false);
1449                 setTimeout(() => {
1450                         newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1451                 }, 500);
1452         } else {
1453                 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1454         }
1456 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
1457         document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1458         document.body.addClass("theme-" + newThemeName);
1460         recomputeUIElementsContainerHeight(true);
1462         let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
1463         if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
1465         recomputeUIElementsContainerHeight();
1466         adjustUIForWindowSize();
1467         window.addEventListener('resize', GW.windowResized = (event) => {
1468                 GWLog("GW.windowResized");
1469                 adjustUIForWindowSize();
1470                 recomputeUIElementsContainerHeight();
1471         });
1473         generateImagesOverlay();
1475         if (window.adjustmentTransitions) pageFadeTransition(true);
1476         updateThemeTweakerSampleText();
1478         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
1479                 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1480         }
1483 function pageFadeTransition(fadeIn) {
1484         if (fadeIn) {
1485                 query("body").removeClass("transparent");
1486         } else {
1487                 query("body").addClass("transparent");
1488         }
1491 GW.themeLoadCallback_less = (fromTheme = "") => {
1492         GWLog("themeLoadCallback_less");
1493         injectSiteNavUIToggle();
1494         if (!GW.isMobile) {
1495                 injectPostNavUIToggle();
1496                 injectAppearanceAdjustUIToggle();
1497         }
1499         registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1500                 let dtf = new Intl.DateTimeFormat([], 
1501                         (window.innerWidth < 1100) ? 
1502                                 { month: 'short', day: 'numeric', year: 'numeric' } : 
1503                                         { month: 'long', day: 'numeric', year: 'numeric' });
1504                 let postDate = query(".top-post-meta .date");
1505                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1506         });
1508         if (GW.isMobile) {
1509                 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1510         }
1512         if (!GW.isMobile) {
1513                 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1514                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1515                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1516                         });
1517                 });
1519                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1520                         // If state is not set (user has never clicked on the Less theme's appearance
1521                         // adjustment UI toggle) then show it, but then hide it after a short time.
1522                         registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1523                                 toggleAppearanceAdjustUI();
1524                                 setTimeout(toggleAppearanceAdjustUI, 3000);
1525                         });
1526                 }
1528                 if (fromTheme != "") {
1529                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1530                         setTimeout(function () {
1531                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1532                         }, 300);
1533                         setTimeout(function () {
1534                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1535                         }, 1800);
1536                 }
1538                 // Unset the height of the #ui-elements-container.
1539                 query("#ui-elements-container").style.height = "";
1541                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1542                 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1543                         "#content #secondary-bar",
1544                         "#content .post .top-post-meta .date",
1545                         "#content .post .top-post-meta .comment-count",
1546                 ];
1547                 applyFilters(GW.currentFilters);
1548         }
1550         // We pre-query the relevant elements, so we don't have to run querySelectorAll
1551         // on every firing of the scroll listener.
1552         GW.scrollState = {
1553                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1554                 "unbrokenDownScrollDistance":           0,
1555                 "unbrokenUpScrollDistance":                     0,
1556                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1557                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1558                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1559         };
1560         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1563 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
1564 // otherwise, show it.
1565 function updatePostNavUIVisibility() {
1566         GWLog("updatePostNavUIVisibility");
1567         var hidePostNavUIToggle = true;
1568         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1569                 if (getComputedStyle(element).visibility == "visible" ||
1570                         element.style.visibility == "visible" ||
1571                         element.style.visibility == "unset")
1572                         hidePostNavUIToggle = false;
1573         });
1574         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1575                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1576         });
1579 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1580 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
1581 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1582 function updateSiteNavUIState(event) {
1583         GWLog("updateSiteNavUIState");
1584         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1585         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
1586                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
1587                                                                                                                 0;
1588         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1589                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1590                                                                                                          0;
1591         GW.scrollState.lastScrollTop = newScrollTop;
1593         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1594         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1595                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1596                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1597         }
1599         // On mobile, make site nav UI translucent on ANY scroll down.
1600         if (GW.isMobile)
1601                 GW.scrollState.siteNavUIElements.forEach(element => {
1602                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1603                         else element.removeClass("translucent-on-scroll");
1604                 });
1606         // Show site nav UI when scrolling a full page up, or to the top.
1607         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
1608                  GW.scrollState.lastScrollTop == 0) &&
1609                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
1610                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1612         // On desktop, show appearance adjust UI when scrolling to the top.
1613         if ((!GW.isMobile) && 
1614                 (GW.scrollState.lastScrollTop == 0) &&
1615                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
1616                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1619 GW.themeUnloadCallback_less = (toTheme = "") => {
1620         GWLog("themeUnloadCallback_less");
1621         removeSiteNavUIToggle();
1622         if (!GW.isMobile) {
1623                 removePostNavUIToggle();
1624                 removeAppearanceAdjustUIToggle();
1625         }
1626         window.removeEventListener('resize', updatePostNavUIVisibility);
1628         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1630         removeElement("#theme-less-mobile-first-row-placeholder");
1632         if (!GW.isMobile) {
1633                 // Remove spans
1634                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1635                         element.innerHTML = element.firstChild.innerHTML;
1636                 });
1637         }
1639         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1641         // Reset filtered elements selector to default.
1642         delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1643         applyFilters(GW.currentFilters);
1646 GW.themeLoadCallback_dark = (fromTheme = "") => {
1647         GWLog("themeLoadCallback_dark");
1648         insertHeadHTML(
1649                 "<style id='dark-theme-adjustments'>" + 
1650                 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` + 
1651                 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1652                 "</style>");
1653         registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1654                 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1655                         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)";
1656                         image.style.width = parseInt(image.style.width) + 12 + "px";
1657                         image.style.height = parseInt(image.style.height) + 12 + "px";
1658                         image.style.top = parseInt(image.style.top) - 6 + "px";
1659                         image.style.left = parseInt(image.style.left) - 6 + "px";
1660                 });
1661         });
1663 GW.themeUnloadCallback_dark = (toTheme = "") => {
1664         GWLog("themeUnloadCallback_dark");
1665         removeElement("#dark-theme-adjustments");
1668 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1669         GWLog("themeLoadCallback_brutalist");
1670         let bottomBarLinks = queryAll("#bottom-bar a");
1671         if (!GW.isMobile && bottomBarLinks.length == 5) {
1672                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1673                 bottomBarLinks.forEach((link, i) => {
1674                         link.dataset.originalText = link.textContent;
1675                         link.textContent = newLinkTexts[i];
1676                 });
1677         }
1679 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1680         GWLog("themeUnloadCallback_brutalist");
1681         let bottomBarLinks = queryAll("#bottom-bar a");
1682         if (!GW.isMobile && bottomBarLinks.length == 5) {
1683                 bottomBarLinks.forEach(link => {
1684                         link.textContent = link.dataset.originalText;
1685                 });
1686         }
1689 GW.themeLoadCallback_classic = (fromTheme = "") => {
1690         GWLog("themeLoadCallback_classic");
1691         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1692                 button.innerHTML = "";
1693         });
1695 GW.themeUnloadCallback_classic = (toTheme = "") => {
1696         GWLog("themeUnloadCallback_classic");
1697         if (GW.isMobile && window.innerWidth <= 900) return;
1698         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1699                 button.innerHTML = button.dataset.label;
1700         });
1703 /********************************************/
1704 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1705 /********************************************/
1707 function injectThemeTweaker() {
1708         GWLog("injectThemeTweaker");
1709         let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" + 
1710         `<div class='main-theme-tweaker-window'>
1711                 <h1>Customize appearance</h1>
1712                 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1713                 <button type='button' class='help-button' tabindex='-1'></button>
1714                 <p class='current-theme'>Current theme: <span>` + 
1715                 (readCookie("theme") || "default") + 
1716                 `</span></p>
1717                 <p class='theme-selector'></p>
1718                 <div class='controls-container'>
1719                         <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1720                                 <div class='sample-text-container'><span class='sample-text'>
1721                                         <p>Less Wrong (text)</p>
1722                                         <p><a href="#">Less Wrong (link)</a></p>
1723                                 </span></div>
1724                         </div>
1725                         <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1726                                 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1727                                 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1728                                 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1729                         </div>
1730                         <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1731                                 <input type='checkbox' id='theme-tweak-control-invert'></input>
1732                                 <label for='theme-tweak-control-invert'>Invert colors</label>
1733                         </div>
1734                         <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1735                                 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1736                                 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1737                                 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1738                         </div>
1739                         <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1740                                 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1741                                 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1742                                 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1743                         </div>
1744                         <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1745                                 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1746                                 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1747                                 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1748                         </div>
1749                         <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1750                                 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1751                                 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1752                                 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1753                         </div>
1754                 </div>
1755                 <div class='buttons-container'>
1756                         <button type="button" class="reset-defaults-button">Reset to defaults</button>
1757                         <button type='button' class='ok-button default-button'>OK</button>
1758                         <button type='button' class='cancel-button'>Cancel</button>
1759                 </div>
1760         </div>
1761         <div class="clippy-container">
1762                 <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>)
1763                 <div class='clippy'></div>
1764                 <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>
1765         </div>
1766         <div class='help-window' style='display: none;'>
1767                 <h1>Theme tweaker help</h1>
1768                 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1769                         <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1770                         <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1771                 </div>
1772                 <div class='buttons-container'>
1773                         <button type='button' class='ok-button default-button'>OK</button>
1774                         <button type='button' class='cancel-button'>Cancel</button>
1775                 </div>
1776         </div>
1777         ` + "</div>");
1779         // Clicking the background overlay closes the theme tweaker.
1780         themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1781                 GWLog("GW.themeTweaker.UIOverlayClicked");
1782                 if (event.type == 'mousedown') {
1783                         themeTweakerUI.style.opacity = "0.01";
1784                 } else {
1785                         toggleThemeTweakerUI();
1786                         themeTweakerUI.style.opacity = "1.0";
1787                         themeTweakReset();
1788                 }
1789         }, true);
1791         // Intercept clicks, so they don't "fall through" the background overlay.
1792         (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1794         let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1795         themeTweakerUI.queryAll("input").forEach(field => {
1796                 // All input types in the theme tweaker receive a 'change' event when
1797                 // their value is changed. (Range inputs, in particular, receive this 
1798                 // event when the user lets go of the handle.) This means we should
1799                 // update the filters for the entire page, to match the new setting.
1800                 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1801                         GWLog("GW.themeTweaker.fieldValueChanged");
1802                         if (event.target.id == 'theme-tweak-control-invert') {
1803                                 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1804                         } else if (event.target.type == 'range') {
1805                                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1806                                 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1807                                 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1808                         } else if (event.target.id == 'theme-tweak-control-clippy') {
1809                                 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1810                         }
1811                         // Clear the sample text filters.
1812                         sampleTextContainer.style.filter = "";
1813                         // Apply the new filters globally.
1814                         applyFilters(GW.currentFilters);
1815                 });
1817                 // Range inputs receive an 'input' event while being scrubbed, updating
1818                 // "live" as the handle is moved. We don't want to change the filters 
1819                 // for the actual page while this is happening, but we do want to change
1820                 // the filters for the *sample text*, so the user can see what effects
1821                 // his changes are having, live, without having to let go of the handle.
1822                 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1823                         GWLog("GW.themeTweaker.fieldInputReceived");
1824                         var sampleTextFilters = GW.currentFilters;
1826                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1827                         query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1828                         sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1830                         sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1831                 });
1832         });
1834         themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1835                 GWLog("GW.themeTweaker.minimizeButtonClicked");
1836                 let themeTweakerStyle = query("#theme-tweaker-style");
1838                 if (event.target.hasClass("minimize")) {
1839                         event.target.removeClass("minimize");
1840                         themeTweakerStyle.innerHTML = 
1841                                 `#theme-tweaker-ui .main-theme-tweaker-window {
1842                                         width: 320px;
1843                                         height: 31px;
1844                                         overflow: hidden;
1845                                         padding: 30px 0 0 0;
1846                                         top: 20px;
1847                                         right: 20px;
1848                                         left: auto;
1849                                 }
1850                                 #theme-tweaker-ui::after {
1851                                         top: 27px;
1852                                         right: 27px;
1853                                 }
1854                                 #theme-tweaker-ui::before {
1855                                         opacity: 0.0;
1856                                         height: 0;
1857                                 }
1858                                 #theme-tweaker-ui .clippy-container {
1859                                         opacity: 1.0;
1860                                 }
1861                                 #theme-tweaker-ui .clippy-container .hint span {
1862                                         color: #c00;
1863                                 }
1864                                 #theme-tweaker-ui {
1865                                         height: 0;
1866                                 }
1867                                 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1868                                         pointer-events: none;
1869                                 }`;
1870                         event.target.addClass("maximize");
1871                 } else {
1872                         event.target.removeClass("maximize");
1873                         themeTweakerStyle.innerHTML = 
1874                                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1875                                         pointer-events: none;
1876                                 }`;
1877                         event.target.addClass("minimize");
1878                 }
1879         });
1880         themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1881                 GWLog("GW.themeTweaker.helpButtonClicked");
1882                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1883                 toggleThemeTweakerHelpWindow();
1884         });
1885         themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1886                 GWLog("GW.themeTweaker.resetDefaultsButtonClicked");
1887                 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1888                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1889                         let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1890                         slider.value = slider.dataset['defaultValue'];
1891                         themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1892                 });
1893                 GW.currentFilters = { };
1894                 applyFilters(GW.currentFilters);
1896                 GW.currentTextZoom = "1.0";
1897                 setTextZoom(GW.currentTextZoom);
1899                 setSelectedTheme("default");
1900         });
1901         themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1902                 GWLog("GW.themeTweaker.cancelButtonClicked");
1903                 toggleThemeTweakerUI();
1904                 themeTweakReset();
1905         });
1906         themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1907                 GWLog("GW.themeTweaker.OKButtonClicked");
1908                 toggleThemeTweakerUI();
1909                 themeTweakSave();
1910         });
1911         themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1912                 GWLog("GW.themeTweaker.helpWindowCancelButtonClicked");
1913                 toggleThemeTweakerHelpWindow();
1914                 themeTweakerResetSettings();
1915         });
1916         themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1917                 GWLog("GW.themeTweaker.helpWindowOKButtonClicked");
1918                 toggleThemeTweakerHelpWindow();
1919                 themeTweakerSaveSettings();
1920         });
1922         themeTweakerUI.queryAll(".notch").forEach(notch => {
1923                 notch.addActivateEvent(GW.themeTweaker.sliderNotchClicked = (event) => {
1924                         GWLog("GW.themeTweaker.sliderNotchClicked");
1925                         let slider = event.target.parentElement.query("input[type='range']");
1926                         slider.value = slider.dataset['defaultValue'];
1927                         event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1928                         GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1929                         applyFilters(GW.currentFilters);
1930                 });
1931         });
1933         themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1934                 GWLog("GW.themeTweaker.clippyCloseButtonClicked");
1935                 themeTweakerUI.query(".clippy-container").style.display = "none";
1936                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1937                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1938         });
1940         query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1942         themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1943         themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1944                 button.addActivateEvent(GW.themeSelectButtonClicked);
1945         });
1947         themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1948                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1949         });
1951         let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'>&#xf1de;</button></div>`);
1952         themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1953                 GWLog("GW.themeTweaker.toggleButtonClicked");
1954                 GW.themeTweakerStyleSheetAvailable = () => {
1955                         GWLog("GW.themeTweakerStyleSheetAvailable");
1956                         themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1958                         themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1959                         [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1960                                 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1961                                 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1962                                 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1963                         });
1965                         toggleThemeTweakerUI();
1966                         event.target.disabled = true;
1967                 };
1969                 if (query("link[href^='/css/theme_tweaker.css']")) {
1970                         // Theme tweaker CSS is already loaded.
1971                         GW.themeTweakerStyleSheetAvailable();
1972                 } else {
1973                         // Load the theme tweaker CSS (if not loaded).
1974                         let themeTweakerStyleSheet = document.createElement('link');
1975                         themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1976                         themeTweakerStyleSheet.setAttribute('href', '/css/theme_tweaker.css');
1977                         themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1978                         query("head").appendChild(themeTweakerStyleSheet);
1979                 }
1980         });
1982 function toggleThemeTweakerUI() {
1983         GWLog("toggleThemeTweakerUI");
1984         let themeTweakerUI = query("#theme-tweaker-ui");
1985         themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1986         query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" : 
1987                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1988                         pointer-events: none;
1989                 }`;
1990         if (themeTweakerUI.style.display != "none") {
1991                 // Save selected theme.
1992                 GW.currentTheme = (readCookie("theme") || "default");
1993                 // Focus invert checkbox.
1994                 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1995                 // Show sample text in appropriate font.
1996                 updateThemeTweakerSampleText();
1997                 // Disable tab-selection of the search box.
1998                 setSearchBoxTabSelectable(false);
1999                 // Disable scrolling of the page.
2000                 togglePageScrolling(false);
2001         } else {
2002                 query("#theme-tweaker-toggle button").disabled = false;
2003                 // Re-enable tab-selection of the search box.
2004                 setSearchBoxTabSelectable(true);
2005                 // Re-enable scrolling of the page.
2006                 togglePageScrolling(true);
2007         }
2008         // Set theme tweaker assistant visibility.
2009         query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
2011 function setSearchBoxTabSelectable(selectable) {
2012         GWLog("setSearchBoxTabSelectable");
2013         query("input[type='search']").tabIndex = selectable ? "" : "-1";
2014         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2016 function toggleThemeTweakerHelpWindow() {
2017         GWLog("toggleThemeTweakerHelpWindow");
2018         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
2019         themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
2020         if (themeTweakerHelpWindow.style.display != "none") {
2021                 // Focus theme tweaker assistant checkbox.
2022                 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
2023                 // Disable interaction on main theme tweaker window.
2024                 query("#theme-tweaker-ui").style.pointerEvents = "none";
2025                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
2026         } else {
2027                 // Re-enable interaction on main theme tweaker window.
2028                 query("#theme-tweaker-ui").style.pointerEvents = "auto";
2029                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
2030         }
2032 function themeTweakReset() {
2033         GWLog("themeTweakReset");
2034         setSelectedTheme(GW.currentTheme);
2035         GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
2036         applyFilters(GW.currentFilters);
2037         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
2038         setTextZoom(GW.currentTextZoom);
2040 function themeTweakSave() {
2041         GWLog("themeTweakSave");
2042         GW.currentTheme = (readCookie("theme") || "default");
2043         localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
2044         localStorage.setItem("text-zoom", GW.currentTextZoom);
2047 function themeTweakerResetSettings() {
2048         GWLog("themeTweakerResetSettings");
2049         query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
2050         query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
2052 function themeTweakerSaveSettings() {
2053         GWLog("themeTweakerSaveSettings");
2054         localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
2056 function updateThemeTweakerSampleText() {
2057         GWLog("updateThemeTweakerSampleText");
2058         let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
2060         // This causes the sample text to take on the properties of the body text of a post.
2061         sampleText.removeClass("body-text");
2062         let bodyTextElement = query(".post-body") || query(".comment-body");
2063         sampleText.addClass("body-text");
2064         sampleText.style.color = bodyTextElement ? 
2065                 getComputedStyle(bodyTextElement).color : 
2066                 getComputedStyle(query("#content")).color;
2068         // Here we find out what is the actual background color that will be visible behind
2069         // the body text of posts, and set the sample text’s background to that.
2070         let findStyleBackground = (selector) => {
2071                 let x;
2072                 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2073                         if(rule.selectorText == selector)
2074                                 x = rule;
2075                 });
2076                 return x.style.backgroundColor;
2077         };
2079         sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2082 /*********************/
2083 /* PAGE QUICK-NAV UI */
2084 /*********************/
2086 function injectQuickNavUI() {
2087         GWLog("injectQuickNavUI");
2088         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2089         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
2090         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
2091         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
2092         ` + "</div>");
2095 /**********************/
2096 /* NEW COMMENT NAV UI */
2097 /**********************/
2099 function injectNewCommentNavUI(newCommentsCount) {
2100         GWLog("injectNewCommentNavUI");
2101         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
2102         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
2103         <span class='new-comments-count'></span>
2104         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
2105         + "</div>");
2107         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2108                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2109                         GWLog("GW.commentQuicknavButtonClicked");
2110                         scrollToNewComment(/next/.test(event.target.className));
2111                         event.target.blur();
2112                 });
2113         });
2115         document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => { 
2116                 GWLog("GW.commentQuicknavKeyPressed");
2117                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2118                 if (event.key == ",") scrollToNewComment(false);
2119                 if (event.key == ".") scrollToNewComment(true)
2120         });
2122         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2123         + `<span>Since:</span>`
2124         + `<input type='text' class='hns-date'></input>`
2125         + "</div>");
2127         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2128                 GWLog("GW.hnsDatePickerValueChanged");
2129                 let hnsDate = time_fromHuman(event.target.value);
2130                 if(hnsDate) {
2131                         setHistoryLastVisitedDate(hnsDate);
2132                         let newCommentsCount = highlightCommentsSince(hnsDate);
2133                         updateNewCommentNavUI(newCommentsCount);
2134                 }
2135         }, false);
2137         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2138                 GWLog("GW.newCommentsCountClicked");
2139                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2140                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2141         });
2144 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2145 function time_fromHuman(string) {
2146         /* Convert a human-readable date into a JS timestamp */
2147         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2148                 string = string.replace(' ', 'T');  // revert nice spacing
2149                 string += ':00.000Z';  // complete ISO 8601 date
2150                 time = Date.parse(string);  // milliseconds since epoch
2152                 // browsers handle ISO 8601 without explicit timezone differently
2153                 // thus, we have to fix that by hand
2154                 time += (new Date()).getTimezoneOffset() * 60e3;
2155         } else {
2156                 string = string.replace(' at', '');
2157                 time = Date.parse(string);  // milliseconds since epoch
2158         }
2159         return time;
2162 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2163         GWLog("updateNewCommentNavUI");
2164         // Update the new comments count.
2165         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2166         newCommentsCountLabel.innerText = newCommentsCount;
2167         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2169         // Update the date picker field.
2170         if (hnsDate != -1) {
2171                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2172         }
2175 /***************************/
2176 /* TEXT SIZE ADJUSTMENT UI */
2177 /***************************/
2179 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
2180         GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
2181         var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
2182         if (event.target.hasClass("decrease")) {
2183                 zoomFactor = (zoomFactor - 0.05).toFixed(2);
2184         } else if (event.target.hasClass("increase")) {
2185                 zoomFactor = (zoomFactor + 0.05).toFixed(2);
2186         } else {
2187                 zoomFactor = 1.0;
2188         }
2189         setTextZoom(zoomFactor);
2190         GW.currentTextZoom = `${zoomFactor}`;
2192         if (event.target.parentElement.id == "text-size-adjustment-ui") {
2193                 localStorage.setItem("text-zoom", GW.currentTextZoom);
2194         }
2197 function injectTextSizeAdjustmentUIReal() {
2198         GWLog("injectTextSizeAdjustmentUIReal");
2199         let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
2200         + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
2201         + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
2202         + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
2203         + "</div>");
2205         textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
2206                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
2207         });
2209         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
2212 function injectTextSizeAdjustmentUI() {
2213         GWLog("injectTextSizeAdjustmentUI");
2214         if (query("#text-size-adjustment-ui") != null) return;
2215         if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
2216         else document.addEventListener("DOMContentLoaded", () => {
2217                 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
2218         }, {once: true});
2221 /********************************/
2222 /* COMMENTS VIEW MODE SELECTION */
2223 /********************************/
2225 function injectCommentsViewModeSelector() {
2226         GWLog("injectCommentsViewModeSelector");
2227         let commentsContainer = query("#comments");
2228         if (commentsContainer == null) return;
2230         let currentModeThreaded = (location.href.search("chrono=t") == -1);
2231         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2233         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2234         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
2235         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
2236         + "</div>");
2238 //      commentsViewModeSelector.queryAll("a").forEach(button => {
2239 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2240 //      });
2242         if (!currentModeThreaded) {
2243                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2244                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2245                         commentParentLink.addClass("inline-author");
2246                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2247                 });
2249                 queryAll(".comment-child-links a").forEach(commentChildLink => {
2250                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
2251                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2252                 });
2254                 rectifyChronoModeCommentChildLinks();
2256                 commentsContainer.addClass("chrono");
2257         } else {
2258                 commentsContainer.addClass("threaded");
2259         }
2261         // Remove extraneous top-level comment thread in chrono mode.
2262         let topLevelCommentThread = query("#comments > .comment-thread");
2263         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2266 // function commentsViewModeSelectorButtonClicked(event) {
2267 //      event.preventDefault();
2268 // 
2269 //      var newDocument;
2270 //      let request = new XMLHttpRequest();
2271 //      request.open("GET", event.target.href);
2272 //      request.onreadystatechange = () => {
2273 //              if (request.readyState != 4) return;
2274 //              newDocument = htmlToElement(request.response);
2275 // 
2276 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2277 // 
2278 //              // Update the buttons.
2279 //              event.target.addClass("selected");
2280 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
2281 // 
2282 //              // Update the #comments container.
2283 //              let commentsContainer = query("#comments");
2284 //              commentsContainer.removeClass(classes.old);
2285 //              commentsContainer.addClass(classes.new);
2286 // 
2287 //              // Update the content.
2288 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2289 //      };
2290 //      request.send();
2291 // }
2292 // 
2293 // function htmlToElement(html) {
2294 //     var template = document.createElement('template');
2295 //     template.innerHTML = html.trim();
2296 //     return template.content;
2297 // }
2299 function rectifyChronoModeCommentChildLinks() {
2300         GWLog("rectifyChronoModeCommentChildLinks");
2301         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2302                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2303                 let childLinks = commentChildLinksContainer.queryAll("a");
2304                 childLinks.forEach((link, index) => {
2305                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2306                 });
2308                 // Sort by date.
2309                 let childLinksArray = Array.from(childLinks)
2310                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2311                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2312         });
2314 function childrenOfComment(commentID) {
2315         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2316                 let commentParentLink = commentItem.query("a.comment-parent-link");
2317                 return ((commentParentLink||{}).hash == "#" + commentID);
2318         });
2321 /********************************/
2322 /* COMMENTS LIST MODE SELECTION */
2323 /********************************/
2325 function injectCommentsListModeSelector() {
2326         GWLog("injectCommentsListModeSelector");
2327         if (query("#content > .comment-thread") == null) return;
2329         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2330         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2331         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2332         + "</div>";
2334         if (query(".sublevel-nav") || query("#top-nav-bar")) {
2335                 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2336         } else {
2337                 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2338         }
2339         let commentsListModeSelector = query("#comments-list-mode-selector");
2341         commentsListModeSelector.queryAll("button").forEach(button => {
2342                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2343                         GWLog("GW.commentsListModeSelectButtonClicked");
2344                         event.target.parentElement.queryAll("button").forEach(button => {
2345                                 button.removeClass("selected");
2346                                 button.disabled = false;
2347                                 button.accessKey = '`';
2348                         });
2349                         localStorage.setItem("comments-list-mode", event.target.className);
2350                         event.target.addClass("selected");
2351                         event.target.disabled = true;
2352                         event.target.removeAttribute("accesskey");
2354                         if (event.target.hasClass("expanded")) {
2355                                 query("#content").removeClass("compact");
2356                         } else {
2357                                 query("#content").addClass("compact");
2358                         }
2359                 });
2360         });
2362         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2363         if (savedMode == "compact")
2364                 query("#content").addClass("compact");
2365         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2366         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2367         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2369         if (GW.isMobile) {
2370                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2371                         commentParentLink.addActivateEvent(function (event) {
2372                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2373                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2374                         }, false);
2375                 });
2376         }
2379 /**********************/
2380 /* SITE NAV UI TOGGLE */
2381 /**********************/
2383 function injectSiteNavUIToggle() {
2384         GWLog("injectSiteNavUIToggle");
2385         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
2386         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2387                 GWLog("GW.siteNavUIToggleButtonClicked");
2388                 toggleSiteNavUI();
2389                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2390         });
2392         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2394 function removeSiteNavUIToggle() {
2395         GWLog("removeSiteNavUIToggle");
2396         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2397                 element.removeClass("engaged");
2398         });
2399         removeElement("#site-nav-ui-toggle");
2401 function toggleSiteNavUI() {
2402         GWLog("toggleSiteNavUI");
2403         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2404                 element.toggleClass("engaged");
2405                 element.removeClass("translucent-on-scroll");
2406         });
2409 /**********************/
2410 /* POST NAV UI TOGGLE */
2411 /**********************/
2413 function injectPostNavUIToggle() {
2414         GWLog("injectPostNavUIToggle");
2415         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
2416         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2417                 GWLog("GW.postNavUIToggleButtonClicked");
2418                 togglePostNavUI();
2419                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2420         });
2422         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2424 function removePostNavUIToggle() {
2425         GWLog("removePostNavUIToggle");
2426         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2427                 element.removeClass("engaged");
2428         });
2429         removeElement("#post-nav-ui-toggle");
2431 function togglePostNavUI() {
2432         GWLog("togglePostNavUI");
2433         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2434                 element.toggleClass("engaged");
2435         });
2438 /*******************************/
2439 /* APPEARANCE ADJUST UI TOGGLE */
2440 /*******************************/
2442 function injectAppearanceAdjustUIToggle() {
2443         GWLog("injectAppearanceAdjustUIToggle");
2444         let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'>&#xf013;</button></div>");
2445         appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
2446                 GWLog("GW.appearanceAdjustUIToggleButtonClicked");
2447                 toggleAppearanceAdjustUI();
2448                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
2449         });
2451         if (GW.isMobile) {
2452                 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
2453                 themeSelectorCloseButton.addClass("theme-selector-close-button");
2454                 themeSelectorCloseButton.innerHTML = "&#xf057;";
2455                 query("#theme-selector").appendChild(themeSelectorCloseButton);
2456                 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
2457         } else {
2458                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
2459         }
2461 function removeAppearanceAdjustUIToggle() {
2462         GWLog("removeAppearanceAdjustUIToggle");
2463         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2464                 element.removeClass("engaged");
2465         });
2466         removeElement("#appearance-adjust-ui-toggle");
2468 function toggleAppearanceAdjustUI() {
2469         GWLog("toggleAppearanceAdjustUI");
2470         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2471                 element.toggleClass("engaged");
2472         });
2475 /**************************/
2476 /* WORD COUNT & READ TIME */
2477 /**************************/
2479 function toggleReadTimeOrWordCount(addWordCountClass) {
2480         GWLog("toggleReadTimeOrWordCount");
2481         queryAll(".post-meta .read-time").forEach(element => {
2482                 if (addWordCountClass) element.addClass("word-count");
2483                 else element.removeClass("word-count");
2485                 let titleParts = /(\S+)(.+)$/.exec(element.title);
2486                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2487         });
2490 /**************************/
2491 /* PROMPT TO SAVE CHANGES */
2492 /**************************/
2494 function enableBeforeUnload() {
2495         window.onbeforeunload = function () { return true; };
2497 function disableBeforeUnload() {
2498         window.onbeforeunload = null;
2501 /***************************/
2502 /* ORIGINAL POSTER BADGING */
2503 /***************************/
2505 function markOriginalPosterComments() {
2506         GWLog("markOriginalPosterComments");
2507         let postAuthor = query(".post .author");
2508         if (postAuthor == null) return;
2510         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2511                 if (author.dataset.userid == postAuthor.dataset.userid ||
2512                         (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2513                         author.addClass("original-poster");
2514                         author.title += "Original poster";
2515                 }
2516         });
2519 /********************************/
2520 /* EDIT POST PAGE SUBMIT BUTTON */
2521 /********************************/
2523 function setEditPostPageSubmitButtonText() {
2524         GWLog("setEditPostPageSubmitButtonText");
2525         if (!query("#content").hasClass("edit-post-page")) return;
2527         queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2528                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2529                         GWLog("GW.postSectionSelectorValueChanged");
2530                         updateEditPostPageSubmitButtonText();
2531                 });
2532         });
2534         updateEditPostPageSubmitButtonText();
2536 function updateEditPostPageSubmitButtonText() {
2537         GWLog("updateEditPostPageSubmitButtonText");
2538         let submitButton = query("input[type='submit']");
2539         if (query("input#drafts").checked == true) 
2540                 submitButton.value = "Save Draft";
2541         else if (query(".posting-controls").hasClass("edit-existing-post"))
2542                 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2543         else
2544                 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2547 /*****************/
2548 /* ANTI-KIBITZER */
2549 /*****************/
2551 function numToAlpha(n) {
2552         let ret = "";
2553         do {
2554                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2555                 n = Math.floor((n / 26) - 1);
2556         } while (n >= 0);
2557         return ret;
2560 function injectAntiKibitzer() {
2561         GWLog("injectAntiKibitzer");
2562         // Inject anti-kibitzer toggle controls.
2563         let antiKibitzerToggle = addUIElement("<div id='anti-kibitzer-toggle'><button type='button' tabindex='-1' accesskey='g' title='Toggle anti-kibitzer (show/hide authors & karma values) [g]'></button>");
2564         antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2565                 GWLog("GW.antiKibitzerToggleButtonClicked");
2566                 if (query("#anti-kibitzer-toggle").hasClass("engaged") && 
2567                         !event.shiftKey &&
2568                         !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!)")) {
2569                         event.target.blur();
2570                         return;
2571                 }
2573                 toggleAntiKibitzerMode();
2574                 event.target.blur();
2575         });
2577         // Activate anti-kibitzer mode (if needed).
2578         if (localStorage.getItem("antikibitzer") == "true")
2579                 toggleAntiKibitzerMode();
2581         // Remove temporary CSS that hides the authors and karma values.
2582         removeElement("#antikibitzer-temp");
2585 function toggleAntiKibitzerMode() {
2586         GWLog("toggleAntiKibitzerMode");
2587         // This will be the URL of the user's own page, if logged in, or the URL of
2588         // the login page otherwise.
2589         let userTabTarget = query("#nav-item-login .nav-inner").href;
2590         let pageHeadingElement = query("h1.page-main-heading");
2592         let userCount = 0;
2593         let userFakeName = { };
2595         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2597         let postAuthor = query(".post-page .post-meta .author");
2598         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2600         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2601         if (antiKibitzerToggle.hasClass("engaged")) {
2602                 localStorage.setItem("antikibitzer", "false");
2604                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2605                 if (redirectTarget) {
2606                         window.location = redirectTarget;
2607                         return;
2608                 }
2610                 // Individual comment page title and header
2611                 if (query(".individual-thread-page")) {
2612                         let replacer = (node) => {
2613                                 if (!node) return;
2614                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
2615                         }
2616                         replacer(query("title:not(.fake-title)"));
2617                         replacer(query("#content > h1"));
2618                 }
2620                 // Author names/links.
2621                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2622                         author.textContent = author.dataset["trueName"];
2623                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2625                         author.removeClass("redacted");
2626                 });
2627                 // Post/comment karma values.
2628                 queryAll(".karma-value.redacted").forEach(karmaValue => {
2629                         karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2631                         karmaValue.removeClass("redacted");
2632                 });
2633                 // Link post domains.
2634                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2635                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2637                         linkPostDomain.removeClass("redacted");
2638                 });
2640                 antiKibitzerToggle.removeClass("engaged");
2641         } else {
2642                 localStorage.setItem("antikibitzer", "true");
2644                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2645                 if (redirectTarget) {
2646                         window.location = redirectTarget;
2647                         return;
2648                 }
2650                 // Individual comment page title and header
2651                 if (query(".individual-thread-page")) {
2652                         let replacer = (node) => {
2653                                 if (!node) return;
2654                                 node.dataset["trueContent"] = node.firstChild.wholeText;
2655                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2656                                 node.firstChild.replaceWith(newText);
2657                         }
2658                         replacer(query("title:not(.fake-title)"));
2659                         replacer(query("#content > h1"));
2660                 }
2662                 removeElement("title.fake-title");
2664                 // Author names/links.
2665                 queryAll(".author, .inline-author").forEach(author => {
2666                         // Skip own posts/comments.
2667                         if (author.hasClass("own-user-author"))
2668                                 return;
2670                         let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2672                         if(!userid) return;
2674                         author.dataset["trueName"] = author.textContent;
2675                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2677                         if (/\/user/.test(author.href)) {
2678                                 author.dataset["trueLink"] = author.pathname;
2679                                 author.href = "/user?id=" + author.dataset["userid"];
2680                         }
2682                         author.addClass("redacted");
2683                 });
2684                 // Post/comment karma values.
2685                 queryAll(".karma-value").forEach(karmaValue => {
2686                         // Skip own posts/comments.
2687                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2688                                 return;
2690                         karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2691                         karmaValue.innerHTML = "##<span> points</span>";
2693                         karmaValue.addClass("redacted");
2694                 });
2695                 // Link post domains.
2696                 queryAll(".link-post-domain").forEach(linkPostDomain => {
2697                         // Skip own posts/comments.
2698                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2699                                 return;
2701                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2702                         linkPostDomain.textContent = "redacted.domain.tld";
2704                         linkPostDomain.addClass("redacted");
2705                 });
2707                 antiKibitzerToggle.addClass("engaged");
2708         }
2711 /*******************************/
2712 /* COMMENT SORT MODE SELECTION */
2713 /*******************************/
2715 var CommentSortMode = Object.freeze({
2716         TOP:            "top",
2717         NEW:            "new",
2718         OLD:            "old",
2719         HOT:            "hot"
2721 function sortComments(mode) {
2722         GWLog("sortComments");
2723         let commentsContainer = query("#comments");
2725         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2726         commentsContainer.addClass("sorting");
2728         GW.commentValues = { };
2729         let clonedCommentsContainer = commentsContainer.cloneNode(true);
2730         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2731                 var comparator;
2732                 switch (mode) {
2733                 case CommentSortMode.NEW:
2734                         comparator = (a,b) => commentDate(b) - commentDate(a);
2735                         break;
2736                 case CommentSortMode.OLD:
2737                         comparator = (a,b) => commentDate(a) - commentDate(b);
2738                         break;
2739                 case CommentSortMode.HOT:
2740                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2741                         break;
2742                 case CommentSortMode.TOP:
2743                 default:
2744                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2745                         break;
2746                 }
2747                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2748         });
2749         removeElement(commentsContainer.lastChild);
2750         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2751         GW.commentValues = { };
2753         if (loggedInUserId) {
2754                 // Re-activate vote buttons.
2755                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2756                         voteButton.addActivateEvent(voteButtonClicked);
2757                 });
2759                 // Re-activate comment action buttons.
2760                 commentsContainer.queryAll(".action-button").forEach(button => {
2761                         button.addActivateEvent(GW.commentActionButtonClicked);
2762                 });
2763         }
2765         // Re-activate comment-minimize buttons.
2766         queryAll(".comment-minimize-button").forEach(button => {
2767                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2768         });
2770         // Re-add comment parent popups.
2771         addCommentParentPopups();
2772         
2773         // Redo new-comments highlighting.
2774         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2776         requestAnimationFrame(() => {
2777                 commentsContainer.removeClass("sorting");
2778                 commentsContainer.addClass("sorted-" + mode);
2779         });
2781 function commentKarmaValue(commentOrSelector) {
2782         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2783         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2785 function commentDate(commentOrSelector) {
2786         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2787         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2789 function commentVoteCount(commentOrSelector) {
2790         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2791         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2794 function injectCommentsSortModeSelector() {
2795         GWLog("injectCommentsSortModeSelector");
2796         let topCommentThread = query("#comments > .comment-thread");
2797         if (topCommentThread == null) return;
2799         // Do not show sort mode selector if there is no branching in comment tree.
2800         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2802         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
2803                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
2804                 "</div>";
2805         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2806         let commentsSortModeSelector = query("#comments-sort-mode-selector");
2808         commentsSortModeSelector.queryAll("button").forEach(button => {
2809                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2810                         GWLog("GW.commentsSortModeSelectButtonClicked");
2811                         event.target.parentElement.queryAll("button").forEach(button => {
2812                                 button.removeClass("selected");
2813                                 button.disabled = false;
2814                         });
2815                         event.target.addClass("selected");
2816                         event.target.disabled = true;
2818                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2819                         setCommentsSortModeSelectButtonsAccesskey();
2820                 });
2821         });
2823         // TODO: Make this actually get the current sort mode (if that's saved).
2824         // TODO: Also change the condition here to properly get chrono/threaded mode,
2825         // when that is properly done with cookies.
2826         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2827         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2828         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2829         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2830         setCommentsSortModeSelectButtonsAccesskey();
2833 function setCommentsSortModeSelectButtonsAccesskey() {
2834         GWLog("setCommentsSortModeSelectButtonsAccesskey");
2835         queryAll("#comments-sort-mode-selector button").forEach(button => {
2836                 button.removeAttribute("accesskey");
2837                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2838         });
2839         let selectedButton = query("#comments-sort-mode-selector button.selected");
2840         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2841         nextButtonInCycle.accessKey = "z";
2842         nextButtonInCycle.title += " [z]";
2845 /*************************/
2846 /* COMMENT PARENT POPUPS */
2847 /*************************/
2849 function previewPopupsEnabled() {
2850         let isDisabled = localStorage.getItem("preview-popups-disabled");
2851         return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
2854 function setPreviewPopupsEnabled(state) {
2855         localStorage.setItem("preview-popups-disabled", !state);
2856         updatePreviewPopupToggle();
2859 function updatePreviewPopupToggle() {
2860         let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
2861         query("#preview-popup-toggle").setAttribute("style", style);
2864 function injectPreviewPopupToggle() {
2865         GWLog("injectPreviewPopupToggle");
2867         let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
2868         // This is required because Chrome can't use filters on an externally used SVG element.
2869         fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
2870         updatePreviewPopupToggle();
2871         toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
2874 var currentPreviewPopup = { };
2876 function removePreviewPopup(previewPopup) {
2877         if(previewPopup.element)
2878                 removeElement(previewPopup.element);
2880         if(previewPopup.timeout)
2881                 clearTimeout(previewPopup.timeout);
2883         if(currentPreviewPopup.pointerListener)
2884                 window.removeEventListener("pointermove", previewPopup.pointerListener);
2886         if(currentPreviewPopup.mouseoutListener)
2887                 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2889         if(currentPreviewPopup.scrollListener)
2890                 window.removeEventListener("scroll", previewPopup.scrollListener);
2892         currentPreviewPopup = { };
2895 function addCommentParentPopups() {
2896         GWLog("addCommentParentPopups");
2897         //if (!query("#content").hasClass("comment-thread-page")) return;
2899         queryAll("a[href]").forEach(linkTag => {
2900                 let linkHref = linkTag.getAttribute("href");
2902                 let url;
2903                 try { url = new URL(linkHref, window.location.href); }
2904                 catch(e) { }
2905                 if(!url) return;
2907                 if(GW.sites[url.host]) {
2908                         let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
2909                         
2910                         if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
2911                                 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
2912                                         if(event.pointerType == "touch") return;
2913                                         GWLog("GW.commentParentLinkMouseOver");
2914                                         removePreviewPopup(currentPreviewPopup);
2915                                         let parentID = linkHref;
2916                                         var parent, popup;
2917                                         if (!(parent = (query(parentID)||{}).firstChild)) return;
2918                                         var highlightClassName;
2919                                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2920                                                 parentHighlightClassName = "comment-item-highlight-faint";
2921                                                 popup = parent.cloneNode(true);
2922                                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2923                                                 linkTag.addEventListener("mouseout", (event) => {
2924                                                         removeElement(popup);
2925                                                 }, {once: true});
2926                                                 linkTag.closest(".comments > .comment-thread").appendChild(popup);
2927                                         } else {
2928                                                 parentHighlightClassName = "comment-item-highlight";
2929                                         }
2930                                         parent.parentNode.addClass(parentHighlightClassName);
2931                                         linkTag.addEventListener("mouseout", (event) => {
2932                                                 parent.parentNode.removeClass(parentHighlightClassName);
2933                                         }, {once: true});
2934                                 });
2935                         }
2936                         else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
2937                                 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
2938                                 && !(url.searchParams.get('format'))
2939                                 && !linkTag.closest("nav:not(.post-nav-links)")
2940                                 && (!url.hash || linkCommentId)
2941                                 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
2942                                 linkTag.addEventListener("pointerover", event => {
2943                                         if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
2944                                         if(currentPreviewPopup.linkTag) return;
2945                                         linkTag.createPreviewPopup();
2946                                 });
2947                                 linkTag.createPreviewPopup = function() {
2948                                         removePreviewPopup(currentPreviewPopup);
2950                                         currentPreviewPopup = {linkTag: linkTag};
2951                                         
2952                                         let popup = document.createElement("iframe");
2953                                         currentPreviewPopup.element = popup;
2955                                         let popupTarget = linkHref;
2956                                         if(popupTarget.match(/#comment-/)) {
2957                                                 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
2958                                         }
2959                                         // 'theme' attribute is required for proper caching
2960                                         popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
2961                                         popup.addClass("preview-popup");
2962                                         
2963                                         let linkRect = linkTag.getBoundingClientRect();
2965                                         if(linkRect.right + 710 < window.innerWidth)
2966                                                 popup.style.left = linkRect.right + 10 + "px";
2967                                         else
2968                                                 popup.style.right = "10px";
2970                                         popup.style.width = "700px";
2971                                         popup.style.height = "500px";
2972                                         popup.style.visibility = "hidden";
2973                                         popup.style.transition = "none";
2975                                         let recenter = function() {
2976                                                 let popupHeight = 500;
2977                                                 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
2978                                                         let popupContent = popup.contentDocument.querySelector("#content");
2979                                                         if(popupContent) {
2980                                                                 popupHeight = popupContent.clientHeight + 2;
2981                                                                 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
2982                                                                 popup.style.height = popupHeight + "px";
2983                                                         }
2984                                                 }
2985                                                 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
2986                                         }
2988                                         recenter();
2990                                         query('#content').insertAdjacentElement("beforeend", popup);
2992                                         let clickListener = event => {
2993                                                 if(!event.target.closest("a, input, label")
2994                                                    && !event.target.closest("popup-hide-button")) {
2995                                                         window.location = linkHref;
2996                                                 }
2997                                         };
2999                                         popup.addEventListener("load", () => {
3000                                                 let hideButton = popup.contentDocument.createElement("div");
3001                                                 hideButton.className = "popup-hide-button";
3002                                                 hideButton.insertAdjacentText('beforeend', "\uF070");
3003                                                 hideButton.onclick = (event) => {
3004                                                         removePreviewPopup(currentPreviewPopup);
3005                                                         setPreviewPopupsEnabled(false);
3006                                                         event.stopPropagation();
3007                                                 }
3008                                                 popup.contentDocument.body.appendChild(hideButton);
3009                                                 
3010                                                 let body = popup.contentDocument.body;
3011                                                 body.addEventListener("click", clickListener);
3012                                                 body.style.cursor = "pointer";
3014                                                 recenter();
3015                                         });
3017                                         popup.contentDocument.body.addEventListener("click", clickListener);
3018                                         
3019                                         currentPreviewPopup.timeout = setTimeout(() => {
3020                                                 recenter();
3022                                                 requestIdleCallback(() => {
3023                                                         if(currentPreviewPopup.element === popup) {
3024                                                                 popup.scrolling = "";
3025                                                                 popup.style.visibility = "unset";
3026                                                                 popup.style.transition = null;
3028                                                                 popup.animate([
3029                                                                         { opacity: 0, transform: "translateY(10%)" },
3030                                                                         { opacity: 1, transform: "none" }
3031                                                                 ], { duration: 150, easing: "ease-out" });
3032                                                         }
3033                                                 });
3034                                         }, 1000);
3036                                         let pointerX, pointerY, mousePauseTimeout = null;
3038                                         currentPreviewPopup.pointerListener = (event) => {
3039                                                 pointerX = event.clientX;
3040                                                 pointerY = event.clientY;
3042                                                 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3043                                                 mousePauseTimeout = null;
3045                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3046                                                 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3048                                                 if(mouseIsOverLink || overElement === popup
3049                                                    || (pointerX < popup.getBoundingClientRect().left
3050                                                        && event.movementX >= 0)) {
3051                                                         if(!mouseIsOverLink && overElement !== popup) {
3052                                                                 if(overElement['createPreviewPopup']) {
3053                                                                         mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3054                                                                 } else {
3055                                                                         mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3056                                                                 }
3057                                                         }
3058                                                 } else {
3059                                                         removePreviewPopup(currentPreviewPopup);
3060                                                         if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3061                                                 }
3062                                         };
3063                                         window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3065                                         currentPreviewPopup.mouseoutListener = (event) => {
3066                                                 clearTimeout(mousePauseTimeout);
3067                                                 mousePauseTimeout = null;
3068                                         }
3069                                         document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3071                                         currentPreviewPopup.scrollListener = (event) => {
3072                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3073                                                 linkRect = linkTag.getBoundingClientRect();
3074                                                 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3075                                                 removePreviewPopup(currentPreviewPopup);
3076                                         };
3077                                         window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3078                                 };
3079                         }
3080                 }
3081         });
3082         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3083                 
3084         });
3086         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3087         GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
3088                 "#content .comments .comment-thread"
3089         ];
3090         applyFilters(GW.currentFilters);
3093 /***************/
3094 /* IMAGE FOCUS */
3095 /***************/
3097 function imageFocusSetup(imagesOverlayOnly = false) {
3098         if (typeof GW.imageFocus == "undefined")
3099                 GW.imageFocus = {
3100                         contentImagesSelector:  "#content img",
3101                         overlayImagesSelector:  "#images-overlay img",
3102                         focusedImageSelector:   "#content img.focused, #images-overlay img.focused",
3103                         pageContentSelector:    "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3104                         shrinkRatio:                    0.975,
3105                         hideUITimerDuration:    1500,
3106                         hideUITimerExpired:             () => {
3107                                 GWLog("GW.imageFocus.hideUITimerExpired");
3108                                 let currentTime = new Date();
3109                                 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3110                                 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3111                                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3112                                 } else {
3113                                         hideImageFocusUI();
3114                                         cancelImageFocusHideUITimer();
3115                                 }
3116                         }
3117                 };
3119         GWLog("imageFocusSetup");
3120         // Create event listener for clicking on images to focus them.
3121         GW.imageClickedToFocus = (event) => {
3122                 GWLog("GW.imageClickedToFocus");
3123                 focusImage(event.target);
3125                 if (!GW.isMobile) {
3126                         // Set timer to hide the image focus UI.
3127                         unhideImageFocusUI();
3128                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3129                 }
3130         };
3131         // Add the listener to each image in the overlay (i.e., those in the post).
3132         queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3133                 image.addActivateEvent(GW.imageClickedToFocus);
3134         });
3135         // Accesskey-L starts the slideshow.
3136         (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3137         // Count how many images there are in the post, and set the "… of X" label to that.
3138         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3139         if (imagesOverlayOnly) return;
3140         // Add the listener to all other content images (including those in comments).
3141         queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3142                 image.addActivateEvent(GW.imageClickedToFocus);
3143         });
3145         // Create the image focus overlay.
3146         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
3147         `<div class='help-overlay'>
3148                  <p><strong>Arrow keys:</strong> Next/previous image</p>
3149                  <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3150                  <p><strong>Space bar:</strong> Reset image size & position</p>
3151                  <p><strong>Scroll</strong> to zoom in/out</p>
3152                  <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3153         </div>
3154         <div class='image-number'></div>
3155         <div class='slideshow-buttons'>
3156                  <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
3157                  <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
3158         </div>
3159         <div class='caption'></div>` + 
3160         "</div>");
3161         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3163         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3164                 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3165                         GWLog("GW.imageFocus.slideshowButtonClicked");
3166                         focusNextImage(event.target.hasClass("next"));
3167                         event.target.blur();
3168                 });
3169         });
3171         // On orientation change, reset the size & position.
3172         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3173                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3174         }
3176         // UI starts out hidden.
3177         hideImageFocusUI();
3180 function focusImage(imageToFocus) {
3181         GWLog("focusImage");
3182         // Clear 'last-focused' class of last focused image.
3183         let lastFocusedImage = query("img.last-focused");
3184         if (lastFocusedImage) {
3185                 lastFocusedImage.removeClass("last-focused");
3186                 lastFocusedImage.removeAttribute("accesskey");
3187         }
3189         // Create the focused version of the image.
3190         imageToFocus.addClass("focused");
3191         let imageFocusOverlay = query("#image-focus-overlay");
3192         let clonedImage = imageToFocus.cloneNode(true);
3193         clonedImage.style = "";
3194         clonedImage.removeAttribute("width");
3195         clonedImage.removeAttribute("height");
3196         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3197         imageFocusOverlay.appendChild(clonedImage);
3198         imageFocusOverlay.addClass("engaged");
3200         // Set image to default size and position.
3201         resetFocusedImagePosition();
3203         // Blur everything else.
3204         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3205                 element.addClass("blurred");
3206         });
3208         // Add listener to zoom image with scroll wheel.
3209         window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3210                 GWLog("GW.imageFocus.scrollEvent");
3211                 event.preventDefault();
3213                 let image = query("#image-focus-overlay img");
3215                 // Remove the filter.
3216                 image.savedFilter = image.style.filter;
3217                 image.style.filter = 'none';
3219                 // Locate point under cursor.
3220                 let imageBoundingBox = image.getBoundingClientRect();
3222                 // Calculate resize factor.
3223                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3224                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3225                                                 1;
3227                 // Resize.
3228                 image.style.width = (event.deltaY < 0 ?
3229                                                         (image.clientWidth * factor) :
3230                                                         (image.clientWidth / factor))
3231                                                         + "px";
3232                 image.style.height = "";
3234                 // Designate zoom origin.
3235                 var zoomOrigin;
3236                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3237                 // the cursor is over the image.
3238                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3239                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3240                                                                 (imageBoundingBox.left <= event.clientX &&
3241                                                                  event.clientX <= imageBoundingBox.right && 
3242                                                                  imageBoundingBox.top <= event.clientY &&
3243                                                                  event.clientY <= imageBoundingBox.bottom);
3244                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
3245                 // zooming IN, zoom from image center.
3246                 let zoomingFromWindowCenter = event.deltaY > 0;
3247                 if (zoomingFromCursor)
3248                         zoomOrigin = { x: event.clientX, 
3249                                                    y: event.clientY };
3250                 else if (zoomingFromWindowCenter)
3251                         zoomOrigin = { x: window.innerWidth / 2, 
3252                                                    y: window.innerHeight / 2 };
3253                 else
3254                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
3255                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3257                 // Calculate offset from zoom origin.
3258                 let offsetOfImageFromZoomOrigin = {
3259                         x: imageBoundingBox.x - zoomOrigin.x,
3260                         y: imageBoundingBox.y - zoomOrigin.y
3261                 }
3262                 // Calculate delta from centered zoom.
3263                 let deltaFromCenteredZoom = {
3264                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3265                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3266                 }
3267                 // Adjust image position appropriately.
3268                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3269                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3270                 // Gradually re-center image, if it's smaller than the window.
3271                 if (!imageSizeExceedsWindowBounds) {
3272                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
3273                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3274                         let windowCenter = { x: window.innerWidth / 2,
3275                                                                  y: window.innerHeight / 2 }
3276                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3277                                                                                   y: windowCenter.y - imageCenter.y }
3278                         // Divide the offset by 10 because we're nudging the image toward center,
3279                         // not jumping it there.
3280                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3281                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3282                 }
3284                 // Put the filter back.
3285                 image.style.filter = image.savedFilter;
3287                 // Set the cursor appropriately.
3288                 setFocusedImageCursor();
3289         });
3290         window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3291                 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3292                 event.preventDefault();
3293         });
3295         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3296         window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3297                 GWLog("GW.imageFocus.mouseUp");
3298                 window.onmousemove = '';
3300                 // We only want to do anything on left-clicks.
3301                 if (event.button != 0) return;
3303                 // Don't unfocus if click was on a slideshow next/prev button!
3304                 if (event.target.hasClass("slideshow-button")) return;
3306                 // We also don't want to do anything if clicked on the help overlay.
3307                 if (event.target.classList.contains("help-overlay") ||
3308                         event.target.closest(".help-overlay"))
3309                         return;
3311                 let focusedImage = query("#image-focus-overlay img");
3312                 if (event.target == focusedImage && 
3313                         (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3314                         // If the mouseup event was the end of a pan of an overside image,
3315                         // put the filter back; do not unfocus.
3316                         focusedImage.style.filter = focusedImage.savedFilter;
3317                 } else {
3318                         unfocusImageOverlay();
3319                         return;
3320                 }
3321         });
3322         window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3323                 GWLog("GW.imageFocus.mouseDown");
3324                 event.preventDefault();
3326                 let focusedImage = query("#image-focus-overlay img");
3327                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3328                         let mouseCoordX = event.clientX;
3329                         let mouseCoordY = event.clientY;
3331                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3332                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3334                         // Save the filter.
3335                         focusedImage.savedFilter = focusedImage.style.filter;
3337                         window.onmousemove = (event) => {
3338                                 // Remove the filter.
3339                                 focusedImage.style.filter = "none";
3340                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3341                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3342                         };
3343                         return false;
3344                 }
3345         });
3347         // Double-click on the image unfocuses.
3348         clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3349                 GWLog("GW.imageFocus.doubleClick");
3350                 if (event.target.hasClass("slideshow-button")) return;
3352                 unfocusImageOverlay();
3353         });
3355         // Escape key unfocuses, spacebar resets.
3356         document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3357                 GWLog("GW.imageFocus.keyUp");
3358                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3359                 if (!allowedKeys.contains(event.key) || 
3360                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3362                 event.preventDefault();
3364                 switch (event.key) {
3365                 case "Escape": 
3366                 case "Esc":
3367                         unfocusImageOverlay();
3368                         break;
3369                 case " ":
3370                 case "Spacebar":
3371                         resetFocusedImagePosition();
3372                         break;
3373                 case "ArrowDown":
3374                 case "Down":
3375                 case "ArrowRight":
3376                 case "Right":
3377                         if (query("#images-overlay img.focused")) focusNextImage(true);
3378                         break;
3379                 case "ArrowUp":
3380                 case "Up":
3381                 case "ArrowLeft":
3382                 case "Left":
3383                         if (query("#images-overlay img.focused")) focusNextImage(false);
3384                         break;
3385                 }
3386         });
3388         // Prevent spacebar or arrow keys from scrolling page when image focused.
3389         togglePageScrolling(false);
3391         // If the image comes from the images overlay, for the main post...
3392         if (imageToFocus.closest("#images-overlay")) {
3393                 // Mark the overlay as being in slide show mode (to show buttons/count).
3394                 imageFocusOverlay.addClass("slideshow");
3396                 // Set state of next/previous buttons.
3397                 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3398                 var indexOfFocusedImage = getIndexOfFocusedImage();
3399                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3400                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3402                 // Set the image number.
3403                 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3405                 // Replace the hash.
3406                 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3407         } else {
3408                 imageFocusOverlay.removeClass("slideshow");
3409         }
3411         // Set the caption.
3412         setImageFocusCaption();
3414         // Moving mouse unhides image focus UI.
3415         window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3416                 GWLog("GW.imageFocus.mouseMoved");
3417                 let currentDateTime = new Date();
3418                 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3419                         cancelImageFocusHideUITimer();
3420                 } else {
3421                         if (!GW.imageFocus.hideUITimer) {
3422                                 unhideImageFocusUI();
3423                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3424                         }
3425                         GW.imageFocus.mouseLastMovedAt = currentDateTime;
3426                 }
3427         });
3430 function resetFocusedImagePosition() {
3431         GWLog("resetFocusedImagePosition");
3432         let focusedImage = query("#image-focus-overlay img");
3433         if (!focusedImage) return;
3435         let sourceImage = query(GW.imageFocus.focusedImageSelector);
3437         // Make sure that initially, the image fits into the viewport.
3438         let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3439         let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3440         var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3441         let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3442         let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3443         focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3444         focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3446         // Remove modifications to position.
3447         focusedImage.style.left = "";
3448         focusedImage.style.top = "";
3450         // Set the cursor appropriately.
3451         setFocusedImageCursor();
3453 function setFocusedImageCursor() {
3454         let focusedImage = query("#image-focus-overlay img");
3455         if (!focusedImage) return;
3456         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
3457                                                                 'move' : '';
3460 function unfocusImageOverlay() {
3461         GWLog("unfocusImageOverlay");
3463         // Remove event listeners.
3464         window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3465         window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3466         // NOTE: The double-click listener does not need to be removed manually,
3467         // because the focused (cloned) image will be removed anyway.
3468         document.removeEventListener("keyup", GW.imageFocus.keyUp);
3469         document.removeEventListener("keydown", GW.imageFocus.keyDown);
3470         window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3471         window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3472         window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3474         // Set accesskey of currently focused image (if it's in the images overlay).
3475         let currentlyFocusedImage = query("#images-overlay img.focused");
3476         if (currentlyFocusedImage) {
3477                 currentlyFocusedImage.addClass("last-focused");
3478                 currentlyFocusedImage.accessKey = 'l';
3479         }
3481         // Remove focused image and hide overlay.
3482         let imageFocusOverlay = query("#image-focus-overlay");
3483         imageFocusOverlay.removeClass("engaged");
3484         removeElement(imageFocusOverlay.query("img"));
3486         // Un-blur content/etc.
3487         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3488                 element.removeClass("blurred");
3489         });
3491         // Unset "focused" class of focused image.
3492         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3494         // Re-enable page scrolling.
3495         togglePageScrolling(true);
3497         // Reset the hash, if needed.
3498         if (location.hash.hasPrefix("#if_slide_"))
3499                 history.replaceState(window.history.state, null, "#");
3502 function getIndexOfFocusedImage() {
3503         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3504         var indexOfFocusedImage = -1;
3505         for (i = 0; i < images.length; i++) {
3506                 if (images[i].hasClass("focused")) {
3507                         indexOfFocusedImage = i;
3508                         break;
3509                 }
3510         }
3511         return indexOfFocusedImage;
3514 function focusNextImage(next = true) {
3515         GWLog("focusNextImage");
3516         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3517         var indexOfFocusedImage = getIndexOfFocusedImage();
3519         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3521         // Remove existing image.
3522         removeElement("#image-focus-overlay img");
3523         // Unset "focused" class of just-removed image.
3524         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3526         // Create the focused version of the image.
3527         images[indexOfFocusedImage].addClass("focused");
3528         let imageFocusOverlay = query("#image-focus-overlay");
3529         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3530         clonedImage.style = "";
3531         clonedImage.removeAttribute("width");
3532         clonedImage.removeAttribute("height");
3533         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3534         imageFocusOverlay.appendChild(clonedImage);
3535         imageFocusOverlay.addClass("engaged");
3536         // Set image to default size and position.
3537         resetFocusedImagePosition();
3538         // Set state of next/previous buttons.
3539         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3540         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3541         // Set the image number display.
3542         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3543         // Set the caption.
3544         setImageFocusCaption();
3545         // Replace the hash.
3546         history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3549 function setImageFocusCaption() {
3550         GWLog("setImageFocusCaption");
3551         var T = { }; // Temporary storage.
3553         // Clear existing caption, if any.
3554         let captionContainer = query("#image-focus-overlay .caption");
3555         Array.from(captionContainer.children).forEach(child => { child.remove(); });
3557         // Determine caption.
3558         let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3559         var captionHTML;
3560         if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) && 
3561                 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3562                 captionHTML = (T.figcaption.query("p")) ? 
3563                                           T.figcaption.innerHTML : 
3564                                           "<p>" + T.figcaption.innerHTML + "</p>"; 
3565         } else if (currentlyFocusedImage.title != "") {
3566                 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3567         }
3568         // Insert the caption, if any.
3569         if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3572 function hideImageFocusUI() {
3573         GWLog("hideImageFocusUI");
3574         let imageFocusOverlay = query("#image-focus-overlay");
3575         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3576                 element.addClass("hidden");
3577         });
3580 function unhideImageFocusUI() {
3581         GWLog("unhideImageFocusUI");
3582         let imageFocusOverlay = query("#image-focus-overlay");
3583         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3584                 element.removeClass("hidden");
3585         });
3588 function cancelImageFocusHideUITimer() {
3589         clearTimeout(GW.imageFocus.hideUITimer);
3590         GW.imageFocus.hideUITimer = null;
3593 /*****************/
3594 /* KEYBOARD HELP */
3595 /*****************/
3597 function keyboardHelpSetup() {
3598         let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3599                 <div class='keyboard-help-container'>
3600                         <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'>&#xf00d;</button>
3601                         <h1>Keyboard shortcuts</h1>
3602                         <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>
3603                         <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3604                         <div class='keyboard-shortcuts-lists'>` + [ [
3605                                 "General",
3606                                 [ [ '?' ], "Show keyboard shortcuts" ],
3607                                 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3608                         ], [
3609                                 "Site navigation",
3610                                 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3611                                 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3612                                 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3613                                 [ [ 'ak-m' ], "Go to Meta view" ],
3614                                 [ [ 'ak-v' ], "Go to Tags view"],
3615                                 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3616                                 [ [ 'ak-r' ], "Go to Archive view" ],
3617                                 [ [ 'ak-q' ], "Go to Sequences view" ],
3618                                 [ [ 'ak-t' ], "Go to About page" ],
3619                                 [ [ 'ak-u' ], "Go to User or Login page" ],
3620                                 [ [ 'ak-o' ], "Go to Inbox page" ]
3621                         ], [
3622                                 "Page navigation",
3623                                 [ [ 'ak-,' ], "Jump up to top of page" ],
3624                                 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3625                                 [ [ 'ak-/' ], "Jump to top of comments section" ],
3626                                 [ [ 'ak-s' ], "Search" ],
3627                         ], [
3628                                 "Page actions",
3629                                 [ [ 'ak-n' ], "New post or comment" ],
3630                                 [ [ 'ak-e' ], "Edit current post" ]
3631                         ], [
3632                                 "Post/comment list views",
3633                                 [ [ '.' ], "Focus next entry in list" ],
3634                                 [ [ ',' ], "Focus previous entry in list" ],
3635                                 [ [ ';' ], "Cycle between links in focused entry" ],
3636                                 [ [ 'Enter' ], "Go to currently focused entry" ],
3637                                 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3638                                 [ [ 'ak-]' ], "Go to next page" ],
3639                                 [ [ 'ak-[' ], "Go to previous page" ],
3640                                 [ [ 'ak-\\' ], "Go to first page" ],
3641                                 [ [ 'ak-e' ], "Edit currently focused post" ]
3642                         ], [
3643                                 "Editor",
3644                                 [ [ 'ak-k' ], "Bold text" ],
3645                                 [ [ 'ak-i' ], "Italic text" ],
3646                                 [ [ 'ak-l' ], "Insert hyperlink" ],
3647                                 [ [ 'ak-q' ], "Blockquote text" ]
3648                         ], [                            
3649                                 "Appearance",
3650                                 [ [ 'ak-=' ], "Increase text size" ],
3651                                 [ [ 'ak--' ], "Decrease text size" ],
3652                                 [ [ 'ak-0' ], "Reset to default text size" ],
3653                                 [ [ 'ak-′' ], "Cycle through content width settings" ],
3654                                 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3655                                 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3656                                 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3657                                 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3658                                 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3659                                 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3660                                 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3661                                 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3662                                 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3663                                 [ [ 'ak-;' ], "Open theme tweaker" ],
3664                                 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3665                                 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3666                         ], [
3667                                 "Slide shows",
3668                                 [ [ 'ak-l' ], "Start/resume slideshow" ],
3669                                 [ [ 'Esc' ], "Exit slideshow" ],
3670                                 [ [ '&#x2192;', '&#x2193;' ], "Next slide" ],
3671                                 [ [ '&#x2190;', '&#x2191;' ], "Previous slide" ],
3672                                 [ [ 'Space' ], "Reset slide zoom" ]
3673                         ], [
3674                                 "Miscellaneous",
3675                                 [ [ 'ak-x' ], "Switch to next view on user page" ],
3676                                 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3677                                 [ [ 'ak-`&nbsp;' ], "Toggle compact comment list view" ],
3678                                 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3679                         ] ].map(section => 
3680                         `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3681                                 `<li>
3682                                         <span class='keys'>` + 
3683                                         entry[0].map(key =>
3684                                                 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3685                                         ).join("") + 
3686                                         `</span>
3687                                         <span class='action'>${entry[1]}</span>
3688                                 </li>`
3689                         ).join("\n") + `</ul>`).join("\n") + `
3690                         </ul></div>             
3691                 </div>
3692         ` + "</nav>");
3694         // Add listener to show the keyboard help overlay.
3695         document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3696                 GWLog("GW.keyboardHelpShowKeyPressed");
3697                 if (event.key == '?')
3698                         toggleKeyboardHelpOverlay(true);
3699         });
3701         // Clicking the background overlay closes the keyboard help overlay.
3702         keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3703                 GWLog("GW.keyboardHelpOverlayClicked");
3704                 if (event.type == 'mousedown') {
3705                         keyboardHelpOverlay.style.opacity = "0.01";
3706                 } else {
3707                         toggleKeyboardHelpOverlay(false);
3708                         keyboardHelpOverlay.style.opacity = "1.0";
3709                 }
3710         }, true);
3712         // Intercept clicks, so they don't "fall through" the background overlay.
3713         (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3715         // Clicking the close button closes the keyboard help overlay.
3716         keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3717                 toggleKeyboardHelpOverlay(false);
3718         });
3720         // Add button to open keyboard help.
3721         query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'>&#xf11c;</button>");
3722         query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3723                 GWLog("GW.openKeyboardHelpButtonClicked");
3724                 toggleKeyboardHelpOverlay(true);
3725                 event.target.blur();
3726         });
3729 function toggleKeyboardHelpOverlay(show) {
3730         console.log("toggleKeyboardHelpOverlay");
3732         let keyboardHelpOverlay = query("#keyboard-help-overlay");
3733         show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3734         keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3736         // Prevent scrolling the document when the overlay is visible.
3737         togglePageScrolling(!show);
3739         // Focus the close button as soon as we open.
3740         keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3742         if (show) {
3743                 // Add listener to show the keyboard help overlay.
3744                 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3745                         GWLog("GW.keyboardHelpHideKeyPressed");
3746                         if (event.key == 'Escape')
3747                                 toggleKeyboardHelpOverlay(false);
3748                 });
3749         } else {
3750                 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3751         }
3753         // Disable / enable tab-selection of the search box.
3754         setSearchBoxTabSelectable(!show);
3757 /**********************/
3758 /* PUSH NOTIFICATIONS */
3759 /**********************/
3761 function pushNotificationsSetup() {
3762         let pushNotificationsButton = query("#enable-push-notifications");
3763         if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3764                 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3765                 pushNotificationsButton.style.display = 'unset';
3766         }
3769 function urlBase64ToUint8Array(base64String) {
3770         const padding = '='.repeat((4 - base64String.length % 4) % 4);
3771         const base64 = (base64String + padding)
3772               .replace(/-/g, '+')
3773               .replace(/_/g, '/');
3774         
3775         const rawData = window.atob(base64);
3776         const outputArray = new Uint8Array(rawData.length);
3777         
3778         for (let i = 0; i < rawData.length; ++i) {
3779                 outputArray[i] = rawData.charCodeAt(i);
3780         }
3781         return outputArray;
3784 function pushNotificationsButtonClicked(event) {
3785         event.target.style.opacity = 0.33;
3786         event.target.style.pointerEvents = "none";
3788         let reEnable = (message) => {
3789                 if(message) alert(message);
3790                 event.target.style.opacity = 1;
3791                 event.target.style.pointerEvents = "unset";
3792         }
3794         if(event.target.dataset.enabled) {
3795                 fetch('/push/register', {
3796                         method: 'post',
3797                         headers: { 'Content-type': 'application/json' },
3798                         body: JSON.stringify({
3799                                 cancel: true
3800                         }),
3801                 }).then(() => {
3802                         event.target.innerHTML = "Enable push notifications";
3803                         event.target.dataset.enabled = "";
3804                         reEnable();
3805                 }).catch((err) => reEnable(err.message));
3806         } else {
3807                 Notification.requestPermission().then((permission) => {
3808                         navigator.serviceWorker.ready
3809                                 .then((registration) => {
3810                                         return registration.pushManager.getSubscription()
3811                                                 .then(async function(subscription) {
3812                                                         if (subscription) {
3813                                                                 return subscription;
3814                                                         }
3815                                                         return registration.pushManager.subscribe({
3816                                                                 userVisibleOnly: true,
3817                                                                 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
3818                                                         });
3819                                                 })
3820                                                 .catch((err) => reEnable(err.message));
3821                                 })
3822                                 .then((subscription) => {
3823                                         fetch('/push/register', {
3824                                                 method: 'post',
3825                                                 headers: {
3826                                                         'Content-type': 'application/json'
3827                                                 },
3828                                                 body: JSON.stringify({
3829                                                         subscription: subscription
3830                                                 }),
3831                                         });
3832                                 })
3833                                 .then(() => {
3834                                         event.target.innerHTML = "Disable push notifications";
3835                                         event.target.dataset.enabled = "true";
3836                                         reEnable();
3837                                 })
3838                                 .catch(function(err){ reEnable(err.message) });
3839                         
3840                 });
3841         }
3844 /*******************************/
3845 /* HTML TO MARKDOWN CONVERSION */
3846 /*******************************/
3848 function MarkdownFromHTML(text) {
3849         GWLog("MarkdownFromHTML");
3850         // Wrapper tags, paragraphs, bold, italic, code blocks.
3851         text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
3852                 switch(tag) {
3853                 case "html":
3854                 case "/html":
3855                 case "head":
3856                 case "/head":
3857                 case "body":
3858                 case "/body":
3859                 case "p":
3860                         return "";
3861                 case "/p":
3862                         return "\n";
3863                 case "strong":
3864                 case "/strong":
3865                         return "**";
3866                 case "em":
3867                 case "/em":
3868                         return "*";
3869                 default:
3870                         return match;
3871                 }
3872         });
3874         // <div> and <span>.
3875         text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
3876                 return `${text}\n`;
3877         }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
3878                 return `${text}\n`;
3879         });
3881         // Unordered lists.
3882         text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
3883                 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
3884                         return `* ${listItem}\n`;
3885                 });
3886         });
3888         // Ordered lists.
3889         text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
3890                 var countedItemValue = 0;
3891                 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
3892                         var itemValue;
3893                         if (typeof specifiedItemValue != "undefined") {
3894                                 specifiedItemValue = parseInt(specifiedItemValue);
3895                                 countedItemValue = itemValue = specifiedItemValue;
3896                         } else {
3897                                 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
3898                         }
3899                         return `${itemValue}. ${listItem.trim()}\n`;
3900                 });
3901         });
3903         // Headings.
3904         text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
3905                 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
3906         });
3908         // Blockquotes.
3909         text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
3910                 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
3911         });
3913         // Links.
3914         text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
3915                 return `[${text}](${href})`;
3916         }).trim();
3918         // Images.
3919         text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
3920                 return `![](${src})`;
3921         });
3923         // Horizontal rules.
3924         text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
3925                 return "\n---\n";
3926         });
3928         // Line breaks.
3929         text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
3930                 return "\\\n";
3931         });
3933         // Preformatted text (possibly with a code block inside).
3934         text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
3935                 return "```\n" + text + "\n```";
3936         });
3938         // Code blocks.
3939         text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
3940                 return "`" + text + "`";
3941         });
3943         // HTML entities.
3944         text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
3945                 switch(entity) {
3946                 case "gt":
3947                         return ">";
3948                 case "lt":
3949                         return "<";
3950                 case "amp":
3951                         return "&";
3952                 case "apos":
3953                         return "'";
3954                 case "quot":
3955                         return "\"";
3956                 default:
3957                         return match;
3958                 }
3959         });
3961         return text;
3964 /************************************/
3965 /* ANCHOR LINK SCROLLING WORKAROUND */
3966 /************************************/
3968 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
3969         let hash = location.hash;
3970         if(hash && hash !== "#top" && !document.query(hash)) {
3971                 let content = document.query("#content");
3972                 content.style.display = "none";
3973                 addTriggerListener("DOMReady", {priority: -1, fn: () => {
3974                         content.style.visibility = "hidden";
3975                         content.style.display = null;
3976                         requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
3977                 }});
3978         }
3979 }});
3981 /******************/
3982 /* INITIALIZATION */
3983 /******************/
3985 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
3986         GWLog("INITIALIZER earlyInitialize");
3987         // Check to see whether we're on a mobile device (which we define as a narrow screen)
3988         GW.isMobile = (window.innerWidth <= 1160);
3989         GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3991         // Backward compatibility
3992         let storedTheme = localStorage.getItem('selected-theme');
3993         if (storedTheme) {
3994                 setTheme(storedTheme);
3995                 localStorage.removeItem('selected-theme');
3996         }
3998         // Animate width & theme adjustments?
3999         GW.adjustmentTransitions = false;
4001         // Add the content width selector.
4002         injectContentWidthSelector();
4003         // Add the text size adjustment widget.
4004         injectTextSizeAdjustmentUI();
4005         // Add the dark mode selector.
4006         DarkMode.injectModeSelector();
4007         // Add the theme selector.
4008         injectThemeSelector();
4009         // Add the theme tweaker.
4010         injectThemeTweaker();
4011         // Add the quick-nav UI.
4012         injectQuickNavUI();
4014         // Finish initializing when ready.
4015         addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4016 }});
4018 function mainInitializer() {
4019         GWLog("INITIALIZER initialize");
4021         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4022         // "link without nav bars".
4023         if (getQueryVariable("hide-nav-bars") == "true") {
4024                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
4025         }
4027         // If the page cannot have comments, remove the accesskey from the #comments
4028         // quick-nav button; and if the page can have comments, but does not, simply 
4029         // disable the #comments quick nav button.
4030         let content = query("#content");
4031         if (content.query("#comments") == null) {
4032                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4033         } else if (content.query("#comments .comment-thread") == null) {
4034                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4035         }
4037         // On edit post pages and conversation pages, add GUIEdit buttons to the 
4038         // textarea, expand it, and markdownify the existing text, if any (this is
4039         // needed if a post was last edited on LW).
4040         queryAll(".with-markdown-editor textarea").forEach(textarea => {
4041                 textarea.addTextareaFeatures();
4042                 expandTextarea(textarea);
4043                 textarea.value = MarkdownFromHTML(textarea.value);
4044         });
4045         // Focus the textarea.
4046         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4048         // Clean up ToC
4049         queryAll(".contents-list li a").forEach(tocLink => {
4050                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
4051                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
4052                 tocLink.innerText = tocLink.innerText.replace(/^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})\. /i, '');
4053                 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
4054         });
4056         // If we're on a comment thread page...
4057         if (query(".comments") != null) {
4058                 // Add comment-minimize buttons to every comment.
4059                 queryAll(".comment-meta").forEach(commentMeta => {
4060                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4061                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
4062                 });
4063                 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4064                         // Format and activate comment-minimize buttons.
4065                         queryAll(".comment-minimize-button").forEach(button => {
4066                                 button.closest(".comment-item").setCommentThreadMaximized(false);
4067                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4068                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
4069                                 });
4070                         });
4071                 }
4072         }
4073         if (getQueryVariable("chrono") == "t") {
4074                 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
4075         }
4077         // On mobile, replace the labels for the checkboxes on the edit post form
4078         // with icons, to save space.
4079         if (GW.isMobile && query(".edit-post-page")) {
4080                 query("label[for='link-post']").innerHTML = "&#xf0c1";
4081                 query("label[for='question']").innerHTML = "&#xf128";
4082         }
4084         // Add error message (as placeholder) if user tries to click Search with
4085         // an empty search field.
4086         searchForm: {
4087                 let searchForm = query("#nav-item-search form");
4088                 if(!searchForm) break searchForm;
4089                 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4090                         let searchField = event.target.query("input");
4091                         if (searchField.value == "") {
4092                                 event.preventDefault();
4093                                 event.target.blur();
4094                                 searchField.placeholder = "Enter a search string!";
4095                                 searchField.focus();
4096                         }
4097                 });
4098                 // Remove the placeholder / error on any input.
4099                 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4100                         event.target.placeholder = "";
4101                 });
4102         }
4104         // Prevent conflict between various single-hotkey listeners and text fields
4105         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4106                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4107                 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4108         });
4110         if (content.hasClass("post-page")) {
4111                 // Read and update last-visited-date.
4112                 let lastVisitedDate = getLastVisitedDate();
4113                 setLastVisitedDate(Date.now());
4115                 // Save the number of comments this post has when it's visited.
4116                 updateSavedCommentCount();
4118                 if (content.query(".comments .comment-thread") != null) {
4119                         // Add the new comments count & navigator.
4120                         injectNewCommentNavUI();
4122                         // Get the highlight-new-since date (as specified by URL parameter, if 
4123                         // present, or otherwise the date of the last visit).
4124                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4126                         // Highlight new comments since the specified date.                      
4127                         let newCommentsCount = highlightCommentsSince(hnsDate);
4129                         // Update the comment count display.
4130                         updateNewCommentNavUI(newCommentsCount, hnsDate);
4131                 }
4132         } else {
4133                 // On listing pages, make comment counts more informative.
4134                 badgePostsWithNewComments();
4135         }
4137         // Add the comments list mode selector widget (expanded vs. compact).
4138         injectCommentsListModeSelector();
4140         // Add the comments view selector widget (threaded vs. chrono).
4141 //      injectCommentsViewModeSelector();
4143         // Add the comments sort mode selector (top, hot, new, old).
4144         if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4146         // Add the toggle for the post nav UI elements on mobile.
4147         if (GW.isMobile) injectPostNavUIToggle();
4149         // Add the toggle for the appearance adjustment UI elements on mobile.
4150         if (GW.isMobile) injectAppearanceAdjustUIToggle();
4152         // Add the antikibitzer.
4153         if (GW.useFancyFeatures) injectAntiKibitzer();
4155         // Add comment parent popups.
4156         injectPreviewPopupToggle();
4157         addCommentParentPopups();
4159         // Mark original poster's comments with a special class.
4160         markOriginalPosterComments();
4161         
4162         // On the All view, mark posts with non-positive karma with a special class.
4163         if (query("#content").hasClass("all-index-page")) {
4164                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4165                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4167                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4168                 });
4169         }
4171         // Set the "submit" button on the edit post page to something more helpful.
4172         setEditPostPageSubmitButtonText();
4174         // Compute the text of the pagination UI tooltip text.
4175         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4176                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4177         });
4179         // Add event listeners for Escape and Enter, for the theme tweaker.
4180         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
4181         let themeTweakerUI = query("#theme-tweaker-ui");
4182         document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
4183                 if (event.key == "Escape") {
4184                         if (themeTweakerHelpWindow.style.display != "none") {
4185                                 toggleThemeTweakerHelpWindow();
4186                                 themeTweakerResetSettings();
4187                         } else if (themeTweakerUI.style.display != "none") {
4188                                 toggleThemeTweakerUI();
4189                                 themeTweakReset();
4190                         }
4191                 } else if (event.key == "Enter") {
4192                         if (themeTweakerHelpWindow.style.display != "none") {
4193                                 toggleThemeTweakerHelpWindow();
4194                                 themeTweakerSaveSettings();
4195                         } else if (themeTweakerUI.style.display != "none") {
4196                                 toggleThemeTweakerUI();
4197                                 themeTweakSave();
4198                         }
4199                 }
4200         });
4202         // Add event listener for . , ; (for navigating listings pages).
4203         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4204         if (!query(".comments") && listings.length > 0) {
4205                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
4206                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4208                         if (event.key == "Escape") {
4209                                 if (document.activeElement.parentElement.hasClass("listing"))
4210                                         document.activeElement.blur();
4211                                 return;
4212                         }
4214                         if (event.key == ';') {
4215                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4216                                         let links = document.activeElement.parentElement.queryAll("a");
4217                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4218                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4219                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4220                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4221                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4222                                 }
4223                                 return;
4224                         }
4226                         var indexOfActiveListing = -1;
4227                         for (i = 0; i < listings.length; i++) {
4228                                 if (document.activeElement.parentElement.hasClass("listing") && 
4229                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4230                                         indexOfActiveListing = i;
4231                                         break;
4232                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
4233                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
4234                                         indexOfActiveListing = i;
4235                                         break;
4236                                 }
4237                         }
4238                         // Remove edit accesskey from currently highlighted post by active user, if applicable.
4239                         if (indexOfActiveListing > -1) {
4240                                 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4241                         }
4242                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4243                         if (indexOfNextListing < listings.length) {
4244                                 listings[indexOfNextListing].focus();
4246                                 if (listings[indexOfNextListing].closest(".comment-item")) {
4247                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4248                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4249                                 }
4250                         } else {
4251                                 document.activeElement.blur();
4252                         }
4253                         // Add edit accesskey to newly highlighted post by active user, if applicable.
4254                         (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4255                 });
4256                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4257                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4258                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4259                         });
4260                 });
4261         }
4262         // Add event listener for ; (to focus the link on link posts).
4263         if (query("#content").hasClass("post-page") && 
4264                 query(".post").hasClass("link-post")) {
4265                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4266                         if (event.key == ';') query("a.link-post-link").focus();
4267                 });
4268         }
4270         // Add accesskeys to user page view selector.
4271         let viewSelector = query("#content.user-page > .sublevel-nav");
4272         if (viewSelector) {
4273                 let currentView = viewSelector.query("span");
4274                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4275                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4276         }
4278         // Add accesskey to index page sort selector.
4279         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4281         // Move MathJax style tags to <head>.
4282         var aggregatedStyles = "";
4283         queryAll("#content style").forEach(styleTag => {
4284                 aggregatedStyles += styleTag.innerHTML;
4285                 removeElement("style", styleTag.parentElement);
4286         });
4287         if (aggregatedStyles != "") {
4288                 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4289         }
4291         // Add listeners to switch between word count and read time.
4292         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4293         queryAll(".post-meta .read-time").forEach(element => {
4294                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4295                         let displayWordCount = localStorage.getItem("display-word-count");
4296                         toggleReadTimeOrWordCount(!displayWordCount);
4297                         if (displayWordCount) localStorage.removeItem("display-word-count");
4298                         else localStorage.setItem("display-word-count", true);
4299                 });
4300         });
4302         // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4303         query("#content").addEventListener("copy", GW.textCopied = (event) => {
4304                 if(event.target.matches("input, textarea")) return;
4305                 event.preventDefault();
4306                 const selectedHTML = getSelectionHTML();
4307                 const selectedText = getSelection().toString();
4308                 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4309                 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4310         });
4312         // Set up Image Focus feature.
4313         imageFocusSetup();
4315         // Set up keyboard shortcuts guide overlay.
4316         keyboardHelpSetup();
4318         // Show push notifications button if supported
4319         pushNotificationsSetup();
4321         // Show elements now that javascript is ready.
4322         removeElement("#hide-until-init");
4324         activateTrigger("pageLayoutFinished");
4327 /*************************/
4328 /* POST-LOAD ADJUSTMENTS */
4329 /*************************/
4331 window.addEventListener("pageshow", badgePostsWithNewComments);
4333 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4334         GWLog("INITIALIZER pageLayoutFinished");
4336         postSetThemeHousekeeping();
4338         focusImageSpecifiedByURL();
4340         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4341 //      query("input[type='search']").value = GW.isMobile;
4342 //      insertHeadHTML("<style>" +
4343 //              `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` + 
4344 //              `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` + 
4345 //              "</style>");
4346 }});
4348 function generateImagesOverlay() {
4349         GWLog("generateImagesOverlay");
4350         // Don't do this on the about page.
4351         if (query(".about-page") != null) return;
4352         return;
4354         // Remove existing, if any.
4355         removeElement("#images-overlay");
4357         // Create new.
4358         query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4359         let imagesOverlay = query("#images-overlay");
4360         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4361         queryAll(".post-body img").forEach(image => {
4362                 let clonedImageContainer = document.createElement("div");
4364                 let clonedImage = image.cloneNode(true);
4365                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4366                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4367                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4368                 clonedImageContainer.appendChild(clonedImage);
4370                 let zoomLevel = parseFloat(GW.currentTextZoom);
4372                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4373                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4374                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4375                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4377                 imagesOverlay.appendChild(clonedImageContainer);
4378         });
4380         // Add the event listeners to focus each image.
4381         imageFocusSetup(true);
4384 function adjustUIForWindowSize() {
4385         GWLog("adjustUIForWindowSize");
4386         var bottomBarOffset;
4388         // Adjust bottom bar state.
4389         let bottomBar = query("#bottom-bar");
4390         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4391         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4392                 bottomBar.removeClass("decorative");
4394                 bottomBar.query("#nav-item-top").style.display = "";
4395         } else if (bottomBar) {
4396                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4397                 else bottomBar.addClass("decorative");
4399                 bottomBar.query("#nav-item-top").style.display = "none";
4400         }
4402         // Show quick-nav UI up/down buttons if content is taller than window.
4403         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4404         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4405                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4406         });
4408         // Move anti-kibitzer toggle if content is very short.
4409         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4411         // Update the visibility of the post nav UI.
4412         updatePostNavUIVisibility();
4415 function recomputeUIElementsContainerHeight(force = false) {
4416         GWLog("recomputeUIElementsContainerHeight");
4417         if (!GW.isMobile &&
4418                 (force || query("#ui-elements-container").style.height != "")) {
4419                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4420                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
4421                                                                                                                 query("#content").clientHeight + "px" :
4422                                                                                                                 "100vh";
4423         }
4426 function focusImageSpecifiedByURL() {
4427         GWLog("focusImageSpecifiedByURL");
4428         if (location.hash.hasPrefix("#if_slide_")) {
4429                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4430                         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4431                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4432                         if (imageToFocus > 0 && imageToFocus <= images.length) {
4433                                 focusImage(images[imageToFocus - 1]);
4435                                 // Set timer to hide the image focus UI.
4436                                 unhideImageFocusUI();
4437                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4438                         }
4439                 });
4440         }
4443 /***********/
4444 /* GUIEDIT */
4445 /***********/
4447 function insertMarkup(event) {
4448         var mopen = '', mclose = '', mtext = '', func = false;
4449         if (typeof arguments[1] == 'function') {
4450                 func = arguments[1];
4451         } else {
4452                 mopen = arguments[1];
4453                 mclose = arguments[2];
4454                 mtext = arguments[3];
4455         }
4457         var textarea = event.target.closest("form").query("textarea");
4458         textarea.focus();
4459         var p0 = textarea.selectionStart;
4460         var p1 = textarea.selectionEnd;
4461         var cur0 = cur1 = p0;
4463         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4464         str = func ? func(str, p0) : (mopen + str + mclose);
4466         // Determine selection.
4467         if (!func) {
4468                 cur0 += (p0 == p1) ? mopen.length : str.length;
4469                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4470         } else {
4471                 cur0 = str[1];
4472                 cur1 = str[2];
4473                 str = str[0];
4474         }
4476         // Update textarea contents.
4477         // The document.execCommand API is broken in Firefox 
4478         // ( https://bugzilla.mozilla.org/show_bug.cgi?id=1220696 ), but using it
4479         // allows native undo/redo to work; so we enable it in other browsers.
4480         if (GW.isFirefox) {
4481                 textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
4482         } else {
4483                 document.execCommand("insertText", false, str);
4484         }
4485         // Expand textarea, if needed.
4486         expandTextarea(textarea);
4488         // Set selection.
4489         textarea.selectionStart = cur0;
4490         textarea.selectionEnd = cur1;
4492         return;
4495 GW.guiEditButtons = [
4496         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
4497         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
4498         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
4499         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
4500         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
4501         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
4502         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
4503         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
4504         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
4505         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
4506         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
4507         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
4508         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
4509         [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
4510         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
4513 function blockquote(text, startpos) {
4514         if (text == '') {
4515                 text = "> Quoted text";
4516                 return [ text, startpos + 2, startpos + text.length ];
4517         } else {
4518                 text = "> " + text.split("\n").join("\n> ") + "\n";
4519                 return [ text, startpos + text.length, startpos + text.length ];
4520         }
4523 function hyperlink(text, startpos) {
4524         var url = '', link_text = text, endpos = startpos;
4525         if (text.search(/^https?/) != -1) {
4526                 url = text;
4527                 link_text = "link text";
4528                 startpos = startpos + 1;
4529                 endpos = startpos + link_text.length;
4530         } else {
4531                 url = prompt("Link address (URL):");
4532                 if (!url) {
4533                         endpos = startpos + text.length;
4534                         return [ text, startpos, endpos ];
4535                 }
4536                 startpos = startpos + text.length + url.length + 4;
4537                 endpos = startpos;
4538         }
4540         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4543 if(navigator.serviceWorker) {
4544         navigator.serviceWorker.register('/service-worker.js');
4545         setCookie("push", "t");