Appearance code refactor
[lw2-viewer.git] / www / script.js
blob5e1fff2ab0722e647694cd2509df3288d960f9e0
1 /***************************/
2 /* INITIALIZATION REGISTRY */
3 /***************************/
5 /*      TBC. */
6 GW.initializersDone = { };
7 GW.initializers = { };
8 function registerInitializer(name, tryEarly, precondition, fn) {
9         GW.initializersDone[name] = false;
10         GW.initializers[name] = fn;
11         let wrapper = function () {
12                 if (GW.initializersDone[name]) return;
13                 if (!precondition()) {
14                         if (tryEarly) {
15                                 setTimeout(() => requestIdleCallback(wrapper, {timeout: 1000}), 50);
16                         } else {
17                                 document.addEventListener("readystatechange", wrapper, {once: true});
18                         }
19                         return;
20                 }
21                 GW.initializersDone[name] = true;
22                 fn();
23         };
24         if (tryEarly) {
25                 requestIdleCallback(wrapper, {timeout: 1000});
26         } else {
27                 document.addEventListener("readystatechange", wrapper, {once: true});
28                 requestIdleCallback(wrapper);
29         }
31 function forceInitializer(name) {
32         if (GW.initializersDone[name]) return;
33         GW.initializersDone[name] = true;
34         GW.initializers[name]();
37 /***********/
38 /* COOKIES */
39 /***********/
41 /*      Sets a cookie. */
42 function setCookie(name, value, days) {
43         var expires = "";
44         if (!days) days = 36500;
45         if (days) {
46                 var date = new Date();
47                 date.setTime(date.getTime() + (days*24*60*60*1000));
48                 expires = "; expires=" + date.toUTCString();
49         }
50         document.cookie = name + "=" + (value || "")  + expires + "; path=/; SameSite=Lax" + (GW.secureCookies ? "; Secure" : "");
53 /*******************************/
54 /* EVENT LISTENER MANIPULATION */
55 /*******************************/
57 /*      Removes event listener from a clickable element, automatically detaching it
58         from all relevant event types. */
59 Element.prototype.removeActivateEvent = function() {
60         let ael = this.activateEventListener;
61         this.removeEventListener("mousedown", ael);
62         this.removeEventListener("click", ael);
63         this.removeEventListener("keyup", ael);
66 /*      Adds a scroll event listener to the page. */
67 function addScrollListener(fn, name) {
68         let wrapper = (event) => {
69                 requestAnimationFrame(() => {
70                         fn(event);
71                         document.addEventListener("scroll", wrapper, {once: true, passive: true});
72                 });
73         }
74         document.addEventListener("scroll", wrapper, {once: true, passive: true});
76         // Retain a reference to the scroll listener, if a name is provided.
77         if (typeof name != "undefined")
78                 GW[name] = wrapper;
81 /****************/
82 /* MISC HELPERS */
83 /****************/
85 // Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=325942
86 Element.prototype.scrollIntoView = function(realSIV) {
87         return function(bottom) {
88                 realSIV.call(this, bottom);
89                 if(fixTarget = this.closest("input[id^='expand'] ~ .comment-thread")) {
90                         window.scrollBy(0, fixTarget.scrollTop);
91                         fixTarget.scrollTop = 0;
92                 }
93         }
94 }(Element.prototype.scrollIntoView);
96 /*      If top of element is not at or above the top of the screen, scroll it into
97         view. */
98 Element.prototype.scrollIntoViewIfNeeded = function() {
99         GWLog("scrollIntoViewIfNeeded");
100         if (this.getBoundingClientRect().bottom > window.innerHeight && 
101                 this.getBoundingClientRect().top > 0) {
102                 this.scrollIntoView(false);
103         }
106 function urlEncodeQuery(params) {
107         return params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params[x])}).join("&");
110 function handleAjaxError(event) {
111         if(event.target.getResponseHeader("Content-Type") === "application/json") alert("Error: " + JSON.parse(event.target.responseText)["error"]);
112         else alert("Error: Something bad happened :(");
115 function doAjax(params) {
116         let req = new XMLHttpRequest();
117         let requestMethod = params["method"] || "GET";
118         req.addEventListener("load", (event) => {
119                 if(event.target.status < 400) {
120                         if(params["onSuccess"]) params.onSuccess(event);
121                 } else {
122                         if(params["onFailure"]) params.onFailure(event);
123                         else handleAjaxError(event);
124                 }
125                 if(params["onFinish"]) params.onFinish(event);
126         });
127         req.open(requestMethod, (params.location || document.location) + ((requestMethod == "GET" && params.params) ? "?" + urlEncodeQuery(params.params) : ""));
128         if(requestMethod == "POST") {
129                 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
130                 params["params"]["csrf-token"] = GW.csrfToken;
131                 req.send(urlEncodeQuery(params.params));
132         } else {
133                 req.send();
134         }
137 function activateReadyStateTriggers() {
138         if(document.readyState == 'interactive') {
139                 activateTrigger('DOMReady');
140         } else if(document.readyState == 'complete') {
141                 activateTrigger('DOMReady');
142                 activateTrigger('DOMComplete');
143         }
146 document.addEventListener('readystatechange', activateReadyStateTriggers);
147 activateReadyStateTriggers();
149 function callWithServerData(fname, uri) {
150         doAjax({
151                 location: uri,
152                 onSuccess: (event) => {
153                         let response = JSON.parse(event.target.responseText);
154                         window[fname](response);
155                 }
156         });
159 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
160 deferredCalls = null;
162 /*      Return the currently selected text, as HTML (rather than unstyled text).
163         */
164 function getSelectionHTML() {
165         var container = document.createElement("div");
166         container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
167         return container.innerHTML;
170 /*      Given an HTML string, creates an element from that HTML, adds it to 
171         #ui-elements-container (creating the latter if it does not exist), and 
172         returns the created element.
173         */
174 function addUIElement(element_html) {
175         var ui_elements_container = query("#ui-elements-container");
176         if (!ui_elements_container) {
177                 ui_elements_container = document.createElement("nav");
178                 ui_elements_container.id = "ui-elements-container";
179                 query("body").appendChild(ui_elements_container);
180         }
182         ui_elements_container.insertAdjacentHTML("beforeend", element_html);
183         return ui_elements_container.lastElementChild;
186 /*      Given an element or a selector, removes that element (or the element 
187         identified by the selector).
188         If multiple elements match the selector, only the first is removed.
189         */
190 function removeElement(elementOrSelector, ancestor = document) {
191         if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
192         if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
195 /*      Returns true if the string begins with the given prefix.
196         */
197 String.prototype.hasPrefix = function (prefix) {
198         return (this.lastIndexOf(prefix, 0) === 0);
201 /*      Toggles whether the page is scrollable.
202         */
203 function togglePageScrolling(enable) {
204         let body = query("body");
205         if (!enable) {
206                 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
207                 body.addClass("no-scroll");
208                 body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
209         } else {
210                 body.removeClass("no-scroll");
211                 body.removeAttribute("style");
212                 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
213         }
216 DOMRectReadOnly.prototype.isInside = function (x, y) {
217         return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
220 /*      Simple mutex mechanism.
221  */
222 function doIfAllowed(f, passHolder, passName, releaseImmediately = false) {
223         if (passHolder[passName] == false)
224                 return;
226         passHolder[passName] = false;
228         f();
230         if (releaseImmediately) {
231                 passHolder[passName] = true;
232         } else {
233                 requestAnimationFrame(() => {
234                         passHolder[passName] = true;
235                 });
236         }
240 /********************/
241 /* DEBUGGING OUTPUT */
242 /********************/
244 GW.enableLogging = (permanently = false) => {
245         if (permanently)
246                 localStorage.setItem("logging-enabled", "true");
247         else
248                 GW.loggingEnabled = true;
250 GW.disableLogging = (permanently = false) => {
251         if (permanently)
252                 localStorage.removeItem("logging-enabled");
253         else
254                 GW.loggingEnabled = false;
257 /*******************/
258 /* INBOX INDICATOR */
259 /*******************/
261 function processUserStatus(userStatus) {
262         window.userStatus = userStatus;
263         if(userStatus) {
264                 if(userStatus.notifications) {
265                         let element = query('#inbox-indicator');
266                         element.className = 'new-messages';
267                         element.title = 'New messages [o]';
268                 }
269         } else {
270                 location.reload();
271         }
274 /**************/
275 /* COMMENTING */
276 /**************/
278 function toggleMarkdownHintsBox() {
279         GWLog("toggleMarkdownHintsBox");
280         let markdownHintsBox = query("#markdown-hints");
281         markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
283 function hideMarkdownHintsBox() {
284         GWLog("hideMarkdownHintsBox");
285         let markdownHintsBox = query("#markdown-hints");
286         if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
289 Element.prototype.addTextareaFeatures = function() {
290         GWLog("addTextareaFeatures");
291         let textarea = this;
293         textarea.addEventListener("focus", GW.textareaFocused = (event) => {
294                 GWLog("GW.textareaFocused");
295                 event.target.closest("form").scrollIntoViewIfNeeded();
296         });
297         textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
298                 GWLog("GW.textareaInputReceived");
299                 if (window.innerWidth > 520) {
300                         // Expand textarea if needed.
301                         expandTextarea(textarea);
302                 } else {
303                         // Remove markdown hints.
304                         hideMarkdownHintsBox();
305                         query(".guiedit-mobile-help-button").removeClass("active");
306                 }
307         }, false);
308         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
309         textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
311         let form = textarea.closest("form");
312         if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
314         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
315         let textareaContainer = textarea.closest(".textarea-container");
316         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
317         for (var button of GW.guiEditButtons) {
318                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
319                 buttons_container.insertAdjacentHTML("beforeend", 
320                         "<button type='button' class='guiedit guiedit-" 
321                         + name
322                         + "' tabindex='-1'"
323                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
324                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
325                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
326                         + " onclick='insertMarkup(event,"
327                         + ((typeof m_before_or_func == 'function') ?
328                                 m_before_or_func.name : 
329                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
330                         + ");'><div>"
331                         + icon
332                         + "</div></button>"
333                 );
334         }
336         var markdown_hints = 
337         `<input type='checkbox' id='markdown-hints-checkbox'>
338         <label for='markdown-hints-checkbox'></label>
339         <div id='markdown-hints'>` + 
340         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
341                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
342                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
343                 "<span>Heading 1</span><code># Heading 1</code>",
344                 "<span>Heading 2</span><code>## Heading 1</code>",
345                 "<span>Heading 3</span><code>### Heading 1</code>",
346                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
347         `</div>`;
348         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
350         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
351                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
352                         GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
353                         if (button.hasClass("guiedit-mobile-help-button")) {
354                                 toggleMarkdownHintsBox();
355                                 event.target.toggleClass("active");
356                                 query(".posting-controls:focus-within textarea").focus();
357                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
358                                 event.target.blur();
359                                 hideMarkdownHintsBox();
360                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
361                         }
362                 });
363         });
365         // On smartphone (narrow mobile) screens, when a textarea is focused (and
366         // automatically fullscreened), remove all the filters from the page, and 
367         // then apply them *just* to the fixed editor UI elements. This is in order
368         // to get around the “children of elements with a filter applied cannot be
369         // fixed” issue.
370         if (GW.isMobile && window.innerWidth <= 520) {
371                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
372                 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
373                         GWLog("GW.textareaFocusedMobile");
374                         Appearance.savedFilters = Appearance.currentFilters;
375                         Appearance.applyFilters(Appearance.noFilters);
376                         fixedEditorElements.forEach(element => {
377                                 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
378                         });
379                 });
380                 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
381                         GWLog("GW.textareaBlurredMobile");
382                         requestAnimationFrame(() => {
383                                 Appearance.applyFilters(Appearance.savedFilters);
384                                 Appearance.savedFilters = null;
385                                 fixedEditorElements.forEach(element => {
386                                         element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
387                                 });
388                         });
389                 });
390         }
393 Element.prototype.injectReplyForm = function(editMarkdownSource) {
394         GWLog("injectReplyForm");
395         let commentControls = this;
396         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
397         let postId = commentControls.parentElement.dataset["postId"];
398         let tagId = commentControls.parentElement.dataset["tagId"];
399         let withparent = (!editMarkdownSource && commentControls.getCommentId());
400         let answer = commentControls.parentElement.id == "answers";
401         let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
402         let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
403         let parentCommentItem = commentControls.closest(".comment-item");
404         let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
405             (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
406         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
407                 "<form method='post'>" + 
408                 "<div class='textarea-container'>" + 
409                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
410                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
411                 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
412                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
413                 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
414                 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
415                 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
416                 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
417                 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
418                 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
419                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
420                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
421                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
422                 "</div><div>" + 
423                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
424                 "<input type='submit' value='Submit'>" + 
425                 "</div></form>";
426         commentControls.onsubmit = disableBeforeUnload;
428         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
429                 GWLog("GW.cancelCommentButtonClicked");
430                 hideReplyForm(event.target.closest(".comment-controls"));
431         });
432         commentControls.scrollIntoViewIfNeeded();
433         commentControls.query("form").onsubmit = (event) => {
434                 if (!event.target.text.value) {
435                         alert("Please enter a comment.");
436                         return false;
437                 }
438         }
439         let textarea = commentControls.query("textarea");
440         textarea.value = MarkdownFromHTML(editMarkdownSource || "");
441         textarea.addTextareaFeatures();
442         textarea.focus();
445 function showCommentEditForm(commentItem) {
446         GWLog("showCommentEditForm");
448         let commentBody = commentItem.query(".comment-body");
449         commentBody.style.display = "none";
451         let commentControls = commentItem.query(".comment-controls");
452         commentControls.injectReplyForm(commentBody.dataset.markdownSource);
453         commentControls.query("form").addClass("edit-existing-comment");
454         expandTextarea(commentControls.query("textarea"));
457 function showReplyForm(commentItem) {
458         GWLog("showReplyForm");
460         let commentControls = commentItem.query(".comment-controls");
461         commentControls.injectReplyForm(commentControls.dataset.enteredText);
464 function hideReplyForm(commentControls) {
465         GWLog("hideReplyForm");
466         // Are we editing a comment? If so, un-hide the existing comment body.
467         let containingComment = commentControls.closest(".comment-item");
468         if (containingComment) containingComment.query(".comment-body").style.display = "";
470         let enteredText = commentControls.query("textarea").value;
471         if (enteredText) commentControls.dataset.enteredText = enteredText;
473         disableBeforeUnload();
474         commentControls.constructCommentControls();
477 function expandTextarea(textarea) {
478         GWLog("expandTextarea");
479         if (window.innerWidth <= 520) return;
481         let totalBorderHeight = 30;
482         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
484         requestAnimationFrame(() => {
485                 textarea.style.height = 'auto';
486                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
487                 if (textarea.clientHeight < window.innerHeight) {
488                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
489                 }
490         });
493 function doCommentAction(action, commentItem) {
494         GWLog("doCommentAction");
495         let params = {};
496         params[(action + "-comment-id")] = commentItem.getCommentId();
497         doAjax({
498                 method: "POST",
499                 params: params,
500                 onSuccess: GW.commentActionPostSucceeded = (event) => {
501                         GWLog("GW.commentActionPostSucceeded");
502                         let fn = {
503                                 retract: () => { commentItem.firstChild.addClass("retracted") },
504                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
505                                 delete: () => {
506                                         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>";
507                                         commentItem.removeChild(commentItem.query(".comment-controls"));
508                                 }
509                         }[action];
510                         if(fn) fn();
511                         if(action != "delete")
512                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
513                 }
514         });
517 /**********/
518 /* VOTING */
519 /**********/
521 function parseVoteType(voteType) {
522         GWLog("parseVoteType");
523         let value = {};
524         if (!voteType) return value;
525         value.up = /[Uu]pvote$/.test(voteType);
526         value.down = /[Dd]ownvote$/.test(voteType);
527         value.big = /^big/.test(voteType);
528         return value;
531 function makeVoteType(value) {
532         GWLog("makeVoteType");
533         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
536 function makeVoteClass(vote) {
537         GWLog("makeVoteClass");
538         if (vote.up || vote.down) {
539                 return (vote.big ? 'selected big-vote' : 'selected');
540         } else {
541                 return '';
542         }
545 function findVoteControls(targetType, targetId, voteAxis) {
546         var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
548         if(targetType == "Post") {
549                 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
550         } else if(targetType == "Comment") {
551                 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
552         }
555 function votesEqual(vote1, vote2) {
556         var allKeys = Object.assign({}, vote1);
557         Object.assign(allKeys, vote2);
559         for(k of allKeys.keys()) {
560                 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
561         }
562         return true;
565 function addVoteButtons(element, vote, targetType) {
566         GWLog("addVoteButtons");
567         vote = vote || {};
568         let voteAxis = element.parentElement.dataset.voteAxis || "karma";
569         let voteType = parseVoteType(vote[voteAxis]);
570         let voteClass = makeVoteClass(voteType);
572         element.parentElement.queryAll("button").forEach((button) => {
573                 button.disabled = false;
574                 if (voteType) {
575                         if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
576                                 button.addClass(voteClass);
577                 }
578                 updateVoteButtonVisualState(button);
579                 button.addActivateEvent(voteButtonClicked);
580         });
583 function updateVoteButtonVisualState(button) {
584         GWLog("updateVoteButtonVisualState");
586         button.removeClasses([ "none", "one", "two-temp", "two" ]);
588         if (button.disabled)
589                 button.addClass("none");
590         else if (button.hasClass("big-vote"))
591                 button.addClass("two");
592         else if (button.hasClass("selected"))
593                 button.addClass("one");
594         else
595                 button.addClass("none");
598 function changeVoteButtonVisualState(button) {
599         GWLog("changeVoteButtonVisualState");
601         /*      Interaction states are:
603                 0  0·    (neutral; +1 click)
604                 1  1·    (small vote; +1 click)
605                 2  2·    (big vote; +1 click)
607                 Visual states are (with their state classes in [brackets]) are:
609                 01    (no vote) [none]
610                 02    (small vote active) [one]
611                 12    (small vote active, temporary indicator of big vote) [two-temp]
612                 22    (big vote active) [two]
614                 The following are the 9 possible interaction state transitions (and
615                 the visual state transitions associated with them):
617                                 VIS.    VIS.
618                 FROM    TO      FROM    TO      NOTES
619                 ====    ====    ====    ====    =====
620                 0       0·      01      12      first click
621                 0·      1       12      02      one click without second
622                 0·      2       12      22      second click
624                 1       1·      02      12      first click
625                 1·      0       12      01      one click without second
626                 1·      2       12      22      second click
628                 2       2·      22      12      first click
629                 2·      1       12      02      one click without second
630                 2·      0       12      01      second click
631         */
632         let transitions = [
633                 [ "big-vote two-temp clicked-twice", "none"     ], // 2· => 0
634                 [ "big-vote two-temp clicked-once",  "one"      ], // 2· => 1
635                 [ "big-vote clicked-once",           "two-temp" ], // 2  => 2·
637                 [ "selected two-temp clicked-twice", "two"      ], // 1· => 2
638                 [ "selected two-temp clicked-once",  "none"     ], // 1· => 0
639                 [ "selected clicked-once",           "two-temp" ], // 1  => 1·
641                 [ "two-temp clicked-twice",          "two"      ], // 0· => 2
642                 [ "two-temp clicked-once",           "one"      ], // 0· => 1
643                 [ "clicked-once",                    "two-temp" ], // 0  => 0·
644         ];
645         for (let [ interactionClasses, visualStateClass ] of transitions) {
646                 if (button.hasClasses(interactionClasses.split(" "))) {
647                         button.removeClasses([ "none", "one", "two-temp", "two" ]);
648                         button.addClass(visualStateClass);
649                         break;
650                 }
651         }
654 function voteCompleteEvent(targetType, targetId, response) {
655         GWLog("voteCompleteEvent");
657         var currentVote = voteData[targetType][targetId] || {};
658         var desiredVote = voteDesired[targetType][targetId];
660         var controls = findVoteControls(targetType, targetId);
661         var controlsByAxis = new Object;
663         controls.forEach(control => {
664                 const voteAxis = (control.dataset.voteAxis || "karma");
666                 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
667                         control.removeClass("waiting");
668                         control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
669                 }
671                 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
672                 controlsByAxis[voteAxis].push(control);
674                 const voteType = currentVote[voteAxis];
675                 const vote = parseVoteType(voteType);
676                 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
677                 const voteClass = makeVoteClass(vote);
679                 if (response && response[voteAxis]) {
680                         const [voteType, displayText, titleText] = response[voteAxis];
682                         const displayTarget = control.query(".karma-value");
683                         if (displayTarget.hasClass("redacted")) {
684                                 displayTarget.dataset["trueValue"] = displayText;
685                         } else {
686                                 displayTarget.innerHTML = displayText;
687                         }
688                         displayTarget.setAttribute("title", titleText);
689                 }
691                 control.queryAll("button.vote").forEach(button => {
692                         updateVoteButton(button, voteUpDown, voteClass);
693                 });
694         });
697 function updateVoteButton(button, voteUpDown, voteClass) {
698         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
699         if (button.dataset.voteType == voteUpDown)
700                 button.addClass(voteClass);
701         updateVoteButtonVisualState(button);
704 function makeVoteRequestCompleteEvent(targetType, targetId) {
705         return (event) => {
706                 var currentVote = {};
707                 var response = null;
709                 if (event.target.status == 200) {
710                         response = JSON.parse(event.target.responseText);
711                         for (const voteAxis of response.keys()) {
712                                 currentVote[voteAxis] = response[voteAxis][0];
713                         }
714                         voteData[targetType][targetId] = currentVote;
715                 } else {
716                         delete voteDesired[targetType][targetId];
717                         currentVote = voteData[targetType][targetId];
718                 }
720                 var desiredVote = voteDesired[targetType][targetId];
722                 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
723                         sendVoteRequest(targetType, targetId);
724                 } else {
725                         delete voteDesired[targetType][targetId];
726                         voteCompleteEvent(targetType, targetId, response);
727                 }
728         }
731 function sendVoteRequest(targetType, targetId) {
732         GWLog("sendVoteRequest");
734         doAjax({
735                 method: "POST",
736                 location: "/karma-vote",
737                 params: { "target": targetId,
738                           "target-type": targetType,
739                           "vote": JSON.stringify(voteDesired[targetType][targetId]) },
740                 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
741         });
744 function voteButtonClicked(event) {
745         GWLog("voteButtonClicked");
746         let voteButton = event.target;
748         // 500 ms (0.5 s) double-click timeout.
749         let doubleClickTimeout = 500;
751         if (!voteButton.clickedOnce) {
752                 voteButton.clickedOnce = true;
753                 voteButton.addClass("clicked-once");
754                 changeVoteButtonVisualState(voteButton);
756                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
757                         if (!voteButton.clickedOnce) return;
759                         // Do single-click code.
760                         voteButton.clickedOnce = false;
761                         voteEvent(voteButton, 1);
762                 }, doubleClickTimeout, voteButton);
763         } else {
764                 voteButton.clickedOnce = false;
766                 // Do double-click code.
767                 voteButton.removeClass("clicked-once");
768                 voteButton.addClass("clicked-twice");
769                 voteEvent(voteButton, 2);
770         }
773 function voteEvent(voteButton, numClicks) {
774         GWLog("voteEvent");
775         voteButton.blur();
777         let voteControl = voteButton.parentNode;
779         let targetType = voteButton.dataset.targetType;
780         let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
781         let voteAxis = voteControl.dataset.voteAxis || "karma";
782         let voteUpDown = voteButton.dataset.voteType;
784         let voteType;
785         if (   (numClicks == 2 && voteButton.hasClass("big-vote"))
786                 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
787                 voteType = "neutral";
788         } else {
789                 let vote = parseVoteType(voteUpDown);
790                 vote.big = (numClicks == 2);
791                 voteType = makeVoteType(vote);
792         }
794         let voteControls = findVoteControls(targetType, targetId, voteAxis);
795         for (const voteControl of voteControls) {
796                 voteControl.addClass("waiting");
797                 voteControl.queryAll(".vote").forEach(button => {
798                         button.addClass("waiting");
799                         updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
800                 });
801         }
803         let voteRequestPending = voteDesired[targetType][targetId];
804         let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
805         voteObject[voteAxis] = voteType;
806         voteDesired[targetType][targetId] = voteObject;
808         if (!voteRequestPending) sendVoteRequest(targetType, targetId);
811 function initializeVoteButtons() {
812         // Color the upvote/downvote buttons with an embedded style sheet.
813         insertHeadHTML("<style id='vote-buttons'>" + `
814                 :root {
815                         --GW-upvote-button-color: #00d800;
816                         --GW-downvote-button-color: #eb4c2a;
817                 }\n` + "</style>");
820 function processVoteData(voteData) {
821         window.voteData = voteData;
823         window.voteDesired = new Object;
824         for(key of voteData.keys()) {
825                 voteDesired[key] = new Object;
826         }
828         initializeVoteButtons();
829         
830         addTriggerListener("postLoaded", {priority: 3000, fn: () => {
831                 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
832                         let postID = karmaValue.parentNode.dataset.postId;
833                         addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
834                         karmaValue.parentElement.addClass("active-controls");
835                 });
836         }});
838         addTriggerListener("DOMReady", {priority: 3000, fn: () => {
839                 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
840                         let commentID = karmaValue.getCommentId();
841                         addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
842                         karmaValue.parentElement.addClass("active-controls");
843                 });
844         }});
847 /*****************************************/
848 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
849 /*****************************************/
851 Element.prototype.getCommentDate = function() {
852         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
853         let dateElement = item && item.query(".date");
854         return (dateElement && parseInt(dateElement.dataset["jsDate"]));
856 function getCurrentVisibleComment() {
857         let px = window.innerWidth/2, py = 5;
858         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
859         let bottomBar = query("#bottom-bar");
860         let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
861         let atbottom =  bottomOffset <= window.innerHeight;
862         if (atbottom) {
863                 let hashci = location.hash && query(location.hash);
864                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
865                         commentItem = hashci;
866                 }
867         }
868         return commentItem;
871 function highlightCommentsSince(date) {
872         GWLog("highlightCommentsSince");
873         var newCommentsCount = 0;
874         GW.newComments = [ ];
875         let oldCommentsStack = [ ];
876         let prevNewComment;
877         queryAll(".comment-item").forEach(commentItem => {
878                 commentItem.prevNewComment = prevNewComment;
879                 commentItem.nextNewComment = null;
880                 if (commentItem.getCommentDate() > date) {
881                         commentItem.addClass("new-comment");
882                         newCommentsCount++;
883                         GW.newComments.push(commentItem.getCommentId());
884                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
885                         oldCommentsStack = [ commentItem ];
886                         prevNewComment = commentItem;
887                 } else {
888                         commentItem.removeClass("new-comment");
889                         oldCommentsStack.push(commentItem);
890                 }
891         });
893         GW.newCommentScrollSet = (commentItem) => {
894                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
895                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
896         };
897         GW.newCommentScrollListener = () => {
898                 let commentItem = getCurrentVisibleComment();
899                 GW.newCommentScrollSet(commentItem);
900         }
902         addScrollListener(GW.newCommentScrollListener);
904         if (document.readyState=="complete") {
905                 GW.newCommentScrollListener();
906         } else {
907                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
908                 GW.newCommentScrollSet(commentItem);
909         }
911         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
913         return newCommentsCount;
916 function scrollToNewComment(next) {
917         GWLog("scrollToNewComment");
918         let commentItem = getCurrentVisibleComment();
919         let targetComment = null;
920         let targetCommentID = null;
921         if (commentItem) {
922                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
923                 if (targetComment) {
924                         targetCommentID = targetComment.getCommentId();
925                 }
926         } else {
927                 if (GW.newComments[0]) {
928                         targetCommentID = GW.newComments[0];
929                         targetComment = query("#comment-" + targetCommentID);
930                 }
931         }
932         if (targetComment) {
933                 expandAncestorsOf(targetCommentID);
934                 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
935                 targetComment.scrollIntoView();
936         }
938         GW.newCommentScrollListener();
941 function getPostHash() {
942         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
943         return (postHash ? postHash[1] : false);
945 function setHistoryLastVisitedDate(date) {
946         window.history.replaceState({ lastVisited: date }, null);
948 function getLastVisitedDate() {
949         // Get the last visited date (or, if posting a comment, the previous last visited date).
950         if(window.history.state) return (window.history.state||{})['lastVisited'];
951         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
952         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
953         let currentVisited = localStorage.getItem(storageName);
954         setHistoryLastVisitedDate(currentVisited);
955         return currentVisited;
957 function setLastVisitedDate(date) {
958         GWLog("setLastVisitedDate");
959         // If NOT posting a comment, save the previous value for the last-visited-date 
960         // (to recover it in case of posting a comment).
961         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
962         if (!aCommentHasJustBeenPosted) {
963                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
964                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
965         }
967         // Set the new value.
968         localStorage.setItem("last-visited-date_" + getPostHash(), date);
971 function updateSavedCommentCount() {
972         GWLog("updateSavedCommentCount");
973         let commentCount = queryAll(".comment").length;
974         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
976 function badgePostsWithNewComments() {
977         GWLog("badgePostsWithNewComments");
978         if (getQueryVariable("show") == "conversations") return;
980         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
981                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
983                 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
984                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
985                 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
987                 if (currentCommentCount > savedCommentCount)
988                         commentCountDisplay.addClass("new-comments");
989                 else
990                         commentCountDisplay.removeClass("new-comments");
991                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
992         });
996 /*****************/
997 /* MEDIA QUERIES */
998 /*****************/
1000 GW.mediaQueries = {
1001     systemDarkModeActive:  matchMedia("(prefers-color-scheme: dark)")
1005 /************************/
1006 /* ACTIVE MEDIA QUERIES */
1007 /************************/
1009 /*  This function provides two slightly different versions of its functionality,
1010     depending on how many arguments it gets.
1012     If one function is given (in addition to the media query and its name), it
1013     is called whenever the media query changes (in either direction).
1015     If two functions are given (in addition to the media query and its name),
1016     then the first function is called whenever the media query starts matching,
1017     and the second function is called whenever the media query stops matching.
1019     If you want to call a function for a change in one direction only, pass an
1020     empty closure (NOT null!) as one of the function arguments.
1022     There is also an optional fifth argument. This should be a function to be
1023     called when the active media query is canceled.
1024  */
1025 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1026     if (typeof GW.mediaQueryResponders == "undefined")
1027         GW.mediaQueryResponders = { };
1029     let mediaQueryResponder = (event, canceling = false) => {
1030         if (canceling) {
1031             GWLog(`Canceling media query “${name}”`, "media queries", 1);
1033             if (whenCanceledDo != null)
1034                 whenCanceledDo(mediaQuery);
1035         } else {
1036             let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1038             GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1040             if ((otherwiseDo == null) || matches)
1041                 ifMatchesOrAlwaysDo(mediaQuery);
1042             else
1043                 otherwiseDo(mediaQuery);
1044         }
1045     };
1046     mediaQueryResponder();
1047     mediaQuery.addListener(mediaQueryResponder);
1049     GW.mediaQueryResponders[name] = mediaQueryResponder;
1052 /*  Deactivates and discards an active media query, after calling the function
1053     that was passed as the whenCanceledDo parameter when the media query was
1054     added.
1055  */
1056 function cancelDoWhenMatchMedia(name) {
1057     GW.mediaQueryResponders[name](null, true);
1059     for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1060         mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1062     GW.mediaQueryResponders[name] = null;
1066 /******************************/
1067 /* DARK/LIGHT MODE ADJUSTMENT */
1068 /******************************/
1070 DarkMode = {
1071         /*****************/
1072         /*      Configuration.
1073          */
1074         modeOptions: [
1075                 [ "auto", "&#xf042;", "Set light or dark mode automatically, according to system-wide setting (Win: Start → Personalization → Colors; Mac: Apple → System-Preferences → General → Appearance; iOS: Settings → Display-and-Brightness; Android: Settings → Display)" ],
1076                 [ "light", "&#xe28f;", "Light mode at all times (black-on-white)" ],
1077                 [ "dark", "&#xf186;", "Dark mode at all times (inverted: white-on-black)" ]
1078         ],
1080         selectedModeOptionNote: " [This option is currently selected.]",
1082         /******************/
1083         /*      Infrastructure.
1084          */
1086         modeSelector: null,
1087         modeSelectorInteractable: true,
1089         /******************/
1090         /*      Mode selection.
1091          */
1093     /*  Returns current (saved) mode (light, dark, or auto).
1094      */
1095     getSavedMode: () => {
1096         return (localStorage.getItem("dark-mode-setting") || "auto");
1097     },
1099         /*      Saves specified mode (light, dark, or auto).
1100          */
1101         saveMode: (mode) => {
1102                 GWLog("DarkMode.setMode");
1104                 if (mode == "auto")
1105                         localStorage.removeItem("dark-mode-setting");
1106                 else
1107                         localStorage.setItem("dark-mode-setting", mode);
1108         },
1110         /*  Set specified color mode (light, dark, or auto).
1111          */
1112         setMode: (selectedMode = DarkMode.getSavedMode()) => {
1113                 GWLog("DarkMode.setMode");
1115                 //      The style block should be inlined (and already loaded).
1116                 let darkModeStyles = document.querySelector("#inlined-dark-mode-styles");
1117                 if (darkModeStyles) {
1118                         //      Set `media` attribute of style block to match requested mode.
1119                         if (selectedMode == "auto") {
1120                                 darkModeStyles.media = "all and (prefers-color-scheme: dark)";
1121                         } else if (selectedMode == "dark") {
1122                                 darkModeStyles.media = "all";
1123                         } else {
1124                                 darkModeStyles.media = "not all";
1125                         }
1126                 }
1128                 //      Update state.
1129                 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1130         },
1132         modeSelectorHTML: (inline = false) => {
1133                 let selectorTagName = (inline ? "span" : "div");
1134                 let selectorId = (inline ? `` : ` id="dark-mode-selector"`);
1135                 let selectorClass = (` class="dark-mode-selector mode-selector` + (inline ? ` mode-selector-inline` : ``) + `"`);
1137                 //      Get saved mode setting (or default).
1138                 let currentMode = DarkMode.getSavedMode();
1140                 return `<${selectorTagName}${selectorId}${selectorClass}>`
1141                         + DarkMode.modeOptions.map(modeOption => {
1142                                 let [ name, label, desc ] = modeOption;
1143                                 let selected = (name == currentMode ? " selected" : "");
1144                                 let disabled = (name == currentMode ? " disabled" : "");
1145                                 let active = ((   currentMode == "auto"
1146                                                            && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1147                                                           ? " active"
1148                                                           : "");
1149                                 if (name == currentMode)
1150                                         desc += DarkMode.selectedModeOptionNote;
1151                                 return `<button
1152                                                         type="button"
1153                                                         class="select-mode-${name}${selected}${active}"
1154                                                         ${disabled}
1155                                                         tabindex="-1"
1156                                                         data-name="${name}"
1157                                                         title="${desc}"
1158                                                                 >${label}</button>`;
1159                           }).join("")
1160                         + `</${selectorTagName}>`;
1161         },
1163         injectModeSelector: (replacedElement = null) => {
1164                 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1166                 //      Inject the mode selector widget.
1167                 let modeSelector;
1168                 if (replacedElement) {
1169                         replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1170                         modeSelector = replacedElement.firstElementChild;
1171                         unwrap(replacedElement);
1172                 } else {
1173                         modeSelector = DarkMode.modeSelector = addUIElement(DarkMode.modeSelectorHTML());
1174                 }
1176                 //  Add event listeners and update state.
1177                 requestAnimationFrame(() => {
1178                         //      Activate mode selector widget buttons.
1179                         modeSelector.querySelectorAll("button").forEach(button => {
1180                                 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1181                         });
1182                 });
1184                 /*      Add active media query to update mode selector state when system dark
1185                         mode setting changes. (This is relevant only for the ‘auto’ setting.)
1186                  */
1187                 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => { 
1188                         DarkMode.updateModeSelectorState(modeSelector);
1189                 });
1190         },
1192         modeSelectButtonClicked: (event) => {
1193                 GWLog("DarkMode.modeSelectButtonClicked");
1195                 /*      We don’t want clicks to go through if the transition 
1196                         between modes has not completed yet, so we disable the 
1197                         button temporarily while we’re transitioning between 
1198                         modes.
1199                  */
1200                 doIfAllowed(() => {
1201                         // Determine which setting was chosen (ie. which button was clicked).
1202                         let selectedMode = event.target.dataset.name;
1204                         // Save the new setting.
1205                         DarkMode.saveMode(selectedMode);
1207                         // Actually change the mode.
1208                         DarkMode.setMode(selectedMode);
1209                 }, DarkMode, "modeSelectorInteractable");
1211                 event.target.blur();
1212         },
1214         updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1215                 GWLog("DarkMode.updateModeSelectorState");
1217                 /*      If the mode selector has not yet been injected, then do nothing.
1218                  */
1219                 if (modeSelector == null)
1220                         return;
1222                 //      Get saved mode setting (or default).
1223                 let currentMode = DarkMode.getSavedMode();
1225                 //      Clear current buttons state.
1226                 modeSelector.querySelectorAll("button").forEach(button => {
1227                         button.classList.remove("active", "selected");
1228                         button.disabled = false;
1229                         if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1230                                 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1231                 });
1233                 //      Set the correct button to be selected.
1234                 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1235                         button.classList.add("selected");
1236                         button.disabled = true;
1237                         button.title += DarkMode.selectedModeOptionNote;
1238                 });
1240                 /*      Ensure the right button (light or dark) has the “currently active” 
1241                         indicator, if the current mode is ‘auto’.
1242                  */
1243                 if (currentMode == "auto")
1244                         modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1245         }
1249 /****************************/
1250 /* APPEARANCE CUSTOMIZATION */
1251 /****************************/
1253 Appearance = { ...Appearance,
1254         /******************/
1255         /*      Infrastructure.
1256          */
1258         noFilters: { },
1260         themeSelector: null,
1262         themeTweakerToggle: null,
1264         themeTweakerUI: null,
1265         themeTweakerUIMainWindow: null,
1266         themeTweakerUIHelpWindow: null,
1267         themeTweakerUISampleTextContainer: null,
1268         themeTweakerUIClippyContainer: null,
1269         themeTweakerUIClippyControl: null,
1271         widthSelector: null,
1273         textSizeAdjustmentWidget: null,
1275         appearanceAdjustUIToggle: null,
1277         /*****************/
1278         /*      Functionality.
1279          */
1281         makeNewStyle: (newThemeName, colorSchemePreference) => {
1282                 let styleSheetNameSuffix = (newThemeName == Appearance.defaultTheme) ? "" : ("-" + newThemeName);
1283                 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1285                 let newStyle = document.createElement("link");
1286                 newStyle.setAttribute("class", "theme");
1287                 if (colorSchemePreference)
1288                         newStyle.setAttribute("media", "(prefers-color-scheme: " + colorSchemePreference + ")");
1289                 newStyle.setAttribute("rel", "stylesheet");
1290                 newStyle.setAttribute("href", "/css/style" + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1291                 return newStyle;
1292         },
1294         setTheme: (newThemeName, save = true) => {
1295                 GWLog("Appearance.setTheme");
1297                 let themeUnloadCallback = "";
1298                 let oldThemeName = "";
1299                 if (typeof(newThemeName) == "undefined") {
1300                         newThemeName = Appearance.currentTheme;
1301                         if (newThemeName == Appearance.defaultTheme)
1302                                 return;
1303                 } else {
1304                         oldThemeName = Appearance.currentTheme;
1305                         themeUnloadCallback = Appearance.themeUnloadCallbacks[oldThemeName];
1307                         Appearance.currentTheme = newThemeName;
1308                         if (save)
1309                                 Appearance.saveCurrentTheme();
1310                 }
1311                 if (themeUnloadCallback != null)
1312                         themeUnloadCallback(newThemeName);
1314                 let newMainStyle, newStyles;
1315                 if (newThemeName === Appearance.defaultTheme) {
1316                         newStyles = [ Appearance.makeNewStyle("dark", "dark"), Appearance.makeNewStyle(Appearance.defaultTheme, "light") ];
1317                         newMainStyle = (window.matchMedia("prefers-color-scheme: dark").matches ? newStyles[0] : newStyles[1]);
1318                 } else {
1319                         newStyles = [ Appearance.makeNewStyle(newThemeName) ];
1320                         newMainStyle = newStyles[0];
1321                 }
1323                 let oldStyles = queryAll("head link.theme");
1324                 newMainStyle.addEventListener("load", (event) => { oldStyles.forEach(x => removeElement(x)); });
1325                 newMainStyle.addEventListener("load", (event) => { Appearance.postSetThemeHousekeeping(oldThemeName, newThemeName); });
1327                 if (Appearance.adjustmentTransitions) {
1328                         pageFadeTransition(false);
1329                         setTimeout(() => {
1330                                 newStyles.forEach(newStyle => document.head.insertBefore(newStyle, oldStyles[0].nextSibling));
1331                         }, 500);
1332                 } else {
1333                         newStyles.forEach(newStyle => document.head.insertBefore(newStyle, oldStyles[0].nextSibling));
1334                 }
1336                 Appearance.updateThemeSelectorsState();
1337         },
1339         postSetThemeHousekeeping: (oldThemeName = "", newThemeName = null) => {
1340                 GWLog("Appearance.postSetThemeHousekeeping");
1342                 if (newThemeName == null)
1343                         newThemeName = Appearance.getSavedTheme();
1345                 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1346                 document.body.addClass("theme-" + newThemeName);
1348                 recomputeUIElementsContainerHeight(true);
1350                 let themeLoadCallback = Appearance.themeLoadCallbacks[newThemeName];
1351                 if (themeLoadCallback != null)
1352                         themeLoadCallback(oldThemeName);
1354                 recomputeUIElementsContainerHeight();
1355                 adjustUIForWindowSize();
1356                 window.addEventListener("resize", GW.windowResized = (event) => {
1357                         GWLog("GW.windowResized");
1358                         adjustUIForWindowSize();
1359                         recomputeUIElementsContainerHeight();
1360                 });
1362                 generateImagesOverlay();
1364                 if (Appearance.adjustmentTransitions)
1365                         pageFadeTransition(true);
1366                 Appearance.updateThemeTweakerSampleText();
1368                 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== "undefined") {
1369                         window.matchMedia("(orientation: portrait)").addListener(generateImagesOverlay);
1370                 }
1371         },
1373         themeLoadCallbacks: {
1374                 brutalist: (fromTheme = "") => {
1375                         GWLog("Appearance.themeLoadCallbacks.brutalist");
1377                         let bottomBarLinks = queryAll("#bottom-bar a");
1378                         if (!GW.isMobile && bottomBarLinks.length == 5) {
1379                                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1380                                 bottomBarLinks.forEach((link, i) => {
1381                                         link.dataset.originalText = link.textContent;
1382                                         link.textContent = newLinkTexts[i];
1383                                 });
1384                         }
1385                 },
1387                 classic: (fromTheme = "") => {
1388                         GWLog("Appearance.themeLoadCallbacks.classic");
1390                         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1391                                 button.innerHTML = "";
1392                         });
1393                 },
1395                 dark: (fromTheme = "") => {
1396                         GWLog("Appearance.themeLoadCallbacks.dark");
1398                         insertHeadHTML(`<style id="dark-theme-adjustments">`  
1399                                 + `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }`
1400                                 + `#bottom-bar.decorative::before { filter: invert(100%); }`
1401                                 + `</style>`);
1402                         registerInitializer("makeImagesGlow", true, () => query("#images-overlay") != null, () => {
1403                                 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1404                                         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)";
1405                                         image.style.width = parseInt(image.style.width) + 12 + "px";
1406                                         image.style.height = parseInt(image.style.height) + 12 + "px";
1407                                         image.style.top = parseInt(image.style.top) - 6 + "px";
1408                                         image.style.left = parseInt(image.style.left) - 6 + "px";
1409                                 });
1410                         });
1411                 },
1413                 less: (fromTheme = "") => {
1414                         GWLog("Appearance.themeLoadCallbacks.less");
1416                         injectSiteNavUIToggle();
1417                         if (!GW.isMobile) {
1418                                 injectPostNavUIToggle();
1419                                 Appearance.injectAppearanceAdjustUIToggle();
1420                         }
1422                         registerInitializer("shortenDate", true, () => query(".top-post-meta") != null, function () {
1423                                 let dtf = new Intl.DateTimeFormat([], 
1424                                         (window.innerWidth < 1100) ? 
1425                                                 { month: "short", day: "numeric", year: "numeric" } : 
1426                                                         { month: "long", day: "numeric", year: "numeric" });
1427                                 let postDate = query(".top-post-meta .date");
1428                                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1429                         });
1431                         if (GW.isMobile) {
1432                                 query("#content").insertAdjacentHTML("beforeend", `<div id="theme-less-mobile-first-row-placeholder"></div>`);
1433                         }
1435                         if (!GW.isMobile) {
1436                                 registerInitializer("addSpans", true, () => query(".top-post-meta") != null, function () {
1437                                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1438                                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1439                                         });
1440                                 });
1442                                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1443                                         // If state is not set (user has never clicked on the Less theme’s appearance
1444                                         // adjustment UI toggle) then show it, but then hide it after a short time.
1445                                         registerInitializer("engageAppearanceAdjustUI", true, () => query("#ui-elements-container") != null, function () {
1446                                                 Appearance.toggleAppearanceAdjustUI();
1447                                                 setTimeout(Appearance.toggleAppearanceAdjustUI, 3000);
1448                                         });
1449                                 }
1451                                 if (fromTheme != "") {
1452                                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1453                                         setTimeout(function () {
1454                                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1455                                         }, 300);
1456                                         setTimeout(function () {
1457                                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1458                                         }, 1800);
1459                                 }
1461                                 // Unset the height of the #ui-elements-container.
1462                                 query("#ui-elements-container").style.height = "";
1464                                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1465                                 Appearance.filtersExclusionPaths.themeLess = [
1466                                         "#content #secondary-bar",
1467                                         "#content .post .top-post-meta .date",
1468                                         "#content .post .top-post-meta .comment-count",
1469                                 ];
1470                                 Appearance.applyFilters();
1471                         }
1473                         // We pre-query the relevant elements, so we don’t have to run querySelectorAll
1474                         // on every firing of the scroll listener.
1475                         GW.scrollState = {
1476                                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1477                                 "unbrokenDownScrollDistance":           0,
1478                                 "unbrokenUpScrollDistance":                     0,
1479                                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1480                                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1481                                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1482                         };
1483                         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1484                 }
1485         },
1487         themeUnloadCallbacks: {
1488                 brutalist: (toTheme = "") => {
1489                         GWLog("Appearance.themeUnloadCallbacks.brutalist");
1491                         let bottomBarLinks = queryAll("#bottom-bar a");
1492                         if (!GW.isMobile && bottomBarLinks.length == 5) {
1493                                 bottomBarLinks.forEach(link => {
1494                                         link.textContent = link.dataset.originalText;
1495                                 });
1496                         }
1497                 },
1499                 classic: (toTheme = "") => {
1500                         GWLog("Appearance.themeUnloadCallbacks.classic");
1502                         if (GW.isMobile && window.innerWidth <= 900)
1503                                 return;
1505                         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1506                                 button.innerHTML = button.dataset.label;
1507                         });
1508                 },
1510                 dark: (toTheme = "") => {
1511                         GWLog("Appearance.themeUnloadCallbacks.dark");
1513                         removeElement("#dark-theme-adjustments");
1514                 },
1516                 less: (toTheme = "") => {
1517                         GWLog("Appearance.themeUnloadCallbacks.less");
1519                         removeSiteNavUIToggle();
1520                         if (!GW.isMobile) {
1521                                 removePostNavUIToggle();
1522                                 Appearance.removeAppearanceAdjustUIToggle();
1523                         }
1525                         window.removeEventListener("resize", updatePostNavUIVisibility);
1527                         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1529                         removeElement("#theme-less-mobile-first-row-placeholder");
1531                         if (!GW.isMobile) {
1532                                 // Remove spans
1533                                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1534                                         element.innerHTML = element.firstChild.innerHTML;
1535                                 });
1536                         }
1538                         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1540                         //      Reset filtered elements selector to default.
1541                         delete Appearance.filtersExclusionPaths.themeLess;
1542                         Appearance.applyFilters();
1543                 }
1544         },
1546         pageFadeTransition: (fadeIn) => {
1547                 if (fadeIn) {
1548                         document.body.removeClass("transparent");
1549                 } else {
1550                         document.body.addClass("transparent");
1551                 }
1552         },
1554         saveCurrentTheme: () => {
1555                 GWLog("Appearance.saveCurrentTheme");
1557                 if (Appearance.currentTheme == Appearance.defaultTheme)
1558                         setCookie("theme", "");
1559                 else
1560                         setCookie("theme", Appearance.currentTheme);
1561         },
1563         themeTweakReset: () => {
1564                 GWLog("Appearance.themeTweakReset");
1566                 Appearance.setTheme(Appearance.getSavedTheme());
1567                 Appearance.applyFilters(Appearance.getSavedFilters());
1568                 Appearance.setTextZoom(Appearance.getSavedTextZoom());
1569         },
1571         themeTweakSave: () => {
1572                 GWLog("Appearance.themeTweakSave");
1574                 Appearance.saveCurrentTheme();
1575                 Appearance.saveCurrentFilters();
1576                 Appearance.saveCurrentTextZoom();
1577         },
1579         themeTweakResetDefaults: () => {
1580                 GWLog("Appearance.themeTweakResetDefaults");
1582                 Appearance.setTheme(Appearance.defaultTheme);
1583                 Appearance.applyFilters(Appearance.defaultFilters);
1584                 Appearance.setTextZoom(Appearance.defaultTextZoom);
1585         },
1587         themeTweakerResetSettings: () => {
1588                 GWLog("Appearance.themeTweakerResetSettings");
1590                 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
1591                 Appearance.themeTweakerUIClippyContainer.style.display = Appearance.themeTweakerUIClippyControl.checked 
1592                                                                                                                                  ? "block" 
1593                                                                                                                                  : "none";
1594         },
1596         themeTweakerSaveSettings: () => {
1597                 GWLog("Appearance.themeTweakerSaveSettings");
1599                 Appearance.saveThemeTweakerClippyState();
1600         },
1602         getSavedThemeTweakerClippyState: () => {
1603                 return (JSON.parse(localStorage.getItem("theme-tweaker-settings") || `{ "showClippy": ${Appearance.defaultThemeTweakerClippyState} }` )["showClippy"]);
1604         },
1606         saveThemeTweakerClippyState: () => {
1607                 GWLog("Appearance.saveThemeTweakerClippyState");
1609                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ "showClippy": Appearance.themeTweakerUIClippyControl.checked }));
1610         },
1612         getSavedAppearanceAdjustUIToggleState: () => {
1613                 return ((localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") || Appearance.defaultAppearanceAdjustUIToggleState);
1614         },
1616         saveAppearanceAdjustUIToggleState: () => {
1617                 GWLog("Appearance.saveAppearanceAdjustUIToggleState");
1619                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", Appearance.appearanceAdjustUIToggle.query("button").hasClass("engaged"));
1620         },
1622         /******/
1623         /*      UI.
1624          */
1626         contentWidthSelectorHTML: () => {
1627                 return ("<div id='width-selector'>"
1628                         + String.prototype.concat.apply("", Appearance.widthOptions.map(widthOption => {
1629                                 let [name, desc, abbr] = widthOption;
1630                                 let selected = (name == Appearance.currentWidth ? " selected" : "");
1631                                 let disabled = (name == Appearance.currentWidth ? " disabled" : "");
1632                                 return `<button type="button" class="select-width-${name}${selected}"${disabled} title="${desc}" tabindex="-1" data-name="${name}">${abbr}</button>`
1633                         }))
1634                 + "</div>");
1635         },
1637         injectContentWidthSelector: () => {
1638                 GWLog("Appearance.injectContentWidthSelector");
1640                 //      Inject the content width selector widget and activate buttons.
1641                 Appearance.widthSelector = addUIElement(Appearance.contentWidthSelectorHTML());
1642                 Appearance.widthSelector.queryAll("button").forEach(button => {
1643                         button.addActivateEvent(Appearance.widthAdjustButtonClicked);
1644                 });
1646                 //      Make sure the accesskey (to cycle to the next width) is on the right button.
1647                 Appearance.setWidthAdjustButtonsAccesskey();
1649                 //      Inject transitions CSS, if animating changes is enabled.
1650                 if (Appearance.adjustmentTransitions) {
1651                         insertHeadHTML(
1652                                 "<style id='width-transition'>" + 
1653                                 `#content,
1654                                 #ui-elements-container,
1655                                 #images-overlay {
1656                                         transition:
1657                                                 max-width 0.3s ease;
1658                                 }` + 
1659                                 "</style>");
1660                 }
1661         },
1663         setWidthAdjustButtonsAccesskey: () => {
1664                 GWLog("Appearance.setWidthAdjustButtonsAccesskey");
1666                 Appearance.widthSelector.queryAll("button").forEach(button => {
1667                         button.removeAttribute("accesskey");
1668                         button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1669                 });
1670                 let selectedButton = Appearance.widthSelector.query("button.selected");
1671                 let nextButtonInCycle = selectedButton == selectedButton.parentElement.lastChild
1672                                                                                                   ? selectedButton.parentElement.firstChild 
1673                                                                                                   : selectedButton.nextSibling;
1674                 nextButtonInCycle.accessKey = "'";
1675                 nextButtonInCycle.title += ` [\']`;
1676         },
1678         injectTextSizeAdjustmentUI: () => {
1679                 GWLog("Appearance.injectTextSizeAdjustmentUI");
1681                 if (Appearance.textSizeAdjustmentWidget != null)
1682                         return;
1684                 let inject = () => {
1685                         GWLog("Appearance.injectTextSizeAdjustmentUI [INJECTING]");
1687                         Appearance.textSizeAdjustmentWidget = addUIElement("<div id='text-size-adjustment-ui'>"
1688                                 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
1689                                 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1690                                 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
1691                         + "</div>");
1693                         Appearance.textSizeAdjustmentWidget.queryAll("button").forEach(button => {
1694                                 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1695                         });
1696                 };
1698                 if (query("#content.post-page") != null) {
1699                         inject();
1700                 } else {
1701                         document.addEventListener("DOMContentLoaded", () => {
1702                                 if (!(   query(".post-body") == null 
1703                                           && query(".comment-body") == null))
1704                                         inject();
1705                         }, { once: true });
1706                 }
1707         },
1709         themeSelectorHTML: () => {
1710                 return ("<div id='theme-selector' class='theme-selector'>"
1711                         + String.prototype.concat.apply("", Appearance.themeOptions.map(themeOption => {
1712                                 let [name, desc, letter] = themeOption;
1713                                 let selected = (name == Appearance.currentTheme ? ' selected' : '');
1714                                 let disabled = (name == Appearance.currentTheme ? ' disabled' : '');
1715                                 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1716                                 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>`;
1717                         }))
1718                 + "</div>");
1719         },
1721         injectThemeSelector: () => {
1722                 GWLog("Appearance.injectThemeSelector");
1724                 Appearance.themeSelector = addUIElement(Appearance.themeSelectorHTML());
1725                 Appearance.themeSelector.queryAll("button").forEach(button => {
1726                         button.addActivateEvent(Appearance.themeSelectButtonClicked);
1727                 });
1729                 // Inject transitions CSS, if animating changes is enabled.
1730                 if (Appearance.adjustmentTransitions) {
1731                         insertHeadHTML("<style id='theme-fade-transition'>" + 
1732                                 `body {
1733                                         transition:
1734                                                 opacity 0.5s ease-out,
1735                                                 background-color 0.3s ease-out;
1736                                 }
1737                                 body.transparent {
1738                                         background-color: #777;
1739                                         opacity: 0.0;
1740                                         transition:
1741                                                 opacity 0.5s ease-in,
1742                                                 background-color 0.3s ease-in;
1743                                 }` + 
1744                         "</style>");
1745                 }
1746         },
1748         updateThemeSelectorsState: () => {
1749                 GWLog("Appearance.updateThemeSelectorsState");
1751                 queryAll(".theme-selector button").forEach(button => {
1752                         button.removeClass("selected");
1753                         button.disabled = false;
1754                 });
1755                 queryAll(".theme-selector button.select-theme-" + Appearance.currentTheme).forEach(button => {
1756                         button.addClass("selected");
1757                         button.disabled = true;
1758                 });
1760                 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.currentTheme;
1761         },
1763         themeTweakerUIHTML: () => {
1764                 return (`<div id="theme-tweaker-ui" style="display: none;">` 
1765                         + `<div class="main-theme-tweaker-window">
1766                                 <h1>Customize appearance</h1>
1767                                 <button type="button" class="minimize-button minimize" tabindex="-1"></button>
1768                                 <button type="button" class="help-button" tabindex="-1"></button>
1769                                 <p class="current-theme">Current theme: <span>` + 
1770                                 Appearance.getSavedTheme() + 
1771                                 `</span></p>
1772                                 <p class="theme-selector"></p>
1773                                 <div class="controls-container">
1774                                         <div id="theme-tweak-section-sample-text" class="section" data-label="Sample text">
1775                                                 <div class="sample-text-container"><span class="sample-text">
1776                                                         <p>Less Wrong (text)</p>
1777                                                         <p><a href="#">Less Wrong (link)</a></p>
1778                                                 </span></div>
1779                                         </div>
1780                                         <div id="theme-tweak-section-text-size-adjust" class="section" data-label="Text size">
1781                                                 <button type="button" class="text-size-adjust-button decrease" title="Decrease text size"></button>
1782                                                 <button type="button" class="text-size-adjust-button default" title="Reset to default text size"></button>
1783                                                 <button type="button" class="text-size-adjust-button increase" title="Increase text size"></button>
1784                                         </div>
1785                                         <div id="theme-tweak-section-invert" class="section" data-label="Invert (photo-negative)">
1786                                                 <input type="checkbox" id="theme-tweak-control-invert"></input>
1787                                                 <label for="theme-tweak-control-invert">Invert colors</label>
1788                                         </div>
1789                                         <div id="theme-tweak-section-saturate" class="section" data-label="Saturation">
1790                                                 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1791                                                 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1792                                                 <div class="notch theme-tweak-slider-notch-saturate" title="Reset saturation to default value (100%)"></div>
1793                                         </div>
1794                                         <div id="theme-tweak-section-brightness" class="section" data-label="Brightness">
1795                                                 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1796                                                 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1797                                                 <div class="notch theme-tweak-slider-notch-brightness" title="Reset brightness to default value (100%)"></div>
1798                                         </div>
1799                                         <div id="theme-tweak-section-contrast" class="section" data-label="Contrast">
1800                                                 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1801                                                 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1802                                                 <div class="notch theme-tweak-slider-notch-contrast" title="Reset contrast to default value (100%)"></div>
1803                                         </div>
1804                                         <div id="theme-tweak-section-hue-rotate" class="section" data-label="Hue rotation">
1805                                                 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1806                                                 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1807                                                 <div class="notch theme-tweak-slider-notch-hue-rotate" title="Reset hue to default (0° away from standard colors for theme)"></div>
1808                                         </div>
1809                                 </div>
1810                                 <div class="buttons-container">
1811                                         <button type="button" class="reset-defaults-button">Reset to defaults</button>
1812                                         <button type="button" class="ok-button default-button">OK</button>
1813                                         <button type="button" class="cancel-button">Cancel</button>
1814                                 </div>
1815                         </div>
1816                         <div class="clippy-container">
1817                                 <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>)
1818                                 <div class="clippy"></div>
1819                                 <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>
1820                         </div>
1821                         <div class="help-window" style="display: none;">
1822                                 <h1>Theme tweaker help</h1>
1823                                 <div id="theme-tweak-section-clippy" class="section" data-label="Theme Tweaker Assistant">
1824                                         <input type="checkbox" id="theme-tweak-control-clippy" checked="checked"></input>
1825                                         <label for="theme-tweak-control-clippy">Show Bobby the Basilisk</label>
1826                                 </div>
1827                                 <div class="buttons-container">
1828                                         <button type="button" class="ok-button default-button">OK</button>
1829                                         <button type="button" class="cancel-button">Cancel</button>
1830                                 </div>
1831                         </div>
1832                 ` + "</div>");
1833         },
1835         injectThemeTweaker: () => {
1836                 GWLog("Appearance.injectThemeTweaker");
1838                 Appearance.themeTweakerUI = addUIElement(Appearance.themeTweakerUIHTML());
1839                 Appearance.themeTweakerUIMainWindow = Appearance.themeTweakerUI.firstElementChild;
1840                 Appearance.themeTweakerUIHelpWindow = Appearance.themeTweakerUI.query(".help-window");
1841                 Appearance.themeTweakerUISampleTextContainer = Appearance.themeTweakerUI.query("#theme-tweak-section-sample-text .sample-text-container");
1842                 Appearance.themeTweakerUIClippyContainer = Appearance.themeTweakerUI.query(".clippy-container");
1843                 Appearance.themeTweakerUIClippyControl = Appearance.themeTweakerUI.query("#theme-tweak-control-clippy");
1845                 //      Clicking the background overlay closes the theme tweaker.
1846                 Appearance.themeTweakerUI.addActivateEvent(Appearance.themeTweakerUIOverlayClicked, true);
1848                 //      Intercept clicks, so they don’t “fall through” the background overlay.
1849                 Appearance.themeTweakerUIMainWindow.addActivateEvent((event) => {
1850                         event.stopPropagation();
1851                 }, true);
1853                 Appearance.themeTweakerUI.queryAll("input").forEach(field => {
1854                         /*      All input types in the theme tweaker receive a ‘change’ event 
1855                                 when their value is changed. (Range inputs, in particular, 
1856                                 receive this event when the user lets go of the handle.) This 
1857                                 means we should update the filters for the entire page, to match 
1858                                 the new setting.
1859                          */
1860                         field.addEventListener("change", Appearance.themeTweakerUIFieldValueChanged);
1862                         /*      Range inputs receive an ‘input’ event while being scrubbed, 
1863                                 updating “live” as the handle is moved. We don’t want to change 
1864                                 the filters for the actual page while this is happening, but we 
1865                                 do want to change the filters for the *sample text*, so the user
1866                                 can see what effects his changes are having, live, without 
1867                                 having to let go of the handle.
1868                          */
1869                         if (field.type == "range")
1870                                 field.addEventListener("input", Appearance.themeTweakerUIFieldInputReceived);
1871                 });
1873                 Appearance.themeTweakerUI.query(".minimize-button").addActivateEvent(Appearance.themeTweakerUIMinimizeButtonClicked);
1874                 Appearance.themeTweakerUI.query(".help-button").addActivateEvent(Appearance.themeTweakerUIHelpButtonClicked);
1875                 Appearance.themeTweakerUI.query(".reset-defaults-button").addActivateEvent(Appearance.themeTweakerUIResetDefaultsButtonClicked);
1876                 Appearance.themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(Appearance.themeTweakerUICancelButtonClicked);
1877                 Appearance.themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(Appearance.themeTweakerUIOKButtonClicked);
1878                 Appearance.themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowCancelButtonClicked);
1879                 Appearance.themeTweakerUI.query(".help-window .ok-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowOKButtonClicked);
1881                 Appearance.themeTweakerUI.queryAll(".notch").forEach(notch => {
1882                         notch.addActivateEvent(Appearance.themeTweakerUISliderNotchClicked);
1883                 });
1885                 Appearance.themeTweakerUI.query(".clippy-close-button").addActivateEvent(Appearance.themeTweakerUIClippyCloseButtonClicked);
1887                 insertHeadHTML(`<style id="theme-tweaker-style"></style>`);
1889                 Appearance.themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1890                 Appearance.themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1891                         button.addActivateEvent(Appearance.themeSelectButtonClicked);
1892                 });
1894                 Appearance.themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1895                         button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1896                 });
1898                 Appearance.themeTweakerToggle = addUIElement(`<div id="theme-tweaker-toggle">`
1899                                                                                 + `<button 
1900                                                                                                 type="button" 
1901                                                                                                 tabindex="-1" 
1902                                                                                                 title="Customize appearance [;]" 
1903                                                                                                 accesskey=";"
1904                                                                                                         >&#xf1de;</button>`
1905                                                                                 + `</div>`);
1906                 Appearance.themeTweakerToggle.query("button").addActivateEvent(Appearance.themeTweakerToggleClicked);
1907         },
1909         showThemeTweakerUI: () => {
1910                 GWLog("Appearance.showThemeTweakerUI");
1912                 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.getSavedTheme();
1914                 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = (Appearance.currentFilters["invert"] == "100%");
1915                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1916                         let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1917                         slider.value = /^[0-9]+/.exec(Appearance.currentFilters[sliderName]) || slider.dataset["defaultValue"];
1918                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
1919                 });
1921                 Appearance.toggleThemeTweakerUI();
1922                 event.target.disabled = true;
1923         },
1925         toggleThemeTweakerUI: () => {
1926                 GWLog("Appearance.toggleThemeTweakerUI");
1928                 Appearance.themeTweakerUI.style.display = Appearance.themeTweakerUI.style.display == "none" 
1929                                                                                           ? "block" 
1930                                                                                           : "none";
1931                 query("#theme-tweaker-style").innerHTML = Appearance.themeTweakerUI.style.display == "none" 
1932                                                                                                   ? "" 
1933                                                                                                   : `#content, #ui-elements-container > div:not(#theme-tweaker-ui) { pointer-events: none; }`;
1935                 if (Appearance.themeTweakerUI.style.display != "none") {
1936                         // Focus invert checkbox.
1937                         Appearance.themeTweakerUI.query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1938                         // Show sample text in appropriate font.
1939                         Appearance.updateThemeTweakerSampleText();
1940                         // Disable tab-selection of the search box.
1941                         setSearchBoxTabSelectable(false);
1942                         // Disable scrolling of the page.
1943                         togglePageScrolling(false);
1944                 } else {
1945                         query("#theme-tweaker-toggle button").disabled = false;
1946                         // Re-enable tab-selection of the search box.
1947                         setSearchBoxTabSelectable(true);
1948                         // Re-enable scrolling of the page.
1949                         togglePageScrolling(true);
1950                 }
1952                 // Set theme tweaker assistant visibility.
1953                 Appearance.themeTweakerUIClippyContainer.style.display = (Appearance.getSavedThemeTweakerClippyState() == true) ? "block" : "none";
1954         },
1956         toggleThemeTweakerHelpWindow: () => {
1957                 GWLog("Appearance.toggleThemeTweakerHelpWindow");
1959                 Appearance.themeTweakerUIHelpWindow.style.display = Appearance.themeTweakerUIHelpWindow.style.display == "none" 
1960                                                                                                                 ? "block" 
1961                                                                                                                 : "none";
1962                 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
1963                         // Focus theme tweaker assistant checkbox.
1964                         Appearance.themeTweakerUI.query("#theme-tweak-control-clippy").focus();
1965                         // Disable interaction on main theme tweaker window.
1966                         Appearance.themeTweakerUI.style.pointerEvents = "none";
1967                         Appearance.themeTweakerUIMainWindow.style.pointerEvents = "none";
1968                 } else {
1969                         // Re-enable interaction on main theme tweaker window.
1970                         Appearance.themeTweakerUI.style.pointerEvents = "auto";
1971                         Appearance.themeTweakerUIMainWindow.style.pointerEvents = "auto";
1972                 }
1973         },
1975         resetThemeTweakerUIDefaultState: () => {
1976                 GWLog("Appearance.resetThemeTweakerUIDefaultState");
1978                 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1980                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1981                         let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1982                         slider.value = slider.dataset["defaultValue"];
1983                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
1984                 });
1985         },
1987         updateThemeTweakerSampleText: () => {
1988                 GWLog("Appearance.updateThemeTweakerSampleText");
1990                 let sampleText = Appearance.themeTweakerUISampleTextContainer.query("#theme-tweak-section-sample-text .sample-text");
1992                 // This causes the sample text to take on the properties of the body text of a post.
1993                 sampleText.removeClass("body-text");
1994                 let bodyTextElement = query(".post-body") || query(".comment-body");
1995                 sampleText.addClass("body-text");
1996                 sampleText.style.color = bodyTextElement ? 
1997                         getComputedStyle(bodyTextElement).color : 
1998                         getComputedStyle(query("#content")).color;
2000                 // Here we find out what is the actual background color that will be visible behind
2001                 // the body text of posts, and set the sample text’s background to that.
2002                 let findStyleBackground = (selector) => {
2003                         let x;
2004                         Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2005                                 if (rule.selectorText == selector)
2006                                         x = rule;
2007                         });
2008                         return x.style.backgroundColor;
2009                 };
2011                 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2012         },
2014         injectAppearanceAdjustUIToggle: () => {
2015                 GWLog("Appearance.injectAppearanceAdjustUIToggle");
2017                 Appearance.appearanceAdjustUIToggle = addUIElement(`<div id="appearance-adjust-ui-toggle"><button type="button" tabindex="-1">&#xf013;</button></div>`);
2018                 Appearance.appearanceAdjustUIToggle.query("button").addActivateEvent(Appearance.appearanceAdjustUIToggleClicked);
2020                 if (GW.isMobile) {
2021                         let themeSelectorCloseButton = Appearance.appearanceAdjustUIToggle.query("button").cloneNode(true);
2022                         themeSelectorCloseButton.addClass("theme-selector-close-button");
2023                         themeSelectorCloseButton.innerHTML = "&#xf057;";
2024                         themeSelectorCloseButton.addActivateEvent(Appearance.appearanceAdjustUIToggleButtonClicked);
2025                         Appearance.themeSelector.appendChild(themeSelectorCloseButton);
2026                 } else {
2027                         if (Appearance.getSavedAppearanceAdjustUIToggleState() == true)
2028                                 Appearance.toggleAppearanceAdjustUI();
2029                 }
2030         },
2032         removeAppearanceAdjustUIToggle: () => {
2033                 GWLog("Appearance.removeAppearanceAdjustUIToggle");
2035                 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2036                         element.removeClass("engaged");
2037                 });
2038                 removeElement("#appearance-adjust-ui-toggle");
2039         },
2041         toggleAppearanceAdjustUI: () => {
2042                 GWLog("Appearance.toggleAppearanceAdjustUI");
2044                 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2045                         element.toggleClass("engaged");
2046                 });
2047         },
2049         /**********/
2050         /*      Events.
2051          */
2053         appearanceAdjustUIToggleButtonClicked: (event) => {
2054                 GWLog("Appearance.appearanceAdjustUIToggleButtonClicked");
2056                 Appearance.toggleAppearanceAdjustUI();
2057                 Appearance.saveAppearanceAdjustUIToggleState();
2058         },
2060         widthAdjustButtonClicked: (event) => {
2061                 GWLog("Appearance.widthAdjustButtonClicked");
2063                 // Determine which setting was chosen (i.e., which button was clicked).
2064                 let selectedWidth = event.target.dataset.name;
2066                 //      Switch width.
2067                 Appearance.currentWidth = selectedWidth;
2069                 // Save the new setting.
2070                 Appearance.saveCurrentWidth();
2072                 // Save current visible comment
2073                 let visibleComment = getCurrentVisibleComment();
2075                 // Actually change the content width.
2076                 Appearance.setContentWidth(selectedWidth);
2077                 event.target.parentElement.childNodes.forEach(button => {
2078                         button.removeClass("selected");
2079                         button.disabled = false;
2080                 });
2081                 event.target.addClass("selected");
2082                 event.target.disabled = true;
2084                 // Make sure the accesskey (to cycle to the next width) is on the right button.
2085                 Appearance.setWidthAdjustButtonsAccesskey();
2087                 // Regenerate images overlay.
2088                 generateImagesOverlay();
2090                 if (visibleComment)
2091                         visibleComment.scrollIntoView();
2092         },
2094         themeSelectButtonClicked: (event) => {
2095                 GWLog("Appearance.themeSelectButtonClicked");
2097                 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
2098                 let save = (Appearance.themeTweakerUI.contains(event.target) == false);
2099                 Appearance.setTheme(themeName, save);
2100                 if (GW.isMobile)
2101                         Appearance.toggleAppearanceAdjustUI();
2102         },
2104         textSizeAdjustButtonClicked: (event) => {
2105                 GWLog("Appearance.textSizeAdjustButtonClicked");
2107                 var zoomFactor = Appearance.currentTextZoom;
2108                 if (event.target.hasClass("decrease")) {
2109                         zoomFactor -= 0.05;
2110                 } else if (event.target.hasClass("increase")) {
2111                         zoomFactor += 0.05;
2112                 } else {
2113                         zoomFactor = Appearance.defaultTextZoom;
2114                 }
2116                 let save = (   Appearance.textSizeAdjustmentWidget != null 
2117                                         && Appearance.textSizeAdjustmentWidget.contains(event.target));
2118                 Appearance.setTextZoom(zoomFactor, save);
2119         },
2121         themeTweakerToggleClicked: (event) => {
2122                 GWLog("Appearance.themeTweakerToggleClicked");
2124                 if (query("link[href^='/css/theme_tweaker.css']")) {
2125                         // Theme tweaker CSS is already loaded.
2126                         Appearance.showThemeTweakerUI();
2127                 } else {
2128                         // Load the theme tweaker CSS (if not loaded).
2129                         let themeTweakerStyleSheet = document.createElement("link");
2130                         themeTweakerStyleSheet.setAttribute("rel", "stylesheet");
2131                         themeTweakerStyleSheet.setAttribute("href", "/css/theme_tweaker.css");
2132                         themeTweakerStyleSheet.addEventListener("load", (event) => {
2133                                 requestAnimationFrame(() => {
2134                                         themeTweakerStyleSheet.disabled = false;
2135                                 });
2136                                 Appearance.showThemeTweakerUI();
2137                         });
2138                         document.head.appendChild(themeTweakerStyleSheet);
2139                 }
2140         },
2142         themeTweakerUIKeyPressed: (event) => {
2143                 GWLog("Appearance.themeTweakerUIKeyPressed");
2145                 if (event.key == "Escape") {
2146                         if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2147                                 Appearance.toggleThemeTweakerHelpWindow();
2148                                 Appearance.themeTweakerResetSettings();
2149                         } else if (Appearance.themeTweakerUI.style.display != "none") {
2150                                 Appearance.toggleThemeTweakerUI();
2151                                 Appearance.themeTweakReset();
2152                         }
2153                 } else if (event.key == "Enter") {
2154                         if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2155                                 Appearance.toggleThemeTweakerHelpWindow();
2156                                 Appearance.themeTweakerSaveSettings();
2157                         } else if (Appearance.themeTweakerUI.style.display != "none") {
2158                                 Appearance.toggleThemeTweakerUI();
2159                                 Appearance.themeTweakSave();
2160                         }
2161                 }
2162         },
2164         themeTweakerUIOverlayClicked: (event) => {
2165                 GWLog("Appearance.themeTweakerUIOverlayClicked");
2167                 if (event.type == "mousedown") {
2168                         Appearance.themeTweakerUI.style.opacity = "0.01";
2169                 } else {
2170                         Appearance.toggleThemeTweakerUI();
2171                         Appearance.themeTweakerUI.style.opacity = "1.0";
2172                         Appearance.themeTweakReset();
2173                 }
2174         },
2176         themeTweakerUIFieldValueChanged: (event) => {
2177                 GWLog("Appearance.themeTweakerUIFieldValueChanged");
2179                 if (event.target.id == "theme-tweak-control-invert") {
2180                         Appearance.currentFilters["invert"] = event.target.checked ? "100%" : "0%";
2181                 } else if (event.target.type == "range") {
2182                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2183                         Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2184                         Appearance.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2185                 } else if (event.target.id == "theme-tweak-control-clippy") {
2186                         Appearance.themeTweakerUIClippyContainer.style.display = event.target.checked ? "block" : "none";
2187                 }
2189                 // Clear the sample text filters.
2190                 Appearance.themeTweakerUISampleTextContainer.style.filter = "";
2192                 // Apply the new filters globally.
2193                 Appearance.applyFilters();
2194         },
2196         themeTweakerUIFieldInputReceived: (event) => {
2197                 GWLog("Appearance.themeTweakerUIFieldInputReceived");
2199                 let sampleTextFilters = Appearance.currentFilters;
2200                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2201                 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2202                 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2204                 Appearance.themeTweakerUISampleTextContainer.style.filter = Appearance.filterStringFromFilters(sampleTextFilters);
2205         },
2207         themeTweakerUIMinimizeButtonClicked: (event) => {
2208                 GWLog("Appearance.themeTweakerUIMinimizeButtonClicked");
2210                 let themeTweakerStyleBlock = Appearance.themeTweakerUI.query("#theme-tweaker-style");
2212                 if (event.target.hasClass("minimize")) {
2213                         event.target.removeClass("minimize");
2214                         themeTweakerStyleBlock.innerHTML = 
2215                                 `#theme-tweaker-ui .main-theme-tweaker-window {
2216                                         width: 320px;
2217                                         height: 31px;
2218                                         overflow: hidden;
2219                                         padding: 30px 0 0 0;
2220                                         top: 20px;
2221                                         right: 20px;
2222                                         left: auto;
2223                                 }
2224                                 #theme-tweaker-ui::after {
2225                                         top: 27px;
2226                                         right: 27px;
2227                                 }
2228                                 #theme-tweaker-ui::before {
2229                                         opacity: 0.0;
2230                                         height: 0;
2231                                 }
2232                                 #theme-tweaker-ui .clippy-container {
2233                                         opacity: 1.0;
2234                                 }
2235                                 #theme-tweaker-ui .clippy-container .hint span {
2236                                         color: #c00;
2237                                 }
2238                                 #theme-tweaker-ui {
2239                                         height: 0;
2240                                 }
2241                                 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
2242                                         pointer-events: none;
2243                                 }`;
2244                         event.target.addClass("maximize");
2245                 } else {
2246                         event.target.removeClass("maximize");
2247                         themeTweakerStyleBlock.innerHTML = 
2248                                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
2249                                         pointer-events: none;
2250                                 }`;
2251                         event.target.addClass("minimize");
2252                 }
2253         },
2255         themeTweakerUIHelpButtonClicked: (event) => {
2256                 GWLog("Appearance.themeTweakerUIHelpButtonClicked");
2258                 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
2259                 Appearance.toggleThemeTweakerHelpWindow();
2260         },
2262         themeTweakerUIResetDefaultsButtonClicked: (event) => {
2263                 GWLog("Appearance.themeTweakerUIResetDefaultsButtonClicked");
2265                 Appearance.themeTweakResetDefaults();
2266                 Appearance.resetThemeTweakerUIDefaultState();
2267         },
2269         themeTweakerUICancelButtonClicked: (event) => {
2270                 GWLog("Appearance.themeTweakerUICancelButtonClicked");
2272                 Appearance.toggleThemeTweakerUI();
2273                 Appearance.themeTweakReset();
2274         },
2276         themeTweakerUIOKButtonClicked: (event) => {
2277                 GWLog("Appearance.themeTweakerUIOKButtonClicked");
2279                 Appearance.toggleThemeTweakerUI();
2280                 Appearance.themeTweakSave();
2281         },
2283         themeTweakerUIHelpWindowCancelButtonClicked: (event) => {
2284                 GWLog("Appearance.themeTweakerUIHelpWindowCancelButtonClicked");
2286                 Appearance.toggleThemeTweakerHelpWindow();
2287                 Appearance.themeTweakerResetSettings();
2288         },
2290         themeTweakerUIHelpWindowOKButtonClicked: (event) => {
2291                 GWLog("Appearance.themeTweakerUIHelpWindowOKButtonClicked");
2293                 Appearance.toggleThemeTweakerHelpWindow();
2294                 Appearance.themeTweakerSaveSettings();
2295         },
2297         themeTweakerUISliderNotchClicked: (event) => {
2298                 GWLog("Appearance.themeTweakerUISliderNotchClicked");
2300                 let slider = event.target.parentElement.query("input[type='range']");
2301                 slider.value = slider.dataset["defaultValue"];
2302                 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset["labelSuffix"];
2303                 Appearance.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset["valueSuffix"];
2304                 Appearance.applyFilters();
2305         },
2307         themeTweakerUIClippyCloseButtonClicked: (event) => {
2308                 GWLog("Appearance.themeTweakerUIClippyCloseButtonClicked");
2310                 Appearance.themeTweakerUIClippyContainer.style.display = "none";
2311                 Appearance.themeTweakerUIClippyControl.checked = false;
2312                 Appearance.saveThemeTweakerClippyState();
2313         }
2316 function setSearchBoxTabSelectable(selectable) {
2317         GWLog("setSearchBoxTabSelectable");
2318         query("input[type='search']").tabIndex = selectable ? "" : "-1";
2319         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2322 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
2323 // otherwise, show it.
2324 function updatePostNavUIVisibility() {
2325         GWLog("updatePostNavUIVisibility");
2326         var hidePostNavUIToggle = true;
2327         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
2328                 if (getComputedStyle(element).visibility == "visible" ||
2329                         element.style.visibility == "visible" ||
2330                         element.style.visibility == "unset")
2331                         hidePostNavUIToggle = false;
2332         });
2333         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
2334                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
2335         });
2338 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
2339 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
2340 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
2341 function updateSiteNavUIState(event) {
2342         GWLog("updateSiteNavUIState");
2343         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
2344         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
2345                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
2346                                                                                                                 0;
2347         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
2348                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
2349                                                                                                          0;
2350         GW.scrollState.lastScrollTop = newScrollTop;
2352         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
2353         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
2354                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
2355                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) 
2356                         Appearance.toggleAppearanceAdjustUI();
2357         }
2359         // On mobile, make site nav UI translucent on ANY scroll down.
2360         if (GW.isMobile)
2361                 GW.scrollState.siteNavUIElements.forEach(element => {
2362                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
2363                         else element.removeClass("translucent-on-scroll");
2364                 });
2366         // Show site nav UI when scrolling a full page up, or to the top.
2367         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
2368                  GW.scrollState.lastScrollTop == 0) &&
2369                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
2370                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
2372         // On desktop, show appearance adjust UI when scrolling to the top.
2373         if ((!GW.isMobile) && 
2374                 (GW.scrollState.lastScrollTop == 0) &&
2375                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
2376                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) 
2377                         Appearance.toggleAppearanceAdjustUI();
2380 /*********************/
2381 /* PAGE QUICK-NAV UI */
2382 /*********************/
2384 function injectQuickNavUI() {
2385         GWLog("injectQuickNavUI");
2386         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2387         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
2388         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
2389         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
2390         ` + "</div>");
2393 /**********************/
2394 /* NEW COMMENT NAV UI */
2395 /**********************/
2397 function injectNewCommentNavUI(newCommentsCount) {
2398         GWLog("injectNewCommentNavUI");
2399         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
2400         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
2401         <span class='new-comments-count'></span>
2402         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
2403         + "</div>");
2405         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2406                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2407                         GWLog("GW.commentQuicknavButtonClicked");
2408                         scrollToNewComment(/next/.test(event.target.className));
2409                         event.target.blur();
2410                 });
2411         });
2413         document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => { 
2414                 GWLog("GW.commentQuicknavKeyPressed");
2415                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2416                 if (event.key == ",") scrollToNewComment(false);
2417                 if (event.key == ".") scrollToNewComment(true)
2418         });
2420         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2421         + `<span>Since:</span>`
2422         + `<input type='text' class='hns-date'></input>`
2423         + "</div>");
2425         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2426                 GWLog("GW.hnsDatePickerValueChanged");
2427                 let hnsDate = time_fromHuman(event.target.value);
2428                 if(hnsDate) {
2429                         setHistoryLastVisitedDate(hnsDate);
2430                         let newCommentsCount = highlightCommentsSince(hnsDate);
2431                         updateNewCommentNavUI(newCommentsCount);
2432                 }
2433         }, false);
2435         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2436                 GWLog("GW.newCommentsCountClicked");
2437                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2438                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2439         });
2442 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2443 function time_fromHuman(string) {
2444         /* Convert a human-readable date into a JS timestamp */
2445         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2446                 string = string.replace(' ', 'T');  // revert nice spacing
2447                 string += ':00.000Z';  // complete ISO 8601 date
2448                 time = Date.parse(string);  // milliseconds since epoch
2450                 // browsers handle ISO 8601 without explicit timezone differently
2451                 // thus, we have to fix that by hand
2452                 time += (new Date()).getTimezoneOffset() * 60e3;
2453         } else {
2454                 string = string.replace(' at', '');
2455                 time = Date.parse(string);  // milliseconds since epoch
2456         }
2457         return time;
2460 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2461         GWLog("updateNewCommentNavUI");
2462         // Update the new comments count.
2463         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2464         newCommentsCountLabel.innerText = newCommentsCount;
2465         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2467         // Update the date picker field.
2468         if (hnsDate != -1) {
2469                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2470         }
2473 /********************************/
2474 /* COMMENTS VIEW MODE SELECTION */
2475 /********************************/
2477 function injectCommentsViewModeSelector() {
2478         GWLog("injectCommentsViewModeSelector");
2479         let commentsContainer = query("#comments");
2480         if (commentsContainer == null) return;
2482         let currentModeThreaded = (location.href.search("chrono=t") == -1);
2483         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2485         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2486         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
2487         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
2488         + "</div>");
2490 //      commentsViewModeSelector.queryAll("a").forEach(button => {
2491 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2492 //      });
2494         if (!currentModeThreaded) {
2495                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2496                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2497                         commentParentLink.addClass("inline-author");
2498                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2499                 });
2501                 queryAll(".comment-child-links a").forEach(commentChildLink => {
2502                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
2503                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2504                 });
2506                 rectifyChronoModeCommentChildLinks();
2508                 commentsContainer.addClass("chrono");
2509         } else {
2510                 commentsContainer.addClass("threaded");
2511         }
2513         // Remove extraneous top-level comment thread in chrono mode.
2514         let topLevelCommentThread = query("#comments > .comment-thread");
2515         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2518 // function commentsViewModeSelectorButtonClicked(event) {
2519 //      event.preventDefault();
2520 // 
2521 //      var newDocument;
2522 //      let request = new XMLHttpRequest();
2523 //      request.open("GET", event.target.href);
2524 //      request.onreadystatechange = () => {
2525 //              if (request.readyState != 4) return;
2526 //              newDocument = htmlToElement(request.response);
2527 // 
2528 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2529 // 
2530 //              // Update the buttons.
2531 //              event.target.addClass("selected");
2532 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
2533 // 
2534 //              // Update the #comments container.
2535 //              let commentsContainer = query("#comments");
2536 //              commentsContainer.removeClass(classes.old);
2537 //              commentsContainer.addClass(classes.new);
2538 // 
2539 //              // Update the content.
2540 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2541 //      };
2542 //      request.send();
2543 // }
2544 // 
2545 // function htmlToElement(html) {
2546 //     var template = document.createElement('template');
2547 //     template.innerHTML = html.trim();
2548 //     return template.content;
2549 // }
2551 function rectifyChronoModeCommentChildLinks() {
2552         GWLog("rectifyChronoModeCommentChildLinks");
2553         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2554                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2555                 let childLinks = commentChildLinksContainer.queryAll("a");
2556                 childLinks.forEach((link, index) => {
2557                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2558                 });
2560                 // Sort by date.
2561                 let childLinksArray = Array.from(childLinks)
2562                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2563                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2564         });
2566 function childrenOfComment(commentID) {
2567         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2568                 let commentParentLink = commentItem.query("a.comment-parent-link");
2569                 return ((commentParentLink||{}).hash == "#" + commentID);
2570         });
2573 /********************************/
2574 /* COMMENTS LIST MODE SELECTION */
2575 /********************************/
2577 function injectCommentsListModeSelector() {
2578         GWLog("injectCommentsListModeSelector");
2579         if (query("#content > .comment-thread") == null) return;
2581         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2582         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2583         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2584         + "</div>";
2586         if (query(".sublevel-nav") || query("#top-nav-bar")) {
2587                 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2588         } else {
2589                 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2590         }
2591         let commentsListModeSelector = query("#comments-list-mode-selector");
2593         commentsListModeSelector.queryAll("button").forEach(button => {
2594                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2595                         GWLog("GW.commentsListModeSelectButtonClicked");
2596                         event.target.parentElement.queryAll("button").forEach(button => {
2597                                 button.removeClass("selected");
2598                                 button.disabled = false;
2599                                 button.accessKey = '`';
2600                         });
2601                         localStorage.setItem("comments-list-mode", event.target.className);
2602                         event.target.addClass("selected");
2603                         event.target.disabled = true;
2604                         event.target.removeAttribute("accesskey");
2606                         if (event.target.hasClass("expanded")) {
2607                                 query("#content").removeClass("compact");
2608                         } else {
2609                                 query("#content").addClass("compact");
2610                         }
2611                 });
2612         });
2614         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2615         if (savedMode == "compact")
2616                 query("#content").addClass("compact");
2617         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2618         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2619         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2621         if (GW.isMobile) {
2622                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2623                         commentParentLink.addActivateEvent(function (event) {
2624                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2625                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2626                         }, false);
2627                 });
2628         }
2631 /**********************/
2632 /* SITE NAV UI TOGGLE */
2633 /**********************/
2635 function injectSiteNavUIToggle() {
2636         GWLog("injectSiteNavUIToggle");
2637         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
2638         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2639                 GWLog("GW.siteNavUIToggleButtonClicked");
2640                 toggleSiteNavUI();
2641                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2642         });
2644         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2646 function removeSiteNavUIToggle() {
2647         GWLog("removeSiteNavUIToggle");
2648         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2649                 element.removeClass("engaged");
2650         });
2651         removeElement("#site-nav-ui-toggle");
2653 function toggleSiteNavUI() {
2654         GWLog("toggleSiteNavUI");
2655         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2656                 element.toggleClass("engaged");
2657                 element.removeClass("translucent-on-scroll");
2658         });
2661 /**********************/
2662 /* POST NAV UI TOGGLE */
2663 /**********************/
2665 function injectPostNavUIToggle() {
2666         GWLog("injectPostNavUIToggle");
2667         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
2668         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2669                 GWLog("GW.postNavUIToggleButtonClicked");
2670                 togglePostNavUI();
2671                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2672         });
2674         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2676 function removePostNavUIToggle() {
2677         GWLog("removePostNavUIToggle");
2678         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2679                 element.removeClass("engaged");
2680         });
2681         removeElement("#post-nav-ui-toggle");
2683 function togglePostNavUI() {
2684         GWLog("togglePostNavUI");
2685         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2686                 element.toggleClass("engaged");
2687         });
2690 /**************************/
2691 /* WORD COUNT & READ TIME */
2692 /**************************/
2694 function toggleReadTimeOrWordCount(addWordCountClass) {
2695         GWLog("toggleReadTimeOrWordCount");
2696         queryAll(".post-meta .read-time").forEach(element => {
2697                 if (addWordCountClass) element.addClass("word-count");
2698                 else element.removeClass("word-count");
2700                 let titleParts = /(\S+)(.+)$/.exec(element.title);
2701                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2702         });
2705 /**************************/
2706 /* PROMPT TO SAVE CHANGES */
2707 /**************************/
2709 function enableBeforeUnload() {
2710         window.onbeforeunload = function () { return true; };
2712 function disableBeforeUnload() {
2713         window.onbeforeunload = null;
2716 /***************************/
2717 /* ORIGINAL POSTER BADGING */
2718 /***************************/
2720 function markOriginalPosterComments() {
2721         GWLog("markOriginalPosterComments");
2722         let postAuthor = query(".post .author");
2723         if (postAuthor == null) return;
2725         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2726                 if (author.dataset.userid == postAuthor.dataset.userid ||
2727                         (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2728                         author.addClass("original-poster");
2729                         author.title += "Original poster";
2730                 }
2731         });
2734 /********************************/
2735 /* EDIT POST PAGE SUBMIT BUTTON */
2736 /********************************/
2738 function setEditPostPageSubmitButtonText() {
2739         GWLog("setEditPostPageSubmitButtonText");
2740         if (!query("#content").hasClass("edit-post-page")) return;
2742         queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2743                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2744                         GWLog("GW.postSectionSelectorValueChanged");
2745                         updateEditPostPageSubmitButtonText();
2746                 });
2747         });
2749         updateEditPostPageSubmitButtonText();
2751 function updateEditPostPageSubmitButtonText() {
2752         GWLog("updateEditPostPageSubmitButtonText");
2753         let submitButton = query("input[type='submit']");
2754         if (query("input#drafts").checked == true) 
2755                 submitButton.value = "Save Draft";
2756         else if (query(".posting-controls").hasClass("edit-existing-post"))
2757                 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2758         else
2759                 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2762 /*****************/
2763 /* ANTI-KIBITZER */
2764 /*****************/
2766 function numToAlpha(n) {
2767         let ret = "";
2768         do {
2769                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2770                 n = Math.floor((n / 26) - 1);
2771         } while (n >= 0);
2772         return ret;
2775 function injectAntiKibitzer() {
2776         GWLog("injectAntiKibitzer");
2777         // Inject anti-kibitzer toggle controls.
2778         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>");
2779         antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2780                 GWLog("GW.antiKibitzerToggleButtonClicked");
2781                 if (query("#anti-kibitzer-toggle").hasClass("engaged") && 
2782                         !event.shiftKey &&
2783                         !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!)")) {
2784                         event.target.blur();
2785                         return;
2786                 }
2788                 toggleAntiKibitzerMode();
2789                 event.target.blur();
2790         });
2792         // Activate anti-kibitzer mode (if needed).
2793         if (localStorage.getItem("antikibitzer") == "true")
2794                 toggleAntiKibitzerMode();
2796         // Remove temporary CSS that hides the authors and karma values.
2797         removeElement("#antikibitzer-temp");
2800 function toggleAntiKibitzerMode() {
2801         GWLog("toggleAntiKibitzerMode");
2802         // This will be the URL of the user's own page, if logged in, or the URL of
2803         // the login page otherwise.
2804         let userTabTarget = query("#nav-item-login .nav-inner").href;
2805         let pageHeadingElement = query("h1.page-main-heading");
2807         let userCount = 0;
2808         let userFakeName = { };
2810         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2812         let postAuthor = query(".post-page .post-meta .author");
2813         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2815         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2816         if (antiKibitzerToggle.hasClass("engaged")) {
2817                 localStorage.setItem("antikibitzer", "false");
2819                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2820                 if (redirectTarget) {
2821                         window.location = redirectTarget;
2822                         return;
2823                 }
2825                 // Individual comment page title and header
2826                 if (query(".individual-thread-page")) {
2827                         let replacer = (node) => {
2828                                 if (!node) return;
2829                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
2830                         }
2831                         replacer(query("title:not(.fake-title)"));
2832                         replacer(query("#content > h1"));
2833                 }
2835                 // Author names/links.
2836                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2837                         author.textContent = author.dataset["trueName"];
2838                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2840                         author.removeClass("redacted");
2841                 });
2842                 // Post/comment karma values.
2843                 queryAll(".karma-value.redacted").forEach(karmaValue => {
2844                         karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2846                         karmaValue.removeClass("redacted");
2847                 });
2848                 // Link post domains.
2849                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2850                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2852                         linkPostDomain.removeClass("redacted");
2853                 });
2855                 antiKibitzerToggle.removeClass("engaged");
2856         } else {
2857                 localStorage.setItem("antikibitzer", "true");
2859                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2860                 if (redirectTarget) {
2861                         window.location = redirectTarget;
2862                         return;
2863                 }
2865                 // Individual comment page title and header
2866                 if (query(".individual-thread-page")) {
2867                         let replacer = (node) => {
2868                                 if (!node) return;
2869                                 node.dataset["trueContent"] = node.firstChild.wholeText;
2870                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2871                                 node.firstChild.replaceWith(newText);
2872                         }
2873                         replacer(query("title:not(.fake-title)"));
2874                         replacer(query("#content > h1"));
2875                 }
2877                 removeElement("title.fake-title");
2879                 // Author names/links.
2880                 queryAll(".author, .inline-author").forEach(author => {
2881                         // Skip own posts/comments.
2882                         if (author.hasClass("own-user-author"))
2883                                 return;
2885                         let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2887                         if(!userid) return;
2889                         author.dataset["trueName"] = author.textContent;
2890                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2892                         if (/\/user/.test(author.href)) {
2893                                 author.dataset["trueLink"] = author.pathname;
2894                                 author.href = "/user?id=" + author.dataset["userid"];
2895                         }
2897                         author.addClass("redacted");
2898                 });
2899                 // Post/comment karma values.
2900                 queryAll(".karma-value").forEach(karmaValue => {
2901                         // Skip own posts/comments.
2902                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2903                                 return;
2905                         karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2906                         karmaValue.innerHTML = "##<span> points</span>";
2908                         karmaValue.addClass("redacted");
2909                 });
2910                 // Link post domains.
2911                 queryAll(".link-post-domain").forEach(linkPostDomain => {
2912                         // Skip own posts/comments.
2913                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2914                                 return;
2916                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2917                         linkPostDomain.textContent = "redacted.domain.tld";
2919                         linkPostDomain.addClass("redacted");
2920                 });
2922                 antiKibitzerToggle.addClass("engaged");
2923         }
2926 /*******************************/
2927 /* COMMENT SORT MODE SELECTION */
2928 /*******************************/
2930 var CommentSortMode = Object.freeze({
2931         TOP:            "top",
2932         NEW:            "new",
2933         OLD:            "old",
2934         HOT:            "hot"
2936 function sortComments(mode) {
2937         GWLog("sortComments");
2938         let commentsContainer = query("#comments");
2940         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2941         commentsContainer.addClass("sorting");
2943         GW.commentValues = { };
2944         let clonedCommentsContainer = commentsContainer.cloneNode(true);
2945         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2946                 var comparator;
2947                 switch (mode) {
2948                 case CommentSortMode.NEW:
2949                         comparator = (a,b) => commentDate(b) - commentDate(a);
2950                         break;
2951                 case CommentSortMode.OLD:
2952                         comparator = (a,b) => commentDate(a) - commentDate(b);
2953                         break;
2954                 case CommentSortMode.HOT:
2955                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2956                         break;
2957                 case CommentSortMode.TOP:
2958                 default:
2959                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2960                         break;
2961                 }
2962                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2963         });
2964         removeElement(commentsContainer.lastChild);
2965         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2966         GW.commentValues = { };
2968         if (loggedInUserId) {
2969                 // Re-activate vote buttons.
2970                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2971                         voteButton.addActivateEvent(voteButtonClicked);
2972                 });
2974                 // Re-activate comment action buttons.
2975                 commentsContainer.queryAll(".action-button").forEach(button => {
2976                         button.addActivateEvent(GW.commentActionButtonClicked);
2977                 });
2978         }
2980         // Re-activate comment-minimize buttons.
2981         queryAll(".comment-minimize-button").forEach(button => {
2982                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2983         });
2985         // Re-add comment parent popups.
2986         addCommentParentPopups();
2987         
2988         // Redo new-comments highlighting.
2989         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2991         requestAnimationFrame(() => {
2992                 commentsContainer.removeClass("sorting");
2993                 commentsContainer.addClass("sorted-" + mode);
2994         });
2996 function commentKarmaValue(commentOrSelector) {
2997         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2998         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
3000 function commentDate(commentOrSelector) {
3001         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3002         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
3004 function commentVoteCount(commentOrSelector) {
3005         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3006         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
3009 function injectCommentsSortModeSelector() {
3010         GWLog("injectCommentsSortModeSelector");
3011         let topCommentThread = query("#comments > .comment-thread");
3012         if (topCommentThread == null) return;
3014         // Do not show sort mode selector if there is no branching in comment tree.
3015         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
3017         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
3018                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
3019                 "</div>";
3020         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
3021         let commentsSortModeSelector = query("#comments-sort-mode-selector");
3023         commentsSortModeSelector.queryAll("button").forEach(button => {
3024                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
3025                         GWLog("GW.commentsSortModeSelectButtonClicked");
3026                         event.target.parentElement.queryAll("button").forEach(button => {
3027                                 button.removeClass("selected");
3028                                 button.disabled = false;
3029                         });
3030                         event.target.addClass("selected");
3031                         event.target.disabled = true;
3033                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
3034                         setCommentsSortModeSelectButtonsAccesskey();
3035                 });
3036         });
3038         // TODO: Make this actually get the current sort mode (if that's saved).
3039         // TODO: Also change the condition here to properly get chrono/threaded mode,
3040         // when that is properly done with cookies.
3041         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
3042         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
3043         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
3044         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
3045         setCommentsSortModeSelectButtonsAccesskey();
3048 function setCommentsSortModeSelectButtonsAccesskey() {
3049         GWLog("setCommentsSortModeSelectButtonsAccesskey");
3050         queryAll("#comments-sort-mode-selector button").forEach(button => {
3051                 button.removeAttribute("accesskey");
3052                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
3053         });
3054         let selectedButton = query("#comments-sort-mode-selector button.selected");
3055         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
3056         nextButtonInCycle.accessKey = "z";
3057         nextButtonInCycle.title += " [z]";
3060 /*************************/
3061 /* COMMENT PARENT POPUPS */
3062 /*************************/
3064 function previewPopupsEnabled() {
3065         let isDisabled = localStorage.getItem("preview-popups-disabled");
3066         return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
3069 function setPreviewPopupsEnabled(state) {
3070         localStorage.setItem("preview-popups-disabled", !state);
3071         updatePreviewPopupToggle();
3074 function updatePreviewPopupToggle() {
3075         let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
3076         query("#preview-popup-toggle").setAttribute("style", style);
3079 function injectPreviewPopupToggle() {
3080         GWLog("injectPreviewPopupToggle");
3082         let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
3083         // This is required because Chrome can't use filters on an externally used SVG element.
3084         fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
3085         updatePreviewPopupToggle();
3086         toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
3089 var currentPreviewPopup = { };
3091 function removePreviewPopup(previewPopup) {
3092         if(previewPopup.element)
3093                 removeElement(previewPopup.element);
3095         if(previewPopup.timeout)
3096                 clearTimeout(previewPopup.timeout);
3098         if(currentPreviewPopup.pointerListener)
3099                 window.removeEventListener("pointermove", previewPopup.pointerListener);
3101         if(currentPreviewPopup.mouseoutListener)
3102                 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3104         if(currentPreviewPopup.scrollListener)
3105                 window.removeEventListener("scroll", previewPopup.scrollListener);
3107         currentPreviewPopup = { };
3110 function addCommentParentPopups() {
3111         GWLog("addCommentParentPopups");
3112         //if (!query("#content").hasClass("comment-thread-page")) return;
3114         queryAll("a[href]").forEach(linkTag => {
3115                 let linkHref = linkTag.getAttribute("href");
3117                 let url;
3118                 try { url = new URL(linkHref, window.location.href); }
3119                 catch(e) { }
3120                 if(!url) return;
3122                 if(GW.sites[url.host]) {
3123                         let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
3124                         
3125                         if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
3126                                 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
3127                                         if(event.pointerType == "touch") return;
3128                                         GWLog("GW.commentParentLinkMouseOver");
3129                                         removePreviewPopup(currentPreviewPopup);
3130                                         let parentID = linkHref;
3131                                         var parent, popup;
3132                                         if (!(parent = (query(parentID)||{}).firstChild)) return;
3133                                         var highlightClassName;
3134                                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
3135                                                 parentHighlightClassName = "comment-item-highlight-faint";
3136                                                 popup = parent.cloneNode(true);
3137                                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
3138                                                 linkTag.addEventListener("mouseout", (event) => {
3139                                                         removeElement(popup);
3140                                                 }, {once: true});
3141                                                 linkTag.closest(".comments > .comment-thread").appendChild(popup);
3142                                         } else {
3143                                                 parentHighlightClassName = "comment-item-highlight";
3144                                         }
3145                                         parent.parentNode.addClass(parentHighlightClassName);
3146                                         linkTag.addEventListener("mouseout", (event) => {
3147                                                 parent.parentNode.removeClass(parentHighlightClassName);
3148                                         }, {once: true});
3149                                 });
3150                         }
3151                         else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
3152                                 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
3153                                 && !(url.searchParams.get('format'))
3154                                 && !linkTag.closest("nav:not(.post-nav-links)")
3155                                 && (!url.hash || linkCommentId)
3156                                 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
3157                                 linkTag.addEventListener("pointerover", event => {
3158                                         if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
3159                                         if(currentPreviewPopup.linkTag) return;
3160                                         linkTag.createPreviewPopup();
3161                                 });
3162                                 linkTag.createPreviewPopup = function() {
3163                                         removePreviewPopup(currentPreviewPopup);
3165                                         currentPreviewPopup = {linkTag: linkTag};
3166                                         
3167                                         let popup = document.createElement("iframe");
3168                                         currentPreviewPopup.element = popup;
3170                                         let popupTarget = linkHref;
3171                                         if(popupTarget.match(/#comment-/)) {
3172                                                 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
3173                                         }
3174                                         // 'theme' attribute is required for proper caching
3175                                         popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
3176                                         popup.addClass("preview-popup");
3177                                         
3178                                         let linkRect = linkTag.getBoundingClientRect();
3180                                         if(linkRect.right + 710 < window.innerWidth)
3181                                                 popup.style.left = linkRect.right + 10 + "px";
3182                                         else
3183                                                 popup.style.right = "10px";
3185                                         popup.style.width = "700px";
3186                                         popup.style.height = "500px";
3187                                         popup.style.visibility = "hidden";
3188                                         popup.style.transition = "none";
3190                                         let recenter = function() {
3191                                                 let popupHeight = 500;
3192                                                 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
3193                                                         let popupContent = popup.contentDocument.querySelector("#content");
3194                                                         if(popupContent) {
3195                                                                 popupHeight = popupContent.clientHeight + 2;
3196                                                                 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
3197                                                                 popup.style.height = popupHeight + "px";
3198                                                         }
3199                                                 }
3200                                                 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
3201                                         }
3203                                         recenter();
3205                                         query('#content').insertAdjacentElement("beforeend", popup);
3207                                         let clickListener = event => {
3208                                                 if(!event.target.closest("a, input, label")
3209                                                    && !event.target.closest("popup-hide-button")) {
3210                                                         window.location = linkHref;
3211                                                 }
3212                                         };
3214                                         popup.addEventListener("load", () => {
3215                                                 let hideButton = popup.contentDocument.createElement("div");
3216                                                 hideButton.className = "popup-hide-button";
3217                                                 hideButton.insertAdjacentText('beforeend', "\uF070");
3218                                                 hideButton.onclick = (event) => {
3219                                                         removePreviewPopup(currentPreviewPopup);
3220                                                         setPreviewPopupsEnabled(false);
3221                                                         event.stopPropagation();
3222                                                 }
3223                                                 popup.contentDocument.body.appendChild(hideButton);
3224                                                 
3225                                                 let body = popup.contentDocument.body;
3226                                                 body.addEventListener("click", clickListener);
3227                                                 body.style.cursor = "pointer";
3229                                                 recenter();
3230                                         });
3232                                         popup.contentDocument.body.addEventListener("click", clickListener);
3233                                         
3234                                         currentPreviewPopup.timeout = setTimeout(() => {
3235                                                 recenter();
3237                                                 requestIdleCallback(() => {
3238                                                         if(currentPreviewPopup.element === popup) {
3239                                                                 popup.scrolling = "";
3240                                                                 popup.style.visibility = "unset";
3241                                                                 popup.style.transition = null;
3243                                                                 popup.animate([
3244                                                                         { opacity: 0, transform: "translateY(10%)" },
3245                                                                         { opacity: 1, transform: "none" }
3246                                                                 ], { duration: 150, easing: "ease-out" });
3247                                                         }
3248                                                 });
3249                                         }, 1000);
3251                                         let pointerX, pointerY, mousePauseTimeout = null;
3253                                         currentPreviewPopup.pointerListener = (event) => {
3254                                                 pointerX = event.clientX;
3255                                                 pointerY = event.clientY;
3257                                                 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3258                                                 mousePauseTimeout = null;
3260                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3261                                                 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3263                                                 if(mouseIsOverLink || overElement === popup
3264                                                    || (pointerX < popup.getBoundingClientRect().left
3265                                                        && event.movementX >= 0)) {
3266                                                         if(!mouseIsOverLink && overElement !== popup) {
3267                                                                 if(overElement['createPreviewPopup']) {
3268                                                                         mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3269                                                                 } else {
3270                                                                         mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3271                                                                 }
3272                                                         }
3273                                                 } else {
3274                                                         removePreviewPopup(currentPreviewPopup);
3275                                                         if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3276                                                 }
3277                                         };
3278                                         window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3280                                         currentPreviewPopup.mouseoutListener = (event) => {
3281                                                 clearTimeout(mousePauseTimeout);
3282                                                 mousePauseTimeout = null;
3283                                         }
3284                                         document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3286                                         currentPreviewPopup.scrollListener = (event) => {
3287                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
3288                                                 linkRect = linkTag.getBoundingClientRect();
3289                                                 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3290                                                 removePreviewPopup(currentPreviewPopup);
3291                                         };
3292                                         window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3293                                 };
3294                         }
3295                 }
3296         });
3297         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3298                 
3299         });
3301         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3302         Appearance.filtersExclusionPaths.commentParentPopups = [
3303                 "#content .comments .comment-thread"
3304         ];
3305         Appearance.applyFilters();
3308 /***************/
3309 /* IMAGE FOCUS */
3310 /***************/
3312 function imageFocusSetup(imagesOverlayOnly = false) {
3313         if (typeof GW.imageFocus == "undefined")
3314                 GW.imageFocus = {
3315                         contentImagesSelector:  "#content img",
3316                         overlayImagesSelector:  "#images-overlay img",
3317                         focusedImageSelector:   "#content img.focused, #images-overlay img.focused",
3318                         pageContentSelector:    "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3319                         shrinkRatio:                    0.975,
3320                         hideUITimerDuration:    1500,
3321                         hideUITimerExpired:             () => {
3322                                 GWLog("GW.imageFocus.hideUITimerExpired");
3323                                 let currentTime = new Date();
3324                                 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3325                                 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3326                                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3327                                 } else {
3328                                         hideImageFocusUI();
3329                                         cancelImageFocusHideUITimer();
3330                                 }
3331                         }
3332                 };
3334         GWLog("imageFocusSetup");
3335         // Create event listener for clicking on images to focus them.
3336         GW.imageClickedToFocus = (event) => {
3337                 GWLog("GW.imageClickedToFocus");
3338                 focusImage(event.target);
3340                 if (!GW.isMobile) {
3341                         // Set timer to hide the image focus UI.
3342                         unhideImageFocusUI();
3343                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3344                 }
3345         };
3346         // Add the listener to each image in the overlay (i.e., those in the post).
3347         queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3348                 image.addActivateEvent(GW.imageClickedToFocus);
3349         });
3350         // Accesskey-L starts the slideshow.
3351         (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3352         // Count how many images there are in the post, and set the "… of X" label to that.
3353         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3354         if (imagesOverlayOnly) return;
3355         // Add the listener to all other content images (including those in comments).
3356         queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3357                 image.addActivateEvent(GW.imageClickedToFocus);
3358         });
3360         // Create the image focus overlay.
3361         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
3362         `<div class='help-overlay'>
3363                  <p><strong>Arrow keys:</strong> Next/previous image</p>
3364                  <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3365                  <p><strong>Space bar:</strong> Reset image size & position</p>
3366                  <p><strong>Scroll</strong> to zoom in/out</p>
3367                  <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3368         </div>
3369         <div class='image-number'></div>
3370         <div class='slideshow-buttons'>
3371                  <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
3372                  <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
3373         </div>
3374         <div class='caption'></div>` + 
3375         "</div>");
3376         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3378         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3379                 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3380                         GWLog("GW.imageFocus.slideshowButtonClicked");
3381                         focusNextImage(event.target.hasClass("next"));
3382                         event.target.blur();
3383                 });
3384         });
3386         // On orientation change, reset the size & position.
3387         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3388                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3389         }
3391         // UI starts out hidden.
3392         hideImageFocusUI();
3395 function focusImage(imageToFocus) {
3396         GWLog("focusImage");
3397         // Clear 'last-focused' class of last focused image.
3398         let lastFocusedImage = query("img.last-focused");
3399         if (lastFocusedImage) {
3400                 lastFocusedImage.removeClass("last-focused");
3401                 lastFocusedImage.removeAttribute("accesskey");
3402         }
3404         // Create the focused version of the image.
3405         imageToFocus.addClass("focused");
3406         let imageFocusOverlay = query("#image-focus-overlay");
3407         let clonedImage = imageToFocus.cloneNode(true);
3408         clonedImage.style = "";
3409         clonedImage.removeAttribute("width");
3410         clonedImage.removeAttribute("height");
3411         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3412         imageFocusOverlay.appendChild(clonedImage);
3413         imageFocusOverlay.addClass("engaged");
3415         // Set image to default size and position.
3416         resetFocusedImagePosition();
3418         // Blur everything else.
3419         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3420                 element.addClass("blurred");
3421         });
3423         // Add listener to zoom image with scroll wheel.
3424         window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3425                 GWLog("GW.imageFocus.scrollEvent");
3426                 event.preventDefault();
3428                 let image = query("#image-focus-overlay img");
3430                 // Remove the filter.
3431                 image.savedFilter = image.style.filter;
3432                 image.style.filter = 'none';
3434                 // Locate point under cursor.
3435                 let imageBoundingBox = image.getBoundingClientRect();
3437                 // Calculate resize factor.
3438                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3439                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3440                                                 1;
3442                 // Resize.
3443                 image.style.width = (event.deltaY < 0 ?
3444                                                         (image.clientWidth * factor) :
3445                                                         (image.clientWidth / factor))
3446                                                         + "px";
3447                 image.style.height = "";
3449                 // Designate zoom origin.
3450                 var zoomOrigin;
3451                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3452                 // the cursor is over the image.
3453                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3454                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3455                                                                 (imageBoundingBox.left <= event.clientX &&
3456                                                                  event.clientX <= imageBoundingBox.right && 
3457                                                                  imageBoundingBox.top <= event.clientY &&
3458                                                                  event.clientY <= imageBoundingBox.bottom);
3459                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
3460                 // zooming IN, zoom from image center.
3461                 let zoomingFromWindowCenter = event.deltaY > 0;
3462                 if (zoomingFromCursor)
3463                         zoomOrigin = { x: event.clientX, 
3464                                                    y: event.clientY };
3465                 else if (zoomingFromWindowCenter)
3466                         zoomOrigin = { x: window.innerWidth / 2, 
3467                                                    y: window.innerHeight / 2 };
3468                 else
3469                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
3470                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3472                 // Calculate offset from zoom origin.
3473                 let offsetOfImageFromZoomOrigin = {
3474                         x: imageBoundingBox.x - zoomOrigin.x,
3475                         y: imageBoundingBox.y - zoomOrigin.y
3476                 }
3477                 // Calculate delta from centered zoom.
3478                 let deltaFromCenteredZoom = {
3479                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3480                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3481                 }
3482                 // Adjust image position appropriately.
3483                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3484                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3485                 // Gradually re-center image, if it's smaller than the window.
3486                 if (!imageSizeExceedsWindowBounds) {
3487                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
3488                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3489                         let windowCenter = { x: window.innerWidth / 2,
3490                                                                  y: window.innerHeight / 2 }
3491                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3492                                                                                   y: windowCenter.y - imageCenter.y }
3493                         // Divide the offset by 10 because we're nudging the image toward center,
3494                         // not jumping it there.
3495                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3496                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3497                 }
3499                 // Put the filter back.
3500                 image.style.filter = image.savedFilter;
3502                 // Set the cursor appropriately.
3503                 setFocusedImageCursor();
3504         });
3505         window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3506                 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3507                 event.preventDefault();
3508         });
3510         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3511         window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3512                 GWLog("GW.imageFocus.mouseUp");
3513                 window.onmousemove = '';
3515                 // We only want to do anything on left-clicks.
3516                 if (event.button != 0) return;
3518                 // Don't unfocus if click was on a slideshow next/prev button!
3519                 if (event.target.hasClass("slideshow-button")) return;
3521                 // We also don't want to do anything if clicked on the help overlay.
3522                 if (event.target.classList.contains("help-overlay") ||
3523                         event.target.closest(".help-overlay"))
3524                         return;
3526                 let focusedImage = query("#image-focus-overlay img");
3527                 if (event.target == focusedImage && 
3528                         (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3529                         // If the mouseup event was the end of a pan of an overside image,
3530                         // put the filter back; do not unfocus.
3531                         focusedImage.style.filter = focusedImage.savedFilter;
3532                 } else {
3533                         unfocusImageOverlay();
3534                         return;
3535                 }
3536         });
3537         window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3538                 GWLog("GW.imageFocus.mouseDown");
3539                 event.preventDefault();
3541                 let focusedImage = query("#image-focus-overlay img");
3542                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3543                         let mouseCoordX = event.clientX;
3544                         let mouseCoordY = event.clientY;
3546                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3547                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3549                         // Save the filter.
3550                         focusedImage.savedFilter = focusedImage.style.filter;
3552                         window.onmousemove = (event) => {
3553                                 // Remove the filter.
3554                                 focusedImage.style.filter = "none";
3555                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3556                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3557                         };
3558                         return false;
3559                 }
3560         });
3562         // Double-click on the image unfocuses.
3563         clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3564                 GWLog("GW.imageFocus.doubleClick");
3565                 if (event.target.hasClass("slideshow-button")) return;
3567                 unfocusImageOverlay();
3568         });
3570         // Escape key unfocuses, spacebar resets.
3571         document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3572                 GWLog("GW.imageFocus.keyUp");
3573                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3574                 if (!allowedKeys.contains(event.key) || 
3575                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3577                 event.preventDefault();
3579                 switch (event.key) {
3580                 case "Escape": 
3581                 case "Esc":
3582                         unfocusImageOverlay();
3583                         break;
3584                 case " ":
3585                 case "Spacebar":
3586                         resetFocusedImagePosition();
3587                         break;
3588                 case "ArrowDown":
3589                 case "Down":
3590                 case "ArrowRight":
3591                 case "Right":
3592                         if (query("#images-overlay img.focused")) focusNextImage(true);
3593                         break;
3594                 case "ArrowUp":
3595                 case "Up":
3596                 case "ArrowLeft":
3597                 case "Left":
3598                         if (query("#images-overlay img.focused")) focusNextImage(false);
3599                         break;
3600                 }
3601         });
3603         // Prevent spacebar or arrow keys from scrolling page when image focused.
3604         togglePageScrolling(false);
3606         // If the image comes from the images overlay, for the main post...
3607         if (imageToFocus.closest("#images-overlay")) {
3608                 // Mark the overlay as being in slide show mode (to show buttons/count).
3609                 imageFocusOverlay.addClass("slideshow");
3611                 // Set state of next/previous buttons.
3612                 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3613                 var indexOfFocusedImage = getIndexOfFocusedImage();
3614                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3615                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3617                 // Set the image number.
3618                 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3620                 // Replace the hash.
3621                 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3622         } else {
3623                 imageFocusOverlay.removeClass("slideshow");
3624         }
3626         // Set the caption.
3627         setImageFocusCaption();
3629         // Moving mouse unhides image focus UI.
3630         window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3631                 GWLog("GW.imageFocus.mouseMoved");
3632                 let currentDateTime = new Date();
3633                 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3634                         cancelImageFocusHideUITimer();
3635                 } else {
3636                         if (!GW.imageFocus.hideUITimer) {
3637                                 unhideImageFocusUI();
3638                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3639                         }
3640                         GW.imageFocus.mouseLastMovedAt = currentDateTime;
3641                 }
3642         });
3645 function resetFocusedImagePosition() {
3646         GWLog("resetFocusedImagePosition");
3647         let focusedImage = query("#image-focus-overlay img");
3648         if (!focusedImage) return;
3650         let sourceImage = query(GW.imageFocus.focusedImageSelector);
3652         // Make sure that initially, the image fits into the viewport.
3653         let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3654         let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3655         var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3656         let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3657         let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3658         focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3659         focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3661         // Remove modifications to position.
3662         focusedImage.style.left = "";
3663         focusedImage.style.top = "";
3665         // Set the cursor appropriately.
3666         setFocusedImageCursor();
3668 function setFocusedImageCursor() {
3669         let focusedImage = query("#image-focus-overlay img");
3670         if (!focusedImage) return;
3671         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
3672                                                                 'move' : '';
3675 function unfocusImageOverlay() {
3676         GWLog("unfocusImageOverlay");
3678         // Remove event listeners.
3679         window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3680         window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3681         // NOTE: The double-click listener does not need to be removed manually,
3682         // because the focused (cloned) image will be removed anyway.
3683         document.removeEventListener("keyup", GW.imageFocus.keyUp);
3684         document.removeEventListener("keydown", GW.imageFocus.keyDown);
3685         window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3686         window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3687         window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3689         // Set accesskey of currently focused image (if it's in the images overlay).
3690         let currentlyFocusedImage = query("#images-overlay img.focused");
3691         if (currentlyFocusedImage) {
3692                 currentlyFocusedImage.addClass("last-focused");
3693                 currentlyFocusedImage.accessKey = 'l';
3694         }
3696         // Remove focused image and hide overlay.
3697         let imageFocusOverlay = query("#image-focus-overlay");
3698         imageFocusOverlay.removeClass("engaged");
3699         removeElement(imageFocusOverlay.query("img"));
3701         // Un-blur content/etc.
3702         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3703                 element.removeClass("blurred");
3704         });
3706         // Unset "focused" class of focused image.
3707         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3709         // Re-enable page scrolling.
3710         togglePageScrolling(true);
3712         // Reset the hash, if needed.
3713         if (location.hash.hasPrefix("#if_slide_"))
3714                 history.replaceState(window.history.state, null, "#");
3717 function getIndexOfFocusedImage() {
3718         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3719         var indexOfFocusedImage = -1;
3720         for (i = 0; i < images.length; i++) {
3721                 if (images[i].hasClass("focused")) {
3722                         indexOfFocusedImage = i;
3723                         break;
3724                 }
3725         }
3726         return indexOfFocusedImage;
3729 function focusNextImage(next = true) {
3730         GWLog("focusNextImage");
3731         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3732         var indexOfFocusedImage = getIndexOfFocusedImage();
3734         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3736         // Remove existing image.
3737         removeElement("#image-focus-overlay img");
3738         // Unset "focused" class of just-removed image.
3739         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3741         // Create the focused version of the image.
3742         images[indexOfFocusedImage].addClass("focused");
3743         let imageFocusOverlay = query("#image-focus-overlay");
3744         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3745         clonedImage.style = "";
3746         clonedImage.removeAttribute("width");
3747         clonedImage.removeAttribute("height");
3748         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3749         imageFocusOverlay.appendChild(clonedImage);
3750         imageFocusOverlay.addClass("engaged");
3751         // Set image to default size and position.
3752         resetFocusedImagePosition();
3753         // Set state of next/previous buttons.
3754         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3755         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3756         // Set the image number display.
3757         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3758         // Set the caption.
3759         setImageFocusCaption();
3760         // Replace the hash.
3761         history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3764 function setImageFocusCaption() {
3765         GWLog("setImageFocusCaption");
3766         var T = { }; // Temporary storage.
3768         // Clear existing caption, if any.
3769         let captionContainer = query("#image-focus-overlay .caption");
3770         Array.from(captionContainer.children).forEach(child => { child.remove(); });
3772         // Determine caption.
3773         let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3774         var captionHTML;
3775         if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) && 
3776                 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3777                 captionHTML = (T.figcaption.query("p")) ? 
3778                                           T.figcaption.innerHTML : 
3779                                           "<p>" + T.figcaption.innerHTML + "</p>"; 
3780         } else if (currentlyFocusedImage.title != "") {
3781                 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3782         }
3783         // Insert the caption, if any.
3784         if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3787 function hideImageFocusUI() {
3788         GWLog("hideImageFocusUI");
3789         let imageFocusOverlay = query("#image-focus-overlay");
3790         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3791                 element.addClass("hidden");
3792         });
3795 function unhideImageFocusUI() {
3796         GWLog("unhideImageFocusUI");
3797         let imageFocusOverlay = query("#image-focus-overlay");
3798         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3799                 element.removeClass("hidden");
3800         });
3803 function cancelImageFocusHideUITimer() {
3804         clearTimeout(GW.imageFocus.hideUITimer);
3805         GW.imageFocus.hideUITimer = null;
3808 /*****************/
3809 /* KEYBOARD HELP */
3810 /*****************/
3812 function keyboardHelpSetup() {
3813         let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3814                 <div class='keyboard-help-container'>
3815                         <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'>&#xf00d;</button>
3816                         <h1>Keyboard shortcuts</h1>
3817                         <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>
3818                         <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3819                         <div class='keyboard-shortcuts-lists'>` + [ [
3820                                 "General",
3821                                 [ [ '?' ], "Show keyboard shortcuts" ],
3822                                 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3823                         ], [
3824                                 "Site navigation",
3825                                 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3826                                 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3827                                 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3828                                 [ [ 'ak-m' ], "Go to Meta view" ],
3829                                 [ [ 'ak-v' ], "Go to Tags view"],
3830                                 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3831                                 [ [ 'ak-r' ], "Go to Archive view" ],
3832                                 [ [ 'ak-q' ], "Go to Sequences view" ],
3833                                 [ [ 'ak-t' ], "Go to About page" ],
3834                                 [ [ 'ak-u' ], "Go to User or Login page" ],
3835                                 [ [ 'ak-o' ], "Go to Inbox page" ]
3836                         ], [
3837                                 "Page navigation",
3838                                 [ [ 'ak-,' ], "Jump up to top of page" ],
3839                                 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3840                                 [ [ 'ak-/' ], "Jump to top of comments section" ],
3841                                 [ [ 'ak-s' ], "Search" ],
3842                         ], [
3843                                 "Page actions",
3844                                 [ [ 'ak-n' ], "New post or comment" ],
3845                                 [ [ 'ak-e' ], "Edit current post" ]
3846                         ], [
3847                                 "Post/comment list views",
3848                                 [ [ '.' ], "Focus next entry in list" ],
3849                                 [ [ ',' ], "Focus previous entry in list" ],
3850                                 [ [ ';' ], "Cycle between links in focused entry" ],
3851                                 [ [ 'Enter' ], "Go to currently focused entry" ],
3852                                 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3853                                 [ [ 'ak-]' ], "Go to next page" ],
3854                                 [ [ 'ak-[' ], "Go to previous page" ],
3855                                 [ [ 'ak-\\' ], "Go to first page" ],
3856                                 [ [ 'ak-e' ], "Edit currently focused post" ]
3857                         ], [
3858                                 "Editor",
3859                                 [ [ 'ak-k' ], "Bold text" ],
3860                                 [ [ 'ak-i' ], "Italic text" ],
3861                                 [ [ 'ak-l' ], "Insert hyperlink" ],
3862                                 [ [ 'ak-q' ], "Blockquote text" ]
3863                         ], [                            
3864                                 "Appearance",
3865                                 [ [ 'ak-=' ], "Increase text size" ],
3866                                 [ [ 'ak--' ], "Decrease text size" ],
3867                                 [ [ 'ak-0' ], "Reset to default text size" ],
3868                                 [ [ 'ak-′' ], "Cycle through content width settings" ],
3869                                 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3870                                 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3871                                 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3872                                 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3873                                 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3874                                 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3875                                 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3876                                 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3877                                 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3878                                 [ [ 'ak-;' ], "Open theme tweaker" ],
3879                                 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3880                                 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3881                         ], [
3882                                 "Slide shows",
3883                                 [ [ 'ak-l' ], "Start/resume slideshow" ],
3884                                 [ [ 'Esc' ], "Exit slideshow" ],
3885                                 [ [ '&#x2192;', '&#x2193;' ], "Next slide" ],
3886                                 [ [ '&#x2190;', '&#x2191;' ], "Previous slide" ],
3887                                 [ [ 'Space' ], "Reset slide zoom" ]
3888                         ], [
3889                                 "Miscellaneous",
3890                                 [ [ 'ak-x' ], "Switch to next view on user page" ],
3891                                 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3892                                 [ [ 'ak-`&nbsp;' ], "Toggle compact comment list view" ],
3893                                 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3894                         ] ].map(section => 
3895                         `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3896                                 `<li>
3897                                         <span class='keys'>` + 
3898                                         entry[0].map(key =>
3899                                                 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3900                                         ).join("") + 
3901                                         `</span>
3902                                         <span class='action'>${entry[1]}</span>
3903                                 </li>`
3904                         ).join("\n") + `</ul>`).join("\n") + `
3905                         </ul></div>             
3906                 </div>
3907         ` + "</nav>");
3909         // Add listener to show the keyboard help overlay.
3910         document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3911                 GWLog("GW.keyboardHelpShowKeyPressed");
3912                 if (event.key == '?')
3913                         toggleKeyboardHelpOverlay(true);
3914         });
3916         // Clicking the background overlay closes the keyboard help overlay.
3917         keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3918                 GWLog("GW.keyboardHelpOverlayClicked");
3919                 if (event.type == 'mousedown') {
3920                         keyboardHelpOverlay.style.opacity = "0.01";
3921                 } else {
3922                         toggleKeyboardHelpOverlay(false);
3923                         keyboardHelpOverlay.style.opacity = "1.0";
3924                 }
3925         }, true);
3927         // Intercept clicks, so they don't "fall through" the background overlay.
3928         (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3930         // Clicking the close button closes the keyboard help overlay.
3931         keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3932                 toggleKeyboardHelpOverlay(false);
3933         });
3935         // Add button to open keyboard help.
3936         query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'>&#xf11c;</button>");
3937         query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3938                 GWLog("GW.openKeyboardHelpButtonClicked");
3939                 toggleKeyboardHelpOverlay(true);
3940                 event.target.blur();
3941         });
3944 function toggleKeyboardHelpOverlay(show) {
3945         console.log("toggleKeyboardHelpOverlay");
3947         let keyboardHelpOverlay = query("#keyboard-help-overlay");
3948         show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3949         keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3951         // Prevent scrolling the document when the overlay is visible.
3952         togglePageScrolling(!show);
3954         // Focus the close button as soon as we open.
3955         keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3957         if (show) {
3958                 // Add listener to show the keyboard help overlay.
3959                 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3960                         GWLog("GW.keyboardHelpHideKeyPressed");
3961                         if (event.key == 'Escape')
3962                                 toggleKeyboardHelpOverlay(false);
3963                 });
3964         } else {
3965                 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3966         }
3968         // Disable / enable tab-selection of the search box.
3969         setSearchBoxTabSelectable(!show);
3972 /**********************/
3973 /* PUSH NOTIFICATIONS */
3974 /**********************/
3976 function pushNotificationsSetup() {
3977         let pushNotificationsButton = query("#enable-push-notifications");
3978         if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3979                 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3980                 pushNotificationsButton.style.display = 'unset';
3981         }
3984 function urlBase64ToUint8Array(base64String) {
3985         const padding = '='.repeat((4 - base64String.length % 4) % 4);
3986         const base64 = (base64String + padding)
3987               .replace(/-/g, '+')
3988               .replace(/_/g, '/');
3989         
3990         const rawData = window.atob(base64);
3991         const outputArray = new Uint8Array(rawData.length);
3992         
3993         for (let i = 0; i < rawData.length; ++i) {
3994                 outputArray[i] = rawData.charCodeAt(i);
3995         }
3996         return outputArray;
3999 function pushNotificationsButtonClicked(event) {
4000         event.target.style.opacity = 0.33;
4001         event.target.style.pointerEvents = "none";
4003         let reEnable = (message) => {
4004                 if(message) alert(message);
4005                 event.target.style.opacity = 1;
4006                 event.target.style.pointerEvents = "unset";
4007         }
4009         if(event.target.dataset.enabled) {
4010                 fetch('/push/register', {
4011                         method: 'post',
4012                         headers: { 'Content-type': 'application/json' },
4013                         body: JSON.stringify({
4014                                 cancel: true
4015                         }),
4016                 }).then(() => {
4017                         event.target.innerHTML = "Enable push notifications";
4018                         event.target.dataset.enabled = "";
4019                         reEnable();
4020                 }).catch((err) => reEnable(err.message));
4021         } else {
4022                 Notification.requestPermission().then((permission) => {
4023                         navigator.serviceWorker.ready
4024                                 .then((registration) => {
4025                                         return registration.pushManager.getSubscription()
4026                                                 .then(async function(subscription) {
4027                                                         if (subscription) {
4028                                                                 return subscription;
4029                                                         }
4030                                                         return registration.pushManager.subscribe({
4031                                                                 userVisibleOnly: true,
4032                                                                 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
4033                                                         });
4034                                                 })
4035                                                 .catch((err) => reEnable(err.message));
4036                                 })
4037                                 .then((subscription) => {
4038                                         fetch('/push/register', {
4039                                                 method: 'post',
4040                                                 headers: {
4041                                                         'Content-type': 'application/json'
4042                                                 },
4043                                                 body: JSON.stringify({
4044                                                         subscription: subscription
4045                                                 }),
4046                                         });
4047                                 })
4048                                 .then(() => {
4049                                         event.target.innerHTML = "Disable push notifications";
4050                                         event.target.dataset.enabled = "true";
4051                                         reEnable();
4052                                 })
4053                                 .catch(function(err){ reEnable(err.message) });
4054                         
4055                 });
4056         }
4059 /*******************************/
4060 /* HTML TO MARKDOWN CONVERSION */
4061 /*******************************/
4063 function MarkdownFromHTML(text) {
4064         GWLog("MarkdownFromHTML");
4065         // Wrapper tags, paragraphs, bold, italic, code blocks.
4066         text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
4067                 switch(tag) {
4068                 case "html":
4069                 case "/html":
4070                 case "head":
4071                 case "/head":
4072                 case "body":
4073                 case "/body":
4074                 case "p":
4075                         return "";
4076                 case "/p":
4077                         return "\n";
4078                 case "strong":
4079                 case "/strong":
4080                         return "**";
4081                 case "em":
4082                 case "/em":
4083                         return "*";
4084                 default:
4085                         return match;
4086                 }
4087         });
4089         // <div> and <span>.
4090         text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
4091                 return `${text}\n`;
4092         }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
4093                 return `${text}\n`;
4094         });
4096         // Unordered lists.
4097         text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
4098                 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
4099                         return `* ${listItem}\n`;
4100                 });
4101         });
4103         // Ordered lists.
4104         text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
4105                 var countedItemValue = 0;
4106                 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
4107                         var itemValue;
4108                         if (typeof specifiedItemValue != "undefined") {
4109                                 specifiedItemValue = parseInt(specifiedItemValue);
4110                                 countedItemValue = itemValue = specifiedItemValue;
4111                         } else {
4112                                 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
4113                         }
4114                         return `${itemValue}. ${listItem.trim()}\n`;
4115                 });
4116         });
4118         // Headings.
4119         text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
4120                 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
4121         });
4123         // Blockquotes.
4124         text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
4125                 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
4126         });
4128         // Links.
4129         text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
4130                 return `[${text}](${href})`;
4131         }).trim();
4133         // Images.
4134         text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
4135                 return `![](${src})`;
4136         });
4138         // Horizontal rules.
4139         text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
4140                 return "\n---\n";
4141         });
4143         // Line breaks.
4144         text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
4145                 return "\\\n";
4146         });
4148         // Preformatted text (possibly with a code block inside).
4149         text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
4150                 return "```\n" + text + "\n```";
4151         });
4153         // Code blocks.
4154         text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
4155                 return "`" + text + "`";
4156         });
4158         // HTML entities.
4159         text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
4160                 switch(entity) {
4161                 case "gt":
4162                         return ">";
4163                 case "lt":
4164                         return "<";
4165                 case "amp":
4166                         return "&";
4167                 case "apos":
4168                         return "'";
4169                 case "quot":
4170                         return "\"";
4171                 default:
4172                         return match;
4173                 }
4174         });
4176         return text;
4179 /************************************/
4180 /* ANCHOR LINK SCROLLING WORKAROUND */
4181 /************************************/
4183 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
4184         let hash = location.hash;
4185         if(hash && hash !== "#top" && !document.query(hash)) {
4186                 let content = document.query("#content");
4187                 content.style.display = "none";
4188                 addTriggerListener("DOMReady", {priority: -1, fn: () => {
4189                         content.style.visibility = "hidden";
4190                         content.style.display = null;
4191                         requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
4192                 }});
4193         }
4194 }});
4196 /******************/
4197 /* INITIALIZATION */
4198 /******************/
4200 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
4201         GWLog("INITIALIZER earlyInitialize");
4202         // Check to see whether we're on a mobile device (which we define as a narrow screen)
4203         GW.isMobile = (window.innerWidth <= 1160);
4204         GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
4206         // Backward compatibility
4207         let storedTheme = localStorage.getItem("selected-theme");
4208         if (storedTheme) {
4209                 Appearance.setTheme(storedTheme);
4210                 localStorage.removeItem("selected-theme");
4211         }
4213         // Animate width & theme adjustments?
4214         Appearance.adjustmentTransitions = false;
4215         // Add the content width selector.
4216         Appearance.injectContentWidthSelector();
4217         // Add the text size adjustment widget.
4218         Appearance.injectTextSizeAdjustmentUI();
4219         // Add the theme selector.
4220         Appearance.injectThemeSelector();
4221         // Add the theme tweaker.
4222         Appearance.injectThemeTweaker();
4224         // Add the dark mode selector.
4225         DarkMode.injectModeSelector();
4227         // Add the quick-nav UI.
4228         injectQuickNavUI();
4230         // Finish initializing when ready.
4231         addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4232 }});
4234 function mainInitializer() {
4235         GWLog("INITIALIZER initialize");
4237         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4238         // "link without nav bars".
4239         if (getQueryVariable("hide-nav-bars") == "true") {
4240                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
4241         }
4243         // If the page cannot have comments, remove the accesskey from the #comments
4244         // quick-nav button; and if the page can have comments, but does not, simply 
4245         // disable the #comments quick nav button.
4246         let content = query("#content");
4247         if (content.query("#comments") == null) {
4248                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4249         } else if (content.query("#comments .comment-thread") == null) {
4250                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4251         }
4253         // On edit post pages and conversation pages, add GUIEdit buttons to the 
4254         // textarea, expand it, and markdownify the existing text, if any (this is
4255         // needed if a post was last edited on LW).
4256         queryAll(".with-markdown-editor textarea").forEach(textarea => {
4257                 textarea.addTextareaFeatures();
4258                 expandTextarea(textarea);
4259                 textarea.value = MarkdownFromHTML(textarea.value);
4260         });
4261         // Focus the textarea.
4262         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4264         // Clean up ToC
4265         queryAll(".contents-list li a").forEach(tocLink => {
4266                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
4267                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
4268                 tocLink.innerText = tocLink.innerText.replace(/^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})\. /i, '');
4269                 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
4270         });
4272         // If we're on a comment thread page...
4273         if (query(".comments") != null) {
4274                 // Add comment-minimize buttons to every comment.
4275                 queryAll(".comment-meta").forEach(commentMeta => {
4276                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4277                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
4278                 });
4279                 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4280                         // Format and activate comment-minimize buttons.
4281                         queryAll(".comment-minimize-button").forEach(button => {
4282                                 button.closest(".comment-item").setCommentThreadMaximized(false);
4283                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4284                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
4285                                 });
4286                         });
4287                 }
4288         }
4289         if (getQueryVariable("chrono") == "t") {
4290                 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
4291         }
4293         // On mobile, replace the labels for the checkboxes on the edit post form
4294         // with icons, to save space.
4295         if (GW.isMobile && query(".edit-post-page")) {
4296                 query("label[for='link-post']").innerHTML = "&#xf0c1";
4297                 query("label[for='question']").innerHTML = "&#xf128";
4298         }
4300         // Add error message (as placeholder) if user tries to click Search with
4301         // an empty search field.
4302         searchForm: {
4303                 let searchForm = query("#nav-item-search form");
4304                 if(!searchForm) break searchForm;
4305                 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4306                         let searchField = event.target.query("input");
4307                         if (searchField.value == "") {
4308                                 event.preventDefault();
4309                                 event.target.blur();
4310                                 searchField.placeholder = "Enter a search string!";
4311                                 searchField.focus();
4312                         }
4313                 });
4314                 // Remove the placeholder / error on any input.
4315                 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4316                         event.target.placeholder = "";
4317                 });
4318         }
4320         // Prevent conflict between various single-hotkey listeners and text fields
4321         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4322                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4323                 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4324         });
4326         if (content.hasClass("post-page")) {
4327                 // Read and update last-visited-date.
4328                 let lastVisitedDate = getLastVisitedDate();
4329                 setLastVisitedDate(Date.now());
4331                 // Save the number of comments this post has when it's visited.
4332                 updateSavedCommentCount();
4334                 if (content.query(".comments .comment-thread") != null) {
4335                         // Add the new comments count & navigator.
4336                         injectNewCommentNavUI();
4338                         // Get the highlight-new-since date (as specified by URL parameter, if 
4339                         // present, or otherwise the date of the last visit).
4340                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4342                         // Highlight new comments since the specified date.                      
4343                         let newCommentsCount = highlightCommentsSince(hnsDate);
4345                         // Update the comment count display.
4346                         updateNewCommentNavUI(newCommentsCount, hnsDate);
4347                 }
4348         } else {
4349                 // On listing pages, make comment counts more informative.
4350                 badgePostsWithNewComments();
4351         }
4353         // Add the comments list mode selector widget (expanded vs. compact).
4354         injectCommentsListModeSelector();
4356         // Add the comments view selector widget (threaded vs. chrono).
4357 //      injectCommentsViewModeSelector();
4359         // Add the comments sort mode selector (top, hot, new, old).
4360         if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4362         // Add the toggle for the post nav UI elements on mobile.
4363         if (GW.isMobile) injectPostNavUIToggle();
4365         // Add the toggle for the appearance adjustment UI elements on mobile.
4366         if (GW.isMobile)
4367                 Appearance.injectAppearanceAdjustUIToggle();
4369         // Add the antikibitzer.
4370         if (GW.useFancyFeatures)
4371                 injectAntiKibitzer();
4373         // Add comment parent popups.
4374         injectPreviewPopupToggle();
4375         addCommentParentPopups();
4377         // Mark original poster's comments with a special class.
4378         markOriginalPosterComments();
4379         
4380         // On the All view, mark posts with non-positive karma with a special class.
4381         if (query("#content").hasClass("all-index-page")) {
4382                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4383                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4385                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4386                 });
4387         }
4389         // Set the "submit" button on the edit post page to something more helpful.
4390         setEditPostPageSubmitButtonText();
4392         // Compute the text of the pagination UI tooltip text.
4393         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4394                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4395         });
4397         // Add event listeners for Escape and Enter, for the theme tweaker.
4398         document.addEventListener("keyup", Appearance.themeTweakerUIKeyPressed);
4400         // Add event listener for . , ; (for navigating listings pages).
4401         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4402         if (!query(".comments") && listings.length > 0) {
4403                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
4404                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4406                         if (event.key == "Escape") {
4407                                 if (document.activeElement.parentElement.hasClass("listing"))
4408                                         document.activeElement.blur();
4409                                 return;
4410                         }
4412                         if (event.key == ';') {
4413                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4414                                         let links = document.activeElement.parentElement.queryAll("a");
4415                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4416                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4417                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4418                                         links[document.activeElement == links[0] ? 1 : 0].focus();
4419                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4420                                 }
4421                                 return;
4422                         }
4424                         var indexOfActiveListing = -1;
4425                         for (i = 0; i < listings.length; i++) {
4426                                 if (document.activeElement.parentElement.hasClass("listing") && 
4427                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4428                                         indexOfActiveListing = i;
4429                                         break;
4430                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
4431                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
4432                                         indexOfActiveListing = i;
4433                                         break;
4434                                 }
4435                         }
4436                         // Remove edit accesskey from currently highlighted post by active user, if applicable.
4437                         if (indexOfActiveListing > -1) {
4438                                 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4439                         }
4440                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4441                         if (indexOfNextListing < listings.length) {
4442                                 listings[indexOfNextListing].focus();
4444                                 if (listings[indexOfNextListing].closest(".comment-item")) {
4445                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4446                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4447                                 }
4448                         } else {
4449                                 document.activeElement.blur();
4450                         }
4451                         // Add edit accesskey to newly highlighted post by active user, if applicable.
4452                         (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4453                 });
4454                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4455                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4456                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4457                         });
4458                 });
4459         }
4460         // Add event listener for ; (to focus the link on link posts).
4461         if (query("#content").hasClass("post-page") && 
4462                 query(".post").hasClass("link-post")) {
4463                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4464                         if (event.key == ';') query("a.link-post-link").focus();
4465                 });
4466         }
4468         // Add accesskeys to user page view selector.
4469         let viewSelector = query("#content.user-page > .sublevel-nav");
4470         if (viewSelector) {
4471                 let currentView = viewSelector.query("span");
4472                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4473                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4474         }
4476         // Add accesskey to index page sort selector.
4477         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4479         // Move MathJax style tags to <head>.
4480         var aggregatedStyles = "";
4481         queryAll("#content style").forEach(styleTag => {
4482                 aggregatedStyles += styleTag.innerHTML;
4483                 removeElement("style", styleTag.parentElement);
4484         });
4485         if (aggregatedStyles != "") {
4486                 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4487         }
4489         // Add listeners to switch between word count and read time.
4490         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4491         queryAll(".post-meta .read-time").forEach(element => {
4492                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4493                         let displayWordCount = localStorage.getItem("display-word-count");
4494                         toggleReadTimeOrWordCount(!displayWordCount);
4495                         if (displayWordCount) localStorage.removeItem("display-word-count");
4496                         else localStorage.setItem("display-word-count", true);
4497                 });
4498         });
4500         // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4501         query("#content").addEventListener("copy", GW.textCopied = (event) => {
4502                 if(event.target.matches("input, textarea")) return;
4503                 event.preventDefault();
4504                 const selectedHTML = getSelectionHTML();
4505                 const selectedText = getSelection().toString();
4506                 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4507                 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4508         });
4510         // Set up Image Focus feature.
4511         imageFocusSetup();
4513         // Set up keyboard shortcuts guide overlay.
4514         keyboardHelpSetup();
4516         // Show push notifications button if supported
4517         pushNotificationsSetup();
4519         // Show elements now that javascript is ready.
4520         removeElement("#hide-until-init");
4522         activateTrigger("pageLayoutFinished");
4525 /*************************/
4526 /* POST-LOAD ADJUSTMENTS */
4527 /*************************/
4529 window.addEventListener("pageshow", badgePostsWithNewComments);
4531 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4532         GWLog("INITIALIZER pageLayoutFinished");
4534         Appearance.postSetThemeHousekeeping();
4536         focusImageSpecifiedByURL();
4538         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4539 //      query("input[type='search']").value = GW.isMobile;
4540 //      insertHeadHTML("<style>" +
4541 //              `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` + 
4542 //              `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` + 
4543 //              "</style>");
4544 }});
4546 function generateImagesOverlay() {
4547         GWLog("generateImagesOverlay");
4548         // Don't do this on the about page.
4549         if (query(".about-page") != null) return;
4550         return;
4552         // Remove existing, if any.
4553         removeElement("#images-overlay");
4555         // Create new.
4556         query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4557         let imagesOverlay = query("#images-overlay");
4558         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4559         queryAll(".post-body img").forEach(image => {
4560                 let clonedImageContainer = document.createElement("div");
4562                 let clonedImage = image.cloneNode(true);
4563                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4564                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4565                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4566                 clonedImageContainer.appendChild(clonedImage);
4568                 let zoomLevel = Appearance.currentTextZoom;
4570                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4571                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4572                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4573                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4575                 imagesOverlay.appendChild(clonedImageContainer);
4576         });
4578         // Add the event listeners to focus each image.
4579         imageFocusSetup(true);
4582 function adjustUIForWindowSize() {
4583         GWLog("adjustUIForWindowSize");
4584         var bottomBarOffset;
4586         // Adjust bottom bar state.
4587         let bottomBar = query("#bottom-bar");
4588         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4589         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4590                 bottomBar.removeClass("decorative");
4592                 bottomBar.query("#nav-item-top").style.display = "";
4593         } else if (bottomBar) {
4594                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4595                 else bottomBar.addClass("decorative");
4597                 bottomBar.query("#nav-item-top").style.display = "none";
4598         }
4600         // Show quick-nav UI up/down buttons if content is taller than window.
4601         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4602         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4603                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4604         });
4606         // Move anti-kibitzer toggle if content is very short.
4607         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4609         // Update the visibility of the post nav UI.
4610         updatePostNavUIVisibility();
4613 function recomputeUIElementsContainerHeight(force = false) {
4614         GWLog("recomputeUIElementsContainerHeight");
4615         if (!GW.isMobile &&
4616                 (force || query("#ui-elements-container").style.height != "")) {
4617                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4618                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
4619                                                                                                                 query("#content").clientHeight + "px" :
4620                                                                                                                 "100vh";
4621         }
4624 function focusImageSpecifiedByURL() {
4625         GWLog("focusImageSpecifiedByURL");
4626         if (location.hash.hasPrefix("#if_slide_")) {
4627                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4628                         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4629                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4630                         if (imageToFocus > 0 && imageToFocus <= images.length) {
4631                                 focusImage(images[imageToFocus - 1]);
4633                                 // Set timer to hide the image focus UI.
4634                                 unhideImageFocusUI();
4635                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4636                         }
4637                 });
4638         }
4641 /***********/
4642 /* GUIEDIT */
4643 /***********/
4645 function insertMarkup(event) {
4646         var mopen = '', mclose = '', mtext = '', func = false;
4647         if (typeof arguments[1] == 'function') {
4648                 func = arguments[1];
4649         } else {
4650                 mopen = arguments[1];
4651                 mclose = arguments[2];
4652                 mtext = arguments[3];
4653         }
4655         var textarea = event.target.closest("form").query("textarea");
4656         textarea.focus();
4657         var p0 = textarea.selectionStart;
4658         var p1 = textarea.selectionEnd;
4659         var cur0 = cur1 = p0;
4661         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4662         str = func ? func(str, p0) : (mopen + str + mclose);
4664         // Determine selection.
4665         if (!func) {
4666                 cur0 += (p0 == p1) ? mopen.length : str.length;
4667                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4668         } else {
4669                 cur0 = str[1];
4670                 cur1 = str[2];
4671                 str = str[0];
4672         }
4674         // Update textarea contents.
4675         // The document.execCommand API is broken in Firefox 
4676         // ( https://bugzilla.mozilla.org/show_bug.cgi?id=1220696 ), but using it
4677         // allows native undo/redo to work; so we enable it in other browsers.
4678         if (GW.isFirefox) {
4679                 textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
4680         } else {
4681                 document.execCommand("insertText", false, str);
4682         }
4683         // Expand textarea, if needed.
4684         expandTextarea(textarea);
4686         // Set selection.
4687         textarea.selectionStart = cur0;
4688         textarea.selectionEnd = cur1;
4690         return;
4693 GW.guiEditButtons = [
4694         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
4695         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
4696         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
4697         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
4698         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
4699         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
4700         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
4701         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
4702         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
4703         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
4704         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
4705         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
4706         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
4707         [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
4708         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
4711 function blockquote(text, startpos) {
4712         if (text == '') {
4713                 text = "> Quoted text";
4714                 return [ text, startpos + 2, startpos + text.length ];
4715         } else {
4716                 text = "> " + text.split("\n").join("\n> ") + "\n";
4717                 return [ text, startpos + text.length, startpos + text.length ];
4718         }
4721 function hyperlink(text, startpos) {
4722         var url = '', link_text = text, endpos = startpos;
4723         if (text.search(/^https?/) != -1) {
4724                 url = text;
4725                 link_text = "link text";
4726                 startpos = startpos + 1;
4727                 endpos = startpos + link_text.length;
4728         } else {
4729                 url = prompt("Link address (URL):");
4730                 if (!url) {
4731                         endpos = startpos + text.length;
4732                         return [ text, startpos, endpos ];
4733                 }
4734                 startpos = startpos + text.length + url.length + 4;
4735                 endpos = startpos;
4736         }
4738         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4741 if(navigator.serviceWorker) {
4742         navigator.serviceWorker.register('/service-worker.js');
4743         setCookie("push", "t");