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