Fix compatibility with new LW footnote markup.
[lw2-viewer.git] / www / script.js
blobb7dd69d1d11511dcbc2a6285f40d319ce2533d74
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 /********************/
234 /* DEBUGGING OUTPUT */
235 /********************/
237 GW.enableLogging = (permanently = false) => {
238         if (permanently)
239                 localStorage.setItem("logging-enabled", "true");
240         else
241                 GW.loggingEnabled = true;
243 GW.disableLogging = (permanently = false) => {
244         if (permanently)
245                 localStorage.removeItem("logging-enabled");
246         else
247                 GW.loggingEnabled = false;
250 /*******************/
251 /* INBOX INDICATOR */
252 /*******************/
254 function processUserStatus(userStatus) {
255         window.userStatus = userStatus;
256         if(userStatus) {
257                 if(userStatus.notifications) {
258                         let element = query('#inbox-indicator');
259                         element.className = 'new-messages';
260                         element.title = 'New messages [o]';
261                 }
262         } else {
263                 location.reload();
264         }
267 /**************/
268 /* COMMENTING */
269 /**************/
271 function toggleMarkdownHintsBox() {
272         GWLog("toggleMarkdownHintsBox");
273         let markdownHintsBox = query("#markdown-hints");
274         markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
276 function hideMarkdownHintsBox() {
277         GWLog("hideMarkdownHintsBox");
278         let markdownHintsBox = query("#markdown-hints");
279         if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
282 Element.prototype.addTextareaFeatures = function() {
283         GWLog("addTextareaFeatures");
284         let textarea = this;
286         textarea.addEventListener("focus", GW.textareaFocused = (event) => {
287                 GWLog("GW.textareaFocused");
288                 event.target.closest("form").scrollIntoViewIfNeeded();
289         });
290         textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
291                 GWLog("GW.textareaInputReceived");
292                 if (window.innerWidth > 520) {
293                         // Expand textarea if needed.
294                         expandTextarea(textarea);
295                 } else {
296                         // Remove markdown hints.
297                         hideMarkdownHintsBox();
298                         query(".guiedit-mobile-help-button").removeClass("active");
299                 }
300         }, false);
301         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
302         textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
304         let form = textarea.closest("form");
305         if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
307         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
308         let textareaContainer = textarea.closest(".textarea-container");
309         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
310         for (var button of GW.guiEditButtons) {
311                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
312                 buttons_container.insertAdjacentHTML("beforeend", 
313                         "<button type='button' class='guiedit guiedit-" 
314                         + name
315                         + "' tabindex='-1'"
316                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
317                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
318                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
319                         + " onclick='insertMarkup(event,"
320                         + ((typeof m_before_or_func == 'function') ?
321                                 m_before_or_func.name : 
322                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
323                         + ");'><div>"
324                         + icon
325                         + "</div></button>"
326                 );
327         }
329         var markdown_hints = 
330         `<input type='checkbox' id='markdown-hints-checkbox'>
331         <label for='markdown-hints-checkbox'></label>
332         <div id='markdown-hints'>` + 
333         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
334                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
335                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
336                 "<span>Heading 1</span><code># Heading 1</code>",
337                 "<span>Heading 2</span><code>## Heading 1</code>",
338                 "<span>Heading 3</span><code>### Heading 1</code>",
339                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
340         `</div>`;
341         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
343         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
344                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
345                         GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
346                         if (button.hasClass("guiedit-mobile-help-button")) {
347                                 toggleMarkdownHintsBox();
348                                 event.target.toggleClass("active");
349                                 query(".posting-controls:focus-within textarea").focus();
350                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
351                                 event.target.blur();
352                                 hideMarkdownHintsBox();
353                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
354                         }
355                 });
356         });
358         // On smartphone (narrow mobile) screens, when a textarea is focused (and
359         // automatically fullscreened), remove all the filters from the page, and 
360         // then apply them *just* to the fixed editor UI elements. This is in order
361         // to get around the "children of elements with a filter applied cannot be
362         // fixed" issue".
363         if (GW.isMobile && window.innerWidth <= 520) {
364                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
365                 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
366                         GWLog("GW.textareaFocusedMobile");
367                         GW.savedFilters = GW.currentFilters;
368                         GW.currentFilters = { };
369                         applyFilters(GW.currentFilters);
370                         fixedEditorElements.forEach(element => {
371                                 element.style.filter = filterStringFromFilters(GW.savedFilters);
372                         });
373                 });
374                 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
375                         GWLog("GW.textareaBlurredMobile");
376                         GW.currentFilters = GW.savedFilters;
377                         GW.savedFilters = { };
378                         requestAnimationFrame(() => {
379                                 applyFilters(GW.currentFilters);
380                                 fixedEditorElements.forEach(element => {
381                                         element.style.filter = filterStringFromFilters(GW.savedFilters);
382                                 });
383                         });
384                 });
385         }
388 Element.prototype.injectReplyForm = function(editMarkdownSource) {
389         GWLog("injectReplyForm");
390         let commentControls = this;
391         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
392         let postId = commentControls.parentElement.dataset["postId"];
393         let tagId = commentControls.parentElement.dataset["tagId"];
394         let withparent = (!editMarkdownSource && commentControls.getCommentId());
395         let answer = commentControls.parentElement.id == "answers";
396         let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
397         let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
398         let parentCommentItem = commentControls.closest(".comment-item");
399         let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
400             (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
401         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
402                 "<form method='post'>" + 
403                 "<div class='textarea-container'>" + 
404                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
405                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
406                 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
407                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
408                 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
409                 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
410                 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
411                 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
412                 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
413                 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
414                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
415                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
416                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
417                 "</div><div>" + 
418                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
419                 "<input type='submit' value='Submit'>" + 
420                 "</div></form>";
421         commentControls.onsubmit = disableBeforeUnload;
423         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
424                 GWLog("GW.cancelCommentButtonClicked");
425                 hideReplyForm(event.target.closest(".comment-controls"));
426         });
427         commentControls.scrollIntoViewIfNeeded();
428         commentControls.query("form").onsubmit = (event) => {
429                 if (!event.target.text.value) {
430                         alert("Please enter a comment.");
431                         return false;
432                 }
433         }
434         let textarea = commentControls.query("textarea");
435         textarea.value = MarkdownFromHTML(editMarkdownSource || "");
436         textarea.addTextareaFeatures();
437         textarea.focus();
440 function showCommentEditForm(commentItem) {
441         GWLog("showCommentEditForm");
443         let commentBody = commentItem.query(".comment-body");
444         commentBody.style.display = "none";
446         let commentControls = commentItem.query(".comment-controls");
447         commentControls.injectReplyForm(commentBody.dataset.markdownSource);
448         commentControls.query("form").addClass("edit-existing-comment");
449         expandTextarea(commentControls.query("textarea"));
452 function showReplyForm(commentItem) {
453         GWLog("showReplyForm");
455         let commentControls = commentItem.query(".comment-controls");
456         commentControls.injectReplyForm(commentControls.dataset.enteredText);
459 function hideReplyForm(commentControls) {
460         GWLog("hideReplyForm");
461         // Are we editing a comment? If so, un-hide the existing comment body.
462         let containingComment = commentControls.closest(".comment-item");
463         if (containingComment) containingComment.query(".comment-body").style.display = "";
465         let enteredText = commentControls.query("textarea").value;
466         if (enteredText) commentControls.dataset.enteredText = enteredText;
468         disableBeforeUnload();
469         commentControls.constructCommentControls();
472 function expandTextarea(textarea) {
473         GWLog("expandTextarea");
474         if (window.innerWidth <= 520) return;
476         let totalBorderHeight = 30;
477         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
479         requestAnimationFrame(() => {
480                 textarea.style.height = 'auto';
481                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
482                 if (textarea.clientHeight < window.innerHeight) {
483                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
484                 }
485         });
488 function doCommentAction(action, commentItem) {
489         GWLog("doCommentAction");
490         let params = {};
491         params[(action + "-comment-id")] = commentItem.getCommentId();
492         doAjax({
493                 method: "POST",
494                 params: params,
495                 onSuccess: GW.commentActionPostSucceeded = (event) => {
496                         GWLog("GW.commentActionPostSucceeded");
497                         let fn = {
498                                 retract: () => { commentItem.firstChild.addClass("retracted") },
499                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
500                                 delete: () => {
501                                         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>";
502                                         commentItem.removeChild(commentItem.query(".comment-controls"));
503                                 }
504                         }[action];
505                         if(fn) fn();
506                         if(action != "delete")
507                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
508                 }
509         });
512 /**********/
513 /* VOTING */
514 /**********/
516 function parseVoteType(voteType) {
517         GWLog("parseVoteType");
518         let value = {};
519         if (!voteType) return value;
520         value.up = /[Uu]pvote$/.test(voteType);
521         value.down = /[Dd]ownvote$/.test(voteType);
522         value.big = /^big/.test(voteType);
523         return value;
526 function makeVoteType(value) {
527         GWLog("makeVoteType");
528         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
531 function makeVoteClass(vote) {
532         GWLog("makeVoteClass");
533         if (vote.up || vote.down) {
534                 return (vote.big ? 'selected big-vote' : 'selected');
535         } else {
536                 return '';
537         }
540 function findVoteControls(targetType, targetId, voteAxis) {
541         var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
543         if(targetType == "Post") {
544                 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
545         } else if(targetType == "Comment") {
546                 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
547         }
550 function votesEqual(vote1, vote2) {
551         var allKeys = Object.assign({}, vote1);
552         Object.assign(allKeys, vote2);
554         for(k of allKeys.keys()) {
555                 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
556         }
557         return true;
560 function addVoteButtons(element, vote, targetType) {
561         GWLog("addVoteButtons");
562         vote = vote || {};
563         let voteAxis = element.parentElement.dataset.voteAxis || "karma";
564         let voteType = parseVoteType(vote[voteAxis]);
565         let voteClass = makeVoteClass(voteType);
567         element.parentElement.queryAll("button").forEach((button) => {
568                 button.disabled = false;
569                 if (voteType) {
570                         if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
571                                 button.addClass(voteClass);
572                 }
573                 updateVoteButtonVisualState(button);
574                 button.addActivateEvent(voteButtonClicked);
575         });
578 function updateVoteButtonVisualState(button) {
579         GWLog("updateVoteButtonVisualState");
581         button.removeClasses([ "none", "one", "two-temp", "two" ]);
583         if (button.disabled)
584                 button.addClass("none");
585         else if (button.hasClass("big-vote"))
586                 button.addClass("two");
587         else if (button.hasClass("selected"))
588                 button.addClass("one");
589         else
590                 button.addClass("none");
593 function changeVoteButtonVisualState(button) {
594         GWLog("changeVoteButtonVisualState");
596         /*      Interaction states are:
598                 0  0·    (neutral; +1 click)
599                 1  1·    (small vote; +1 click)
600                 2  2·    (big vote; +1 click)
602                 Visual states are (with their state classes in [brackets]) are:
604                 01    (no vote) [none]
605                 02    (small vote active) [one]
606                 12    (small vote active, temporary indicator of big vote) [two-temp]
607                 22    (big vote active) [two]
609                 The following are the 9 possible interaction state transitions (and
610                 the visual state transitions associated with them):
612                                 VIS.    VIS.
613                 FROM    TO      FROM    TO      NOTES
614                 ====    ====    ====    ====    =====
615                 0       0·      01      12      first click
616                 0·      1       12      02      one click without second
617                 0·      2       12      22      second click
619                 1       1·      02      12      first click
620                 1·      0       12      01      one click without second
621                 1·      2       12      22      second click
623                 2       2·      22      12      first click
624                 2·      1       12      02      one click without second
625                 2·      0       12      01      second click
626         */
627         let transitions = [
628                 [ "big-vote two-temp clicked-twice", "none"     ], // 2· => 0
629                 [ "big-vote two-temp clicked-once",  "one"      ], // 2· => 1
630                 [ "big-vote clicked-once",           "two-temp" ], // 2  => 2·
632                 [ "selected two-temp clicked-twice", "two"      ], // 1· => 2
633                 [ "selected two-temp clicked-once",  "none"     ], // 1· => 0
634                 [ "selected clicked-once",           "two-temp" ], // 1  => 1·
636                 [ "two-temp clicked-twice",          "two"      ], // 0· => 2
637                 [ "two-temp clicked-once",           "one"      ], // 0· => 1
638                 [ "clicked-once",                    "two-temp" ], // 0  => 0·
639         ];
640         for (let [ interactionClasses, visualStateClass ] of transitions) {
641                 if (button.hasClasses(interactionClasses.split(" "))) {
642                         button.removeClasses([ "none", "one", "two-temp", "two" ]);
643                         button.addClass(visualStateClass);
644                         break;
645                 }
646         }
649 function voteCompleteEvent(targetType, targetId, response) {
650         GWLog("voteCompleteEvent");
652         var currentVote = voteData[targetType][targetId] || {};
653         var desiredVote = voteDesired[targetType][targetId];
655         var controls = findVoteControls(targetType, targetId);
656         var controlsByAxis = new Object;
658         controls.forEach(control => {
659                 const voteAxis = (control.dataset.voteAxis || "karma");
661                 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
662                         control.removeClass("waiting");
663                         control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
664                 }
666                 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
667                 controlsByAxis[voteAxis].push(control);
669                 const voteType = currentVote[voteAxis];
670                 const vote = parseVoteType(voteType);
671                 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
672                 const voteClass = makeVoteClass(vote);
674                 if (response && response[voteAxis]) {
675                         const [voteType, displayText, titleText] = response[voteAxis];
677                         const displayTarget = control.query(".karma-value");
678                         if (displayTarget.hasClass("redacted")) {
679                                 displayTarget.dataset["trueValue"] = displayText;
680                         } else {
681                                 displayTarget.innerHTML = displayText;
682                         }
683                         displayTarget.setAttribute("title", titleText);
684                 }
686                 control.queryAll("button.vote").forEach(button => {
687                         updateVoteButton(button, voteUpDown, voteClass);
688                 });
689         });
692 function updateVoteButton(button, voteUpDown, voteClass) {
693         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
694         if (button.dataset.voteType == voteUpDown)
695                 button.addClass(voteClass);
696         updateVoteButtonVisualState(button);
699 function makeVoteRequestCompleteEvent(targetType, targetId) {
700         return (event) => {
701                 var currentVote = {};
702                 var response = null;
704                 if (event.target.status == 200) {
705                         response = JSON.parse(event.target.responseText);
706                         for (const voteAxis of response.keys()) {
707                                 currentVote[voteAxis] = response[voteAxis][0];
708                         }
709                         voteData[targetType][targetId] = currentVote;
710                 } else {
711                         delete voteDesired[targetType][targetId];
712                         currentVote = voteData[targetType][targetId];
713                 }
715                 var desiredVote = voteDesired[targetType][targetId];
717                 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
718                         sendVoteRequest(targetType, targetId);
719                 } else {
720                         delete voteDesired[targetType][targetId];
721                         voteCompleteEvent(targetType, targetId, response);
722                 }
723         }
726 function sendVoteRequest(targetType, targetId) {
727         GWLog("sendVoteRequest");
729         doAjax({
730                 method: "POST",
731                 location: "/karma-vote",
732                 params: { "target": targetId,
733                           "target-type": targetType,
734                           "vote": JSON.stringify(voteDesired[targetType][targetId]) },
735                 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
736         });
739 function voteButtonClicked(event) {
740         GWLog("voteButtonClicked");
741         let voteButton = event.target;
743         // 500 ms (0.5 s) double-click timeout.
744         let doubleClickTimeout = 500;
746         if (!voteButton.clickedOnce) {
747                 voteButton.clickedOnce = true;
748                 voteButton.addClass("clicked-once");
749                 changeVoteButtonVisualState(voteButton);
751                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
752                         if (!voteButton.clickedOnce) return;
754                         // Do single-click code.
755                         voteButton.clickedOnce = false;
756                         voteEvent(voteButton, 1);
757                 }, doubleClickTimeout, voteButton);
758         } else {
759                 voteButton.clickedOnce = false;
761                 // Do double-click code.
762                 voteButton.removeClass("clicked-once");
763                 voteButton.addClass("clicked-twice");
764                 voteEvent(voteButton, 2);
765         }
768 function voteEvent(voteButton, numClicks) {
769         GWLog("voteEvent");
770         voteButton.blur();
772         let voteControl = voteButton.parentNode;
774         let targetType = voteButton.dataset.targetType;
775         let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
776         let voteAxis = voteControl.dataset.voteAxis || "karma";
777         let voteUpDown = voteButton.dataset.voteType;
779         let voteType;
780         if (   (numClicks == 2 && voteButton.hasClass("big-vote"))
781                 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
782                 voteType = "neutral";
783         } else {
784                 let vote = parseVoteType(voteUpDown);
785                 vote.big = (numClicks == 2);
786                 voteType = makeVoteType(vote);
787         }
789         let voteControls = findVoteControls(targetType, targetId, voteAxis);
790         for (const voteControl of voteControls) {
791                 voteControl.addClass("waiting");
792                 voteControl.queryAll(".vote").forEach(button => {
793                         button.addClass("waiting");
794                         updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
795                 });
796         }
798         let voteRequestPending = voteDesired[targetType][targetId];
799         let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
800         voteObject[voteAxis] = voteType;
801         voteDesired[targetType][targetId] = voteObject;
803         if (!voteRequestPending) sendVoteRequest(targetType, targetId);
806 function initializeVoteButtons() {
807         // Color the upvote/downvote buttons with an embedded style sheet.
808         query("head").insertAdjacentHTML("beforeend", "<style id='vote-buttons'>" + `
809                 :root {
810                         --GW-upvote-button-color: #00d800;
811                         --GW-downvote-button-color: #eb4c2a;
812                 }\n` + "</style>");
815 function processVoteData(voteData) {
816         window.voteData = voteData;
818         window.voteDesired = new Object;
819         for(key of voteData.keys()) {
820                 voteDesired[key] = new Object;
821         }
823         initializeVoteButtons();
824         
825         addTriggerListener("postLoaded", {priority: 3000, fn: () => {
826                 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
827                         let postID = karmaValue.parentNode.dataset.postId;
828                         addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
829                         karmaValue.parentElement.addClass("active-controls");
830                 });
831         }});
833         addTriggerListener("DOMReady", {priority: 3000, fn: () => {
834                 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
835                         let commentID = karmaValue.getCommentId();
836                         addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
837                         karmaValue.parentElement.addClass("active-controls");
838                 });
839         }});
842 /*****************************************/
843 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
844 /*****************************************/
846 Element.prototype.getCommentDate = function() {
847         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
848         let dateElement = item && item.query(".date");
849         return (dateElement && parseInt(dateElement.dataset["jsDate"]));
851 function getCurrentVisibleComment() {
852         let px = window.innerWidth/2, py = 5;
853         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
854         let bottomBar = query("#bottom-bar");
855         let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
856         let atbottom =  bottomOffset <= window.innerHeight;
857         if (atbottom) {
858                 let hashci = location.hash && query(location.hash);
859                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
860                         commentItem = hashci;
861                 }
862         }
863         return commentItem;
866 function highlightCommentsSince(date) {
867         GWLog("highlightCommentsSince");
868         var newCommentsCount = 0;
869         GW.newComments = [ ];
870         let oldCommentsStack = [ ];
871         let prevNewComment;
872         queryAll(".comment-item").forEach(commentItem => {
873                 commentItem.prevNewComment = prevNewComment;
874                 commentItem.nextNewComment = null;
875                 if (commentItem.getCommentDate() > date) {
876                         commentItem.addClass("new-comment");
877                         newCommentsCount++;
878                         GW.newComments.push(commentItem.getCommentId());
879                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
880                         oldCommentsStack = [ commentItem ];
881                         prevNewComment = commentItem;
882                 } else {
883                         commentItem.removeClass("new-comment");
884                         oldCommentsStack.push(commentItem);
885                 }
886         });
888         GW.newCommentScrollSet = (commentItem) => {
889                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
890                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
891         };
892         GW.newCommentScrollListener = () => {
893                 let commentItem = getCurrentVisibleComment();
894                 GW.newCommentScrollSet(commentItem);
895         }
897         addScrollListener(GW.newCommentScrollListener);
899         if (document.readyState=="complete") {
900                 GW.newCommentScrollListener();
901         } else {
902                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
903                 GW.newCommentScrollSet(commentItem);
904         }
906         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
908         return newCommentsCount;
911 function scrollToNewComment(next) {
912         GWLog("scrollToNewComment");
913         let commentItem = getCurrentVisibleComment();
914         let targetComment = null;
915         let targetCommentID = null;
916         if (commentItem) {
917                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
918                 if (targetComment) {
919                         targetCommentID = targetComment.getCommentId();
920                 }
921         } else {
922                 if (GW.newComments[0]) {
923                         targetCommentID = GW.newComments[0];
924                         targetComment = query("#comment-" + targetCommentID);
925                 }
926         }
927         if (targetComment) {
928                 expandAncestorsOf(targetCommentID);
929                 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
930                 targetComment.scrollIntoView();
931         }
933         GW.newCommentScrollListener();
936 function getPostHash() {
937         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
938         return (postHash ? postHash[1] : false);
940 function setHistoryLastVisitedDate(date) {
941         window.history.replaceState({ lastVisited: date }, null);
943 function getLastVisitedDate() {
944         // Get the last visited date (or, if posting a comment, the previous last visited date).
945         if(window.history.state) return (window.history.state||{})['lastVisited'];
946         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
947         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
948         let currentVisited = localStorage.getItem(storageName);
949         setHistoryLastVisitedDate(currentVisited);
950         return currentVisited;
952 function setLastVisitedDate(date) {
953         GWLog("setLastVisitedDate");
954         // If NOT posting a comment, save the previous value for the last-visited-date 
955         // (to recover it in case of posting a comment).
956         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
957         if (!aCommentHasJustBeenPosted) {
958                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
959                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
960         }
962         // Set the new value.
963         localStorage.setItem("last-visited-date_" + getPostHash(), date);
966 function updateSavedCommentCount() {
967         GWLog("updateSavedCommentCount");
968         let commentCount = queryAll(".comment").length;
969         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
971 function badgePostsWithNewComments() {
972         GWLog("badgePostsWithNewComments");
973         if (getQueryVariable("show") == "conversations") return;
975         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
976                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
978                 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
979                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
980                 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
982                 if (currentCommentCount > savedCommentCount)
983                         commentCountDisplay.addClass("new-comments");
984                 else
985                         commentCountDisplay.removeClass("new-comments");
986                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
987         });
990 /***********************************/
991 /* CONTENT COLUMN WIDTH ADJUSTMENT */
992 /***********************************/
994 function injectContentWidthSelector() {
995         GWLog("injectContentWidthSelector");
996         // Get saved width setting (or default).
997         let currentWidth = localStorage.getItem("selected-width") || 'normal';
999         // Inject the content width selector widget and activate buttons.
1000         let widthSelector = addUIElement(
1001                 "<div id='width-selector'>" +
1002                 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
1003                         let [name, desc, abbr] = widthOption;
1004                         let selected = (name == currentWidth ? ' selected' : '');
1005                         let disabled = (name == currentWidth ? ' disabled' : '');
1006                         return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
1007                 "</div>");
1008         widthSelector.queryAll("button").forEach(button => {
1009                 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
1010                         GWLog("GW.widthAdjustButtonClicked");
1012                         // Determine which setting was chosen (i.e., which button was clicked).
1013                         let selectedWidth = event.target.dataset.name;
1015                         // Save the new setting.
1016                         if (selectedWidth == "normal") localStorage.removeItem("selected-width");
1017                         else localStorage.setItem("selected-width", selectedWidth);
1019                         // Save current visible comment
1020                         let visibleComment = getCurrentVisibleComment();
1022                         // Actually change the content width.
1023                         setContentWidth(selectedWidth);
1024                         event.target.parentElement.childNodes.forEach(button => {
1025                                 button.removeClass("selected");
1026                                 button.disabled = false;
1027                         });
1028                         event.target.addClass("selected");
1029                         event.target.disabled = true;
1031                         // Make sure the accesskey (to cycle to the next width) is on the right button.
1032                         setWidthAdjustButtonsAccesskey();
1034                         // Regenerate images overlay.
1035                         generateImagesOverlay();
1037                         if(visibleComment) visibleComment.scrollIntoView();
1038                 });
1039         });
1041         // Make sure the accesskey (to cycle to the next width) is on the right button.
1042         setWidthAdjustButtonsAccesskey();
1044         // Inject transitions CSS, if animating changes is enabled.
1045         if (GW.adjustmentTransitions) {
1046                 insertHeadHTML(
1047                         "<style id='width-transition'>" + 
1048                         `#content,
1049                         #ui-elements-container,
1050                         #images-overlay {
1051                                 transition:
1052                                         max-width 0.3s ease;
1053                         }` + 
1054                         "</style>");
1055         }
1057 function setWidthAdjustButtonsAccesskey() {
1058         GWLog("setWidthAdjustButtonsAccesskey");
1059         let widthSelector = query("#width-selector");
1060         widthSelector.queryAll("button").forEach(button => {
1061                 button.removeAttribute("accesskey");
1062                 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1063         });
1064         let selectedButton = widthSelector.query("button.selected");
1065         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
1066         nextButtonInCycle.accessKey = "'";
1067         nextButtonInCycle.title += ` [\']`;
1070 /*******************/
1071 /* THEME SELECTION */
1072 /*******************/
1074 function injectThemeSelector() {
1075         GWLog("injectThemeSelector");
1076         let currentTheme = readCookie("theme") || "default";
1077         let themeSelector = addUIElement(
1078                 "<div id='theme-selector' class='theme-selector'>" +
1079                 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
1080                         let [name, desc, letter] = themeOption;
1081                         let selected = (name == currentTheme ? ' selected' : '');
1082                         let disabled = (name == currentTheme ? ' disabled' : '');
1083                         let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1084                         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>`;})) +
1085                 "</div>");
1086         themeSelector.queryAll("button").forEach(button => {
1087                 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
1088                         GWLog("GW.themeSelectButtonClicked");
1089                         let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
1090                         setSelectedTheme(themeName);
1091                         if (GW.isMobile) toggleAppearanceAdjustUI();
1092                 });
1093         });
1095         // Inject transitions CSS, if animating changes is enabled.
1096         if (GW.adjustmentTransitions) {
1097                 insertHeadHTML(
1098                         "<style id='theme-fade-transition'>" + 
1099                         `body {
1100                                 transition:
1101                                         opacity 0.5s ease-out,
1102                                         background-color 0.3s ease-out;
1103                         }
1104                         body.transparent {
1105                                 background-color: #777;
1106                                 opacity: 0.0;
1107                                 transition:
1108                                         opacity 0.5s ease-in,
1109                                         background-color 0.3s ease-in;
1110                         }` + 
1111                         "</style>");
1112         }
1114 function setSelectedTheme(themeName) {
1115         GWLog("setSelectedTheme");
1116         queryAll(".theme-selector button").forEach(button => {
1117                 button.removeClass("selected");
1118                 button.disabled = false;
1119         });
1120         queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
1121                 button.addClass("selected");
1122                 button.disabled = true;
1123         });
1124         setTheme(themeName);
1125         query("#theme-tweaker-ui .current-theme span").innerText = themeName;
1127 function setTheme(newThemeName) {
1128         var themeUnloadCallback = '';
1129         var oldThemeName = '';
1130         if (typeof(newThemeName) == 'undefined') {
1131                 newThemeName = readCookie('theme');
1132                 if (!newThemeName) return;
1133         } else {
1134                 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
1135                 oldThemeName = readCookie('theme') || 'default';
1137                 if (newThemeName == 'default') setCookie('theme', '');
1138                 else setCookie('theme', newThemeName);
1139         }
1140         if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
1142         let makeNewStyle = function(newThemeName, colorSchemePreference) {
1143                 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
1144                 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1146                 let newStyle = document.createElement('link');
1147                 newStyle.setAttribute('class', 'theme');
1148                 if(colorSchemePreference)
1149                         newStyle.setAttribute('media', '(prefers-color-scheme: ' + colorSchemePreference + ')');
1150                 newStyle.setAttribute('rel', 'stylesheet');
1151                 newStyle.setAttribute('href', '/css/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1152                 return newStyle;
1153         }
1155         let newMainStyle, newStyles;
1156         if(newThemeName === 'default') {
1157                 newStyles = [makeNewStyle('dark', 'dark'), makeNewStyle('default', 'light')];
1158                 newMainStyle = (window.matchMedia('prefers-color-scheme: dark').matches ? newStyles[0] : newStyles[1]);
1159         } else {
1160                 newStyles = [makeNewStyle(newThemeName)];
1161                 newMainStyle = newStyles[0];
1162         }
1164         let oldStyles = queryAll("head link.theme");
1165         newMainStyle.addEventListener('load', () => { oldStyles.forEach(x => removeElement(x)); });
1166         newMainStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
1168         if (GW.adjustmentTransitions) {
1169                 pageFadeTransition(false);
1170                 setTimeout(() => {
1171                         newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1172                 }, 500);
1173         } else {
1174                 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1175         }
1177 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
1178         document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1179         document.body.addClass("theme-" + newThemeName);
1181         recomputeUIElementsContainerHeight(true);
1183         let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
1184         if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
1186         recomputeUIElementsContainerHeight();
1187         adjustUIForWindowSize();
1188         window.addEventListener('resize', GW.windowResized = (event) => {
1189                 GWLog("GW.windowResized");
1190                 adjustUIForWindowSize();
1191                 recomputeUIElementsContainerHeight();
1192         });
1194         generateImagesOverlay();
1196         if (window.adjustmentTransitions) pageFadeTransition(true);
1197         updateThemeTweakerSampleText();
1199         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
1200                 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1201         }
1204 function pageFadeTransition(fadeIn) {
1205         if (fadeIn) {
1206                 query("body").removeClass("transparent");
1207         } else {
1208                 query("body").addClass("transparent");
1209         }
1212 GW.themeLoadCallback_less = (fromTheme = "") => {
1213         GWLog("themeLoadCallback_less");
1214         injectSiteNavUIToggle();
1215         if (!GW.isMobile) {
1216                 injectPostNavUIToggle();
1217                 injectAppearanceAdjustUIToggle();
1218         }
1220         registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1221                 let dtf = new Intl.DateTimeFormat([], 
1222                         (window.innerWidth < 1100) ? 
1223                                 { month: 'short', day: 'numeric', year: 'numeric' } : 
1224                                         { month: 'long', day: 'numeric', year: 'numeric' });
1225                 let postDate = query(".top-post-meta .date");
1226                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1227         });
1229         if (GW.isMobile) {
1230                 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1231         }
1233         if (!GW.isMobile) {
1234                 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1235                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1236                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1237                         });
1238                 });
1240                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1241                         // If state is not set (user has never clicked on the Less theme's appearance
1242                         // adjustment UI toggle) then show it, but then hide it after a short time.
1243                         registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1244                                 toggleAppearanceAdjustUI();
1245                                 setTimeout(toggleAppearanceAdjustUI, 3000);
1246                         });
1247                 }
1249                 if (fromTheme != "") {
1250                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1251                         setTimeout(function () {
1252                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1253                         }, 300);
1254                         setTimeout(function () {
1255                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1256                         }, 1800);
1257                 }
1259                 // Unset the height of the #ui-elements-container.
1260                 query("#ui-elements-container").style.height = "";
1262                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1263                 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1264                         "#content #secondary-bar",
1265                         "#content .post .top-post-meta .date",
1266                         "#content .post .top-post-meta .comment-count",
1267                 ];
1268                 applyFilters(GW.currentFilters);
1269         }
1271         // We pre-query the relevant elements, so we don't have to run querySelectorAll
1272         // on every firing of the scroll listener.
1273         GW.scrollState = {
1274                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1275                 "unbrokenDownScrollDistance":           0,
1276                 "unbrokenUpScrollDistance":                     0,
1277                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1278                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1279                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1280         };
1281         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1284 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
1285 // otherwise, show it.
1286 function updatePostNavUIVisibility() {
1287         GWLog("updatePostNavUIVisibility");
1288         var hidePostNavUIToggle = true;
1289         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1290                 if (getComputedStyle(element).visibility == "visible" ||
1291                         element.style.visibility == "visible" ||
1292                         element.style.visibility == "unset")
1293                         hidePostNavUIToggle = false;
1294         });
1295         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1296                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1297         });
1300 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1301 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
1302 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1303 function updateSiteNavUIState(event) {
1304         GWLog("updateSiteNavUIState");
1305         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1306         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
1307                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
1308                                                                                                                 0;
1309         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1310                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1311                                                                                                          0;
1312         GW.scrollState.lastScrollTop = newScrollTop;
1314         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1315         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1316                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1317                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1318         }
1320         // On mobile, make site nav UI translucent on ANY scroll down.
1321         if (GW.isMobile)
1322                 GW.scrollState.siteNavUIElements.forEach(element => {
1323                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1324                         else element.removeClass("translucent-on-scroll");
1325                 });
1327         // Show site nav UI when scrolling a full page up, or to the top.
1328         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
1329                  GW.scrollState.lastScrollTop == 0) &&
1330                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
1331                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1333         // On desktop, show appearance adjust UI when scrolling to the top.
1334         if ((!GW.isMobile) && 
1335                 (GW.scrollState.lastScrollTop == 0) &&
1336                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
1337                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1340 GW.themeUnloadCallback_less = (toTheme = "") => {
1341         GWLog("themeUnloadCallback_less");
1342         removeSiteNavUIToggle();
1343         if (!GW.isMobile) {
1344                 removePostNavUIToggle();
1345                 removeAppearanceAdjustUIToggle();
1346         }
1347         window.removeEventListener('resize', updatePostNavUIVisibility);
1349         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1351         removeElement("#theme-less-mobile-first-row-placeholder");
1353         if (!GW.isMobile) {
1354                 // Remove spans
1355                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1356                         element.innerHTML = element.firstChild.innerHTML;
1357                 });
1358         }
1360         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1362         // Reset filtered elements selector to default.
1363         delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1364         applyFilters(GW.currentFilters);
1367 GW.themeLoadCallback_dark = (fromTheme = "") => {
1368         GWLog("themeLoadCallback_dark");
1369         insertHeadHTML(
1370                 "<style id='dark-theme-adjustments'>" + 
1371                 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` + 
1372                 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1373                 "</style>");
1374         registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1375                 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1376                         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)";
1377                         image.style.width = parseInt(image.style.width) + 12 + "px";
1378                         image.style.height = parseInt(image.style.height) + 12 + "px";
1379                         image.style.top = parseInt(image.style.top) - 6 + "px";
1380                         image.style.left = parseInt(image.style.left) - 6 + "px";
1381                 });
1382         });
1384 GW.themeUnloadCallback_dark = (toTheme = "") => {
1385         GWLog("themeUnloadCallback_dark");
1386         removeElement("#dark-theme-adjustments");
1389 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1390         GWLog("themeLoadCallback_brutalist");
1391         let bottomBarLinks = queryAll("#bottom-bar a");
1392         if (!GW.isMobile && bottomBarLinks.length == 5) {
1393                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1394                 bottomBarLinks.forEach((link, i) => {
1395                         link.dataset.originalText = link.textContent;
1396                         link.textContent = newLinkTexts[i];
1397                 });
1398         }
1400 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1401         GWLog("themeUnloadCallback_brutalist");
1402         let bottomBarLinks = queryAll("#bottom-bar a");
1403         if (!GW.isMobile && bottomBarLinks.length == 5) {
1404                 bottomBarLinks.forEach(link => {
1405                         link.textContent = link.dataset.originalText;
1406                 });
1407         }
1410 GW.themeLoadCallback_classic = (fromTheme = "") => {
1411         GWLog("themeLoadCallback_classic");
1412         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1413                 button.innerHTML = "";
1414         });
1416 GW.themeUnloadCallback_classic = (toTheme = "") => {
1417         GWLog("themeUnloadCallback_classic");
1418         if (GW.isMobile && window.innerWidth <= 900) return;
1419         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1420                 button.innerHTML = button.dataset.label;
1421         });
1424 /********************************************/
1425 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1426 /********************************************/
1428 function injectThemeTweaker() {
1429         GWLog("injectThemeTweaker");
1430         let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" + 
1431         `<div class='main-theme-tweaker-window'>
1432                 <h1>Customize appearance</h1>
1433                 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1434                 <button type='button' class='help-button' tabindex='-1'></button>
1435                 <p class='current-theme'>Current theme: <span>` + 
1436                 (readCookie("theme") || "default") + 
1437                 `</span></p>
1438                 <p class='theme-selector'></p>
1439                 <div class='controls-container'>
1440                         <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1441                                 <div class='sample-text-container'><span class='sample-text'>
1442                                         <p>Less Wrong (text)</p>
1443                                         <p><a href="#">Less Wrong (link)</a></p>
1444                                 </span></div>
1445                         </div>
1446                         <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1447                                 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1448                                 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1449                                 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1450                         </div>
1451                         <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1452                                 <input type='checkbox' id='theme-tweak-control-invert'></input>
1453                                 <label for='theme-tweak-control-invert'>Invert colors</label>
1454                         </div>
1455                         <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1456                                 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1457                                 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1458                                 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1459                         </div>
1460                         <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1461                                 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1462                                 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1463                                 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1464                         </div>
1465                         <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1466                                 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1467                                 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1468                                 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1469                         </div>
1470                         <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1471                                 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1472                                 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1473                                 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1474                         </div>
1475                 </div>
1476                 <div class='buttons-container'>
1477                         <button type="button" class="reset-defaults-button">Reset to defaults</button>
1478                         <button type='button' class='ok-button default-button'>OK</button>
1479                         <button type='button' class='cancel-button'>Cancel</button>
1480                 </div>
1481         </div>
1482         <div class="clippy-container">
1483                 <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>)
1484                 <div class='clippy'></div>
1485                 <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>
1486         </div>
1487         <div class='help-window' style='display: none;'>
1488                 <h1>Theme tweaker help</h1>
1489                 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1490                         <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1491                         <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1492                 </div>
1493                 <div class='buttons-container'>
1494                         <button type='button' class='ok-button default-button'>OK</button>
1495                         <button type='button' class='cancel-button'>Cancel</button>
1496                 </div>
1497         </div>
1498         ` + "</div>");
1500         // Clicking the background overlay closes the theme tweaker.
1501         themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1502                 GWLog("GW.themeTweaker.UIOverlayClicked");
1503                 if (event.type == 'mousedown') {
1504                         themeTweakerUI.style.opacity = "0.01";
1505                 } else {
1506                         toggleThemeTweakerUI();
1507                         themeTweakerUI.style.opacity = "1.0";
1508                         themeTweakReset();
1509                 }
1510         }, true);
1512         // Intercept clicks, so they don't "fall through" the background overlay.
1513         (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1515         let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1516         themeTweakerUI.queryAll("input").forEach(field => {
1517                 // All input types in the theme tweaker receive a 'change' event when
1518                 // their value is changed. (Range inputs, in particular, receive this 
1519                 // event when the user lets go of the handle.) This means we should
1520                 // update the filters for the entire page, to match the new setting.
1521                 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1522                         GWLog("GW.themeTweaker.fieldValueChanged");
1523                         if (event.target.id == 'theme-tweak-control-invert') {
1524                                 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1525                         } else if (event.target.type == 'range') {
1526                                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1527                                 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1528                                 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1529                         } else if (event.target.id == 'theme-tweak-control-clippy') {
1530                                 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1531                         }
1532                         // Clear the sample text filters.
1533                         sampleTextContainer.style.filter = "";
1534                         // Apply the new filters globally.
1535                         applyFilters(GW.currentFilters);
1536                 });
1538                 // Range inputs receive an 'input' event while being scrubbed, updating
1539                 // "live" as the handle is moved. We don't want to change the filters 
1540                 // for the actual page while this is happening, but we do want to change
1541                 // the filters for the *sample text*, so the user can see what effects
1542                 // his changes are having, live, without having to let go of the handle.
1543                 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1544                         GWLog("GW.themeTweaker.fieldInputReceived");
1545                         var sampleTextFilters = GW.currentFilters;
1547                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1548                         query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1549                         sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1551                         sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1552                 });
1553         });
1555         themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1556                 GWLog("GW.themeTweaker.minimizeButtonClicked");
1557                 let themeTweakerStyle = query("#theme-tweaker-style");
1559                 if (event.target.hasClass("minimize")) {
1560                         event.target.removeClass("minimize");
1561                         themeTweakerStyle.innerHTML = 
1562                                 `#theme-tweaker-ui .main-theme-tweaker-window {
1563                                         width: 320px;
1564                                         height: 31px;
1565                                         overflow: hidden;
1566                                         padding: 30px 0 0 0;
1567                                         top: 20px;
1568                                         right: 20px;
1569                                         left: auto;
1570                                 }
1571                                 #theme-tweaker-ui::after {
1572                                         top: 27px;
1573                                         right: 27px;
1574                                 }
1575                                 #theme-tweaker-ui::before {
1576                                         opacity: 0.0;
1577                                         height: 0;
1578                                 }
1579                                 #theme-tweaker-ui .clippy-container {
1580                                         opacity: 1.0;
1581                                 }
1582                                 #theme-tweaker-ui .clippy-container .hint span {
1583                                         color: #c00;
1584                                 }
1585                                 #theme-tweaker-ui {
1586                                         height: 0;
1587                                 }
1588                                 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1589                                         pointer-events: none;
1590                                 }`;
1591                         event.target.addClass("maximize");
1592                 } else {
1593                         event.target.removeClass("maximize");
1594                         themeTweakerStyle.innerHTML = 
1595                                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1596                                         pointer-events: none;
1597                                 }`;
1598                         event.target.addClass("minimize");
1599                 }
1600         });
1601         themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1602                 GWLog("GW.themeTweaker.helpButtonClicked");
1603                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1604                 toggleThemeTweakerHelpWindow();
1605         });
1606         themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1607                 GWLog("GW.themeTweaker.resetDefaultsButtonClicked");
1608                 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1609                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1610                         let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1611                         slider.value = slider.dataset['defaultValue'];
1612                         themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1613                 });
1614                 GW.currentFilters = { };
1615                 applyFilters(GW.currentFilters);
1617                 GW.currentTextZoom = "1.0";
1618                 setTextZoom(GW.currentTextZoom);
1620                 setSelectedTheme("default");
1621         });
1622         themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1623                 GWLog("GW.themeTweaker.cancelButtonClicked");
1624                 toggleThemeTweakerUI();
1625                 themeTweakReset();
1626         });
1627         themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1628                 GWLog("GW.themeTweaker.OKButtonClicked");
1629                 toggleThemeTweakerUI();
1630                 themeTweakSave();
1631         });
1632         themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1633                 GWLog("GW.themeTweaker.helpWindowCancelButtonClicked");
1634                 toggleThemeTweakerHelpWindow();
1635                 themeTweakerResetSettings();
1636         });
1637         themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1638                 GWLog("GW.themeTweaker.helpWindowOKButtonClicked");
1639                 toggleThemeTweakerHelpWindow();
1640                 themeTweakerSaveSettings();
1641         });
1643         themeTweakerUI.queryAll(".notch").forEach(notch => {
1644                 notch.addActivateEvent(GW.themeTweaker.sliderNotchClicked = (event) => {
1645                         GWLog("GW.themeTweaker.sliderNotchClicked");
1646                         let slider = event.target.parentElement.query("input[type='range']");
1647                         slider.value = slider.dataset['defaultValue'];
1648                         event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1649                         GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1650                         applyFilters(GW.currentFilters);
1651                 });
1652         });
1654         themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1655                 GWLog("GW.themeTweaker.clippyCloseButtonClicked");
1656                 themeTweakerUI.query(".clippy-container").style.display = "none";
1657                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1658                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1659         });
1661         query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1663         themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1664         themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1665                 button.addActivateEvent(GW.themeSelectButtonClicked);
1666         });
1668         themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1669                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1670         });
1672         let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'>&#xf1de;</button></div>`);
1673         themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1674                 GWLog("GW.themeTweaker.toggleButtonClicked");
1675                 GW.themeTweakerStyleSheetAvailable = () => {
1676                         GWLog("GW.themeTweakerStyleSheetAvailable");
1677                         themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1679                         themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1680                         [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1681                                 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1682                                 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1683                                 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1684                         });
1686                         toggleThemeTweakerUI();
1687                         event.target.disabled = true;
1688                 };
1690                 if (query("link[href^='/css/theme_tweaker.css']")) {
1691                         // Theme tweaker CSS is already loaded.
1692                         GW.themeTweakerStyleSheetAvailable();
1693                 } else {
1694                         // Load the theme tweaker CSS (if not loaded).
1695                         let themeTweakerStyleSheet = document.createElement('link');
1696                         themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1697                         themeTweakerStyleSheet.setAttribute('href', '/css/theme_tweaker.css');
1698                         themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1699                         query("head").appendChild(themeTweakerStyleSheet);
1700                 }
1701         });
1703 function toggleThemeTweakerUI() {
1704         GWLog("toggleThemeTweakerUI");
1705         let themeTweakerUI = query("#theme-tweaker-ui");
1706         themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1707         query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" : 
1708                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1709                         pointer-events: none;
1710                 }`;
1711         if (themeTweakerUI.style.display != "none") {
1712                 // Save selected theme.
1713                 GW.currentTheme = (readCookie("theme") || "default");
1714                 // Focus invert checkbox.
1715                 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1716                 // Show sample text in appropriate font.
1717                 updateThemeTweakerSampleText();
1718                 // Disable tab-selection of the search box.
1719                 setSearchBoxTabSelectable(false);
1720                 // Disable scrolling of the page.
1721                 togglePageScrolling(false);
1722         } else {
1723                 query("#theme-tweaker-toggle button").disabled = false;
1724                 // Re-enable tab-selection of the search box.
1725                 setSearchBoxTabSelectable(true);
1726                 // Re-enable scrolling of the page.
1727                 togglePageScrolling(true);
1728         }
1729         // Set theme tweaker assistant visibility.
1730         query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1732 function setSearchBoxTabSelectable(selectable) {
1733         GWLog("setSearchBoxTabSelectable");
1734         query("input[type='search']").tabIndex = selectable ? "" : "-1";
1735         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1737 function toggleThemeTweakerHelpWindow() {
1738         GWLog("toggleThemeTweakerHelpWindow");
1739         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1740         themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1741         if (themeTweakerHelpWindow.style.display != "none") {
1742                 // Focus theme tweaker assistant checkbox.
1743                 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1744                 // Disable interaction on main theme tweaker window.
1745                 query("#theme-tweaker-ui").style.pointerEvents = "none";
1746                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1747         } else {
1748                 // Re-enable interaction on main theme tweaker window.
1749                 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1750                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1751         }
1753 function themeTweakReset() {
1754         GWLog("themeTweakReset");
1755         setSelectedTheme(GW.currentTheme);
1756         GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1757         applyFilters(GW.currentFilters);
1758         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1759         setTextZoom(GW.currentTextZoom);
1761 function themeTweakSave() {
1762         GWLog("themeTweakSave");
1763         GW.currentTheme = (readCookie("theme") || "default");
1764         localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1765         localStorage.setItem("text-zoom", GW.currentTextZoom);
1768 function themeTweakerResetSettings() {
1769         GWLog("themeTweakerResetSettings");
1770         query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1771         query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1773 function themeTweakerSaveSettings() {
1774         GWLog("themeTweakerSaveSettings");
1775         localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1777 function updateThemeTweakerSampleText() {
1778         GWLog("updateThemeTweakerSampleText");
1779         let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1781         // This causes the sample text to take on the properties of the body text of a post.
1782         sampleText.removeClass("body-text");
1783         let bodyTextElement = query(".post-body") || query(".comment-body");
1784         sampleText.addClass("body-text");
1785         sampleText.style.color = bodyTextElement ? 
1786                 getComputedStyle(bodyTextElement).color : 
1787                 getComputedStyle(query("#content")).color;
1789         // Here we find out what is the actual background color that will be visible behind
1790         // the body text of posts, and set the sample text’s background to that.
1791         let findStyleBackground = (selector) => {
1792                 let x;
1793                 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
1794                         if(rule.selectorText == selector)
1795                                 x = rule;
1796                 });
1797                 return x.style.backgroundColor;
1798         };
1800         sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
1803 /*********************/
1804 /* PAGE QUICK-NAV UI */
1805 /*********************/
1807 function injectQuickNavUI() {
1808         GWLog("injectQuickNavUI");
1809         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1810         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
1811         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
1812         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
1813         ` + "</div>");
1816 /**********************/
1817 /* NEW COMMENT NAV UI */
1818 /**********************/
1820 function injectNewCommentNavUI(newCommentsCount) {
1821         GWLog("injectNewCommentNavUI");
1822         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
1823         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
1824         <span class='new-comments-count'></span>
1825         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
1826         + "</div>");
1828         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1829                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1830                         GWLog("GW.commentQuicknavButtonClicked");
1831                         scrollToNewComment(/next/.test(event.target.className));
1832                         event.target.blur();
1833                 });
1834         });
1836         document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => { 
1837                 GWLog("GW.commentQuicknavKeyPressed");
1838                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1839                 if (event.key == ",") scrollToNewComment(false);
1840                 if (event.key == ".") scrollToNewComment(true)
1841         });
1843         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1844         + `<span>Since:</span>`
1845         + `<input type='text' class='hns-date'></input>`
1846         + "</div>");
1848         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1849                 GWLog("GW.hnsDatePickerValueChanged");
1850                 let hnsDate = time_fromHuman(event.target.value);
1851                 if(hnsDate) {
1852                         setHistoryLastVisitedDate(hnsDate);
1853                         let newCommentsCount = highlightCommentsSince(hnsDate);
1854                         updateNewCommentNavUI(newCommentsCount);
1855                 }
1856         }, false);
1858         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1859                 GWLog("GW.newCommentsCountClicked");
1860                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1861                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1862         });
1865 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1866 function time_fromHuman(string) {
1867         /* Convert a human-readable date into a JS timestamp */
1868         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1869                 string = string.replace(' ', 'T');  // revert nice spacing
1870                 string += ':00.000Z';  // complete ISO 8601 date
1871                 time = Date.parse(string);  // milliseconds since epoch
1873                 // browsers handle ISO 8601 without explicit timezone differently
1874                 // thus, we have to fix that by hand
1875                 time += (new Date()).getTimezoneOffset() * 60e3;
1876         } else {
1877                 string = string.replace(' at', '');
1878                 time = Date.parse(string);  // milliseconds since epoch
1879         }
1880         return time;
1883 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1884         GWLog("updateNewCommentNavUI");
1885         // Update the new comments count.
1886         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1887         newCommentsCountLabel.innerText = newCommentsCount;
1888         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1890         // Update the date picker field.
1891         if (hnsDate != -1) {
1892                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1893         }
1896 /***************************/
1897 /* TEXT SIZE ADJUSTMENT UI */
1898 /***************************/
1900 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1901         GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1902         var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1903         if (event.target.hasClass("decrease")) {
1904                 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1905         } else if (event.target.hasClass("increase")) {
1906                 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1907         } else {
1908                 zoomFactor = 1.0;
1909         }
1910         setTextZoom(zoomFactor);
1911         GW.currentTextZoom = `${zoomFactor}`;
1913         if (event.target.parentElement.id == "text-size-adjustment-ui") {
1914                 localStorage.setItem("text-zoom", GW.currentTextZoom);
1915         }
1918 function injectTextSizeAdjustmentUIReal() {
1919         GWLog("injectTextSizeAdjustmentUIReal");
1920         let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1921         + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
1922         + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1923         + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
1924         + "</div>");
1926         textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1927                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1928         });
1930         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1933 function injectTextSizeAdjustmentUI() {
1934         GWLog("injectTextSizeAdjustmentUI");
1935         if (query("#text-size-adjustment-ui") != null) return;
1936         if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1937         else document.addEventListener("DOMContentLoaded", () => {
1938                 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1939         }, {once: true});
1942 /********************************/
1943 /* COMMENTS VIEW MODE SELECTION */
1944 /********************************/
1946 function injectCommentsViewModeSelector() {
1947         GWLog("injectCommentsViewModeSelector");
1948         let commentsContainer = query("#comments");
1949         if (commentsContainer == null) return;
1951         let currentModeThreaded = (location.href.search("chrono=t") == -1);
1952         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1954         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1955         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
1956         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
1957         + "</div>");
1959 //      commentsViewModeSelector.queryAll("a").forEach(button => {
1960 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1961 //      });
1963         if (!currentModeThreaded) {
1964                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1965                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1966                         commentParentLink.addClass("inline-author");
1967                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1968                 });
1970                 queryAll(".comment-child-links a").forEach(commentChildLink => {
1971                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
1972                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
1973                 });
1975                 rectifyChronoModeCommentChildLinks();
1977                 commentsContainer.addClass("chrono");
1978         } else {
1979                 commentsContainer.addClass("threaded");
1980         }
1982         // Remove extraneous top-level comment thread in chrono mode.
1983         let topLevelCommentThread = query("#comments > .comment-thread");
1984         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
1987 // function commentsViewModeSelectorButtonClicked(event) {
1988 //      event.preventDefault();
1989 // 
1990 //      var newDocument;
1991 //      let request = new XMLHttpRequest();
1992 //      request.open("GET", event.target.href);
1993 //      request.onreadystatechange = () => {
1994 //              if (request.readyState != 4) return;
1995 //              newDocument = htmlToElement(request.response);
1996 // 
1997 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
1998 // 
1999 //              // Update the buttons.
2000 //              event.target.addClass("selected");
2001 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
2002 // 
2003 //              // Update the #comments container.
2004 //              let commentsContainer = query("#comments");
2005 //              commentsContainer.removeClass(classes.old);
2006 //              commentsContainer.addClass(classes.new);
2007 // 
2008 //              // Update the content.
2009 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2010 //      };
2011 //      request.send();
2012 // }
2013 // 
2014 // function htmlToElement(html) {
2015 //     var template = document.createElement('template');
2016 //     template.innerHTML = html.trim();
2017 //     return template.content;
2018 // }
2020 function rectifyChronoModeCommentChildLinks() {
2021         GWLog("rectifyChronoModeCommentChildLinks");
2022         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2023                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2024                 let childLinks = commentChildLinksContainer.queryAll("a");
2025                 childLinks.forEach((link, index) => {
2026                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2027                 });
2029                 // Sort by date.
2030                 let childLinksArray = Array.from(childLinks)
2031                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2032                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2033         });
2035 function childrenOfComment(commentID) {
2036         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2037                 let commentParentLink = commentItem.query("a.comment-parent-link");
2038                 return ((commentParentLink||{}).hash == "#" + commentID);
2039         });
2042 /********************************/
2043 /* COMMENTS LIST MODE SELECTION */
2044 /********************************/
2046 function injectCommentsListModeSelector() {
2047         GWLog("injectCommentsListModeSelector");
2048         if (query("#content > .comment-thread") == null) return;
2050         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2051         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2052         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2053         + "</div>";
2055         if (query(".sublevel-nav") || query("#top-nav-bar")) {
2056                 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2057         } else {
2058                 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2059         }
2060         let commentsListModeSelector = query("#comments-list-mode-selector");
2062         commentsListModeSelector.queryAll("button").forEach(button => {
2063                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2064                         GWLog("GW.commentsListModeSelectButtonClicked");
2065                         event.target.parentElement.queryAll("button").forEach(button => {
2066                                 button.removeClass("selected");
2067                                 button.disabled = false;
2068                                 button.accessKey = '`';
2069                         });
2070                         localStorage.setItem("comments-list-mode", event.target.className);
2071                         event.target.addClass("selected");
2072                         event.target.disabled = true;
2073                         event.target.removeAttribute("accesskey");
2075                         if (event.target.hasClass("expanded")) {
2076                                 query("#content").removeClass("compact");
2077                         } else {
2078                                 query("#content").addClass("compact");
2079                         }
2080                 });
2081         });
2083         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2084         if (savedMode == "compact")
2085                 query("#content").addClass("compact");
2086         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2087         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2088         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2090         if (GW.isMobile) {
2091                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2092                         commentParentLink.addActivateEvent(function (event) {
2093                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2094                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2095                         }, false);
2096                 });
2097         }
2100 /**********************/
2101 /* SITE NAV UI TOGGLE */
2102 /**********************/
2104 function injectSiteNavUIToggle() {
2105         GWLog("injectSiteNavUIToggle");
2106         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
2107         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2108                 GWLog("GW.siteNavUIToggleButtonClicked");
2109                 toggleSiteNavUI();
2110                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2111         });
2113         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2115 function removeSiteNavUIToggle() {
2116         GWLog("removeSiteNavUIToggle");
2117         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2118                 element.removeClass("engaged");
2119         });
2120         removeElement("#site-nav-ui-toggle");
2122 function toggleSiteNavUI() {
2123         GWLog("toggleSiteNavUI");
2124         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2125                 element.toggleClass("engaged");
2126                 element.removeClass("translucent-on-scroll");
2127         });
2130 /**********************/
2131 /* POST NAV UI TOGGLE */
2132 /**********************/
2134 function injectPostNavUIToggle() {
2135         GWLog("injectPostNavUIToggle");
2136         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
2137         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2138                 GWLog("GW.postNavUIToggleButtonClicked");
2139                 togglePostNavUI();
2140                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2141         });
2143         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2145 function removePostNavUIToggle() {
2146         GWLog("removePostNavUIToggle");
2147         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2148                 element.removeClass("engaged");
2149         });
2150         removeElement("#post-nav-ui-toggle");
2152 function togglePostNavUI() {
2153         GWLog("togglePostNavUI");
2154         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2155                 element.toggleClass("engaged");
2156         });
2159 /*******************************/
2160 /* APPEARANCE ADJUST UI TOGGLE */
2161 /*******************************/
2163 function injectAppearanceAdjustUIToggle() {
2164         GWLog("injectAppearanceAdjustUIToggle");
2165         let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'>&#xf013;</button></div>");
2166         appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
2167                 GWLog("GW.appearanceAdjustUIToggleButtonClicked");
2168                 toggleAppearanceAdjustUI();
2169                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
2170         });
2172         if (GW.isMobile) {
2173                 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
2174                 themeSelectorCloseButton.addClass("theme-selector-close-button");
2175                 themeSelectorCloseButton.innerHTML = "&#xf057;";
2176                 query("#theme-selector").appendChild(themeSelectorCloseButton);
2177                 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
2178         } else {
2179                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
2180         }
2182 function removeAppearanceAdjustUIToggle() {
2183         GWLog("removeAppearanceAdjustUIToggle");
2184         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2185                 element.removeClass("engaged");
2186         });
2187         removeElement("#appearance-adjust-ui-toggle");
2189 function toggleAppearanceAdjustUI() {
2190         GWLog("toggleAppearanceAdjustUI");
2191         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2192                 element.toggleClass("engaged");
2193         });
2196 /**************************/
2197 /* WORD COUNT & READ TIME */
2198 /**************************/
2200 function toggleReadTimeOrWordCount(addWordCountClass) {
2201         GWLog("toggleReadTimeOrWordCount");
2202         queryAll(".post-meta .read-time").forEach(element => {
2203                 if (addWordCountClass) element.addClass("word-count");
2204                 else element.removeClass("word-count");
2206                 let titleParts = /(\S+)(.+)$/.exec(element.title);
2207                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2208         });
2211 /**************************/
2212 /* PROMPT TO SAVE CHANGES */
2213 /**************************/
2215 function enableBeforeUnload() {
2216         window.onbeforeunload = function () { return true; };
2218 function disableBeforeUnload() {
2219         window.onbeforeunload = null;
2222 /***************************/
2223 /* ORIGINAL POSTER BADGING */
2224 /***************************/
2226 function markOriginalPosterComments() {
2227         GWLog("markOriginalPosterComments");
2228         let postAuthor = query(".post .author");
2229         if (postAuthor == null) return;
2231         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2232                 if (author.dataset.userid == postAuthor.dataset.userid ||
2233                         (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2234                         author.addClass("original-poster");
2235                         author.title += "Original poster";
2236                 }
2237         });
2240 /********************************/
2241 /* EDIT POST PAGE SUBMIT BUTTON */
2242 /********************************/
2244 function setEditPostPageSubmitButtonText() {
2245         GWLog("setEditPostPageSubmitButtonText");
2246         if (!query("#content").hasClass("edit-post-page")) return;
2248         queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2249                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2250                         GWLog("GW.postSectionSelectorValueChanged");
2251                         updateEditPostPageSubmitButtonText();
2252                 });
2253         });
2255         updateEditPostPageSubmitButtonText();
2257 function updateEditPostPageSubmitButtonText() {
2258         GWLog("updateEditPostPageSubmitButtonText");
2259         let submitButton = query("input[type='submit']");
2260         if (query("input#drafts").checked == true) 
2261                 submitButton.value = "Save Draft";
2262         else if (query(".posting-controls").hasClass("edit-existing-post"))
2263                 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2264         else
2265                 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2268 /*****************/
2269 /* ANTI-KIBITZER */
2270 /*****************/
2272 function numToAlpha(n) {
2273         let ret = "";
2274         do {
2275                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2276                 n = Math.floor((n / 26) - 1);
2277         } while (n >= 0);
2278         return ret;
2281 function injectAntiKibitzer() {
2282         GWLog("injectAntiKibitzer");
2283         // Inject anti-kibitzer toggle controls.
2284         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>");
2285         antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2286                 GWLog("GW.antiKibitzerToggleButtonClicked");
2287                 if (query("#anti-kibitzer-toggle").hasClass("engaged") && 
2288                         !event.shiftKey &&
2289                         !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!)")) {
2290                         event.target.blur();
2291                         return;
2292                 }
2294                 toggleAntiKibitzerMode();
2295                 event.target.blur();
2296         });
2298         // Activate anti-kibitzer mode (if needed).
2299         if (localStorage.getItem("antikibitzer") == "true")
2300                 toggleAntiKibitzerMode();
2302         // Remove temporary CSS that hides the authors and karma values.
2303         removeElement("#antikibitzer-temp");
2306 function toggleAntiKibitzerMode() {
2307         GWLog("toggleAntiKibitzerMode");
2308         // This will be the URL of the user's own page, if logged in, or the URL of
2309         // the login page otherwise.
2310         let userTabTarget = query("#nav-item-login .nav-inner").href;
2311         let pageHeadingElement = query("h1.page-main-heading");
2313         let userCount = 0;
2314         let userFakeName = { };
2316         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2318         let postAuthor = query(".post-page .post-meta .author");
2319         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2321         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2322         if (antiKibitzerToggle.hasClass("engaged")) {
2323                 localStorage.setItem("antikibitzer", "false");
2325                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2326                 if (redirectTarget) {
2327                         window.location = redirectTarget;
2328                         return;
2329                 }
2331                 // Individual comment page title and header
2332                 if (query(".individual-thread-page")) {
2333                         let replacer = (node) => {
2334                                 if (!node) return;
2335                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
2336                         }
2337                         replacer(query("title:not(.fake-title)"));
2338                         replacer(query("#content > h1"));
2339                 }
2341                 // Author names/links.
2342                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2343                         author.textContent = author.dataset["trueName"];
2344                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2346                         author.removeClass("redacted");
2347                 });
2348                 // Post/comment karma values.
2349                 queryAll(".karma-value.redacted").forEach(karmaValue => {
2350                         karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2352                         karmaValue.removeClass("redacted");
2353                 });
2354                 // Link post domains.
2355                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2356                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2358                         linkPostDomain.removeClass("redacted");
2359                 });
2361                 antiKibitzerToggle.removeClass("engaged");
2362         } else {
2363                 localStorage.setItem("antikibitzer", "true");
2365                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2366                 if (redirectTarget) {
2367                         window.location = redirectTarget;
2368                         return;
2369                 }
2371                 // Individual comment page title and header
2372                 if (query(".individual-thread-page")) {
2373                         let replacer = (node) => {
2374                                 if (!node) return;
2375                                 node.dataset["trueContent"] = node.firstChild.wholeText;
2376                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2377                                 node.firstChild.replaceWith(newText);
2378                         }
2379                         replacer(query("title:not(.fake-title)"));
2380                         replacer(query("#content > h1"));
2381                 }
2383                 removeElement("title.fake-title");
2385                 // Author names/links.
2386                 queryAll(".author, .inline-author").forEach(author => {
2387                         // Skip own posts/comments.
2388                         if (author.hasClass("own-user-author"))
2389                                 return;
2391                         let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2393                         if(!userid) return;
2395                         author.dataset["trueName"] = author.textContent;
2396                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2398                         if (/\/user/.test(author.href)) {
2399                                 author.dataset["trueLink"] = author.pathname;
2400                                 author.href = "/user?id=" + author.dataset["userid"];
2401                         }
2403                         author.addClass("redacted");
2404                 });
2405                 // Post/comment karma values.
2406                 queryAll(".karma-value").forEach(karmaValue => {
2407                         // Skip own posts/comments.
2408                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2409                                 return;
2411                         karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2412                         karmaValue.innerHTML = "##<span> points</span>";
2414                         karmaValue.addClass("redacted");
2415                 });
2416                 // Link post domains.
2417                 queryAll(".link-post-domain").forEach(linkPostDomain => {
2418                         // Skip own posts/comments.
2419                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2420                                 return;
2422                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2423                         linkPostDomain.textContent = "redacted.domain.tld";
2425                         linkPostDomain.addClass("redacted");
2426                 });
2428                 antiKibitzerToggle.addClass("engaged");
2429         }
2432 /*******************************/
2433 /* COMMENT SORT MODE SELECTION */
2434 /*******************************/
2436 var CommentSortMode = Object.freeze({
2437         TOP:            "top",
2438         NEW:            "new",
2439         OLD:            "old",
2440         HOT:            "hot"
2442 function sortComments(mode) {
2443         GWLog("sortComments");
2444         let commentsContainer = query("#comments");
2446         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2447         commentsContainer.addClass("sorting");
2449         GW.commentValues = { };
2450         let clonedCommentsContainer = commentsContainer.cloneNode(true);
2451         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2452                 var comparator;
2453                 switch (mode) {
2454                 case CommentSortMode.NEW:
2455                         comparator = (a,b) => commentDate(b) - commentDate(a);
2456                         break;
2457                 case CommentSortMode.OLD:
2458                         comparator = (a,b) => commentDate(a) - commentDate(b);
2459                         break;
2460                 case CommentSortMode.HOT:
2461                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2462                         break;
2463                 case CommentSortMode.TOP:
2464                 default:
2465                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2466                         break;
2467                 }
2468                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2469         });
2470         removeElement(commentsContainer.lastChild);
2471         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2472         GW.commentValues = { };
2474         if (loggedInUserId) {
2475                 // Re-activate vote buttons.
2476                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2477                         voteButton.addActivateEvent(voteButtonClicked);
2478                 });
2480                 // Re-activate comment action buttons.
2481                 commentsContainer.queryAll(".action-button").forEach(button => {
2482                         button.addActivateEvent(GW.commentActionButtonClicked);
2483                 });
2484         }
2486         // Re-activate comment-minimize buttons.
2487         queryAll(".comment-minimize-button").forEach(button => {
2488                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2489         });
2491         // Re-add comment parent popups.
2492         addCommentParentPopups();
2493         
2494         // Redo new-comments highlighting.
2495         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2497         requestAnimationFrame(() => {
2498                 commentsContainer.removeClass("sorting");
2499                 commentsContainer.addClass("sorted-" + mode);
2500         });
2502 function commentKarmaValue(commentOrSelector) {
2503         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2504         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2506 function commentDate(commentOrSelector) {
2507         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2508         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2510 function commentVoteCount(commentOrSelector) {
2511         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2512         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2515 function injectCommentsSortModeSelector() {
2516         GWLog("injectCommentsSortModeSelector");
2517         let topCommentThread = query("#comments > .comment-thread");
2518         if (topCommentThread == null) return;
2520         // Do not show sort mode selector if there is no branching in comment tree.
2521         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2523         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
2524                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
2525                 "</div>";
2526         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2527         let commentsSortModeSelector = query("#comments-sort-mode-selector");
2529         commentsSortModeSelector.queryAll("button").forEach(button => {
2530                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2531                         GWLog("GW.commentsSortModeSelectButtonClicked");
2532                         event.target.parentElement.queryAll("button").forEach(button => {
2533                                 button.removeClass("selected");
2534                                 button.disabled = false;
2535                         });
2536                         event.target.addClass("selected");
2537                         event.target.disabled = true;
2539                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2540                         setCommentsSortModeSelectButtonsAccesskey();
2541                 });
2542         });
2544         // TODO: Make this actually get the current sort mode (if that's saved).
2545         // TODO: Also change the condition here to properly get chrono/threaded mode,
2546         // when that is properly done with cookies.
2547         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2548         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2549         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2550         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2551         setCommentsSortModeSelectButtonsAccesskey();
2554 function setCommentsSortModeSelectButtonsAccesskey() {
2555         GWLog("setCommentsSortModeSelectButtonsAccesskey");
2556         queryAll("#comments-sort-mode-selector button").forEach(button => {
2557                 button.removeAttribute("accesskey");
2558                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2559         });
2560         let selectedButton = query("#comments-sort-mode-selector button.selected");
2561         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2562         nextButtonInCycle.accessKey = "z";
2563         nextButtonInCycle.title += " [z]";
2566 /*************************/
2567 /* COMMENT PARENT POPUPS */
2568 /*************************/
2570 function previewPopupsEnabled() {
2571         let isDisabled = localStorage.getItem("preview-popups-disabled");
2572         return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
2575 function setPreviewPopupsEnabled(state) {
2576         localStorage.setItem("preview-popups-disabled", !state);
2577         updatePreviewPopupToggle();
2580 function updatePreviewPopupToggle() {
2581         let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
2582         query("#preview-popup-toggle").setAttribute("style", style);
2585 function injectPreviewPopupToggle() {
2586         GWLog("injectPreviewPopupToggle");
2588         let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
2589         // This is required because Chrome can't use filters on an externally used SVG element.
2590         fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
2591         updatePreviewPopupToggle();
2592         toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
2595 var currentPreviewPopup = { };
2597 function removePreviewPopup(previewPopup) {
2598         if(previewPopup.element)
2599                 removeElement(previewPopup.element);
2601         if(previewPopup.timeout)
2602                 clearTimeout(previewPopup.timeout);
2604         if(currentPreviewPopup.pointerListener)
2605                 window.removeEventListener("pointermove", previewPopup.pointerListener);
2607         if(currentPreviewPopup.mouseoutListener)
2608                 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2610         if(currentPreviewPopup.scrollListener)
2611                 window.removeEventListener("scroll", previewPopup.scrollListener);
2613         currentPreviewPopup = { };
2616 function addCommentParentPopups() {
2617         GWLog("addCommentParentPopups");
2618         //if (!query("#content").hasClass("comment-thread-page")) return;
2620         queryAll("a[href]").forEach(linkTag => {
2621                 let linkHref = linkTag.getAttribute("href");
2623                 let url;
2624                 try { url = new URL(linkHref, window.location.href); }
2625                 catch(e) { }
2626                 if(!url) return;
2628                 if(GW.sites[url.host]) {
2629                         let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
2630                         
2631                         if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
2632                                 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
2633                                         if(event.pointerType == "touch") return;
2634                                         GWLog("GW.commentParentLinkMouseOver");
2635                                         removePreviewPopup(currentPreviewPopup);
2636                                         let parentID = linkHref;
2637                                         var parent, popup;
2638                                         if (!(parent = (query(parentID)||{}).firstChild)) return;
2639                                         var highlightClassName;
2640                                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2641                                                 parentHighlightClassName = "comment-item-highlight-faint";
2642                                                 popup = parent.cloneNode(true);
2643                                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2644                                                 linkTag.addEventListener("mouseout", (event) => {
2645                                                         removeElement(popup);
2646                                                 }, {once: true});
2647                                                 linkTag.closest(".comments > .comment-thread").appendChild(popup);
2648                                         } else {
2649                                                 parentHighlightClassName = "comment-item-highlight";
2650                                         }
2651                                         parent.parentNode.addClass(parentHighlightClassName);
2652                                         linkTag.addEventListener("mouseout", (event) => {
2653                                                 parent.parentNode.removeClass(parentHighlightClassName);
2654                                         }, {once: true});
2655                                 });
2656                         }
2657                         else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
2658                                 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
2659                                 && !(url.searchParams.get('format'))
2660                                 && !linkTag.closest("nav:not(.post-nav-links)")
2661                                 && (!url.hash || linkCommentId)
2662                                 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
2663                                 linkTag.addEventListener("pointerover", event => {
2664                                         if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
2665                                         if(currentPreviewPopup.linkTag) return;
2666                                         linkTag.createPreviewPopup();
2667                                 });
2668                                 linkTag.createPreviewPopup = function() {
2669                                         removePreviewPopup(currentPreviewPopup);
2671                                         currentPreviewPopup = {linkTag: linkTag};
2672                                         
2673                                         let popup = document.createElement("iframe");
2674                                         currentPreviewPopup.element = popup;
2676                                         let popupTarget = linkHref;
2677                                         if(popupTarget.match(/#comment-/)) {
2678                                                 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
2679                                         }
2680                                         // 'theme' attribute is required for proper caching
2681                                         popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
2682                                         popup.addClass("preview-popup");
2683                                         
2684                                         let linkRect = linkTag.getBoundingClientRect();
2686                                         if(linkRect.right + 710 < window.innerWidth)
2687                                                 popup.style.left = linkRect.right + 10 + "px";
2688                                         else
2689                                                 popup.style.right = "10px";
2691                                         popup.style.width = "700px";
2692                                         popup.style.height = "500px";
2693                                         popup.style.visibility = "hidden";
2694                                         popup.style.transition = "none";
2696                                         let recenter = function() {
2697                                                 let popupHeight = 500;
2698                                                 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
2699                                                         let popupContent = popup.contentDocument.querySelector("#content");
2700                                                         if(popupContent) {
2701                                                                 popupHeight = popupContent.clientHeight + 2;
2702                                                                 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
2703                                                                 popup.style.height = popupHeight + "px";
2704                                                         }
2705                                                 }
2706                                                 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
2707                                         }
2709                                         recenter();
2711                                         query('#content').insertAdjacentElement("beforeend", popup);
2713                                         let clickListener = event => {
2714                                                 if(!event.target.closest("a, input, label")
2715                                                    && !event.target.closest("popup-hide-button")) {
2716                                                         window.location = linkHref;
2717                                                 }
2718                                         };
2720                                         popup.addEventListener("load", () => {
2721                                                 let hideButton = popup.contentDocument.createElement("div");
2722                                                 hideButton.className = "popup-hide-button";
2723                                                 hideButton.insertAdjacentText('beforeend', "\uF070");
2724                                                 hideButton.onclick = (event) => {
2725                                                         removePreviewPopup(currentPreviewPopup);
2726                                                         setPreviewPopupsEnabled(false);
2727                                                         event.stopPropagation();
2728                                                 }
2729                                                 popup.contentDocument.body.appendChild(hideButton);
2730                                                 
2731                                                 let body = popup.contentDocument.body;
2732                                                 body.addEventListener("click", clickListener);
2733                                                 body.style.cursor = "pointer";
2735                                                 recenter();
2736                                         });
2738                                         popup.contentDocument.body.addEventListener("click", clickListener);
2739                                         
2740                                         currentPreviewPopup.timeout = setTimeout(() => {
2741                                                 recenter();
2743                                                 requestIdleCallback(() => {
2744                                                         if(currentPreviewPopup.element === popup) {
2745                                                                 popup.scrolling = "";
2746                                                                 popup.style.visibility = "unset";
2747                                                                 popup.style.transition = null;
2749                                                                 popup.animate([
2750                                                                         { opacity: 0, transform: "translateY(10%)" },
2751                                                                         { opacity: 1, transform: "none" }
2752                                                                 ], { duration: 150, easing: "ease-out" });
2753                                                         }
2754                                                 });
2755                                         }, 1000);
2757                                         let pointerX, pointerY, mousePauseTimeout = null;
2759                                         currentPreviewPopup.pointerListener = (event) => {
2760                                                 pointerX = event.clientX;
2761                                                 pointerY = event.clientY;
2763                                                 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
2764                                                 mousePauseTimeout = null;
2766                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
2767                                                 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
2769                                                 if(mouseIsOverLink || overElement === popup
2770                                                    || (pointerX < popup.getBoundingClientRect().left
2771                                                        && event.movementX >= 0)) {
2772                                                         if(!mouseIsOverLink && overElement !== popup) {
2773                                                                 if(overElement['createPreviewPopup']) {
2774                                                                         mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
2775                                                                 } else {
2776                                                                         mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
2777                                                                 }
2778                                                         }
2779                                                 } else {
2780                                                         removePreviewPopup(currentPreviewPopup);
2781                                                         if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
2782                                                 }
2783                                         };
2784                                         window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
2786                                         currentPreviewPopup.mouseoutListener = (event) => {
2787                                                 clearTimeout(mousePauseTimeout);
2788                                                 mousePauseTimeout = null;
2789                                         }
2790                                         document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2792                                         currentPreviewPopup.scrollListener = (event) => {
2793                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
2794                                                 linkRect = linkTag.getBoundingClientRect();
2795                                                 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
2796                                                 removePreviewPopup(currentPreviewPopup);
2797                                         };
2798                                         window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
2799                                 };
2800                         }
2801                 }
2802         });
2803         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2804                 
2805         });
2807         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2808         GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2809                 "#content .comments .comment-thread"
2810         ];
2811         applyFilters(GW.currentFilters);
2814 /***************/
2815 /* IMAGE FOCUS */
2816 /***************/
2818 function imageFocusSetup(imagesOverlayOnly = false) {
2819         if (typeof GW.imageFocus == "undefined")
2820                 GW.imageFocus = {
2821                         contentImagesSelector:  "#content img",
2822                         overlayImagesSelector:  "#images-overlay img",
2823                         focusedImageSelector:   "#content img.focused, #images-overlay img.focused",
2824                         pageContentSelector:    "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
2825                         shrinkRatio:                    0.975,
2826                         hideUITimerDuration:    1500,
2827                         hideUITimerExpired:             () => {
2828                                 GWLog("GW.imageFocus.hideUITimerExpired");
2829                                 let currentTime = new Date();
2830                                 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
2831                                 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
2832                                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
2833                                 } else {
2834                                         hideImageFocusUI();
2835                                         cancelImageFocusHideUITimer();
2836                                 }
2837                         }
2838                 };
2840         GWLog("imageFocusSetup");
2841         // Create event listener for clicking on images to focus them.
2842         GW.imageClickedToFocus = (event) => {
2843                 GWLog("GW.imageClickedToFocus");
2844                 focusImage(event.target);
2846                 if (!GW.isMobile) {
2847                         // Set timer to hide the image focus UI.
2848                         unhideImageFocusUI();
2849                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
2850                 }
2851         };
2852         // Add the listener to each image in the overlay (i.e., those in the post).
2853         queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
2854                 image.addActivateEvent(GW.imageClickedToFocus);
2855         });
2856         // Accesskey-L starts the slideshow.
2857         (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
2858         // Count how many images there are in the post, and set the "… of X" label to that.
2859         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
2860         if (imagesOverlayOnly) return;
2861         // Add the listener to all other content images (including those in comments).
2862         queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
2863                 image.addActivateEvent(GW.imageClickedToFocus);
2864         });
2866         // Create the image focus overlay.
2867         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
2868         `<div class='help-overlay'>
2869                  <p><strong>Arrow keys:</strong> Next/previous image</p>
2870                  <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2871                  <p><strong>Space bar:</strong> Reset image size & position</p>
2872                  <p><strong>Scroll</strong> to zoom in/out</p>
2873                  <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2874         </div>
2875         <div class='image-number'></div>
2876         <div class='slideshow-buttons'>
2877                  <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
2878                  <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
2879         </div>
2880         <div class='caption'></div>` + 
2881         "</div>");
2882         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2884         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2885                 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
2886                         GWLog("GW.imageFocus.slideshowButtonClicked");
2887                         focusNextImage(event.target.hasClass("next"));
2888                         event.target.blur();
2889                 });
2890         });
2892         // On orientation change, reset the size & position.
2893         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2894                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2895         }
2897         // UI starts out hidden.
2898         hideImageFocusUI();
2901 function focusImage(imageToFocus) {
2902         GWLog("focusImage");
2903         // Clear 'last-focused' class of last focused image.
2904         let lastFocusedImage = query("img.last-focused");
2905         if (lastFocusedImage) {
2906                 lastFocusedImage.removeClass("last-focused");
2907                 lastFocusedImage.removeAttribute("accesskey");
2908         }
2910         // Create the focused version of the image.
2911         imageToFocus.addClass("focused");
2912         let imageFocusOverlay = query("#image-focus-overlay");
2913         let clonedImage = imageToFocus.cloneNode(true);
2914         clonedImage.style = "";
2915         clonedImage.removeAttribute("width");
2916         clonedImage.removeAttribute("height");
2917         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2918         imageFocusOverlay.appendChild(clonedImage);
2919         imageFocusOverlay.addClass("engaged");
2921         // Set image to default size and position.
2922         resetFocusedImagePosition();
2924         // Blur everything else.
2925         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
2926                 element.addClass("blurred");
2927         });
2929         // Add listener to zoom image with scroll wheel.
2930         window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
2931                 GWLog("GW.imageFocus.scrollEvent");
2932                 event.preventDefault();
2934                 let image = query("#image-focus-overlay img");
2936                 // Remove the filter.
2937                 image.savedFilter = image.style.filter;
2938                 image.style.filter = 'none';
2940                 // Locate point under cursor.
2941                 let imageBoundingBox = image.getBoundingClientRect();
2943                 // Calculate resize factor.
2944                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2945                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2946                                                 1;
2948                 // Resize.
2949                 image.style.width = (event.deltaY < 0 ?
2950                                                         (image.clientWidth * factor) :
2951                                                         (image.clientWidth / factor))
2952                                                         + "px";
2953                 image.style.height = "";
2955                 // Designate zoom origin.
2956                 var zoomOrigin;
2957                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2958                 // the cursor is over the image.
2959                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2960                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2961                                                                 (imageBoundingBox.left <= event.clientX &&
2962                                                                  event.clientX <= imageBoundingBox.right && 
2963                                                                  imageBoundingBox.top <= event.clientY &&
2964                                                                  event.clientY <= imageBoundingBox.bottom);
2965                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
2966                 // zooming IN, zoom from image center.
2967                 let zoomingFromWindowCenter = event.deltaY > 0;
2968                 if (zoomingFromCursor)
2969                         zoomOrigin = { x: event.clientX, 
2970                                                    y: event.clientY };
2971                 else if (zoomingFromWindowCenter)
2972                         zoomOrigin = { x: window.innerWidth / 2, 
2973                                                    y: window.innerHeight / 2 };
2974                 else
2975                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
2976                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
2978                 // Calculate offset from zoom origin.
2979                 let offsetOfImageFromZoomOrigin = {
2980                         x: imageBoundingBox.x - zoomOrigin.x,
2981                         y: imageBoundingBox.y - zoomOrigin.y
2982                 }
2983                 // Calculate delta from centered zoom.
2984                 let deltaFromCenteredZoom = {
2985                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
2986                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
2987                 }
2988                 // Adjust image position appropriately.
2989                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
2990                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
2991                 // Gradually re-center image, if it's smaller than the window.
2992                 if (!imageSizeExceedsWindowBounds) {
2993                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
2994                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
2995                         let windowCenter = { x: window.innerWidth / 2,
2996                                                                  y: window.innerHeight / 2 }
2997                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
2998                                                                                   y: windowCenter.y - imageCenter.y }
2999                         // Divide the offset by 10 because we're nudging the image toward center,
3000                         // not jumping it there.
3001                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3002                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3003                 }
3005                 // Put the filter back.
3006                 image.style.filter = image.savedFilter;
3008                 // Set the cursor appropriately.
3009                 setFocusedImageCursor();
3010         });
3011         window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3012                 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3013                 event.preventDefault();
3014         });
3016         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3017         window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3018                 GWLog("GW.imageFocus.mouseUp");
3019                 window.onmousemove = '';
3021                 // We only want to do anything on left-clicks.
3022                 if (event.button != 0) return;
3024                 // Don't unfocus if click was on a slideshow next/prev button!
3025                 if (event.target.hasClass("slideshow-button")) return;
3027                 // We also don't want to do anything if clicked on the help overlay.
3028                 if (event.target.classList.contains("help-overlay") ||
3029                         event.target.closest(".help-overlay"))
3030                         return;
3032                 let focusedImage = query("#image-focus-overlay img");
3033                 if (event.target == focusedImage && 
3034                         (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3035                         // If the mouseup event was the end of a pan of an overside image,
3036                         // put the filter back; do not unfocus.
3037                         focusedImage.style.filter = focusedImage.savedFilter;
3038                 } else {
3039                         unfocusImageOverlay();
3040                         return;
3041                 }
3042         });
3043         window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3044                 GWLog("GW.imageFocus.mouseDown");
3045                 event.preventDefault();
3047                 let focusedImage = query("#image-focus-overlay img");
3048                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3049                         let mouseCoordX = event.clientX;
3050                         let mouseCoordY = event.clientY;
3052                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3053                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3055                         // Save the filter.
3056                         focusedImage.savedFilter = focusedImage.style.filter;
3058                         window.onmousemove = (event) => {
3059                                 // Remove the filter.
3060                                 focusedImage.style.filter = "none";
3061                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3062                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3063                         };
3064                         return false;
3065                 }
3066         });
3068         // Double-click on the image unfocuses.
3069         clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3070                 GWLog("GW.imageFocus.doubleClick");
3071                 if (event.target.hasClass("slideshow-button")) return;
3073                 unfocusImageOverlay();
3074         });
3076         // Escape key unfocuses, spacebar resets.
3077         document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3078                 GWLog("GW.imageFocus.keyUp");
3079                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3080                 if (!allowedKeys.contains(event.key) || 
3081                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3083                 event.preventDefault();
3085                 switch (event.key) {
3086                 case "Escape": 
3087                 case "Esc":
3088                         unfocusImageOverlay();
3089                         break;
3090                 case " ":
3091                 case "Spacebar":
3092                         resetFocusedImagePosition();
3093                         break;
3094                 case "ArrowDown":
3095                 case "Down":
3096                 case "ArrowRight":
3097                 case "Right":
3098                         if (query("#images-overlay img.focused")) focusNextImage(true);
3099                         break;
3100                 case "ArrowUp":
3101                 case "Up":
3102                 case "ArrowLeft":
3103                 case "Left":
3104                         if (query("#images-overlay img.focused")) focusNextImage(false);
3105                         break;
3106                 }
3107         });
3109         // Prevent spacebar or arrow keys from scrolling page when image focused.
3110         togglePageScrolling(false);
3112         // If the image comes from the images overlay, for the main post...
3113         if (imageToFocus.closest("#images-overlay")) {
3114                 // Mark the overlay as being in slide show mode (to show buttons/count).
3115                 imageFocusOverlay.addClass("slideshow");
3117                 // Set state of next/previous buttons.
3118                 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3119                 var indexOfFocusedImage = getIndexOfFocusedImage();
3120                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3121                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3123                 // Set the image number.
3124                 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3126                 // Replace the hash.
3127                 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3128         } else {
3129                 imageFocusOverlay.removeClass("slideshow");
3130         }
3132         // Set the caption.
3133         setImageFocusCaption();
3135         // Moving mouse unhides image focus UI.
3136         window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3137                 GWLog("GW.imageFocus.mouseMoved");
3138                 let currentDateTime = new Date();
3139                 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3140                         cancelImageFocusHideUITimer();
3141                 } else {
3142                         if (!GW.imageFocus.hideUITimer) {
3143                                 unhideImageFocusUI();
3144                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3145                         }
3146                         GW.imageFocus.mouseLastMovedAt = currentDateTime;
3147                 }
3148         });
3151 function resetFocusedImagePosition() {
3152         GWLog("resetFocusedImagePosition");
3153         let focusedImage = query("#image-focus-overlay img");
3154         if (!focusedImage) return;
3156         let sourceImage = query(GW.imageFocus.focusedImageSelector);
3158         // Make sure that initially, the image fits into the viewport.
3159         let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3160         let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3161         var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3162         let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3163         let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3164         focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3165         focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3167         // Remove modifications to position.
3168         focusedImage.style.left = "";
3169         focusedImage.style.top = "";
3171         // Set the cursor appropriately.
3172         setFocusedImageCursor();
3174 function setFocusedImageCursor() {
3175         let focusedImage = query("#image-focus-overlay img");
3176         if (!focusedImage) return;
3177         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
3178                                                                 'move' : '';
3181 function unfocusImageOverlay() {
3182         GWLog("unfocusImageOverlay");
3184         // Remove event listeners.
3185         window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3186         window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3187         // NOTE: The double-click listener does not need to be removed manually,
3188         // because the focused (cloned) image will be removed anyway.
3189         document.removeEventListener("keyup", GW.imageFocus.keyUp);
3190         document.removeEventListener("keydown", GW.imageFocus.keyDown);
3191         window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3192         window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3193         window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3195         // Set accesskey of currently focused image (if it's in the images overlay).
3196         let currentlyFocusedImage = query("#images-overlay img.focused");
3197         if (currentlyFocusedImage) {
3198                 currentlyFocusedImage.addClass("last-focused");
3199                 currentlyFocusedImage.accessKey = 'l';
3200         }
3202         // Remove focused image and hide overlay.
3203         let imageFocusOverlay = query("#image-focus-overlay");
3204         imageFocusOverlay.removeClass("engaged");
3205         removeElement(imageFocusOverlay.query("img"));
3207         // Un-blur content/etc.
3208         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3209                 element.removeClass("blurred");
3210         });
3212         // Unset "focused" class of focused image.
3213         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3215         // Re-enable page scrolling.
3216         togglePageScrolling(true);
3218         // Reset the hash, if needed.
3219         if (location.hash.hasPrefix("#if_slide_"))
3220                 history.replaceState(window.history.state, null, "#");
3223 function getIndexOfFocusedImage() {
3224         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3225         var indexOfFocusedImage = -1;
3226         for (i = 0; i < images.length; i++) {
3227                 if (images[i].hasClass("focused")) {
3228                         indexOfFocusedImage = i;
3229                         break;
3230                 }
3231         }
3232         return indexOfFocusedImage;
3235 function focusNextImage(next = true) {
3236         GWLog("focusNextImage");
3237         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3238         var indexOfFocusedImage = getIndexOfFocusedImage();
3240         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3242         // Remove existing image.
3243         removeElement("#image-focus-overlay img");
3244         // Unset "focused" class of just-removed image.
3245         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3247         // Create the focused version of the image.
3248         images[indexOfFocusedImage].addClass("focused");
3249         let imageFocusOverlay = query("#image-focus-overlay");
3250         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3251         clonedImage.style = "";
3252         clonedImage.removeAttribute("width");
3253         clonedImage.removeAttribute("height");
3254         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3255         imageFocusOverlay.appendChild(clonedImage);
3256         imageFocusOverlay.addClass("engaged");
3257         // Set image to default size and position.
3258         resetFocusedImagePosition();
3259         // Set state of next/previous buttons.
3260         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3261         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3262         // Set the image number display.
3263         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3264         // Set the caption.
3265         setImageFocusCaption();
3266         // Replace the hash.
3267         history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3270 function setImageFocusCaption() {
3271         GWLog("setImageFocusCaption");
3272         var T = { }; // Temporary storage.
3274         // Clear existing caption, if any.
3275         let captionContainer = query("#image-focus-overlay .caption");
3276         Array.from(captionContainer.children).forEach(child => { child.remove(); });
3278         // Determine caption.
3279         let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3280         var captionHTML;
3281         if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) && 
3282                 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3283                 captionHTML = (T.figcaption.query("p")) ? 
3284                                           T.figcaption.innerHTML : 
3285                                           "<p>" + T.figcaption.innerHTML + "</p>"; 
3286         } else if (currentlyFocusedImage.title != "") {
3287                 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3288         }
3289         // Insert the caption, if any.
3290         if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3293 function hideImageFocusUI() {
3294         GWLog("hideImageFocusUI");
3295         let imageFocusOverlay = query("#image-focus-overlay");
3296         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3297                 element.addClass("hidden");
3298         });
3301 function unhideImageFocusUI() {
3302         GWLog("unhideImageFocusUI");
3303         let imageFocusOverlay = query("#image-focus-overlay");
3304         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3305                 element.removeClass("hidden");
3306         });
3309 function cancelImageFocusHideUITimer() {
3310         clearTimeout(GW.imageFocus.hideUITimer);
3311         GW.imageFocus.hideUITimer = null;
3314 /*****************/
3315 /* KEYBOARD HELP */
3316 /*****************/
3318 function keyboardHelpSetup() {
3319         let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3320                 <div class='keyboard-help-container'>
3321                         <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'>&#xf00d;</button>
3322                         <h1>Keyboard shortcuts</h1>
3323                         <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>
3324                         <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3325                         <div class='keyboard-shortcuts-lists'>` + [ [
3326                                 "General",
3327                                 [ [ '?' ], "Show keyboard shortcuts" ],
3328                                 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3329                         ], [
3330                                 "Site navigation",
3331                                 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3332                                 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3333                                 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3334                                 [ [ 'ak-m' ], "Go to Meta view" ],
3335                                 [ [ 'ak-v' ], "Go to Tags view"],
3336                                 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3337                                 [ [ 'ak-r' ], "Go to Archive view" ],
3338                                 [ [ 'ak-q' ], "Go to Sequences view" ],
3339                                 [ [ 'ak-t' ], "Go to About page" ],
3340                                 [ [ 'ak-u' ], "Go to User or Login page" ],
3341                                 [ [ 'ak-o' ], "Go to Inbox page" ]
3342                         ], [
3343                                 "Page navigation",
3344                                 [ [ 'ak-,' ], "Jump up to top of page" ],
3345                                 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3346                                 [ [ 'ak-/' ], "Jump to top of comments section" ],
3347                                 [ [ 'ak-s' ], "Search" ],
3348                         ], [
3349                                 "Page actions",
3350                                 [ [ 'ak-n' ], "New post or comment" ],
3351                                 [ [ 'ak-e' ], "Edit current post" ]
3352                         ], [
3353                                 "Post/comment list views",
3354                                 [ [ '.' ], "Focus next entry in list" ],
3355                                 [ [ ',' ], "Focus previous entry in list" ],
3356                                 [ [ ';' ], "Cycle between links in focused entry" ],
3357                                 [ [ 'Enter' ], "Go to currently focused entry" ],
3358                                 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3359                                 [ [ 'ak-]' ], "Go to next page" ],
3360                                 [ [ 'ak-[' ], "Go to previous page" ],
3361                                 [ [ 'ak-\\' ], "Go to first page" ],
3362                                 [ [ 'ak-e' ], "Edit currently focused post" ]
3363                         ], [
3364                                 "Editor",
3365                                 [ [ 'ak-k' ], "Bold text" ],
3366                                 [ [ 'ak-i' ], "Italic text" ],
3367                                 [ [ 'ak-l' ], "Insert hyperlink" ],
3368                                 [ [ 'ak-q' ], "Blockquote text" ]
3369                         ], [                            
3370                                 "Appearance",
3371                                 [ [ 'ak-=' ], "Increase text size" ],
3372                                 [ [ 'ak--' ], "Decrease text size" ],
3373                                 [ [ 'ak-0' ], "Reset to default text size" ],
3374                                 [ [ 'ak-′' ], "Cycle through content width settings" ],
3375                                 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3376                                 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3377                                 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3378                                 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3379                                 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3380                                 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3381                                 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3382                                 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3383                                 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3384                                 [ [ 'ak-;' ], "Open theme tweaker" ],
3385                                 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3386                                 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3387                         ], [
3388                                 "Slide shows",
3389                                 [ [ 'ak-l' ], "Start/resume slideshow" ],
3390                                 [ [ 'Esc' ], "Exit slideshow" ],
3391                                 [ [ '&#x2192;', '&#x2193;' ], "Next slide" ],
3392                                 [ [ '&#x2190;', '&#x2191;' ], "Previous slide" ],
3393                                 [ [ 'Space' ], "Reset slide zoom" ]
3394                         ], [
3395                                 "Miscellaneous",
3396                                 [ [ 'ak-x' ], "Switch to next view on user page" ],
3397                                 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3398                                 [ [ 'ak-`&nbsp;' ], "Toggle compact comment list view" ],
3399                                 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3400                         ] ].map(section => 
3401                         `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3402                                 `<li>
3403                                         <span class='keys'>` + 
3404                                         entry[0].map(key =>
3405                                                 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3406                                         ).join("") + 
3407                                         `</span>
3408                                         <span class='action'>${entry[1]}</span>
3409                                 </li>`
3410                         ).join("\n") + `</ul>`).join("\n") + `
3411                         </ul></div>             
3412                 </div>
3413         ` + "</nav>");
3415         // Add listener to show the keyboard help overlay.
3416         document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3417                 GWLog("GW.keyboardHelpShowKeyPressed");
3418                 if (event.key == '?')
3419                         toggleKeyboardHelpOverlay(true);
3420         });
3422         // Clicking the background overlay closes the keyboard help overlay.
3423         keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3424                 GWLog("GW.keyboardHelpOverlayClicked");
3425                 if (event.type == 'mousedown') {
3426                         keyboardHelpOverlay.style.opacity = "0.01";
3427                 } else {
3428                         toggleKeyboardHelpOverlay(false);
3429                         keyboardHelpOverlay.style.opacity = "1.0";
3430                 }
3431         }, true);
3433         // Intercept clicks, so they don't "fall through" the background overlay.
3434         (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3436         // Clicking the close button closes the keyboard help overlay.
3437         keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3438                 toggleKeyboardHelpOverlay(false);
3439         });
3441         // Add button to open keyboard help.
3442         query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'>&#xf11c;</button>");
3443         query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3444                 GWLog("GW.openKeyboardHelpButtonClicked");
3445                 toggleKeyboardHelpOverlay(true);
3446                 event.target.blur();
3447         });
3450 function toggleKeyboardHelpOverlay(show) {
3451         console.log("toggleKeyboardHelpOverlay");
3453         let keyboardHelpOverlay = query("#keyboard-help-overlay");
3454         show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3455         keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3457         // Prevent scrolling the document when the overlay is visible.
3458         togglePageScrolling(!show);
3460         // Focus the close button as soon as we open.
3461         keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3463         if (show) {
3464                 // Add listener to show the keyboard help overlay.
3465                 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3466                         GWLog("GW.keyboardHelpHideKeyPressed");
3467                         if (event.key == 'Escape')
3468                                 toggleKeyboardHelpOverlay(false);
3469                 });
3470         } else {
3471                 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3472         }
3474         // Disable / enable tab-selection of the search box.
3475         setSearchBoxTabSelectable(!show);
3478 /**********************/
3479 /* PUSH NOTIFICATIONS */
3480 /**********************/
3482 function pushNotificationsSetup() {
3483         let pushNotificationsButton = query("#enable-push-notifications");
3484         if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3485                 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3486                 pushNotificationsButton.style.display = 'unset';
3487         }
3490 function urlBase64ToUint8Array(base64String) {
3491         const padding = '='.repeat((4 - base64String.length % 4) % 4);
3492         const base64 = (base64String + padding)
3493               .replace(/-/g, '+')
3494               .replace(/_/g, '/');
3495         
3496         const rawData = window.atob(base64);
3497         const outputArray = new Uint8Array(rawData.length);
3498         
3499         for (let i = 0; i < rawData.length; ++i) {
3500                 outputArray[i] = rawData.charCodeAt(i);
3501         }
3502         return outputArray;
3505 function pushNotificationsButtonClicked(event) {
3506         event.target.style.opacity = 0.33;
3507         event.target.style.pointerEvents = "none";
3509         let reEnable = (message) => {
3510                 if(message) alert(message);
3511                 event.target.style.opacity = 1;
3512                 event.target.style.pointerEvents = "unset";
3513         }
3515         if(event.target.dataset.enabled) {
3516                 fetch('/push/register', {
3517                         method: 'post',
3518                         headers: { 'Content-type': 'application/json' },
3519                         body: JSON.stringify({
3520                                 cancel: true
3521                         }),
3522                 }).then(() => {
3523                         event.target.innerHTML = "Enable push notifications";
3524                         event.target.dataset.enabled = "";
3525                         reEnable();
3526                 }).catch((err) => reEnable(err.message));
3527         } else {
3528                 Notification.requestPermission().then((permission) => {
3529                         navigator.serviceWorker.ready
3530                                 .then((registration) => {
3531                                         return registration.pushManager.getSubscription()
3532                                                 .then(async function(subscription) {
3533                                                         if (subscription) {
3534                                                                 return subscription;
3535                                                         }
3536                                                         return registration.pushManager.subscribe({
3537                                                                 userVisibleOnly: true,
3538                                                                 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
3539                                                         });
3540                                                 })
3541                                                 .catch((err) => reEnable(err.message));
3542                                 })
3543                                 .then((subscription) => {
3544                                         fetch('/push/register', {
3545                                                 method: 'post',
3546                                                 headers: {
3547                                                         'Content-type': 'application/json'
3548                                                 },
3549                                                 body: JSON.stringify({
3550                                                         subscription: subscription
3551                                                 }),
3552                                         });
3553                                 })
3554                                 .then(() => {
3555                                         event.target.innerHTML = "Disable push notifications";
3556                                         event.target.dataset.enabled = "true";
3557                                         reEnable();
3558                                 })
3559                                 .catch(function(err){ reEnable(err.message) });
3560                         
3561                 });
3562         }
3565 /*******************************/
3566 /* HTML TO MARKDOWN CONVERSION */
3567 /*******************************/
3569 function MarkdownFromHTML(text) {
3570         GWLog("MarkdownFromHTML");
3571         // Wrapper tags, paragraphs, bold, italic, code blocks.
3572         text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
3573                 switch(tag) {
3574                 case "html":
3575                 case "/html":
3576                 case "head":
3577                 case "/head":
3578                 case "body":
3579                 case "/body":
3580                 case "p":
3581                         return "";
3582                 case "/p":
3583                         return "\n";
3584                 case "strong":
3585                 case "/strong":
3586                         return "**";
3587                 case "em":
3588                 case "/em":
3589                         return "*";
3590                 default:
3591                         return match;
3592                 }
3593         });
3595         // <div> and <span>.
3596         text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
3597                 return `${text}\n`;
3598         }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
3599                 return `${text}\n`;
3600         });
3602         // Unordered lists.
3603         text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
3604                 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
3605                         return `* ${listItem}\n`;
3606                 });
3607         });
3609         // Ordered lists.
3610         text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
3611                 var countedItemValue = 0;
3612                 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
3613                         var itemValue;
3614                         if (typeof specifiedItemValue != "undefined") {
3615                                 specifiedItemValue = parseInt(specifiedItemValue);
3616                                 countedItemValue = itemValue = specifiedItemValue;
3617                         } else {
3618                                 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
3619                         }
3620                         return `${itemValue}. ${listItem.trim()}\n`;
3621                 });
3622         });
3624         // Headings.
3625         text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
3626                 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
3627         });
3629         // Blockquotes.
3630         text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
3631                 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
3632         });
3634         // Links.
3635         text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
3636                 return `[${text}](${href})`;
3637         }).trim();
3639         // Images.
3640         text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
3641                 return `![](${src})`;
3642         });
3644         // Horizontal rules.
3645         text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
3646                 return "\n---\n";
3647         });
3649         // Line breaks.
3650         text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
3651                 return "\\\n";
3652         });
3654         // Preformatted text (possibly with a code block inside).
3655         text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
3656                 return "```\n" + text + "\n```";
3657         });
3659         // Code blocks.
3660         text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
3661                 return "`" + text + "`";
3662         });
3664         // HTML entities.
3665         text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
3666                 switch(entity) {
3667                 case "gt":
3668                         return ">";
3669                 case "lt":
3670                         return "<";
3671                 case "amp":
3672                         return "&";
3673                 case "apos":
3674                         return "'";
3675                 case "quot":
3676                         return "\"";
3677                 default:
3678                         return match;
3679                 }
3680         });
3682         return text;
3685 /************************************/
3686 /* ANCHOR LINK SCROLLING WORKAROUND */
3687 /************************************/
3689 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
3690         let hash = location.hash;
3691         if(hash && hash !== "#top" && !document.query(hash)) {
3692                 let content = document.query("#content");
3693                 content.style.display = "none";
3694                 addTriggerListener("DOMReady", {priority: -1, fn: () => {
3695                         content.style.visibility = "hidden";
3696                         content.style.display = null;
3697                         requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
3698                 }});
3699         }
3700 }});
3702 /******************/
3703 /* INITIALIZATION */
3704 /******************/
3706 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
3707         GWLog("INITIALIZER earlyInitialize");
3708         // Check to see whether we're on a mobile device (which we define as a narrow screen)
3709         GW.isMobile = (window.innerWidth <= 1160);
3710         GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3712         // Backward compatibility
3713         let storedTheme = localStorage.getItem('selected-theme');
3714         if (storedTheme) {
3715                 setTheme(storedTheme);
3716                 localStorage.removeItem('selected-theme');
3717         }
3719         // Animate width & theme adjustments?
3720         GW.adjustmentTransitions = false;
3722         // Add the content width selector.
3723         injectContentWidthSelector();
3724         // Add the text size adjustment widget.
3725         injectTextSizeAdjustmentUI();
3726         // Add the theme selector.
3727         injectThemeSelector();
3728         // Add the theme tweaker.
3729         injectThemeTweaker();
3730         // Add the quick-nav UI.
3731         injectQuickNavUI();
3733         // Finish initializing when ready.
3734         addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
3735 }});
3737 function mainInitializer() {
3738         GWLog("INITIALIZER initialize");
3740         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
3741         // "link without nav bars".
3742         if (getQueryVariable("hide-nav-bars") == "true") {
3743                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
3744         }
3746         // If the page cannot have comments, remove the accesskey from the #comments
3747         // quick-nav button; and if the page can have comments, but does not, simply 
3748         // disable the #comments quick nav button.
3749         let content = query("#content");
3750         if (content.query("#comments") == null) {
3751                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
3752         } else if (content.query("#comments .comment-thread") == null) {
3753                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
3754         }
3756         // On edit post pages and conversation pages, add GUIEdit buttons to the 
3757         // textarea, expand it, and markdownify the existing text, if any (this is
3758         // needed if a post was last edited on LW).
3759         queryAll(".with-markdown-editor textarea").forEach(textarea => {
3760                 textarea.addTextareaFeatures();
3761                 expandTextarea(textarea);
3762                 textarea.value = MarkdownFromHTML(textarea.value);
3763         });
3764         // Focus the textarea.
3765         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3767         // Clean up ToC
3768         queryAll(".contents-list li a").forEach(tocLink => {
3769                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
3770                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
3771                 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, '');
3772                 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
3773         });
3775         // If we're on a comment thread page...
3776         if (query(".comments") != null) {
3777                 // Add comment-minimize buttons to every comment.
3778                 queryAll(".comment-meta").forEach(commentMeta => {
3779                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3780                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
3781                 });
3782                 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
3783                         // Format and activate comment-minimize buttons.
3784                         queryAll(".comment-minimize-button").forEach(button => {
3785                                 button.closest(".comment-item").setCommentThreadMaximized(false);
3786                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3787                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
3788                                 });
3789                         });
3790                 }
3791         }
3792         if (getQueryVariable("chrono") == "t") {
3793                 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
3794         }
3796         // On mobile, replace the labels for the checkboxes on the edit post form
3797         // with icons, to save space.
3798         if (GW.isMobile && query(".edit-post-page")) {
3799                 query("label[for='link-post']").innerHTML = "&#xf0c1";
3800                 query("label[for='question']").innerHTML = "&#xf128";
3801         }
3803         // Add error message (as placeholder) if user tries to click Search with
3804         // an empty search field.
3805         searchForm: {
3806                 let searchForm = query("#nav-item-search form");
3807                 if(!searchForm) break searchForm;
3808                 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3809                         let searchField = event.target.query("input");
3810                         if (searchField.value == "") {
3811                                 event.preventDefault();
3812                                 event.target.blur();
3813                                 searchField.placeholder = "Enter a search string!";
3814                                 searchField.focus();
3815                         }
3816                 });
3817                 // Remove the placeholder / error on any input.
3818                 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3819                         event.target.placeholder = "";
3820                 });
3821         }
3823         // Prevent conflict between various single-hotkey listeners and text fields
3824         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3825                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3826                 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
3827         });
3829         if (content.hasClass("post-page")) {
3830                 // Read and update last-visited-date.
3831                 let lastVisitedDate = getLastVisitedDate();
3832                 setLastVisitedDate(Date.now());
3834                 // Save the number of comments this post has when it's visited.
3835                 updateSavedCommentCount();
3837                 if (content.query(".comments .comment-thread") != null) {
3838                         // Add the new comments count & navigator.
3839                         injectNewCommentNavUI();
3841                         // Get the highlight-new-since date (as specified by URL parameter, if 
3842                         // present, or otherwise the date of the last visit).
3843                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3845                         // Highlight new comments since the specified date.                      
3846                         let newCommentsCount = highlightCommentsSince(hnsDate);
3848                         // Update the comment count display.
3849                         updateNewCommentNavUI(newCommentsCount, hnsDate);
3850                 }
3851         } else {
3852                 // On listing pages, make comment counts more informative.
3853                 badgePostsWithNewComments();
3854         }
3856         // Add the comments list mode selector widget (expanded vs. compact).
3857         injectCommentsListModeSelector();
3859         // Add the comments view selector widget (threaded vs. chrono).
3860 //      injectCommentsViewModeSelector();
3862         // Add the comments sort mode selector (top, hot, new, old).
3863         if (GW.useFancyFeatures) injectCommentsSortModeSelector();
3865         // Add the toggle for the post nav UI elements on mobile.
3866         if (GW.isMobile) injectPostNavUIToggle();
3868         // Add the toggle for the appearance adjustment UI elements on mobile.
3869         if (GW.isMobile) injectAppearanceAdjustUIToggle();
3871         // Add the antikibitzer.
3872         if (GW.useFancyFeatures) injectAntiKibitzer();
3874         // Add comment parent popups.
3875         injectPreviewPopupToggle();
3876         addCommentParentPopups();
3878         // Mark original poster's comments with a special class.
3879         markOriginalPosterComments();
3880         
3881         // On the All view, mark posts with non-positive karma with a special class.
3882         if (query("#content").hasClass("all-index-page")) {
3883                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3884                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3886                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3887                 });
3888         }
3890         // Set the "submit" button on the edit post page to something more helpful.
3891         setEditPostPageSubmitButtonText();
3893         // Compute the text of the pagination UI tooltip text.
3894         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3895                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3896         });
3898         // Add event listeners for Escape and Enter, for the theme tweaker.
3899         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3900         let themeTweakerUI = query("#theme-tweaker-ui");
3901         document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3902                 if (event.key == "Escape") {
3903                         if (themeTweakerHelpWindow.style.display != "none") {
3904                                 toggleThemeTweakerHelpWindow();
3905                                 themeTweakerResetSettings();
3906                         } else if (themeTweakerUI.style.display != "none") {
3907                                 toggleThemeTweakerUI();
3908                                 themeTweakReset();
3909                         }
3910                 } else if (event.key == "Enter") {
3911                         if (themeTweakerHelpWindow.style.display != "none") {
3912                                 toggleThemeTweakerHelpWindow();
3913                                 themeTweakerSaveSettings();
3914                         } else if (themeTweakerUI.style.display != "none") {
3915                                 toggleThemeTweakerUI();
3916                                 themeTweakSave();
3917                         }
3918                 }
3919         });
3921         // Add event listener for . , ; (for navigating listings pages).
3922         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3923         if (!query(".comments") && listings.length > 0) {
3924                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
3925                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
3927                         if (event.key == "Escape") {
3928                                 if (document.activeElement.parentElement.hasClass("listing"))
3929                                         document.activeElement.blur();
3930                                 return;
3931                         }
3933                         if (event.key == ';') {
3934                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3935                                         let links = document.activeElement.parentElement.queryAll("a");
3936                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3937                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3938                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3939                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3940                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3941                                 }
3942                                 return;
3943                         }
3945                         var indexOfActiveListing = -1;
3946                         for (i = 0; i < listings.length; i++) {
3947                                 if (document.activeElement.parentElement.hasClass("listing") && 
3948                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3949                                         indexOfActiveListing = i;
3950                                         break;
3951                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
3952                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
3953                                         indexOfActiveListing = i;
3954                                         break;
3955                                 }
3956                         }
3957                         // Remove edit accesskey from currently highlighted post by active user, if applicable.
3958                         if (indexOfActiveListing > -1) {
3959                                 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
3960                         }
3961                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3962                         if (indexOfNextListing < listings.length) {
3963                                 listings[indexOfNextListing].focus();
3965                                 if (listings[indexOfNextListing].closest(".comment-item")) {
3966                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3967                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3968                                 }
3969                         } else {
3970                                 document.activeElement.blur();
3971                         }
3972                         // Add edit accesskey to newly highlighted post by active user, if applicable.
3973                         (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
3974                 });
3975                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
3976                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
3977                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
3978                         });
3979                 });
3980         }
3981         // Add event listener for ; (to focus the link on link posts).
3982         if (query("#content").hasClass("post-page") && 
3983                 query(".post").hasClass("link-post")) {
3984                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
3985                         if (event.key == ';') query("a.link-post-link").focus();
3986                 });
3987         }
3989         // Add accesskeys to user page view selector.
3990         let viewSelector = query("#content.user-page > .sublevel-nav");
3991         if (viewSelector) {
3992                 let currentView = viewSelector.query("span");
3993                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
3994                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
3995         }
3997         // Add accesskey to index page sort selector.
3998         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4000         // Move MathJax style tags to <head>.
4001         var aggregatedStyles = "";
4002         queryAll("#content style").forEach(styleTag => {
4003                 aggregatedStyles += styleTag.innerHTML;
4004                 removeElement("style", styleTag.parentElement);
4005         });
4006         if (aggregatedStyles != "") {
4007                 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4008         }
4010         // Add listeners to switch between word count and read time.
4011         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4012         queryAll(".post-meta .read-time").forEach(element => {
4013                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4014                         let displayWordCount = localStorage.getItem("display-word-count");
4015                         toggleReadTimeOrWordCount(!displayWordCount);
4016                         if (displayWordCount) localStorage.removeItem("display-word-count");
4017                         else localStorage.setItem("display-word-count", true);
4018                 });
4019         });
4021         // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4022         query("#content").addEventListener("copy", GW.textCopied = (event) => {
4023                 if(event.target.matches("input, textarea")) return;
4024                 event.preventDefault();
4025                 const selectedHTML = getSelectionHTML();
4026                 const selectedText = getSelection().toString();
4027                 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4028                 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4029         });
4031         // Set up Image Focus feature.
4032         imageFocusSetup();
4034         // Set up keyboard shortcuts guide overlay.
4035         keyboardHelpSetup();
4037         // Show push notifications button if supported
4038         pushNotificationsSetup();
4040         // Show elements now that javascript is ready.
4041         removeElement("#hide-until-init");
4043         activateTrigger("pageLayoutFinished");
4046 /*************************/
4047 /* POST-LOAD ADJUSTMENTS */
4048 /*************************/
4050 window.addEventListener("pageshow", badgePostsWithNewComments);
4052 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4053         GWLog("INITIALIZER pageLayoutFinished");
4055         postSetThemeHousekeeping();
4057         focusImageSpecifiedByURL();
4059         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4060 //      query("input[type='search']").value = GW.isMobile;
4061 //      insertHeadHTML("<style>" +
4062 //              `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` + 
4063 //              `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` + 
4064 //              "</style>");
4065 }});
4067 function generateImagesOverlay() {
4068         GWLog("generateImagesOverlay");
4069         // Don't do this on the about page.
4070         if (query(".about-page") != null) return;
4071         return;
4073         // Remove existing, if any.
4074         removeElement("#images-overlay");
4076         // Create new.
4077         query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4078         let imagesOverlay = query("#images-overlay");
4079         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4080         queryAll(".post-body img").forEach(image => {
4081                 let clonedImageContainer = document.createElement("div");
4083                 let clonedImage = image.cloneNode(true);
4084                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4085                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4086                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4087                 clonedImageContainer.appendChild(clonedImage);
4089                 let zoomLevel = parseFloat(GW.currentTextZoom);
4091                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4092                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4093                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4094                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4096                 imagesOverlay.appendChild(clonedImageContainer);
4097         });
4099         // Add the event listeners to focus each image.
4100         imageFocusSetup(true);
4103 function adjustUIForWindowSize() {
4104         GWLog("adjustUIForWindowSize");
4105         var bottomBarOffset;
4107         // Adjust bottom bar state.
4108         let bottomBar = query("#bottom-bar");
4109         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4110         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4111                 bottomBar.removeClass("decorative");
4113                 bottomBar.query("#nav-item-top").style.display = "";
4114         } else if (bottomBar) {
4115                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4116                 else bottomBar.addClass("decorative");
4118                 bottomBar.query("#nav-item-top").style.display = "none";
4119         }
4121         // Show quick-nav UI up/down buttons if content is taller than window.
4122         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4123         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4124                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4125         });
4127         // Move anti-kibitzer toggle if content is very short.
4128         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4130         // Update the visibility of the post nav UI.
4131         updatePostNavUIVisibility();
4134 function recomputeUIElementsContainerHeight(force = false) {
4135         GWLog("recomputeUIElementsContainerHeight");
4136         if (!GW.isMobile &&
4137                 (force || query("#ui-elements-container").style.height != "")) {
4138                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4139                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
4140                                                                                                                 query("#content").clientHeight + "px" :
4141                                                                                                                 "100vh";
4142         }
4145 function focusImageSpecifiedByURL() {
4146         GWLog("focusImageSpecifiedByURL");
4147         if (location.hash.hasPrefix("#if_slide_")) {
4148                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4149                         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4150                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4151                         if (imageToFocus > 0 && imageToFocus <= images.length) {
4152                                 focusImage(images[imageToFocus - 1]);
4154                                 // Set timer to hide the image focus UI.
4155                                 unhideImageFocusUI();
4156                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4157                         }
4158                 });
4159         }
4162 /***********/
4163 /* GUIEDIT */
4164 /***********/
4166 function insertMarkup(event) {
4167         var mopen = '', mclose = '', mtext = '', func = false;
4168         if (typeof arguments[1] == 'function') {
4169                 func = arguments[1];
4170         } else {
4171                 mopen = arguments[1];
4172                 mclose = arguments[2];
4173                 mtext = arguments[3];
4174         }
4176         var textarea = event.target.closest("form").query("textarea");
4177         textarea.focus();
4178         var p0 = textarea.selectionStart;
4179         var p1 = textarea.selectionEnd;
4180         var cur0 = cur1 = p0;
4182         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4183         str = func ? func(str, p0) : (mopen + str + mclose);
4185         // Determine selection.
4186         if (!func) {
4187                 cur0 += (p0 == p1) ? mopen.length : str.length;
4188                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4189         } else {
4190                 cur0 = str[1];
4191                 cur1 = str[2];
4192                 str = str[0];
4193         }
4195         // Update textarea contents.
4196         // The document.execCommand API is broken in Firefox 
4197         // ( https://bugzilla.mozilla.org/show_bug.cgi?id=1220696 ), but using it
4198         // allows native undo/redo to work; so we enable it in other browsers.
4199         if (GW.isFirefox) {
4200                 textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
4201         } else {
4202                 document.execCommand("insertText", false, str);
4203         }
4204         // Expand textarea, if needed.
4205         expandTextarea(textarea);
4207         // Set selection.
4208         textarea.selectionStart = cur0;
4209         textarea.selectionEnd = cur1;
4211         return;
4214 GW.guiEditButtons = [
4215         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
4216         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
4217         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
4218         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
4219         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
4220         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
4221         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
4222         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
4223         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
4224         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
4225         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
4226         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
4227         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
4228         [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
4229         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
4232 function blockquote(text, startpos) {
4233         if (text == '') {
4234                 text = "> Quoted text";
4235                 return [ text, startpos + 2, startpos + text.length ];
4236         } else {
4237                 text = "> " + text.split("\n").join("\n> ") + "\n";
4238                 return [ text, startpos + text.length, startpos + text.length ];
4239         }
4242 function hyperlink(text, startpos) {
4243         var url = '', link_text = text, endpos = startpos;
4244         if (text.search(/^https?/) != -1) {
4245                 url = text;
4246                 link_text = "link text";
4247                 startpos = startpos + 1;
4248                 endpos = startpos + link_text.length;
4249         } else {
4250                 url = prompt("Link address (URL):");
4251                 if (!url) {
4252                         endpos = startpos + text.length;
4253                         return [ text, startpos, endpos ];
4254                 }
4255                 startpos = startpos + text.length + url.length + 4;
4256                 endpos = startpos;
4257         }
4259         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4262 if(navigator.serviceWorker) {
4263         navigator.serviceWorker.register('/service-worker.js');
4264         setCookie("push", "t");