Fixed bug in generateImagesOverlay
[lw2-viewer.git] / www / script.js
blobaa6902e2be03ba20853fe80e0f822429f9dd07d7
1 /***************************/
2 /* INITIALIZATION REGISTRY */
3 /***************************/
5 if (!window.requestIdleCallback) {
6         window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
9 GW.initializersDone = {};
10 GW.initializers = {};
11 function registerInitializer(name, tryEarly, precondition, fn) {
12         GW.initializersDone[name] = false;
13         GW.initializers[name] = fn;
14         let wrapper = function () {
15                 if (GW.initializersDone[name]) return;
16                 if (!precondition()) {
17                         if (tryEarly) {
18                                 setTimeout(() => requestIdleCallback(wrapper, {timeout: 1000}), 50);
19                         } else {
20                                 document.addEventListener("readystatechange", wrapper, {once: true});
21                         }
22                         return;
23                 }
24                 GW.initializersDone[name] = true;
25                 fn();
26         };
27         if (tryEarly) {
28                 requestIdleCallback(wrapper, {timeout: 1000});
29         } else {
30                 document.addEventListener("readystatechange", wrapper, {once: true});
31                 requestIdleCallback(wrapper);
32         }
34 function forceInitializer(name) {
35         if (GW.initializersDone[name]) return;
36         GW.initializersDone[name] = true;
37         GW.initializers[name]();
40 /***********/
41 /* COOKIES */
42 /***********/
44 function setCookie(name, value, days) {
45         var expires = "";
46         if (!days) days = 36500;
47         if (days) {
48                 var date = new Date();
49                 date.setTime(date.getTime() + (days*24*60*60*1000));
50                 expires = "; expires=" + date.toUTCString();
51         }
52         document.cookie = name + "=" + (value || "")  + expires + "; path=/";
54 function readCookie(name) {
55         var nameEQ = name + "=";
56         var ca = document.cookie.split(';');
57         for(var i = 0; i < ca.length; i++) {
58                 var c = ca[i];
59                 while (c.charAt(0)==' ') c = c.substring(1, c.length);
60                 if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
61         }
62         return null;
65 /*******************************/
66 /* EVENT LISTENER MANIPULATION */
67 /*******************************/
69 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
70         let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
71         if (includeMouseDown) this.addEventListener("mousedown", ael);
72         this.addEventListener("click", ael);
73         this.addEventListener("keyup", ael);
76 Element.prototype.removeActivateEvent = function() {
77         let ael = this.activateEventListener;
78         this.removeEventListener("mousedown", ael);
79         this.removeEventListener("click", ael);
80         this.removeEventListener("keyup", ael);
83 function addScrollListener(fn, name) {
84         let wrapper = (event) => {
85                 requestAnimationFrame(() => {
86                         fn(event);
87                         document.addEventListener("scroll", wrapper, {once: true, passive: true});
88                 });
89         }
90         document.addEventListener("scroll", wrapper, {once: true, passive: true});
92         // Retain a reference to the scroll listener, if a name is provided.
93         if (typeof name != "undefined")
94                 GW[name] = wrapper;
97 /****************/
98 /* MISC HELPERS */
99 /****************/
101 Element.prototype.scrollIntoViewIfNeeded = function() {
102         if (this.getBoundingClientRect().bottom > window.innerHeight) {
103                 this.scrollIntoView(false);
104         }
107 Element.prototype.getCommentId = function() {
108         let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
109         if (item) {
110                 return /^comment-(.*)/.exec(item.id)[1];
111         } else {
112                 return false;
113         }
116 function GWLog (string) {
117         if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
118                 console.log(string);
120 GW.enableLogging = (permanently = false) => {
121         if (permanently)
122                 localStorage.setItem("logging-enabled", "true");
123         else
124                 GW.loggingEnabled = true;
126 GW.disableLogging = (permanently = false) => {
127         if (permanently)
128                 localStorage.removeItem("logging-enabled");
129         else
130                 GW.loggingEnabled = false;
133 function doAjax(params) {
134         let req = new XMLHttpRequest();
135         req.addEventListener("load", (event) => {
136                 if(event.target.status < 400) {
137                         if(params["onSuccess"]) params.onSuccess();
138                 } else {
139                         if(params["onFailure"]) params.onFailure();
140                 }
141         });
142         req.open((params["method"] || "GET"), (params.location || document.location));
143         if(params["method"] == "POST") {
144                 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
145                 params["params"]["csrf-token"] = GW.csrfToken;
146                 req.send(params.params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params.params[x])}).join("&"));
147         } else {
148                 req.send();
149         }
152 function getSelectionHTML() {
153         var container = document.createElement("div");
154         container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
155         return container.innerHTML;
158 /*******************/
159 /* INBOX INDICATOR */
160 /*******************/
162 function updateInbox() {
163         GWLog("updateInbox");
164         if (!loggedInUserId) return;
166         let request = new XMLHttpRequest();
167         request.addEventListener("load", (event) => {
168                 if (event.target.status != 200) return;
170                 let response = JSON.parse(event.target.responseText);
171                 if (response) {
172                         let element = query('#inbox-indicator');
173                         element.className = 'new-messages';
174                         element.title = 'New messages [o]';
175                 }
176         });
177         request.open("GET", "/check-notifications");
178         request.send();
181 /**************/
182 /* COMMENTING */
183 /**************/
185 function toggleMarkdownHintsBox() {
186         GWLog("toggleMarkdownHintsBox");
187         let markdownHintsBox = query("#markdown-hints");
188         markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
190 function hideMarkdownHintsBox() {
191         GWLog("hideMarkdownHintsBox");
192         let markdownHintsBox = query("#markdown-hints");
193         if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
196 Element.prototype.addTextareaFeatures = function() {
197         GWLog("addTextareaFeatures");
198         let textarea = this;
200         textarea.addEventListener("focus", (event) => { event.target.closest("form").scrollIntoViewIfNeeded(); });
201         textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
202                 if (window.innerWidth > 520) {
203                         // Expand textarea if needed.
204                         expandTextarea(textarea);
205                 } else {
206                         // Remove markdown hints.
207                         hideMarkdownHintsBox();
208                         query(".guiedit-mobile-help-button").removeClass("active");
209                 }
210         }, false);
211         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
213         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
214         let textareaContainer = textarea.closest(".textarea-container");
215         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
216         for (var button of GW.guiEditButtons) {
217                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
218                 buttons_container.insertAdjacentHTML("beforeend", 
219                         "<button type='button' class='guiedit guiedit-" 
220                         + name
221                         + "' tabindex='-1'"
222                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
223                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
224                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
225                         + " onclick='insertMarkup(event,"
226                         + ((typeof m_before_or_func == 'function') ?
227                                 m_before_or_func.name : 
228                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
229                         + ");'><div>"
230                         + icon
231                         + "</div></button>"
232                 );
233         }
235         var markdown_hints = 
236         `<input type='checkbox' id='markdown-hints-checkbox'>
237         <label for='markdown-hints-checkbox'></label>
238         <div id='markdown-hints'>` + 
239         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
240                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
241                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
242                 "<span>Heading 1</span><code># Heading 1</code>",
243                 "<span>Heading 2</span><code>## Heading 1</code>",
244                 "<span>Heading 3</span><code>### Heading 1</code>",
245                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
246         `</div>`;
247         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
249         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
250                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
251                         if (button.hasClass("guiedit-mobile-help-button")) {
252                                 toggleMarkdownHintsBox();
253                                 event.target.toggleClass("active");
254                                 query(".posting-controls:focus-within textarea").focus();
255                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
256                                 event.target.blur();
257                                 hideMarkdownHintsBox();
258                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
259                         }
260                 });
261         });
263         // On smartphone (narrow mobile) screens, when a textarea is focused (and
264         // automatically fullscreened), remove all the filters from the page, and 
265         // then apply them *just* to the fixed editor UI elements. This is in order
266         // to get around the "children of elements with a filter applied cannot be
267         // fixed" issue".
268         if (GW.isMobile && window.innerWidth <= 520) {
269                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
270                 textarea.addEventListener("focus", (event) => {
271                         GW.savedFilters = GW.currentFilters;
272                         GW.currentFilters = { };
273                         applyFilters(GW.currentFilters);
274                         fixedEditorElements.forEach(element => {
275                                 element.style.filter = filterStringFromFilters(GW.savedFilters);
276                         });
277                 });
278                 textarea.addEventListener("blur", (event) => {
279                         GW.currentFilters = GW.savedFilters;
280                         GW.savedFilters = { };
281                         requestAnimationFrame(() => {
282                                 applyFilters(GW.currentFilters);
283                                 fixedEditorElements.forEach(element => {
284                                         element.style.filter = filterStringFromFilters(GW.savedFilters);
285                                 });
286                         });
287                 });
288         }
291 Element.prototype.injectReplyForm = function(editMarkdownSource) {
292         GWLog("injectReplyForm");
293         let commentControls = this;
294         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
295         let withparent = (!editMarkdownSource && commentControls.getCommentId());
296         let answer = commentControls.parentElement.id == "answers";
297         let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
298         let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId())
299         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
300                 "<form method='post'>" + 
301                 "<div class='textarea-container'>" + 
302                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
303                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
304                 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
305                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
306                 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
307                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
308                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
309                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
310                 "</div><div>" + 
311                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
312                 "<input type='submit' value='Submit'>" + 
313                 "</div></form>";
314         commentControls.onsubmit = disableBeforeUnload;
316         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
317                 hideReplyForm(event.target.closest(".comment-controls"));
318         });
319         commentControls.scrollIntoViewIfNeeded();
320         commentControls.query("form").onsubmit = (event) => {
321                 if (!event.target.text.value) {
322                         alert("Please enter a comment.");
323                         return false;
324                 }
325         }
326         let textarea = commentControls.query("textarea");
327         textarea.value = MarkdownFromHTML(editMarkdownSource || "");
328         textarea.addTextareaFeatures();
329         textarea.focus();
332 Element.prototype.updateCommentControlButton = function() {
333         let retractFn = () => {
334                 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
335                         return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
336                 else
337                         return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
338         };
339         let classMap = {
340                 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
341                 "retract-button": retractFn,
342                 "unretract-button": retractFn,
343                 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
344         };
345         classMap.keys().forEach((testClass) => {
346                 if(this.hasClass(testClass)) {
347                         let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
348                         this.className = "";
349                         this.addClasses([ buttonClass, "action-button" ]);
350                         if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
351                         this.dataset.label = buttonLabel;
352                         this.title = buttonAltText;
353                         this.tabIndex = '-1';
354                         return;
355                 }
356         });
359 Element.prototype.constructCommentControls = function() {
360         GWLog("constructCommentControls");
361         let commentControls = this;
362         let commentType = (commentControls.parentElement.id == "answers" ? "answer" : "comment");
363         commentControls.innerHTML = "";
364         let replyButton = document.createElement("button");
365         if (commentControls.parentElement.hasClass("comments")) {
366                 replyButton.className = "new-comment-button action-button";
367                 replyButton.innerHTML = "Post new " + commentType;
368                 replyButton.setAttribute("accesskey", (commentType == "comment" ? "n" : ""));
369                 replyButton.setAttribute("title", "Post new " + commentType + (commentType == "comment" ? " [n]" : ""));
370         } else {
371                 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
372                         let buttonsList = [];
373                         if(!commentControls.parentElement.query(".comment-thread"))
374                                 buttonsList.push("delete-button");
375                         buttonsList.push("retract-button", "edit-button");
376                         buttonsList.forEach(buttonClass => {
377                                 let button = commentControls.appendChild(document.createElement("button"));
378                                 button.addClass(buttonClass);
379                                 button.updateCommentControlButton();
380                         });
381                 }
382                 replyButton.className = "reply-button action-button";
383                 replyButton.innerHTML = "Reply";
384                 replyButton.dataset.label = "Reply";
385         }
386         commentControls.appendChild(replyButton);
387         replyButton.tabIndex = '-1';
389         // On mobile, hide labels for all but the Reply button.
390         if (GW.isMobile && window.innerWidth <= 900) {
391                 commentControls.queryAll(".delete-button, .retract-button, .unretract-button, .edit-button").forEach(button => {
392                         button.innerHTML = "";
393                 });
394         }
396         // Activate buttons.
397         commentControls.queryAll(".action-button").forEach(button => {
398                 button.addActivateEvent(GW.commentActionButtonClicked);
399         });
401         // Replicate karma controls at the bottom of comments.
402         if (commentControls.parentElement.hasClass("comments")) return;
403         let karmaControls = commentControls.parentElement.query(".comment-meta .karma");
404         let karmaControlsCloned = karmaControls.cloneNode(true);
405         commentControls.appendChild(karmaControlsCloned);
406         commentControls.queryAll("button.vote").forEach(voteButton => {
407                 voteButton.addActivateEvent(voteButtonClicked);
408         });
411 GW.commentActionButtonClicked = (event) => {
412         GWLog("commentActionButtonClicked");
413         if (event.target.hasClass("edit-button") ||
414                 event.target.hasClass("reply-button") ||
415                 event.target.hasClass("new-comment-button")) {
416                 queryAll("textarea").forEach(textarea => {
417                         hideReplyForm(textarea.closest(".comment-controls"));
418                 });
419         }
421         if (event.target.hasClass("delete-button")) {
422                 let commentItem = event.target.closest(".comment-item");
423                 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
424                                         "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" + 
425                                         "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" + 
426                                         "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
427                         doCommentAction("delete", commentItem);
428         } else if (event.target.hasClass("retract-button")) {
429                 doCommentAction("retract", event.target.closest(".comment-item"));
430         } else if (event.target.hasClass("unretract-button")) {
431                 doCommentAction("unretract", event.target.closest(".comment-item"));
432         } else if (event.target.hasClass("edit-button")) {
433                 showCommentEditForm(event.target.closest(".comment-item"));
434         } else if (event.target.hasClass("reply-button")) {
435                 showReplyForm(event.target.closest(".comment-item"));
436         } else if (event.target.hasClass("new-comment-button")) {
437                 showReplyForm(event.target.closest(".comments"));
438         }
440         event.target.blur();
443 function showCommentEditForm(commentItem) {
444         GWLog("showCommentEditForm");
446         let commentBody = commentItem.query(".comment-body");
447         commentBody.style.display = "none";
449         let commentControls = commentItem.query(".comment-controls");
450         commentControls.injectReplyForm(commentBody.dataset.markdownSource);
451         commentControls.query("form").addClass("edit-existing-comment");
452         expandTextarea(commentControls.query("textarea"));
455 function showReplyForm(commentItem) {
456         GWLog("showReplyForm");
458         let commentControls = commentItem.query(".comment-controls");
459         commentControls.injectReplyForm(commentControls.dataset.enteredText);
462 function hideReplyForm(commentControls) {
463         GWLog("hideReplyForm");
464         // Are we editing a comment? If so, un-hide the existing comment body.
465         let containingComment = commentControls.closest(".comment-item");
466         if (containingComment) containingComment.query(".comment-body").style.display = "";
468         let enteredText = commentControls.query("textarea").value;
469         if (enteredText) commentControls.dataset.enteredText = enteredText;
471         disableBeforeUnload();
472         commentControls.constructCommentControls();
475 function expandTextarea(textarea) {
476         GWLog("expandTextarea");
477         if (window.innerWidth <= 520) return;
479         let totalBorderHeight = 30;
480         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
482         requestAnimationFrame(() => {
483                 textarea.style.height = 'auto';
484                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
485                 if (textarea.clientHeight < window.innerHeight) {
486                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
487                 }
488         });
491 function doCommentAction(action, commentItem) {
492         GWLog("doCommentAction");
493         let params = {};
494         params[(action + "-comment-id")] = commentItem.getCommentId();
495         doAjax({
496                 method: "POST",
497                 params: params,
498                 onSuccess: (event) => {
499                         let fn = {
500                                 retract: () => { commentItem.firstChild.addClass("retracted") },
501                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
502                                 delete: () => {
503                                         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>";
504                                         commentItem.removeChild(commentItem.query(".comment-controls"));
505                                 }
506                         }[action];
507                         if(fn) fn();
508                         if(action != "delete")
509                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
510                 }
511         });
514 /**********/
515 /* VOTING */
516 /**********/
518 function parseVoteType(voteType) {
519         GWLog("parseVoteType");
520         let value = {};
521         if (!voteType) return value;
522         value.up = /[Uu]pvote$/.test(voteType);
523         value.down = /[Dd]ownvote$/.test(voteType);
524         value.big = /^big/.test(voteType);
525         return value;
528 function makeVoteType(value) {
529         GWLog("makeVoteType");
530         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
533 function makeVoteClass(vote) {
534         GWLog("makeVoteClass");
535         if (vote.up || vote.down) {
536                 return (vote.big ? 'selected big-vote' : 'selected');
537         } else {
538                 return '';
539         }
542 function addVoteButtons(element, voteType, targetType) {
543         GWLog("addVoteButtons");
544         let vote = parseVoteType(voteType);
545         let voteClass = makeVoteClass(vote);
546         element.insertAdjacentHTML('beforebegin', "<button type='button' class='vote upvote"+(vote.up ?' '+voteClass:'')+"' data-vote-type='upvote' data-target-type='"+targetType+"' tabindex='-1'></button>");
547         element.insertAdjacentHTML('afterend', "<button type='button' class='vote downvote"+(vote.down ?' '+voteClass:'')+"' data-vote-type='downvote' data-target-type='"+targetType+"' tabindex='-1'></button>");
550 function makeVoteCompleteEvent(target) {
551         GWLog("makeVoteCompleteEvent");
552         return (event) => {
553                 var buttonTargets, karmaTargets;
554                 if (target === null) {
555                         buttonTargets = queryAll(".post-meta .karma");
556                         karmaTargets = queryAll(".post-meta .karma-value");
557                 } else {
558                         let commentItem = target.closest(".comment-item")
559                         buttonTargets = [ commentItem.query(".comment-meta .karma"), commentItem.query(".comment-controls .karma") ];
560                         karmaTargets = [ commentItem.query(".comment-meta .karma-value"), commentItem.query(".comment-controls .karma-value") ];
561                 }
562                 buttonTargets.forEach(buttonTarget => {
563                         buttonTarget.removeClass("waiting");
564                 });
565                 if (event.target.status == 200) {
566                         let response = JSON.parse(event.target.responseText);
567                         let karmaText = response[0], voteType = response[1];
569                         let vote = parseVoteType(voteType);
570                         let voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
571                         let voteClass = makeVoteClass(vote);
573                         karmaTargets.forEach(karmaTarget => {
574                                 karmaTarget.innerHTML = karmaText;
575                                 if (karmaTarget.hasClass("redacted")) {
576                                         karmaTarget.dataset["trueValue"] = karmaTarget.firstChild.textContent;
577                                         karmaTarget.firstChild.textContent = "##";
578                                 }
579                         });
580                         buttonTargets.forEach(buttonTarget => {
581                                 buttonTarget.queryAll("button.vote").forEach(button => {
582                                         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
583                                         if (button.dataset.voteType == voteUpDown) button.addClass(voteClass);
584                                 });
585                         });
586                 }
587         }
590 function sendVoteRequest(targetId, targetType, voteType, onFinish) {
591         GWLog("sendVoteRequest");
592         let req = new XMLHttpRequest();
593         req.addEventListener("load", onFinish);
594         req.open("POST", "/karma-vote");
595         req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
596         req.send("csrf-token="+encodeURIComponent(GW.csrfToken)+"&target="+encodeURIComponent(targetId)+"&target-type="+encodeURIComponent(targetType)+"&vote-type="+encodeURIComponent(voteType));
599 function voteButtonClicked(event) {
600         let voteButton = event.target;
602         // 500 ms (0.5 s) double-click timeout.
603         let doubleClickTimeout = 500;
605         if (!voteButton.clickedOnce) {
606                 voteButton.clickedOnce = true;
607                 voteButton.addClass("clicked-once");
609                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
610                         if (!voteButton.clickedOnce) return;
612                         // Do single-click code.
613                         voteButton.clickedOnce = false;
614                         voteEvent(voteButton, 1);
615                 }, doubleClickTimeout, voteButton);
616         } else {
617                 voteButton.clickedOnce = false;
619                 // Do double-click code.
620                 voteEvent(voteButton, 2);
621                 voteButton.removeClass("clicked-once");
622                 voteButton.addClass("clicked-twice");
623         }
625 function voteEvent(voteButton, numClicks) {
626         voteButton.blur();
627         voteButton.parentNode.addClass("waiting");
628         let targetType = voteButton.dataset.targetType;
629         let targetId = ((targetType == 'Comments') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
630         let voteUpDown = voteButton.dataset.voteType;
631         let vote = parseVoteType(voteUpDown);
632         vote.big = (numClicks == 2);
633         let voteType = makeVoteType(vote);
634         let oldVoteType;
635         if (targetType == "Posts") {
636                 oldVoteType = postVote;
637                 postVote = ((voteType == oldVoteType) ? null : voteType);
638         } else {
639                 oldVoteType = commentVotes[targetId];
640                 commentVotes[targetId] = ((voteType == oldVoteType) ? null : voteType);
641         }
642         let f = () => { sendVoteRequest(targetId, targetType, voteType, makeVoteCompleteEvent((targetType == 'Comments' ? voteButton.parentNode : null))) };
643         if (oldVoteType && (oldVoteType != voteType)) {
644                 sendVoteRequest(targetId, targetType, oldVoteType, f);
645         } else {
646                 f();
647         }
650 /***********************************/
651 /* COMMENT THREAD MINIMIZE BUTTONS */
652 /***********************************/
654 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
655         GWLog("setCommentThreadMaximized");
656         let commentItem = this;
657         let storageName = "thread-minimized-" + commentItem.getCommentId();
658         let minimize_button = commentItem.query(".comment-minimize-button");
659         let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !localStorage.getItem(storageName));
660         if (userOriginated) {
661                 if (maximize) {
662                         localStorage.removeItem(storageName);
663                 } else {
664                         localStorage.setItem(storageName, true);
665                 }
666         }
668         commentItem.style.height = maximize ? 'auto' : '38px';
669         commentItem.style.overflow = maximize ? 'visible' : 'hidden';
671         minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
672         minimize_button.innerHTML = maximize ? "&#xf146;" : "&#xf0fe;";
673         minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
674         if (getQueryVariable("chrono") != "t") {
675                 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
676         }
679 /*****************************************/
680 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
681 /*****************************************/
683 Element.prototype.getCommentDate = function() {
684         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
685         return (item ? parseInt(item.query(".date").dataset["jsDate"]) : false);
687 function getCurrentVisibleComment() {
688         let px = window.innerWidth/2, py = 5;
689         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
690         let atbottom = query("#comments").getBoundingClientRect().bottom < window.innerHeight;
691         if (atbottom) {
692                 let hashci = location.hash && query(location.hash);
693                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
694                         commentItem = hashci;
695                 }
696         }
697         return commentItem;
700 function highlightCommentsSince(date) {
701         GWLog("highlightCommentsSince");
702         var newCommentsCount = 0;
703         GW.newComments = [ ];
704         let oldCommentsStack = [ ];
705         let prevNewComment;
706         queryAll(".comment-item").forEach(commentItem => {
707                 commentItem.prevNewComment = prevNewComment;
708                 if (commentItem.getCommentDate() > date) {
709                         commentItem.addClass("new-comment");
710                         newCommentsCount++;
711                         GW.newComments.push(commentItem.getCommentId());
712                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
713                         oldCommentsStack = [ commentItem ];
714                         prevNewComment = commentItem;
715                 } else {
716                         commentItem.removeClass("new-comment");
717                         oldCommentsStack.push(commentItem);
718                 }
719         });
721         GW.newCommentScrollSet = (commentItem) => {
722                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
723                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
724         };
725         GW.newCommentScrollListener = () => {
726                 let commentItem = getCurrentVisibleComment();
727                 GW.newCommentScrollSet(commentItem);
728         }
730         addScrollListener(GW.newCommentScrollListener);
732         if (document.readyState=="complete") {
733                 GW.newCommentScrollListener();
734         } else {
735                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
736                 GW.newCommentScrollSet(commentItem);
737         }
739         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
741         return newCommentsCount;
744 function scrollToNewComment(next) {
745         GWLog("scrollToNewComment");
746         let commentItem = getCurrentVisibleComment();
747         let targetComment = null;
748         let targetCommentID = null;
749         if (commentItem) {
750                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
751                 if (targetComment) {
752                         targetCommentID = targetComment.getCommentId();
753                 }
754         } else {
755                 if (GW.newComments[0]) {
756                         targetCommentID = GW.newComments[0];
757                         targetComment = query("#comment-" + targetCommentID);
758                 }
759         }
760         if (targetComment) {
761                 expandAncestorsOf(targetCommentID);
762                 history.replaceState(null, null, "#comment-" + targetCommentID);
763                 targetComment.scrollIntoView();
764         }
766         GW.newCommentScrollListener();
769 function getPostHash() {
770         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
771         return (postHash ? postHash[1] : false);
773 function getLastVisitedDate() {
774         // Get the last visited date (or, if posting a comment, the previous last visited date).
775         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
776         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
777         return localStorage.getItem(storageName);
779 function setLastVisitedDate(date) {
780         // If NOT posting a comment, save the previous value for the last-visited-date 
781         // (to recover it in case of posting a comment).
782         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
783         if (!aCommentHasJustBeenPosted) {
784                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
785                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
786         }
788         // Set the new value.
789         localStorage.setItem("last-visited-date_" + getPostHash(), date);
792 function updateSavedCommentCount() {
793         let commentCount = queryAll(".comment").length;
794         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
796 function badgePostsWithNewComments() {
797         if (getQueryVariable("show") == "conversations") return;
799         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
800                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
802                 let savedCommentCount = localStorage.getItem("comment-count_" + postHash);
803                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
804                 let currentCommentCount = /([0-9]+)/.exec(commentCountDisplay.textContent)[1];
806                 if (currentCommentCount > savedCommentCount)
807                         commentCountDisplay.addClass("new-comments");
808                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
809         });
812 /***********************************/
813 /* CONTENT COLUMN WIDTH ADJUSTMENT */
814 /***********************************/
816 function injectContentWidthSelector() {
817         GWLog("injectContentWidthSelector");
818         // Get saved width setting (or default).
819         let currentWidth = localStorage.getItem("selected-width") || 'normal';
821         // Inject the content width selector widget and activate buttons.
822         let widthSelector = addUIElement(
823                 "<div id='width-selector'>" +
824                 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
825                         let [name, desc, abbr] = widthOption;
826                         let selected = (name == currentWidth ? ' selected' : '');
827                         let disabled = (name == currentWidth ? ' disabled' : '');
828                         return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
829                 "</div>");
830         widthSelector.queryAll("button").forEach(button => {
831                 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
832                         // Determine which setting was chosen (i.e., which button was clicked).
833                         let selectedWidth = event.target.dataset.name;
835                         // Save the new setting.
836                         if (selectedWidth == "normal") localStorage.removeItem("selected-width");
837                         else localStorage.setItem("selected-width", selectedWidth);
839                         // Actually change the content width.
840                         setContentWidth(selectedWidth);
841                         event.target.parentElement.childNodes.forEach(button => {
842                                 button.removeClass("selected");
843                                 button.disabled = false;
844                         });
845                         event.target.addClass("selected");
846                         event.target.disabled = true;
848                         // Make sure the accesskey (to cycle to the next width) is on the right button.
849                         setWidthAdjustButtonsAccesskey();
851                         // Regenerate images overlay.
852                         generateImagesOverlay();
854                         // Realign hash.
855                         realignHash();
856                 });
857         });
859         // Make sure the accesskey (to cycle to the next width) is on the right button.
860         setWidthAdjustButtonsAccesskey();
862         // Inject transitions CSS, if animating changes is enabled.
863         if (GW.adjustmentTransitions) {
864                 query("head").insertAdjacentHTML("beforeend", 
865                         "<style id='width-transition'>" + 
866                         `#content,
867                         #ui-elements-container,
868                         #images-overlay {
869                                 transition:
870                                         max-width 0.3s ease;
871                         }` + 
872                         "</style>");
873         }
875 function setWidthAdjustButtonsAccesskey() {
876         GWLog("setWidthAdjustButtonsAccesskey");
877         let widthSelector = query("#width-selector");
878         widthSelector.queryAll("button").forEach(button => {
879                 button.removeAttribute("accesskey");
880                 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
881         });
882         let selectedButton = widthSelector.query("button.selected");
883         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
884         nextButtonInCycle.accessKey = "'";
885         nextButtonInCycle.title += ` [\']`;
888 /*******************/
889 /* THEME SELECTION */
890 /*******************/
892 function injectThemeSelector() {
893         GWLog("injectThemeSelector");
894         let currentTheme = readCookie("theme") || "default";
895         let themeSelector = addUIElement(
896                 "<div id='theme-selector' class='theme-selector'>" +
897                 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
898                         let [name, desc, letter] = themeOption;
899                         let selected = (name == currentTheme ? ' selected' : '');
900                         let disabled = (name == currentTheme ? ' disabled' : '');
901                         let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
902                         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>`;})) +
903                 "</div>");
904         themeSelector.queryAll("button").forEach(button => {
905                 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
906                         let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
907                         setSelectedTheme(themeName);
908                         if (GW.isMobile) toggleAppearanceAdjustUI();
909                 });
910         });
912         // Inject transitions CSS, if animating changes is enabled.
913         if (GW.adjustmentTransitions) {
914                 query("head").insertAdjacentHTML("beforeend", 
915                         "<style id='theme-fade-transition'>" + 
916                         `body {
917                                 transition:
918                                         opacity 0.5s ease-out,
919                                         background-color 0.3s ease-out;
920                         }
921                         body.transparent {
922                                 background-color: #777;
923                                 opacity: 0.0;
924                                 transition:
925                                         opacity 0.5s ease-in,
926                                         background-color 0.3s ease-in;
927                         }` + 
928                         "</style>");
929         }
931 function setSelectedTheme(themeName) {
932         GWLog("setSelectedTheme");
933         queryAll(".theme-selector button").forEach(button => {
934                 button.removeClass("selected");
935                 button.disabled = false;
936         });
937         queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
938                 button.addClass("selected");
939                 button.disabled = true;
940         });
941         setTheme(themeName);
942         query("#theme-tweaker-ui .current-theme span").innerText = themeName;
944 function setTheme(newThemeName) {
945         var themeUnloadCallback = '';
946         var oldThemeName = '';
947         if (typeof(newThemeName) == 'undefined') {
948                 newThemeName = readCookie('theme');
949                 if (!newThemeName) return;
950         } else {
951                 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
952                 oldThemeName = readCookie('theme') || 'default';
954                 if (newThemeName == 'default') setCookie('theme', '');
955                 else setCookie('theme', newThemeName);
956         }
957         if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
959         let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
960         let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
962         let newStyle = document.createElement('link');
963         newStyle.setAttribute('rel', 'stylesheet');
964         newStyle.setAttribute('href', '/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
966         let oldStyle = query("head link[href*='.css']");
967         newStyle.addEventListener('load', () => { removeElement(oldStyle); });
968         newStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
970         if (GW.adjustmentTransitions) {
971                 pageFadeTransition(false);
972                 setTimeout(() => {
973                         query('head').insertBefore(newStyle, oldStyle.nextSibling);
974                 }, 500);
975         } else {
976                 query('head').insertBefore(newStyle, oldStyle.nextSibling);
977         }
979 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
980         recomputeUIElementsContainerHeight(true);
982         let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
983         if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
985         recomputeUIElementsContainerHeight();
986         adjustUIForWindowSize();
987         window.addEventListener('resize', GW.windowResized = (event) => {
988                 adjustUIForWindowSize();
989                 recomputeUIElementsContainerHeight();
990         });
992         generateImagesOverlay();
994         if (window.adjustmentTransitions) pageFadeTransition(true);
995         updateThemeTweakerSampleText();
997         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
998                 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
999         }
1001         setTimeout(realignHash, 0);
1004 function pageFadeTransition(fadeIn) {
1005         if (fadeIn) {
1006                 query("body").removeClass("transparent");
1007         } else {
1008                 query("body").addClass("transparent");
1009         }
1012 GW.themeLoadCallback_less = (fromTheme = "") => {
1013         GWLog("themeLoadCallback_less");
1014         injectSiteNavUIToggle();
1015         if (!GW.isMobile) {
1016                 injectPostNavUIToggle();
1017                 injectAppearanceAdjustUIToggle();
1018         }
1020         registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1021                 let dtf = new Intl.DateTimeFormat([], 
1022                         (window.innerWidth < 1100) ? 
1023                                 { month: 'short', day: 'numeric', year: 'numeric' } : 
1024                                         { month: 'long', day: 'numeric', year: 'numeric' });
1025                 let postDate = query(".top-post-meta .date");
1026                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1027         });
1029         if (GW.isMobile) {
1030                 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1031         }
1033         if (!GW.isMobile) {
1034                 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1035                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1036                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1037                         });
1038                 });
1040                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1041                         // If state is not set (user has never clicked on the Less theme's appearance
1042                         // adjustment UI toggle) then show it, but then hide it after a short time.
1043                         registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1044                                 toggleAppearanceAdjustUI();
1045                                 setTimeout(toggleAppearanceAdjustUI, 3000);
1046                         });
1047                 }
1049                 if (fromTheme != "") {
1050                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1051                         setTimeout(function () {
1052                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1053                         }, 300);
1054                         setTimeout(function () {
1055                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1056                         }, 1800);
1057                 }
1059                 // Unset the height of the #ui-elements-container.
1060                 query("#ui-elements-container").style.height = "";
1062                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1063                 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1064                         "#content #secondary-bar",
1065                         "#content .post .top-post-meta .date",
1066                         "#content .post .top-post-meta .comment-count",
1067                 ];
1068                 applyFilters(GW.currentFilters);
1069         }
1071         // We pre-query the relevant elements, so we don't have to run querySelectorAll
1072         // on every firing of the scroll listener.
1073         GW.scrollState = {
1074                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1075                 "unbrokenDownScrollDistance":           0,
1076                 "unbrokenUpScrollDistance":                     0,
1077                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1078                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1079                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1080         };
1081         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1084 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
1085 // otherwise, show it.
1086 function updatePostNavUIVisibility() {
1087         GWLog("updatePostNavUIVisibility");
1088         var hidePostNavUIToggle = true;
1089         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1090                 if (getComputedStyle(element).visibility == "visible" ||
1091                         element.style.visibility == "visible" ||
1092                         element.style.visibility == "unset")
1093                         hidePostNavUIToggle = false;
1094         });
1095         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1096                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1097         });
1100 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1101 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
1102 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1103 function updateSiteNavUIState(event) {
1104         GWLog("updateSiteNavUIState");
1105         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1106         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
1107                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
1108                                                                                                                 0;
1109         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1110                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1111                                                                                                          0;
1112         GW.scrollState.lastScrollTop = newScrollTop;
1114         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1115         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1116                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1117                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1118         }
1120         // On mobile, make site nav UI translucent on ANY scroll down.
1121         if (GW.isMobile)
1122                 GW.scrollState.siteNavUIElements.forEach(element => {
1123                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1124                         else element.removeClass("translucent-on-scroll");
1125                 });
1127         // Show site nav UI when scrolling a full page up, or to the top.
1128         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
1129                  GW.scrollState.lastScrollTop == 0) &&
1130                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
1131                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1133         // On desktop, show appearance adjust UI when scrolling to the top.
1134         if ((!GW.isMobile) && 
1135                 (GW.scrollState.lastScrollTop == 0) &&
1136                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
1137                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1140 GW.themeUnloadCallback_less = (toTheme = "") => {
1141         GWLog("themeUnloadCallback_less");
1142         removeSiteNavUIToggle();
1143         if (!GW.isMobile) {
1144                 removePostNavUIToggle();
1145                 removeAppearanceAdjustUIToggle();
1146         }
1147         window.removeEventListener('resize', updatePostNavUIVisibility);
1149         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1151         removeElement("#theme-less-mobile-first-row-placeholder");
1153         if (!GW.isMobile) {
1154                 // Remove spans
1155                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1156                         element.innerHTML = element.firstChild.innerHTML;
1157                 });
1158         }
1160         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1162         // Reset filtered elements selector to default.
1163         delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1164         applyFilters(GW.currentFilters);
1167 GW.themeLoadCallback_dark = (fromTheme = "") => {
1168         GWLog("themeLoadCallback_dark");
1169         query("head").insertAdjacentHTML("beforeend", 
1170                 "<style id='dark-theme-adjustments'>" + 
1171                 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` + 
1172                 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1173                 "</style>");
1174         registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1175                 queryAll("#images-overlay img").forEach(image => {
1176                         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)";
1177                         image.style.width = parseInt(image.style.width) + 12 + "px";
1178                         image.style.height = parseInt(image.style.height) + 12 + "px";
1179                         image.style.top = parseInt(image.style.top) - 6 + "px";
1180                         image.style.left = parseInt(image.style.left) - 6 + "px";
1181                 });
1182         });
1184 GW.themeUnloadCallback_dark = (toTheme = "") => {
1185         GWLog("themeUnloadCallback_dark");
1186         removeElement("#dark-theme-adjustments");
1189 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1190         GWLog("themeLoadCallback_brutalist");
1191         let bottomBarLinks = queryAll("#bottom-bar a");
1192         if (!GW.isMobile && bottomBarLinks.length == 5) {
1193                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1194                 bottomBarLinks.forEach((link, i) => {
1195                         link.dataset.originalText = link.textContent;
1196                         link.textContent = newLinkTexts[i];
1197                 });
1198         }
1200 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1201         GWLog("themeUnloadCallback_brutalist");
1202         let bottomBarLinks = queryAll("#bottom-bar a");
1203         if (!GW.isMobile && bottomBarLinks.length == 5) {
1204                 bottomBarLinks.forEach(link => {
1205                         link.textContent = link.dataset.originalText;
1206                 });
1207         }
1210 GW.themeLoadCallback_classic = (fromTheme = "") => {
1211         GWLog("themeLoadCallback_classic");
1212         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1213                 button.innerHTML = "";
1214         });
1216 GW.themeUnloadCallback_classic = (toTheme = "") => {
1217         GWLog("themeUnloadCallback_classic");
1218         if (GW.isMobile && window.innerWidth <= 900) return;
1219         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1220                 button.innerHTML = button.dataset.label;
1221         });
1224 /********************************************/
1225 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1226 /********************************************/
1228 function injectThemeTweaker() {
1229         GWLog("injectThemeTweaker");
1230         let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" + 
1231         `<div class='main-theme-tweaker-window'>
1232                 <h1>Customize appearance</h1>
1233                 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1234                 <button type='button' class='help-button' tabindex='-1'></button>
1235                 <p class='current-theme'>Current theme: <span>` + 
1236                 (readCookie("theme") || "default") + 
1237                 `</span></p>
1238                 <p class='theme-selector'></p>
1239                 <div class='controls-container'>
1240                         <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1241                                 <div class='sample-text-container'><span class='sample-text'>
1242                                         <p>Less Wrong (text)</p>
1243                                         <p><a href="#">Less Wrong (link)</a></p>
1244                                 </span></div>
1245                         </div>
1246                         <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1247                                 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1248                                 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1249                                 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1250                         </div>
1251                         <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1252                                 <input type='checkbox' id='theme-tweak-control-invert'></input>
1253                                 <label for='theme-tweak-control-invert'>Invert colors</label>
1254                         </div>
1255                         <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1256                                 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1257                                 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1258                                 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1259                         </div>
1260                         <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1261                                 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1262                                 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1263                                 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1264                         </div>
1265                         <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1266                                 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1267                                 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1268                                 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1269                         </div>
1270                         <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1271                                 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1272                                 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1273                                 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1274                         </div>
1275                 </div>
1276                 <div class='buttons-container'>
1277                         <button type="button" class="reset-defaults-button">Reset to defaults</button>
1278                         <button type='button' class='ok-button default-button'>OK</button>
1279                         <button type='button' class='cancel-button'>Cancel</button>
1280                 </div>
1281         </div>
1282         <div class="clippy-container">
1283                 <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>)
1284                 <div class='clippy'></div>
1285                 <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>
1286         </div>
1287         <div class='help-window' style='display: none;'>
1288                 <h1>Theme tweaker help</h1>
1289                 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1290                         <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1291                         <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1292                 </div>
1293                 <div class='buttons-container'>
1294                         <button type='button' class='ok-button default-button'>OK</button>
1295                         <button type='button' class='cancel-button'>Cancel</button>
1296                 </div>
1297         </div>
1298         ` + "</div>");
1300         // Clicking the background overlay closes the theme tweaker.
1301         themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1302                 if (event.type == 'mousedown') {
1303                         themeTweakerUI.style.opacity = "0.01";
1304                 } else {
1305                         toggleThemeTweakerUI();
1306                         themeTweakerUI.style.opacity = "1.0";
1307                         themeTweakReset();
1308                 }
1309         }, true);
1311         // Intercept clicks, so they don't "fall through" the background overlay.
1312         (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1314         let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1315         themeTweakerUI.queryAll("input").forEach(field => {
1316                 // All input types in the theme tweaker receive a 'change' event when
1317                 // their value is changed. (Range inputs, in particular, receive this 
1318                 // event when the user lets go of the handle.) This means we should
1319                 // update the filters for the entire page, to match the new setting.
1320                 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1321                         if (event.target.id == 'theme-tweak-control-invert') {
1322                                 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1323                         } else if (event.target.type == 'range') {
1324                                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1325                                 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1326                                 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1327                         } else if (event.target.id == 'theme-tweak-control-clippy') {
1328                                 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1329                         }
1330                         // Clear the sample text filters.
1331                         sampleTextContainer.style.filter = "";
1332                         // Apply the new filters globally.
1333                         applyFilters(GW.currentFilters);
1334                 });
1336                 // Range inputs receive an 'input' event while being scrubbed, updating
1337                 // "live" as the handle is moved. We don't want to change the filters 
1338                 // for the actual page while this is happening, but we do want to change
1339                 // the filters for the *sample text*, so the user can see what effects
1340                 // his changes are having, live, without having to let go of the handle.
1341                 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1342                         var sampleTextFilters = GW.currentFilters;
1344                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1345                         query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1346                         sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1348                         sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1349                 });
1350         });
1352         themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1353                 let themeTweakerStyle = query("#theme-tweaker-style");
1355                 if (event.target.hasClass("minimize")) {
1356                         event.target.removeClass("minimize");
1357                         themeTweakerStyle.innerHTML = 
1358                                 `#theme-tweaker-ui .main-theme-tweaker-window {
1359                                         width: 320px;
1360                                         height: 31px;
1361                                         overflow: hidden;
1362                                         padding: 30px 0 0 0;
1363                                         top: 20px;
1364                                         right: 20px;
1365                                         left: auto;
1366                                 }
1367                                 #theme-tweaker-ui::after {
1368                                         top: 27px;
1369                                         right: 27px;
1370                                 }
1371                                 #theme-tweaker-ui::before {
1372                                         opacity: 0.0;
1373                                         height: 0;
1374                                 }
1375                                 #theme-tweaker-ui .clippy-container {
1376                                         opacity: 1.0;
1377                                 }
1378                                 #theme-tweaker-ui .clippy-container .hint span {
1379                                         color: #c00;
1380                                 }
1381                                 #theme-tweaker-ui {
1382                                         height: 0;
1383                                 }
1384                                 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1385                                         pointer-events: none;
1386                                 }`;
1387                         event.target.addClass("maximize");
1388                 } else {
1389                         event.target.removeClass("maximize");
1390                         themeTweakerStyle.innerHTML = 
1391                                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1392                                         pointer-events: none;
1393                                 }`;
1394                         event.target.addClass("minimize");
1395                 }
1396         });
1397         themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1398                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1399                 toggleThemeTweakerHelpWindow();
1400         });
1401         themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1402                 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1403                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1404                         let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1405                         slider.value = slider.dataset['defaultValue'];
1406                         themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1407                 });
1408                 GW.currentFilters = { };
1409                 applyFilters(GW.currentFilters);
1411                 GW.currentTextZoom = "1.0";
1412                 setTextZoom(GW.currentTextZoom);
1414                 setSelectedTheme("default");
1415         });
1416         themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1417                 toggleThemeTweakerUI();
1418                 themeTweakReset();
1419         });
1420         themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1421                 toggleThemeTweakerUI();
1422                 themeTweakSave();
1423         });
1424         themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1425                 toggleThemeTweakerHelpWindow();
1426                 themeTweakerResetSettings();
1427         });
1428         themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1429                 toggleThemeTweakerHelpWindow();
1430                 themeTweakerSaveSettings();
1431         });
1433         themeTweakerUI.queryAll(".notch").forEach(notch => {
1434                 notch.addActivateEvent(function (event) {
1435                         let slider = event.target.parentElement.query("input[type='range']");
1436                         slider.value = slider.dataset['defaultValue'];
1437                         event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1438                         GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1439                         applyFilters(GW.currentFilters);
1440                 });
1441         });
1443         themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1444                 themeTweakerUI.query(".clippy-container").style.display = "none";
1445                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1446                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1447         });
1449         query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1451         themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1452         themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1453                 button.addActivateEvent(GW.themeSelectButtonClicked);
1454         });
1456         themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1457                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1458         });
1460         let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'>&#xf1de;</button></div>`);
1461         themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1462                 GW.themeTweakerStyleSheetAvailable = () => {
1463                         themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1465                         themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1466                         [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1467                                 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1468                                 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1469                                 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1470                         });
1472                         toggleThemeTweakerUI();
1473                         event.target.disabled = true;
1474                 };
1476                 if (query("link[href^='/theme_tweaker.css']")) {
1477                         GW.themeTweakerStyleSheetAvailable();
1478                 } else {
1479                         // Load the theme tweaker CSS (if not loaded).
1480                         let themeTweakerStyleSheet = document.createElement('link');
1481                         themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1482                         themeTweakerStyleSheet.setAttribute('href', '/theme_tweaker.css');
1483                         themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1484                         query("head").appendChild(themeTweakerStyleSheet);
1485                 }
1486         });
1488 function toggleThemeTweakerUI() {
1489         GWLog("toggleThemeTweakerUI");
1490         let themeTweakerUI = query("#theme-tweaker-ui");
1491         themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1492         query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" : 
1493                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1494                         pointer-events: none;
1495                 }`;
1496         if (themeTweakerUI.style.display != "none") {
1497                 // Save selected theme.
1498                 GW.currentTheme = (readCookie("theme") || "default");
1499                 // Focus invert checkbox.
1500                 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1501                 // Show sample text in appropriate font.
1502                 updateThemeTweakerSampleText();
1503                 // Disable tab-selection of the search box.
1504                 setSearchBoxTabSelectable(false);
1505         } else {
1506                 query("#theme-tweaker-toggle button").disabled = false;
1507                 // Re-enable tab-selection of the search box.
1508                 setSearchBoxTabSelectable(true);
1509         }
1510         // Set theme tweaker assistant visibility.
1511         query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1513 function setSearchBoxTabSelectable(selectable) {
1514         GWLog("setSearchBoxTabSelectable");
1515         query("input[type='search']").tabIndex = selectable ? "" : "-1";
1516         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1518 function toggleThemeTweakerHelpWindow() {
1519         GWLog("toggleThemeTweakerHelpWindow");
1520         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1521         themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1522         if (themeTweakerHelpWindow.style.display != "none") {
1523                 // Focus theme tweaker assistant checkbox.
1524                 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1525                 // Disable interaction on main theme tweaker window.
1526                 query("#theme-tweaker-ui").style.pointerEvents = "none";
1527                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1528         } else {
1529                 // Re-enable interaction on main theme tweaker window.
1530                 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1531                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1532         }
1534 function themeTweakReset() {
1535         GWLog("themeTweakReset");
1536         setSelectedTheme(GW.currentTheme);
1537         GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1538         applyFilters(GW.currentFilters);
1539         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1540         setTextZoom(GW.currentTextZoom);
1542 function themeTweakSave() {
1543         GWLog("themeTweakSave");
1544         GW.currentTheme = (readCookie("theme") || "default");
1545         localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1546         localStorage.setItem("text-zoom", GW.currentTextZoom);
1549 function themeTweakerResetSettings() {
1550         GWLog("themeTweakerResetSettings");
1551         query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1552         query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1554 function themeTweakerSaveSettings() {
1555         GWLog("themeTweakerSaveSettings");
1556         localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1558 function updateThemeTweakerSampleText() {
1559         GWLog("updateThemeTweakerSampleText");
1560         let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1562         // This causes the sample text to take on the properties of the body text of a post.
1563         sampleText.removeClass("post-body");
1564         let bodyTextElement = query(".post-body") || query(".comment-body");
1565         sampleText.addClass("post-body");
1566         sampleText.style.color = bodyTextElement ? 
1567                                                                 getComputedStyle(bodyTextElement).color : 
1568                                                                         getComputedStyle(query("#content")).color;
1570         // Here we find out what is the actual background color that will be visible behind
1571         // the body text of posts, and set the sample text’s background to that.
1572         var backgroundElement = query("#content");
1573         let searchField = query("#nav-item-search input");
1574         if (!(getComputedStyle(searchField).backgroundColor == "" || 
1575                   getComputedStyle(searchField).backgroundColor == "rgba(0, 0, 0, 0)"))
1576                 backgroundElement = searchField;
1577         else while (getComputedStyle(backgroundElement).backgroundColor == "" || 
1578                                 getComputedStyle(backgroundElement).backgroundColor == "rgba(0, 0, 0, 0)")
1579                                 backgroundElement = backgroundElement.parentElement;
1580         sampleText.parentElement.style.backgroundColor = getComputedStyle(backgroundElement).backgroundColor;
1583 /*********************/
1584 /* PAGE QUICK-NAV UI */
1585 /*********************/
1587 function injectQuickNavUI() {
1588         GWLog("injectQuickNavUI");
1589         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1590         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
1591         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
1592         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
1593         ` + "</div>");
1596 /**********************/
1597 /* NEW COMMENT NAV UI */
1598 /**********************/
1600 function injectNewCommentNavUI(newCommentsCount) {
1601         GWLog("injectNewCommentNavUI");
1602         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
1603         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
1604         <span class='new-comments-count'></span>
1605         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
1606         + "</div>");
1608         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1609                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1610                         scrollToNewComment(/next/.test(event.target.className));
1611                         event.target.blur();
1612                 });
1613         });
1615         document.addEventListener("keyup", (event) => { 
1616                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1617                 if (event.key == ",") scrollToNewComment(false);
1618                 if (event.key == ".") scrollToNewComment(true)
1619         });
1621         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1622         + `<span>Since:</span>`
1623         + `<input type='text' class='hns-date'></input>`
1624         + "</div>");
1626         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1627                 let hnsDate = time_fromHuman(event.target.value);
1628                 let newCommentsCount = highlightCommentsSince(hnsDate);
1629                 updateNewCommentNavUI(newCommentsCount);
1630         }, false);
1632         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1633                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1634                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1635         });
1638 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1639 function time_fromHuman(string) {
1640         /* Convert a human-readable date into a JS timestamp */
1641         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1642                 string = string.replace(' ', 'T');  // revert nice spacing
1643                 string += ':00.000Z';  // complete ISO 8601 date
1644                 time = Date.parse(string);  // milliseconds since epoch
1646                 // browsers handle ISO 8601 without explicit timezone differently
1647                 // thus, we have to fix that by hand
1648                 time += (new Date()).getTimezoneOffset() * 60e3;
1649         } else {
1650                 string = string.replace(' at', '');
1651                 time = Date.parse(string);  // milliseconds since epoch
1652         }
1653         return time;
1656 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1657         GWLog("updateNewCommentNavUI");
1658         // Update the new comments count.
1659         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1660         newCommentsCountLabel.innerText = newCommentsCount;
1661         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1663         // Update the date picker field.
1664         if (hnsDate != -1) {
1665                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1666         }
1669 /***************************/
1670 /* TEXT SIZE ADJUSTMENT UI */
1671 /***************************/
1673 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1674         GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1675         var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1676         if (event.target.hasClass("decrease")) {
1677                 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1678         } else if (event.target.hasClass("increase")) {
1679                 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1680         } else {
1681                 zoomFactor = 1.0;
1682         }
1683         setTextZoom(zoomFactor);
1684         GW.currentTextZoom = `${zoomFactor}`;
1686         if (event.target.parentElement.id == "text-size-adjustment-ui") {
1687                 localStorage.setItem("text-zoom", GW.currentTextZoom);
1688         }
1691 function injectTextSizeAdjustmentUIReal() {
1692         GWLog("injectTextSizeAdjustmentUIReal");
1693         let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1694         + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
1695         + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1696         + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
1697         + "</div>");
1699         textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1700                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1701         });
1703         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1706 function injectTextSizeAdjustmentUI() {
1707         GWLog("injectTextSizeAdjustmentUI");
1708         if (query("#text-size-adjustment-ui") != null) return;
1709         if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1710         else document.addEventListener("DOMContentLoaded", () => {
1711                 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1712         }, {once: true});
1715 /********************************/
1716 /* COMMENTS VIEW MODE SELECTION */
1717 /********************************/
1719 function injectCommentsViewModeSelector() {
1720         GWLog("injectCommentsViewModeSelector");
1721         let commentsContainer = query("#comments");
1722         if (commentsContainer == null) return;
1724         let currentModeThreaded = (location.href.search("chrono=t") == -1);
1725         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1727         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1728         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
1729         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
1730         + "</div>");
1732 //      commentsViewModeSelector.queryAll("a").forEach(button => {
1733 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1734 //      });
1736         if (!currentModeThreaded) {
1737                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1738                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1739                         commentParentLink.addClass("inline-author");
1740                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1741                 });
1743                 queryAll(".comment-child-links a").forEach(commentChildLink => {
1744                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
1745                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
1746                 });
1748                 rectifyChronoModeCommentChildLinks();
1750                 commentsContainer.addClass("chrono");
1751         } else {
1752                 commentsContainer.addClass("threaded");
1753         }
1755         // Remove extraneous top-level comment thread in chrono mode.
1756         let topLevelCommentThread = query("#comments > .comment-thread");
1757         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
1760 // function commentsViewModeSelectorButtonClicked(event) {
1761 //      event.preventDefault();
1762 // 
1763 //      var newDocument;
1764 //      let request = new XMLHttpRequest();
1765 //      request.open("GET", event.target.href);
1766 //      request.onreadystatechange = () => {
1767 //              if (request.readyState != 4) return;
1768 //              newDocument = htmlToElement(request.response);
1769 // 
1770 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
1771 // 
1772 //              // Update the buttons.
1773 //              event.target.addClass("selected");
1774 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
1775 // 
1776 //              // Update the #comments container.
1777 //              let commentsContainer = query("#comments");
1778 //              commentsContainer.removeClass(classes.old);
1779 //              commentsContainer.addClass(classes.new);
1780 // 
1781 //              // Update the content.
1782 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
1783 //      };
1784 //      request.send();
1785 // }
1786 // 
1787 // function htmlToElement(html) {
1788 //     var template = document.createElement('template');
1789 //     template.innerHTML = html.trim();
1790 //     return template.content;
1791 // }
1793 function rectifyChronoModeCommentChildLinks() {
1794         GWLog("rectifyChronoModeCommentChildLinks");
1795         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
1796                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
1797                 let childLinks = commentChildLinksContainer.queryAll("a");
1798                 childLinks.forEach((link, index) => {
1799                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
1800                 });
1802                 // Sort by date.
1803                 let childLinksArray = Array.from(childLinks)
1804                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
1805                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
1806         });
1808 function childrenOfComment(commentID) {
1809         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
1810                 let commentParentLink = commentItem.query("a.comment-parent-link");
1811                 return ((commentParentLink||{}).hash == "#" + commentID);
1812         });
1815 /********************************/
1816 /* COMMENTS LIST MODE SELECTION */
1817 /********************************/
1819 function injectCommentsListModeSelector() {
1820         GWLog("injectCommentsListModeSelector");
1821         if (query("#content > .comment-thread") == null) return;
1823         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
1824         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
1825         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
1826         + "</div>";
1827         (query("#content.user-page .user-stats") || query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
1828         let commentsListModeSelector = query("#comments-list-mode-selector");
1830         commentsListModeSelector.queryAll("button").forEach(button => {
1831                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
1832                         event.target.parentElement.queryAll("button").forEach(button => {
1833                                 button.removeClass("selected");
1834                                 button.disabled = false;
1835                                 button.accessKey = '`';
1836                         });
1837                         localStorage.setItem("comments-list-mode", event.target.className);
1838                         event.target.addClass("selected");
1839                         event.target.disabled = true;
1840                         event.target.removeAttribute("accesskey");
1842                         if (event.target.hasClass("expanded")) {
1843                                 query("#content").removeClass("compact");
1844                         } else {
1845                                 query("#content").addClass("compact");
1846                         }
1847                 });
1848         });
1850         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
1851         if (savedMode == "compact")
1852                 query("#content").addClass("compact");
1853         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
1854         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
1855         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
1857         if (GW.isMobile) {
1858                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
1859                         commentParentLink.addActivateEvent(function (event) {
1860                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
1861                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
1862                         }, false);
1863                 });
1864         }
1867 /**********************/
1868 /* SITE NAV UI TOGGLE */
1869 /**********************/
1871 function injectSiteNavUIToggle() {
1872         GWLog("injectSiteNavUIToggle");
1873         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
1874         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
1875                 toggleSiteNavUI();
1876                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
1877         });
1879         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
1881 function removeSiteNavUIToggle() {
1882         GWLog("removeSiteNavUIToggle");
1883         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
1884                 element.removeClass("engaged");
1885         });
1886         removeElement("#site-nav-ui-toggle");
1888 function toggleSiteNavUI() {
1889         GWLog("toggleSiteNavUI");
1890         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
1891                 element.toggleClass("engaged");
1892                 element.removeClass("translucent-on-scroll");
1893         });
1896 /**********************/
1897 /* POST NAV UI TOGGLE */
1898 /**********************/
1900 function injectPostNavUIToggle() {
1901         GWLog("injectPostNavUIToggle");
1902         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
1903         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
1904                 togglePostNavUI();
1905                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
1906         });
1908         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
1910 function removePostNavUIToggle() {
1911         GWLog("removePostNavUIToggle");
1912         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
1913                 element.removeClass("engaged");
1914         });
1915         removeElement("#post-nav-ui-toggle");
1917 function togglePostNavUI() {
1918         GWLog("togglePostNavUI");
1919         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
1920                 element.toggleClass("engaged");
1921         });
1924 /*******************************/
1925 /* APPEARANCE ADJUST UI TOGGLE */
1926 /*******************************/
1928 function injectAppearanceAdjustUIToggle() {
1929         GWLog("injectAppearanceAdjustUIToggle");
1930         let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'>&#xf013;</button></div>");
1931         appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
1932                 toggleAppearanceAdjustUI();
1933                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
1934         });
1936         if (GW.isMobile) {
1937                 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
1938                 themeSelectorCloseButton.addClass("theme-selector-close-button");
1939                 themeSelectorCloseButton.innerHTML = "&#xf057;";
1940                 query("#theme-selector").appendChild(themeSelectorCloseButton);
1941                 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
1942         } else {
1943                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
1944         }
1946 function removeAppearanceAdjustUIToggle() {
1947         GWLog("removeAppearanceAdjustUIToggle");
1948         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
1949                 element.removeClass("engaged");
1950         });
1951         removeElement("#appearance-adjust-ui-toggle");
1953 function toggleAppearanceAdjustUI() {
1954         GWLog("toggleAppearanceAdjustUI");
1955         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
1956                 element.toggleClass("engaged");
1957         });
1960 /*****************************/
1961 /* MINIMIZED THREAD HANDLING */
1962 /*****************************/
1964 function expandAncestorsOf(comment) {
1965         GWLog("expandAncestorsOf");
1966         if (typeof comment == "string") {
1967                 comment = /(?:comment-)?(.+)/.exec(comment)[1];
1968                 comment = query("#comment-" + comment);
1969         }
1970         if (!comment) {
1971                 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
1972                 return;
1973         }
1975         // Expand collapsed comment threads.
1976         let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
1977         if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
1979         // Expand collapsed comments.
1980         let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
1981         if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
1984 /**************************/
1985 /* WORD COUNT & READ TIME */
1986 /**************************/
1988 function toggleReadTimeOrWordCount(addWordCountClass) {
1989         GWLog("toggleReadTimeOrWordCount");
1990         queryAll(".post-meta .read-time").forEach(element => {
1991                 if (addWordCountClass) element.addClass("word-count");
1992                 else element.removeClass("word-count");
1994                 let titleParts = /(\S+)(.+)$/.exec(element.title);
1995                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
1996         });
1999 /**************************/
2000 /* PROMPT TO SAVE CHANGES */
2001 /**************************/
2003 function enableBeforeUnload() {
2004         window.onbeforeunload = function () { return true; };
2006 function disableBeforeUnload() {
2007         window.onbeforeunload = null;
2010 /***************************/
2011 /* ORIGINAL POSTER BADGING */
2012 /***************************/
2014 function markOriginalPosterComments() {
2015         GWLog("markOriginalPosterComments");
2016         let postAuthor = query(".post .author");
2017         if (postAuthor == null) return;
2019         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2020                 if (author.dataset.userid == postAuthor.dataset.userid ||
2021                         (author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2022                         author.addClass("original-poster");
2023                         author.title += "Original poster";
2024                 }
2025         });
2028 /********************************/
2029 /* EDIT POST PAGE SUBMIT BUTTON */
2030 /********************************/
2032 function setEditPostPageSubmitButtonText() {
2033         GWLog("setEditPostPageSubmitButtonText");
2034         if (!query("#content").hasClass("edit-post-page")) return;
2036         queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2037                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2038                         updateEditPostPageSubmitButtonText();
2039                 });
2040         });
2042         updateEditPostPageSubmitButtonText();
2044 function updateEditPostPageSubmitButtonText() {
2045         GWLog("updateEditPostPageSubmitButtonText");
2046         let submitButton = query("input[type='submit']");
2047         if (query("input#drafts").checked == true) 
2048                 submitButton.value = "Save Draft";
2049         else if (query(".posting-controls").hasClass("edit-existing-post"))
2050                 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2051         else
2052                 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2055 /*****************/
2056 /* ANTI-KIBITZER */
2057 /*****************/
2059 function numToAlpha(n) {
2060         let ret = "";
2061         do {
2062                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2063                 n = Math.floor((n / 26) - 1);
2064         } while (n >= 0);
2065         return ret;
2068 function injectAntiKibitzer() {
2069         GWLog("injectAntiKibitzer");
2070         // Inject anti-kibitzer toggle controls.
2071         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>");
2072         antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2073                 if (query("#anti-kibitzer-toggle").hasClass("engaged") && 
2074                         !event.shiftKey &&
2075                         !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!)")) {
2076                         event.target.blur();
2077                         return;
2078                 }
2080                 toggleAntiKibitzerMode();
2081                 event.target.blur();
2082         });
2084         // Activate anti-kibitzer mode (if needed).
2085         if (localStorage.getItem("antikibitzer") == "true")
2086                 toggleAntiKibitzerMode();
2088         // Remove temporary CSS that hides the authors and karma values.
2089         removeElement("#antikibitzer-temp");
2092 function toggleAntiKibitzerMode() {
2093         GWLog("toggleAntiKibitzerMode");
2094         // This will be the URL of the user's own page, if logged in, or the URL of
2095         // the login page otherwise.
2096         let userTabTarget = query("#nav-item-login .nav-inner").href;
2097         let pageHeadingElement = query("h1.page-main-heading");
2099         let userCount = 0;
2100         let userFakeName = { };
2102         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2104         let postAuthor = query(".post-page .post-meta .author");
2105         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2107         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2108         if (antiKibitzerToggle.hasClass("engaged")) {
2109                 localStorage.setItem("antikibitzer", "false");
2111                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2112                 if (redirectTarget) {
2113                         window.location = redirectTarget;
2114                         return;
2115                 }
2117                 // Individual comment page title and header
2118                 if (query(".individual-thread-page")) {
2119                         let replacer = (node) => {
2120                                 if (!node) return;
2121                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
2122                         }
2123                         replacer(query("title:not(.fake-title)"));
2124                         replacer(query("#content > h1"));
2125                 }
2127                 // Author names/links.
2128                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2129                         author.textContent = author.dataset["trueName"];
2130                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2132                         author.removeClass("redacted");
2133                 });
2134                 // Post/comment karma values.
2135                 queryAll(".karma-value.redacted").forEach(karmaValue => {
2136                         karmaValue.innerHTML = karmaValue.dataset["trueValue"] + karmaValue.lastChild.outerHTML;
2137                         karmaValue.lastChild.textContent = (parseInt(karmaValue.dataset["trueValue"]) == 1) ? " point" : " points";
2139                         karmaValue.removeClass("redacted");
2140                 });
2141                 // Link post domains.
2142                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2143                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2145                         linkPostDomain.removeClass("redacted");
2146                 });
2148                 antiKibitzerToggle.removeClass("engaged");
2149         } else {
2150                 localStorage.setItem("antikibitzer", "true");
2152                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2153                 if (redirectTarget) {
2154                         window.location = redirectTarget;
2155                         return;
2156                 }
2158                 // Individual comment page title and header
2159                 if (query(".individual-thread-page")) {
2160                         let replacer = (node) => {
2161                                 if (!node) return;
2162                                 node.dataset["trueContent"] = node.firstChild.wholeText;
2163                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2164                                 node.firstChild.replaceWith(newText);
2165                         }
2166                         replacer(query("title:not(.fake-title)"));
2167                         replacer(query("#content > h1"));
2168                 }
2170                 removeElement("title.fake-title");
2172                 // Author names/links.
2173                 queryAll(".author, .inline-author").forEach(author => {
2174                         // Skip own posts/comments.
2175                         if (author.hasClass("own-user-author"))
2176                                 return;
2178                         let userid = author.dataset["userid"] || query(`${author.hash} .author`).dataset["userid"];
2180                         author.dataset["trueName"] = author.textContent;
2181                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2183                         if (/\/user/.test(author.href)) {
2184                                 author.dataset["trueLink"] = author.pathname;
2185                                 author.href = "/user?id=" + author.dataset["userid"];
2186                         }
2188                         author.addClass("redacted");
2189                 });
2190                 // Post/comment karma values.
2191                 queryAll(".karma-value").forEach(karmaValue => {
2192                         // Skip own posts/comments.
2193                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2194                                 return;
2196                         karmaValue.dataset["trueValue"] = karmaValue.firstChild.textContent;
2197                         karmaValue.innerHTML = "##" + karmaValue.lastChild.outerHTML;
2198                         karmaValue.lastChild.textContent = " points";
2200                         karmaValue.addClass("redacted");
2201                 });
2202                 // Link post domains.
2203                 queryAll(".link-post-domain").forEach(linkPostDomain => {
2204                         // Skip own posts/comments.
2205                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2206                                 return;
2208                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2209                         linkPostDomain.textContent = "redacted.domain.tld";
2211                         linkPostDomain.addClass("redacted");
2212                 });
2214                 antiKibitzerToggle.addClass("engaged");
2215         }
2218 /*******************************/
2219 /* COMMENT SORT MODE SELECTION */
2220 /*******************************/
2222 var CommentSortMode = Object.freeze({
2223         TOP:            "top",
2224         NEW:            "new",
2225         OLD:            "old",
2226         HOT:            "hot"
2228 function sortComments(mode) {
2229         GWLog("sortComments");
2230         let commentsContainer = query("#comments");
2232         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2233         commentsContainer.addClass("sorting");
2235         GW.commentValues = { };
2236         let clonedCommentsContainer = commentsContainer.cloneNode(true);
2237         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2238                 var comparator;
2239                 switch (mode) {
2240                 case CommentSortMode.NEW:
2241                         comparator = (a,b) => commentDate(b) - commentDate(a);
2242                         break;
2243                 case CommentSortMode.OLD:
2244                         comparator = (a,b) => commentDate(a) - commentDate(b);
2245                         break;
2246                 case CommentSortMode.HOT:
2247                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2248                         break;
2249                 case CommentSortMode.TOP:
2250                 default:
2251                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2252                         break;
2253                 }
2254                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2255         });
2256         removeElement(commentsContainer.lastChild);
2257         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2258         GW.commentValues = { };
2260         if (loggedInUserId) {
2261                 // Re-activate vote buttons.
2262                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2263                         voteButton.addActivateEvent(voteButtonClicked);
2264                 });
2266                 // Re-activate comment action buttons.
2267                 commentsContainer.queryAll(".action-button").forEach(button => {
2268                         button.addActivateEvent(GW.commentActionButtonClicked);
2269                 });
2270         }
2272         // Re-activate comment-minimize buttons.
2273         queryAll(".comment-minimize-button").forEach(button => {
2274                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2275         });
2277         // Re-add comment parent popups.
2278         addCommentParentPopups();
2279         
2280         // Redo new-comments highlighting.
2281         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2283         requestAnimationFrame(() => {
2284                 commentsContainer.removeClass("sorting");
2285                 commentsContainer.addClass("sorted-" + mode);
2286         });
2288 function commentKarmaValue(commentOrSelector) {
2289         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2290         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2292 function commentDate(commentOrSelector) {
2293         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2294         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2296 function commentVoteCount(commentOrSelector) {
2297         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2298         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2301 function injectCommentsSortModeSelector() {
2302         GWLog("injectCommentsSortModeSelector");
2303         let topCommentThread = query("#comments > .comment-thread");
2304         if (topCommentThread == null) return;
2306         // Do not show sort mode selector if there is no branching in comment tree.
2307         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2309         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
2310                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
2311                 "</div>";
2312         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2313         let commentsSortModeSelector = query("#comments-sort-mode-selector");
2315         commentsSortModeSelector.queryAll("button").forEach(button => {
2316                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2317                         event.target.parentElement.queryAll("button").forEach(button => {
2318                                 button.removeClass("selected");
2319                                 button.disabled = false;
2320                         });
2321                         event.target.addClass("selected");
2322                         event.target.disabled = true;
2324                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2325                         setCommentsSortModeSelectButtonsAccesskey();
2326                 });
2327         });
2329         // TODO: Make this actually get the current sort mode (if that's saved).
2330         // TODO: Also change the condition here to properly get chrono/threaded mode,
2331         // when that is properly done with cookies.
2332         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2333         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2334         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2335         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2336         setCommentsSortModeSelectButtonsAccesskey();
2339 function setCommentsSortModeSelectButtonsAccesskey() {
2340         GWLog("setCommentsSortModeSelectButtonsAccesskey");
2341         queryAll("#comments-sort-mode-selector button").forEach(button => {
2342                 button.removeAttribute("accesskey");
2343                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2344         });
2345         let selectedButton = query("#comments-sort-mode-selector button.selected");
2346         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2347         nextButtonInCycle.accessKey = "z";
2348         nextButtonInCycle.title += " [z]";
2351 /*************************/
2352 /* COMMENT PARENT POPUPS */
2353 /*************************/
2355 function addCommentParentPopups() {
2356         GWLog("addCommentParentPopups");
2357         if (!query("#content").hasClass("comment-thread-page")) return;
2359         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2360                 commentParentLink.addEventListener("mouseover", GW.commentParentLinkMouseOver = (event) => {
2361                         let parentID = commentParentLink.getAttribute("href");
2362                         var parent, popup;
2363                         if (!(parent = (query(parentID)||{}).firstChild)) return;
2364                         var highlightClassName;
2365                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2366                                 parentHighlightClassName = "comment-item-highlight-faint";
2367                                 popup = parent.cloneNode(true);
2368                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2369                                 commentParentLink.addEventListener("mouseout", (event) => {
2370                                         removeElement(popup);
2371                                 }, {once: true});
2372                                 commentParentLink.closest(".comments > .comment-thread").appendChild(popup);
2373                         } else {
2374                                 parentHighlightClassName = "comment-item-highlight";
2375                         }
2376                         parent.parentNode.addClass(parentHighlightClassName);
2377                         commentParentLink.addEventListener("mouseout", (event) => {
2378                                 parent.parentNode.removeClass(parentHighlightClassName);
2379                         }, {once: true});
2380                 });
2381         });
2383         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2384         GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2385                 "#content .comments .comment-thread"
2386         ];
2387         applyFilters(GW.currentFilters);
2390 /***************/
2391 /* IMAGE FOCUS */
2392 /***************/
2394 function imageFocusSetup(imagesOverlayOnly = false) {
2395         GWLog("imageFocusSetup");
2396         // Create event listener for clicking on images to focus them.
2397         GW.imageClickedToFocus = (event) => {
2398                 focusImage(event.target);
2400                 if (event.target.closest("#images-overlay")) {
2401                         query("#image-focus-overlay .image-number").textContent = (getIndexOfFocusedImage() + 1);
2403                         // Set timer to hide the image focus UI.
2404                         resetImageFocusHideUITimer(true);
2405                 }
2406         };
2407         // Add the listener to each image in the overlay (i.e., those in the post).
2408         queryAll("#images-overlay img").forEach(image => {
2409                 image.addActivateEvent(GW.imageClickedToFocus);
2410         });
2411         // Accesskey-L starts the slideshow.
2412         (query("#images-overlay img")||{}).accessKey = 'l';
2413         // Count how many images there are in the post, and set the "… of X" label to that.
2414         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll("#images-overlay img").length;
2415         if (imagesOverlayOnly) return;
2416         // Add the listener to all other content images (including those in comments).
2417         queryAll("#content img").forEach(image => {
2418                 image.addActivateEvent(GW.imageClickedToFocus);
2419         });
2421         // Create the image focus overlay.
2422         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
2423         `<div class='help-overlay'>
2424          <p><strong>Arrow keys:</strong> Next/previous image</p>
2425          <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2426          <p><strong>Space bar:</strong> Reset image size & position</p>
2427          <p><strong>Scroll</strong> to zoom in/out</p>
2428          <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2429          </div>` + 
2430         `<div class='image-number'></div>` + 
2431         `<div class='slideshow-buttons'>
2432          <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
2433          <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
2434          </div>` + 
2435         "</div>");
2436         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2438         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2439                 button.addActivateEvent(GW.imageFocusSlideshowButtonClicked = (event) => {
2440                         focusNextImage(event.target.hasClass("next"));
2441                         event.target.blur();
2442                 });
2443         });
2445         // On orientation change, reset the size & position.
2446         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2447                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2448         }
2450         // UI starts out hidden.
2451         hideImageFocusUI();
2454 function focusImage(imageToFocus) {
2455         GWLog("focusImage");
2456         // Clear 'last-focused' class of last focused image.
2457         let lastFocusedImage = query("img.last-focused");
2458         if (lastFocusedImage) {
2459                 lastFocusedImage.removeClass("last-focused");
2460                 lastFocusedImage.removeAttribute("accesskey");
2461         }
2463         // Create the focused version of the image.
2464         imageToFocus.addClass("focused");
2465         let imageFocusOverlay = query("#image-focus-overlay");
2466         let clonedImage = imageToFocus.cloneNode(true);
2467         clonedImage.style = "";
2468         clonedImage.removeAttribute("width");
2469         clonedImage.removeAttribute("height");
2470         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2471         imageFocusOverlay.appendChild(clonedImage);
2472         imageFocusOverlay.addClass("engaged");
2474         // Set image to default size and position.
2475         resetFocusedImagePosition();
2477         // Blur everything else.
2478         queryAll("#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay").forEach(element => {
2479                 element.addClass("blurred");
2480         });
2482         // Add listener to zoom image with scroll wheel.
2483         window.addEventListener("wheel", GW.imageFocusScroll = (event) => {
2484                 event.preventDefault();
2486                 let image = query("#image-focus-overlay img");
2488                 // Remove the filter.
2489                 image.savedFilter = image.style.filter;
2490                 image.style.filter = 'none';
2492                 // Locate point under cursor.
2493                 let imageBoundingBox = image.getBoundingClientRect();
2495                 // Calculate resize factor.
2496                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2497                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2498                                                 1;
2500                 // Resize.
2501                 image.style.width = (event.deltaY < 0 ?
2502                                                         (image.clientWidth * factor) :
2503                                                         (image.clientWidth / factor))
2504                                                         + "px";
2505                 image.style.height = "";
2507                 // Designate zoom origin.
2508                 var zoomOrigin;
2509                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2510                 // the cursor is over the image.
2511                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2512                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2513                                                                 (imageBoundingBox.left <= event.clientX &&
2514                                                                  event.clientX <= imageBoundingBox.right && 
2515                                                                  imageBoundingBox.top <= event.clientY &&
2516                                                                  event.clientY <= imageBoundingBox.bottom);
2517                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
2518                 // zooming IN, zoom from image center.
2519                 let zoomingFromWindowCenter = event.deltaY > 0;
2520                 if (zoomingFromCursor)
2521                         zoomOrigin = { x: event.clientX, 
2522                                                    y: event.clientY };
2523                 else if (zoomingFromWindowCenter)
2524                         zoomOrigin = { x: window.innerWidth / 2, 
2525                                                    y: window.innerHeight / 2 };
2526                 else
2527                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
2528                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
2530                 // Calculate offset from zoom origin.
2531                 let offsetOfImageFromZoomOrigin = {
2532                         x: imageBoundingBox.x - zoomOrigin.x,
2533                         y: imageBoundingBox.y - zoomOrigin.y
2534                 }
2535                 // Calculate delta from centered zoom.
2536                 let deltaFromCenteredZoom = {
2537                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
2538                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
2539                 }
2540                 // Adjust image position appropriately.
2541                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
2542                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
2543                 // Gradually re-center image, if it's smaller than the window.
2544                 if (!imageSizeExceedsWindowBounds) {
2545                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
2546                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
2547                         let windowCenter = { x: window.innerWidth / 2,
2548                                                                  y: window.innerHeight / 2 }
2549                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
2550                                                                                   y: windowCenter.y - imageCenter.y }
2551                         // Divide the offset by 10 because we're nudging the image toward center,
2552                         // not jumping it there.
2553                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
2554                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
2555                 }
2557                 // Put the filter back.
2558                 image.style.filter = image.savedFilter;
2560                 // Set the cursor appropriately.
2561                 setFocusedImageCursor();
2562         });
2563         window.addEventListener("MozMousePixelScroll", GW.imageFocusOldFirefoxCompatibilityScrollEventFired = (event) => {
2564                 event.preventDefault();
2565         });
2567         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
2568         window.addEventListener("mouseup", GW.imageFocusMouseUp = (event) => {
2569                 window.onmousemove = '';
2571                 // We only want to do anything on left-clicks.
2572                 if (event.button != 0) return;
2574                 if (event.target.hasClass("slideshow-button")) {
2575                         resetImageFocusHideUITimer(false);
2576                         return;
2577                 }
2579                 let focusedImage = query("#image-focus-overlay img");
2581                 if (event.target != focusedImage) {
2582                         unfocusImageOverlay();
2583                         return;
2584                 }
2586                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
2587                         // Put the filter back.
2588                         focusedImage.style.filter = focusedImage.savedFilter;
2589                 } else {
2590                         unfocusImageOverlay();
2591                 }
2592         });
2593         window.addEventListener("mousedown", GW.imageFocusMouseDown = (event) => {
2594                 event.preventDefault();
2596                 let focusedImage = query("#image-focus-overlay img");
2597                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
2598                         let mouseCoordX = event.clientX;
2599                         let mouseCoordY = event.clientY;
2601                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
2602                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
2604                         // Save the filter.
2605                         focusedImage.savedFilter = focusedImage.style.filter;
2607                         window.onmousemove = (event) => {
2608                                 // Remove the filter.
2609                                 focusedImage.style.filter = "none";
2610                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
2611                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
2612                         };
2613                         return false;
2614                 }
2615         });
2617         // Double-click unfocuses, always.
2618         window.addEventListener('dblclick', GW.imageFocusDoubleClick = (event) => {
2619                 if (event.target.hasClass("slideshow-button")) return;
2621                 unfocusImageOverlay();
2622         });
2624         // Escape key unfocuses, spacebar resets.
2625         document.addEventListener("keyup", GW.imageFocusKeyUp = (event) => {
2626                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
2627                 if (!allowedKeys.contains(event.key) || 
2628                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
2630                 event.preventDefault();
2632                 switch (event.key) {
2633                 case "Escape": 
2634                 case "Esc":
2635                         unfocusImageOverlay();
2636                         break;
2637                 case " ":
2638                 case "Spacebar":
2639                         resetFocusedImagePosition();
2640                         break;
2641                 case "ArrowDown":
2642                 case "Down":
2643                 case "ArrowRight":
2644                 case "Right":
2645                         if (query("#images-overlay img.focused")) focusNextImage(true);
2646                         break;
2647                 case "ArrowUp":
2648                 case "Up":
2649                 case "ArrowLeft":
2650                 case "Left":
2651                         if (query("#images-overlay img.focused")) focusNextImage(false);
2652                         break;
2653                 }
2654         });
2656         // Prevent spacebar or arrow keys from scrolling page when image focused.
2657         document.addEventListener("keydown", GW.imageFocusKeyDown = (event) => {
2658                 let disabledKeys = [ " ", "Spacebar", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
2659                 if (disabledKeys.contains(event.key))
2660                         event.preventDefault();
2661         });
2663         if (imageToFocus.closest("#images-overlay")) {
2664                 // Set state of next/previous buttons.
2665                 let images = queryAll("#images-overlay img");
2666                 var indexOfFocusedImage = getIndexOfFocusedImage();
2667                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
2668                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
2670                 // Moving mouse unhides image focus UI.
2671                 window.addEventListener("mousemove", GW.imageFocusMouseMoved = (event) => {
2672                         let restartTimer = (event.target.tagName == "IMG" || event.target.id == "image-focus-overlay");
2673                         resetImageFocusHideUITimer(restartTimer);
2674                 });
2676                 // Replace the hash.
2677                 history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
2678         }
2681 function resetFocusedImagePosition() {
2682         GWLog("resetFocusedImagePosition");
2683         let focusedImage = query("#image-focus-overlay img");
2684         if (!focusedImage) return;
2686         // Reset modifications to size.
2687         focusedImage.style.width = "";
2688         focusedImage.style.height = "";
2690         // Make sure that initially, the image fits into the viewport.
2691         let shrinkRatio = 0.975;
2692         focusedImage.style.width = Math.min(focusedImage.clientWidth, window.innerWidth * shrinkRatio) + 'px';
2693         let maxImageHeight = window.innerHeight * shrinkRatio;
2694         if (focusedImage.clientHeight > maxImageHeight) {
2695                 focusedImage.style.height = maxImageHeight + 'px';
2696                 focusedImage.style.width = "";
2697         }
2699         // Remove modifications to position.
2700         focusedImage.style.left = "";
2701         focusedImage.style.top = "";
2703         // Set the cursor appropriately.
2704         setFocusedImageCursor();
2706 function setFocusedImageCursor() {
2707         let focusedImage = query("#image-focus-overlay img");
2708         if (!focusedImage) return;
2709         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
2710                                                                 'move' : '';
2713 function unfocusImageOverlay() {
2714         GWLog("unfocusImageOverlay");
2715         // Set accesskey of currently focused image (if it's in the images overlay).
2716         let currentlyFocusedImage = query("#images-overlay img.focused");
2717         if (currentlyFocusedImage) {
2718                 currentlyFocusedImage.addClass("last-focused");
2719                 currentlyFocusedImage.accessKey = 'l';
2720         }
2722         // Remove focused image and hide overlay.
2723         let imageFocusOverlay = query("#image-focus-overlay");
2724         imageFocusOverlay.removeClass("engaged");
2725         removeElement(imageFocusOverlay.query("img"));
2727         // Un-blur content/etc.
2728         queryAll("#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay").forEach(element => {
2729                 element.removeClass("blurred");
2730         });
2732         // Unset "focused" class of focused image.
2733         queryAll("#content img.focused, #images-overlay img.focused").forEach(image => {
2734                 image.removeClass("focused");
2735         });
2737         // Remove event listeners.
2738         window.removeEventListener("wheel", GW.imageFocusScroll);
2739         window.removeEventListener("MozMousePixelScroll", GW.imageFocusOldFirefoxCompatibilityScrollEventFired);
2740         window.removeEventListener("dblclick", GW.imageFocusDoubleClick);
2741         document.removeEventListener("keyup", GW.imageFocusKeyUp);
2742         document.removeEventListener("keydown", GW.imageFocusKeyDown);
2743         window.removeEventListener("mousemove", GW.imageFocusMouseMoved);
2744         window.removeEventListener("mousedown", GW.imageFocusMouseDown);
2746         // Reset the hash, if needed.
2747         if (location.hash.hasPrefix("#if_slide_"))
2748                 history.replaceState(null, null, "#");
2751 function getIndexOfFocusedImage() {
2752         let images = queryAll("#images-overlay img");
2753         var indexOfFocusedImage = -1;
2754         for (i = 0; i < images.length; i++) {
2755                 if (images[i].hasClass("focused")) {
2756                         indexOfFocusedImage = i;
2757                         break;
2758                 }
2759         }
2760         return indexOfFocusedImage;
2763 function focusNextImage(next = true) {
2764         GWLog("focusNextImage");
2765         let images = queryAll("#images-overlay img");
2766         var indexOfFocusedImage = getIndexOfFocusedImage();
2768         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
2770         // Remove existing image.
2771         removeElement("#image-focus-overlay img");
2772         // Unset "focused" class of just-removed image.
2773         queryAll("#content img.focused, #images-overlay img.focused").forEach(image => {
2774                 image.removeClass("focused");
2775         });
2777         // Create the focused version of the image.
2778         images[indexOfFocusedImage].addClass("focused");
2779         let imageFocusOverlay = query("#image-focus-overlay");
2780         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
2781         clonedImage.style = "";
2782         clonedImage.removeAttribute("width");
2783         clonedImage.removeAttribute("height");
2784         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
2785         imageFocusOverlay.appendChild(clonedImage);
2786         imageFocusOverlay.addClass("engaged");
2787         // Set image to default size and position.
2788         resetFocusedImagePosition();
2789         // Set state of next/previous buttons.
2790         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
2791         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
2792         // Set the image number display.
2793         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
2794         // Replace the hash.
2795         history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
2798 function hideImageFocusUI() {
2799         let imageFocusOverlay = query("#image-focus-overlay");
2800         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number").forEach(element => {
2801                 element.addClass("hidden");
2802         });
2805 function unhideImageFocusUI() {
2806         let imageFocusOverlay = query("#image-focus-overlay");
2807         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number").forEach(element => {
2808                 element.removeClass("hidden");
2809         });
2812 function resetImageFocusHideUITimer(restart) {
2813         if (GW.isMobile) return;
2815         clearTimeout(GW.imageFocusHideUITimer);
2816         unhideImageFocusUI();
2817         if (restart) GW.imageFocusHideUITimer = setTimeout(hideImageFocusUI, 1500);
2820 /*********************/
2821 /* MORE MISC HELPERS */
2822 /*********************/
2824 function getQueryVariable(variable) {
2825         var query = window.location.search.substring(1);
2826         var vars = query.split("&");
2827         for (var i = 0; i < vars.length; i++) {
2828                 var pair = vars[i].split("=");
2829                 if (pair[0] == variable)
2830                         return pair[1];
2831         }
2833         return false;
2836 function addUIElement(element_html) {
2837         var ui_elements_container = query("#ui-elements-container");
2838         if (!ui_elements_container) {
2839                 ui_elements_container = document.createElement("div");
2840                 ui_elements_container.id = "ui-elements-container";
2841                 query("body").appendChild(ui_elements_container);
2842         }
2844         ui_elements_container.insertAdjacentHTML("beforeend", element_html);
2845         return ui_elements_container.lastElementChild;
2848 function removeElement(elementOrSelector, ancestor = document) {
2849         if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
2850         if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
2853 String.prototype.hasPrefix = function (prefix) {
2854         return (this.lastIndexOf(prefix, 0) === 0);
2857 /*******************************/
2858 /* HTML TO MARKDOWN CONVERSION */
2859 /*******************************/
2861 function MarkdownFromHTML(text) {
2862         GWLog("MarkdownFromHTML");
2863         // Wrapper tags, paragraphs, bold, italic, code blocks.
2864         text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
2865                 switch(tag) {
2866                 case "html":
2867                 case "/html":
2868                 case "head":
2869                 case "/head":
2870                 case "body":
2871                 case "/body":
2872                 case "p":
2873                         return "";
2874                 case "/p":
2875                         return "\n";
2876                 case "strong":
2877                 case "/strong":
2878                         return "**";
2879                 case "em":
2880                 case "/em":
2881                         return "*";
2882                 case "code":
2883                 case "/code":
2884                         return "`";
2885                 default:
2886                         return match;
2887                 }
2888         });
2890         // Unordered lists.
2891         text = text.replace(/<ul>((?:.|\n)+?)<\/ul>/g, (match, listItems, offset, string) => {
2892                 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
2893                         return "* " + listItem + "\n";
2894                 });
2895         });
2897         // Headings.
2898         text = text.replace(/<h([1-9])>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
2899                 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
2900         });
2902         // Blockquotes.
2903         text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
2904                 return "> " + quotedText.trim().split("\n").join("\n> ") + "\n";
2905         });
2907         // Links.
2908         text = text.replace(/<a href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
2909                 return `[${text}](${href})`;
2910         }).trim();
2912         // Horizontal rules.
2913         text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
2914                 return "\n---\n";
2915         });
2917         return text;
2920 /******************/
2921 /* INITIALIZATION */
2922 /******************/
2924 registerInitializer('earlyInitialize', true, () => query("#content") != null, function () {
2925         GWLog("INITIALIZER earlyInitialize");
2926         // Check to see whether we're on a mobile device (which we define as a touchscreen)
2927 //      GW.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
2928         GW.isMobile = ('ontouchstart' in document.documentElement);
2930         // Backward compatibility
2931         let storedTheme = localStorage.getItem('selected-theme');
2932         if (storedTheme) {
2933                 setTheme(storedTheme);
2934                 localStorage.removeItem('selected-theme');
2935         }
2937         // Animate width & theme adjustments?
2938         GW.adjustmentTransitions = false;
2940         // Add the content width selector.
2941         injectContentWidthSelector();
2942         // Add the text size adjustment widget.
2943         injectTextSizeAdjustmentUI();
2944         // Add the theme selector.
2945         injectThemeSelector();
2946         // Add the theme tweaker.
2947         injectThemeTweaker();
2948         // Add the quick-nav UI.
2949         injectQuickNavUI();
2951         setTimeout(() => { updateInbox(); }, 0);
2954 registerInitializer('initialize', false, () => document.readyState != 'loading', function () {
2955         GWLog("INITIALIZER initialize");
2956         forceInitializer('earlyInitialize');
2958         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
2959         // "link without nav bars".
2960         if (getQueryVariable("comments") == "false")
2961                 query("#content").addClass("no-comments");
2962         if (getQueryVariable("hide-nav-bars") == "true") {
2963                 query("#content").addClass("no-nav-bars");
2964                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
2965         }
2967         // If the page cannot have comments, remove the accesskey from the #comments
2968         // quick-nav button; and if the page can have comments, but does not, simply 
2969         // disable the #comments quick nav button.
2970         let content = query("#content");
2971         if (content.query("#comments") == null) {
2972                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
2973         } else if (content.query("#comments .comment-thread") == null) {
2974                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
2975         }
2977         // Links to comments generated by LW have a hash that consists of just the 
2978         // comment ID, which can start with a number. Prefix it with "comment-".
2979         if (location.hash.length == 18) {
2980                 location.hash = "#comment-" + location.hash.substring(1);
2981         }
2983         // If the viewport is wide enough to fit the desktop-size content column,
2984         // use a long date format; otherwise, a short one.
2985         let useLongDate = window.innerWidth > 900;
2986         let dtf = new Intl.DateTimeFormat([], 
2987                 ( useLongDate ? 
2988                         { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
2989                                 : { month: 'numeric', day: 'numeric', year: '2-digit', hour: 'numeric', minute: 'numeric' } ));
2990         queryAll(".date").forEach(date => {
2991                 let d = date.dataset.jsDate;
2992                 if (d) { date.innerHTML = dtf.format(new Date(+ d)); }
2993         });
2995         GW.needHashRealignment = false;
2997         // On edit post pages and conversation pages, add GUIEdit buttons to the 
2998         // textarea, expand it, and markdownify the existing text, if any (this is
2999         // needed if a post was last edited on LW).
3000         queryAll(".with-markdown-editor textarea").forEach(textarea => {
3001                 textarea.addTextareaFeatures();
3002                 expandTextarea(textarea);
3003                 textarea.value = MarkdownFromHTML(textarea.value);
3004         });
3005         // Focus the textarea.
3006         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3008         // If this is a post page...
3009         let postMeta = query(".post .post-meta");
3010         if (postMeta) {
3011                 // Add "qualified hyperlinking" toolbar.
3012                 let postPermalink = location.protocol + "//" + location.host + location.pathname;
3013                 postMeta.insertAdjacentHTML("beforeend", "<div class='qualified-linking'>" + 
3014                 "<input type='checkbox' tabindex='-1' id='qualified-linking-toolbar-toggle-checkbox'><label for='qualified-linking-toolbar-toggle-checkbox'><span>&#xf141;</span></label>" + 
3015                 "<div class='qualified-linking-toolbar'>" +
3016                 `<a href='${postPermalink}'>Post permalink</a>` +
3017                 `<a href='${postPermalink}?comments=false'>Link without comments</a>` +
3018                 `<a href='${postPermalink}?hide-nav-bars=true'>Link without top nav bars</a>` +
3019                 `<a href='${postPermalink}?comments=false&hide-nav-bars=true'>Link without comments or top nav bars</a>` +
3020                 "</div>" +
3021                 "</div>");
3023                 // Replicate .post-meta at bottom of post.
3024                 let clonedPostMeta = postMeta.cloneNode(true);
3025                 postMeta.addClass("top-post-meta");
3026                 clonedPostMeta.addClass("bottom-post-meta");
3027                 clonedPostMeta.query("input[type='checkbox']").id += "-bottom";
3028                 clonedPostMeta.query("label").htmlFor += "-bottom";
3029                 query(".post").appendChild(clonedPostMeta);
3030         }
3032         // If client is logged in...
3033         if (loggedInUserId) {
3034                 // Add upvote/downvote buttons.
3035                 if (typeof postVote != 'undefined') {
3036                         queryAll(".post-meta .karma-value").forEach(karmaValue => {
3037                                 addVoteButtons(karmaValue, postVote, 'Posts');
3038                                 karmaValue.parentElement.addClass("active-controls");
3039                         });
3040                 }
3041                 if (typeof commentVotes != 'undefined') {
3042                         queryAll(".comment-meta .karma-value").forEach(karmaValue => {
3043                                 let commentID = karmaValue.getCommentId();
3044                                 addVoteButtons(karmaValue, commentVotes[commentID], 'Comments');
3045                                 karmaValue.parentElement.addClass("active-controls");
3046                         });
3047                 }
3049                 // Color the upvote/downvote buttons with an embedded style sheet.
3050                 query("head").insertAdjacentHTML("beforeend","<style id='vote-buttons'>" + 
3051                 `.upvote:hover,
3052                 .upvote:focus,
3053                 .upvote.selected {
3054                         color: #00d800;
3055                 }
3056                 .downvote:hover,
3057                 .downvote:focus,
3058                 .downvote.selected {
3059                         color: #eb4c2a;
3060                 }` +
3061                 "</style>");
3063                 // Activate the vote buttons.
3064                 queryAll("button.vote").forEach(voteButton => {
3065                         voteButton.addActivateEvent(voteButtonClicked);
3066                 });
3068                 // For all comment containers...
3069                 queryAll(".comments").forEach((commentsContainer) => {
3070                         // Add reply buttons.
3071                         commentsContainer.queryAll(".comment").forEach(comment => {
3072                                 comment.insertAdjacentHTML("afterend", "<div class='comment-controls posting-controls'></div>");
3073                                 comment.parentElement.query(".comment-controls").constructCommentControls();
3074                         });
3076                         // Add top-level new comment form.
3077                         if (!query(".individual-thread-page")) {
3078                                 commentsContainer.insertAdjacentHTML("afterbegin", "<div class='comment-controls posting-controls'></div>");
3079                                 commentsContainer.query(".comment-controls").constructCommentControls();
3080                         }
3081                 });
3083                 // Hash realignment is needed because adding the above elements almost
3084                 // certainly caused the page to reflow, and now client is no longer
3085                 // scrolled to the place indicated by the hash.
3086                 GW.needHashRealignment = true;
3087         }
3089         // Clean up ToC
3090         queryAll(".contents-list li a").forEach(tocLink => {
3091                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
3092                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
3093                 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, '');
3094                 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
3095         });
3097         // If we're on a comment thread page...
3098         if (query(".comments") != null) {
3099                 // Add comment-minimize buttons to every comment.
3100                 queryAll(".comment-meta").forEach(commentMeta => {
3101                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3102                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
3103                 });
3104                 if (!query("#content").hasClass("individual-thread-page")) {
3105                         // Format and activate comment-minimize buttons.
3106                         queryAll(".comment-minimize-button").forEach(button => {
3107                                 button.closest(".comment-item").setCommentThreadMaximized(false);
3108                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3109                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
3110                                 });
3111                         });
3112                 }
3113         }
3114         if (getQueryVariable("chrono") == "t") {
3115                 query("head").insertAdjacentHTML("beforeend", "<style>.comment-minimize-button::after { display: none; }</style>");
3116         }
3117         let urlParts = document.URL.split('#comment-');
3118         if (urlParts.length > 1) {
3119                 expandAncestorsOf(urlParts[1]);
3120                 GW.needHashRealignment = true;
3121         }
3123         // On mobile, replace the labels for the checkboxes on the edit post form
3124         // with icons, to save space.
3125         if (GW.isMobile && query(".edit-post-page")) {
3126                 query("label[for='link-post']").innerHTML = "&#xf0c1";
3127                 query("label[for='question']").innerHTML = "&#xf128";
3128         }
3130         // Add error message (as placeholder) if user tries to click Search with
3131         // an empty search field.
3132         query("#nav-item-search form").addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3133                 let searchField = event.target.query("input");
3134                 if (searchField.value == "") {
3135                         event.preventDefault();
3136                         event.target.blur();
3137                         searchField.placeholder = "Enter a search string!";
3138                         searchField.focus();
3139                 }
3140         });
3141         // Remove the placeholder / error on any input.
3142         query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3143                 event.target.placeholder = "";
3144         });
3146         // Prevent conflict between various single-hotkey listeners and text fields
3147         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3148                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3149         });
3151         if (content.hasClass("post-page")) {
3152                 // Read and update last-visited-date.
3153                 let lastVisitedDate = getLastVisitedDate();
3154                 setLastVisitedDate(Date.now());
3156                 // Save the number of comments this post has when it's visited.
3157                 updateSavedCommentCount();
3159                 if (content.query(".comments .comment-thread") != null) {
3160                         // Add the new comments count & navigator.
3161                         injectNewCommentNavUI();
3163                         // Get the highlight-new-since date (as specified by URL parameter, if 
3164                         // present, or otherwise the date of the last visit).
3165                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3167                         // Highlight new comments since the specified date.                      
3168                         let newCommentsCount = highlightCommentsSince(hnsDate);
3170                         // Update the comment count display.
3171                         updateNewCommentNavUI(newCommentsCount, hnsDate);
3172                 }
3173         } else {
3174                 // On listing pages, make comment counts more informative.
3175                 badgePostsWithNewComments();
3176         }
3178         // Add the comments list mode selector widget (expanded vs. compact).
3179         injectCommentsListModeSelector();
3181         // Add the comments view selector widget (threaded vs. chrono).
3182 //      injectCommentsViewModeSelector();
3184         // Add the comments sort mode selector (top, hot, new, old).
3185         injectCommentsSortModeSelector();
3187         // Add the toggle for the post nav UI elements on mobile.
3188         if (GW.isMobile) injectPostNavUIToggle();
3190         // Add the toggle for the appearance adjustment UI elements on mobile.
3191         if (GW.isMobile) injectAppearanceAdjustUIToggle();
3193         // Add the antikibitzer.
3194         injectAntiKibitzer();
3196         // Add comment parent popups.
3197         addCommentParentPopups();
3199         // Mark original poster's comments with a special class.
3200         markOriginalPosterComments();
3201         
3202         // On the All view, mark posts with non-positive karma with a special class.
3203         if (query("#content").hasClass("all-index-page")) {
3204                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3205                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3207                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3208                 });
3209         }
3211         // Set the "submit" button on the edit post page to something more helpful.
3212         setEditPostPageSubmitButtonText();
3214         // Compute the text of the pagination UI tooltip text.
3215         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3216                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3217         });
3219         // Add event listeners for Escape and Enter, for the theme tweaker.
3220         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3221         let themeTweakerUI = query("#theme-tweaker-ui");
3222         document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3223                 if (event.keyCode == 27) {
3224                 // Escape key.
3225                         if (themeTweakerHelpWindow.style.display != "none") {
3226                                 toggleThemeTweakerHelpWindow();
3227                                 themeTweakerResetSettings();
3228                         } else if (themeTweakerUI.style.display != "none") {
3229                                 toggleThemeTweakerUI();
3230                                 themeTweakReset();
3231                         }
3232                 } else if (event.keyCode == 13) {
3233                 // Enter key.
3234                         if (themeTweakerHelpWindow.style.display != "none") {
3235                                 toggleThemeTweakerHelpWindow();
3236                                 themeTweakerSaveSettings();
3237                         } else if (themeTweakerUI.style.display != "none") {
3238                                 toggleThemeTweakerUI();
3239                                 themeTweakSave();
3240                         }
3241                 }
3242         });
3244         // Add event listener for . , ; (for navigating listings pages).
3245         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3246         if (listings.length > 0) {
3247                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
3248                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.keyCode == 27)) return;
3250                         if (event.keyCode == 27) {
3251                                 if (document.activeElement.parentElement.hasClass("listing"))
3252                                         document.activeElement.blur();
3253                                 return;
3254                         }
3256                         if (event.key == ';') {
3257                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3258                                         let links = document.activeElement.parentElement.queryAll("a");
3259                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3260                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3261                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3262                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3263                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3264                                 }
3265                                 return;
3266                         }
3268                         var indexOfActiveListing = -1;
3269                         for (i = 0; i < listings.length; i++) {
3270                                 if (document.activeElement.parentElement.hasClass("listing") && 
3271                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3272                                         indexOfActiveListing = i;
3273                                         break;
3274                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
3275                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
3276                                         indexOfActiveListing = i;
3277                                         break;
3278                                 }
3279                         }
3280                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3281                         if (indexOfNextListing < listings.length) {
3282                                 listings[indexOfNextListing].focus();
3284                                 if (listings[indexOfNextListing].closest(".comment-item")) {
3285                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3286                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3287                                 }
3288                         } else {
3289                                 document.activeElement.blur();
3290                         }
3291                 });
3292                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
3293                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
3294                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
3295                         });
3296                 });
3297         }
3298         // Add event listener for ; (to focus the link on link posts).
3299         if (query("#content").hasClass("post-page") && 
3300                 query(".post").hasClass("link-post")) {
3301                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
3302                         if (event.key == ';') query("a.link-post-link").focus();
3303                 });
3304         }
3306         // Add accesskeys to user page view selector.
3307         let viewSelector = query("#content.user-page > .sublevel-nav");
3308         if (viewSelector) {
3309                 let currentView = viewSelector.query("span");
3310                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
3311                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
3312         }
3314         // Add accesskey to index page sort selector.
3315         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
3317         // Move MathJax style tags to <head>.
3318         var aggregatedStyles = "";
3319         queryAll("#content style").forEach(styleTag => {
3320                 aggregatedStyles += styleTag.innerHTML;
3321                 removeElement("style", styleTag.parentElement);
3322         });
3323         if (aggregatedStyles != "") {
3324                 query("head").insertAdjacentHTML("beforeend", "<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
3325         }
3327         // Add listeners to switch between word count and read time.
3328         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
3329         queryAll(".post-meta .read-time").forEach(element => {
3330                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
3331                         let displayWordCount = localStorage.getItem("display-word-count");
3332                         toggleReadTimeOrWordCount(!displayWordCount);
3333                         if (displayWordCount) localStorage.removeItem("display-word-count");
3334                         else localStorage.setItem("display-word-count", true);
3335                 });
3336         });
3338         // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
3339         query("#content").addEventListener("copy", GW.textCopied = (event) => {
3340                 event.preventDefault();
3341                 const selectedHTML = getSelectionHTML();
3342                 const selectedText = getSelection().toString();
3343                 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
3344                 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
3345         });
3347         // Set up Image Focus feature.
3348         imageFocusSetup();
3351 /*************************/
3352 /* POST-LOAD ADJUSTMENTS */
3353 /*************************/
3355 registerInitializer('pageLayoutFinished', false, () => document.readyState == "complete", function () {
3356         GWLog("INITIALIZER pageLayoutFinished");
3357         forceInitializer('initialize');
3359         realignHashIfNeeded();
3361         postSetThemeHousekeeping();
3363         focusImageSpecifiedByURL();
3365         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
3366 //      query("input[type='search']").value = GW.isMobile;
3367 //      query("head").insertAdjacentHTML("beforeend", "<style>" + 
3368 //              `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` + 
3369 //              `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` + 
3370 //              "</style>");
3373 function generateImagesOverlay() {
3374         GWLog("generateImagesOverlay");
3375         // Don't do this on the about page.
3376         if (query(".about-page") != null) return;
3378         // Remove existing, if any.
3379         removeElement("#images-overlay");
3381         // Create new.
3382         query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
3383         let imagesOverlay = query("#images-overlay");
3384         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
3385         queryAll(".post-body img").forEach(image => {
3386                 image.style = "";
3387                 image.className = "";
3389                 let clonedImageContainer = document.createElement("div");
3391                 let clonedImage = image.cloneNode(true);
3392                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
3393                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
3394                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
3395                 clonedImageContainer.appendChild(clonedImage);
3397                 let zoomLevel = parseFloat(GW.currentTextZoom);
3399                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
3400                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
3401                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
3402                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
3403                 GWLog(clonedImageContainer);
3405                 imagesOverlay.appendChild(clonedImageContainer);
3406         });
3408         // Add the event listeners to focus each image.
3409         imageFocusSetup(true);
3412 function adjustUIForWindowSize() {
3413         GWLog("adjustUIForWindowSize");
3414         var bottomBarOffset;
3416         // Adjust bottom bar state.
3417         let bottomBar = query("#bottom-bar");
3418         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
3419         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
3420                 bottomBar.removeClass("decorative");
3422                 bottomBar.query("#nav-item-top").style.display = "";
3423         } else if (bottomBar) {
3424                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
3425                 else bottomBar.addClass("decorative");
3427                 bottomBar.query("#nav-item-top").style.display = "none";
3428         }
3430         // Show quick-nav UI up/down buttons if content is taller than window.
3431         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
3432         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
3433                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
3434         });
3436         // Move anti-kibitzer toggle if content is very short.
3437         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
3439         // Update the visibility of the post nav UI.
3440         updatePostNavUIVisibility();
3443 function recomputeUIElementsContainerHeight(force = false) {
3444         GWLog("recomputeUIElementsContainerHeight");
3445         if (!GW.isMobile &&
3446                 (force || query("#ui-elements-container").style.height != "")) {
3447                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
3448                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
3449                                                                                                                 query("#content").clientHeight + "px" :
3450                                                                                                                 "100vh";
3451         }
3454 function realignHashIfNeeded() {
3455         if (GW.needHashRealignment)
3456                 realignHash();
3458 function realignHash() {
3459         GWLog("realignHash");
3460         if (!location.hash) return;
3462         let targetElement = query(location.hash);
3463         if (targetElement) targetElement.scrollIntoView(true);
3464         GW.needHashRealignment = false;
3467 function focusImageSpecifiedByURL() {
3468         GWLog("focusImageSpecifiedByURL");
3469         if (location.hash.hasPrefix("#if_slide_")) {
3470                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
3471                         let images = queryAll("#images-overlay img");
3472                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
3473                         if (imageToFocus > 0 && imageToFocus <= images.length) {
3474                                 focusImage(images[imageToFocus - 1]);
3475                                 query("#image-focus-overlay .image-number").textContent = imageToFocus;
3476                         }
3477                 });
3478         }
3481 /***********/
3482 /* GUIEDIT */
3483 /***********/
3485 function insertMarkup(event) {
3486         var mopen = '', mclose = '', mtext = '', func = false;
3487         if (typeof arguments[1] == 'function') {
3488                 func = arguments[1];
3489         } else {
3490                 mopen = arguments[1];
3491                 mclose = arguments[2];
3492                 mtext = arguments[3];
3493         }
3495         var textarea = event.target.closest("form").query("textarea");
3496         textarea.focus();
3497         var p0 = textarea.selectionStart;
3498         var p1 = textarea.selectionEnd;
3499         var cur0 = cur1 = p0;
3501         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
3502         str = func ? func(str, p0) : (mopen + str + mclose);
3504         // Determine selection.
3505         if (!func) {
3506                 cur0 += (p0 == p1) ? mopen.length : str.length;
3507                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
3508         } else {
3509                 cur0 = str[1];
3510                 cur1 = str[2];
3511                 str = str[0];
3512         }
3514         // Update textarea contents.
3515         textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
3517         // Set selection.
3518         textarea.selectionStart = cur0;
3519         textarea.selectionEnd = cur1;
3521         return;
3524 GW.guiEditButtons = [
3525         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
3526         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
3527         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
3528         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
3529         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
3530         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
3531         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
3532         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
3533         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
3534         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
3535         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
3536         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
3537         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
3538         [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
3539         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
3542 function blockquote(text, startpos) {
3543         if (text == '') {
3544                 text = "> Quoted text";
3545                 return [ text, startpos + 2, startpos + text.length ];
3546         } else {
3547                 text = "> " + text.split("\n").join("\n> ") + "\n";
3548                 return [ text, startpos + text.length, startpos + text.length ];
3549         }
3552 function hyperlink(text, startpos) {
3553         var url = '', link_text = text, endpos = startpos;
3554         if (text.search(/^https?/) != -1) {
3555                 url = text;
3556                 link_text = "link text";
3557                 startpos = startpos + 1;
3558                 endpos = startpos + link_text.length;
3559         } else {
3560                 url = prompt("Link address (URL):");
3561                 if (!url) {
3562                         endpos = startpos + text.length;
3563                         return [ text, startpos, endpos ];
3564                 }
3565                 startpos = startpos + text.length + url.length + 4;
3566                 endpos = startpos;
3567         }
3569         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];