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