Fixed another image overlay bug
[lw2-viewer.git] / www / script.js
blobbba2cf2d590bb541fe9f2816de5c37981e327c18
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 /*******************/
153 /* INBOX INDICATOR */
154 /*******************/
156 function updateInbox() {
157         GWLog("updateInbox");
158         if (!loggedInUserId) return;
160         let request = new XMLHttpRequest();
161         request.addEventListener("load", (event) => {
162                 if (event.target.status != 200) return;
164                 let response = JSON.parse(event.target.responseText);
165                 if (response) {
166                         let element = query('#inbox-indicator');
167                         element.className = 'new-messages';
168                         element.title = 'New messages [o]';
169                 }
170         });
171         request.open("GET", "/check-notifications");
172         request.send();
175 /**************/
176 /* COMMENTING */
177 /**************/
179 function toggleMarkdownHintsBox() {
180         GWLog("toggleMarkdownHintsBox");
181         let markdownHintsBox = query("#markdown-hints");
182         markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
184 function hideMarkdownHintsBox() {
185         GWLog("hideMarkdownHintsBox");
186         let markdownHintsBox = query("#markdown-hints");
187         if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
190 Element.prototype.addTextareaFeatures = function() {
191         GWLog("addTextareaFeatures");
192         let textarea = this;
194         textarea.addEventListener("focus", (event) => { event.target.closest("form").scrollIntoViewIfNeeded(); });
195         textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
196                 if (window.innerWidth > 520) {
197                         // Expand textarea if needed.
198                         expandTextarea(textarea);
199                 } else {
200                         // Remove markdown hints.
201                         hideMarkdownHintsBox();
202                         query(".guiedit-mobile-help-button").removeClass("active");
203                 }
204         }, false);
205         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
207         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
208         let textareaContainer = textarea.closest(".textarea-container");
209         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
210         for (var button of GW.guiEditButtons) {
211                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
212                 buttons_container.insertAdjacentHTML("beforeend", 
213                         "<button type='button' class='guiedit guiedit-" 
214                         + name
215                         + "' tabindex='-1'"
216                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
217                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
218                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
219                         + " onclick='insertMarkup(event,"
220                         + ((typeof m_before_or_func == 'function') ?
221                                 m_before_or_func.name : 
222                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
223                         + ");'><div>"
224                         + icon
225                         + "</div></button>"
226                 );
227         }
229         var markdown_hints = 
230         `<input type='checkbox' id='markdown-hints-checkbox'>
231         <label for='markdown-hints-checkbox'></label>
232         <div id='markdown-hints'>` + 
233         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
234                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
235                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
236                 "<span>Heading 1</span><code># Heading 1</code>",
237                 "<span>Heading 2</span><code>## Heading 1</code>",
238                 "<span>Heading 3</span><code>### Heading 1</code>",
239                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
240         `</div>`;
241         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
243         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
244                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
245                         if (button.hasClass("guiedit-mobile-help-button")) {
246                                 toggleMarkdownHintsBox();
247                                 event.target.toggleClass("active");
248                                 query(".posting-controls:focus-within textarea").focus();
249                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
250                                 event.target.blur();
251                                 hideMarkdownHintsBox();
252                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
253                         }
254                 });
255         });
257         // On smartphone (narrow mobile) screens, when a textarea is focused (and
258         // automatically fullscreened), remove all the filters from the page, and 
259         // then apply them *just* to the fixed editor UI elements. This is in order
260         // to get around the "children of elements with a filter applied cannot be
261         // fixed" issue".
262         if (GW.isMobile && window.innerWidth <= 520) {
263                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
264                 textarea.addEventListener("focus", (event) => {
265                         GW.savedFilters = GW.currentFilters;
266                         GW.currentFilters = { };
267                         applyFilters(GW.currentFilters);
268                         fixedEditorElements.forEach(element => {
269                                 element.style.filter = filterStringFromFilters(GW.savedFilters);
270                         });
271                 });
272                 textarea.addEventListener("blur", (event) => {
273                         GW.currentFilters = GW.savedFilters;
274                         GW.savedFilters = { };
275                         requestAnimationFrame(() => {
276                                 applyFilters(GW.currentFilters);
277                                 fixedEditorElements.forEach(element => {
278                                         element.style.filter = filterStringFromFilters(GW.savedFilters);
279                                 });
280                         });
281                 });
282         }
285 Element.prototype.injectReplyForm = function(editMarkdownSource) {
286         GWLog("injectReplyForm");
287         let commentControls = this;
288         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
289         let withparent = (!editMarkdownSource && commentControls.getCommentId());
290         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
291                 "<form method='post'>" + 
292                 "<div class='textarea-container'>" + 
293                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
294                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
295                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
296                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
297                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
298                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
299                 "</div><div>" + 
300                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
301                 "<input type='submit' value='Submit'>" + 
302                 "</div></form>";
303         commentControls.onsubmit = disableBeforeUnload;
305         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
306                 hideReplyForm(event.target.closest(".comment-controls"));
307         });
308         commentControls.scrollIntoViewIfNeeded();
309         commentControls.query("form").onsubmit = (event) => {
310                 if (!event.target.text.value) {
311                         alert("Please enter a comment.");
312                         return false;
313                 }
314         }
315         let textarea = commentControls.query("textarea");
316         textarea.value = MarkdownFromHTML(editMarkdownSource || "");
317         textarea.addTextareaFeatures();
318         textarea.focus();
321 Element.prototype.updateCommentControlButton = function() {
322         let retractFn = () => {
323                 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
324                         return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
325                 else
326                         return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
327         };
328         let classMap = {
329                 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
330                 "retract-button": retractFn,
331                 "unretract-button": retractFn,
332                 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
333         };
334         classMap.keys().forEach((testClass) => {
335                 if(this.hasClass(testClass)) {
336                         let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
337                         this.className = "";
338                         this.addClasses([ buttonClass, "action-button" ]);
339                         if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
340                         this.dataset.label = buttonLabel;
341                         this.title = buttonAltText;
342                         this.tabIndex = '-1';
343                         return;
344                 }
345         });
348 Element.prototype.constructCommentControls = function() {
349         GWLog("constructCommentControls");
350         let commentControls = this;
351         commentControls.innerHTML = "";
352         let replyButton = document.createElement("button");
353         if (commentControls.parentElement.id == 'comments') {
354                 replyButton.className = "new-comment-button action-button";
355                 replyButton.innerHTML = "Post new comment";
356                 replyButton.setAttribute("accesskey", "n");
357                 replyButton.setAttribute("title", "Post new comment [n]");
358         } else {
359                 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
360                         let buttonsList = [];
361                         if(!commentControls.parentElement.query(".comment-thread"))
362                                 buttonsList.push("delete-button");
363                         buttonsList.push("retract-button", "edit-button");
364                         buttonsList.forEach(buttonClass => {
365                                 let button = commentControls.appendChild(document.createElement("button"));
366                                 button.addClass(buttonClass);
367                                 button.updateCommentControlButton();
368                         });
369                 }
370                 replyButton.className = "reply-button action-button";
371                 replyButton.innerHTML = "Reply";
372                 replyButton.dataset.label = "Reply";
373         }
374         commentControls.appendChild(replyButton);
375         replyButton.tabIndex = '-1';
377         // On mobile, hide labels for all but the Reply button.
378         if (GW.isMobile && window.innerWidth <= 900) {
379                 commentControls.queryAll(".delete-button, .retract-button, .unretract-button, .edit-button").forEach(button => {
380                         button.innerHTML = "";
381                 });
382         }
384         // Activate buttons.
385         commentControls.queryAll(".action-button").forEach(button => {
386                 button.addActivateEvent(GW.commentActionButtonClicked);
387         });
389         // Replicate karma controls at the bottom of comments.
390         if (commentControls.parentElement.id == "comments") return;
391         let karmaControls = commentControls.parentElement.query(".comment-meta .karma");
392         let karmaControlsCloned = karmaControls.cloneNode(true);
393         commentControls.appendChild(karmaControlsCloned);
394         commentControls.queryAll("button.vote").forEach(voteButton => {
395                 voteButton.addActivateEvent(voteButtonClicked);
396         });
399 GW.commentActionButtonClicked = (event) => {
400         GWLog("commentActionButtonClicked");
401         if (event.target.hasClass("edit-button") ||
402                 event.target.hasClass("reply-button") ||
403                 event.target.hasClass("new-comment-button")) {
404                 queryAll("textarea").forEach(textarea => {
405                         hideReplyForm(textarea.closest(".comment-controls"));
406                 });
407         }
409         if (event.target.hasClass("delete-button")) {
410                 let commentItem = event.target.closest(".comment-item");
411                 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
412                                         "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" + 
413                                         "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" + 
414                                         "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
415                         doCommentAction("delete", commentItem);
416         } else if (event.target.hasClass("retract-button")) {
417                 doCommentAction("retract", event.target.closest(".comment-item"));
418         } else if (event.target.hasClass("unretract-button")) {
419                 doCommentAction("unretract", event.target.closest(".comment-item"));
420         } else if (event.target.hasClass("edit-button")) {
421                 showCommentEditForm(event.target.closest(".comment-item"));
422         } else if (event.target.hasClass("reply-button")) {
423                 showReplyForm(event.target.closest(".comment-item"));
424         } else if (event.target.hasClass("new-comment-button")) {
425                 showReplyForm(event.target.closest("#comments"));
426         }
428         event.target.blur();
431 function showCommentEditForm(commentItem) {
432         GWLog("showCommentEditForm");
434         let commentBody = commentItem.query(".comment-body");
435         commentBody.style.display = "none";
437         let commentControls = commentItem.query(".comment-controls");
438         commentControls.injectReplyForm(commentBody.dataset.markdownSource);
439         commentControls.query("form").addClass("edit-existing-comment");
440         expandTextarea(commentControls.query("textarea"));
443 function showReplyForm(commentItem) {
444         GWLog("showReplyForm");
446         let commentControls = commentItem.query(".comment-controls");
447         commentControls.injectReplyForm(commentControls.dataset.enteredText);
450 function hideReplyForm(commentControls) {
451         GWLog("hideReplyForm");
452         // Are we editing a comment? If so, un-hide the existing comment body.
453         let containingComment = commentControls.closest(".comment-item");
454         if (containingComment) containingComment.query(".comment-body").style.display = "";
456         let enteredText = commentControls.query("textarea").value;
457         if (enteredText) commentControls.dataset.enteredText = enteredText;
459         disableBeforeUnload();
460         commentControls.constructCommentControls();
463 function expandTextarea(textarea) {
464         GWLog("expandTextarea");
465         if (window.innerWidth <= 520) return;
467         let totalBorderHeight = 30;
468         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
470         requestAnimationFrame(() => {
471                 textarea.style.height = 'auto';
472                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
473                 if (textarea.clientHeight < window.innerHeight) {
474                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
475                 }
476         });
479 function doCommentAction(action, commentItem) {
480         GWLog("doCommentAction");
481         let params = {};
482         params[(action + "-comment-id")] = commentItem.getCommentId();
483         doAjax({
484                 method: "POST",
485                 params: params,
486                 onSuccess: (event) => {
487                         let fn = {
488                                 retract: () => { commentItem.firstChild.addClass("retracted") },
489                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
490                                 delete: () => {
491                                         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>";
492                                         commentItem.removeChild(commentItem.query(".comment-controls"));
493                                 }
494                         }[action];
495                         if(fn) fn();
496                         if(action != "delete")
497                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
498                 }
499         });
502 /**********/
503 /* VOTING */
504 /**********/
506 function parseVoteType(voteType) {
507         GWLog("parseVoteType");
508         let value = {};
509         if (!voteType) return value;
510         value.up = /[Uu]pvote$/.test(voteType);
511         value.down = /[Dd]ownvote$/.test(voteType);
512         value.big = /^big/.test(voteType);
513         return value;
516 function makeVoteType(value) {
517         GWLog("makeVoteType");
518         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
521 function makeVoteClass(vote) {
522         GWLog("makeVoteClass");
523         if (vote.up || vote.down) {
524                 return (vote.big ? 'selected big-vote' : 'selected');
525         } else {
526                 return '';
527         }
530 function addVoteButtons(element, voteType, targetType) {
531         GWLog("addVoteButtons");
532         let vote = parseVoteType(voteType);
533         let voteClass = makeVoteClass(vote);
534         element.insertAdjacentHTML('beforebegin', "<button type='button' class='vote upvote"+(vote.up ?' '+voteClass:'')+"' data-vote-type='upvote' data-target-type='"+targetType+"' tabindex='-1'></button>");
535         element.insertAdjacentHTML('afterend', "<button type='button' class='vote downvote"+(vote.down ?' '+voteClass:'')+"' data-vote-type='downvote' data-target-type='"+targetType+"' tabindex='-1'></button>");
538 function makeVoteCompleteEvent(target) {
539         GWLog("makeVoteCompleteEvent");
540         return (event) => {
541                 var buttonTargets, karmaTargets;
542                 if (target === null) {
543                         buttonTargets = queryAll(".post-meta .karma");
544                         karmaTargets = queryAll(".post-meta .karma-value");
545                 } else {
546                         let commentItem = target.closest(".comment-item")
547                         buttonTargets = [ commentItem.query(".comment-meta .karma"), commentItem.query(".comment-controls .karma") ];
548                         karmaTargets = [ commentItem.query(".comment-meta .karma-value"), commentItem.query(".comment-controls .karma-value") ];
549                 }
550                 buttonTargets.forEach(buttonTarget => {
551                         buttonTarget.removeClass("waiting");
552                 });
553                 if (event.target.status == 200) {
554                         let response = JSON.parse(event.target.responseText);
555                         let karmaText = response[0], voteType = response[1];
557                         let vote = parseVoteType(voteType);
558                         let voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
559                         let voteClass = makeVoteClass(vote);
561                         karmaTargets.forEach(karmaTarget => {
562                                 karmaTarget.innerHTML = karmaText;
563                                 if (karmaTarget.hasClass("redacted")) {
564                                         karmaTarget.dataset["trueValue"] = karmaTarget.firstChild.textContent;
565                                         karmaTarget.firstChild.textContent = "##";
566                                 }
567                         });
568                         buttonTargets.forEach(buttonTarget => {
569                                 buttonTarget.queryAll("button.vote").forEach(button => {
570                                         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
571                                         if (button.dataset.voteType == voteUpDown) button.addClass(voteClass);
572                                 });
573                         });
574                 }
575         }
578 function sendVoteRequest(targetId, targetType, voteType, onFinish) {
579         GWLog("sendVoteRequest");
580         let req = new XMLHttpRequest();
581         req.addEventListener("load", onFinish);
582         req.open("POST", "/karma-vote");
583         req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
584         req.send("csrf-token="+encodeURIComponent(GW.csrfToken)+"&target="+encodeURIComponent(targetId)+"&target-type="+encodeURIComponent(targetType)+"&vote-type="+encodeURIComponent(voteType));
587 function voteButtonClicked(event) {
588         let voteButton = event.target;
590         // 500 ms (0.5 s) double-click timeout.
591         let doubleClickTimeout = 500;
593         if (!voteButton.clickedOnce) {
594                 voteButton.clickedOnce = true;
595                 voteButton.addClass("clicked-once");
597                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
598                         if (!voteButton.clickedOnce) return;
600                         // Do single-click code.
601                         voteButton.clickedOnce = false;
602                         voteEvent(voteButton, 1);
603                 }, doubleClickTimeout, voteButton);
604         } else {
605                 voteButton.clickedOnce = false;
607                 // Do double-click code.
608                 voteEvent(voteButton, 2);
609                 voteButton.removeClass("clicked-once");
610                 voteButton.addClass("clicked-twice");
611         }
613 function voteEvent(voteButton, numClicks) {
614         voteButton.blur();
615         voteButton.parentNode.addClass("waiting");
616         let targetType = voteButton.dataset.targetType;
617         let targetId = ((targetType == 'Comments') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
618         let voteUpDown = voteButton.dataset.voteType;
619         let vote = parseVoteType(voteUpDown);
620         vote.big = (numClicks == 2);
621         let voteType = makeVoteType(vote);
622         let oldVoteType;
623         if (targetType == "Posts") {
624                 oldVoteType = postVote;
625                 postVote = ((voteType == oldVoteType) ? null : voteType);
626         } else {
627                 oldVoteType = commentVotes[targetId];
628                 commentVotes[targetId] = ((voteType == oldVoteType) ? null : voteType);
629         }
630         let f = () => { sendVoteRequest(targetId, targetType, voteType, makeVoteCompleteEvent((targetType == 'Comments' ? voteButton.parentNode : null))) };
631         if (oldVoteType && (oldVoteType != voteType)) {
632                 sendVoteRequest(targetId, targetType, oldVoteType, f);
633         } else {
634                 f();
635         }
638 /***********************************/
639 /* COMMENT THREAD MINIMIZE BUTTONS */
640 /***********************************/
642 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
643         GWLog("setCommentThreadMaximized");
644         let commentItem = this;
645         let storageName = "thread-minimized-" + commentItem.getCommentId();
646         let minimize_button = commentItem.query(".comment-minimize-button");
647         let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !localStorage.getItem(storageName));
648         if (userOriginated) {
649                 if (maximize) {
650                         localStorage.removeItem(storageName);
651                 } else {
652                         localStorage.setItem(storageName, true);
653                 }
654         }
656         commentItem.style.height = maximize ? 'auto' : '38px';
657         commentItem.style.overflow = maximize ? 'visible' : 'hidden';
659         minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
660         minimize_button.innerHTML = maximize ? "&#xf146;" : "&#xf0fe;";
661         minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
662         if (getQueryVariable("chrono") != "t") {
663                 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
664         }
667 /*****************************************/
668 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
669 /*****************************************/
671 Element.prototype.getCommentDate = function() {
672         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
673         return (item ? parseInt(item.query(".date").dataset["jsDate"]) : false);
675 function getCurrentVisibleComment() {
676         let px = window.innerWidth/2, py = 5;
677         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
678         let atbottom = query("#comments").getBoundingClientRect().bottom < window.innerHeight;
679         if (atbottom) {
680                 let hashci = location.hash && query(location.hash);
681                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
682                         commentItem = hashci;
683                 }
684         }
685         return commentItem;
688 function highlightCommentsSince(date) {
689         GWLog("highlightCommentsSince");
690         var newCommentsCount = 0;
691         GW.newComments = [ ];
692         let oldCommentsStack = [ ];
693         let prevNewComment;
694         queryAll(".comment-item").forEach(commentItem => {
695                 commentItem.prevNewComment = prevNewComment;
696                 if (commentItem.getCommentDate() > date) {
697                         commentItem.addClass("new-comment");
698                         newCommentsCount++;
699                         GW.newComments.push(commentItem.getCommentId());
700                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
701                         oldCommentsStack = [ commentItem ];
702                         prevNewComment = commentItem;
703                 } else {
704                         commentItem.removeClass("new-comment");
705                         oldCommentsStack.push(commentItem);
706                 }
707         });
709         GW.newCommentScrollSet = (commentItem) => {
710                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
711                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
712         };
713         GW.newCommentScrollListener = () => {
714                 let commentItem = getCurrentVisibleComment();
715                 GW.newCommentScrollSet(commentItem);
716         }
718         addScrollListener(GW.newCommentScrollListener);
720         if (document.readyState=="complete") {
721                 GW.newCommentScrollListener();
722         } else {
723                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
724                 GW.newCommentScrollSet(commentItem);
725         }
727         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
729         return newCommentsCount;
732 function scrollToNewComment(next) {
733         GWLog("scrollToNewComment");
734         let commentItem = getCurrentVisibleComment();
735         let targetComment = null;
736         let targetCommentID = null;
737         if (commentItem) {
738                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
739                 if (targetComment) {
740                         targetCommentID = targetComment.getCommentId();
741                 }
742         } else {
743                 if (GW.newComments[0]) {
744                         targetCommentID = GW.newComments[0];
745                         targetComment = query("#comment-" + targetCommentID);
746                 }
747         }
748         if (targetComment) {
749                 expandAncestorsOf(targetCommentID);
750                 history.replaceState(null, null, "#comment-" + targetCommentID);
751                 targetComment.scrollIntoView();
752         }
754         GW.newCommentScrollListener();
757 function getPostHash() {
758         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
759         return (postHash ? postHash[1] : false);
761 function getLastVisitedDate() {
762         // Get the last visited date (or, if posting a comment, the previous last visited date).
763         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
764         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
765         return localStorage.getItem(storageName);
767 function setLastVisitedDate(date) {
768         // If NOT posting a comment, save the previous value for the last-visited-date 
769         // (to recover it in case of posting a comment).
770         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
771         if (!aCommentHasJustBeenPosted) {
772                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
773                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
774         }
776         // Set the new value.
777         localStorage.setItem("last-visited-date_" + getPostHash(), date);
780 function updateSavedCommentCount() {
781         let commentCount = queryAll(".comment").length;
782         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
784 function badgePostsWithNewComments() {
785         if (getQueryVariable("show") == "conversations") return;
787         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
788                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
790                 let savedCommentCount = localStorage.getItem("comment-count_" + postHash);
791                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
792                 let currentCommentCount = /([0-9]+)/.exec(commentCountDisplay.textContent)[1];
794                 if (currentCommentCount > savedCommentCount)
795                         commentCountDisplay.addClass("new-comments");
796                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
797         });
800 /***********************************/
801 /* CONTENT COLUMN WIDTH ADJUSTMENT */
802 /***********************************/
804 function injectContentWidthSelector() {
805         GWLog("injectContentWidthSelector");
806         // Get saved width setting (or default).
807         let currentWidth = localStorage.getItem("selected-width") || 'normal';
809         // Inject the content width selector widget and activate buttons.
810         let widthSelector = addUIElement(
811                 "<div id='width-selector'>" +
812                 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
813                         let [name, desc, abbr] = widthOption;
814                         let selected = (name == currentWidth ? ' selected' : '');
815                         let disabled = (name == currentWidth ? ' disabled' : '');
816                         return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
817                 "</div>");
818         widthSelector.queryAll("button").forEach(button => {
819                 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
820                         // Determine which setting was chosen (i.e., which button was clicked).
821                         let selectedWidth = event.target.dataset.name;
823                         // Save the new setting.
824                         if (selectedWidth == "normal") localStorage.removeItem("selected-width");
825                         else localStorage.setItem("selected-width", selectedWidth);
827                         // Actually change the content width.
828                         setContentWidth(selectedWidth);
829                         event.target.parentElement.childNodes.forEach(button => {
830                                 button.removeClass("selected");
831                                 button.disabled = false;
832                         });
833                         event.target.addClass("selected");
834                         event.target.disabled = true;
836                         // Make sure the accesskey (to cycle to the next width) is on the right button.
837                         setWidthAdjustButtonsAccesskey();
839                         // Regenerate images overlay.
840                         generateImagesOverlay();
842                         // Realign hash.
843                         realignHash();
844                 });
845         });
847         // Make sure the accesskey (to cycle to the next width) is on the right button.
848         setWidthAdjustButtonsAccesskey();
850         // Inject transitions CSS, if animating changes is enabled.
851         if (GW.adjustmentTransitions) {
852                 query("head").insertAdjacentHTML("beforeend", 
853                         "<style id='width-transition'>" + 
854                         `#content,
855                         #ui-elements-container,
856                         #images-overlay {
857                                 transition:
858                                         max-width 0.3s ease;
859                         }` + 
860                         "</style>");
861         }
863 function setWidthAdjustButtonsAccesskey() {
864         GWLog("setWidthAdjustButtonsAccesskey");
865         let widthSelector = query("#width-selector");
866         widthSelector.queryAll("button").forEach(button => {
867                 button.removeAttribute("accesskey");
868                 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
869         });
870         let selectedButton = widthSelector.query("button.selected");
871         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
872         nextButtonInCycle.accessKey = "'";
873         nextButtonInCycle.title += ` [\']`;
876 /*******************/
877 /* THEME SELECTION */
878 /*******************/
880 function injectThemeSelector() {
881         GWLog("injectThemeSelector");
882         let currentTheme = readCookie("theme") || "default";
883         let themeSelector = addUIElement(
884                 "<div id='theme-selector' class='theme-selector'>" +
885                 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
886                         let [name, desc, letter] = themeOption;
887                         let selected = (name == currentTheme ? ' selected' : '');
888                         let disabled = (name == currentTheme ? ' disabled' : '');
889                         let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
890                         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>`;})) +
891                 "</div>");
892         themeSelector.queryAll("button").forEach(button => {
893                 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
894                         let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
895                         setSelectedTheme(themeName);
896                 });
897         });
899         // Inject transitions CSS, if animating changes is enabled.
900         if (GW.adjustmentTransitions) {
901                 query("head").insertAdjacentHTML("beforeend", 
902                         "<style id='theme-fade-transition'>" + 
903                         `body {
904                                 transition:
905                                         opacity 0.5s ease-out,
906                                         background-color 0.3s ease-out;
907                         }
908                         body.transparent {
909                                 background-color: #777;
910                                 opacity: 0.0;
911                                 transition:
912                                         opacity 0.5s ease-in,
913                                         background-color 0.3s ease-in;
914                         }` + 
915                         "</style>");
916         }
918 function setSelectedTheme(themeName) {
919         GWLog("setSelectedTheme");
920         queryAll(".theme-selector button").forEach(button => {
921                 button.removeClass("selected");
922                 button.disabled = false;
923         });
924         queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
925                 button.addClass("selected");
926                 button.disabled = true;
927         });
928         setTheme(themeName);
929         query("#theme-tweaker-ui .current-theme span").innerText = themeName;
931 function setTheme(newThemeName) {
932         var themeUnloadCallback = '';
933         var oldThemeName = '';
934         if (typeof(newThemeName) == 'undefined') {
935                 newThemeName = readCookie('theme');
936                 if (!newThemeName) return;
937         } else {
938                 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
939                 oldThemeName = readCookie('theme') || 'default';
941                 if (newThemeName == 'default') setCookie('theme', '');
942                 else setCookie('theme', newThemeName);
943         }
944         if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
946         let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
947         let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
949         let newStyle = document.createElement('link');
950         newStyle.setAttribute('rel', 'stylesheet');
951         newStyle.setAttribute('href', '/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
953         let oldStyle = query("head link[href*='.css']");
954         newStyle.addEventListener('load', () => { removeElement(oldStyle); });
955         newStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
957         if (GW.adjustmentTransitions) {
958                 pageFadeTransition(false);
959                 setTimeout(() => {
960                         query('head').insertBefore(newStyle, oldStyle.nextSibling);
961                 }, 500);
962         } else {
963                 query('head').insertBefore(newStyle, oldStyle.nextSibling);
964         }
966 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
967         recomputeUIElementsContainerHeight(true);
969         let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
970         if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
972         recomputeUIElementsContainerHeight();
973         adjustUIForWindowSize();
974         window.addEventListener('resize', GW.windowResized = (event) => {
975                 adjustUIForWindowSize();
976                 recomputeUIElementsContainerHeight();
977         });
979         generateImagesOverlay();
981         if (window.adjustmentTransitions) pageFadeTransition(true);
982         updateThemeTweakerSampleText();
984         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
985                 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
986         }
988         setTimeout(realignHash, 0);
991 function pageFadeTransition(fadeIn) {
992         if (fadeIn) {
993                 query("body").removeClass("transparent");
994         } else {
995                 query("body").addClass("transparent");
996         }
999 GW.themeLoadCallback_less = (fromTheme = "") => {
1000         GWLog("themeLoadCallback_less");
1001         injectSiteNavUIToggle();
1002         if (!GW.isMobile) {
1003                 injectPostNavUIToggle();
1004                 injectAppearanceAdjustUIToggle();
1005         }
1007         registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1008                 let dtf = new Intl.DateTimeFormat([], 
1009                         (window.innerWidth < 1100) ? 
1010                                 { month: 'short', day: 'numeric', year: 'numeric' } : 
1011                                         { month: 'long', day: 'numeric', year: 'numeric' });
1012                 let postDate = query(".top-post-meta .date");
1013                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1014         });
1016         if (GW.isMobile) {
1017                 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1018         }
1020         if (!GW.isMobile) {
1021                 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1022                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1023                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1024                         });
1025                 });
1027                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1028                         // If state is not set (user has never clicked on the Less theme's appearance
1029                         // adjustment UI toggle) then show it, but then hide it after a short time.
1030                         registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1031                                 toggleAppearanceAdjustUI();
1032                                 setTimeout(toggleAppearanceAdjustUI, 3000);
1033                         });
1034                 }
1036                 if (fromTheme != "") {
1037                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1038                         setTimeout(function () {
1039                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1040                         }, 300);
1041                         setTimeout(function () {
1042                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1043                         }, 1800);
1044                 }
1046                 // Unset the height of the #ui-elements-container.
1047                 query("#ui-elements-container").style.height = "";
1049                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1050                 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1051                         "#content #secondary-bar",
1052                         "#content .post .top-post-meta .date",
1053                         "#content .post .top-post-meta .comment-count",
1054                 ];
1055                 applyFilters(GW.currentFilters);
1056         }
1058         // We pre-query the relevant elements, so we don't have to run querySelectorAll
1059         // on every firing of the scroll listener.
1060         GW.scrollState = {
1061                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1062                 "unbrokenDownScrollDistance":           0,
1063                 "unbrokenUpScrollDistance":                     0,
1064                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1065                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1066                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1067         };
1068         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1071 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
1072 // otherwise, show it.
1073 function updatePostNavUIVisibility() {
1074         GWLog("updatePostNavUIVisibility");
1075         var hidePostNavUIToggle = true;
1076         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1077                 if (getComputedStyle(element).visibility == "visible" ||
1078                         element.style.visibility == "visible" ||
1079                         element.style.visibility == "unset")
1080                         hidePostNavUIToggle = false;
1081         });
1082         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1083                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1084         });
1087 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1088 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
1089 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1090 function updateSiteNavUIState(event) {
1091         GWLog("updateSiteNavUIState");
1092         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1093         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
1094                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
1095                                                                                                                 0;
1096         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1097                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1098                                                                                                          0;
1099         GW.scrollState.lastScrollTop = newScrollTop;
1101         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1102         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1103                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1104                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1105         }
1107         // On mobile, make site nav UI translucent on ANY scroll down.
1108         if (GW.isMobile)
1109                 GW.scrollState.siteNavUIElements.forEach(element => {
1110                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1111                         else element.removeClass("translucent-on-scroll");
1112                 });
1114         // Show site nav UI when scrolling a full page up, or to the top.
1115         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
1116                  GW.scrollState.lastScrollTop == 0) &&
1117                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
1118                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1120         // On desktop, show appearance adjust UI when scrolling to the top.
1121         if ((!GW.isMobile) && 
1122                 (GW.scrollState.lastScrollTop == 0) &&
1123                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
1124                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1127 GW.themeUnloadCallback_less = (toTheme = "") => {
1128         GWLog("themeUnloadCallback_less");
1129         removeSiteNavUIToggle();
1130         if (!GW.isMobile) {
1131                 removePostNavUIToggle();
1132                 removeAppearanceAdjustUIToggle();
1133         }
1134         window.removeEventListener('resize', updatePostNavUIVisibility);
1136         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1138         removeElement("#theme-less-mobile-first-row-placeholder");
1140         if (!GW.isMobile) {
1141                 // Remove spans
1142                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1143                         element.innerHTML = element.firstChild.innerHTML;
1144                 });
1145         }
1147         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1149         // Reset filtered elements selector to default.
1150         delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1151         applyFilters(GW.currentFilters);
1154 GW.themeLoadCallback_dark = (fromTheme = "") => {
1155         GWLog("themeLoadCallback_dark");
1156         query("head").insertAdjacentHTML("beforeend", 
1157                 "<style id='dark-theme-adjustments'>" + 
1158                 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` + 
1159                 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1160                 "</style>");
1161         registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1162                 queryAll("#images-overlay img").forEach(image => {
1163                         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)";
1164                         image.style.width = parseInt(image.style.width) + 12 + "px";
1165                         image.style.height = parseInt(image.style.height) + 12 + "px";
1166                         image.style.top = parseInt(image.style.top) - 6 + "px";
1167                         image.style.left = parseInt(image.style.left) - 6 + "px";
1168                 });
1169         });
1171 GW.themeUnloadCallback_dark = (toTheme = "") => {
1172         GWLog("themeUnloadCallback_dark");
1173         removeElement("#dark-theme-adjustments");
1176 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1177         GWLog("themeLoadCallback_brutalist");
1178         let bottomBarLinks = queryAll("#bottom-bar a");
1179         if (!GW.isMobile && bottomBarLinks.length == 5) {
1180                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1181                 bottomBarLinks.forEach((link, i) => {
1182                         link.dataset.originalText = link.textContent;
1183                         link.textContent = newLinkTexts[i];
1184                 });
1185         }
1187 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1188         GWLog("themeUnloadCallback_brutalist");
1189         let bottomBarLinks = queryAll("#bottom-bar a");
1190         if (!GW.isMobile && bottomBarLinks.length == 5) {
1191                 bottomBarLinks.forEach(link => {
1192                         link.textContent = link.dataset.originalText;
1193                 });
1194         }
1197 GW.themeLoadCallback_classic = (fromTheme = "") => {
1198         GWLog("themeLoadCallback_classic");
1199         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1200                 button.innerHTML = "";
1201         });
1203 GW.themeUnloadCallback_classic = (toTheme = "") => {
1204         GWLog("themeUnloadCallback_classic");
1205         if (GW.isMobile && window.innerWidth <= 900) return;
1206         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1207                 button.innerHTML = button.dataset.label;
1208         });
1211 /********************************************/
1212 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1213 /********************************************/
1215 function injectThemeTweaker() {
1216         GWLog("injectThemeTweaker");
1217         let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" + 
1218         `<div class='main-theme-tweaker-window'>
1219                 <h1>Customize appearance</h1>
1220                 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1221                 <button type='button' class='help-button' tabindex='-1'></button>
1222                 <p class='current-theme'>Current theme: <span>` + 
1223                 (readCookie("theme") || "default") + 
1224                 `</span></p>
1225                 <p class='theme-selector'></p>
1226                 <div class='controls-container'>
1227                         <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1228                                 <div class='sample-text-container'><span class='sample-text'>
1229                                         <p>Less Wrong (text)</p>
1230                                         <p><a href="#">Less Wrong (link)</a></p>
1231                                 </span></div>
1232                         </div>
1233                         <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1234                                 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1235                                 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1236                                 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1237                         </div>
1238                         <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1239                                 <input type='checkbox' id='theme-tweak-control-invert'></input>
1240                                 <label for='theme-tweak-control-invert'>Invert colors</label>
1241                         </div>
1242                         <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1243                                 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1244                                 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1245                                 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1246                         </div>
1247                         <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1248                                 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1249                                 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1250                                 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1251                         </div>
1252                         <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1253                                 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1254                                 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1255                                 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1256                         </div>
1257                         <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1258                                 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1259                                 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1260                                 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1261                         </div>
1262                 </div>
1263                 <div class='buttons-container'>
1264                         <button type="button" class="reset-defaults-button">Reset to defaults</button>
1265                         <button type='button' class='ok-button default-button'>OK</button>
1266                         <button type='button' class='cancel-button'>Cancel</button>
1267                 </div>
1268         </div>
1269         <div class="clippy-container">
1270                 <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>)
1271                 <div class='clippy'></div>
1272                 <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>
1273         </div>
1274         <div class='help-window' style='display: none;'>
1275                 <h1>Theme tweaker help</h1>
1276                 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1277                         <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1278                         <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1279                 </div>
1280                 <div class='buttons-container'>
1281                         <button type='button' class='ok-button default-button'>OK</button>
1282                         <button type='button' class='cancel-button'>Cancel</button>
1283                 </div>
1284         </div>
1285         ` + "</div>");
1287         // Clicking the background overlay closes the theme tweaker.
1288         themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1289                 if (event.type == 'mousedown') {
1290                         themeTweakerUI.style.opacity = "0.01";
1291                 } else {
1292                         toggleThemeTweakerUI();
1293                         themeTweakerUI.style.opacity = "1.0";
1294                         themeTweakReset();
1295                 }
1296         }, true);
1298         // Intercept clicks, so they don't "fall through" the background overlay.
1299         (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1301         let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1302         themeTweakerUI.queryAll("input").forEach(field => {
1303                 // All input types in the theme tweaker receive a 'change' event when
1304                 // their value is changed. (Range inputs, in particular, receive this 
1305                 // event when the user lets go of the handle.) This means we should
1306                 // update the filters for the entire page, to match the new setting.
1307                 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1308                         if (event.target.id == 'theme-tweak-control-invert') {
1309                                 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1310                         } else if (event.target.type == 'range') {
1311                                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1312                                 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1313                                 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1314                         } else if (event.target.id == 'theme-tweak-control-clippy') {
1315                                 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1316                         }
1317                         // Clear the sample text filters.
1318                         sampleTextContainer.style.filter = "";
1319                         // Apply the new filters globally.
1320                         applyFilters(GW.currentFilters);
1321                 });
1323                 // Range inputs receive an 'input' event while being scrubbed, updating
1324                 // "live" as the handle is moved. We don't want to change the filters 
1325                 // for the actual page while this is happening, but we do want to change
1326                 // the filters for the *sample text*, so the user can see what effects
1327                 // his changes are having, live, without having to let go of the handle.
1328                 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1329                         var sampleTextFilters = GW.currentFilters;
1331                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1332                         query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1333                         sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1335                         sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1336                 });
1337         });
1339         themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1340                 let themeTweakerStyle = query("#theme-tweaker-style");
1342                 if (event.target.hasClass("minimize")) {
1343                         event.target.removeClass("minimize");
1344                         themeTweakerStyle.innerHTML = 
1345                                 `#theme-tweaker-ui .main-theme-tweaker-window {
1346                                         width: 320px;
1347                                         height: 31px;
1348                                         overflow: hidden;
1349                                         padding: 30px 0 0 0;
1350                                         top: 20px;
1351                                         right: 20px;
1352                                         left: auto;
1353                                 }
1354                                 #theme-tweaker-ui::after {
1355                                         top: 27px;
1356                                         right: 27px;
1357                                 }
1358                                 #theme-tweaker-ui::before {
1359                                         opacity: 0.0;
1360                                         height: 0;
1361                                 }
1362                                 #theme-tweaker-ui .clippy-container {
1363                                         opacity: 1.0;
1364                                 }
1365                                 #theme-tweaker-ui .clippy-container .hint span {
1366                                         color: #c00;
1367                                 }
1368                                 #theme-tweaker-ui {
1369                                         height: 0;
1370                                 }
1371                                 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1372                                         pointer-events: none;
1373                                 }`;
1374                         event.target.addClass("maximize");
1375                 } else {
1376                         event.target.removeClass("maximize");
1377                         themeTweakerStyle.innerHTML = 
1378                                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1379                                         pointer-events: none;
1380                                 }`;
1381                         event.target.addClass("minimize");
1382                 }
1383         });
1384         themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1385                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1386                 toggleThemeTweakerHelpWindow();
1387         });
1388         themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1389                 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1390                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1391                         let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1392                         slider.value = slider.dataset['defaultValue'];
1393                         themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1394                 });
1395                 GW.currentFilters = { };
1396                 applyFilters(GW.currentFilters);
1398                 GW.currentTextZoom = "1.0";
1399                 setTextZoom(GW.currentTextZoom);
1401                 setSelectedTheme("default");
1402         });
1403         themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1404                 toggleThemeTweakerUI();
1405                 themeTweakReset();
1406         });
1407         themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1408                 toggleThemeTweakerUI();
1409                 themeTweakSave();
1410         });
1411         themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1412                 toggleThemeTweakerHelpWindow();
1413                 themeTweakerResetSettings();
1414         });
1415         themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1416                 toggleThemeTweakerHelpWindow();
1417                 themeTweakerSaveSettings();
1418         });
1420         themeTweakerUI.queryAll(".notch").forEach(notch => {
1421                 notch.addActivateEvent(function (event) {
1422                         let slider = event.target.parentElement.query("input[type='range']");
1423                         slider.value = slider.dataset['defaultValue'];
1424                         event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1425                         GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1426                         applyFilters(GW.currentFilters);
1427                 });
1428         });
1430         themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1431                 themeTweakerUI.query(".clippy-container").style.display = "none";
1432                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1433                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1434         });
1436         query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1438         themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1439         themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1440                 button.addActivateEvent(GW.themeSelectButtonClicked);
1441         });
1443         themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1444                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1445         });
1447         let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'>&#xf1de;</button></div>`);
1448         themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1449                 GW.themeTweakerStyleSheetAvailable = () => {
1450                         themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1452                         themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1453                         [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1454                                 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1455                                 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1456                                 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1457                         });
1459                         toggleThemeTweakerUI();
1460                         event.target.disabled = true;
1461                 };
1463                 if (query("link[href^='/theme_tweaker.css']")) {
1464                         GW.themeTweakerStyleSheetAvailable();
1465                 } else {
1466                         // Load the theme tweaker CSS (if not loaded).
1467                         let themeTweakerStyleSheet = document.createElement('link');
1468                         themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1469                         themeTweakerStyleSheet.setAttribute('href', '/theme_tweaker.css');
1470                         themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1471                         query("head").appendChild(themeTweakerStyleSheet);
1472                 }
1473         });
1475 function toggleThemeTweakerUI() {
1476         GWLog("toggleThemeTweakerUI");
1477         let themeTweakerUI = query("#theme-tweaker-ui");
1478         themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1479         query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" : 
1480                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1481                         pointer-events: none;
1482                 }`;
1483         if (themeTweakerUI.style.display != "none") {
1484                 // Save selected theme.
1485                 GW.currentTheme = (readCookie("theme") || "default");
1486                 // Focus invert checkbox.
1487                 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1488                 // Show sample text in appropriate font.
1489                 updateThemeTweakerSampleText();
1490                 // Disable tab-selection of the search box.
1491                 setSearchBoxTabSelectable(false);
1492         } else {
1493                 query("#theme-tweaker-toggle button").disabled = false;
1494                 // Re-enable tab-selection of the search box.
1495                 setSearchBoxTabSelectable(true);
1496         }
1497         // Set theme tweaker assistant visibility.
1498         query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1500 function setSearchBoxTabSelectable(selectable) {
1501         GWLog("setSearchBoxTabSelectable");
1502         query("input[type='search']").tabIndex = selectable ? "" : "-1";
1503         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1505 function toggleThemeTweakerHelpWindow() {
1506         GWLog("toggleThemeTweakerHelpWindow");
1507         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1508         themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1509         if (themeTweakerHelpWindow.style.display != "none") {
1510                 // Focus theme tweaker assistant checkbox.
1511                 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1512                 // Disable interaction on main theme tweaker window.
1513                 query("#theme-tweaker-ui").style.pointerEvents = "none";
1514                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1515         } else {
1516                 // Re-enable interaction on main theme tweaker window.
1517                 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1518                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1519         }
1521 function themeTweakReset() {
1522         GWLog("themeTweakReset");
1523         setSelectedTheme(GW.currentTheme);
1524         GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1525         applyFilters(GW.currentFilters);
1526         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1527         setTextZoom(GW.currentTextZoom);
1529 function themeTweakSave() {
1530         GWLog("themeTweakSave");
1531         GW.currentTheme = (readCookie("theme") || "default");
1532         localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1533         localStorage.setItem("text-zoom", GW.currentTextZoom);
1536 function themeTweakerResetSettings() {
1537         GWLog("themeTweakerResetSettings");
1538         query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1539         query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1541 function themeTweakerSaveSettings() {
1542         GWLog("themeTweakerSaveSettings");
1543         localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1545 function updateThemeTweakerSampleText() {
1546         GWLog("updateThemeTweakerSampleText");
1547         let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1549         // This causes the sample text to take on the properties of the body text of a post.
1550         sampleText.removeClass("post-body");
1551         let bodyTextElement = query(".post-body") || query(".comment-body");
1552         sampleText.addClass("post-body");
1553         sampleText.style.color = bodyTextElement ? 
1554                                                                 getComputedStyle(bodyTextElement).color : 
1555                                                                         getComputedStyle(query("#content")).color;
1557         // Here we find out what is the actual background color that will be visible behind
1558         // the body text of posts, and set the sample text’s background to that.
1559         var backgroundElement = query("#content");
1560         let searchField = query("#nav-item-search input");
1561         if (!(getComputedStyle(searchField).backgroundColor == "" || 
1562                   getComputedStyle(searchField).backgroundColor == "rgba(0, 0, 0, 0)"))
1563                 backgroundElement = searchField;
1564         else while (getComputedStyle(backgroundElement).backgroundColor == "" || 
1565                                 getComputedStyle(backgroundElement).backgroundColor == "rgba(0, 0, 0, 0)")
1566                                 backgroundElement = backgroundElement.parentElement;
1567         sampleText.parentElement.style.backgroundColor = getComputedStyle(backgroundElement).backgroundColor;
1570 /*********************/
1571 /* PAGE QUICK-NAV UI */
1572 /*********************/
1574 function injectQuickNavUI() {
1575         GWLog("injectQuickNavUI");
1576         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1577         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
1578         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
1579         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
1580         ` + "</div>");
1583 /**********************/
1584 /* NEW COMMENT NAV UI */
1585 /**********************/
1587 function injectNewCommentNavUI(newCommentsCount) {
1588         GWLog("injectNewCommentNavUI");
1589         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
1590         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
1591         <span class='new-comments-count'></span>
1592         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
1593         + "</div>");
1595         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1596                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1597                         scrollToNewComment(/next/.test(event.target.className));
1598                         event.target.blur();
1599                 });
1600         });
1602         document.addEventListener("keyup", (event) => { 
1603                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1604                 if (event.key == ",") scrollToNewComment(false);
1605                 if (event.key == ".") scrollToNewComment(true)
1606         });
1608         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1609         + `<span>Since:</span>`
1610         + `<input type='text' class='hns-date'></input>`
1611         + "</div>");
1613         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1614                 let hnsDate = time_fromHuman(event.target.value);
1615                 let newCommentsCount = highlightCommentsSince(hnsDate);
1616                 updateNewCommentNavUI(newCommentsCount);
1617         }, false);
1619         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1620                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1621                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1622         });
1625 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1626 function time_fromHuman(string) {
1627         /* Convert a human-readable date into a JS timestamp */
1628         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1629                 string = string.replace(' ', 'T');  // revert nice spacing
1630                 string += ':00.000Z';  // complete ISO 8601 date
1631                 time = Date.parse(string);  // milliseconds since epoch
1633                 // browsers handle ISO 8601 without explicit timezone differently
1634                 // thus, we have to fix that by hand
1635                 time += (new Date()).getTimezoneOffset() * 60e3;
1636         } else {
1637                 string = string.replace(' at', '');
1638                 time = Date.parse(string);  // milliseconds since epoch
1639         }
1640         return time;
1643 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1644         GWLog("updateNewCommentNavUI");
1645         // Update the new comments count.
1646         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1647         newCommentsCountLabel.innerText = newCommentsCount;
1648         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1650         // Update the date picker field.
1651         if (hnsDate != -1) {
1652                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1653         }
1656 /***************************/
1657 /* TEXT SIZE ADJUSTMENT UI */
1658 /***************************/
1660 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1661         GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1662         var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1663         if (event.target.hasClass("decrease")) {
1664                 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1665         } else if (event.target.hasClass("increase")) {
1666                 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1667         } else {
1668                 zoomFactor = 1.0;
1669         }
1670         setTextZoom(zoomFactor);
1671         GW.currentTextZoom = `${zoomFactor}`;
1673         if (event.target.parentElement.id == "text-size-adjustment-ui") {
1674                 localStorage.setItem("text-zoom", GW.currentTextZoom);
1675         }
1678 function injectTextSizeAdjustmentUIReal() {
1679         GWLog("injectTextSizeAdjustmentUIReal");
1680         let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1681         + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
1682         + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1683         + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
1684         + "</div>");
1686         textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1687                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1688         });
1690         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1693 function injectTextSizeAdjustmentUI() {
1694         GWLog("injectTextSizeAdjustmentUI");
1695         if (query("#text-size-adjustment-ui") != null) return;
1696         if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1697         else document.addEventListener("DOMContentLoaded", () => {
1698                 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1699         }, {once: true});
1702 /********************************/
1703 /* COMMENTS VIEW MODE SELECTION */
1704 /********************************/
1706 function injectCommentsViewModeSelector() {
1707         GWLog("injectCommentsViewModeSelector");
1708         let commentsContainer = query("#comments");
1709         if (commentsContainer == null) return;
1711         let currentModeThreaded = (location.href.search("chrono=t") == -1);
1712         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1714         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1715         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
1716         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
1717         + "</div>");
1719 //      commentsViewModeSelector.queryAll("a").forEach(button => {
1720 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1721 //      });
1723         if (!currentModeThreaded) {
1724                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1725                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1726                         commentParentLink.addClass("inline-author");
1727                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1728                 });
1730                 queryAll(".comment-child-links a").forEach(commentChildLink => {
1731                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
1732                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
1733                 });
1735                 rectifyChronoModeCommentChildLinks();
1737                 commentsContainer.addClass("chrono");
1738         } else {
1739                 commentsContainer.addClass("threaded");
1740         }
1742         // Remove extraneous top-level comment thread in chrono mode.
1743         let topLevelCommentThread = query("#comments > .comment-thread");
1744         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
1747 // function commentsViewModeSelectorButtonClicked(event) {
1748 //      event.preventDefault();
1749 // 
1750 //      var newDocument;
1751 //      let request = new XMLHttpRequest();
1752 //      request.open("GET", event.target.href);
1753 //      request.onreadystatechange = () => {
1754 //              if (request.readyState != 4) return;
1755 //              newDocument = htmlToElement(request.response);
1756 // 
1757 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
1758 // 
1759 //              // Update the buttons.
1760 //              event.target.addClass("selected");
1761 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
1762 // 
1763 //              // Update the #comments container.
1764 //              let commentsContainer = query("#comments");
1765 //              commentsContainer.removeClass(classes.old);
1766 //              commentsContainer.addClass(classes.new);
1767 // 
1768 //              // Update the content.
1769 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
1770 //      };
1771 //      request.send();
1772 // }
1773 // 
1774 // function htmlToElement(html) {
1775 //     var template = document.createElement('template');
1776 //     template.innerHTML = html.trim();
1777 //     return template.content;
1778 // }
1780 function rectifyChronoModeCommentChildLinks() {
1781         GWLog("rectifyChronoModeCommentChildLinks");
1782         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
1783                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
1784                 let childLinks = commentChildLinksContainer.queryAll("a");
1785                 childLinks.forEach((link, index) => {
1786                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
1787                 });
1789                 // Sort by date.
1790                 let childLinksArray = Array.from(childLinks)
1791                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
1792                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
1793         });
1795 function childrenOfComment(commentID) {
1796         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
1797                 let commentParentLink = commentItem.query("a.comment-parent-link");
1798                 return ((commentParentLink||{}).hash == "#" + commentID);
1799         });
1802 /********************************/
1803 /* COMMENTS LIST MODE SELECTION */
1804 /********************************/
1806 function injectCommentsListModeSelector() {
1807         GWLog("injectCommentsListModeSelector");
1808         if (query("#content > .comment-thread") == null) return;
1810         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
1811         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
1812         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
1813         + "</div>";
1814         (query("#content.user-page .user-stats") || query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
1815         let commentsListModeSelector = query("#comments-list-mode-selector");
1817         commentsListModeSelector.queryAll("button").forEach(button => {
1818                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
1819                         event.target.parentElement.queryAll("button").forEach(button => {
1820                                 button.removeClass("selected");
1821                                 button.disabled = false;
1822                                 button.accessKey = '`';
1823                         });
1824                         localStorage.setItem("comments-list-mode", event.target.className);
1825                         event.target.addClass("selected");
1826                         event.target.disabled = true;
1827                         event.target.removeAttribute("accesskey");
1829                         if (event.target.hasClass("expanded")) {
1830                                 query("#content").removeClass("compact");
1831                         } else {
1832                                 query("#content").addClass("compact");
1833                         }
1834                 });
1835         });
1837         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
1838         if (savedMode == "compact")
1839                 query("#content").addClass("compact");
1840         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
1841         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
1842         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
1844         if (GW.isMobile) {
1845                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
1846                         commentParentLink.addActivateEvent(function (event) {
1847                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
1848                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
1849                         }, false);
1850                 });
1851         }
1854 /**********************/
1855 /* SITE NAV UI TOGGLE */
1856 /**********************/
1858 function injectSiteNavUIToggle() {
1859         GWLog("injectSiteNavUIToggle");
1860         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
1861         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
1862                 toggleSiteNavUI();
1863                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
1864         });
1866         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
1868 function removeSiteNavUIToggle() {
1869         GWLog("removeSiteNavUIToggle");
1870         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
1871                 element.removeClass("engaged");
1872         });
1873         removeElement("#site-nav-ui-toggle");
1875 function toggleSiteNavUI() {
1876         GWLog("toggleSiteNavUI");
1877         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
1878                 element.toggleClass("engaged");
1879                 element.removeClass("translucent-on-scroll");
1880         });
1883 /**********************/
1884 /* POST NAV UI TOGGLE */
1885 /**********************/
1887 function injectPostNavUIToggle() {
1888         GWLog("injectPostNavUIToggle");
1889         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
1890         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
1891                 togglePostNavUI();
1892                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
1893         });
1895         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
1897 function removePostNavUIToggle() {
1898         GWLog("removePostNavUIToggle");
1899         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
1900                 element.removeClass("engaged");
1901         });
1902         removeElement("#post-nav-ui-toggle");
1904 function togglePostNavUI() {
1905         GWLog("togglePostNavUI");
1906         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
1907                 element.toggleClass("engaged");
1908         });
1911 /*******************************/
1912 /* APPEARANCE ADJUST UI TOGGLE */
1913 /*******************************/
1915 function injectAppearanceAdjustUIToggle() {
1916         GWLog("injectAppearanceAdjustUIToggle");
1917         let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'>&#xf013;</button></div>");
1918         appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
1919                 toggleAppearanceAdjustUI();
1920                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
1921         });
1923         if (GW.isMobile) {
1924                 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
1925                 themeSelectorCloseButton.addClass("theme-selector-close-button");
1926                 themeSelectorCloseButton.innerHTML = "&#xf057;";
1927                 query("#theme-selector").appendChild(themeSelectorCloseButton);
1928                 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
1929         } else {
1930                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
1931         }
1933 function removeAppearanceAdjustUIToggle() {
1934         GWLog("removeAppearanceAdjustUIToggle");
1935         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
1936                 element.removeClass("engaged");
1937         });
1938         removeElement("#appearance-adjust-ui-toggle");
1940 function toggleAppearanceAdjustUI() {
1941         GWLog("toggleAppearanceAdjustUI");
1942         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
1943                 element.toggleClass("engaged");
1944         });
1947 /*****************************/
1948 /* MINIMIZED THREAD HANDLING */
1949 /*****************************/
1951 function expandAncestorsOf(comment) {
1952         GWLog("expandAncestorsOf");
1953         if (typeof comment == "string") {
1954                 comment = /(?:comment-)?(.+)/.exec(comment)[1];
1955                 comment = query("#comment-" + comment);
1956         }
1957         if (!comment) {
1958                 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
1959                 return;
1960         }
1962         // Expand collapsed comment threads.
1963         let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
1964         if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
1966         // Expand collapsed comments.
1967         let containingTopLevelCommentItem = comment.closest("#comments > ul > li");
1968         if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
1971 /**************************/
1972 /* WORD COUNT & READ TIME */
1973 /**************************/
1975 function toggleReadTimeOrWordCount(addWordCountClass) {
1976         GWLog("toggleReadTimeOrWordCount");
1977         queryAll(".post-meta .read-time").forEach(element => {
1978                 if (addWordCountClass) element.addClass("word-count");
1979                 else element.removeClass("word-count");
1981                 let titleParts = /(\S+)(.+)$/.exec(element.title);
1982                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
1983         });
1986 /**************************/
1987 /* PROMPT TO SAVE CHANGES */
1988 /**************************/
1990 function enableBeforeUnload() {
1991         window.onbeforeunload = function () { return true; };
1993 function disableBeforeUnload() {
1994         window.onbeforeunload = null;
1997 /***************************/
1998 /* ORIGINAL POSTER BADGING */
1999 /***************************/
2001 function markOriginalPosterComments() {
2002         GWLog("markOriginalPosterComments");
2003         let postAuthor = query(".post .author");
2004         if (postAuthor == null) return;
2006         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2007                 if (author.dataset.userid == postAuthor.dataset.userid ||
2008                         (author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2009                         author.addClass("original-poster");
2010                         author.title += "Original poster";
2011                 }
2012         });
2015 /********************************/
2016 /* EDIT POST PAGE SUBMIT BUTTON */
2017 /********************************/
2019 function setEditPostPageSubmitButtonText() {
2020         GWLog("setEditPostPageSubmitButtonText");
2021         if (!query("#content").hasClass("edit-post-page")) return;
2023         queryAll("input[type='radio'][name='section']").forEach(radio => {
2024                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2025                         updateEditPostPageSubmitButtonText();
2026                 });
2027         });
2029         updateEditPostPageSubmitButtonText();
2031 function updateEditPostPageSubmitButtonText() {
2032         GWLog("updateEditPostPageSubmitButtonText");
2033         let submitButton = query("input[type='submit']");
2034         if (query("input#drafts").checked == true)
2035                 submitButton.value = "Save Draft";
2036         else if (query(".posting-controls").hasClass("edit-existing-post"))
2037                 submitButton.value = "Save Post";
2038         else
2039                 submitButton.value = "Submit Post";
2042 /*****************/
2043 /* ANTI-KIBITZER */
2044 /*****************/
2046 function numToAlpha(n) {
2047         let ret = "";
2048         do {
2049                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2050                 n = Math.floor((n / 26) - 1);
2051         } while (n >= 0);
2052         return ret;
2055 function injectAntiKibitzer() {
2056         GWLog("injectAntiKibitzer");
2057         // Inject anti-kibitzer toggle controls.
2058         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>");
2059         antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2060                 if (query("#anti-kibitzer-toggle").hasClass("engaged") && 
2061                         !event.shiftKey &&
2062                         !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!)")) {
2063                         event.target.blur();
2064                         return;
2065                 }
2067                 toggleAntiKibitzerMode();
2068                 event.target.blur();
2069         });
2071         // Activate anti-kibitzer mode (if needed).
2072         if (localStorage.getItem("antikibitzer") == "true")
2073                 toggleAntiKibitzerMode();
2075         // Remove temporary CSS that hides the authors and karma values.
2076         removeElement("#antikibitzer-temp");
2079 function toggleAntiKibitzerMode() {
2080         GWLog("toggleAntiKibitzerMode");
2081         // This will be the URL of the user's own page, if logged in, or the URL of
2082         // the login page otherwise.
2083         let userTabTarget = query("#nav-item-login .nav-inner").href;
2084         let pageHeadingElement = query("h1.page-main-heading");
2086         let userCount = 0;
2087         let userFakeName = { };
2089         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2091         let postAuthor = query(".post-page .post-meta .author");
2092         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2094         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2095         if (antiKibitzerToggle.hasClass("engaged")) {
2096                 localStorage.setItem("antikibitzer", "false");
2098                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2099                 if (redirectTarget) {
2100                         window.location = redirectTarget;
2101                         return;
2102                 }
2104                 // Individual comment page title and header
2105                 if (query(".individual-thread-page")) {
2106                         let replacer = (node) => {
2107                                 if (!node) return;
2108                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
2109                         }
2110                         replacer(query("title:not(.fake-title)"));
2111                         replacer(query("#content > h1"));
2112                 }
2114                 // Author names/links.
2115                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2116                         author.textContent = author.dataset["trueName"];
2117                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2119                         author.removeClass("redacted");
2120                 });
2121                 // Post/comment karma values.
2122                 queryAll(".karma-value.redacted").forEach(karmaValue => {
2123                         karmaValue.innerHTML = karmaValue.dataset["trueValue"] + karmaValue.lastChild.outerHTML;
2124                         karmaValue.lastChild.textContent = (parseInt(karmaValue.dataset["trueValue"]) == 1) ? " point" : " points";
2126                         karmaValue.removeClass("redacted");
2127                 });
2128                 // Link post domains.
2129                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2130                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2132                         linkPostDomain.removeClass("redacted");
2133                 });
2135                 antiKibitzerToggle.removeClass("engaged");
2136         } else {
2137                 localStorage.setItem("antikibitzer", "true");
2139                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2140                 if (redirectTarget) {
2141                         window.location = redirectTarget;
2142                         return;
2143                 }
2145                 // Individual comment page title and header
2146                 if (query(".individual-thread-page")) {
2147                         let replacer = (node) => {
2148                                 if (!node) return;
2149                                 node.dataset["trueContent"] = node.firstChild.wholeText;
2150                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2151                                 node.firstChild.replaceWith(newText);
2152                         }
2153                         replacer(query("title:not(.fake-title)"));
2154                         replacer(query("#content > h1"));
2155                 }
2157                 removeElement("title.fake-title");
2159                 // Author names/links.
2160                 queryAll(".author, .inline-author").forEach(author => {
2161                         // Skip own posts/comments.
2162                         if (author.hasClass("own-user-author"))
2163                                 return;
2165                         let userid = author.dataset["userid"] || query(`${author.hash} .author`).dataset["userid"];
2167                         author.dataset["trueName"] = author.textContent;
2168                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2170                         if (/\/user/.test(author.href)) {
2171                                 author.dataset["trueLink"] = author.pathname;
2172                                 author.href = "/user?id=" + author.dataset["userid"];
2173                         }
2175                         author.addClass("redacted");
2176                 });
2177                 // Post/comment karma values.
2178                 queryAll(".karma-value").forEach(karmaValue => {
2179                         // Skip own posts/comments.
2180                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2181                                 return;
2183                         karmaValue.dataset["trueValue"] = karmaValue.firstChild.textContent;
2184                         karmaValue.innerHTML = "##" + karmaValue.lastChild.outerHTML;
2185                         karmaValue.lastChild.textContent = " points";
2187                         karmaValue.addClass("redacted");
2188                 });
2189                 // Link post domains.
2190                 queryAll(".link-post-domain").forEach(linkPostDomain => {
2191                         // Skip own posts/comments.
2192                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2193                                 return;
2195                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2196                         linkPostDomain.textContent = "redacted.domain.tld";
2198                         linkPostDomain.addClass("redacted");
2199                 });
2201                 antiKibitzerToggle.addClass("engaged");
2202         }
2205 /*******************************/
2206 /* COMMENT SORT MODE SELECTION */
2207 /*******************************/
2209 var CommentSortMode = Object.freeze({
2210         TOP:            "top",
2211         NEW:            "new",
2212         OLD:            "old",
2213         HOT:            "hot"
2215 function sortComments(mode) {
2216         GWLog("sortComments");
2217         let commentsContainer = query("#comments");
2219         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2220         commentsContainer.addClass("sorting");
2222         GW.commentValues = { };
2223         let clonedCommentsContainer = commentsContainer.cloneNode(true);
2224         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2225                 var comparator;
2226                 switch (mode) {
2227                 case CommentSortMode.NEW:
2228                         comparator = (a,b) => commentDate(b) - commentDate(a);
2229                         break;
2230                 case CommentSortMode.OLD:
2231                         comparator = (a,b) => commentDate(a) - commentDate(b);
2232                         break;
2233                 case CommentSortMode.HOT:
2234                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2235                         break;
2236                 case CommentSortMode.TOP:
2237                 default:
2238                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2239                         break;
2240                 }
2241                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2242         });
2243         removeElement(commentsContainer.lastChild);
2244         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2245         GW.commentValues = { };
2247         if (loggedInUserId) {
2248                 // Re-activate vote buttons.
2249                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2250                         voteButton.addActivateEvent(voteButtonClicked);
2251                 });
2253                 // Re-activate comment action buttons.
2254                 commentsContainer.queryAll(".action-button").forEach(button => {
2255                         button.addActivateEvent(GW.commentActionButtonClicked);
2256                 });
2257         }
2259         // Re-activate comment-minimize buttons.
2260         queryAll(".comment-minimize-button").forEach(button => {
2261                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2262         });
2264         // Re-add comment parent popups.
2265         addCommentParentPopups();
2266         
2267         // Redo new-comments highlighting.
2268         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2270         requestAnimationFrame(() => {
2271                 commentsContainer.removeClass("sorting");
2272                 commentsContainer.addClass("sorted-" + mode);
2273         });
2275 function commentKarmaValue(commentOrSelector) {
2276         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2277         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2279 function commentDate(commentOrSelector) {
2280         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2281         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2283 function commentVoteCount(commentOrSelector) {
2284         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2285         return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2288 function injectCommentsSortModeSelector() {
2289         GWLog("injectCommentsSortModeSelector");
2290         let topCommentThread = query("#comments > .comment-thread");
2291         if (topCommentThread == null) return;
2293         // Do not show sort mode selector if there is no branching in comment tree.
2294         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2296         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
2297                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
2298                 "</div>";
2299         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2300         let commentsSortModeSelector = query("#comments-sort-mode-selector");
2302         commentsSortModeSelector.queryAll("button").forEach(button => {
2303                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2304                         event.target.parentElement.queryAll("button").forEach(button => {
2305                                 button.removeClass("selected");
2306                                 button.disabled = false;
2307                         });
2308                         event.target.addClass("selected");
2309                         event.target.disabled = true;
2311                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2312                         setCommentsSortModeSelectButtonsAccesskey();
2313                 });
2314         });
2316         // TODO: Make this actually get the current sort mode (if that's saved).
2317         // TODO: Also change the condition here to properly get chrono/threaded mode,
2318         // when that is properly done with cookies.
2319         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2320         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2321         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2322         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2323         setCommentsSortModeSelectButtonsAccesskey();
2326 function setCommentsSortModeSelectButtonsAccesskey() {
2327         GWLog("setCommentsSortModeSelectButtonsAccesskey");
2328         queryAll("#comments-sort-mode-selector button").forEach(button => {
2329                 button.removeAttribute("accesskey");
2330                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2331         });
2332         let selectedButton = query("#comments-sort-mode-selector button.selected");
2333         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2334         nextButtonInCycle.accessKey = "z";
2335         nextButtonInCycle.title += " [z]";
2338 /*************************/
2339 /* COMMENT PARENT POPUPS */
2340 /*************************/
2342 function addCommentParentPopups() {
2343         GWLog("addCommentParentPopups");
2344         if (!query("#content").hasClass("comment-thread-page")) return;
2346         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2347                 commentParentLink.addEventListener("mouseover", GW.commentParentLinkMouseOver = (event) => {
2348                         let parentID = commentParentLink.getAttribute("href");
2349                         var parent, popup;
2350                         if (!(parent = (query(parentID)||{}).firstChild)) return;
2351                         var highlightClassName;
2352                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2353                                 parentHighlightClassName = "comment-item-highlight-faint";
2354                                 popup = parent.cloneNode(true);
2355                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2356                                 commentParentLink.addEventListener("mouseout", (event) => {
2357                                         removeElement(popup);
2358                                 }, {once: true});
2359                                 commentParentLink.closest("#comments > .comment-thread").appendChild(popup);
2360                         } else {
2361                                 parentHighlightClassName = "comment-item-highlight";
2362                         }
2363                         parent.parentNode.addClass(parentHighlightClassName);
2364                         commentParentLink.addEventListener("mouseout", (event) => {
2365                                 parent.parentNode.removeClass(parentHighlightClassName);
2366                         }, {once: true});
2367                 });
2368         });
2370         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2371         GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2372                 "#content #comments .comment-thread"
2373         ];
2374         applyFilters(GW.currentFilters);
2377 /***************/
2378 /* IMAGE FOCUS */
2379 /***************/
2381 function imageFocusSetup(imagesOverlayOnly = false) {
2382         GWLog("imageFocusSetup");
2383         // Create event listener for clicking on images to focus them.
2384         GW.imageClickedToFocus = (event) => {
2385                 focusImage(event.target);
2387                 if (event.target.closest("#images-overlay")) {
2388                         query("#image-focus-overlay .image-number").textContent = (getIndexOfFocusedImage() + 1);
2390                         // Set timer to hide the image focus UI.
2391                         resetImageFocusHideUITimer(true);
2392                 }
2393         };
2394         // Add the listener to each image in the overlay (i.e., those in the post).
2395         queryAll("#images-overlay img").forEach(image => {
2396                 image.addActivateEvent(GW.imageClickedToFocus);
2397         });
2398         // Accesskey-L starts the slideshow.
2399         (query("#images-overlay img")||{}).accessKey = 'l';
2400         // Count how many images there are in the post, and set the "… of X" label to that.
2401         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll("#images-overlay img").length;
2402         if (imagesOverlayOnly) return;
2403         // Add the listener to all other content images (including those in comments).
2404         queryAll("#content img").forEach(image => {
2405                 image.addActivateEvent(GW.imageClickedToFocus);
2406         });
2408         // Create the image focus overlay.
2409         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
2410         `<div class='help-overlay'>
2411          <p><strong>Arrow keys:</strong> Next/previous image</p>
2412          <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2413          <p><strong>Space bar:</strong> Reset image size & position</p>
2414          <p><strong>Scroll</strong> to zoom in/out</p>
2415          <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2416          </div>` + 
2417         `<div class='image-number'></div>` + 
2418         `<div class='slideshow-buttons'>
2419          <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
2420          <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
2421          </div>` + 
2422         "</div>");
2423         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2425         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2426                 button.addActivateEvent(GW.imageFocusSlideshowButtonClicked = (event) => {
2427                         focusNextImage(event.target.hasClass("next"));
2428                         event.target.blur();
2429                 });
2430         });
2432         // On orientation change, reset the size & position.
2433         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2434                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2435         }
2437         // UI starts out hidden.
2438         hideImageFocusUI();
2441 function focusImage(imageToFocus) {
2442         GWLog("focusImage");
2443         // Clear 'last-focused' class of last focused image.
2444         let lastFocusedImage = query("img.last-focused");
2445         if (lastFocusedImage) {
2446                 lastFocusedImage.removeClass("last-focused");
2447                 lastFocusedImage.removeAttribute("accesskey");
2448         }
2450         // Create the focused version of the image.
2451         imageToFocus.addClass("focused");
2452         let imageFocusOverlay = query("#image-focus-overlay");
2453         let clonedImage = imageToFocus.cloneNode(true);
2454         clonedImage.style = "";
2455         clonedImage.removeAttribute("width");
2456         clonedImage.removeAttribute("height");
2457         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2458         imageFocusOverlay.appendChild(clonedImage);
2459         imageFocusOverlay.addClass("engaged");
2461         // Set image to default size and position.
2462         resetFocusedImagePosition();
2464         // Blur everything else.
2465         queryAll("#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay").forEach(element => {
2466                 element.addClass("blurred");
2467         });
2469         // Add listener to zoom image with scroll wheel.
2470         window.addEventListener("wheel", GW.imageFocusScroll = (event) => {
2471                 event.preventDefault();
2473                 let image = query("#image-focus-overlay img");
2475                 // Remove the filter.
2476                 image.savedFilter = image.style.filter;
2477                 image.style.filter = 'none';
2479                 // Locate point under cursor.
2480                 let imageBoundingBox = image.getBoundingClientRect();
2482                 // Calculate resize factor.
2483                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2484                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2485                                                 1;
2487                 // Resize.
2488                 image.style.width = (event.deltaY < 0 ?
2489                                                         (image.clientWidth * factor) :
2490                                                         (image.clientWidth / factor))
2491                                                         + "px";
2492                 image.style.height = "";
2494                 // Designate zoom origin.
2495                 var zoomOrigin;
2496                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2497                 // the cursor is over the image.
2498                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2499                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2500                                                                 (imageBoundingBox.left <= event.clientX &&
2501                                                                  event.clientX <= imageBoundingBox.right && 
2502                                                                  imageBoundingBox.top <= event.clientY &&
2503                                                                  event.clientY <= imageBoundingBox.bottom);
2504                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
2505                 // zooming IN, zoom from image center.
2506                 let zoomingFromWindowCenter = event.deltaY > 0;
2507                 if (zoomingFromCursor)
2508                         zoomOrigin = { x: event.clientX, 
2509                                                    y: event.clientY };
2510                 else if (zoomingFromWindowCenter)
2511                         zoomOrigin = { x: window.innerWidth / 2, 
2512                                                    y: window.innerHeight / 2 };
2513                 else
2514                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
2515                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
2517                 // Calculate offset from zoom origin.
2518                 let offsetOfImageFromZoomOrigin = {
2519                         x: imageBoundingBox.x - zoomOrigin.x,
2520                         y: imageBoundingBox.y - zoomOrigin.y
2521                 }
2522                 // Calculate delta from centered zoom.
2523                 let deltaFromCenteredZoom = {
2524                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
2525                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
2526                 }
2527                 // Adjust image position appropriately.
2528                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
2529                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
2530                 // Gradually re-center image, if it's smaller than the window.
2531                 if (!imageSizeExceedsWindowBounds) {
2532                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
2533                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
2534                         let windowCenter = { x: window.innerWidth / 2,
2535                                                                  y: window.innerHeight / 2 }
2536                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
2537                                                                                   y: windowCenter.y - imageCenter.y }
2538                         // Divide the offset by 10 because we're nudging the image toward center,
2539                         // not jumping it there.
2540                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
2541                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
2542                 }
2544                 // Put the filter back.
2545                 image.style.filter = image.savedFilter;
2547                 // Set the cursor appropriately.
2548                 setFocusedImageCursor();
2549         });
2550         window.addEventListener("MozMousePixelScroll", GW.imageFocusOldFirefoxCompatibilityScrollEventFired = (event) => {
2551                 event.preventDefault();
2552         });
2554         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
2555         window.addEventListener("mouseup", GW.imageFocusMouseUp = (event) => {
2556                 window.onmousemove = '';
2558                 // We only want to do anything on left-clicks.
2559                 if (event.button != 0) return;
2561                 if (event.target.hasClass("slideshow-button")) {
2562                         resetImageFocusHideUITimer(false);
2563                         return;
2564                 }
2566                 let focusedImage = query("#image-focus-overlay img");
2568                 if (event.target != focusedImage) {
2569                         unfocusImageOverlay();
2570                         return;
2571                 }
2573                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
2574                         // Put the filter back.
2575                         focusedImage.style.filter = focusedImage.savedFilter;
2576                 } else {
2577                         unfocusImageOverlay();
2578                 }
2579         });
2580         window.addEventListener("mousedown", GW.imageFocusMouseDown = (event) => {
2581                 event.preventDefault();
2583                 let focusedImage = query("#image-focus-overlay img");
2584                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
2585                         let mouseCoordX = event.clientX;
2586                         let mouseCoordY = event.clientY;
2588                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
2589                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
2591                         // Save the filter.
2592                         focusedImage.savedFilter = focusedImage.style.filter;
2594                         window.onmousemove = (event) => {
2595                                 // Remove the filter.
2596                                 focusedImage.style.filter = "none";
2597                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
2598                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
2599                         };
2600                         return false;
2601                 }
2602         });
2604         // Double-click unfocuses, always.
2605         window.addEventListener('dblclick', GW.imageFocusDoubleClick = (event) => {
2606                 if (event.target.hasClass("slideshow-button")) return;
2608                 unfocusImageOverlay();
2609         });
2611         // Escape key unfocuses, spacebar resets.
2612         document.addEventListener("keyup", GW.imageFocusKeyUp = (event) => {
2613                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
2614                 if (!allowedKeys.contains(event.key) || 
2615                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
2617                 event.preventDefault();
2619                 switch (event.key) {
2620                 case "Escape": 
2621                 case "Esc":
2622                         unfocusImageOverlay();
2623                         break;
2624                 case " ":
2625                 case "Spacebar":
2626                         resetFocusedImagePosition();
2627                         break;
2628                 case "ArrowDown":
2629                 case "Down":
2630                 case "ArrowRight":
2631                 case "Right":
2632                         if (query("#images-overlay img.focused")) focusNextImage(true);
2633                         break;
2634                 case "ArrowUp":
2635                 case "Up":
2636                 case "ArrowLeft":
2637                 case "Left":
2638                         if (query("#images-overlay img.focused")) focusNextImage(false);
2639                         break;
2640                 }
2641         });
2643         // Prevent spacebar or arrow keys from scrolling page when image focused.
2644         document.addEventListener("keydown", GW.imageFocusKeyDown = (event) => {
2645                 let disabledKeys = [ " ", "Spacebar", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
2646                 if (disabledKeys.contains(event.key))
2647                         event.preventDefault();
2648         });
2650         if (imageToFocus.closest("#images-overlay")) {
2651                 // Set state of next/previous buttons.
2652                 let images = queryAll("#images-overlay img");
2653                 var indexOfFocusedImage = getIndexOfFocusedImage();
2654                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
2655                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
2657                 // Moving mouse unhides image focus UI.
2658                 window.addEventListener("mousemove", GW.imageFocusMouseMoved = (event) => {
2659                         let restartTimer = (event.target.tagName == "IMG" || event.target.id == "image-focus-overlay");
2660                         resetImageFocusHideUITimer(restartTimer);
2661                 });
2663                 // Replace the hash.
2664                 history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
2665         }
2668 function resetFocusedImagePosition() {
2669         GWLog("resetFocusedImagePosition");
2670         let focusedImage = query("#image-focus-overlay img");
2671         if (!focusedImage) return;
2673         // Reset modifications to size.
2674         focusedImage.style.width = "";
2675         focusedImage.style.height = "";
2677         // Make sure that initially, the image fits into the viewport.
2678         let shrinkRatio = 0.975;
2679         focusedImage.style.width = Math.min(focusedImage.clientWidth, window.innerWidth * shrinkRatio) + 'px';
2680         let maxImageHeight = window.innerHeight * shrinkRatio;
2681         if (focusedImage.clientHeight > maxImageHeight) {
2682                 focusedImage.style.height = maxImageHeight + 'px';
2683                 focusedImage.style.width = "";
2684         }
2686         // Remove modifications to position.
2687         focusedImage.style.left = "";
2688         focusedImage.style.top = "";
2690         // Set the cursor appropriately.
2691         setFocusedImageCursor();
2693 function setFocusedImageCursor() {
2694         let focusedImage = query("#image-focus-overlay img");
2695         if (!focusedImage) return;
2696         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
2697                                                                 'move' : '';
2700 function unfocusImageOverlay() {
2701         GWLog("unfocusImageOverlay");
2702         // Set accesskey of currently focused image (if it's in the images overlay).
2703         let currentlyFocusedImage = query("#images-overlay img.focused");
2704         if (currentlyFocusedImage) {
2705                 currentlyFocusedImage.addClass("last-focused");
2706                 currentlyFocusedImage.accessKey = 'l';
2707         }
2709         // Remove focused image and hide overlay.
2710         let imageFocusOverlay = query("#image-focus-overlay");
2711         imageFocusOverlay.removeClass("engaged");
2712         removeElement(imageFocusOverlay.query("img"));
2714         // Un-blur content/etc.
2715         queryAll("#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay").forEach(element => {
2716                 element.removeClass("blurred");
2717         });
2719         // Unset "focused" class of focused image.
2720         queryAll("#content img.focused, #images-overlay img.focused").forEach(image => {
2721                 image.removeClass("focused");
2722         });
2724         // Remove event listeners.
2725         window.removeEventListener("wheel", GW.imageFocusScroll);
2726         window.removeEventListener("MozMousePixelScroll", GW.imageFocusOldFirefoxCompatibilityScrollEventFired);
2727         window.removeEventListener("dblclick", GW.imageFocusDoubleClick);
2728         document.removeEventListener("keyup", GW.imageFocusKeyUp);
2729         document.removeEventListener("keydown", GW.imageFocusKeyDown);
2730         window.removeEventListener("mousemove", GW.imageFocusMouseMoved);
2731         window.removeEventListener("mousedown", GW.imageFocusMouseDown);
2733         // Reset the hash, if needed.
2734         if (location.hash.hasPrefix("#if_slide_"))
2735                 history.replaceState(null, null, "#");
2738 function getIndexOfFocusedImage() {
2739         let images = queryAll("#images-overlay img");
2740         var indexOfFocusedImage = -1;
2741         for (i = 0; i < images.length; i++) {
2742                 if (images[i].hasClass("focused")) {
2743                         indexOfFocusedImage = i;
2744                         break;
2745                 }
2746         }
2747         return indexOfFocusedImage;
2750 function focusNextImage(next = true) {
2751         GWLog("focusNextImage");
2752         let images = queryAll("#images-overlay img");
2753         var indexOfFocusedImage = getIndexOfFocusedImage();
2755         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
2757         // Remove existing image.
2758         removeElement("#image-focus-overlay img");
2759         // Unset "focused" class of just-removed image.
2760         queryAll("#content img.focused, #images-overlay img.focused").forEach(image => {
2761                 image.removeClass("focused");
2762         });
2764         // Create the focused version of the image.
2765         images[indexOfFocusedImage].addClass("focused");
2766         let imageFocusOverlay = query("#image-focus-overlay");
2767         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
2768         clonedImage.style = "";
2769         clonedImage.removeAttribute("width");
2770         clonedImage.removeAttribute("height");
2771         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
2772         imageFocusOverlay.appendChild(clonedImage);
2773         imageFocusOverlay.addClass("engaged");
2774         // Set image to default size and position.
2775         resetFocusedImagePosition();
2776         // Set state of next/previous buttons.
2777         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
2778         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
2779         // Set the image number display.
2780         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
2781         // Replace the hash.
2782         history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
2785 function hideImageFocusUI() {
2786         let imageFocusOverlay = query("#image-focus-overlay");
2787         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number").forEach(element => {
2788                 element.addClass("hidden");
2789         });
2792 function unhideImageFocusUI() {
2793         let imageFocusOverlay = query("#image-focus-overlay");
2794         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number").forEach(element => {
2795                 element.removeClass("hidden");
2796         });
2799 function resetImageFocusHideUITimer(restart) {
2800         if (GW.isMobile) return;
2802         clearTimeout(GW.imageFocusHideUITimer);
2803         unhideImageFocusUI();
2804         if (restart) GW.imageFocusHideUITimer = setTimeout(hideImageFocusUI, 1500);
2807 /*********************/
2808 /* MORE MISC HELPERS */
2809 /*********************/
2811 function getQueryVariable(variable) {
2812         var query = window.location.search.substring(1);
2813         var vars = query.split("&");
2814         for (var i = 0; i < vars.length; i++) {
2815                 var pair = vars[i].split("=");
2816                 if (pair[0] == variable)
2817                         return pair[1];
2818         }
2820         return false;
2823 function addUIElement(element_html) {
2824         var ui_elements_container = query("#ui-elements-container");
2825         if (!ui_elements_container) {
2826                 ui_elements_container = document.createElement("div");
2827                 ui_elements_container.id = "ui-elements-container";
2828                 query("body").appendChild(ui_elements_container);
2829         }
2831         ui_elements_container.insertAdjacentHTML("beforeend", element_html);
2832         return ui_elements_container.lastElementChild;
2835 function removeElement(elementOrSelector, ancestor = document) {
2836         if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
2837         if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
2840 String.prototype.hasPrefix = function (prefix) {
2841         return (this.lastIndexOf(prefix, 0) === 0);
2844 /*******************************/
2845 /* HTML TO MARKDOWN CONVERSION */
2846 /*******************************/
2848 function MarkdownFromHTML(text) {
2849         GWLog("MarkdownFromHTML");
2850         // Wrapper tags, paragraphs, bold, italic, code blocks.
2851         text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
2852                 switch(tag) {
2853                 case "html":
2854                 case "/html":
2855                 case "head":
2856                 case "/head":
2857                 case "body":
2858                 case "/body":
2859                 case "p":
2860                         return "";
2861                 case "/p":
2862                         return "\n";
2863                 case "strong":
2864                 case "/strong":
2865                         return "**";
2866                 case "em":
2867                 case "/em":
2868                         return "*";
2869                 case "code":
2870                 case "/code":
2871                         return "`";
2872                 default:
2873                         return match;
2874                 }
2875         });
2877         // Unordered lists.
2878         text = text.replace(/<ul>((?:.|\n)+?)<\/ul>/g, (match, listItems, offset, string) => {
2879                 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
2880                         return "* " + listItem + "\n";
2881                 });
2882         });
2884         // Headings.
2885         text = text.replace(/<h([1-9])>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
2886                 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
2887         });
2889         // Blockquotes.
2890         text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
2891                 return "> " + quotedText.trim().split("\n").join("\n> ") + "\n";
2892         });
2894         // Links.
2895         text = text.replace(/<a href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
2896                 return `[${text}](${href})`;
2897         }).trim();
2899         // Horizontal rules.
2900         text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
2901                 return "\n---\n";
2902         });
2904         return text;
2907 /******************/
2908 /* INITIALIZATION */
2909 /******************/
2911 registerInitializer('earlyInitialize', true, () => query("#content") != null, function () {
2912         GWLog("INITIALIZER earlyInitialize");
2913         // Check to see whether we're on a mobile device (which we define as a touchscreen)
2914 //      GW.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
2915         GW.isMobile = ('ontouchstart' in document.documentElement);
2917         // Backward compatibility
2918         let storedTheme = localStorage.getItem('selected-theme');
2919         if (storedTheme) {
2920                 setTheme(storedTheme);
2921                 localStorage.removeItem('selected-theme');
2922         }
2924         // Animate width & theme adjustments?
2925         GW.adjustmentTransitions = false;
2927         // Add the content width selector.
2928         injectContentWidthSelector();
2929         // Add the text size adjustment widget.
2930         injectTextSizeAdjustmentUI();
2931         // Add the theme selector.
2932         injectThemeSelector();
2933         // Add the theme tweaker.
2934         injectThemeTweaker();
2935         // Add the quick-nav UI.
2936         injectQuickNavUI();
2938         setTimeout(() => { updateInbox(); }, 0);
2941 registerInitializer('initialize', false, () => document.readyState != 'loading', function () {
2942         GWLog("INITIALIZER initialize");
2943         forceInitializer('earlyInitialize');
2945         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
2946         // "link without nav bars".
2947         if (getQueryVariable("comments") == "false")
2948                 query("#content").addClass("no-comments");
2949         if (getQueryVariable("hide-nav-bars") == "true") {
2950                 query("#content").addClass("no-nav-bars");
2951                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
2952         }
2954         // If the page cannot have comments, remove the accesskey from the #comments
2955         // quick-nav button; and if the page can have comments, but does not, simply 
2956         // disable the #comments quick nav button.
2957         let content = query("#content");
2958         if (content.query("#comments") == null) {
2959                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
2960         } else if (content.query("#comments .comment-thread") == null) {
2961                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
2962         }
2964         // Links to comments generated by LW have a hash that consists of just the 
2965         // comment ID, which can start with a number. Prefix it with "comment-".
2966         if (location.hash.length == 18) {
2967                 location.hash = "#comment-" + location.hash.substring(1);
2968         }
2970         // If the viewport is wide enough to fit the desktop-size content column,
2971         // use a long date format; otherwise, a short one.
2972         let useLongDate = window.innerWidth > 900;
2973         let dtf = new Intl.DateTimeFormat([], 
2974                 ( useLongDate ? 
2975                         { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
2976                                 : { month: 'numeric', day: 'numeric', year: '2-digit', hour: 'numeric', minute: 'numeric' } ));
2977         queryAll(".date").forEach(date => {
2978                 let d = date.dataset.jsDate;
2979                 if (d) { date.innerHTML = dtf.format(new Date(+ d)); }
2980         });
2982         GW.needHashRealignment = false;
2984         // On edit post pages and conversation pages, add GUIEdit buttons to the 
2985         // textarea, expand it, and markdownify the existing text, if any (this is
2986         // needed if a post was last edited on LW).
2987         queryAll(".with-markdown-editor textarea").forEach(textarea => {
2988                 textarea.addTextareaFeatures();
2989                 expandTextarea(textarea);
2990                 textarea.value = MarkdownFromHTML(textarea.value);
2991         });
2992         // Focus the textarea.
2993         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
2995         // If this is a post page...
2996         let postMeta = query(".post .post-meta");
2997         if (postMeta) {
2998                 // Add "qualified hyperlinking" toolbar.
2999                 let postPermalink = location.protocol + "//" + location.host + location.pathname;
3000                 postMeta.insertAdjacentHTML("beforeend", "<div class='qualified-linking'>" + 
3001                 "<input type='checkbox' tabindex='-1' id='qualified-linking-toolbar-toggle-checkbox'><label for='qualified-linking-toolbar-toggle-checkbox'><span>&#xf141;</span></label>" + 
3002                 "<div class='qualified-linking-toolbar'>" +
3003                 `<a href='${postPermalink}'>Post permalink</a>` +
3004                 `<a href='${postPermalink}?comments=false'>Link without comments</a>` +
3005                 `<a href='${postPermalink}?hide-nav-bars=true'>Link without top nav bars</a>` +
3006                 `<a href='${postPermalink}?comments=false&hide-nav-bars=true'>Link without comments or top nav bars</a>` +
3007                 "</div>" +
3008                 "</div>");
3010                 // Replicate .post-meta at bottom of post.
3011                 let clonedPostMeta = postMeta.cloneNode(true);
3012                 postMeta.addClass("top-post-meta");
3013                 clonedPostMeta.addClass("bottom-post-meta");
3014                 clonedPostMeta.query("input[type='checkbox']").id += "-bottom";
3015                 clonedPostMeta.query("label").htmlFor += "-bottom";
3016                 query(".post").appendChild(clonedPostMeta);
3017         }
3019         // If client is logged in...
3020         if (loggedInUserId) {
3021                 // Add upvote/downvote buttons.
3022                 if (typeof postVote != 'undefined') {
3023                         queryAll(".post-meta .karma-value").forEach(karmaValue => {
3024                                 addVoteButtons(karmaValue, postVote, 'Posts');
3025                                 karmaValue.parentElement.addClass("active-controls");
3026                         });
3027                 }
3028                 if (typeof commentVotes != 'undefined') {
3029                         queryAll(".comment-meta .karma-value").forEach(karmaValue => {
3030                                 let commentID = karmaValue.getCommentId();
3031                                 addVoteButtons(karmaValue, commentVotes[commentID], 'Comments');
3032                                 karmaValue.parentElement.addClass("active-controls");
3033                         });
3034                 }
3036                 // Color the upvote/downvote buttons with an embedded style sheet.
3037                 query("head").insertAdjacentHTML("beforeend","<style id='vote-buttons'>" + 
3038                 `.upvote:hover,
3039                 .upvote:focus,
3040                 .upvote.selected {
3041                         color: #00d800;
3042                 }
3043                 .downvote:hover,
3044                 .downvote:focus,
3045                 .downvote.selected {
3046                         color: #eb4c2a;
3047                 }` +
3048                 "</style>");
3050                 // Activate the vote buttons.
3051                 queryAll("button.vote").forEach(voteButton => {
3052                         voteButton.addActivateEvent(voteButtonClicked);
3053                 });
3055                 // If we're on a comment thread page...
3056                 var commentsContainer = query("#comments");
3057                 if (commentsContainer) {
3058                         // Add reply buttons.
3059                         commentsContainer.queryAll(".comment").forEach(comment => {
3060                                 comment.insertAdjacentHTML("afterend", "<div class='comment-controls posting-controls'></div>");
3061                                 comment.parentElement.query(".comment-controls").constructCommentControls();
3062                         });
3064                         // Add top-level new comment form.
3065                         if (!query(".individual-thread-page")) {
3066                                 commentsContainer.insertAdjacentHTML("afterbegin", "<div class='comment-controls posting-controls'></div>");
3067                                 commentsContainer.query(".comment-controls").constructCommentControls();
3068                         }
3069                 }
3071                 // Hash realignment is needed because adding the above elements almost
3072                 // certainly caused the page to reflow, and now client is no longer
3073                 // scrolled to the place indicated by the hash.
3074                 GW.needHashRealignment = true;
3075         }
3077         // Clean up ToC
3078         queryAll(".contents-list li a").forEach(tocLink => {
3079                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
3080                 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
3081                 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, '');
3082                 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
3083         });
3085         // If we're on a comment thread page...
3086         if (query("#comments") != null) {
3087                 // Add comment-minimize buttons to every comment.
3088                 queryAll(".comment-meta").forEach(commentMeta => {
3089                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3090                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
3091                 });
3092                 if (!query("#content").hasClass("individual-thread-page")) {
3093                         // Format and activate comment-minimize buttons.
3094                         queryAll(".comment-minimize-button").forEach(button => {
3095                                 button.closest(".comment-item").setCommentThreadMaximized(false);
3096                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3097                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
3098                                 });
3099                         });
3100                 }
3101         }
3102         if (getQueryVariable("chrono") == "t") {
3103                 query("head").insertAdjacentHTML("beforeend", "<style>.comment-minimize-button::after { display: none; }</style>");
3104         }
3105         let urlParts = document.URL.split('#comment-');
3106         if (urlParts.length > 1) {
3107                 expandAncestorsOf(urlParts[1]);
3108                 GW.needHashRealignment = true;
3109         }
3111         // Add error message (as placeholder) if user tries to click Search with
3112         // an empty search field.
3113         query("#nav-item-search form").addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3114                 let searchField = event.target.query("input");
3115                 if (searchField.value == "") {
3116                         event.preventDefault();
3117                         event.target.blur();
3118                         searchField.placeholder = "Enter a search string!";
3119                         searchField.focus();
3120                 }
3121         });
3122         // Remove the placeholder / error on any input.
3123         query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3124                 event.target.placeholder = "";
3125         });
3127         // Prevent conflict between various single-hotkey listeners and text fields
3128         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3129                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3130         });
3132         if (content.hasClass("post-page")) {
3133                 // Read and update last-visited-date.
3134                 let lastVisitedDate = getLastVisitedDate();
3135                 setLastVisitedDate(Date.now());
3137                 // Save the number of comments this post has when it's visited.
3138                 updateSavedCommentCount();
3140                 if (content.query("#comments .comment-thread") != null) {
3141                         // Add the new comments count & navigator.
3142                         injectNewCommentNavUI();
3144                         // Get the highlight-new-since date (as specified by URL parameter, if 
3145                         // present, or otherwise the date of the last visit).
3146                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3148                         // Highlight new comments since the specified date.                      
3149                         let newCommentsCount = highlightCommentsSince(hnsDate);
3151                         // Update the comment count display.
3152                         updateNewCommentNavUI(newCommentsCount, hnsDate);
3153                 }
3154         } else {
3155                 // On listing pages, make comment counts more informative.
3156                 badgePostsWithNewComments();
3157         }
3159         // Add the comments list mode selector widget (expanded vs. compact).
3160         injectCommentsListModeSelector();
3162         // Add the comments view selector widget (threaded vs. chrono).
3163 //      injectCommentsViewModeSelector();
3165         // Add the comments sort mode selector (top, hot, new, old).
3166         injectCommentsSortModeSelector();
3168         // Add the toggle for the post nav UI elements on mobile.
3169         if (GW.isMobile) injectPostNavUIToggle();
3171         // Add the toggle for the appearance adjustment UI elements on mobile.
3172         if (GW.isMobile) injectAppearanceAdjustUIToggle();
3174         // Add the antikibitzer.
3175         injectAntiKibitzer();
3177         // Add comment parent popups.
3178         addCommentParentPopups();
3180         // Mark original poster's comments with a special class.
3181         markOriginalPosterComments();
3182         
3183         // On the All view, mark posts with non-positive karma with a special class.
3184         if (query("#content").hasClass("all-index-page")) {
3185                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3186                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3188                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3189                 });
3190         }
3192         // Set the "submit" button on the edit post page to something more helpful.
3193         setEditPostPageSubmitButtonText();
3195         // Compute the text of the pagination UI tooltip text.
3196         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3197                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3198         });
3200         // Add event listeners for Escape and Enter, for the theme tweaker.
3201         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3202         let themeTweakerUI = query("#theme-tweaker-ui");
3203         document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3204                 if (event.keyCode == 27) {
3205                 // Escape key.
3206                         if (themeTweakerHelpWindow.style.display != "none") {
3207                                 toggleThemeTweakerHelpWindow();
3208                                 themeTweakerResetSettings();
3209                         } else if (themeTweakerUI.style.display != "none") {
3210                                 toggleThemeTweakerUI();
3211                                 themeTweakReset();
3212                         }
3213                 } else if (event.keyCode == 13) {
3214                 // Enter key.
3215                         if (themeTweakerHelpWindow.style.display != "none") {
3216                                 toggleThemeTweakerHelpWindow();
3217                                 themeTweakerSaveSettings();
3218                         } else if (themeTweakerUI.style.display != "none") {
3219                                 toggleThemeTweakerUI();
3220                                 themeTweakSave();
3221                         }
3222                 }
3223         });
3225         // Add event listener for . , ; (for navigating listings pages).
3226         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3227         if (listings.length > 0) {
3228                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
3229                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.keyCode == 27)) return;
3231                         if (event.keyCode == 27) {
3232                                 if (document.activeElement.parentElement.hasClass("listing"))
3233                                         document.activeElement.blur();
3234                                 return;
3235                         }
3237                         if (event.key == ';') {
3238                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3239                                         let links = document.activeElement.parentElement.queryAll("a");
3240                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3241                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3242                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3243                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3244                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3245                                 }
3246                                 return;
3247                         }
3249                         var indexOfActiveListing = -1;
3250                         for (i = 0; i < listings.length; i++) {
3251                                 if (document.activeElement.parentElement.hasClass("listing") && 
3252                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3253                                         indexOfActiveListing = i;
3254                                         break;
3255                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
3256                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
3257                                         indexOfActiveListing = i;
3258                                         break;
3259                                 }
3260                         }
3261                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3262                         if (indexOfNextListing < listings.length) {
3263                                 listings[indexOfNextListing].focus();
3265                                 if (listings[indexOfNextListing].closest(".comment-item")) {
3266                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3267                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3268                                 }
3269                         } else {
3270                                 document.activeElement.blur();
3271                         }
3272                 });
3273                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
3274                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
3275                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
3276                         });
3277                 });
3278         }
3279         // Add event listener for ; (to focus the link on link posts).
3280         if (query("#content").hasClass("post-page") && 
3281                 query(".post").hasClass("link-post")) {
3282                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
3283                         if (event.key == ';') query("a.link-post-link").focus();
3284                 });
3285         }
3287         // Add accesskeys to user page view selector.
3288         let viewSelector = query("#content.user-page > .sublevel-nav");
3289         if (viewSelector) {
3290                 let currentView = viewSelector.query("span");
3291                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
3292                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
3293         }
3295         // Add accesskey to index page sort selector.
3296         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
3298         // Move MathJax style tags to <head>.
3299         var aggregatedStyles = "";
3300         queryAll("#content style").forEach(styleTag => {
3301                 aggregatedStyles += styleTag.innerHTML;
3302                 removeElement("style", styleTag.parentElement);
3303         });
3304         if (aggregatedStyles != "") {
3305                 query("head").insertAdjacentHTML("beforeend", "<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
3306         }
3308         // Add listeners to switch between word count and read time.
3309         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
3310         queryAll(".post-meta .read-time").forEach(element => {
3311                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
3312                         let displayWordCount = localStorage.getItem("display-word-count");
3313                         toggleReadTimeOrWordCount(!displayWordCount);
3314                         if (displayWordCount) localStorage.removeItem("display-word-count");
3315                         else localStorage.setItem("display-word-count", true);
3316                 });
3317         });
3319         // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
3320         query("#content").addEventListener("copy", GW.textCopied = (event) => {
3321                 event.preventDefault();
3322                 const selectedText = window.getSelection().toString();
3323                 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
3324         });
3326         // Set up Image Focus feature.
3327         imageFocusSetup();
3330 /*************************/
3331 /* POST-LOAD ADJUSTMENTS */
3332 /*************************/
3334 registerInitializer('pageLayoutFinished', false, () => document.readyState == "complete", function () {
3335         GWLog("INITIALIZER pageLayoutFinished");
3336         forceInitializer('initialize');
3338         realignHashIfNeeded();
3340         postSetThemeHousekeeping();
3342         focusImageSpecifiedByURL();
3344         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
3345 //      query("input[type='search']").value = document.documentElement.clientWidth;
3348 function generateImagesOverlay() {
3349         GWLog("generateImagesOverlay");
3350         // Don't do this on the about page.
3351         if (query(".about-page") != null) return;
3353         // Remove existing, if any.
3354         removeElement("#images-overlay");
3356         // Create new.
3357         query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
3358         let imagesOverlay = query("#images-overlay");
3359         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
3360         queryAll(".post-body img").forEach(image => {
3361                 delete image.style;
3362                 delete image.className;
3364                 let clonedImageContainer = document.createElement("div");
3366                 let clonedImage = image.cloneNode(true);
3367                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
3368                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
3369                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
3370                 clonedImageContainer.appendChild(clonedImage);
3372                 let zoomLevel = parseFloat(GW.currentTextZoom);
3374                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
3375                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
3376                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
3377                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
3378                 GWLog(clonedImageContainer);
3380                 imagesOverlay.appendChild(clonedImageContainer);
3381         });
3383         // Add the event listeners to focus each image.
3384         imageFocusSetup(true);
3387 function adjustUIForWindowSize() {
3388         GWLog("adjustUIForWindowSize");
3389         var bottomBarOffset;
3391         // Adjust bottom bar state.
3392         let bottomBar = query("#bottom-bar");
3393         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
3394         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
3395                 bottomBar.removeClass("decorative");
3397                 bottomBar.query("#nav-item-top").style.display = "";
3398         } else if (bottomBar) {
3399                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
3400                 else bottomBar.addClass("decorative");
3402                 bottomBar.query("#nav-item-top").style.display = "none";
3403         }
3405         // Show quick-nav UI up/down buttons if content is taller than window.
3406         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
3407         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
3408                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
3409         });
3411         // Move anti-kibitzer toggle if content is very short.
3412         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
3414         // Update the visibility of the post nav UI.
3415         updatePostNavUIVisibility();
3418 function recomputeUIElementsContainerHeight(force = false) {
3419         GWLog("recomputeUIElementsContainerHeight");
3420         if (!GW.isMobile &&
3421                 (force || query("#ui-elements-container").style.height != "")) {
3422                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
3423                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
3424                                                                                                                 query("#content").clientHeight + "px" :
3425                                                                                                                 "100vh";
3426         }
3429 function realignHashIfNeeded() {
3430         if (GW.needHashRealignment)
3431                 realignHash();
3433 function realignHash() {
3434         GWLog("realignHash");
3435         if (!location.hash) return;
3437         let targetElement = query(location.hash);
3438         if (targetElement) targetElement.scrollIntoView(true);
3439         GW.needHashRealignment = false;
3442 function focusImageSpecifiedByURL() {
3443         GWLog("focusImageSpecifiedByURL");
3444         if (location.hash.hasPrefix("#if_slide_")) {
3445                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
3446                         let images = queryAll("#images-overlay img");
3447                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
3448                         if (imageToFocus > 0 && imageToFocus <= images.length) {
3449                                 focusImage(images[imageToFocus - 1]);
3450                                 query("#image-focus-overlay .image-number").textContent = imageToFocus;
3451                         }
3452                 });
3453         }
3456 /***********/
3457 /* GUIEDIT */
3458 /***********/
3460 function insertMarkup(event) {
3461         var mopen = '', mclose = '', mtext = '', func = false;
3462         if (typeof arguments[1] == 'function') {
3463                 func = arguments[1];
3464         } else {
3465                 mopen = arguments[1];
3466                 mclose = arguments[2];
3467                 mtext = arguments[3];
3468         }
3470         var textarea = event.target.closest("form").query("textarea");
3471         textarea.focus();
3472         var p0 = textarea.selectionStart;
3473         var p1 = textarea.selectionEnd;
3474         var cur0 = cur1 = p0;
3476         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
3477         str = func ? func(str, p0) : (mopen + str + mclose);
3479         // Determine selection.
3480         if (!func) {
3481                 cur0 += (p0 == p1) ? mopen.length : str.length;
3482                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
3483         } else {
3484                 cur0 = str[1];
3485                 cur1 = str[2];
3486                 str = str[0];
3487         }
3489         // Update textarea contents.
3490         textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
3492         // Set selection.
3493         textarea.selectionStart = cur0;
3494         textarea.selectionEnd = cur1;
3496         return;
3499 GW.guiEditButtons = [
3500         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
3501         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
3502         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
3503         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
3504         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
3505         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
3506         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
3507         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
3508         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
3509         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
3510         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
3511         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
3512         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
3513         [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
3514         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
3517 function blockquote(text, startpos) {
3518         if (text == '') {
3519                 text = "> Quoted text";
3520                 return [ text, startpos + 2, startpos + text.length ];
3521         } else {
3522                 text = "> " + text.split("\n").join("\n> ") + "\n";
3523                 return [ text, startpos + text.length, startpos + text.length ];
3524         }
3527 function hyperlink(text, startpos) {
3528         var url = '', link_text = text, endpos = startpos;
3529         if (text.search(/^https?/) != -1) {
3530                 url = text;
3531                 link_text = "link text";
3532                 startpos = startpos + 1;
3533                 endpos = startpos + link_text.length;
3534         } else {
3535                 url = prompt("Link address (URL):");
3536                 if (!url) {
3537                         endpos = startpos + text.length;
3538                         return [ text, startpos, endpos ];
3539                 }
3540                 startpos = startpos + text.length + url.length + 4;
3541                 endpos = startpos;
3542         }
3544         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];