Interaction improvement/bug-fix with user mention list
[lw2-viewer.git] / www / script.js
blob2b98f2f2f28fbef9baf2047181e4d0cd6faa2e46
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") console.log("doAjax error: " + JSON.parse(event.target.responseText)["error"]);
125         else console.log("doAjax 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                 // User mentions autocomplete
301                 if(textarea.value.charAt(textarea.selectionStart - 1) === "@") {
302                         beginAutocompletion(textarea, textarea.selectionStart);
303                 }
304         }, false);
305         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
306         textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
307         textarea.addEventListener("keydown", (event) => {
308                 // Special case for alt+4
309                 // Generalize this before adding more.
310                 if(event.altKey && event.key === '4') {
311                         insertMarkup(event, "$", "$", "LaTeX formula");
312                         event.stopPropagation();
313                         event.preventDefault();
314                 }
315         });
317         let form = textarea.closest("form");
318         if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
320         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
321         let textareaContainer = textarea.closest(".textarea-container");
322         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
323         for (var button of GW.guiEditButtons) {
324                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
325                 buttons_container.insertAdjacentHTML("beforeend", 
326                         "<button type='button' class='guiedit guiedit-" 
327                         + name
328                         + "' tabindex='-1'"
329                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
330                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
331                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
332                         + " onclick='insertMarkup(event,"
333                         + ((typeof m_before_or_func == 'function') ?
334                                 m_before_or_func.name : 
335                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
336                         + ");'><div>"
337                         + icon
338                         + "</div></button>"
339                 );
340         }
342         var markdown_hints = 
343         `<input type='checkbox' id='markdown-hints-checkbox'>
344         <label for='markdown-hints-checkbox'></label>
345         <div id='markdown-hints'>` + 
346         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
347                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
348                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
349                 "<span>Heading 1</span><code># Heading 1</code>",
350                 "<span>Heading 2</span><code>## Heading 1</code>",
351                 "<span>Heading 3</span><code>### Heading 1</code>",
352                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
353         `</div>`;
354         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
356         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
357                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
358                         GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
359                         if (button.hasClass("guiedit-mobile-help-button")) {
360                                 toggleMarkdownHintsBox();
361                                 event.target.toggleClass("active");
362                                 query(".posting-controls:focus-within textarea").focus();
363                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
364                                 event.target.blur();
365                                 hideMarkdownHintsBox();
366                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
367                         }
368                 });
369         });
371         // On smartphone (narrow mobile) screens, when a textarea is focused (and
372         // automatically fullscreened), remove all the filters from the page, and 
373         // then apply them *just* to the fixed editor UI elements. This is in order
374         // to get around the "children of elements with a filter applied cannot be
375         // fixed" issue".
376         if (GW.isMobile && window.innerWidth <= 520) {
377                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
378                 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
379                         GWLog("GW.textareaFocusedMobile");
380                         GW.savedFilters = GW.currentFilters;
381                         GW.currentFilters = { };
382                         applyFilters(GW.currentFilters);
383                         fixedEditorElements.forEach(element => {
384                                 element.style.filter = filterStringFromFilters(GW.savedFilters);
385                         });
386                 });
387                 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
388                         GWLog("GW.textareaBlurredMobile");
389                         GW.currentFilters = GW.savedFilters;
390                         GW.savedFilters = { };
391                         requestAnimationFrame(() => {
392                                 applyFilters(GW.currentFilters);
393                                 fixedEditorElements.forEach(element => {
394                                         element.style.filter = filterStringFromFilters(GW.savedFilters);
395                                 });
396                         });
397                 });
398         }
401 Element.prototype.injectReplyForm = function(editMarkdownSource) {
402         GWLog("injectReplyForm");
403         let commentControls = this;
404         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
405         let postId = commentControls.parentElement.dataset["postId"];
406         let tagId = commentControls.parentElement.dataset["tagId"];
407         let withparent = (!editMarkdownSource && commentControls.getCommentId());
408         let answer = commentControls.parentElement.id == "answers";
409         let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
410         let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
411         let parentCommentItem = commentControls.closest(".comment-item");
412         let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
413             (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
414         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
415                 "<form method='post'>" + 
416                 "<div class='textarea-container'>" + 
417                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
418                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
419                 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
420                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
421                 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
422                 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
423                 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
424                 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
425                 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
426                 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
427                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
428                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
429                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
430                 "</div><div>" + 
431                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
432                 "<input type='submit' value='Submit'>" + 
433                 "</div></form>";
434         commentControls.onsubmit = disableBeforeUnload;
436         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
437                 GWLog("GW.cancelCommentButtonClicked");
438                 hideReplyForm(event.target.closest(".comment-controls"));
439         });
440         commentControls.scrollIntoViewIfNeeded();
441         commentControls.query("form").onsubmit = (event) => {
442                 if (!event.target.text.value) {
443                         alert("Please enter a comment.");
444                         return false;
445                 }
446         }
447         let textarea = commentControls.query("textarea");
448         textarea.value = MarkdownFromHTML(editMarkdownSource || "");
449         textarea.addTextareaFeatures();
450         textarea.focus();
453 function showCommentEditForm(commentItem) {
454         GWLog("showCommentEditForm");
456         let commentBody = commentItem.query(".comment-body");
457         commentBody.style.display = "none";
459         let commentControls = commentItem.query(".comment-controls");
460         commentControls.injectReplyForm(commentBody.dataset.markdownSource);
461         commentControls.query("form").addClass("edit-existing-comment");
462         expandTextarea(commentControls.query("textarea"));
465 function showReplyForm(commentItem) {
466         GWLog("showReplyForm");
468         let commentControls = commentItem.query(".comment-controls");
469         commentControls.injectReplyForm(commentControls.dataset.enteredText);
472 function hideReplyForm(commentControls) {
473         GWLog("hideReplyForm");
474         // Are we editing a comment? If so, un-hide the existing comment body.
475         let containingComment = commentControls.closest(".comment-item");
476         if (containingComment) containingComment.query(".comment-body").style.display = "";
478         let enteredText = commentControls.query("textarea").value;
479         if (enteredText) commentControls.dataset.enteredText = enteredText;
481         disableBeforeUnload();
482         commentControls.constructCommentControls();
485 function expandTextarea(textarea) {
486         GWLog("expandTextarea");
487         if (window.innerWidth <= 520) return;
489         let totalBorderHeight = 30;
490         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
492         requestAnimationFrame(() => {
493                 textarea.style.height = 'auto';
494                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
495                 if (textarea.clientHeight < window.innerHeight) {
496                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
497                 }
498         });
501 function doCommentAction(action, commentItem) {
502         GWLog("doCommentAction");
503         let params = {};
504         params[(action + "-comment-id")] = commentItem.getCommentId();
505         doAjax({
506                 method: "POST",
507                 params: params,
508                 onSuccess: GW.commentActionPostSucceeded = (event) => {
509                         GWLog("GW.commentActionPostSucceeded");
510                         let fn = {
511                                 retract: () => { commentItem.firstChild.addClass("retracted") },
512                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
513                                 delete: () => {
514                                         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>";
515                                         commentItem.removeChild(commentItem.query(".comment-controls"));
516                                 }
517                         }[action];
518                         if(fn) fn();
519                         if(action != "delete")
520                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
521                 }
522         });
525 /**********/
526 /* VOTING */
527 /**********/
529 function parseVoteType(voteType) {
530         GWLog("parseVoteType");
531         let value = {};
532         if (!voteType) return value;
533         value.up = /[Uu]pvote$/.test(voteType);
534         value.down = /[Dd]ownvote$/.test(voteType);
535         value.big = /^big/.test(voteType);
536         return value;
539 function makeVoteType(value) {
540         GWLog("makeVoteType");
541         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
544 function makeVoteClass(vote) {
545         GWLog("makeVoteClass");
546         if (vote.up || vote.down) {
547                 return (vote.big ? 'selected big-vote' : 'selected');
548         } else {
549                 return '';
550         }
553 function findVoteControls(targetType, targetId, voteAxis) {
554         var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
556         if(targetType == "Post") {
557                 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
558         } else if(targetType == "Comment") {
559                 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
560         }
563 function votesEqual(vote1, vote2) {
564         var allKeys = Object.assign({}, vote1);
565         Object.assign(allKeys, vote2);
567         for(k of allKeys.keys()) {
568                 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
569         }
570         return true;
573 function addVoteButtons(element, vote, targetType) {
574         GWLog("addVoteButtons");
575         vote = vote || {};
576         let voteAxis = element.parentElement.dataset.voteAxis || "karma";
577         let voteType = parseVoteType(vote[voteAxis]);
578         let voteClass = makeVoteClass(voteType);
580         element.parentElement.queryAll("button").forEach((button) => {
581                 button.disabled = false;
582                 if (voteType) {
583                         if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
584                                 button.addClass(voteClass);
585                 }
586                 updateVoteButtonVisualState(button);
587                 button.addActivateEvent(voteButtonClicked);
588         });
591 function updateVoteButtonVisualState(button) {
592         GWLog("updateVoteButtonVisualState");
594         button.removeClasses([ "none", "one", "two-temp", "two" ]);
596         if (button.disabled)
597                 button.addClass("none");
598         else if (button.hasClass("big-vote"))
599                 button.addClass("two");
600         else if (button.hasClass("selected"))
601                 button.addClass("one");
602         else
603                 button.addClass("none");
606 function changeVoteButtonVisualState(button) {
607         GWLog("changeVoteButtonVisualState");
609         /*      Interaction states are:
611                 0  0·    (neutral; +1 click)
612                 1  1·    (small vote; +1 click)
613                 2  2·    (big vote; +1 click)
615                 Visual states are (with their state classes in [brackets]) are:
617                 01    (no vote) [none]
618                 02    (small vote active) [one]
619                 12    (small vote active, temporary indicator of big vote) [two-temp]
620                 22    (big vote active) [two]
622                 The following are the 9 possible interaction state transitions (and
623                 the visual state transitions associated with them):
625                                 VIS.    VIS.
626                 FROM    TO      FROM    TO      NOTES
627                 ====    ====    ====    ====    =====
628                 0       0·      01      12      first click
629                 0·      1       12      02      one click without second
630                 0·      2       12      22      second click
632                 1       1·      02      12      first click
633                 1·      0       12      01      one click without second
634                 1·      2       12      22      second click
636                 2       2·      22      12      first click
637                 2·      1       12      02      one click without second
638                 2·      0       12      01      second click
639         */
640         let transitions = [
641                 [ "big-vote two-temp clicked-twice", "none"     ], // 2· => 0
642                 [ "big-vote two-temp clicked-once",  "one"      ], // 2· => 1
643                 [ "big-vote clicked-once",           "two-temp" ], // 2  => 2·
645                 [ "selected two-temp clicked-twice", "two"      ], // 1· => 2
646                 [ "selected two-temp clicked-once",  "none"     ], // 1· => 0
647                 [ "selected clicked-once",           "two-temp" ], // 1  => 1·
649                 [ "two-temp clicked-twice",          "two"      ], // 0· => 2
650                 [ "two-temp clicked-once",           "one"      ], // 0· => 1
651                 [ "clicked-once",                    "two-temp" ], // 0  => 0·
652         ];
653         for (let [ interactionClasses, visualStateClass ] of transitions) {
654                 if (button.hasClasses(interactionClasses.split(" "))) {
655                         button.removeClasses([ "none", "one", "two-temp", "two" ]);
656                         button.addClass(visualStateClass);
657                         break;
658                 }
659         }
662 function voteCompleteEvent(targetType, targetId, response) {
663         GWLog("voteCompleteEvent");
665         var currentVote = voteData[targetType][targetId] || {};
666         var desiredVote = voteDesired[targetType][targetId];
668         var controls = findVoteControls(targetType, targetId);
669         var controlsByAxis = new Object;
671         controls.forEach(control => {
672                 const voteAxis = (control.dataset.voteAxis || "karma");
674                 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
675                         control.removeClass("waiting");
676                         control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
677                 }
679                 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
680                 controlsByAxis[voteAxis].push(control);
682                 const voteType = currentVote[voteAxis];
683                 const vote = parseVoteType(voteType);
684                 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
685                 const voteClass = makeVoteClass(vote);
687                 if (response && response[voteAxis]) {
688                         const [voteType, displayText, titleText] = response[voteAxis];
690                         const displayTarget = control.query(".karma-value");
691                         if (displayTarget.hasClass("redacted")) {
692                                 displayTarget.dataset["trueValue"] = displayText;
693                         } else {
694                                 displayTarget.innerHTML = displayText;
695                         }
696                         displayTarget.setAttribute("title", titleText);
697                 }
699                 control.queryAll("button.vote").forEach(button => {
700                         updateVoteButton(button, voteUpDown, voteClass);
701                 });
702         });
705 function updateVoteButton(button, voteUpDown, voteClass) {
706         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
707         if (button.dataset.voteType == voteUpDown)
708                 button.addClass(voteClass);
709         updateVoteButtonVisualState(button);
712 function makeVoteRequestCompleteEvent(targetType, targetId) {
713         return (event) => {
714                 var currentVote = {};
715                 var response = null;
717                 if (event.target.status == 200) {
718                         response = JSON.parse(event.target.responseText);
719                         for (const voteAxis of response.keys()) {
720                                 currentVote[voteAxis] = response[voteAxis][0];
721                         }
722                         voteData[targetType][targetId] = currentVote;
723                 } else {
724                         delete voteDesired[targetType][targetId];
725                         currentVote = voteData[targetType][targetId];
726                 }
728                 var desiredVote = voteDesired[targetType][targetId];
730                 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
731                         sendVoteRequest(targetType, targetId);
732                 } else {
733                         delete voteDesired[targetType][targetId];
734                         voteCompleteEvent(targetType, targetId, response);
735                 }
736         }
739 function sendVoteRequest(targetType, targetId) {
740         GWLog("sendVoteRequest");
742         doAjax({
743                 method: "POST",
744                 location: "/karma-vote",
745                 params: { "target": targetId,
746                           "target-type": targetType,
747                           "vote": JSON.stringify(voteDesired[targetType][targetId]) },
748                 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
749         });
752 function voteButtonClicked(event) {
753         GWLog("voteButtonClicked");
754         let voteButton = event.target;
756         // 500 ms (0.5 s) double-click timeout.
757         let doubleClickTimeout = 500;
759         if (!voteButton.clickedOnce) {
760                 voteButton.clickedOnce = true;
761                 voteButton.addClass("clicked-once");
762                 changeVoteButtonVisualState(voteButton);
764                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
765                         if (!voteButton.clickedOnce) return;
767                         // Do single-click code.
768                         voteButton.clickedOnce = false;
769                         voteEvent(voteButton, 1);
770                 }, doubleClickTimeout, voteButton);
771         } else {
772                 voteButton.clickedOnce = false;
774                 // Do double-click code.
775                 voteButton.removeClass("clicked-once");
776                 voteButton.addClass("clicked-twice");
777                 voteEvent(voteButton, 2);
778         }
781 function voteEvent(voteButton, numClicks) {
782         GWLog("voteEvent");
783         voteButton.blur();
785         let voteControl = voteButton.parentNode;
787         let targetType = voteButton.dataset.targetType;
788         let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
789         let voteAxis = voteControl.dataset.voteAxis || "karma";
790         let voteUpDown = voteButton.dataset.voteType;
792         let voteType;
793         if (   (numClicks == 2 && voteButton.hasClass("big-vote"))
794                 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
795                 voteType = "neutral";
796         } else {
797                 let vote = parseVoteType(voteUpDown);
798                 vote.big = (numClicks == 2);
799                 voteType = makeVoteType(vote);
800         }
802         let voteControls = findVoteControls(targetType, targetId, voteAxis);
803         for (const voteControl of voteControls) {
804                 voteControl.addClass("waiting");
805                 voteControl.queryAll(".vote").forEach(button => {
806                         button.addClass("waiting");
807                         updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
808                 });
809         }
811         let voteRequestPending = voteDesired[targetType][targetId];
812         let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
813         voteObject[voteAxis] = voteType;
814         voteDesired[targetType][targetId] = voteObject;
816         if (!voteRequestPending) sendVoteRequest(targetType, targetId);
819 function initializeVoteButtons() {
820         // Color the upvote/downvote buttons with an embedded style sheet.
821         query("head").insertAdjacentHTML("beforeend", "<style id='vote-buttons'>" + `
822                 :root {
823                         --GW-upvote-button-color: #00d800;
824                         --GW-downvote-button-color: #eb4c2a;
825                 }\n` + "</style>");
828 function processVoteData(voteData) {
829         window.voteData = voteData;
831         window.voteDesired = new Object;
832         for(key of voteData.keys()) {
833                 voteDesired[key] = new Object;
834         }
836         initializeVoteButtons();
837         
838         addTriggerListener("postLoaded", {priority: 3000, fn: () => {
839                 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
840                         let postID = karmaValue.parentNode.dataset.postId;
841                         addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
842                         karmaValue.parentElement.addClass("active-controls");
843                 });
844         }});
846         addTriggerListener("DOMReady", {priority: 3000, fn: () => {
847                 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
848                         let commentID = karmaValue.getCommentId();
849                         addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
850                         karmaValue.parentElement.addClass("active-controls");
851                 });
852         }});
855 /*****************************************/
856 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
857 /*****************************************/
859 Element.prototype.getCommentDate = function() {
860         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
861         let dateElement = item && item.query(".date");
862         return (dateElement && parseInt(dateElement.dataset["jsDate"]));
864 function getCurrentVisibleComment() {
865         let px = window.innerWidth/2, py = 5;
866         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
867         let bottomBar = query("#bottom-bar");
868         let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
869         let atbottom =  bottomOffset <= window.innerHeight;
870         if (atbottom) {
871                 let hashci = location.hash && query(location.hash);
872                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
873                         commentItem = hashci;
874                 }
875         }
876         return commentItem;
879 function highlightCommentsSince(date) {
880         GWLog("highlightCommentsSince");
881         var newCommentsCount = 0;
882         GW.newComments = [ ];
883         let oldCommentsStack = [ ];
884         let prevNewComment;
885         queryAll(".comment-item").forEach(commentItem => {
886                 commentItem.prevNewComment = prevNewComment;
887                 commentItem.nextNewComment = null;
888                 if (commentItem.getCommentDate() > date) {
889                         commentItem.addClass("new-comment");
890                         newCommentsCount++;
891                         GW.newComments.push(commentItem.getCommentId());
892                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
893                         oldCommentsStack = [ commentItem ];
894                         prevNewComment = commentItem;
895                 } else {
896                         commentItem.removeClass("new-comment");
897                         oldCommentsStack.push(commentItem);
898                 }
899         });
901         GW.newCommentScrollSet = (commentItem) => {
902                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
903                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
904         };
905         GW.newCommentScrollListener = () => {
906                 let commentItem = getCurrentVisibleComment();
907                 GW.newCommentScrollSet(commentItem);
908         }
910         addScrollListener(GW.newCommentScrollListener);
912         if (document.readyState=="complete") {
913                 GW.newCommentScrollListener();
914         } else {
915                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
916                 GW.newCommentScrollSet(commentItem);
917         }
919         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
921         return newCommentsCount;
924 function scrollToNewComment(next) {
925         GWLog("scrollToNewComment");
926         let commentItem = getCurrentVisibleComment();
927         let targetComment = null;
928         let targetCommentID = null;
929         if (commentItem) {
930                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
931                 if (targetComment) {
932                         targetCommentID = targetComment.getCommentId();
933                 }
934         } else {
935                 if (GW.newComments[0]) {
936                         targetCommentID = GW.newComments[0];
937                         targetComment = query("#comment-" + targetCommentID);
938                 }
939         }
940         if (targetComment) {
941                 expandAncestorsOf(targetCommentID);
942                 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
943                 targetComment.scrollIntoView();
944         }
946         GW.newCommentScrollListener();
949 function getPostHash() {
950         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
951         return (postHash ? postHash[1] : false);
953 function setHistoryLastVisitedDate(date) {
954         window.history.replaceState({ lastVisited: date }, null);
956 function getLastVisitedDate() {
957         // Get the last visited date (or, if posting a comment, the previous last visited date).
958         if(window.history.state) return (window.history.state||{})['lastVisited'];
959         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
960         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
961         let currentVisited = localStorage.getItem(storageName);
962         setHistoryLastVisitedDate(currentVisited);
963         return currentVisited;
965 function setLastVisitedDate(date) {
966         GWLog("setLastVisitedDate");
967         // If NOT posting a comment, save the previous value for the last-visited-date 
968         // (to recover it in case of posting a comment).
969         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
970         if (!aCommentHasJustBeenPosted) {
971                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
972                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
973         }
975         // Set the new value.
976         localStorage.setItem("last-visited-date_" + getPostHash(), date);
979 function updateSavedCommentCount() {
980         GWLog("updateSavedCommentCount");
981         let commentCount = queryAll(".comment").length;
982         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
984 function badgePostsWithNewComments() {
985         GWLog("badgePostsWithNewComments");
986         if (getQueryVariable("show") == "conversations") return;
988         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
989                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
991                 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
992                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
993                 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
995                 if (currentCommentCount > savedCommentCount)
996                         commentCountDisplay.addClass("new-comments");
997                 else
998                         commentCountDisplay.removeClass("new-comments");
999                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1000         });
1003 /***********************************/
1004 /* CONTENT COLUMN WIDTH ADJUSTMENT */
1005 /***********************************/
1007 function injectContentWidthSelector() {
1008         GWLog("injectContentWidthSelector");
1009         // Get saved width setting (or default).
1010         let currentWidth = localStorage.getItem("selected-width") || 'normal';
1012         // Inject the content width selector widget and activate buttons.
1013         let widthSelector = addUIElement(
1014                 "<div id='width-selector'>" +
1015                 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
1016                         let [name, desc, abbr] = widthOption;
1017                         let selected = (name == currentWidth ? ' selected' : '');
1018                         let disabled = (name == currentWidth ? ' disabled' : '');
1019                         return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
1020                 "</div>");
1021         widthSelector.queryAll("button").forEach(button => {
1022                 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
1023                         GWLog("GW.widthAdjustButtonClicked");
1025                         // Determine which setting was chosen (i.e., which button was clicked).
1026                         let selectedWidth = event.target.dataset.name;
1028                         // Save the new setting.
1029                         if (selectedWidth == "normal") localStorage.removeItem("selected-width");
1030                         else localStorage.setItem("selected-width", selectedWidth);
1032                         // Save current visible comment
1033                         let visibleComment = getCurrentVisibleComment();
1035                         // Actually change the content width.
1036                         setContentWidth(selectedWidth);
1037                         event.target.parentElement.childNodes.forEach(button => {
1038                                 button.removeClass("selected");
1039                                 button.disabled = false;
1040                         });
1041                         event.target.addClass("selected");
1042                         event.target.disabled = true;
1044                         // Make sure the accesskey (to cycle to the next width) is on the right button.
1045                         setWidthAdjustButtonsAccesskey();
1047                         // Regenerate images overlay.
1048                         generateImagesOverlay();
1050                         if(visibleComment) visibleComment.scrollIntoView();
1051                 });
1052         });
1054         // Make sure the accesskey (to cycle to the next width) is on the right button.
1055         setWidthAdjustButtonsAccesskey();
1057         // Inject transitions CSS, if animating changes is enabled.
1058         if (GW.adjustmentTransitions) {
1059                 insertHeadHTML(
1060                         "<style id='width-transition'>" + 
1061                         `#content,
1062                         #ui-elements-container,
1063                         #images-overlay {
1064                                 transition:
1065                                         max-width 0.3s ease;
1066                         }` + 
1067                         "</style>");
1068         }
1070 function setWidthAdjustButtonsAccesskey() {
1071         GWLog("setWidthAdjustButtonsAccesskey");
1072         let widthSelector = query("#width-selector");
1073         widthSelector.queryAll("button").forEach(button => {
1074                 button.removeAttribute("accesskey");
1075                 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1076         });
1077         let selectedButton = widthSelector.query("button.selected");
1078         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
1079         nextButtonInCycle.accessKey = "'";
1080         nextButtonInCycle.title += ` [\']`;
1083 /*******************/
1084 /* THEME SELECTION */
1085 /*******************/
1087 function injectThemeSelector() {
1088         GWLog("injectThemeSelector");
1089         let currentTheme = readCookie("theme") || "default";
1090         let themeSelector = addUIElement(
1091                 "<div id='theme-selector' class='theme-selector'>" +
1092                 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
1093                         let [name, desc, letter] = themeOption;
1094                         let selected = (name == currentTheme ? ' selected' : '');
1095                         let disabled = (name == currentTheme ? ' disabled' : '');
1096                         let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1097                         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>`;})) +
1098                 "</div>");
1099         themeSelector.queryAll("button").forEach(button => {
1100                 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
1101                         GWLog("GW.themeSelectButtonClicked");
1102                         let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
1103                         setSelectedTheme(themeName);
1104                         if (GW.isMobile) toggleAppearanceAdjustUI();
1105                 });
1106         });
1108         // Inject transitions CSS, if animating changes is enabled.
1109         if (GW.adjustmentTransitions) {
1110                 insertHeadHTML(
1111                         "<style id='theme-fade-transition'>" + 
1112                         `body {
1113                                 transition:
1114                                         opacity 0.5s ease-out,
1115                                         background-color 0.3s ease-out;
1116                         }
1117                         body.transparent {
1118                                 background-color: #777;
1119                                 opacity: 0.0;
1120                                 transition:
1121                                         opacity 0.5s ease-in,
1122                                         background-color 0.3s ease-in;
1123                         }` + 
1124                         "</style>");
1125         }
1127 function setSelectedTheme(themeName) {
1128         GWLog("setSelectedTheme");
1129         queryAll(".theme-selector button").forEach(button => {
1130                 button.removeClass("selected");
1131                 button.disabled = false;
1132         });
1133         queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
1134                 button.addClass("selected");
1135                 button.disabled = true;
1136         });
1137         setTheme(themeName);
1138         query("#theme-tweaker-ui .current-theme span").innerText = themeName;
1140 function setTheme(newThemeName) {
1141         var themeUnloadCallback = '';
1142         var oldThemeName = '';
1143         if (typeof(newThemeName) == 'undefined') {
1144                 newThemeName = readCookie('theme');
1145                 if (!newThemeName) return;
1146         } else {
1147                 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
1148                 oldThemeName = readCookie('theme') || 'default';
1150                 if (newThemeName == 'default') setCookie('theme', '');
1151                 else setCookie('theme', newThemeName);
1152         }
1153         if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
1155         let makeNewStyle = function(newThemeName, colorSchemePreference) {
1156                 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
1157                 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1159                 let newStyle = document.createElement('link');
1160                 newStyle.setAttribute('class', 'theme');
1161                 if(colorSchemePreference)
1162                         newStyle.setAttribute('media', '(prefers-color-scheme: ' + colorSchemePreference + ')');
1163                 newStyle.setAttribute('rel', 'stylesheet');
1164                 newStyle.setAttribute('href', '/css/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1165                 return newStyle;
1166         }
1168         let newMainStyle, newStyles;
1169         if(newThemeName === 'default') {
1170                 newStyles = [makeNewStyle('dark', 'dark'), makeNewStyle('default', 'light')];
1171                 newMainStyle = (window.matchMedia('prefers-color-scheme: dark').matches ? newStyles[0] : newStyles[1]);
1172         } else {
1173                 newStyles = [makeNewStyle(newThemeName)];
1174                 newMainStyle = newStyles[0];
1175         }
1177         let oldStyles = queryAll("head link.theme");
1178         newMainStyle.addEventListener('load', () => { oldStyles.forEach(x => removeElement(x)); });
1179         newMainStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
1181         if (GW.adjustmentTransitions) {
1182                 pageFadeTransition(false);
1183                 setTimeout(() => {
1184                         newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1185                 }, 500);
1186         } else {
1187                 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1188         }
1190 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
1191         document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1192         document.body.addClass("theme-" + newThemeName);
1194         recomputeUIElementsContainerHeight(true);
1196         let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
1197         if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
1199         recomputeUIElementsContainerHeight();
1200         adjustUIForWindowSize();
1201         window.addEventListener('resize', GW.windowResized = (event) => {
1202                 GWLog("GW.windowResized");
1203                 adjustUIForWindowSize();
1204                 recomputeUIElementsContainerHeight();
1205         });
1207         generateImagesOverlay();
1209         if (window.adjustmentTransitions) pageFadeTransition(true);
1210         updateThemeTweakerSampleText();
1212         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
1213                 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1214         }
1217 function pageFadeTransition(fadeIn) {
1218         if (fadeIn) {
1219                 query("body").removeClass("transparent");
1220         } else {
1221                 query("body").addClass("transparent");
1222         }
1225 GW.themeLoadCallback_less = (fromTheme = "") => {
1226         GWLog("themeLoadCallback_less");
1227         injectSiteNavUIToggle();
1228         if (!GW.isMobile) {
1229                 injectPostNavUIToggle();
1230                 injectAppearanceAdjustUIToggle();
1231         }
1233         registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1234                 let dtf = new Intl.DateTimeFormat([], 
1235                         (window.innerWidth < 1100) ? 
1236                                 { month: 'short', day: 'numeric', year: 'numeric' } : 
1237                                         { month: 'long', day: 'numeric', year: 'numeric' });
1238                 let postDate = query(".top-post-meta .date");
1239                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1240         });
1242         if (GW.isMobile) {
1243                 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1244         }
1246         if (!GW.isMobile) {
1247                 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1248                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1249                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1250                         });
1251                 });
1253                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1254                         // If state is not set (user has never clicked on the Less theme's appearance
1255                         // adjustment UI toggle) then show it, but then hide it after a short time.
1256                         registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1257                                 toggleAppearanceAdjustUI();
1258                                 setTimeout(toggleAppearanceAdjustUI, 3000);
1259                         });
1260                 }
1262                 if (fromTheme != "") {
1263                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1264                         setTimeout(function () {
1265                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1266                         }, 300);
1267                         setTimeout(function () {
1268                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1269                         }, 1800);
1270                 }
1272                 // Unset the height of the #ui-elements-container.
1273                 query("#ui-elements-container").style.height = "";
1275                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1276                 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1277                         "#content #secondary-bar",
1278                         "#content .post .top-post-meta .date",
1279                         "#content .post .top-post-meta .comment-count",
1280                 ];
1281                 applyFilters(GW.currentFilters);
1282         }
1284         // We pre-query the relevant elements, so we don't have to run querySelectorAll
1285         // on every firing of the scroll listener.
1286         GW.scrollState = {
1287                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1288                 "unbrokenDownScrollDistance":           0,
1289                 "unbrokenUpScrollDistance":                     0,
1290                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1291                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1292                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1293         };
1294         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1297 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
1298 // otherwise, show it.
1299 function updatePostNavUIVisibility() {
1300         GWLog("updatePostNavUIVisibility");
1301         var hidePostNavUIToggle = true;
1302         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1303                 if (getComputedStyle(element).visibility == "visible" ||
1304                         element.style.visibility == "visible" ||
1305                         element.style.visibility == "unset")
1306                         hidePostNavUIToggle = false;
1307         });
1308         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1309                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1310         });
1313 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1314 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
1315 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1316 function updateSiteNavUIState(event) {
1317         GWLog("updateSiteNavUIState");
1318         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1319         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
1320                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
1321                                                                                                                 0;
1322         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1323                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1324                                                                                                          0;
1325         GW.scrollState.lastScrollTop = newScrollTop;
1327         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1328         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1329                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1330                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1331         }
1333         // On mobile, make site nav UI translucent on ANY scroll down.
1334         if (GW.isMobile)
1335                 GW.scrollState.siteNavUIElements.forEach(element => {
1336                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1337                         else element.removeClass("translucent-on-scroll");
1338                 });
1340         // Show site nav UI when scrolling a full page up, or to the top.
1341         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
1342                  GW.scrollState.lastScrollTop == 0) &&
1343                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
1344                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1346         // On desktop, show appearance adjust UI when scrolling to the top.
1347         if ((!GW.isMobile) && 
1348                 (GW.scrollState.lastScrollTop == 0) &&
1349                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
1350                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1353 GW.themeUnloadCallback_less = (toTheme = "") => {
1354         GWLog("themeUnloadCallback_less");
1355         removeSiteNavUIToggle();
1356         if (!GW.isMobile) {
1357                 removePostNavUIToggle();
1358                 removeAppearanceAdjustUIToggle();
1359         }
1360         window.removeEventListener('resize', updatePostNavUIVisibility);
1362         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1364         removeElement("#theme-less-mobile-first-row-placeholder");
1366         if (!GW.isMobile) {
1367                 // Remove spans
1368                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1369                         element.innerHTML = element.firstChild.innerHTML;
1370                 });
1371         }
1373         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1375         // Reset filtered elements selector to default.
1376         delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1377         applyFilters(GW.currentFilters);
1380 GW.themeLoadCallback_dark = (fromTheme = "") => {
1381         GWLog("themeLoadCallback_dark");
1382         insertHeadHTML(
1383                 "<style id='dark-theme-adjustments'>" + 
1384                 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` + 
1385                 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1386                 "</style>");
1387         registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1388                 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1389                         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)";
1390                         image.style.width = parseInt(image.style.width) + 12 + "px";
1391                         image.style.height = parseInt(image.style.height) + 12 + "px";
1392                         image.style.top = parseInt(image.style.top) - 6 + "px";
1393                         image.style.left = parseInt(image.style.left) - 6 + "px";
1394                 });
1395         });
1397 GW.themeUnloadCallback_dark = (toTheme = "") => {
1398         GWLog("themeUnloadCallback_dark");
1399         removeElement("#dark-theme-adjustments");
1402 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1403         GWLog("themeLoadCallback_brutalist");
1404         let bottomBarLinks = queryAll("#bottom-bar a");
1405         if (!GW.isMobile && bottomBarLinks.length == 5) {
1406                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1407                 bottomBarLinks.forEach((link, i) => {
1408                         link.dataset.originalText = link.textContent;
1409                         link.textContent = newLinkTexts[i];
1410                 });
1411         }
1413 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1414         GWLog("themeUnloadCallback_brutalist");
1415         let bottomBarLinks = queryAll("#bottom-bar a");
1416         if (!GW.isMobile && bottomBarLinks.length == 5) {
1417                 bottomBarLinks.forEach(link => {
1418                         link.textContent = link.dataset.originalText;
1419                 });
1420         }
1423 GW.themeLoadCallback_classic = (fromTheme = "") => {
1424         GWLog("themeLoadCallback_classic");
1425         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1426                 button.innerHTML = "";
1427         });
1429 GW.themeUnloadCallback_classic = (toTheme = "") => {
1430         GWLog("themeUnloadCallback_classic");
1431         if (GW.isMobile && window.innerWidth <= 900) return;
1432         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1433                 button.innerHTML = button.dataset.label;
1434         });
1437 /********************************************/
1438 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1439 /********************************************/
1441 function injectThemeTweaker() {
1442         GWLog("injectThemeTweaker");
1443         let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" + 
1444         `<div class='main-theme-tweaker-window'>
1445                 <h1>Customize appearance</h1>
1446                 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1447                 <button type='button' class='help-button' tabindex='-1'></button>
1448                 <p class='current-theme'>Current theme: <span>` + 
1449                 (readCookie("theme") || "default") + 
1450                 `</span></p>
1451                 <p class='theme-selector'></p>
1452                 <div class='controls-container'>
1453                         <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1454                                 <div class='sample-text-container'><span class='sample-text'>
1455                                         <p>Less Wrong (text)</p>
1456                                         <p><a href="#">Less Wrong (link)</a></p>
1457                                 </span></div>
1458                         </div>
1459                         <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1460                                 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1461                                 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1462                                 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1463                         </div>
1464                         <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1465                                 <input type='checkbox' id='theme-tweak-control-invert'></input>
1466                                 <label for='theme-tweak-control-invert'>Invert colors</label>
1467                         </div>
1468                         <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1469                                 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1470                                 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1471                                 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1472                         </div>
1473                         <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1474                                 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1475                                 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1476                                 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1477                         </div>
1478                         <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1479                                 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1480                                 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1481                                 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1482                         </div>
1483                         <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1484                                 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1485                                 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1486                                 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1487                         </div>
1488                 </div>
1489                 <div class='buttons-container'>
1490                         <button type="button" class="reset-defaults-button">Reset to defaults</button>
1491                         <button type='button' class='ok-button default-button'>OK</button>
1492                         <button type='button' class='cancel-button'>Cancel</button>
1493                 </div>
1494         </div>
1495         <div class="clippy-container">
1496                 <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>)
1497                 <div class='clippy'></div>
1498                 <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>
1499         </div>
1500         <div class='help-window' style='display: none;'>
1501                 <h1>Theme tweaker help</h1>
1502                 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1503                         <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1504                         <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1505                 </div>
1506                 <div class='buttons-container'>
1507                         <button type='button' class='ok-button default-button'>OK</button>
1508                         <button type='button' class='cancel-button'>Cancel</button>
1509                 </div>
1510         </div>
1511         ` + "</div>");
1513         // Clicking the background overlay closes the theme tweaker.
1514         themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1515                 GWLog("GW.themeTweaker.UIOverlayClicked");
1516                 if (event.type == 'mousedown') {
1517                         themeTweakerUI.style.opacity = "0.01";
1518                 } else {
1519                         toggleThemeTweakerUI();
1520                         themeTweakerUI.style.opacity = "1.0";
1521                         themeTweakReset();
1522                 }
1523         }, true);
1525         // Intercept clicks, so they don't "fall through" the background overlay.
1526         (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1528         let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1529         themeTweakerUI.queryAll("input").forEach(field => {
1530                 // All input types in the theme tweaker receive a 'change' event when
1531                 // their value is changed. (Range inputs, in particular, receive this 
1532                 // event when the user lets go of the handle.) This means we should
1533                 // update the filters for the entire page, to match the new setting.
1534                 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1535                         GWLog("GW.themeTweaker.fieldValueChanged");
1536                         if (event.target.id == 'theme-tweak-control-invert') {
1537                                 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1538                         } else if (event.target.type == 'range') {
1539                                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1540                                 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1541                                 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1542                         } else if (event.target.id == 'theme-tweak-control-clippy') {
1543                                 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1544                         }
1545                         // Clear the sample text filters.
1546                         sampleTextContainer.style.filter = "";
1547                         // Apply the new filters globally.
1548                         applyFilters(GW.currentFilters);
1549                 });
1551                 // Range inputs receive an 'input' event while being scrubbed, updating
1552                 // "live" as the handle is moved. We don't want to change the filters 
1553                 // for the actual page while this is happening, but we do want to change
1554                 // the filters for the *sample text*, so the user can see what effects
1555                 // his changes are having, live, without having to let go of the handle.
1556                 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1557                         GWLog("GW.themeTweaker.fieldInputReceived");
1558                         var sampleTextFilters = GW.currentFilters;
1560                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1561                         query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1562                         sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1564                         sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1565                 });
1566         });
1568         themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1569                 GWLog("GW.themeTweaker.minimizeButtonClicked");
1570                 let themeTweakerStyle = query("#theme-tweaker-style");
1572                 if (event.target.hasClass("minimize")) {
1573                         event.target.removeClass("minimize");
1574                         themeTweakerStyle.innerHTML = 
1575                                 `#theme-tweaker-ui .main-theme-tweaker-window {
1576                                         width: 320px;
1577                                         height: 31px;
1578                                         overflow: hidden;
1579                                         padding: 30px 0 0 0;
1580                                         top: 20px;
1581                                         right: 20px;
1582                                         left: auto;
1583                                 }
1584                                 #theme-tweaker-ui::after {
1585                                         top: 27px;
1586                                         right: 27px;
1587                                 }
1588                                 #theme-tweaker-ui::before {
1589                                         opacity: 0.0;
1590                                         height: 0;
1591                                 }
1592                                 #theme-tweaker-ui .clippy-container {
1593                                         opacity: 1.0;
1594                                 }
1595                                 #theme-tweaker-ui .clippy-container .hint span {
1596                                         color: #c00;
1597                                 }
1598                                 #theme-tweaker-ui {
1599                                         height: 0;
1600                                 }
1601                                 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1602                                         pointer-events: none;
1603                                 }`;
1604                         event.target.addClass("maximize");
1605                 } else {
1606                         event.target.removeClass("maximize");
1607                         themeTweakerStyle.innerHTML = 
1608                                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1609                                         pointer-events: none;
1610                                 }`;
1611                         event.target.addClass("minimize");
1612                 }
1613         });
1614         themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1615                 GWLog("GW.themeTweaker.helpButtonClicked");
1616                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1617                 toggleThemeTweakerHelpWindow();
1618         });
1619         themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1620                 GWLog("GW.themeTweaker.resetDefaultsButtonClicked");
1621                 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1622                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1623                         let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1624                         slider.value = slider.dataset['defaultValue'];
1625                         themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1626                 });
1627                 GW.currentFilters = { };
1628                 applyFilters(GW.currentFilters);
1630                 GW.currentTextZoom = "1.0";
1631                 setTextZoom(GW.currentTextZoom);
1633                 setSelectedTheme("default");
1634         });
1635         themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1636                 GWLog("GW.themeTweaker.cancelButtonClicked");
1637                 toggleThemeTweakerUI();
1638                 themeTweakReset();
1639         });
1640         themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1641                 GWLog("GW.themeTweaker.OKButtonClicked");
1642                 toggleThemeTweakerUI();
1643                 themeTweakSave();
1644         });
1645         themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1646                 GWLog("GW.themeTweaker.helpWindowCancelButtonClicked");
1647                 toggleThemeTweakerHelpWindow();
1648                 themeTweakerResetSettings();
1649         });
1650         themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1651                 GWLog("GW.themeTweaker.helpWindowOKButtonClicked");
1652                 toggleThemeTweakerHelpWindow();
1653                 themeTweakerSaveSettings();
1654         });
1656         themeTweakerUI.queryAll(".notch").forEach(notch => {
1657                 notch.addActivateEvent(GW.themeTweaker.sliderNotchClicked = (event) => {
1658                         GWLog("GW.themeTweaker.sliderNotchClicked");
1659                         let slider = event.target.parentElement.query("input[type='range']");
1660                         slider.value = slider.dataset['defaultValue'];
1661                         event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1662                         GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1663                         applyFilters(GW.currentFilters);
1664                 });
1665         });
1667         themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1668                 GWLog("GW.themeTweaker.clippyCloseButtonClicked");
1669                 themeTweakerUI.query(".clippy-container").style.display = "none";
1670                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1671                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1672         });
1674         query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1676         themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1677         themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1678                 button.addActivateEvent(GW.themeSelectButtonClicked);
1679         });
1681         themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1682                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1683         });
1685         let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'>&#xf1de;</button></div>`);
1686         themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1687                 GWLog("GW.themeTweaker.toggleButtonClicked");
1688                 GW.themeTweakerStyleSheetAvailable = () => {
1689                         GWLog("GW.themeTweakerStyleSheetAvailable");
1690                         themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1692                         themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1693                         [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1694                                 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1695                                 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1696                                 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1697                         });
1699                         toggleThemeTweakerUI();
1700                         event.target.disabled = true;
1701                 };
1703                 if (query("link[href^='/css/theme_tweaker.css']")) {
1704                         // Theme tweaker CSS is already loaded.
1705                         GW.themeTweakerStyleSheetAvailable();
1706                 } else {
1707                         // Load the theme tweaker CSS (if not loaded).
1708                         let themeTweakerStyleSheet = document.createElement('link');
1709                         themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1710                         themeTweakerStyleSheet.setAttribute('href', '/css/theme_tweaker.css');
1711                         themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1712                         query("head").appendChild(themeTweakerStyleSheet);
1713                 }
1714         });
1716 function toggleThemeTweakerUI() {
1717         GWLog("toggleThemeTweakerUI");
1718         let themeTweakerUI = query("#theme-tweaker-ui");
1719         themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1720         query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" : 
1721                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1722                         pointer-events: none;
1723                 }`;
1724         if (themeTweakerUI.style.display != "none") {
1725                 // Save selected theme.
1726                 GW.currentTheme = (readCookie("theme") || "default");
1727                 // Focus invert checkbox.
1728                 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1729                 // Show sample text in appropriate font.
1730                 updateThemeTweakerSampleText();
1731                 // Disable tab-selection of the search box.
1732                 setSearchBoxTabSelectable(false);
1733                 // Disable scrolling of the page.
1734                 togglePageScrolling(false);
1735         } else {
1736                 query("#theme-tweaker-toggle button").disabled = false;
1737                 // Re-enable tab-selection of the search box.
1738                 setSearchBoxTabSelectable(true);
1739                 // Re-enable scrolling of the page.
1740                 togglePageScrolling(true);
1741         }
1742         // Set theme tweaker assistant visibility.
1743         query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1745 function setSearchBoxTabSelectable(selectable) {
1746         GWLog("setSearchBoxTabSelectable");
1747         query("input[type='search']").tabIndex = selectable ? "" : "-1";
1748         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1750 function toggleThemeTweakerHelpWindow() {
1751         GWLog("toggleThemeTweakerHelpWindow");
1752         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1753         themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1754         if (themeTweakerHelpWindow.style.display != "none") {
1755                 // Focus theme tweaker assistant checkbox.
1756                 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1757                 // Disable interaction on main theme tweaker window.
1758                 query("#theme-tweaker-ui").style.pointerEvents = "none";
1759                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1760         } else {
1761                 // Re-enable interaction on main theme tweaker window.
1762                 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1763                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1764         }
1766 function themeTweakReset() {
1767         GWLog("themeTweakReset");
1768         setSelectedTheme(GW.currentTheme);
1769         GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1770         applyFilters(GW.currentFilters);
1771         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1772         setTextZoom(GW.currentTextZoom);
1774 function themeTweakSave() {
1775         GWLog("themeTweakSave");
1776         GW.currentTheme = (readCookie("theme") || "default");
1777         localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1778         localStorage.setItem("text-zoom", GW.currentTextZoom);
1781 function themeTweakerResetSettings() {
1782         GWLog("themeTweakerResetSettings");
1783         query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1784         query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1786 function themeTweakerSaveSettings() {
1787         GWLog("themeTweakerSaveSettings");
1788         localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1790 function updateThemeTweakerSampleText() {
1791         GWLog("updateThemeTweakerSampleText");
1792         let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1794         // This causes the sample text to take on the properties of the body text of a post.
1795         sampleText.removeClass("body-text");
1796         let bodyTextElement = query(".post-body") || query(".comment-body");
1797         sampleText.addClass("body-text");
1798         sampleText.style.color = bodyTextElement ? 
1799                 getComputedStyle(bodyTextElement).color : 
1800                 getComputedStyle(query("#content")).color;
1802         // Here we find out what is the actual background color that will be visible behind
1803         // the body text of posts, and set the sample text’s background to that.
1804         let findStyleBackground = (selector) => {
1805                 let x;
1806                 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
1807                         if(rule.selectorText == selector)
1808                                 x = rule;
1809                 });
1810                 return x.style.backgroundColor;
1811         };
1813         sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
1816 /*********************/
1817 /* PAGE QUICK-NAV UI */
1818 /*********************/
1820 function injectQuickNavUI() {
1821         GWLog("injectQuickNavUI");
1822         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1823         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
1824         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
1825         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
1826         ` + "</div>");
1829 /**********************/
1830 /* NEW COMMENT NAV UI */
1831 /**********************/
1833 function injectNewCommentNavUI(newCommentsCount) {
1834         GWLog("injectNewCommentNavUI");
1835         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
1836         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
1837         <span class='new-comments-count'></span>
1838         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
1839         + "</div>");
1841         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1842                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1843                         GWLog("GW.commentQuicknavButtonClicked");
1844                         scrollToNewComment(/next/.test(event.target.className));
1845                         event.target.blur();
1846                 });
1847         });
1849         document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => { 
1850                 GWLog("GW.commentQuicknavKeyPressed");
1851                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1852                 if (event.key == ",") scrollToNewComment(false);
1853                 if (event.key == ".") scrollToNewComment(true)
1854         });
1856         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1857         + `<span>Since:</span>`
1858         + `<input type='text' class='hns-date'></input>`
1859         + "</div>");
1861         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1862                 GWLog("GW.hnsDatePickerValueChanged");
1863                 let hnsDate = time_fromHuman(event.target.value);
1864                 if(hnsDate) {
1865                         setHistoryLastVisitedDate(hnsDate);
1866                         let newCommentsCount = highlightCommentsSince(hnsDate);
1867                         updateNewCommentNavUI(newCommentsCount);
1868                 }
1869         }, false);
1871         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1872                 GWLog("GW.newCommentsCountClicked");
1873                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1874                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1875         });
1878 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1879 function time_fromHuman(string) {
1880         /* Convert a human-readable date into a JS timestamp */
1881         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1882                 string = string.replace(' ', 'T');  // revert nice spacing
1883                 string += ':00.000Z';  // complete ISO 8601 date
1884                 time = Date.parse(string);  // milliseconds since epoch
1886                 // browsers handle ISO 8601 without explicit timezone differently
1887                 // thus, we have to fix that by hand
1888                 time += (new Date()).getTimezoneOffset() * 60e3;
1889         } else {
1890                 string = string.replace(' at', '');
1891                 time = Date.parse(string);  // milliseconds since epoch
1892         }
1893         return time;
1896 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1897         GWLog("updateNewCommentNavUI");
1898         // Update the new comments count.
1899         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1900         newCommentsCountLabel.innerText = newCommentsCount;
1901         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1903         // Update the date picker field.
1904         if (hnsDate != -1) {
1905                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1906         }
1909 /***************************/
1910 /* TEXT SIZE ADJUSTMENT UI */
1911 /***************************/
1913 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1914         GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1915         var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1916         if (event.target.hasClass("decrease")) {
1917                 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1918         } else if (event.target.hasClass("increase")) {
1919                 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1920         } else {
1921                 zoomFactor = 1.0;
1922         }
1923         setTextZoom(zoomFactor);
1924         GW.currentTextZoom = `${zoomFactor}`;
1926         if (event.target.parentElement.id == "text-size-adjustment-ui") {
1927                 localStorage.setItem("text-zoom", GW.currentTextZoom);
1928         }
1931 function injectTextSizeAdjustmentUIReal() {
1932         GWLog("injectTextSizeAdjustmentUIReal");
1933         let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1934         + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
1935         + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1936         + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
1937         + "</div>");
1939         textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1940                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1941         });
1943         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1946 function injectTextSizeAdjustmentUI() {
1947         GWLog("injectTextSizeAdjustmentUI");
1948         if (query("#text-size-adjustment-ui") != null) return;
1949         if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1950         else document.addEventListener("DOMContentLoaded", () => {
1951                 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1952         }, {once: true});
1955 /********************************/
1956 /* COMMENTS VIEW MODE SELECTION */
1957 /********************************/
1959 function injectCommentsViewModeSelector() {
1960         GWLog("injectCommentsViewModeSelector");
1961         let commentsContainer = query("#comments");
1962         if (commentsContainer == null) return;
1964         let currentModeThreaded = (location.href.search("chrono=t") == -1);
1965         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1967         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1968         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
1969         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
1970         + "</div>");
1972 //      commentsViewModeSelector.queryAll("a").forEach(button => {
1973 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1974 //      });
1976         if (!currentModeThreaded) {
1977                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1978                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1979                         commentParentLink.addClass("inline-author");
1980                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1981                 });
1983                 queryAll(".comment-child-links a").forEach(commentChildLink => {
1984                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
1985                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
1986                 });
1988                 rectifyChronoModeCommentChildLinks();
1990                 commentsContainer.addClass("chrono");
1991         } else {
1992                 commentsContainer.addClass("threaded");
1993         }
1995         // Remove extraneous top-level comment thread in chrono mode.
1996         let topLevelCommentThread = query("#comments > .comment-thread");
1997         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2000 // function commentsViewModeSelectorButtonClicked(event) {
2001 //      event.preventDefault();
2002 // 
2003 //      var newDocument;
2004 //      let request = new XMLHttpRequest();
2005 //      request.open("GET", event.target.href);
2006 //      request.onreadystatechange = () => {
2007 //              if (request.readyState != 4) return;
2008 //              newDocument = htmlToElement(request.response);
2009 // 
2010 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2011 // 
2012 //              // Update the buttons.
2013 //              event.target.addClass("selected");
2014 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
2015 // 
2016 //              // Update the #comments container.
2017 //              let commentsContainer = query("#comments");
2018 //              commentsContainer.removeClass(classes.old);
2019 //              commentsContainer.addClass(classes.new);
2020 // 
2021 //              // Update the content.
2022 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2023 //      };
2024 //      request.send();
2025 // }
2026 // 
2027 // function htmlToElement(html) {
2028 //     var template = document.createElement('template');
2029 //     template.innerHTML = html.trim();
2030 //     return template.content;
2031 // }
2033 function rectifyChronoModeCommentChildLinks() {
2034         GWLog("rectifyChronoModeCommentChildLinks");
2035         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2036                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2037                 let childLinks = commentChildLinksContainer.queryAll("a");
2038                 childLinks.forEach((link, index) => {
2039                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2040                 });
2042                 // Sort by date.
2043                 let childLinksArray = Array.from(childLinks)
2044                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2045                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2046         });
2048 function childrenOfComment(commentID) {
2049         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2050                 let commentParentLink = commentItem.query("a.comment-parent-link");
2051                 return ((commentParentLink||{}).hash == "#" + commentID);
2052         });
2055 /********************************/
2056 /* COMMENTS LIST MODE SELECTION */
2057 /********************************/
2059 function injectCommentsListModeSelector() {
2060         GWLog("injectCommentsListModeSelector");
2061         if (query("#content > .comment-thread") == null) return;
2063         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2064         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2065         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2066         + "</div>";
2068         if (query(".sublevel-nav") || query("#top-nav-bar")) {
2069                 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2070         } else {
2071                 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2072         }
2073         let commentsListModeSelector = query("#comments-list-mode-selector");
2075         commentsListModeSelector.queryAll("button").forEach(button => {
2076                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2077                         GWLog("GW.commentsListModeSelectButtonClicked");
2078                         event.target.parentElement.queryAll("button").forEach(button => {
2079                                 button.removeClass("selected");
2080                                 button.disabled = false;
2081                                 button.accessKey = '`';
2082                         });
2083                         localStorage.setItem("comments-list-mode", event.target.className);
2084                         event.target.addClass("selected");
2085                         event.target.disabled = true;
2086                         event.target.removeAttribute("accesskey");
2088                         if (event.target.hasClass("expanded")) {
2089                                 query("#content").removeClass("compact");
2090                         } else {
2091                                 query("#content").addClass("compact");
2092                         }
2093                 });
2094         });
2096         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2097         if (savedMode == "compact")
2098                 query("#content").addClass("compact");
2099         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2100         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2101         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2103         if (GW.isMobile) {
2104                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2105                         commentParentLink.addActivateEvent(function (event) {
2106                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2107                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2108                         }, false);
2109                 });
2110         }
2113 /**********************/
2114 /* SITE NAV UI TOGGLE */
2115 /**********************/
2117 function injectSiteNavUIToggle() {
2118         GWLog("injectSiteNavUIToggle");
2119         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
2120         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2121                 GWLog("GW.siteNavUIToggleButtonClicked");
2122                 toggleSiteNavUI();
2123                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2124         });
2126         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2128 function removeSiteNavUIToggle() {
2129         GWLog("removeSiteNavUIToggle");
2130         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2131                 element.removeClass("engaged");
2132         });
2133         removeElement("#site-nav-ui-toggle");
2135 function toggleSiteNavUI() {
2136         GWLog("toggleSiteNavUI");
2137         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2138                 element.toggleClass("engaged");
2139                 element.removeClass("translucent-on-scroll");
2140         });
2143 /**********************/
2144 /* POST NAV UI TOGGLE */
2145 /**********************/
2147 function injectPostNavUIToggle() {
2148         GWLog("injectPostNavUIToggle");
2149         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
2150         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2151                 GWLog("GW.postNavUIToggleButtonClicked");
2152                 togglePostNavUI();
2153                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2154         });
2156         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2158 function removePostNavUIToggle() {
2159         GWLog("removePostNavUIToggle");
2160         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2161                 element.removeClass("engaged");
2162         });
2163         removeElement("#post-nav-ui-toggle");
2165 function togglePostNavUI() {
2166         GWLog("togglePostNavUI");
2167         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2168                 element.toggleClass("engaged");
2169         });
2172 /*******************************/
2173 /* APPEARANCE ADJUST UI TOGGLE */
2174 /*******************************/
2176 function injectAppearanceAdjustUIToggle() {
2177         GWLog("injectAppearanceAdjustUIToggle");
2178         let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'>&#xf013;</button></div>");
2179         appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
2180                 GWLog("GW.appearanceAdjustUIToggleButtonClicked");
2181                 toggleAppearanceAdjustUI();
2182                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
2183         });
2185         if (GW.isMobile) {
2186                 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
2187                 themeSelectorCloseButton.addClass("theme-selector-close-button");
2188                 themeSelectorCloseButton.innerHTML = "&#xf057;";
2189                 query("#theme-selector").appendChild(themeSelectorCloseButton);
2190                 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
2191         } else {
2192                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
2193         }
2195 function removeAppearanceAdjustUIToggle() {
2196         GWLog("removeAppearanceAdjustUIToggle");
2197         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2198                 element.removeClass("engaged");
2199         });
2200         removeElement("#appearance-adjust-ui-toggle");
2202 function toggleAppearanceAdjustUI() {
2203         GWLog("toggleAppearanceAdjustUI");
2204         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2205                 element.toggleClass("engaged");
2206         });
2209 /**************************/
2210 /* WORD COUNT & READ TIME */
2211 /**************************/
2213 function toggleReadTimeOrWordCount(addWordCountClass) {
2214         GWLog("toggleReadTimeOrWordCount");
2215         queryAll(".post-meta .read-time").forEach(element => {
2216                 if (addWordCountClass) element.addClass("word-count");
2217                 else element.removeClass("word-count");
2219                 let titleParts = /(\S+)(.+)$/.exec(element.title);
2220                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2221         });
2224 /**************************/
2225 /* PROMPT TO SAVE CHANGES */
2226 /**************************/
2228 function enableBeforeUnload() {
2229         window.onbeforeunload = function () { return true; };
2231 function disableBeforeUnload() {
2232         window.onbeforeunload = null;
2235 /***************************/
2236 /* ORIGINAL POSTER BADGING */
2237 /***************************/
2239 function markOriginalPosterComments() {
2240         GWLog("markOriginalPosterComments");
2241         let postAuthor = query(".post .author");
2242         if (postAuthor == null) return;
2244         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2245                 if (author.dataset.userid == postAuthor.dataset.userid ||
2246                         (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2247                         author.addClass("original-poster");
2248                         author.title += "Original poster";
2249                 }
2250         });
2253 /********************************/
2254 /* EDIT POST PAGE SUBMIT BUTTON */
2255 /********************************/
2257 function setEditPostPageSubmitButtonText() {
2258         GWLog("setEditPostPageSubmitButtonText");
2259         if (!query("#content").hasClass("edit-post-page")) return;
2261         queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2262                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2263                         GWLog("GW.postSectionSelectorValueChanged");
2264                         updateEditPostPageSubmitButtonText();
2265                 });
2266         });
2268         updateEditPostPageSubmitButtonText();
2270 function updateEditPostPageSubmitButtonText() {
2271         GWLog("updateEditPostPageSubmitButtonText");
2272         let submitButton = query("input[type='submit']");
2273         if (query("input#drafts").checked == true) 
2274                 submitButton.value = "Save Draft";
2275         else if (query(".posting-controls").hasClass("edit-existing-post"))
2276                 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2277         else
2278                 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2281 /*****************/
2282 /* ANTI-KIBITZER */
2283 /*****************/
2285 function numToAlpha(n) {
2286         let ret = "";
2287         do {
2288                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2289                 n = Math.floor((n / 26) - 1);
2290         } while (n >= 0);
2291         return ret;
2294 function injectAntiKibitzer() {
2295         GWLog("injectAntiKibitzer");
2296         // Inject anti-kibitzer toggle controls.
2297         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>");
2298         antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2299                 GWLog("GW.antiKibitzerToggleButtonClicked");
2300                 if (query("#anti-kibitzer-toggle").hasClass("engaged") && 
2301                         !event.shiftKey &&
2302                         !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!)")) {
2303                         event.target.blur();
2304                         return;
2305                 }
2307                 toggleAntiKibitzerMode();
2308                 event.target.blur();
2309         });
2311         // Activate anti-kibitzer mode (if needed).
2312         if (localStorage.getItem("antikibitzer") == "true")
2313                 toggleAntiKibitzerMode();
2315         // Remove temporary CSS that hides the authors and karma values.
2316         removeElement("#antikibitzer-temp");
2319 function toggleAntiKibitzerMode() {
2320         GWLog("toggleAntiKibitzerMode");
2321         // This will be the URL of the user's own page, if logged in, or the URL of
2322         // the login page otherwise.
2323         let userTabTarget = query("#nav-item-login .nav-inner").href;
2324         let pageHeadingElement = query("h1.page-main-heading");
2326         let userCount = 0;
2327         let userFakeName = { };
2329         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2331         let postAuthor = query(".post-page .post-meta .author");
2332         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2334         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2335         if (antiKibitzerToggle.hasClass("engaged")) {
2336                 localStorage.setItem("antikibitzer", "false");
2338                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2339                 if (redirectTarget) {
2340                         window.location = redirectTarget;
2341                         return;
2342                 }
2344                 // Individual comment page title and header
2345                 if (query(".individual-thread-page")) {
2346                         let replacer = (node) => {
2347                                 if (!node) return;
2348                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
2349                         }
2350                         replacer(query("title:not(.fake-title)"));
2351                         replacer(query("#content > h1"));
2352                 }
2354                 // Author names/links.
2355                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2356                         author.textContent = author.dataset["trueName"];
2357                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2359                         author.removeClass("redacted");
2360                 });
2361                 // Post/comment karma values.
2362                 queryAll(".karma-value.redacted").forEach(karmaValue => {
2363                         karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2365                         karmaValue.removeClass("redacted");
2366                 });
2367                 // Link post domains.
2368                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2369                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2371                         linkPostDomain.removeClass("redacted");
2372                 });
2374                 antiKibitzerToggle.removeClass("engaged");
2375         } else {
2376                 localStorage.setItem("antikibitzer", "true");
2378                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2379                 if (redirectTarget) {
2380                         window.location = redirectTarget;
2381                         return;
2382                 }
2384                 // Individual comment page title and header
2385                 if (query(".individual-thread-page")) {
2386                         let replacer = (node) => {
2387                                 if (!node) return;
2388                                 node.dataset["trueContent"] = node.firstChild.wholeText;
2389                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2390                                 node.firstChild.replaceWith(newText);
2391                         }
2392                         replacer(query("title:not(.fake-title)"));
2393                         replacer(query("#content > h1"));
2394                 }
2396                 removeElement("title.fake-title");
2398                 // Author names/links.
2399                 queryAll(".author, .inline-author").forEach(author => {
2400                         // Skip own posts/comments.
2401                         if (author.hasClass("own-user-author"))
2402                                 return;
2404                         let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2406                         if(!userid) return;
2408                         author.dataset["trueName"] = author.textContent;
2409                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2411                         if (/\/user/.test(author.href)) {
2412                                 author.dataset["trueLink"] = author.pathname;
2413                                 author.href = "/user?id=" + author.dataset["userid"];
2414                         }
2416                         author.addClass("redacted");
2417                 });
2418                 // Post/comment karma values.
2419                 queryAll(".karma-value").forEach(karmaValue => {
2420                         // Skip own posts/comments.
2421                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2422                                 return;
2424                         karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2425                         karmaValue.innerHTML = "##<span> points</span>";
2427                         karmaValue.addClass("redacted");
2428                 });
2429                 // Link post domains.
2430                 queryAll(".link-post-domain").forEach(linkPostDomain => {
2431                         // Skip own posts/comments.
2432                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2433                                 return;
2435                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2436                         linkPostDomain.textContent = "redacted.domain.tld";
2438                         linkPostDomain.addClass("redacted");
2439                 });
2441                 antiKibitzerToggle.addClass("engaged");
2442         }
2445 /*******************************/
2446 /* COMMENT SORT MODE SELECTION */
2447 /*******************************/
2449 var CommentSortMode = Object.freeze({
2450         TOP:            "top",
2451         NEW:            "new",
2452         OLD:            "old",
2453         HOT:            "hot"
2455 function sortComments(mode) {
2456         GWLog("sortComments");
2457         let commentsContainer = query("#comments");
2459         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2460         commentsContainer.addClass("sorting");
2462         GW.commentValues = { };
2463         let clonedCommentsContainer = commentsContainer.cloneNode(true);
2464         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2465                 var comparator;
2466                 switch (mode) {
2467                 case CommentSortMode.NEW:
2468                         comparator = (a,b) => commentDate(b) - commentDate(a);
2469                         break;
2470                 case CommentSortMode.OLD:
2471                         comparator = (a,b) => commentDate(a) - commentDate(b);
2472                         break;
2473                 case CommentSortMode.HOT:
2474                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2475                         break;
2476                 case CommentSortMode.TOP:
2477                 default:
2478                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2479                         break;
2480                 }
2481                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2482         });
2483         removeElement(commentsContainer.lastChild);
2484         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2485         GW.commentValues = { };
2487         if (loggedInUserId) {
2488                 // Re-activate vote buttons.
2489                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2490                         voteButton.addActivateEvent(voteButtonClicked);
2491                 });
2493                 // Re-activate comment action buttons.
2494                 commentsContainer.queryAll(".action-button").forEach(button => {
2495                         button.addActivateEvent(GW.commentActionButtonClicked);
2496                 });
2497         }
2499         // Re-activate comment-minimize buttons.
2500         queryAll(".comment-minimize-button").forEach(button => {
2501                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2502         });
2504         // Re-add comment parent popups.
2505         addCommentParentPopups();
2506         
2507         // Redo new-comments highlighting.
2508         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2510         requestAnimationFrame(() => {
2511                 commentsContainer.removeClass("sorting");
2512                 commentsContainer.addClass("sorted-" + mode);
2513         });
2515 function commentKarmaValue(commentOrSelector) {
2516         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2517         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2519 function commentDate(commentOrSelector) {
2520         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2521         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2523 function commentVoteCount(commentOrSelector) {
2524         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2525         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2528 function injectCommentsSortModeSelector() {
2529         GWLog("injectCommentsSortModeSelector");
2530         let topCommentThread = query("#comments > .comment-thread");
2531         if (topCommentThread == null) return;
2533         // Do not show sort mode selector if there is no branching in comment tree.
2534         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2536         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
2537                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
2538                 "</div>";
2539         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2540         let commentsSortModeSelector = query("#comments-sort-mode-selector");
2542         commentsSortModeSelector.queryAll("button").forEach(button => {
2543                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2544                         GWLog("GW.commentsSortModeSelectButtonClicked");
2545                         event.target.parentElement.queryAll("button").forEach(button => {
2546                                 button.removeClass("selected");
2547                                 button.disabled = false;
2548                         });
2549                         event.target.addClass("selected");
2550                         event.target.disabled = true;
2552                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2553                         setCommentsSortModeSelectButtonsAccesskey();
2554                 });
2555         });
2557         // TODO: Make this actually get the current sort mode (if that's saved).
2558         // TODO: Also change the condition here to properly get chrono/threaded mode,
2559         // when that is properly done with cookies.
2560         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2561         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2562         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2563         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2564         setCommentsSortModeSelectButtonsAccesskey();
2567 function setCommentsSortModeSelectButtonsAccesskey() {
2568         GWLog("setCommentsSortModeSelectButtonsAccesskey");
2569         queryAll("#comments-sort-mode-selector button").forEach(button => {
2570                 button.removeAttribute("accesskey");
2571                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2572         });
2573         let selectedButton = query("#comments-sort-mode-selector button.selected");
2574         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2575         nextButtonInCycle.accessKey = "z";
2576         nextButtonInCycle.title += " [z]";
2579 /*************************/
2580 /* COMMENT PARENT POPUPS */
2581 /*************************/
2583 function previewPopupsEnabled() {
2584         let isDisabled = localStorage.getItem("preview-popups-disabled");
2585         return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
2588 function setPreviewPopupsEnabled(state) {
2589         localStorage.setItem("preview-popups-disabled", !state);
2590         updatePreviewPopupToggle();
2593 function updatePreviewPopupToggle() {
2594         let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
2595         query("#preview-popup-toggle").setAttribute("style", style);
2598 function injectPreviewPopupToggle() {
2599         GWLog("injectPreviewPopupToggle");
2601         let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
2602         // This is required because Chrome can't use filters on an externally used SVG element.
2603         fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
2604         updatePreviewPopupToggle();
2605         toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
2608 var currentPreviewPopup = { };
2610 function removePreviewPopup(previewPopup) {
2611         if(previewPopup.element)
2612                 removeElement(previewPopup.element);
2614         if(previewPopup.timeout)
2615                 clearTimeout(previewPopup.timeout);
2617         if(currentPreviewPopup.pointerListener)
2618                 window.removeEventListener("pointermove", previewPopup.pointerListener);
2620         if(currentPreviewPopup.mouseoutListener)
2621                 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2623         if(currentPreviewPopup.scrollListener)
2624                 window.removeEventListener("scroll", previewPopup.scrollListener);
2626         currentPreviewPopup = { };
2629 function addCommentParentPopups() {
2630         GWLog("addCommentParentPopups");
2631         //if (!query("#content").hasClass("comment-thread-page")) return;
2633         queryAll("a[href]").forEach(linkTag => {
2634                 let linkHref = linkTag.getAttribute("href");
2636                 let url;
2637                 try { url = new URL(linkHref, window.location.href); }
2638                 catch(e) { }
2639                 if(!url) return;
2641                 if(GW.sites[url.host]) {
2642                         let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
2643                         
2644                         if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
2645                                 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
2646                                         if(event.pointerType == "touch") return;
2647                                         GWLog("GW.commentParentLinkMouseOver");
2648                                         removePreviewPopup(currentPreviewPopup);
2649                                         let parentID = linkHref;
2650                                         var parent, popup;
2651                                         if (!(parent = (query(parentID)||{}).firstChild)) return;
2652                                         var highlightClassName;
2653                                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2654                                                 parentHighlightClassName = "comment-item-highlight-faint";
2655                                                 popup = parent.cloneNode(true);
2656                                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2657                                                 linkTag.addEventListener("mouseout", (event) => {
2658                                                         removeElement(popup);
2659                                                 }, {once: true});
2660                                                 linkTag.closest(".comments > .comment-thread").appendChild(popup);
2661                                         } else {
2662                                                 parentHighlightClassName = "comment-item-highlight";
2663                                         }
2664                                         parent.parentNode.addClass(parentHighlightClassName);
2665                                         linkTag.addEventListener("mouseout", (event) => {
2666                                                 parent.parentNode.removeClass(parentHighlightClassName);
2667                                         }, {once: true});
2668                                 });
2669                         }
2670                         else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
2671                                 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
2672                                 && !(url.searchParams.get('format'))
2673                                 && !linkTag.closest("nav:not(.post-nav-links)")
2674                                 && (!url.hash || linkCommentId)
2675                                 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
2676                                 linkTag.addEventListener("pointerover", event => {
2677                                         if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
2678                                         if(currentPreviewPopup.linkTag) return;
2679                                         linkTag.createPreviewPopup();
2680                                 });
2681                                 linkTag.createPreviewPopup = function() {
2682                                         removePreviewPopup(currentPreviewPopup);
2684                                         currentPreviewPopup = {linkTag: linkTag};
2685                                         
2686                                         let popup = document.createElement("iframe");
2687                                         currentPreviewPopup.element = popup;
2689                                         let popupTarget = linkHref;
2690                                         if(popupTarget.match(/#comment-/)) {
2691                                                 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
2692                                         }
2693                                         // 'theme' attribute is required for proper caching
2694                                         popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
2695                                         popup.addClass("preview-popup");
2696                                         
2697                                         let linkRect = linkTag.getBoundingClientRect();
2699                                         if(linkRect.right + 710 < window.innerWidth)
2700                                                 popup.style.left = linkRect.right + 10 + "px";
2701                                         else
2702                                                 popup.style.right = "10px";
2704                                         popup.style.width = "700px";
2705                                         popup.style.height = "500px";
2706                                         popup.style.visibility = "hidden";
2707                                         popup.style.transition = "none";
2709                                         let recenter = function() {
2710                                                 let popupHeight = 500;
2711                                                 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
2712                                                         let popupContent = popup.contentDocument.querySelector("#content");
2713                                                         if(popupContent) {
2714                                                                 popupHeight = popupContent.clientHeight + 2;
2715                                                                 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
2716                                                                 popup.style.height = popupHeight + "px";
2717                                                         }
2718                                                 }
2719                                                 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
2720                                         }
2722                                         recenter();
2724                                         query('#content').insertAdjacentElement("beforeend", popup);
2726                                         let clickListener = event => {
2727                                                 if(!event.target.closest("a, input, label")
2728                                                    && !event.target.closest("popup-hide-button")) {
2729                                                         window.location = linkHref;
2730                                                 }
2731                                         };
2733                                         popup.addEventListener("load", () => {
2734                                                 let hideButton = popup.contentDocument.createElement("div");
2735                                                 hideButton.className = "popup-hide-button";
2736                                                 hideButton.insertAdjacentText('beforeend', "\uF070");
2737                                                 hideButton.onclick = (event) => {
2738                                                         removePreviewPopup(currentPreviewPopup);
2739                                                         setPreviewPopupsEnabled(false);
2740                                                         event.stopPropagation();
2741                                                 }
2742                                                 popup.contentDocument.body.appendChild(hideButton);
2743                                                 
2744                                                 let body = popup.contentDocument.body;
2745                                                 body.addEventListener("click", clickListener);
2746                                                 body.style.cursor = "pointer";
2748                                                 recenter();
2749                                         });
2751                                         popup.contentDocument.body.addEventListener("click", clickListener);
2752                                         
2753                                         currentPreviewPopup.timeout = setTimeout(() => {
2754                                                 recenter();
2756                                                 requestIdleCallback(() => {
2757                                                         if(currentPreviewPopup.element === popup) {
2758                                                                 popup.scrolling = "";
2759                                                                 popup.style.visibility = "unset";
2760                                                                 popup.style.transition = null;
2762                                                                 popup.animate([
2763                                                                         { opacity: 0, transform: "translateY(10%)" },
2764                                                                         { opacity: 1, transform: "none" }
2765                                                                 ], { duration: 150, easing: "ease-out" });
2766                                                         }
2767                                                 });
2768                                         }, 1000);
2770                                         let pointerX, pointerY, mousePauseTimeout = null;
2772                                         currentPreviewPopup.pointerListener = (event) => {
2773                                                 pointerX = event.clientX;
2774                                                 pointerY = event.clientY;
2776                                                 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
2777                                                 mousePauseTimeout = null;
2779                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
2780                                                 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
2782                                                 if(mouseIsOverLink || overElement === popup
2783                                                    || (pointerX < popup.getBoundingClientRect().left
2784                                                        && event.movementX >= 0)) {
2785                                                         if(!mouseIsOverLink && overElement !== popup) {
2786                                                                 if(overElement['createPreviewPopup']) {
2787                                                                         mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
2788                                                                 } else {
2789                                                                         mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
2790                                                                 }
2791                                                         }
2792                                                 } else {
2793                                                         removePreviewPopup(currentPreviewPopup);
2794                                                         if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
2795                                                 }
2796                                         };
2797                                         window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
2799                                         currentPreviewPopup.mouseoutListener = (event) => {
2800                                                 clearTimeout(mousePauseTimeout);
2801                                                 mousePauseTimeout = null;
2802                                         }
2803                                         document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2805                                         currentPreviewPopup.scrollListener = (event) => {
2806                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
2807                                                 linkRect = linkTag.getBoundingClientRect();
2808                                                 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
2809                                                 removePreviewPopup(currentPreviewPopup);
2810                                         };
2811                                         window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
2812                                 };
2813                         }
2814                 }
2815         });
2816         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2817                 
2818         });
2820         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2821         GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2822                 "#content .comments .comment-thread"
2823         ];
2824         applyFilters(GW.currentFilters);
2827 /***************/
2828 /* IMAGE FOCUS */
2829 /***************/
2831 function imageFocusSetup(imagesOverlayOnly = false) {
2832         if (typeof GW.imageFocus == "undefined")
2833                 GW.imageFocus = {
2834                         contentImagesSelector:  "#content img",
2835                         overlayImagesSelector:  "#images-overlay img",
2836                         focusedImageSelector:   "#content img.focused, #images-overlay img.focused",
2837                         pageContentSelector:    "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
2838                         shrinkRatio:                    0.975,
2839                         hideUITimerDuration:    1500,
2840                         hideUITimerExpired:             () => {
2841                                 GWLog("GW.imageFocus.hideUITimerExpired");
2842                                 let currentTime = new Date();
2843                                 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
2844                                 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
2845                                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
2846                                 } else {
2847                                         hideImageFocusUI();
2848                                         cancelImageFocusHideUITimer();
2849                                 }
2850                         }
2851                 };
2853         GWLog("imageFocusSetup");
2854         // Create event listener for clicking on images to focus them.
2855         GW.imageClickedToFocus = (event) => {
2856                 GWLog("GW.imageClickedToFocus");
2857                 focusImage(event.target);
2859                 if (!GW.isMobile) {
2860                         // Set timer to hide the image focus UI.
2861                         unhideImageFocusUI();
2862                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
2863                 }
2864         };
2865         // Add the listener to each image in the overlay (i.e., those in the post).
2866         queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
2867                 image.addActivateEvent(GW.imageClickedToFocus);
2868         });
2869         // Accesskey-L starts the slideshow.
2870         (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
2871         // Count how many images there are in the post, and set the "… of X" label to that.
2872         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
2873         if (imagesOverlayOnly) return;
2874         // Add the listener to all other content images (including those in comments).
2875         queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
2876                 image.addActivateEvent(GW.imageClickedToFocus);
2877         });
2879         // Create the image focus overlay.
2880         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
2881         `<div class='help-overlay'>
2882                  <p><strong>Arrow keys:</strong> Next/previous image</p>
2883                  <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2884                  <p><strong>Space bar:</strong> Reset image size & position</p>
2885                  <p><strong>Scroll</strong> to zoom in/out</p>
2886                  <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2887         </div>
2888         <div class='image-number'></div>
2889         <div class='slideshow-buttons'>
2890                  <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
2891                  <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
2892         </div>
2893         <div class='caption'></div>` + 
2894         "</div>");
2895         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2897         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2898                 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
2899                         GWLog("GW.imageFocus.slideshowButtonClicked");
2900                         focusNextImage(event.target.hasClass("next"));
2901                         event.target.blur();
2902                 });
2903         });
2905         // On orientation change, reset the size & position.
2906         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2907                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2908         }
2910         // UI starts out hidden.
2911         hideImageFocusUI();
2914 function focusImage(imageToFocus) {
2915         GWLog("focusImage");
2916         // Clear 'last-focused' class of last focused image.
2917         let lastFocusedImage = query("img.last-focused");
2918         if (lastFocusedImage) {
2919                 lastFocusedImage.removeClass("last-focused");
2920                 lastFocusedImage.removeAttribute("accesskey");
2921         }
2923         // Create the focused version of the image.
2924         imageToFocus.addClass("focused");
2925         let imageFocusOverlay = query("#image-focus-overlay");
2926         let clonedImage = imageToFocus.cloneNode(true);
2927         clonedImage.style = "";
2928         clonedImage.removeAttribute("width");
2929         clonedImage.removeAttribute("height");
2930         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2931         imageFocusOverlay.appendChild(clonedImage);
2932         imageFocusOverlay.addClass("engaged");
2934         // Set image to default size and position.
2935         resetFocusedImagePosition();
2937         // Blur everything else.
2938         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
2939                 element.addClass("blurred");
2940         });
2942         // Add listener to zoom image with scroll wheel.
2943         window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
2944                 GWLog("GW.imageFocus.scrollEvent");
2945                 event.preventDefault();
2947                 let image = query("#image-focus-overlay img");
2949                 // Remove the filter.
2950                 image.savedFilter = image.style.filter;
2951                 image.style.filter = 'none';
2953                 // Locate point under cursor.
2954                 let imageBoundingBox = image.getBoundingClientRect();
2956                 // Calculate resize factor.
2957                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2958                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2959                                                 1;
2961                 // Resize.
2962                 image.style.width = (event.deltaY < 0 ?
2963                                                         (image.clientWidth * factor) :
2964                                                         (image.clientWidth / factor))
2965                                                         + "px";
2966                 image.style.height = "";
2968                 // Designate zoom origin.
2969                 var zoomOrigin;
2970                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2971                 // the cursor is over the image.
2972                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2973                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2974                                                                 (imageBoundingBox.left <= event.clientX &&
2975                                                                  event.clientX <= imageBoundingBox.right && 
2976                                                                  imageBoundingBox.top <= event.clientY &&
2977                                                                  event.clientY <= imageBoundingBox.bottom);
2978                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
2979                 // zooming IN, zoom from image center.
2980                 let zoomingFromWindowCenter = event.deltaY > 0;
2981                 if (zoomingFromCursor)
2982                         zoomOrigin = { x: event.clientX, 
2983                                                    y: event.clientY };
2984                 else if (zoomingFromWindowCenter)
2985                         zoomOrigin = { x: window.innerWidth / 2, 
2986                                                    y: window.innerHeight / 2 };
2987                 else
2988                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
2989                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
2991                 // Calculate offset from zoom origin.
2992                 let offsetOfImageFromZoomOrigin = {
2993                         x: imageBoundingBox.x - zoomOrigin.x,
2994                         y: imageBoundingBox.y - zoomOrigin.y
2995                 }
2996                 // Calculate delta from centered zoom.
2997                 let deltaFromCenteredZoom = {
2998                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
2999                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3000                 }
3001                 // Adjust image position appropriately.
3002                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3003                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3004                 // Gradually re-center image, if it's smaller than the window.
3005                 if (!imageSizeExceedsWindowBounds) {
3006                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
3007                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3008                         let windowCenter = { x: window.innerWidth / 2,
3009                                                                  y: window.innerHeight / 2 }
3010                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3011                                                                                   y: windowCenter.y - imageCenter.y }
3012                         // Divide the offset by 10 because we're nudging the image toward center,
3013                         // not jumping it there.
3014                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3015                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3016                 }
3018                 // Put the filter back.
3019                 image.style.filter = image.savedFilter;
3021                 // Set the cursor appropriately.
3022                 setFocusedImageCursor();
3023         });
3024         window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3025                 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3026                 event.preventDefault();
3027         });
3029         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3030         window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3031                 GWLog("GW.imageFocus.mouseUp");
3032                 window.onmousemove = '';
3034                 // We only want to do anything on left-clicks.
3035                 if (event.button != 0) return;
3037                 // Don't unfocus if click was on a slideshow next/prev button!
3038                 if (event.target.hasClass("slideshow-button")) return;
3040                 // We also don't want to do anything if clicked on the help overlay.
3041                 if (event.target.classList.contains("help-overlay") ||
3042                         event.target.closest(".help-overlay"))
3043                         return;
3045                 let focusedImage = query("#image-focus-overlay img");
3046                 if (event.target == focusedImage && 
3047                         (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3048                         // If the mouseup event was the end of a pan of an overside image,
3049                         // put the filter back; do not unfocus.
3050                         focusedImage.style.filter = focusedImage.savedFilter;
3051                 } else {
3052                         unfocusImageOverlay();
3053                         return;
3054                 }
3055         });
3056         window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3057                 GWLog("GW.imageFocus.mouseDown");
3058                 event.preventDefault();
3060                 let focusedImage = query("#image-focus-overlay img");
3061                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3062                         let mouseCoordX = event.clientX;
3063                         let mouseCoordY = event.clientY;
3065                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3066                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3068                         // Save the filter.
3069                         focusedImage.savedFilter = focusedImage.style.filter;
3071                         window.onmousemove = (event) => {
3072                                 // Remove the filter.
3073                                 focusedImage.style.filter = "none";
3074                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3075                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3076                         };
3077                         return false;
3078                 }
3079         });
3081         // Double-click on the image unfocuses.
3082         clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3083                 GWLog("GW.imageFocus.doubleClick");
3084                 if (event.target.hasClass("slideshow-button")) return;
3086                 unfocusImageOverlay();
3087         });
3089         // Escape key unfocuses, spacebar resets.
3090         document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3091                 GWLog("GW.imageFocus.keyUp");
3092                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3093                 if (!allowedKeys.contains(event.key) || 
3094                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3096                 event.preventDefault();
3098                 switch (event.key) {
3099                 case "Escape": 
3100                 case "Esc":
3101                         unfocusImageOverlay();
3102                         break;
3103                 case " ":
3104                 case "Spacebar":
3105                         resetFocusedImagePosition();
3106                         break;
3107                 case "ArrowDown":
3108                 case "Down":
3109                 case "ArrowRight":
3110                 case "Right":
3111                         if (query("#images-overlay img.focused")) focusNextImage(true);
3112                         break;
3113                 case "ArrowUp":
3114                 case "Up":
3115                 case "ArrowLeft":
3116                 case "Left":
3117                         if (query("#images-overlay img.focused")) focusNextImage(false);
3118                         break;
3119                 }
3120         });
3122         // Prevent spacebar or arrow keys from scrolling page when image focused.
3123         togglePageScrolling(false);
3125         // If the image comes from the images overlay, for the main post...
3126         if (imageToFocus.closest("#images-overlay")) {
3127                 // Mark the overlay as being in slide show mode (to show buttons/count).
3128                 imageFocusOverlay.addClass("slideshow");
3130                 // Set state of next/previous buttons.
3131                 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3132                 var indexOfFocusedImage = getIndexOfFocusedImage();
3133                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3134                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3136                 // Set the image number.
3137                 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3139                 // Replace the hash.
3140                 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3141         } else {
3142                 imageFocusOverlay.removeClass("slideshow");
3143         }
3145         // Set the caption.
3146         setImageFocusCaption();
3148         // Moving mouse unhides image focus UI.
3149         window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3150                 GWLog("GW.imageFocus.mouseMoved");
3151                 let currentDateTime = new Date();
3152                 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3153                         cancelImageFocusHideUITimer();
3154                 } else {
3155                         if (!GW.imageFocus.hideUITimer) {
3156                                 unhideImageFocusUI();
3157                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3158                         }
3159                         GW.imageFocus.mouseLastMovedAt = currentDateTime;
3160                 }
3161         });
3164 function resetFocusedImagePosition() {
3165         GWLog("resetFocusedImagePosition");
3166         let focusedImage = query("#image-focus-overlay img");
3167         if (!focusedImage) return;
3169         let sourceImage = query(GW.imageFocus.focusedImageSelector);
3171         // Make sure that initially, the image fits into the viewport.
3172         let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3173         let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3174         var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3175         let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3176         let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3177         focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3178         focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3180         // Remove modifications to position.
3181         focusedImage.style.left = "";
3182         focusedImage.style.top = "";
3184         // Set the cursor appropriately.
3185         setFocusedImageCursor();
3187 function setFocusedImageCursor() {
3188         let focusedImage = query("#image-focus-overlay img");
3189         if (!focusedImage) return;
3190         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
3191                                                                 'move' : '';
3194 function unfocusImageOverlay() {
3195         GWLog("unfocusImageOverlay");
3197         // Remove event listeners.
3198         window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3199         window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3200         // NOTE: The double-click listener does not need to be removed manually,
3201         // because the focused (cloned) image will be removed anyway.
3202         document.removeEventListener("keyup", GW.imageFocus.keyUp);
3203         document.removeEventListener("keydown", GW.imageFocus.keyDown);
3204         window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3205         window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3206         window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3208         // Set accesskey of currently focused image (if it's in the images overlay).
3209         let currentlyFocusedImage = query("#images-overlay img.focused");
3210         if (currentlyFocusedImage) {
3211                 currentlyFocusedImage.addClass("last-focused");
3212                 currentlyFocusedImage.accessKey = 'l';
3213         }
3215         // Remove focused image and hide overlay.
3216         let imageFocusOverlay = query("#image-focus-overlay");
3217         imageFocusOverlay.removeClass("engaged");
3218         removeElement(imageFocusOverlay.query("img"));
3220         // Un-blur content/etc.
3221         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3222                 element.removeClass("blurred");
3223         });
3225         // Unset "focused" class of focused image.
3226         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3228         // Re-enable page scrolling.
3229         togglePageScrolling(true);
3231         // Reset the hash, if needed.
3232         if (location.hash.hasPrefix("#if_slide_"))
3233                 history.replaceState(window.history.state, null, "#");
3236 function getIndexOfFocusedImage() {
3237         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3238         var indexOfFocusedImage = -1;
3239         for (i = 0; i < images.length; i++) {
3240                 if (images[i].hasClass("focused")) {
3241                         indexOfFocusedImage = i;
3242                         break;
3243                 }
3244         }
3245         return indexOfFocusedImage;
3248 function focusNextImage(next = true) {
3249         GWLog("focusNextImage");
3250         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3251         var indexOfFocusedImage = getIndexOfFocusedImage();
3253         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3255         // Remove existing image.
3256         removeElement("#image-focus-overlay img");
3257         // Unset "focused" class of just-removed image.
3258         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3260         // Create the focused version of the image.
3261         images[indexOfFocusedImage].addClass("focused");
3262         let imageFocusOverlay = query("#image-focus-overlay");
3263         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3264         clonedImage.style = "";
3265         clonedImage.removeAttribute("width");
3266         clonedImage.removeAttribute("height");
3267         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3268         imageFocusOverlay.appendChild(clonedImage);
3269         imageFocusOverlay.addClass("engaged");
3270         // Set image to default size and position.
3271         resetFocusedImagePosition();
3272         // Set state of next/previous buttons.
3273         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3274         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3275         // Set the image number display.
3276         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3277         // Set the caption.
3278         setImageFocusCaption();
3279         // Replace the hash.
3280         history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3283 function setImageFocusCaption() {
3284         GWLog("setImageFocusCaption");
3285         var T = { }; // Temporary storage.
3287         // Clear existing caption, if any.
3288         let captionContainer = query("#image-focus-overlay .caption");
3289         Array.from(captionContainer.children).forEach(child => { child.remove(); });
3291         // Determine caption.
3292         let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3293         var captionHTML;
3294         if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) && 
3295                 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3296                 captionHTML = (T.figcaption.query("p")) ? 
3297                                           T.figcaption.innerHTML : 
3298                                           "<p>" + T.figcaption.innerHTML + "</p>"; 
3299         } else if (currentlyFocusedImage.title != "") {
3300                 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3301         }
3302         // Insert the caption, if any.
3303         if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3306 function hideImageFocusUI() {
3307         GWLog("hideImageFocusUI");
3308         let imageFocusOverlay = query("#image-focus-overlay");
3309         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3310                 element.addClass("hidden");
3311         });
3314 function unhideImageFocusUI() {
3315         GWLog("unhideImageFocusUI");
3316         let imageFocusOverlay = query("#image-focus-overlay");
3317         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3318                 element.removeClass("hidden");
3319         });
3322 function cancelImageFocusHideUITimer() {
3323         clearTimeout(GW.imageFocus.hideUITimer);
3324         GW.imageFocus.hideUITimer = null;
3327 /*****************/
3328 /* KEYBOARD HELP */
3329 /*****************/
3331 function keyboardHelpSetup() {
3332         let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3333                 <div class='keyboard-help-container'>
3334                         <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'>&#xf00d;</button>
3335                         <h1>Keyboard shortcuts</h1>
3336                         <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>
3337                         <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3338                         <div class='keyboard-shortcuts-lists'>` + [ [
3339                                 "General",
3340                                 [ [ '?' ], "Show keyboard shortcuts" ],
3341                                 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3342                         ], [
3343                                 "Site navigation",
3344                                 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3345                                 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3346                                 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3347                                 [ [ 'ak-m' ], "Go to Meta view" ],
3348                                 [ [ 'ak-v' ], "Go to Tags view"],
3349                                 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3350                                 [ [ 'ak-r' ], "Go to Archive view" ],
3351                                 [ [ 'ak-q' ], "Go to Sequences view" ],
3352                                 [ [ 'ak-t' ], "Go to About page" ],
3353                                 [ [ 'ak-u' ], "Go to User or Login page" ],
3354                                 [ [ 'ak-o' ], "Go to Inbox page" ]
3355                         ], [
3356                                 "Page navigation",
3357                                 [ [ 'ak-,' ], "Jump up to top of page" ],
3358                                 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3359                                 [ [ 'ak-/' ], "Jump to top of comments section" ],
3360                                 [ [ 'ak-s' ], "Search" ],
3361                         ], [
3362                                 "Page actions",
3363                                 [ [ 'ak-n' ], "New post or comment" ],
3364                                 [ [ 'ak-e' ], "Edit current post" ]
3365                         ], [
3366                                 "Post/comment list views",
3367                                 [ [ '.' ], "Focus next entry in list" ],
3368                                 [ [ ',' ], "Focus previous entry in list" ],
3369                                 [ [ ';' ], "Cycle between links in focused entry" ],
3370                                 [ [ 'Enter' ], "Go to currently focused entry" ],
3371                                 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3372                                 [ [ 'ak-]' ], "Go to next page" ],
3373                                 [ [ 'ak-[' ], "Go to previous page" ],
3374                                 [ [ 'ak-\\' ], "Go to first page" ],
3375                                 [ [ 'ak-e' ], "Edit currently focused post" ]
3376                         ], [
3377                                 "Editor",
3378                                 [ [ 'ak-k' ], "Bold text" ],
3379                                 [ [ 'ak-i' ], "Italic text" ],
3380                                 [ [ 'ak-l' ], "Insert hyperlink" ],
3381                                 [ [ 'ak-q' ], "Blockquote text" ]
3382                         ], [                            
3383                                 "Appearance",
3384                                 [ [ 'ak-=' ], "Increase text size" ],
3385                                 [ [ 'ak--' ], "Decrease text size" ],
3386                                 [ [ 'ak-0' ], "Reset to default text size" ],
3387                                 [ [ 'ak-′' ], "Cycle through content width settings" ],
3388                                 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3389                                 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3390                                 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3391                                 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3392                                 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3393                                 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3394                                 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3395                                 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3396                                 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3397                                 [ [ 'ak-;' ], "Open theme tweaker" ],
3398                                 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3399                                 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3400                         ], [
3401                                 "Slide shows",
3402                                 [ [ 'ak-l' ], "Start/resume slideshow" ],
3403                                 [ [ 'Esc' ], "Exit slideshow" ],
3404                                 [ [ '&#x2192;', '&#x2193;' ], "Next slide" ],
3405                                 [ [ '&#x2190;', '&#x2191;' ], "Previous slide" ],
3406                                 [ [ 'Space' ], "Reset slide zoom" ]
3407                         ], [
3408                                 "Miscellaneous",
3409                                 [ [ 'ak-x' ], "Switch to next view on user page" ],
3410                                 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3411                                 [ [ 'ak-`&nbsp;' ], "Toggle compact comment list view" ],
3412                                 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3413                         ] ].map(section => 
3414                         `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3415                                 `<li>
3416                                         <span class='keys'>` + 
3417                                         entry[0].map(key =>
3418                                                 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3419                                         ).join("") + 
3420                                         `</span>
3421                                         <span class='action'>${entry[1]}</span>
3422                                 </li>`
3423                         ).join("\n") + `</ul>`).join("\n") + `
3424                         </ul></div>             
3425                 </div>
3426         ` + "</nav>");
3428         // Add listener to show the keyboard help overlay.
3429         document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3430                 GWLog("GW.keyboardHelpShowKeyPressed");
3431                 if (event.key == '?')
3432                         toggleKeyboardHelpOverlay(true);
3433         });
3435         // Clicking the background overlay closes the keyboard help overlay.
3436         keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3437                 GWLog("GW.keyboardHelpOverlayClicked");
3438                 if (event.type == 'mousedown') {
3439                         keyboardHelpOverlay.style.opacity = "0.01";
3440                 } else {
3441                         toggleKeyboardHelpOverlay(false);
3442                         keyboardHelpOverlay.style.opacity = "1.0";
3443                 }
3444         }, true);
3446         // Intercept clicks, so they don't "fall through" the background overlay.
3447         (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3449         // Clicking the close button closes the keyboard help overlay.
3450         keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3451                 toggleKeyboardHelpOverlay(false);
3452         });
3454         // Add button to open keyboard help.
3455         query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'>&#xf11c;</button>");
3456         query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3457                 GWLog("GW.openKeyboardHelpButtonClicked");
3458                 toggleKeyboardHelpOverlay(true);
3459                 event.target.blur();
3460         });
3463 function toggleKeyboardHelpOverlay(show) {
3464         console.log("toggleKeyboardHelpOverlay");
3466         let keyboardHelpOverlay = query("#keyboard-help-overlay");
3467         show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3468         keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3470         // Prevent scrolling the document when the overlay is visible.
3471         togglePageScrolling(!show);
3473         // Focus the close button as soon as we open.
3474         keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3476         if (show) {
3477                 // Add listener to show the keyboard help overlay.
3478                 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3479                         GWLog("GW.keyboardHelpHideKeyPressed");
3480                         if (event.key == 'Escape')
3481                                 toggleKeyboardHelpOverlay(false);
3482                 });
3483         } else {
3484                 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3485         }
3487         // Disable / enable tab-selection of the search box.
3488         setSearchBoxTabSelectable(!show);
3491 /**********************/
3492 /* PUSH NOTIFICATIONS */
3493 /**********************/
3495 function pushNotificationsSetup() {
3496         let pushNotificationsButton = query("#enable-push-notifications");
3497         if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3498                 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3499                 pushNotificationsButton.style.display = 'unset';
3500         }
3503 function urlBase64ToUint8Array(base64String) {
3504         const padding = '='.repeat((4 - base64String.length % 4) % 4);
3505         const base64 = (base64String + padding)
3506               .replace(/-/g, '+')
3507               .replace(/_/g, '/');
3508         
3509         const rawData = window.atob(base64);
3510         const outputArray = new Uint8Array(rawData.length);
3511         
3512         for (let i = 0; i < rawData.length; ++i) {
3513                 outputArray[i] = rawData.charCodeAt(i);
3514         }
3515         return outputArray;
3518 function pushNotificationsButtonClicked(event) {
3519         event.target.style.opacity = 0.33;
3520         event.target.style.pointerEvents = "none";
3522         let reEnable = (message) => {
3523                 if(message) alert(message);
3524                 event.target.style.opacity = 1;
3525                 event.target.style.pointerEvents = "unset";
3526         }
3528         if(event.target.dataset.enabled) {
3529                 fetch('/push/register', {
3530                         method: 'post',
3531                         headers: { 'Content-type': 'application/json' },
3532                         body: JSON.stringify({
3533                                 cancel: true
3534                         }),
3535                 }).then(() => {
3536                         event.target.innerHTML = "Enable push notifications";
3537                         event.target.dataset.enabled = "";
3538                         reEnable();
3539                 }).catch((err) => reEnable(err.message));
3540         } else {
3541                 Notification.requestPermission().then((permission) => {
3542                         navigator.serviceWorker.ready
3543                                 .then((registration) => {
3544                                         return registration.pushManager.getSubscription()
3545                                                 .then(async function(subscription) {
3546                                                         if (subscription) {
3547                                                                 return subscription;
3548                                                         }
3549                                                         return registration.pushManager.subscribe({
3550                                                                 userVisibleOnly: true,
3551                                                                 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
3552                                                         });
3553                                                 })
3554                                                 .catch((err) => reEnable(err.message));
3555                                 })
3556                                 .then((subscription) => {
3557                                         fetch('/push/register', {
3558                                                 method: 'post',
3559                                                 headers: {
3560                                                         'Content-type': 'application/json'
3561                                                 },
3562                                                 body: JSON.stringify({
3563                                                         subscription: subscription
3564                                                 }),
3565                                         });
3566                                 })
3567                                 .then(() => {
3568                                         event.target.innerHTML = "Disable push notifications";
3569                                         event.target.dataset.enabled = "true";
3570                                         reEnable();
3571                                 })
3572                                 .catch(function(err){ reEnable(err.message) });
3573                         
3574                 });
3575         }
3578 /*******************************/
3579 /* HTML TO MARKDOWN CONVERSION */
3580 /*******************************/
3582 function MarkdownFromHTML(text) {
3583         GWLog("MarkdownFromHTML");
3584         // Wrapper tags, paragraphs, bold, italic, code blocks.
3585         text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
3586                 switch(tag) {
3587                 case "html":
3588                 case "/html":
3589                 case "head":
3590                 case "/head":
3591                 case "body":
3592                 case "/body":
3593                 case "p":
3594                         return "";
3595                 case "/p":
3596                         return "\n";
3597                 case "strong":
3598                 case "/strong":
3599                         return "**";
3600                 case "em":
3601                 case "/em":
3602                         return "*";
3603                 default:
3604                         return match;
3605                 }
3606         });
3608         // <div> and <span>.
3609         text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
3610                 return `${text}\n`;
3611         }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
3612                 return `${text}\n`;
3613         });
3615         // Unordered lists.
3616         text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
3617                 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
3618                         return `* ${listItem}\n`;
3619                 });
3620         });
3622         // Ordered lists.
3623         text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
3624                 var countedItemValue = 0;
3625                 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
3626                         var itemValue;
3627                         if (typeof specifiedItemValue != "undefined") {
3628                                 specifiedItemValue = parseInt(specifiedItemValue);
3629                                 countedItemValue = itemValue = specifiedItemValue;
3630                         } else {
3631                                 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
3632                         }
3633                         return `${itemValue}. ${listItem.trim()}\n`;
3634                 });
3635         });
3637         // Headings.
3638         text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
3639                 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
3640         });
3642         // Blockquotes.
3643         text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
3644                 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
3645         });
3647         // Links.
3648         text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
3649                 return `[${text}](${href})`;
3650         }).trim();
3652         // Images.
3653         text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
3654                 return `![](${src})`;
3655         });
3657         // Horizontal rules.
3658         text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
3659                 return "\n---\n";
3660         });
3662         // Line breaks.
3663         text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
3664                 return "\\\n";
3665         });
3667         // Preformatted text (possibly with a code block inside).
3668         text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
3669                 return "```\n" + text + "\n```";
3670         });
3672         // Code blocks.
3673         text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
3674                 return "`" + text + "`";
3675         });
3677         // HTML entities.
3678         text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
3679                 switch(entity) {
3680                 case "gt":
3681                         return ">";
3682                 case "lt":
3683                         return "<";
3684                 case "amp":
3685                         return "&";
3686                 case "apos":
3687                         return "'";
3688                 case "quot":
3689                         return "\"";
3690                 default:
3691                         return match;
3692                 }
3693         });
3695         return text;
3698 /************************************/
3699 /* ANCHOR LINK SCROLLING WORKAROUND */
3700 /************************************/
3702 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
3703         let hash = location.hash;
3704         if(hash && hash !== "#top" && !document.query(hash)) {
3705                 let content = document.query("#content");
3706                 content.style.display = "none";
3707                 addTriggerListener("DOMReady", {priority: -1, fn: () => {
3708                         content.style.visibility = "hidden";
3709                         content.style.display = null;
3710                         requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
3711                 }});
3712         }
3713 }});
3715 /******************/
3716 /* INITIALIZATION */
3717 /******************/
3719 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
3720         GWLog("INITIALIZER earlyInitialize");
3721         // Check to see whether we're on a mobile device (which we define as a narrow screen)
3722         GW.isMobile = (window.innerWidth <= 1160);
3723         GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3725         // Backward compatibility
3726         let storedTheme = localStorage.getItem('selected-theme');
3727         if (storedTheme) {
3728                 setTheme(storedTheme);
3729                 localStorage.removeItem('selected-theme');
3730         }
3732         // Animate width & theme adjustments?
3733         GW.adjustmentTransitions = false;
3735         // Add the content width selector.
3736         injectContentWidthSelector();
3737         // Add the text size adjustment widget.
3738         injectTextSizeAdjustmentUI();
3739         // Add the theme selector.
3740         injectThemeSelector();
3741         // Add the theme tweaker.
3742         injectThemeTweaker();
3743         // Add the quick-nav UI.
3744         injectQuickNavUI();
3746         // Finish initializing when ready.
3747         addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
3748 }});
3750 function mainInitializer() {
3751         GWLog("INITIALIZER initialize");
3753         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
3754         // "link without nav bars".
3755         if (getQueryVariable("hide-nav-bars") == "true") {
3756                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
3757         }
3759         // If the page cannot have comments, remove the accesskey from the #comments
3760         // quick-nav button; and if the page can have comments, but does not, simply 
3761         // disable the #comments quick nav button.
3762         let content = query("#content");
3763         if (content.query("#comments") == null) {
3764                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
3765         } else if (content.query("#comments .comment-thread") == null) {
3766                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
3767         }
3769         // On edit post pages and conversation pages, add GUIEdit buttons to the 
3770         // textarea, expand it, and markdownify the existing text, if any (this is
3771         // needed if a post was last edited on LW).
3772         queryAll(".with-markdown-editor textarea").forEach(textarea => {
3773                 textarea.addTextareaFeatures();
3774                 expandTextarea(textarea);
3775                 textarea.value = MarkdownFromHTML(textarea.value);
3776         });
3777         // Focus the textarea.
3778         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3780         // If we're on a comment thread page...
3781         if (query(".comments") != null) {
3782                 // Add comment-minimize buttons to every comment.
3783                 queryAll(".comment-meta").forEach(commentMeta => {
3784                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3785                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
3786                 });
3787                 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
3788                         // Format and activate comment-minimize buttons.
3789                         queryAll(".comment-minimize-button").forEach(button => {
3790                                 button.closest(".comment-item").setCommentThreadMaximized(false);
3791                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3792                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
3793                                 });
3794                         });
3795                 }
3796         }
3797         if (getQueryVariable("chrono") == "t") {
3798                 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
3799         }
3801         // On mobile, replace the labels for the checkboxes on the edit post form
3802         // with icons, to save space.
3803         if (GW.isMobile && query(".edit-post-page")) {
3804                 query("label[for='link-post']").innerHTML = "&#xf0c1";
3805                 query("label[for='question']").innerHTML = "&#xf128";
3806         }
3808         // Add error message (as placeholder) if user tries to click Search with
3809         // an empty search field.
3810         searchForm: {
3811                 let searchForm = query("#nav-item-search form");
3812                 if(!searchForm) break searchForm;
3813                 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3814                         let searchField = event.target.query("input");
3815                         if (searchField.value == "") {
3816                                 event.preventDefault();
3817                                 event.target.blur();
3818                                 searchField.placeholder = "Enter a search string!";
3819                                 searchField.focus();
3820                         }
3821                 });
3822                 // Remove the placeholder / error on any input.
3823                 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3824                         event.target.placeholder = "";
3825                 });
3826         }
3828         // Prevent conflict between various single-hotkey listeners and text fields
3829         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3830                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3831                 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
3832         });
3834         if (content.hasClass("post-page")) {
3835                 // Read and update last-visited-date.
3836                 let lastVisitedDate = getLastVisitedDate();
3837                 setLastVisitedDate(Date.now());
3839                 // Save the number of comments this post has when it's visited.
3840                 updateSavedCommentCount();
3842                 if (content.query(".comments .comment-thread") != null) {
3843                         // Add the new comments count & navigator.
3844                         injectNewCommentNavUI();
3846                         // Get the highlight-new-since date (as specified by URL parameter, if 
3847                         // present, or otherwise the date of the last visit).
3848                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3850                         // Highlight new comments since the specified date.                      
3851                         let newCommentsCount = highlightCommentsSince(hnsDate);
3853                         // Update the comment count display.
3854                         updateNewCommentNavUI(newCommentsCount, hnsDate);
3855                 }
3856         } else {
3857                 // On listing pages, make comment counts more informative.
3858                 badgePostsWithNewComments();
3859         }
3861         // Add the comments list mode selector widget (expanded vs. compact).
3862         injectCommentsListModeSelector();
3864         // Add the comments view selector widget (threaded vs. chrono).
3865 //      injectCommentsViewModeSelector();
3867         // Add the comments sort mode selector (top, hot, new, old).
3868         if (GW.useFancyFeatures) injectCommentsSortModeSelector();
3870         // Add the toggle for the post nav UI elements on mobile.
3871         if (GW.isMobile) injectPostNavUIToggle();
3873         // Add the toggle for the appearance adjustment UI elements on mobile.
3874         if (GW.isMobile) injectAppearanceAdjustUIToggle();
3876         // Add the antikibitzer.
3877         if (GW.useFancyFeatures) injectAntiKibitzer();
3879         // Add comment parent popups.
3880         injectPreviewPopupToggle();
3881         addCommentParentPopups();
3883         // Mark original poster's comments with a special class.
3884         markOriginalPosterComments();
3885         
3886         // On the All view, mark posts with non-positive karma with a special class.
3887         if (query("#content").hasClass("all-index-page")) {
3888                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3889                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3891                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3892                 });
3893         }
3895         // Set the "submit" button on the edit post page to something more helpful.
3896         setEditPostPageSubmitButtonText();
3898         // Compute the text of the pagination UI tooltip text.
3899         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3900                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3901         });
3903         // Add event listeners for Escape and Enter, for the theme tweaker.
3904         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3905         let themeTweakerUI = query("#theme-tweaker-ui");
3906         document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3907                 if (event.key == "Escape") {
3908                         if (themeTweakerHelpWindow.style.display != "none") {
3909                                 toggleThemeTweakerHelpWindow();
3910                                 themeTweakerResetSettings();
3911                         } else if (themeTweakerUI.style.display != "none") {
3912                                 toggleThemeTweakerUI();
3913                                 themeTweakReset();
3914                         }
3915                 } else if (event.key == "Enter") {
3916                         if (themeTweakerHelpWindow.style.display != "none") {
3917                                 toggleThemeTweakerHelpWindow();
3918                                 themeTweakerSaveSettings();
3919                         } else if (themeTweakerUI.style.display != "none") {
3920                                 toggleThemeTweakerUI();
3921                                 themeTweakSave();
3922                         }
3923                 }
3924         });
3926         // Add event listener for . , ; (for navigating listings pages).
3927         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3928         if (!query(".comments") && listings.length > 0) {
3929                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
3930                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
3932                         if (event.key == "Escape") {
3933                                 if (document.activeElement.parentElement.hasClass("listing"))
3934                                         document.activeElement.blur();
3935                                 return;
3936                         }
3938                         if (event.key == ';') {
3939                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3940                                         let links = document.activeElement.parentElement.queryAll("a");
3941                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3942                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3943                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3944                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3945                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3946                                 }
3947                                 return;
3948                         }
3950                         var indexOfActiveListing = -1;
3951                         for (i = 0; i < listings.length; i++) {
3952                                 if (document.activeElement.parentElement.hasClass("listing") && 
3953                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3954                                         indexOfActiveListing = i;
3955                                         break;
3956                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
3957                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
3958                                         indexOfActiveListing = i;
3959                                         break;
3960                                 }
3961                         }
3962                         // Remove edit accesskey from currently highlighted post by active user, if applicable.
3963                         if (indexOfActiveListing > -1) {
3964                                 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
3965                         }
3966                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3967                         if (indexOfNextListing < listings.length) {
3968                                 listings[indexOfNextListing].focus();
3970                                 if (listings[indexOfNextListing].closest(".comment-item")) {
3971                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3972                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3973                                 }
3974                         } else {
3975                                 document.activeElement.blur();
3976                         }
3977                         // Add edit accesskey to newly highlighted post by active user, if applicable.
3978                         (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
3979                 });
3980                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
3981                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
3982                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
3983                         });
3984                 });
3985         }
3986         // Add event listener for ; (to focus the link on link posts).
3987         if (query("#content").hasClass("post-page") && 
3988                 query(".post").hasClass("link-post")) {
3989                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
3990                         if (event.key == ';') query("a.link-post-link").focus();
3991                 });
3992         }
3994         // Add accesskeys to user page view selector.
3995         let viewSelector = query("#content.user-page > .sublevel-nav");
3996         if (viewSelector) {
3997                 let currentView = viewSelector.query("span");
3998                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
3999                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4000         }
4002         // Add accesskey to index page sort selector.
4003         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4005         // Move MathJax style tags to <head>.
4006         var aggregatedStyles = "";
4007         queryAll("#content style").forEach(styleTag => {
4008                 aggregatedStyles += styleTag.innerHTML;
4009                 removeElement("style", styleTag.parentElement);
4010         });
4011         if (aggregatedStyles != "") {
4012                 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4013         }
4015         // Add listeners to switch between word count and read time.
4016         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4017         queryAll(".post-meta .read-time").forEach(element => {
4018                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4019                         let displayWordCount = localStorage.getItem("display-word-count");
4020                         toggleReadTimeOrWordCount(!displayWordCount);
4021                         if (displayWordCount) localStorage.removeItem("display-word-count");
4022                         else localStorage.setItem("display-word-count", true);
4023                 });
4024         });
4026         // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4027         query("#content").addEventListener("copy", GW.textCopied = (event) => {
4028                 if(event.target.matches("input, textarea")) return;
4029                 event.preventDefault();
4030                 const selectedHTML = getSelectionHTML();
4031                 const selectedText = getSelection().toString();
4032                 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4033                 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4034         });
4036         // Set up Image Focus feature.
4037         imageFocusSetup();
4039         // Set up keyboard shortcuts guide overlay.
4040         keyboardHelpSetup();
4042         // Show push notifications button if supported
4043         pushNotificationsSetup();
4045         // Show elements now that javascript is ready.
4046         removeElement("#hide-until-init");
4048         activateTrigger("pageLayoutFinished");
4051 /*************************/
4052 /* POST-LOAD ADJUSTMENTS */
4053 /*************************/
4055 window.addEventListener("pageshow", badgePostsWithNewComments);
4057 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4058         GWLog("INITIALIZER pageLayoutFinished");
4060         postSetThemeHousekeeping();
4062         focusImageSpecifiedByURL();
4064         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4065 //      query("input[type='search']").value = GW.isMobile;
4066 //      insertHeadHTML("<style>" +
4067 //              `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` + 
4068 //              `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` + 
4069 //              "</style>");
4070 }});
4072 function generateImagesOverlay() {
4073         GWLog("generateImagesOverlay");
4074         // Don't do this on the about page.
4075         if (query(".about-page") != null) return;
4076         return;
4078         // Remove existing, if any.
4079         removeElement("#images-overlay");
4081         // Create new.
4082         query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4083         let imagesOverlay = query("#images-overlay");
4084         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4085         queryAll(".post-body img").forEach(image => {
4086                 let clonedImageContainer = document.createElement("div");
4088                 let clonedImage = image.cloneNode(true);
4089                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4090                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4091                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4092                 clonedImageContainer.appendChild(clonedImage);
4094                 let zoomLevel = parseFloat(GW.currentTextZoom);
4096                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4097                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4098                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4099                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4101                 imagesOverlay.appendChild(clonedImageContainer);
4102         });
4104         // Add the event listeners to focus each image.
4105         imageFocusSetup(true);
4108 function adjustUIForWindowSize() {
4109         GWLog("adjustUIForWindowSize");
4110         var bottomBarOffset;
4112         // Adjust bottom bar state.
4113         let bottomBar = query("#bottom-bar");
4114         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4115         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4116                 bottomBar.removeClass("decorative");
4118                 bottomBar.query("#nav-item-top").style.display = "";
4119         } else if (bottomBar) {
4120                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4121                 else bottomBar.addClass("decorative");
4123                 bottomBar.query("#nav-item-top").style.display = "none";
4124         }
4126         // Show quick-nav UI up/down buttons if content is taller than window.
4127         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4128         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4129                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4130         });
4132         // Move anti-kibitzer toggle if content is very short.
4133         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4135         // Update the visibility of the post nav UI.
4136         updatePostNavUIVisibility();
4139 function recomputeUIElementsContainerHeight(force = false) {
4140         GWLog("recomputeUIElementsContainerHeight");
4141         if (!GW.isMobile &&
4142                 (force || query("#ui-elements-container").style.height != "")) {
4143                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4144                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
4145                                                                                                                 query("#content").clientHeight + "px" :
4146                                                                                                                 "100vh";
4147         }
4150 function focusImageSpecifiedByURL() {
4151         GWLog("focusImageSpecifiedByURL");
4152         if (location.hash.hasPrefix("#if_slide_")) {
4153                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4154                         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4155                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4156                         if (imageToFocus > 0 && imageToFocus <= images.length) {
4157                                 focusImage(images[imageToFocus - 1]);
4159                                 // Set timer to hide the image focus UI.
4160                                 unhideImageFocusUI();
4161                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4162                         }
4163                 });
4164         }
4167 /***********/
4168 /* GUIEDIT */
4169 /***********/
4171 function insertMarkup(event) {
4172         var mopen = '', mclose = '', mtext = '', func = false;
4173         if (typeof arguments[1] == 'function') {
4174                 func = arguments[1];
4175         } else {
4176                 mopen = arguments[1];
4177                 mclose = arguments[2];
4178                 mtext = arguments[3];
4179         }
4181         var textarea = event.target.closest("form").query("textarea");
4182         textarea.focus();
4183         var p0 = textarea.selectionStart;
4184         var p1 = textarea.selectionEnd;
4185         var cur0 = cur1 = p0;
4187         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4188         str = func ? func(str, p0) : (mopen + str + mclose);
4190         // Determine selection.
4191         if (!func) {
4192                 cur0 += (p0 == p1) ? mopen.length : str.length;
4193                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4194         } else {
4195                 cur0 = str[1];
4196                 cur1 = str[2];
4197                 str = str[0];
4198         }
4200         // Update textarea contents.
4201         document.execCommand("insertText", false, str);
4203         // Expand textarea, if needed.
4204         expandTextarea(textarea);
4206         // Set selection.
4207         textarea.selectionStart = cur0;
4208         textarea.selectionEnd = cur1;
4210         return;
4213 GW.guiEditButtons = [
4214         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
4215         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
4216         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
4217         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
4218         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
4219         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
4220         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
4221         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
4222         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
4223         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
4224         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
4225         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
4226         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
4227         [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
4228         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
4231 function blockquote(text, startpos) {
4232         if (text == '') {
4233                 text = "> Quoted text";
4234                 return [ text, startpos + 2, startpos + text.length ];
4235         } else {
4236                 text = "> " + text.split("\n").join("\n> ") + "\n";
4237                 return [ text, startpos + text.length, startpos + text.length ];
4238         }
4241 function hyperlink(text, startpos) {
4242         var url = '', link_text = text, endpos = startpos;
4243         if (text.search(/^https?/) != -1) {
4244                 url = text;
4245                 link_text = "link text";
4246                 startpos = startpos + 1;
4247                 endpos = startpos + link_text.length;
4248         } else {
4249                 url = prompt("Link address (URL):");
4250                 if (!url) {
4251                         endpos = startpos + text.length;
4252                         return [ text, startpos, endpos ];
4253                 }
4254                 startpos = startpos + text.length + url.length + 4;
4255                 endpos = startpos;
4256         }
4258         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4261 /******************/
4262 /* SERVICE WORKER */
4263 /******************/
4265 if(navigator.serviceWorker) {
4266         navigator.serviceWorker.register('/service-worker.js');
4267         setCookie("push", "t");
4270 /*********************/
4271 /* USER AUTOCOMPLETE */
4272 /*********************/
4274 var userAutocomplete = null;
4276 function abbreviatedInterval(date) {
4277         let seconds = Math.floor((new Date() - date) / 1000);
4278         let days = Math.floor(seconds / (60 * 60 * 24));
4279         let years = Math.floor(days / 365);
4280         if (years)
4281                 return years + "y";
4282         else if (days)
4283                 return days + "d";
4284         else
4285                 return "today";
4288 function beginAutocompletion(control, startIndex) {
4289         if(userAutocomplete) abortAutocompletion(userAutocomplete);
4291         complete = { control: control,
4292                      abortController: new AbortController(),
4293                      container: document.createElement("div") };
4295         complete.container.className = "autocomplete-container "
4296                                                                  + "right "
4297                                                                  + (window.innerWidth > 1200
4298                                                                         ? "outside"
4299                                                                         : "inside");
4300         control.insertAdjacentElement("afterend", complete.container);
4302         let makeReplacer = (userSlug, displayName) => {
4303                 return () => {
4304                         let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
4305                         control.value = control.value.substring(0, startIndex - 1) +
4306                                 replacement +
4307                                 control.value.substring(control.selectionEnd);
4308                         abortAutocompletion(complete);
4309                         complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
4310                         complete.control.focus();
4311                 };
4312         };
4314         let switchHighlight = (newHighlight) => {
4315                 if (!newHighlight)
4316                         return;
4318                 complete.highlighted.removeClass("highlighted");
4319                 newHighlight.addClass("highlighted");
4320                 complete.highlighted = newHighlight;
4322                 //      Scroll newly highlighted item into view, if need be.
4323                 if (  complete.highlighted.offsetTop + complete.highlighted.offsetHeight 
4324                         > complete.container.scrollTop + complete.container.clientHeight) {
4325                         complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
4326                 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
4327                         complete.container.scrollTo(0, complete.highlighted.offsetTop);
4328                 }
4329         };
4330         let highlightNext = () => {
4331                 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
4332         };
4333         let highlightPrev = () => {
4334                 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
4335         };
4337         document.body.addEventListener("click", complete.abortClickListener = (event) => {
4338                 if (!complete.container.contains(event.target)) {
4339                         abortAutocompletion(complete);
4340                         event.preventDefault();
4341                 }
4342         }, {capture: true});
4343         
4344         control.addEventListener("keydown", complete.eventListener = (event) => {
4345                 switch (event.key) {
4346                 case "Escape":
4347                         abortAutocompletion(complete);
4348                         event.preventDefault();
4349                         return;
4350                 case "ArrowUp":
4351                         highlightPrev();
4352                         event.preventDefault();
4353                         return;
4354                 case "ArrowDown":
4355                         highlightNext();
4356                         event.preventDefault();
4357                         return;
4358                 case "Tab":
4359                         if (event.shiftKey)
4360                                 highlightPrev();
4361                         else
4362                                 highlightNext();
4363                         event.preventDefault();
4364                         return;
4365                 case "Enter":
4366                         complete.highlighted.onclick();
4367                         event.preventDefault();
4368                         return;
4369                 }
4371                 if (event.key.length > 1) return;
4373                 complete.abortController.abort();
4374                 complete.abortController = new AbortController();
4375                 
4376                 let fragment = control.value.substring(startIndex, control.selectionEnd) + event.key;
4378                 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
4379                       {signal: complete.abortController.signal})
4380                         .then((res) => res.json())
4381                         .then((res) => {
4382                                 if(res.error) return;
4383                                 if(res.length == 0) return abortAutocompletion(complete);
4384                                 
4385                                 complete.container.innerHTML = "";
4386                                 res.forEach(entry => {
4387                                         let entryContainer = document.createElement("div");
4388                                         [ [ entry.displayName, "name" ],
4389                                           [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
4390                                           [ (entry.karma || 0) + " karma", "karma" ]
4391                                         ].forEach(x => {
4392                                                 let e = document.createElement("span");
4393                                                 e.append(x[0]);
4394                                                 e.className = x[1];
4395                                                 entryContainer.append(e);
4396                                         });
4397                                         entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
4398                                         complete.container.append(entryContainer);
4399                                 });
4400                                 complete.highlighted = complete.container.children[0];
4401                                 complete.highlighted.classList.add("highlighted");
4402                                 })
4403                         .catch((e) => {});
4404         });
4406         userAutocomplete = complete;
4409 function abortAutocompletion(complete) {
4410         complete.control.removeEventListener("keydown", complete.eventListener);
4411         document.body.removeEventListener("click", complete.abortClickListener);
4412         complete.container.remove();
4413         userAutocomplete = null;