Split contents-to-html into top-level function.
[lw2-viewer.git] / www / script.js
blobfb45853d2181ad65ad9b9512c1669476ada4491c
1 /***************************/
2 /* INITIALIZATION REGISTRY */
3 /***************************/
5 /*      TBC. */
6 GW.initializersDone = { };
7 GW.initializers = { };
8 function registerInitializer(name, tryEarly, precondition, fn) {
9         GW.initializersDone[name] = false;
10         GW.initializers[name] = fn;
11         let wrapper = function () {
12                 if (GW.initializersDone[name]) return;
13                 if (!precondition()) {
14                         if (tryEarly) {
15                                 setTimeout(() => requestIdleCallback(wrapper, {timeout: 1000}), 50);
16                         } else {
17                                 document.addEventListener("readystatechange", wrapper, {once: true});
18                         }
19                         return;
20                 }
21                 GW.initializersDone[name] = true;
22                 fn();
23         };
24         if (tryEarly) {
25                 requestIdleCallback(wrapper, {timeout: 1000});
26         } else {
27                 document.addEventListener("readystatechange", wrapper, {once: true});
28                 requestIdleCallback(wrapper);
29         }
31 function forceInitializer(name) {
32         if (GW.initializersDone[name]) return;
33         GW.initializersDone[name] = true;
34         GW.initializers[name]();
37 /***********/
38 /* COOKIES */
39 /***********/
41 /*      Sets a cookie. */
42 function setCookie(name, value, days) {
43         var expires = "";
44         if (!days) days = 36500;
45         if (days) {
46                 var date = new Date();
47                 date.setTime(date.getTime() + (days*24*60*60*1000));
48                 expires = "; expires=" + date.toUTCString();
49         }
50         document.cookie = name + "=" + (value || "")  + expires + "; path=/; SameSite=Lax" + (GW.secureCookies ? "; Secure" : "");
53 /*      Reads the value of named cookie.
54         Returns the cookie as a string, or null if no such cookie exists. */
55 function readCookie(name) {
56         var nameEQ = name + "=";
57         var ca = document.cookie.split(';');
58         for(var i = 0; i < ca.length; i++) {
59                 var c = ca[i];
60                 while (c.charAt(0)==' ') c = c.substring(1, c.length);
61                 if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
62         }
63         return null;
66 /*******************************/
67 /* EVENT LISTENER MANIPULATION */
68 /*******************************/
70 /*      Removes event listener from a clickable element, automatically detaching it
71         from all relevant event types. */
72 Element.prototype.removeActivateEvent = function() {
73         let ael = this.activateEventListener;
74         this.removeEventListener("mousedown", ael);
75         this.removeEventListener("click", ael);
76         this.removeEventListener("keyup", ael);
79 /*      Adds a scroll event listener to the page. */
80 function addScrollListener(fn, name) {
81         let wrapper = (event) => {
82                 requestAnimationFrame(() => {
83                         fn(event);
84                         document.addEventListener("scroll", wrapper, {once: true, passive: true});
85                 });
86         }
87         document.addEventListener("scroll", wrapper, {once: true, passive: true});
89         // Retain a reference to the scroll listener, if a name is provided.
90         if (typeof name != "undefined")
91                 GW[name] = wrapper;
94 /****************/
95 /* MISC HELPERS */
96 /****************/
98 // Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=325942
99 Element.prototype.scrollIntoView = function(realSIV) {
100         return function(bottom) {
101                 realSIV.call(this, bottom);
102                 if(fixTarget = this.closest("input[id^='expand'] ~ .comment-thread")) {
103                         window.scrollBy(0, fixTarget.scrollTop);
104                         fixTarget.scrollTop = 0;
105                 }
106         }
107 }(Element.prototype.scrollIntoView);
109 /*      If top of element is not at or above the top of the screen, scroll it into
110         view. */
111 Element.prototype.scrollIntoViewIfNeeded = function() {
112         GWLog("scrollIntoViewIfNeeded");
113         if (this.getBoundingClientRect().bottom > window.innerHeight && 
114                 this.getBoundingClientRect().top > 0) {
115                 this.scrollIntoView(false);
116         }
119 function urlEncodeQuery(params) {
120         return params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params[x])}).join("&");
123 function handleAjaxError(event) {
124         if(event.target.getResponseHeader("Content-Type") === "application/json") console.log("doAjax error: " + JSON.parse(event.target.responseText)["error"]);
125         else console.log("doAjax error: Something bad happened :(");
128 function doAjax(params) {
129         let req = new XMLHttpRequest();
130         let requestMethod = params["method"] || "GET";
131         req.addEventListener("load", (event) => {
132                 if(event.target.status < 400) {
133                         if(params["onSuccess"]) params.onSuccess(event);
134                 } else {
135                         if(params["onFailure"]) params.onFailure(event);
136                         else handleAjaxError(event);
137                 }
138                 if(params["onFinish"]) params.onFinish(event);
139         });
140         req.open(requestMethod, (params.location || document.location) + ((requestMethod == "GET" && params.params) ? "?" + urlEncodeQuery(params.params) : ""));
141         if(requestMethod == "POST") {
142                 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
143                 params["params"]["csrf-token"] = GW.csrfToken;
144                 req.send(urlEncodeQuery(params.params));
145         } else {
146                 req.send();
147         }
150 function activateReadyStateTriggers() {
151         if(document.readyState == 'interactive') {
152                 activateTrigger('DOMReady');
153         } else if(document.readyState == 'complete') {
154                 activateTrigger('DOMReady');
155                 activateTrigger('DOMComplete');
156         }
159 document.addEventListener('readystatechange', activateReadyStateTriggers);
160 activateReadyStateTriggers();
162 function callWithServerData(fname, uri) {
163         doAjax({
164                 location: uri,
165                 onSuccess: (event) => {
166                         let response = JSON.parse(event.target.responseText);
167                         window[fname](response);
168                 }
169         });
172 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
173 deferredCalls = null;
175 /*      Return the currently selected text, as HTML (rather than unstyled text).
176         */
177 function getSelectionHTML() {
178         var container = document.createElement("div");
179         container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
180         return container.innerHTML;
183 /*      Given an HTML string, creates an element from that HTML, adds it to 
184         #ui-elements-container (creating the latter if it does not exist), and 
185         returns the created element.
186         */
187 function addUIElement(element_html) {
188         var ui_elements_container = query("#ui-elements-container");
189         if (!ui_elements_container) {
190                 ui_elements_container = document.createElement("nav");
191                 ui_elements_container.id = "ui-elements-container";
192                 query("body").appendChild(ui_elements_container);
193         }
195         ui_elements_container.insertAdjacentHTML("beforeend", element_html);
196         return ui_elements_container.lastElementChild;
199 /*      Given an element or a selector, removes that element (or the element 
200         identified by the selector).
201         If multiple elements match the selector, only the first is removed.
202         */
203 function removeElement(elementOrSelector, ancestor = document) {
204         if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
205         if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
208 /*      Returns true if the string begins with the given prefix.
209         */
210 String.prototype.hasPrefix = function (prefix) {
211         return (this.lastIndexOf(prefix, 0) === 0);
214 /*      Toggles whether the page is scrollable.
215         */
216 function togglePageScrolling(enable) {
217         let body = query("body");
218         if (!enable) {
219                 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
220                 body.addClass("no-scroll");
221                 body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
222         } else {
223                 body.removeClass("no-scroll");
224                 body.removeAttribute("style");
225                 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
226         }
229 DOMRectReadOnly.prototype.isInside = function (x, y) {
230         return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
233 /********************/
234 /* DEBUGGING OUTPUT */
235 /********************/
237 GW.enableLogging = (permanently = false) => {
238         if (permanently)
239                 localStorage.setItem("logging-enabled", "true");
240         else
241                 GW.loggingEnabled = true;
243 GW.disableLogging = (permanently = false) => {
244         if (permanently)
245                 localStorage.removeItem("logging-enabled");
246         else
247                 GW.loggingEnabled = false;
250 /*******************/
251 /* INBOX INDICATOR */
252 /*******************/
254 function processUserStatus(userStatus) {
255         window.userStatus = userStatus;
256         if(userStatus) {
257                 if(userStatus.notifications) {
258                         let element = query('#inbox-indicator');
259                         element.className = 'new-messages';
260                         element.title = 'New messages [o]';
261                 }
262         } else {
263                 location.reload();
264         }
267 /**************/
268 /* COMMENTING */
269 /**************/
271 function toggleMarkdownHintsBox() {
272         GWLog("toggleMarkdownHintsBox");
273         let markdownHintsBox = query("#markdown-hints");
274         markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
276 function hideMarkdownHintsBox() {
277         GWLog("hideMarkdownHintsBox");
278         let markdownHintsBox = query("#markdown-hints");
279         if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
282 Element.prototype.addTextareaFeatures = function() {
283         GWLog("addTextareaFeatures");
284         let textarea = this;
286         textarea.addEventListener("focus", GW.textareaFocused = (event) => {
287                 GWLog("GW.textareaFocused");
288                 event.target.closest("form").scrollIntoViewIfNeeded();
289         });
290         textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
291                 GWLog("GW.textareaInputReceived");
292                 if (window.innerWidth > 520) {
293                         // Expand textarea if needed.
294                         expandTextarea(textarea);
295                 } else {
296                         // Remove markdown hints.
297                         hideMarkdownHintsBox();
298                         query(".guiedit-mobile-help-button").removeClass("active");
299                 }
300                 // User mentions autocomplete
301                 if(!userAutocomplete &&
302                    textarea.value.charAt(textarea.selectionStart - 1) === "@" &&
303                    (textarea.selectionStart === 1 ||
304                     !textarea.value.charAt(textarea.selectionStart - 2).match(/[a-zA-Z0-9]/))) {
305                         beginAutocompletion(textarea, textarea.selectionStart);
306                 }
307         }, false);
308         textarea.addEventListener("click", (event) => {
309                 if(!userAutocomplete) {
310                         let start = textarea.selectionStart, end = textarea.selectionEnd;
311                         let value = textarea.value;
312                         if (start <= 1) return;
313                         for (; value.charAt(start - 1) != "@"; start--) {
314                                 if (start <= 1) return;
315                                 if (value.charAt(start - 1) == " ") return;
316                         }
317                         for(; end < value.length && value.charAt(end) != " "; end++) { true }
318                         beginAutocompletion(textarea, start, end);
319                 }
320         });
321                         
322         textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
323         textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
324         textarea.addEventListener("keydown", (event) => {
325                 // Special case for alt+4
326                 // Generalize this before adding more.
327                 if(event.altKey && event.key === '4') {
328                         insertMarkup(event, "$", "$", "LaTeX formula");
329                         event.stopPropagation();
330                         event.preventDefault();
331                 }
332         });
334         let form = textarea.closest("form");
335         if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
337         textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
338         let textareaContainer = textarea.closest(".textarea-container");
339         var buttons_container = textareaContainer.query(".guiedit-buttons-container");
340         for (var button of GW.guiEditButtons) {
341                 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
342                 buttons_container.insertAdjacentHTML("beforeend", 
343                         "<button type='button' class='guiedit guiedit-" 
344                         + name
345                         + "' tabindex='-1'"
346                         + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
347                         + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
348                         + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
349                         + " onclick='insertMarkup(event,"
350                         + ((typeof m_before_or_func == 'function') ?
351                                 m_before_or_func.name : 
352                                 ("\"" + m_before_or_func  + "\",\"" + m_after + "\",\"" + placeholder + "\""))
353                         + ");'><div>"
354                         + icon
355                         + "</div></button>"
356                 );
357         }
359         var markdown_hints = 
360         `<input type='checkbox' id='markdown-hints-checkbox'>
361         <label for='markdown-hints-checkbox'></label>
362         <div id='markdown-hints'>` + 
363         [       "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>", 
364                 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
365                 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
366                 "<span>Heading 1</span><code># Heading 1</code>",
367                 "<span>Heading 2</span><code>## Heading 1</code>",
368                 "<span>Heading 3</span><code>### Heading 1</code>",
369                 "<span>Blockquote</span><code>&gt; Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
370         `</div>`;
371         textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
373         textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
374                 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
375                         GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
376                         if (button.hasClass("guiedit-mobile-help-button")) {
377                                 toggleMarkdownHintsBox();
378                                 event.target.toggleClass("active");
379                                 query(".posting-controls:focus-within textarea").focus();
380                         } else if (button.hasClass("guiedit-mobile-exit-button")) {
381                                 event.target.blur();
382                                 hideMarkdownHintsBox();
383                                 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
384                         }
385                 });
386         });
388         // On smartphone (narrow mobile) screens, when a textarea is focused (and
389         // automatically fullscreened), remove all the filters from the page, and 
390         // then apply them *just* to the fixed editor UI elements. This is in order
391         // to get around the "children of elements with a filter applied cannot be
392         // fixed" issue".
393         if (GW.isMobile && window.innerWidth <= 520) {
394                 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
395                 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
396                         GWLog("GW.textareaFocusedMobile");
397                         GW.savedFilters = GW.currentFilters;
398                         GW.currentFilters = { };
399                         applyFilters(GW.currentFilters);
400                         fixedEditorElements.forEach(element => {
401                                 element.style.filter = filterStringFromFilters(GW.savedFilters);
402                         });
403                 });
404                 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
405                         GWLog("GW.textareaBlurredMobile");
406                         GW.currentFilters = GW.savedFilters;
407                         GW.savedFilters = { };
408                         requestAnimationFrame(() => {
409                                 applyFilters(GW.currentFilters);
410                                 fixedEditorElements.forEach(element => {
411                                         element.style.filter = filterStringFromFilters(GW.savedFilters);
412                                 });
413                         });
414                 });
415         }
418 Element.prototype.injectReplyForm = function(editMarkdownSource) {
419         GWLog("injectReplyForm");
420         let commentControls = this;
421         let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
422         let postId = commentControls.parentElement.dataset["postId"];
423         let tagId = commentControls.parentElement.dataset["tagId"];
424         let withparent = (!editMarkdownSource && commentControls.getCommentId());
425         let answer = commentControls.parentElement.id == "answers";
426         let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
427         let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
428         let parentCommentItem = commentControls.closest(".comment-item");
429         let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
430             (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
431         commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
432                 "<form method='post'>" + 
433                 "<div class='textarea-container'>" + 
434                 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
435                 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
436                 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
437                 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
438                 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
439                 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
440                 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
441                 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
442                 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
443                 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
444                 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" + 
445                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` + 
446                 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` + 
447                 "</div><div>" + 
448                 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
449                 "<input type='submit' value='Submit'>" + 
450                 "</div></form>";
451         commentControls.onsubmit = disableBeforeUnload;
453         commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
454                 GWLog("GW.cancelCommentButtonClicked");
455                 hideReplyForm(event.target.closest(".comment-controls"));
456         });
457         commentControls.scrollIntoViewIfNeeded();
458         commentControls.query("form").onsubmit = (event) => {
459                 if (!event.target.text.value) {
460                         alert("Please enter a comment.");
461                         return false;
462                 }
463         }
464         let textarea = commentControls.query("textarea");
465         textarea.value = MarkdownFromHTML(editMarkdownSource || "");
466         textarea.addTextareaFeatures();
467         textarea.focus();
470 function showCommentEditForm(commentItem) {
471         GWLog("showCommentEditForm");
473         let commentBody = commentItem.query(".comment-body");
474         commentBody.style.display = "none";
476         let commentControls = commentItem.query(".comment-controls");
477         commentControls.injectReplyForm(commentBody.dataset.markdownSource);
478         commentControls.query("form").addClass("edit-existing-comment");
479         expandTextarea(commentControls.query("textarea"));
482 function showReplyForm(commentItem) {
483         GWLog("showReplyForm");
485         let commentControls = commentItem.query(".comment-controls");
486         commentControls.injectReplyForm(commentControls.dataset.enteredText);
489 function hideReplyForm(commentControls) {
490         GWLog("hideReplyForm");
491         // Are we editing a comment? If so, un-hide the existing comment body.
492         let containingComment = commentControls.closest(".comment-item");
493         if (containingComment) containingComment.query(".comment-body").style.display = "";
495         let enteredText = commentControls.query("textarea").value;
496         if (enteredText) commentControls.dataset.enteredText = enteredText;
498         disableBeforeUnload();
499         commentControls.constructCommentControls();
502 function expandTextarea(textarea) {
503         GWLog("expandTextarea");
504         if (window.innerWidth <= 520) return;
506         let totalBorderHeight = 30;
507         if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
509         requestAnimationFrame(() => {
510                 textarea.style.height = 'auto';
511                 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
512                 if (textarea.clientHeight < window.innerHeight) {
513                         textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
514                 }
515         });
518 function doCommentAction(action, commentItem) {
519         GWLog("doCommentAction");
520         let params = {};
521         params[(action + "-comment-id")] = commentItem.getCommentId();
522         doAjax({
523                 method: "POST",
524                 params: params,
525                 onSuccess: GW.commentActionPostSucceeded = (event) => {
526                         GWLog("GW.commentActionPostSucceeded");
527                         let fn = {
528                                 retract: () => { commentItem.firstChild.addClass("retracted") },
529                                 unretract: () => { commentItem.firstChild.removeClass("retracted") },
530                                 delete: () => {
531                                         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>";
532                                         commentItem.removeChild(commentItem.query(".comment-controls"));
533                                 }
534                         }[action];
535                         if(fn) fn();
536                         if(action != "delete")
537                                 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
538                 }
539         });
542 /**********/
543 /* VOTING */
544 /**********/
546 function parseVoteType(voteType) {
547         GWLog("parseVoteType");
548         let value = {};
549         if (!voteType) return value;
550         value.up = /[Uu]pvote$/.test(voteType);
551         value.down = /[Dd]ownvote$/.test(voteType);
552         value.big = /^big/.test(voteType);
553         return value;
556 function makeVoteType(value) {
557         GWLog("makeVoteType");
558         return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
561 function makeVoteClass(vote) {
562         GWLog("makeVoteClass");
563         if (vote.up || vote.down) {
564                 return (vote.big ? 'selected big-vote' : 'selected');
565         } else {
566                 return '';
567         }
570 function findVoteControls(targetType, targetId, voteAxis) {
571         var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
573         if(targetType == "Post") {
574                 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
575         } else if(targetType == "Comment") {
576                 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
577         }
580 function votesEqual(vote1, vote2) {
581         var allKeys = Object.assign({}, vote1);
582         Object.assign(allKeys, vote2);
584         for(k of allKeys.keys()) {
585                 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
586         }
587         return true;
590 function addVoteButtons(element, vote, targetType) {
591         GWLog("addVoteButtons");
592         vote = vote || {};
593         let voteAxis = element.parentElement.dataset.voteAxis || "karma";
594         let voteType = parseVoteType(vote[voteAxis]);
595         let voteClass = makeVoteClass(voteType);
597         element.parentElement.queryAll("button").forEach((button) => {
598                 button.disabled = false;
599                 if (voteType) {
600                         if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
601                                 button.addClass(voteClass);
602                 }
603                 updateVoteButtonVisualState(button);
604                 button.addActivateEvent(voteButtonClicked);
605         });
608 function updateVoteButtonVisualState(button) {
609         GWLog("updateVoteButtonVisualState");
611         button.removeClasses([ "none", "one", "two-temp", "two" ]);
613         if (button.disabled)
614                 button.addClass("none");
615         else if (button.hasClass("big-vote"))
616                 button.addClass("two");
617         else if (button.hasClass("selected"))
618                 button.addClass("one");
619         else
620                 button.addClass("none");
623 function changeVoteButtonVisualState(button) {
624         GWLog("changeVoteButtonVisualState");
626         /*      Interaction states are:
628                 0  0·    (neutral; +1 click)
629                 1  1·    (small vote; +1 click)
630                 2  2·    (big vote; +1 click)
632                 Visual states are (with their state classes in [brackets]) are:
634                 01    (no vote) [none]
635                 02    (small vote active) [one]
636                 12    (small vote active, temporary indicator of big vote) [two-temp]
637                 22    (big vote active) [two]
639                 The following are the 9 possible interaction state transitions (and
640                 the visual state transitions associated with them):
642                                 VIS.    VIS.
643                 FROM    TO      FROM    TO      NOTES
644                 ====    ====    ====    ====    =====
645                 0       0·      01      12      first click
646                 0·      1       12      02      one click without second
647                 0·      2       12      22      second click
649                 1       1·      02      12      first click
650                 1·      0       12      01      one click without second
651                 1·      2       12      22      second click
653                 2       2·      22      12      first click
654                 2·      1       12      02      one click without second
655                 2·      0       12      01      second click
656         */
657         let transitions = [
658                 [ "big-vote two-temp clicked-twice", "none"     ], // 2· => 0
659                 [ "big-vote two-temp clicked-once",  "one"      ], // 2· => 1
660                 [ "big-vote clicked-once",           "two-temp" ], // 2  => 2·
662                 [ "selected two-temp clicked-twice", "two"      ], // 1· => 2
663                 [ "selected two-temp clicked-once",  "none"     ], // 1· => 0
664                 [ "selected clicked-once",           "two-temp" ], // 1  => 1·
666                 [ "two-temp clicked-twice",          "two"      ], // 0· => 2
667                 [ "two-temp clicked-once",           "one"      ], // 0· => 1
668                 [ "clicked-once",                    "two-temp" ], // 0  => 0·
669         ];
670         for (let [ interactionClasses, visualStateClass ] of transitions) {
671                 if (button.hasClasses(interactionClasses.split(" "))) {
672                         button.removeClasses([ "none", "one", "two-temp", "two" ]);
673                         button.addClass(visualStateClass);
674                         break;
675                 }
676         }
679 function voteCompleteEvent(targetType, targetId, response) {
680         GWLog("voteCompleteEvent");
682         var currentVote = voteData[targetType][targetId] || {};
683         var desiredVote = voteDesired[targetType][targetId];
685         var controls = findVoteControls(targetType, targetId);
686         var controlsByAxis = new Object;
688         controls.forEach(control => {
689                 const voteAxis = (control.dataset.voteAxis || "karma");
691                 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
692                         control.removeClass("waiting");
693                         control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
694                 }
696                 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
697                 controlsByAxis[voteAxis].push(control);
699                 const voteType = currentVote[voteAxis];
700                 const vote = parseVoteType(voteType);
701                 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
702                 const voteClass = makeVoteClass(vote);
704                 if (response && response[voteAxis]) {
705                         const [voteType, displayText, titleText] = response[voteAxis];
707                         const displayTarget = control.query(".karma-value");
708                         if (displayTarget.hasClass("redacted")) {
709                                 displayTarget.dataset["trueValue"] = displayText;
710                         } else {
711                                 displayTarget.innerHTML = displayText;
712                         }
713                         displayTarget.setAttribute("title", titleText);
714                 }
716                 control.queryAll("button.vote").forEach(button => {
717                         updateVoteButton(button, voteUpDown, voteClass);
718                 });
719         });
722 function updateVoteButton(button, voteUpDown, voteClass) {
723         button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
724         if (button.dataset.voteType == voteUpDown)
725                 button.addClass(voteClass);
726         updateVoteButtonVisualState(button);
729 function makeVoteRequestCompleteEvent(targetType, targetId) {
730         return (event) => {
731                 var currentVote = {};
732                 var response = null;
734                 if (event.target.status == 200) {
735                         response = JSON.parse(event.target.responseText);
736                         for (const voteAxis of response.keys()) {
737                                 currentVote[voteAxis] = response[voteAxis][0];
738                         }
739                         voteData[targetType][targetId] = currentVote;
740                 } else {
741                         delete voteDesired[targetType][targetId];
742                         currentVote = voteData[targetType][targetId];
743                 }
745                 var desiredVote = voteDesired[targetType][targetId];
747                 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
748                         sendVoteRequest(targetType, targetId);
749                 } else {
750                         delete voteDesired[targetType][targetId];
751                         voteCompleteEvent(targetType, targetId, response);
752                 }
753         }
756 function sendVoteRequest(targetType, targetId) {
757         GWLog("sendVoteRequest");
759         doAjax({
760                 method: "POST",
761                 location: "/karma-vote",
762                 params: { "target": targetId,
763                           "target-type": targetType,
764                           "vote": JSON.stringify(voteDesired[targetType][targetId]) },
765                 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
766         });
769 function voteButtonClicked(event) {
770         GWLog("voteButtonClicked");
771         let voteButton = event.target;
773         // 500 ms (0.5 s) double-click timeout.
774         let doubleClickTimeout = 500;
776         if (!voteButton.clickedOnce) {
777                 voteButton.clickedOnce = true;
778                 voteButton.addClass("clicked-once");
779                 changeVoteButtonVisualState(voteButton);
781                 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
782                         if (!voteButton.clickedOnce) return;
784                         // Do single-click code.
785                         voteButton.clickedOnce = false;
786                         voteEvent(voteButton, 1);
787                 }, doubleClickTimeout, voteButton);
788         } else {
789                 voteButton.clickedOnce = false;
791                 // Do double-click code.
792                 voteButton.removeClass("clicked-once");
793                 voteButton.addClass("clicked-twice");
794                 voteEvent(voteButton, 2);
795         }
798 function voteEvent(voteButton, numClicks) {
799         GWLog("voteEvent");
800         voteButton.blur();
802         let voteControl = voteButton.parentNode;
804         let targetType = voteButton.dataset.targetType;
805         let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
806         let voteAxis = voteControl.dataset.voteAxis || "karma";
807         let voteUpDown = voteButton.dataset.voteType;
809         let voteType;
810         if (   (numClicks == 2 && voteButton.hasClass("big-vote"))
811                 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
812                 voteType = "neutral";
813         } else {
814                 let vote = parseVoteType(voteUpDown);
815                 vote.big = (numClicks == 2);
816                 voteType = makeVoteType(vote);
817         }
819         let voteControls = findVoteControls(targetType, targetId, voteAxis);
820         for (const voteControl of voteControls) {
821                 voteControl.addClass("waiting");
822                 voteControl.queryAll(".vote").forEach(button => {
823                         button.addClass("waiting");
824                         updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
825                 });
826         }
828         let voteRequestPending = voteDesired[targetType][targetId];
829         let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
830         voteObject[voteAxis] = voteType;
831         voteDesired[targetType][targetId] = voteObject;
833         if (!voteRequestPending) sendVoteRequest(targetType, targetId);
836 function initializeVoteButtons() {
837         // Color the upvote/downvote buttons with an embedded style sheet.
838         query("head").insertAdjacentHTML("beforeend", "<style id='vote-buttons'>" + `
839                 :root {
840                         --GW-upvote-button-color: #00d800;
841                         --GW-downvote-button-color: #eb4c2a;
842                 }\n` + "</style>");
845 function processVoteData(voteData) {
846         window.voteData = voteData;
848         window.voteDesired = new Object;
849         for(key of voteData.keys()) {
850                 voteDesired[key] = new Object;
851         }
853         initializeVoteButtons();
854         
855         addTriggerListener("postLoaded", {priority: 3000, fn: () => {
856                 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
857                         let postID = karmaValue.parentNode.dataset.postId;
858                         addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
859                         karmaValue.parentElement.addClass("active-controls");
860                 });
861         }});
863         addTriggerListener("DOMReady", {priority: 3000, fn: () => {
864                 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
865                         let commentID = karmaValue.getCommentId();
866                         addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
867                         karmaValue.parentElement.addClass("active-controls");
868                 });
869         }});
872 /*****************************************/
873 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
874 /*****************************************/
876 Element.prototype.getCommentDate = function() {
877         let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
878         let dateElement = item && item.query(".date");
879         return (dateElement && parseInt(dateElement.dataset["jsDate"]));
881 function getCurrentVisibleComment() {
882         let px = window.innerWidth/2, py = 5;
883         let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
884         let bottomBar = query("#bottom-bar");
885         let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
886         let atbottom =  bottomOffset <= window.innerHeight;
887         if (atbottom) {
888                 let hashci = location.hash && query(location.hash);
889                 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
890                         commentItem = hashci;
891                 }
892         }
893         return commentItem;
896 function highlightCommentsSince(date) {
897         GWLog("highlightCommentsSince");
898         var newCommentsCount = 0;
899         GW.newComments = [ ];
900         let oldCommentsStack = [ ];
901         let prevNewComment;
902         queryAll(".comment-item").forEach(commentItem => {
903                 commentItem.prevNewComment = prevNewComment;
904                 commentItem.nextNewComment = null;
905                 if (commentItem.getCommentDate() > date) {
906                         commentItem.addClass("new-comment");
907                         newCommentsCount++;
908                         GW.newComments.push(commentItem.getCommentId());
909                         oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
910                         oldCommentsStack = [ commentItem ];
911                         prevNewComment = commentItem;
912                 } else {
913                         commentItem.removeClass("new-comment");
914                         oldCommentsStack.push(commentItem);
915                 }
916         });
918         GW.newCommentScrollSet = (commentItem) => {
919                 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
920                 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
921         };
922         GW.newCommentScrollListener = () => {
923                 let commentItem = getCurrentVisibleComment();
924                 GW.newCommentScrollSet(commentItem);
925         }
927         addScrollListener(GW.newCommentScrollListener);
929         if (document.readyState=="complete") {
930                 GW.newCommentScrollListener();
931         } else {
932                 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
933                 GW.newCommentScrollSet(commentItem);
934         }
936         registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
938         return newCommentsCount;
941 function scrollToNewComment(next) {
942         GWLog("scrollToNewComment");
943         let commentItem = getCurrentVisibleComment();
944         let targetComment = null;
945         let targetCommentID = null;
946         if (commentItem) {
947                 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
948                 if (targetComment) {
949                         targetCommentID = targetComment.getCommentId();
950                 }
951         } else {
952                 if (GW.newComments[0]) {
953                         targetCommentID = GW.newComments[0];
954                         targetComment = query("#comment-" + targetCommentID);
955                 }
956         }
957         if (targetComment) {
958                 expandAncestorsOf(targetCommentID);
959                 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
960                 targetComment.scrollIntoView();
961         }
963         GW.newCommentScrollListener();
966 function getPostHash() {
967         let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
968         return (postHash ? postHash[1] : false);
970 function setHistoryLastVisitedDate(date) {
971         window.history.replaceState({ lastVisited: date }, null);
973 function getLastVisitedDate() {
974         // Get the last visited date (or, if posting a comment, the previous last visited date).
975         if(window.history.state) return (window.history.state||{})['lastVisited'];
976         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
977         let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
978         let currentVisited = localStorage.getItem(storageName);
979         setHistoryLastVisitedDate(currentVisited);
980         return currentVisited;
982 function setLastVisitedDate(date) {
983         GWLog("setLastVisitedDate");
984         // If NOT posting a comment, save the previous value for the last-visited-date 
985         // (to recover it in case of posting a comment).
986         let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
987         if (!aCommentHasJustBeenPosted) {
988                 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
989                 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
990         }
992         // Set the new value.
993         localStorage.setItem("last-visited-date_" + getPostHash(), date);
996 function updateSavedCommentCount() {
997         GWLog("updateSavedCommentCount");
998         let commentCount = queryAll(".comment").length;
999         localStorage.setItem("comment-count_" + getPostHash(), commentCount);
1001 function badgePostsWithNewComments() {
1002         GWLog("badgePostsWithNewComments");
1003         if (getQueryVariable("show") == "conversations") return;
1005         queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
1006                 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
1008                 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
1009                 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1010                 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1012                 if (currentCommentCount > savedCommentCount)
1013                         commentCountDisplay.addClass("new-comments");
1014                 else
1015                         commentCountDisplay.removeClass("new-comments");
1016                 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1017         });
1020 /***********************************/
1021 /* CONTENT COLUMN WIDTH ADJUSTMENT */
1022 /***********************************/
1024 function injectContentWidthSelector() {
1025         GWLog("injectContentWidthSelector");
1026         // Get saved width setting (or default).
1027         let currentWidth = localStorage.getItem("selected-width") || 'normal';
1029         // Inject the content width selector widget and activate buttons.
1030         let widthSelector = addUIElement(
1031                 "<div id='width-selector'>" +
1032                 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
1033                         let [name, desc, abbr] = widthOption;
1034                         let selected = (name == currentWidth ? ' selected' : '');
1035                         let disabled = (name == currentWidth ? ' disabled' : '');
1036                         return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
1037                 "</div>");
1038         widthSelector.queryAll("button").forEach(button => {
1039                 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
1040                         GWLog("GW.widthAdjustButtonClicked");
1042                         // Determine which setting was chosen (i.e., which button was clicked).
1043                         let selectedWidth = event.target.dataset.name;
1045                         // Save the new setting.
1046                         if (selectedWidth == "normal") localStorage.removeItem("selected-width");
1047                         else localStorage.setItem("selected-width", selectedWidth);
1049                         // Save current visible comment
1050                         let visibleComment = getCurrentVisibleComment();
1052                         // Actually change the content width.
1053                         setContentWidth(selectedWidth);
1054                         event.target.parentElement.childNodes.forEach(button => {
1055                                 button.removeClass("selected");
1056                                 button.disabled = false;
1057                         });
1058                         event.target.addClass("selected");
1059                         event.target.disabled = true;
1061                         // Make sure the accesskey (to cycle to the next width) is on the right button.
1062                         setWidthAdjustButtonsAccesskey();
1064                         // Regenerate images overlay.
1065                         generateImagesOverlay();
1067                         if(visibleComment) visibleComment.scrollIntoView();
1068                 });
1069         });
1071         // Make sure the accesskey (to cycle to the next width) is on the right button.
1072         setWidthAdjustButtonsAccesskey();
1074         // Inject transitions CSS, if animating changes is enabled.
1075         if (GW.adjustmentTransitions) {
1076                 insertHeadHTML(
1077                         "<style id='width-transition'>" + 
1078                         `#content,
1079                         #ui-elements-container,
1080                         #images-overlay {
1081                                 transition:
1082                                         max-width 0.3s ease;
1083                         }` + 
1084                         "</style>");
1085         }
1087 function setWidthAdjustButtonsAccesskey() {
1088         GWLog("setWidthAdjustButtonsAccesskey");
1089         let widthSelector = query("#width-selector");
1090         widthSelector.queryAll("button").forEach(button => {
1091                 button.removeAttribute("accesskey");
1092                 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1093         });
1094         let selectedButton = widthSelector.query("button.selected");
1095         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
1096         nextButtonInCycle.accessKey = "'";
1097         nextButtonInCycle.title += ` [\']`;
1100 /*******************/
1101 /* THEME SELECTION */
1102 /*******************/
1104 function injectThemeSelector() {
1105         GWLog("injectThemeSelector");
1106         let currentTheme = readCookie("theme") || "default";
1107         let themeSelector = addUIElement(
1108                 "<div id='theme-selector' class='theme-selector'>" +
1109                 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
1110                         let [name, desc, letter] = themeOption;
1111                         let selected = (name == currentTheme ? ' selected' : '');
1112                         let disabled = (name == currentTheme ? ' disabled' : '');
1113                         let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1114                         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>`;})) +
1115                 "</div>");
1116         themeSelector.queryAll("button").forEach(button => {
1117                 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
1118                         GWLog("GW.themeSelectButtonClicked");
1119                         let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
1120                         setSelectedTheme(themeName);
1121                         if (GW.isMobile) toggleAppearanceAdjustUI();
1122                 });
1123         });
1125         // Inject transitions CSS, if animating changes is enabled.
1126         if (GW.adjustmentTransitions) {
1127                 insertHeadHTML(
1128                         "<style id='theme-fade-transition'>" + 
1129                         `body {
1130                                 transition:
1131                                         opacity 0.5s ease-out,
1132                                         background-color 0.3s ease-out;
1133                         }
1134                         body.transparent {
1135                                 background-color: #777;
1136                                 opacity: 0.0;
1137                                 transition:
1138                                         opacity 0.5s ease-in,
1139                                         background-color 0.3s ease-in;
1140                         }` + 
1141                         "</style>");
1142         }
1144 function setSelectedTheme(themeName) {
1145         GWLog("setSelectedTheme");
1146         queryAll(".theme-selector button").forEach(button => {
1147                 button.removeClass("selected");
1148                 button.disabled = false;
1149         });
1150         queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
1151                 button.addClass("selected");
1152                 button.disabled = true;
1153         });
1154         setTheme(themeName);
1155         query("#theme-tweaker-ui .current-theme span").innerText = themeName;
1157 function setTheme(newThemeName) {
1158         var themeUnloadCallback = '';
1159         var oldThemeName = '';
1160         if (typeof(newThemeName) == 'undefined') {
1161                 newThemeName = readCookie('theme');
1162                 if (!newThemeName) return;
1163         } else {
1164                 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
1165                 oldThemeName = readCookie('theme') || 'default';
1167                 if (newThemeName == 'default') setCookie('theme', '');
1168                 else setCookie('theme', newThemeName);
1169         }
1170         if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
1172         let makeNewStyle = function(newThemeName, colorSchemePreference) {
1173                 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
1174                 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1176                 let newStyle = document.createElement('link');
1177                 newStyle.setAttribute('class', 'theme');
1178                 if(colorSchemePreference)
1179                         newStyle.setAttribute('media', '(prefers-color-scheme: ' + colorSchemePreference + ')');
1180                 newStyle.setAttribute('rel', 'stylesheet');
1181                 newStyle.setAttribute('href', '/css/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1182                 return newStyle;
1183         }
1185         let newMainStyle, newStyles;
1186         if(newThemeName === 'default') {
1187                 newStyles = [makeNewStyle('dark', 'dark'), makeNewStyle('default', 'light')];
1188                 newMainStyle = (window.matchMedia('prefers-color-scheme: dark').matches ? newStyles[0] : newStyles[1]);
1189         } else {
1190                 newStyles = [makeNewStyle(newThemeName)];
1191                 newMainStyle = newStyles[0];
1192         }
1194         let oldStyles = queryAll("head link.theme");
1195         newMainStyle.addEventListener('load', () => { oldStyles.forEach(x => removeElement(x)); });
1196         newMainStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
1198         if (GW.adjustmentTransitions) {
1199                 pageFadeTransition(false);
1200                 setTimeout(() => {
1201                         newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1202                 }, 500);
1203         } else {
1204                 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1205         }
1207 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
1208         document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1209         document.body.addClass("theme-" + newThemeName);
1211         recomputeUIElementsContainerHeight(true);
1213         let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
1214         if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
1216         recomputeUIElementsContainerHeight();
1217         adjustUIForWindowSize();
1218         window.addEventListener('resize', GW.windowResized = (event) => {
1219                 GWLog("GW.windowResized");
1220                 adjustUIForWindowSize();
1221                 recomputeUIElementsContainerHeight();
1222         });
1224         generateImagesOverlay();
1226         if (window.adjustmentTransitions) pageFadeTransition(true);
1227         updateThemeTweakerSampleText();
1229         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
1230                 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1231         }
1234 function pageFadeTransition(fadeIn) {
1235         if (fadeIn) {
1236                 query("body").removeClass("transparent");
1237         } else {
1238                 query("body").addClass("transparent");
1239         }
1242 GW.themeLoadCallback_less = (fromTheme = "") => {
1243         GWLog("themeLoadCallback_less");
1244         injectSiteNavUIToggle();
1245         if (!GW.isMobile) {
1246                 injectPostNavUIToggle();
1247                 injectAppearanceAdjustUIToggle();
1248         }
1250         registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1251                 let dtf = new Intl.DateTimeFormat([], 
1252                         (window.innerWidth < 1100) ? 
1253                                 { month: 'short', day: 'numeric', year: 'numeric' } : 
1254                                         { month: 'long', day: 'numeric', year: 'numeric' });
1255                 let postDate = query(".top-post-meta .date");
1256                 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1257         });
1259         if (GW.isMobile) {
1260                 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1261         }
1263         if (!GW.isMobile) {
1264                 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1265                         queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1266                                 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1267                         });
1268                 });
1270                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1271                         // If state is not set (user has never clicked on the Less theme's appearance
1272                         // adjustment UI toggle) then show it, but then hide it after a short time.
1273                         registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1274                                 toggleAppearanceAdjustUI();
1275                                 setTimeout(toggleAppearanceAdjustUI, 3000);
1276                         });
1277                 }
1279                 if (fromTheme != "") {
1280                         allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1281                         setTimeout(function () {
1282                                 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1283                         }, 300);
1284                         setTimeout(function () {
1285                                 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1286                         }, 1800);
1287                 }
1289                 // Unset the height of the #ui-elements-container.
1290                 query("#ui-elements-container").style.height = "";
1292                 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1293                 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1294                         "#content #secondary-bar",
1295                         "#content .post .top-post-meta .date",
1296                         "#content .post .top-post-meta .comment-count",
1297                 ];
1298                 applyFilters(GW.currentFilters);
1299         }
1301         // We pre-query the relevant elements, so we don't have to run querySelectorAll
1302         // on every firing of the scroll listener.
1303         GW.scrollState = {
1304                 "lastScrollTop":                                        window.pageYOffset || document.documentElement.scrollTop,
1305                 "unbrokenDownScrollDistance":           0,
1306                 "unbrokenUpScrollDistance":                     0,
1307                 "siteNavUIToggleButton":                        query("#site-nav-ui-toggle button"),
1308                 "siteNavUIElements":                            queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1309                 "appearanceAdjustUIToggleButton":       query("#appearance-adjust-ui-toggle button")
1310         };
1311         addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1314 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible; 
1315 // otherwise, show it.
1316 function updatePostNavUIVisibility() {
1317         GWLog("updatePostNavUIVisibility");
1318         var hidePostNavUIToggle = true;
1319         queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1320                 if (getComputedStyle(element).visibility == "visible" ||
1321                         element.style.visibility == "visible" ||
1322                         element.style.visibility == "unset")
1323                         hidePostNavUIToggle = false;
1324         });
1325         queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1326                 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1327         });
1330 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1331 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be 
1332 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1333 function updateSiteNavUIState(event) {
1334         GWLog("updateSiteNavUIState");
1335         let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1336         GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? 
1337                                                                                                                 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : 
1338                                                                                                                 0;
1339         GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1340                                                                                                          (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1341                                                                                                          0;
1342         GW.scrollState.lastScrollTop = newScrollTop;
1344         // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1345         if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1346                 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1347                 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1348         }
1350         // On mobile, make site nav UI translucent on ANY scroll down.
1351         if (GW.isMobile)
1352                 GW.scrollState.siteNavUIElements.forEach(element => {
1353                         if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1354                         else element.removeClass("translucent-on-scroll");
1355                 });
1357         // Show site nav UI when scrolling a full page up, or to the top.
1358         if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight || 
1359                  GW.scrollState.lastScrollTop == 0) &&
1360                 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") && 
1361                  localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1363         // On desktop, show appearance adjust UI when scrolling to the top.
1364         if ((!GW.isMobile) && 
1365                 (GW.scrollState.lastScrollTop == 0) &&
1366                 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) && 
1367                 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1370 GW.themeUnloadCallback_less = (toTheme = "") => {
1371         GWLog("themeUnloadCallback_less");
1372         removeSiteNavUIToggle();
1373         if (!GW.isMobile) {
1374                 removePostNavUIToggle();
1375                 removeAppearanceAdjustUIToggle();
1376         }
1377         window.removeEventListener('resize', updatePostNavUIVisibility);
1379         document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1381         removeElement("#theme-less-mobile-first-row-placeholder");
1383         if (!GW.isMobile) {
1384                 // Remove spans
1385                 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1386                         element.innerHTML = element.firstChild.innerHTML;
1387                 });
1388         }
1390         (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1392         // Reset filtered elements selector to default.
1393         delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1394         applyFilters(GW.currentFilters);
1397 GW.themeLoadCallback_dark = (fromTheme = "") => {
1398         GWLog("themeLoadCallback_dark");
1399         insertHeadHTML(
1400                 "<style id='dark-theme-adjustments'>" + 
1401                 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` + 
1402                 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1403                 "</style>");
1404         registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1405                 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1406                         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)";
1407                         image.style.width = parseInt(image.style.width) + 12 + "px";
1408                         image.style.height = parseInt(image.style.height) + 12 + "px";
1409                         image.style.top = parseInt(image.style.top) - 6 + "px";
1410                         image.style.left = parseInt(image.style.left) - 6 + "px";
1411                 });
1412         });
1414 GW.themeUnloadCallback_dark = (toTheme = "") => {
1415         GWLog("themeUnloadCallback_dark");
1416         removeElement("#dark-theme-adjustments");
1419 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1420         GWLog("themeLoadCallback_brutalist");
1421         let bottomBarLinks = queryAll("#bottom-bar a");
1422         if (!GW.isMobile && bottomBarLinks.length == 5) {
1423                 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1424                 bottomBarLinks.forEach((link, i) => {
1425                         link.dataset.originalText = link.textContent;
1426                         link.textContent = newLinkTexts[i];
1427                 });
1428         }
1430 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1431         GWLog("themeUnloadCallback_brutalist");
1432         let bottomBarLinks = queryAll("#bottom-bar a");
1433         if (!GW.isMobile && bottomBarLinks.length == 5) {
1434                 bottomBarLinks.forEach(link => {
1435                         link.textContent = link.dataset.originalText;
1436                 });
1437         }
1440 GW.themeLoadCallback_classic = (fromTheme = "") => {
1441         GWLog("themeLoadCallback_classic");
1442         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1443                 button.innerHTML = "";
1444         });
1446 GW.themeUnloadCallback_classic = (toTheme = "") => {
1447         GWLog("themeUnloadCallback_classic");
1448         if (GW.isMobile && window.innerWidth <= 900) return;
1449         queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1450                 button.innerHTML = button.dataset.label;
1451         });
1454 /********************************************/
1455 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1456 /********************************************/
1458 function injectThemeTweaker() {
1459         GWLog("injectThemeTweaker");
1460         let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" + 
1461         `<div class='main-theme-tweaker-window'>
1462                 <h1>Customize appearance</h1>
1463                 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1464                 <button type='button' class='help-button' tabindex='-1'></button>
1465                 <p class='current-theme'>Current theme: <span>` + 
1466                 (readCookie("theme") || "default") + 
1467                 `</span></p>
1468                 <p class='theme-selector'></p>
1469                 <div class='controls-container'>
1470                         <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1471                                 <div class='sample-text-container'><span class='sample-text'>
1472                                         <p>Less Wrong (text)</p>
1473                                         <p><a href="#">Less Wrong (link)</a></p>
1474                                 </span></div>
1475                         </div>
1476                         <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1477                                 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1478                                 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1479                                 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1480                         </div>
1481                         <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1482                                 <input type='checkbox' id='theme-tweak-control-invert'></input>
1483                                 <label for='theme-tweak-control-invert'>Invert colors</label>
1484                         </div>
1485                         <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1486                                 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1487                                 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1488                                 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1489                         </div>
1490                         <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1491                                 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1492                                 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1493                                 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1494                         </div>
1495                         <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1496                                 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1497                                 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1498                                 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1499                         </div>
1500                         <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1501                                 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1502                                 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1503                                 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1504                         </div>
1505                 </div>
1506                 <div class='buttons-container'>
1507                         <button type="button" class="reset-defaults-button">Reset to defaults</button>
1508                         <button type='button' class='ok-button default-button'>OK</button>
1509                         <button type='button' class='cancel-button'>Cancel</button>
1510                 </div>
1511         </div>
1512         <div class="clippy-container">
1513                 <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>)
1514                 <div class='clippy'></div>
1515                 <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>
1516         </div>
1517         <div class='help-window' style='display: none;'>
1518                 <h1>Theme tweaker help</h1>
1519                 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1520                         <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1521                         <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1522                 </div>
1523                 <div class='buttons-container'>
1524                         <button type='button' class='ok-button default-button'>OK</button>
1525                         <button type='button' class='cancel-button'>Cancel</button>
1526                 </div>
1527         </div>
1528         ` + "</div>");
1530         // Clicking the background overlay closes the theme tweaker.
1531         themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1532                 GWLog("GW.themeTweaker.UIOverlayClicked");
1533                 if (event.type == 'mousedown') {
1534                         themeTweakerUI.style.opacity = "0.01";
1535                 } else {
1536                         toggleThemeTweakerUI();
1537                         themeTweakerUI.style.opacity = "1.0";
1538                         themeTweakReset();
1539                 }
1540         }, true);
1542         // Intercept clicks, so they don't "fall through" the background overlay.
1543         (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1545         let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1546         themeTweakerUI.queryAll("input").forEach(field => {
1547                 // All input types in the theme tweaker receive a 'change' event when
1548                 // their value is changed. (Range inputs, in particular, receive this 
1549                 // event when the user lets go of the handle.) This means we should
1550                 // update the filters for the entire page, to match the new setting.
1551                 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1552                         GWLog("GW.themeTweaker.fieldValueChanged");
1553                         if (event.target.id == 'theme-tweak-control-invert') {
1554                                 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1555                         } else if (event.target.type == 'range') {
1556                                 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1557                                 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1558                                 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1559                         } else if (event.target.id == 'theme-tweak-control-clippy') {
1560                                 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1561                         }
1562                         // Clear the sample text filters.
1563                         sampleTextContainer.style.filter = "";
1564                         // Apply the new filters globally.
1565                         applyFilters(GW.currentFilters);
1566                 });
1568                 // Range inputs receive an 'input' event while being scrubbed, updating
1569                 // "live" as the handle is moved. We don't want to change the filters 
1570                 // for the actual page while this is happening, but we do want to change
1571                 // the filters for the *sample text*, so the user can see what effects
1572                 // his changes are having, live, without having to let go of the handle.
1573                 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1574                         GWLog("GW.themeTweaker.fieldInputReceived");
1575                         var sampleTextFilters = GW.currentFilters;
1577                         let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1578                         query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1579                         sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1581                         sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1582                 });
1583         });
1585         themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1586                 GWLog("GW.themeTweaker.minimizeButtonClicked");
1587                 let themeTweakerStyle = query("#theme-tweaker-style");
1589                 if (event.target.hasClass("minimize")) {
1590                         event.target.removeClass("minimize");
1591                         themeTweakerStyle.innerHTML = 
1592                                 `#theme-tweaker-ui .main-theme-tweaker-window {
1593                                         width: 320px;
1594                                         height: 31px;
1595                                         overflow: hidden;
1596                                         padding: 30px 0 0 0;
1597                                         top: 20px;
1598                                         right: 20px;
1599                                         left: auto;
1600                                 }
1601                                 #theme-tweaker-ui::after {
1602                                         top: 27px;
1603                                         right: 27px;
1604                                 }
1605                                 #theme-tweaker-ui::before {
1606                                         opacity: 0.0;
1607                                         height: 0;
1608                                 }
1609                                 #theme-tweaker-ui .clippy-container {
1610                                         opacity: 1.0;
1611                                 }
1612                                 #theme-tweaker-ui .clippy-container .hint span {
1613                                         color: #c00;
1614                                 }
1615                                 #theme-tweaker-ui {
1616                                         height: 0;
1617                                 }
1618                                 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1619                                         pointer-events: none;
1620                                 }`;
1621                         event.target.addClass("maximize");
1622                 } else {
1623                         event.target.removeClass("maximize");
1624                         themeTweakerStyle.innerHTML = 
1625                                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1626                                         pointer-events: none;
1627                                 }`;
1628                         event.target.addClass("minimize");
1629                 }
1630         });
1631         themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1632                 GWLog("GW.themeTweaker.helpButtonClicked");
1633                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1634                 toggleThemeTweakerHelpWindow();
1635         });
1636         themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1637                 GWLog("GW.themeTweaker.resetDefaultsButtonClicked");
1638                 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1639                 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1640                         let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1641                         slider.value = slider.dataset['defaultValue'];
1642                         themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1643                 });
1644                 GW.currentFilters = { };
1645                 applyFilters(GW.currentFilters);
1647                 GW.currentTextZoom = "1.0";
1648                 setTextZoom(GW.currentTextZoom);
1650                 setSelectedTheme("default");
1651         });
1652         themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1653                 GWLog("GW.themeTweaker.cancelButtonClicked");
1654                 toggleThemeTweakerUI();
1655                 themeTweakReset();
1656         });
1657         themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1658                 GWLog("GW.themeTweaker.OKButtonClicked");
1659                 toggleThemeTweakerUI();
1660                 themeTweakSave();
1661         });
1662         themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1663                 GWLog("GW.themeTweaker.helpWindowCancelButtonClicked");
1664                 toggleThemeTweakerHelpWindow();
1665                 themeTweakerResetSettings();
1666         });
1667         themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1668                 GWLog("GW.themeTweaker.helpWindowOKButtonClicked");
1669                 toggleThemeTweakerHelpWindow();
1670                 themeTweakerSaveSettings();
1671         });
1673         themeTweakerUI.queryAll(".notch").forEach(notch => {
1674                 notch.addActivateEvent(GW.themeTweaker.sliderNotchClicked = (event) => {
1675                         GWLog("GW.themeTweaker.sliderNotchClicked");
1676                         let slider = event.target.parentElement.query("input[type='range']");
1677                         slider.value = slider.dataset['defaultValue'];
1678                         event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1679                         GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1680                         applyFilters(GW.currentFilters);
1681                 });
1682         });
1684         themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1685                 GWLog("GW.themeTweaker.clippyCloseButtonClicked");
1686                 themeTweakerUI.query(".clippy-container").style.display = "none";
1687                 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1688                 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1689         });
1691         query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1693         themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1694         themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1695                 button.addActivateEvent(GW.themeSelectButtonClicked);
1696         });
1698         themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1699                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1700         });
1702         let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'>&#xf1de;</button></div>`);
1703         themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1704                 GWLog("GW.themeTweaker.toggleButtonClicked");
1705                 GW.themeTweakerStyleSheetAvailable = () => {
1706                         GWLog("GW.themeTweakerStyleSheetAvailable");
1707                         themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1709                         themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1710                         [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1711                                 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1712                                 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1713                                 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1714                         });
1716                         toggleThemeTweakerUI();
1717                         event.target.disabled = true;
1718                 };
1720                 if (query("link[href^='/css/theme_tweaker.css']")) {
1721                         // Theme tweaker CSS is already loaded.
1722                         GW.themeTweakerStyleSheetAvailable();
1723                 } else {
1724                         // Load the theme tweaker CSS (if not loaded).
1725                         let themeTweakerStyleSheet = document.createElement('link');
1726                         themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1727                         themeTweakerStyleSheet.setAttribute('href', '/css/theme_tweaker.css');
1728                         themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1729                         query("head").appendChild(themeTweakerStyleSheet);
1730                 }
1731         });
1733 function toggleThemeTweakerUI() {
1734         GWLog("toggleThemeTweakerUI");
1735         let themeTweakerUI = query("#theme-tweaker-ui");
1736         themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1737         query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" : 
1738                 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1739                         pointer-events: none;
1740                 }`;
1741         if (themeTweakerUI.style.display != "none") {
1742                 // Save selected theme.
1743                 GW.currentTheme = (readCookie("theme") || "default");
1744                 // Focus invert checkbox.
1745                 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1746                 // Show sample text in appropriate font.
1747                 updateThemeTweakerSampleText();
1748                 // Disable tab-selection of the search box.
1749                 setSearchBoxTabSelectable(false);
1750                 // Disable scrolling of the page.
1751                 togglePageScrolling(false);
1752         } else {
1753                 query("#theme-tweaker-toggle button").disabled = false;
1754                 // Re-enable tab-selection of the search box.
1755                 setSearchBoxTabSelectable(true);
1756                 // Re-enable scrolling of the page.
1757                 togglePageScrolling(true);
1758         }
1759         // Set theme tweaker assistant visibility.
1760         query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1762 function setSearchBoxTabSelectable(selectable) {
1763         GWLog("setSearchBoxTabSelectable");
1764         query("input[type='search']").tabIndex = selectable ? "" : "-1";
1765         query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1767 function toggleThemeTweakerHelpWindow() {
1768         GWLog("toggleThemeTweakerHelpWindow");
1769         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1770         themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1771         if (themeTweakerHelpWindow.style.display != "none") {
1772                 // Focus theme tweaker assistant checkbox.
1773                 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1774                 // Disable interaction on main theme tweaker window.
1775                 query("#theme-tweaker-ui").style.pointerEvents = "none";
1776                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1777         } else {
1778                 // Re-enable interaction on main theme tweaker window.
1779                 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1780                 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1781         }
1783 function themeTweakReset() {
1784         GWLog("themeTweakReset");
1785         setSelectedTheme(GW.currentTheme);
1786         GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1787         applyFilters(GW.currentFilters);
1788         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1789         setTextZoom(GW.currentTextZoom);
1791 function themeTweakSave() {
1792         GWLog("themeTweakSave");
1793         GW.currentTheme = (readCookie("theme") || "default");
1794         localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1795         localStorage.setItem("text-zoom", GW.currentTextZoom);
1798 function themeTweakerResetSettings() {
1799         GWLog("themeTweakerResetSettings");
1800         query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1801         query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1803 function themeTweakerSaveSettings() {
1804         GWLog("themeTweakerSaveSettings");
1805         localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1807 function updateThemeTweakerSampleText() {
1808         GWLog("updateThemeTweakerSampleText");
1809         let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1811         // This causes the sample text to take on the properties of the body text of a post.
1812         sampleText.removeClass("body-text");
1813         let bodyTextElement = query(".post-body") || query(".comment-body");
1814         sampleText.addClass("body-text");
1815         sampleText.style.color = bodyTextElement ? 
1816                 getComputedStyle(bodyTextElement).color : 
1817                 getComputedStyle(query("#content")).color;
1819         // Here we find out what is the actual background color that will be visible behind
1820         // the body text of posts, and set the sample text’s background to that.
1821         let findStyleBackground = (selector) => {
1822                 let x;
1823                 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
1824                         if(rule.selectorText == selector)
1825                                 x = rule;
1826                 });
1827                 return x.style.backgroundColor;
1828         };
1830         sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
1833 /*********************/
1834 /* PAGE QUICK-NAV UI */
1835 /*********************/
1837 function injectQuickNavUI() {
1838         GWLog("injectQuickNavUI");
1839         let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1840         `<a href='#top' title="Up to top [,]" accesskey=','>&#xf106;</a>
1841         <a href='#comments' title="Comments [/]" accesskey='/'>&#xf036;</a>
1842         <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'>&#xf107;</a>
1843         ` + "</div>");
1846 /**********************/
1847 /* NEW COMMENT NAV UI */
1848 /**********************/
1850 function injectNewCommentNavUI(newCommentsCount) {
1851         GWLog("injectNewCommentNavUI");
1852         let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" + 
1853         `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'>&#xf0d8;</button>
1854         <span class='new-comments-count'></span>
1855         <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'>&#xf0d7;</button>`
1856         + "</div>");
1858         newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1859                 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1860                         GWLog("GW.commentQuicknavButtonClicked");
1861                         scrollToNewComment(/next/.test(event.target.className));
1862                         event.target.blur();
1863                 });
1864         });
1866         document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => { 
1867                 GWLog("GW.commentQuicknavKeyPressed");
1868                 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1869                 if (event.key == ",") scrollToNewComment(false);
1870                 if (event.key == ".") scrollToNewComment(true)
1871         });
1873         let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1874         + `<span>Since:</span>`
1875         + `<input type='text' class='hns-date'></input>`
1876         + "</div>");
1878         hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1879                 GWLog("GW.hnsDatePickerValueChanged");
1880                 let hnsDate = time_fromHuman(event.target.value);
1881                 if(hnsDate) {
1882                         setHistoryLastVisitedDate(hnsDate);
1883                         let newCommentsCount = highlightCommentsSince(hnsDate);
1884                         updateNewCommentNavUI(newCommentsCount);
1885                 }
1886         }, false);
1888         newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1889                 GWLog("GW.newCommentsCountClicked");
1890                 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1891                 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1892         });
1895 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1896 function time_fromHuman(string) {
1897         /* Convert a human-readable date into a JS timestamp */
1898         if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1899                 string = string.replace(' ', 'T');  // revert nice spacing
1900                 string += ':00.000Z';  // complete ISO 8601 date
1901                 time = Date.parse(string);  // milliseconds since epoch
1903                 // browsers handle ISO 8601 without explicit timezone differently
1904                 // thus, we have to fix that by hand
1905                 time += (new Date()).getTimezoneOffset() * 60e3;
1906         } else {
1907                 string = string.replace(' at', '');
1908                 time = Date.parse(string);  // milliseconds since epoch
1909         }
1910         return time;
1913 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1914         GWLog("updateNewCommentNavUI");
1915         // Update the new comments count.
1916         let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1917         newCommentsCountLabel.innerText = newCommentsCount;
1918         newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1920         // Update the date picker field.
1921         if (hnsDate != -1) {
1922                 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1923         }
1926 /***************************/
1927 /* TEXT SIZE ADJUSTMENT UI */
1928 /***************************/
1930 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1931         GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1932         var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1933         if (event.target.hasClass("decrease")) {
1934                 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1935         } else if (event.target.hasClass("increase")) {
1936                 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1937         } else {
1938                 zoomFactor = 1.0;
1939         }
1940         setTextZoom(zoomFactor);
1941         GW.currentTextZoom = `${zoomFactor}`;
1943         if (event.target.parentElement.id == "text-size-adjustment-ui") {
1944                 localStorage.setItem("text-zoom", GW.currentTextZoom);
1945         }
1948 function injectTextSizeAdjustmentUIReal() {
1949         GWLog("injectTextSizeAdjustmentUIReal");
1950         let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1951         + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'>&#xf068;</button>`
1952         + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1953         + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='>&#xf067;</button>`
1954         + "</div>");
1956         textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1957                 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1958         });
1960         GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1963 function injectTextSizeAdjustmentUI() {
1964         GWLog("injectTextSizeAdjustmentUI");
1965         if (query("#text-size-adjustment-ui") != null) return;
1966         if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1967         else document.addEventListener("DOMContentLoaded", () => {
1968                 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1969         }, {once: true});
1972 /********************************/
1973 /* COMMENTS VIEW MODE SELECTION */
1974 /********************************/
1976 function injectCommentsViewModeSelector() {
1977         GWLog("injectCommentsViewModeSelector");
1978         let commentsContainer = query("#comments");
1979         if (commentsContainer == null) return;
1981         let currentModeThreaded = (location.href.search("chrono=t") == -1);
1982         let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1984         let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1985         + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'>&#xf038;</a>`
1986         + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'>&#xf017;</a>`
1987         + "</div>");
1989 //      commentsViewModeSelector.queryAll("a").forEach(button => {
1990 //              button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1991 //      });
1993         if (!currentModeThreaded) {
1994                 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1995                         commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1996                         commentParentLink.addClass("inline-author");
1997                         commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1998                 });
2000                 queryAll(".comment-child-links a").forEach(commentChildLink => {
2001                         commentChildLink.textContent = commentChildLink.textContent.slice(1);
2002                         commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2003                 });
2005                 rectifyChronoModeCommentChildLinks();
2007                 commentsContainer.addClass("chrono");
2008         } else {
2009                 commentsContainer.addClass("threaded");
2010         }
2012         // Remove extraneous top-level comment thread in chrono mode.
2013         let topLevelCommentThread = query("#comments > .comment-thread");
2014         if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2017 // function commentsViewModeSelectorButtonClicked(event) {
2018 //      event.preventDefault();
2019 // 
2020 //      var newDocument;
2021 //      let request = new XMLHttpRequest();
2022 //      request.open("GET", event.target.href);
2023 //      request.onreadystatechange = () => {
2024 //              if (request.readyState != 4) return;
2025 //              newDocument = htmlToElement(request.response);
2026 // 
2027 //              let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2028 // 
2029 //              // Update the buttons.
2030 //              event.target.addClass("selected");
2031 //              event.target.parentElement.query("." + classes.old).removeClass("selected");
2032 // 
2033 //              // Update the #comments container.
2034 //              let commentsContainer = query("#comments");
2035 //              commentsContainer.removeClass(classes.old);
2036 //              commentsContainer.addClass(classes.new);
2037 // 
2038 //              // Update the content.
2039 //              commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2040 //      };
2041 //      request.send();
2042 // }
2043 // 
2044 // function htmlToElement(html) {
2045 //     var template = document.createElement('template');
2046 //     template.innerHTML = html.trim();
2047 //     return template.content;
2048 // }
2050 function rectifyChronoModeCommentChildLinks() {
2051         GWLog("rectifyChronoModeCommentChildLinks");
2052         queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2053                 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2054                 let childLinks = commentChildLinksContainer.queryAll("a");
2055                 childLinks.forEach((link, index) => {
2056                         link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2057                 });
2059                 // Sort by date.
2060                 let childLinksArray = Array.from(childLinks)
2061                 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2062                 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2063         });
2065 function childrenOfComment(commentID) {
2066         return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2067                 let commentParentLink = commentItem.query("a.comment-parent-link");
2068                 return ((commentParentLink||{}).hash == "#" + commentID);
2069         });
2072 /********************************/
2073 /* COMMENTS LIST MODE SELECTION */
2074 /********************************/
2076 function injectCommentsListModeSelector() {
2077         GWLog("injectCommentsListModeSelector");
2078         if (query("#content > .comment-thread") == null) return;
2080         let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2081         + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2082         + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2083         + "</div>";
2085         if (query(".sublevel-nav") || query("#top-nav-bar")) {
2086                 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2087         } else {
2088                 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2089         }
2090         let commentsListModeSelector = query("#comments-list-mode-selector");
2092         commentsListModeSelector.queryAll("button").forEach(button => {
2093                 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2094                         GWLog("GW.commentsListModeSelectButtonClicked");
2095                         event.target.parentElement.queryAll("button").forEach(button => {
2096                                 button.removeClass("selected");
2097                                 button.disabled = false;
2098                                 button.accessKey = '`';
2099                         });
2100                         localStorage.setItem("comments-list-mode", event.target.className);
2101                         event.target.addClass("selected");
2102                         event.target.disabled = true;
2103                         event.target.removeAttribute("accesskey");
2105                         if (event.target.hasClass("expanded")) {
2106                                 query("#content").removeClass("compact");
2107                         } else {
2108                                 query("#content").addClass("compact");
2109                         }
2110                 });
2111         });
2113         let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2114         if (savedMode == "compact")
2115                 query("#content").addClass("compact");
2116         commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2117         commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2118         commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2120         if (GW.isMobile) {
2121                 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2122                         commentParentLink.addActivateEvent(function (event) {
2123                                 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2124                                 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2125                         }, false);
2126                 });
2127         }
2130 /**********************/
2131 /* SITE NAV UI TOGGLE */
2132 /**********************/
2134 function injectSiteNavUIToggle() {
2135         GWLog("injectSiteNavUIToggle");
2136         let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf0c9;</button></div>");
2137         siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2138                 GWLog("GW.siteNavUIToggleButtonClicked");
2139                 toggleSiteNavUI();
2140                 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2141         });
2143         if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2145 function removeSiteNavUIToggle() {
2146         GWLog("removeSiteNavUIToggle");
2147         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2148                 element.removeClass("engaged");
2149         });
2150         removeElement("#site-nav-ui-toggle");
2152 function toggleSiteNavUI() {
2153         GWLog("toggleSiteNavUI");
2154         queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2155                 element.toggleClass("engaged");
2156                 element.removeClass("translucent-on-scroll");
2157         });
2160 /**********************/
2161 /* POST NAV UI TOGGLE */
2162 /**********************/
2164 function injectPostNavUIToggle() {
2165         GWLog("injectPostNavUIToggle");
2166         let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'>&#xf14e;</button></div>");
2167         postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2168                 GWLog("GW.postNavUIToggleButtonClicked");
2169                 togglePostNavUI();
2170                 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2171         });
2173         if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2175 function removePostNavUIToggle() {
2176         GWLog("removePostNavUIToggle");
2177         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2178                 element.removeClass("engaged");
2179         });
2180         removeElement("#post-nav-ui-toggle");
2182 function togglePostNavUI() {
2183         GWLog("togglePostNavUI");
2184         queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2185                 element.toggleClass("engaged");
2186         });
2189 /*******************************/
2190 /* APPEARANCE ADJUST UI TOGGLE */
2191 /*******************************/
2193 function injectAppearanceAdjustUIToggle() {
2194         GWLog("injectAppearanceAdjustUIToggle");
2195         let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'>&#xf013;</button></div>");
2196         appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
2197                 GWLog("GW.appearanceAdjustUIToggleButtonClicked");
2198                 toggleAppearanceAdjustUI();
2199                 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
2200         });
2202         if (GW.isMobile) {
2203                 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
2204                 themeSelectorCloseButton.addClass("theme-selector-close-button");
2205                 themeSelectorCloseButton.innerHTML = "&#xf057;";
2206                 query("#theme-selector").appendChild(themeSelectorCloseButton);
2207                 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
2208         } else {
2209                 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
2210         }
2212 function removeAppearanceAdjustUIToggle() {
2213         GWLog("removeAppearanceAdjustUIToggle");
2214         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2215                 element.removeClass("engaged");
2216         });
2217         removeElement("#appearance-adjust-ui-toggle");
2219 function toggleAppearanceAdjustUI() {
2220         GWLog("toggleAppearanceAdjustUI");
2221         queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2222                 element.toggleClass("engaged");
2223         });
2226 /**************************/
2227 /* WORD COUNT & READ TIME */
2228 /**************************/
2230 function toggleReadTimeOrWordCount(addWordCountClass) {
2231         GWLog("toggleReadTimeOrWordCount");
2232         queryAll(".post-meta .read-time").forEach(element => {
2233                 if (addWordCountClass) element.addClass("word-count");
2234                 else element.removeClass("word-count");
2236                 let titleParts = /(\S+)(.+)$/.exec(element.title);
2237                 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2238         });
2241 /**************************/
2242 /* PROMPT TO SAVE CHANGES */
2243 /**************************/
2245 function enableBeforeUnload() {
2246         window.onbeforeunload = function () { return true; };
2248 function disableBeforeUnload() {
2249         window.onbeforeunload = null;
2252 /***************************/
2253 /* ORIGINAL POSTER BADGING */
2254 /***************************/
2256 function markOriginalPosterComments() {
2257         GWLog("markOriginalPosterComments");
2258         let postAuthor = query(".post .author");
2259         if (postAuthor == null) return;
2261         queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2262                 if (author.dataset.userid == postAuthor.dataset.userid ||
2263                         (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2264                         author.addClass("original-poster");
2265                         author.title += "Original poster";
2266                 }
2267         });
2270 /********************************/
2271 /* EDIT POST PAGE SUBMIT BUTTON */
2272 /********************************/
2274 function setEditPostPageSubmitButtonText() {
2275         GWLog("setEditPostPageSubmitButtonText");
2276         if (!query("#content").hasClass("edit-post-page")) return;
2278         queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2279                 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2280                         GWLog("GW.postSectionSelectorValueChanged");
2281                         updateEditPostPageSubmitButtonText();
2282                 });
2283         });
2285         updateEditPostPageSubmitButtonText();
2287 function updateEditPostPageSubmitButtonText() {
2288         GWLog("updateEditPostPageSubmitButtonText");
2289         let submitButton = query("input[type='submit']");
2290         if (query("input#drafts").checked == true) 
2291                 submitButton.value = "Save Draft";
2292         else if (query(".posting-controls").hasClass("edit-existing-post"))
2293                 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2294         else
2295                 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2298 /*****************/
2299 /* ANTI-KIBITZER */
2300 /*****************/
2302 function numToAlpha(n) {
2303         let ret = "";
2304         do {
2305                 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2306                 n = Math.floor((n / 26) - 1);
2307         } while (n >= 0);
2308         return ret;
2311 function injectAntiKibitzer() {
2312         GWLog("injectAntiKibitzer");
2313         // Inject anti-kibitzer toggle controls.
2314         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>");
2315         antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2316                 GWLog("GW.antiKibitzerToggleButtonClicked");
2317                 if (query("#anti-kibitzer-toggle").hasClass("engaged") && 
2318                         !event.shiftKey &&
2319                         !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!)")) {
2320                         event.target.blur();
2321                         return;
2322                 }
2324                 toggleAntiKibitzerMode();
2325                 event.target.blur();
2326         });
2328         // Activate anti-kibitzer mode (if needed).
2329         if (localStorage.getItem("antikibitzer") == "true")
2330                 toggleAntiKibitzerMode();
2332         // Remove temporary CSS that hides the authors and karma values.
2333         removeElement("#antikibitzer-temp");
2336 function toggleAntiKibitzerMode() {
2337         GWLog("toggleAntiKibitzerMode");
2338         // This will be the URL of the user's own page, if logged in, or the URL of
2339         // the login page otherwise.
2340         let userTabTarget = query("#nav-item-login .nav-inner").href;
2341         let pageHeadingElement = query("h1.page-main-heading");
2343         let userCount = 0;
2344         let userFakeName = { };
2346         let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2348         let postAuthor = query(".post-page .post-meta .author");
2349         if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2351         let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2352         if (antiKibitzerToggle.hasClass("engaged")) {
2353                 localStorage.setItem("antikibitzer", "false");
2355                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2356                 if (redirectTarget) {
2357                         window.location = redirectTarget;
2358                         return;
2359                 }
2361                 // Individual comment page title and header
2362                 if (query(".individual-thread-page")) {
2363                         let replacer = (node) => {
2364                                 if (!node) return;
2365                                 node.firstChild.replaceWith(node.dataset["trueContent"]);
2366                         }
2367                         replacer(query("title:not(.fake-title)"));
2368                         replacer(query("#content > h1"));
2369                 }
2371                 // Author names/links.
2372                 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2373                         author.textContent = author.dataset["trueName"];
2374                         if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2376                         author.removeClass("redacted");
2377                 });
2378                 // Post/comment karma values.
2379                 queryAll(".karma-value.redacted").forEach(karmaValue => {
2380                         karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2382                         karmaValue.removeClass("redacted");
2383                 });
2384                 // Link post domains.
2385                 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2386                         linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2388                         linkPostDomain.removeClass("redacted");
2389                 });
2391                 antiKibitzerToggle.removeClass("engaged");
2392         } else {
2393                 localStorage.setItem("antikibitzer", "true");
2395                 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2396                 if (redirectTarget) {
2397                         window.location = redirectTarget;
2398                         return;
2399                 }
2401                 // Individual comment page title and header
2402                 if (query(".individual-thread-page")) {
2403                         let replacer = (node) => {
2404                                 if (!node) return;
2405                                 node.dataset["trueContent"] = node.firstChild.wholeText;
2406                                 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2407                                 node.firstChild.replaceWith(newText);
2408                         }
2409                         replacer(query("title:not(.fake-title)"));
2410                         replacer(query("#content > h1"));
2411                 }
2413                 removeElement("title.fake-title");
2415                 // Author names/links.
2416                 queryAll(".author, .inline-author").forEach(author => {
2417                         // Skip own posts/comments.
2418                         if (author.hasClass("own-user-author"))
2419                                 return;
2421                         let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2423                         if(!userid) return;
2425                         author.dataset["trueName"] = author.textContent;
2426                         author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2428                         if (/\/user/.test(author.href)) {
2429                                 author.dataset["trueLink"] = author.pathname;
2430                                 author.href = "/user?id=" + author.dataset["userid"];
2431                         }
2433                         author.addClass("redacted");
2434                 });
2435                 // Post/comment karma values.
2436                 queryAll(".karma-value").forEach(karmaValue => {
2437                         // Skip own posts/comments.
2438                         if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2439                                 return;
2441                         karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2442                         karmaValue.innerHTML = "##<span> points</span>";
2444                         karmaValue.addClass("redacted");
2445                 });
2446                 // Link post domains.
2447                 queryAll(".link-post-domain").forEach(linkPostDomain => {
2448                         // Skip own posts/comments.
2449                         if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2450                                 return;
2452                         linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2453                         linkPostDomain.textContent = "redacted.domain.tld";
2455                         linkPostDomain.addClass("redacted");
2456                 });
2458                 antiKibitzerToggle.addClass("engaged");
2459         }
2462 /*******************************/
2463 /* COMMENT SORT MODE SELECTION */
2464 /*******************************/
2466 var CommentSortMode = Object.freeze({
2467         TOP:            "top",
2468         NEW:            "new",
2469         OLD:            "old",
2470         HOT:            "hot"
2472 function sortComments(mode) {
2473         GWLog("sortComments");
2474         let commentsContainer = query("#comments");
2476         commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2477         commentsContainer.addClass("sorting");
2479         GW.commentValues = { };
2480         let clonedCommentsContainer = commentsContainer.cloneNode(true);
2481         clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2482                 var comparator;
2483                 switch (mode) {
2484                 case CommentSortMode.NEW:
2485                         comparator = (a,b) => commentDate(b) - commentDate(a);
2486                         break;
2487                 case CommentSortMode.OLD:
2488                         comparator = (a,b) => commentDate(a) - commentDate(b);
2489                         break;
2490                 case CommentSortMode.HOT:
2491                         comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2492                         break;
2493                 case CommentSortMode.TOP:
2494                 default:
2495                         comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2496                         break;
2497                 }
2498                 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2499         });
2500         removeElement(commentsContainer.lastChild);
2501         commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2502         GW.commentValues = { };
2504         if (loggedInUserId) {
2505                 // Re-activate vote buttons.
2506                 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2507                         voteButton.addActivateEvent(voteButtonClicked);
2508                 });
2510                 // Re-activate comment action buttons.
2511                 commentsContainer.queryAll(".action-button").forEach(button => {
2512                         button.addActivateEvent(GW.commentActionButtonClicked);
2513                 });
2514         }
2516         // Re-activate comment-minimize buttons.
2517         queryAll(".comment-minimize-button").forEach(button => {
2518                 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2519         });
2521         // Re-add comment parent popups.
2522         addCommentParentPopups();
2523         
2524         // Redo new-comments highlighting.
2525         highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2527         requestAnimationFrame(() => {
2528                 commentsContainer.removeClass("sorting");
2529                 commentsContainer.addClass("sorted-" + mode);
2530         });
2532 function commentKarmaValue(commentOrSelector) {
2533         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2534         try {
2535                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2536         } catch(e) {return null};
2538 function commentDate(commentOrSelector) {
2539         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2540         try {
2541                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2542         } catch(e) {return null};
2544 function commentVoteCount(commentOrSelector) {
2545         if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2546         try {
2547                 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2548         } catch(e) {return null};
2551 function injectCommentsSortModeSelector() {
2552         GWLog("injectCommentsSortModeSelector");
2553         let topCommentThread = query("#comments > .comment-thread");
2554         if (topCommentThread == null) return;
2556         // Do not show sort mode selector if there is no branching in comment tree.
2557         if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2559         let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" + 
2560                 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +  
2561                 "</div>";
2562         topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2563         let commentsSortModeSelector = query("#comments-sort-mode-selector");
2565         commentsSortModeSelector.queryAll("button").forEach(button => {
2566                 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2567                         GWLog("GW.commentsSortModeSelectButtonClicked");
2568                         event.target.parentElement.queryAll("button").forEach(button => {
2569                                 button.removeClass("selected");
2570                                 button.disabled = false;
2571                         });
2572                         event.target.addClass("selected");
2573                         event.target.disabled = true;
2575                         setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2576                         setCommentsSortModeSelectButtonsAccesskey();
2577                 });
2578         });
2580         // TODO: Make this actually get the current sort mode (if that's saved).
2581         // TODO: Also change the condition here to properly get chrono/threaded mode,
2582         // when that is properly done with cookies.
2583         let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2584         topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2585         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2586         commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2587         setCommentsSortModeSelectButtonsAccesskey();
2590 function setCommentsSortModeSelectButtonsAccesskey() {
2591         GWLog("setCommentsSortModeSelectButtonsAccesskey");
2592         queryAll("#comments-sort-mode-selector button").forEach(button => {
2593                 button.removeAttribute("accesskey");
2594                 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2595         });
2596         let selectedButton = query("#comments-sort-mode-selector button.selected");
2597         let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2598         nextButtonInCycle.accessKey = "z";
2599         nextButtonInCycle.title += " [z]";
2602 /*************************/
2603 /* COMMENT PARENT POPUPS */
2604 /*************************/
2606 function previewPopupsEnabled() {
2607         let isDisabled = localStorage.getItem("preview-popups-disabled");
2608         return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
2611 function setPreviewPopupsEnabled(state) {
2612         localStorage.setItem("preview-popups-disabled", !state);
2613         updatePreviewPopupToggle();
2616 function updatePreviewPopupToggle() {
2617         let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
2618         query("#preview-popup-toggle").setAttribute("style", style);
2621 function injectPreviewPopupToggle() {
2622         GWLog("injectPreviewPopupToggle");
2624         let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
2625         // This is required because Chrome can't use filters on an externally used SVG element.
2626         fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
2627         updatePreviewPopupToggle();
2628         toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
2631 var currentPreviewPopup = { };
2633 function removePreviewPopup(previewPopup) {
2634         if(previewPopup.element)
2635                 removeElement(previewPopup.element);
2637         if(previewPopup.timeout)
2638                 clearTimeout(previewPopup.timeout);
2640         if(currentPreviewPopup.pointerListener)
2641                 window.removeEventListener("pointermove", previewPopup.pointerListener);
2643         if(currentPreviewPopup.mouseoutListener)
2644                 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2646         if(currentPreviewPopup.scrollListener)
2647                 window.removeEventListener("scroll", previewPopup.scrollListener);
2649         currentPreviewPopup = { };
2652 function addCommentParentPopups() {
2653         GWLog("addCommentParentPopups");
2654         //if (!query("#content").hasClass("comment-thread-page")) return;
2656         queryAll("a[href]").forEach(linkTag => {
2657                 let linkHref = linkTag.getAttribute("href");
2659                 let url;
2660                 try { url = new URL(linkHref, window.location.href); }
2661                 catch(e) { }
2662                 if(!url) return;
2664                 if(GW.sites[url.host]) {
2665                         let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
2666                         
2667                         if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
2668                                 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
2669                                         if(event.pointerType == "touch") return;
2670                                         GWLog("GW.commentParentLinkMouseOver");
2671                                         removePreviewPopup(currentPreviewPopup);
2672                                         let parentID = linkHref;
2673                                         var parent, popup;
2674                                         if (!(parent = (query(parentID)||{}).firstChild)) return;
2675                                         var highlightClassName;
2676                                         if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2677                                                 parentHighlightClassName = "comment-item-highlight-faint";
2678                                                 popup = parent.cloneNode(true);
2679                                                 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2680                                                 linkTag.addEventListener("mouseout", (event) => {
2681                                                         removeElement(popup);
2682                                                 }, {once: true});
2683                                                 linkTag.closest(".comments > .comment-thread").appendChild(popup);
2684                                         } else {
2685                                                 parentHighlightClassName = "comment-item-highlight";
2686                                         }
2687                                         parent.parentNode.addClass(parentHighlightClassName);
2688                                         linkTag.addEventListener("mouseout", (event) => {
2689                                                 parent.parentNode.removeClass(parentHighlightClassName);
2690                                         }, {once: true});
2691                                 });
2692                         }
2693                         else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
2694                                 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
2695                                 && !(url.searchParams.get('format'))
2696                                 && !linkTag.closest("nav:not(.post-nav-links)")
2697                                 && (!url.hash || linkCommentId)
2698                                 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
2699                                 linkTag.addEventListener("pointerover", event => {
2700                                         if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
2701                                         if(currentPreviewPopup.linkTag) return;
2702                                         linkTag.createPreviewPopup();
2703                                 });
2704                                 linkTag.createPreviewPopup = function() {
2705                                         removePreviewPopup(currentPreviewPopup);
2707                                         currentPreviewPopup = {linkTag: linkTag};
2708                                         
2709                                         let popup = document.createElement("iframe");
2710                                         currentPreviewPopup.element = popup;
2712                                         let popupTarget = linkHref;
2713                                         if(popupTarget.match(/#comment-/)) {
2714                                                 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
2715                                         }
2716                                         // 'theme' attribute is required for proper caching
2717                                         popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
2718                                         popup.addClass("preview-popup");
2719                                         
2720                                         let linkRect = linkTag.getBoundingClientRect();
2722                                         if(linkRect.right + 710 < window.innerWidth)
2723                                                 popup.style.left = linkRect.right + 10 + "px";
2724                                         else
2725                                                 popup.style.right = "10px";
2727                                         popup.style.width = "700px";
2728                                         popup.style.height = "500px";
2729                                         popup.style.visibility = "hidden";
2730                                         popup.style.transition = "none";
2732                                         let recenter = function() {
2733                                                 let popupHeight = 500;
2734                                                 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
2735                                                         let popupContent = popup.contentDocument.querySelector("#content");
2736                                                         if(popupContent) {
2737                                                                 popupHeight = popupContent.clientHeight + 2;
2738                                                                 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
2739                                                                 popup.style.height = popupHeight + "px";
2740                                                         }
2741                                                 }
2742                                                 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
2743                                         }
2745                                         recenter();
2747                                         query('#content').insertAdjacentElement("beforeend", popup);
2749                                         let clickListener = event => {
2750                                                 if(!event.target.closest("a, input, label")
2751                                                    && !event.target.closest("popup-hide-button")) {
2752                                                         window.location = linkHref;
2753                                                 }
2754                                         };
2756                                         popup.addEventListener("load", () => {
2757                                                 let hideButton = popup.contentDocument.createElement("div");
2758                                                 hideButton.className = "popup-hide-button";
2759                                                 hideButton.insertAdjacentText('beforeend', "\uF070");
2760                                                 hideButton.onclick = (event) => {
2761                                                         removePreviewPopup(currentPreviewPopup);
2762                                                         setPreviewPopupsEnabled(false);
2763                                                         event.stopPropagation();
2764                                                 }
2765                                                 popup.contentDocument.body.appendChild(hideButton);
2766                                                 
2767                                                 let body = popup.contentDocument.body;
2768                                                 body.addEventListener("click", clickListener);
2769                                                 body.style.cursor = "pointer";
2771                                                 recenter();
2772                                         });
2774                                         popup.contentDocument.body.addEventListener("click", clickListener);
2775                                         
2776                                         currentPreviewPopup.timeout = setTimeout(() => {
2777                                                 recenter();
2779                                                 requestIdleCallback(() => {
2780                                                         if(currentPreviewPopup.element === popup) {
2781                                                                 popup.scrolling = "";
2782                                                                 popup.style.visibility = "unset";
2783                                                                 popup.style.transition = null;
2785                                                                 popup.animate([
2786                                                                         { opacity: 0, transform: "translateY(10%)" },
2787                                                                         { opacity: 1, transform: "none" }
2788                                                                 ], { duration: 150, easing: "ease-out" });
2789                                                         }
2790                                                 });
2791                                         }, 1000);
2793                                         let pointerX, pointerY, mousePauseTimeout = null;
2795                                         currentPreviewPopup.pointerListener = (event) => {
2796                                                 pointerX = event.clientX;
2797                                                 pointerY = event.clientY;
2799                                                 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
2800                                                 mousePauseTimeout = null;
2802                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
2803                                                 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
2805                                                 if(mouseIsOverLink || overElement === popup
2806                                                    || (pointerX < popup.getBoundingClientRect().left
2807                                                        && event.movementX >= 0)) {
2808                                                         if(!mouseIsOverLink && overElement !== popup) {
2809                                                                 if(overElement['createPreviewPopup']) {
2810                                                                         mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
2811                                                                 } else {
2812                                                                         mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
2813                                                                 }
2814                                                         }
2815                                                 } else {
2816                                                         removePreviewPopup(currentPreviewPopup);
2817                                                         if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
2818                                                 }
2819                                         };
2820                                         window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
2822                                         currentPreviewPopup.mouseoutListener = (event) => {
2823                                                 clearTimeout(mousePauseTimeout);
2824                                                 mousePauseTimeout = null;
2825                                         }
2826                                         document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2828                                         currentPreviewPopup.scrollListener = (event) => {
2829                                                 let overElement = document.elementFromPoint(pointerX, pointerY);
2830                                                 linkRect = linkTag.getBoundingClientRect();
2831                                                 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
2832                                                 removePreviewPopup(currentPreviewPopup);
2833                                         };
2834                                         window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
2835                                 };
2836                         }
2837                 }
2838         });
2839         queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2840                 
2841         });
2843         // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2844         GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2845                 "#content .comments .comment-thread"
2846         ];
2847         applyFilters(GW.currentFilters);
2850 /***************/
2851 /* IMAGE FOCUS */
2852 /***************/
2854 function imageFocusSetup(imagesOverlayOnly = false) {
2855         if (typeof GW.imageFocus == "undefined")
2856                 GW.imageFocus = {
2857                         contentImagesSelector:  "#content img",
2858                         overlayImagesSelector:  "#images-overlay img",
2859                         focusedImageSelector:   "#content img.focused, #images-overlay img.focused",
2860                         pageContentSelector:    "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
2861                         shrinkRatio:                    0.975,
2862                         hideUITimerDuration:    1500,
2863                         hideUITimerExpired:             () => {
2864                                 GWLog("GW.imageFocus.hideUITimerExpired");
2865                                 let currentTime = new Date();
2866                                 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
2867                                 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
2868                                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
2869                                 } else {
2870                                         hideImageFocusUI();
2871                                         cancelImageFocusHideUITimer();
2872                                 }
2873                         }
2874                 };
2876         GWLog("imageFocusSetup");
2877         // Create event listener for clicking on images to focus them.
2878         GW.imageClickedToFocus = (event) => {
2879                 GWLog("GW.imageClickedToFocus");
2880                 focusImage(event.target);
2882                 if (!GW.isMobile) {
2883                         // Set timer to hide the image focus UI.
2884                         unhideImageFocusUI();
2885                         GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
2886                 }
2887         };
2888         // Add the listener to each image in the overlay (i.e., those in the post).
2889         queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
2890                 image.addActivateEvent(GW.imageClickedToFocus);
2891         });
2892         // Accesskey-L starts the slideshow.
2893         (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
2894         // Count how many images there are in the post, and set the "… of X" label to that.
2895         ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
2896         if (imagesOverlayOnly) return;
2897         // Add the listener to all other content images (including those in comments).
2898         queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
2899                 image.addActivateEvent(GW.imageClickedToFocus);
2900         });
2902         // Create the image focus overlay.
2903         let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" + 
2904         `<div class='help-overlay'>
2905                  <p><strong>Arrow keys:</strong> Next/previous image</p>
2906                  <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2907                  <p><strong>Space bar:</strong> Reset image size & position</p>
2908                  <p><strong>Scroll</strong> to zoom in/out</p>
2909                  <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2910         </div>
2911         <div class='image-number'></div>
2912         <div class='slideshow-buttons'>
2913                  <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'>&#xf053;</button>
2914                  <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'>&#xf054;</button>
2915         </div>
2916         <div class='caption'></div>` + 
2917         "</div>");
2918         imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2920         imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2921                 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
2922                         GWLog("GW.imageFocus.slideshowButtonClicked");
2923                         focusNextImage(event.target.hasClass("next"));
2924                         event.target.blur();
2925                 });
2926         });
2928         // On orientation change, reset the size & position.
2929         if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2930                 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2931         }
2933         // UI starts out hidden.
2934         hideImageFocusUI();
2937 function focusImage(imageToFocus) {
2938         GWLog("focusImage");
2939         // Clear 'last-focused' class of last focused image.
2940         let lastFocusedImage = query("img.last-focused");
2941         if (lastFocusedImage) {
2942                 lastFocusedImage.removeClass("last-focused");
2943                 lastFocusedImage.removeAttribute("accesskey");
2944         }
2946         // Create the focused version of the image.
2947         imageToFocus.addClass("focused");
2948         let imageFocusOverlay = query("#image-focus-overlay");
2949         let clonedImage = imageToFocus.cloneNode(true);
2950         clonedImage.style = "";
2951         clonedImage.removeAttribute("width");
2952         clonedImage.removeAttribute("height");
2953         clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2954         imageFocusOverlay.appendChild(clonedImage);
2955         imageFocusOverlay.addClass("engaged");
2957         // Set image to default size and position.
2958         resetFocusedImagePosition();
2960         // Blur everything else.
2961         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
2962                 element.addClass("blurred");
2963         });
2965         // Add listener to zoom image with scroll wheel.
2966         window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
2967                 GWLog("GW.imageFocus.scrollEvent");
2968                 event.preventDefault();
2970                 let image = query("#image-focus-overlay img");
2972                 // Remove the filter.
2973                 image.savedFilter = image.style.filter;
2974                 image.style.filter = 'none';
2976                 // Locate point under cursor.
2977                 let imageBoundingBox = image.getBoundingClientRect();
2979                 // Calculate resize factor.
2980                 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2981                                                 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2982                                                 1;
2984                 // Resize.
2985                 image.style.width = (event.deltaY < 0 ?
2986                                                         (image.clientWidth * factor) :
2987                                                         (image.clientWidth / factor))
2988                                                         + "px";
2989                 image.style.height = "";
2991                 // Designate zoom origin.
2992                 var zoomOrigin;
2993                 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2994                 // the cursor is over the image.
2995                 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2996                 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2997                                                                 (imageBoundingBox.left <= event.clientX &&
2998                                                                  event.clientX <= imageBoundingBox.right && 
2999                                                                  imageBoundingBox.top <= event.clientY &&
3000                                                                  event.clientY <= imageBoundingBox.bottom);
3001                 // Otherwise, if we're zooming OUT, zoom from window center; if we're 
3002                 // zooming IN, zoom from image center.
3003                 let zoomingFromWindowCenter = event.deltaY > 0;
3004                 if (zoomingFromCursor)
3005                         zoomOrigin = { x: event.clientX, 
3006                                                    y: event.clientY };
3007                 else if (zoomingFromWindowCenter)
3008                         zoomOrigin = { x: window.innerWidth / 2, 
3009                                                    y: window.innerHeight / 2 };
3010                 else
3011                         zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2, 
3012                                                    y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3014                 // Calculate offset from zoom origin.
3015                 let offsetOfImageFromZoomOrigin = {
3016                         x: imageBoundingBox.x - zoomOrigin.x,
3017                         y: imageBoundingBox.y - zoomOrigin.y
3018                 }
3019                 // Calculate delta from centered zoom.
3020                 let deltaFromCenteredZoom = {
3021                         x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3022                         y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3023                 }
3024                 // Adjust image position appropriately.
3025                 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3026                 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3027                 // Gradually re-center image, if it's smaller than the window.
3028                 if (!imageSizeExceedsWindowBounds) {
3029                         let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2, 
3030                                                                 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3031                         let windowCenter = { x: window.innerWidth / 2,
3032                                                                  y: window.innerHeight / 2 }
3033                         let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3034                                                                                   y: windowCenter.y - imageCenter.y }
3035                         // Divide the offset by 10 because we're nudging the image toward center,
3036                         // not jumping it there.
3037                         image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3038                         image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3039                 }
3041                 // Put the filter back.
3042                 image.style.filter = image.savedFilter;
3044                 // Set the cursor appropriately.
3045                 setFocusedImageCursor();
3046         });
3047         window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3048                 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3049                 event.preventDefault();
3050         });
3052         // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3053         window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3054                 GWLog("GW.imageFocus.mouseUp");
3055                 window.onmousemove = '';
3057                 // We only want to do anything on left-clicks.
3058                 if (event.button != 0) return;
3060                 // Don't unfocus if click was on a slideshow next/prev button!
3061                 if (event.target.hasClass("slideshow-button")) return;
3063                 // We also don't want to do anything if clicked on the help overlay.
3064                 if (event.target.classList.contains("help-overlay") ||
3065                         event.target.closest(".help-overlay"))
3066                         return;
3068                 let focusedImage = query("#image-focus-overlay img");
3069                 if (event.target == focusedImage && 
3070                         (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3071                         // If the mouseup event was the end of a pan of an overside image,
3072                         // put the filter back; do not unfocus.
3073                         focusedImage.style.filter = focusedImage.savedFilter;
3074                 } else {
3075                         unfocusImageOverlay();
3076                         return;
3077                 }
3078         });
3079         window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3080                 GWLog("GW.imageFocus.mouseDown");
3081                 event.preventDefault();
3083                 let focusedImage = query("#image-focus-overlay img");
3084                 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3085                         let mouseCoordX = event.clientX;
3086                         let mouseCoordY = event.clientY;
3088                         let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3089                         let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3091                         // Save the filter.
3092                         focusedImage.savedFilter = focusedImage.style.filter;
3094                         window.onmousemove = (event) => {
3095                                 // Remove the filter.
3096                                 focusedImage.style.filter = "none";
3097                                 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3098                                 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3099                         };
3100                         return false;
3101                 }
3102         });
3104         // Double-click on the image unfocuses.
3105         clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3106                 GWLog("GW.imageFocus.doubleClick");
3107                 if (event.target.hasClass("slideshow-button")) return;
3109                 unfocusImageOverlay();
3110         });
3112         // Escape key unfocuses, spacebar resets.
3113         document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3114                 GWLog("GW.imageFocus.keyUp");
3115                 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3116                 if (!allowedKeys.contains(event.key) || 
3117                         getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3119                 event.preventDefault();
3121                 switch (event.key) {
3122                 case "Escape": 
3123                 case "Esc":
3124                         unfocusImageOverlay();
3125                         break;
3126                 case " ":
3127                 case "Spacebar":
3128                         resetFocusedImagePosition();
3129                         break;
3130                 case "ArrowDown":
3131                 case "Down":
3132                 case "ArrowRight":
3133                 case "Right":
3134                         if (query("#images-overlay img.focused")) focusNextImage(true);
3135                         break;
3136                 case "ArrowUp":
3137                 case "Up":
3138                 case "ArrowLeft":
3139                 case "Left":
3140                         if (query("#images-overlay img.focused")) focusNextImage(false);
3141                         break;
3142                 }
3143         });
3145         // Prevent spacebar or arrow keys from scrolling page when image focused.
3146         togglePageScrolling(false);
3148         // If the image comes from the images overlay, for the main post...
3149         if (imageToFocus.closest("#images-overlay")) {
3150                 // Mark the overlay as being in slide show mode (to show buttons/count).
3151                 imageFocusOverlay.addClass("slideshow");
3153                 // Set state of next/previous buttons.
3154                 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3155                 var indexOfFocusedImage = getIndexOfFocusedImage();
3156                 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3157                 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3159                 // Set the image number.
3160                 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3162                 // Replace the hash.
3163                 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3164         } else {
3165                 imageFocusOverlay.removeClass("slideshow");
3166         }
3168         // Set the caption.
3169         setImageFocusCaption();
3171         // Moving mouse unhides image focus UI.
3172         window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3173                 GWLog("GW.imageFocus.mouseMoved");
3174                 let currentDateTime = new Date();
3175                 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3176                         cancelImageFocusHideUITimer();
3177                 } else {
3178                         if (!GW.imageFocus.hideUITimer) {
3179                                 unhideImageFocusUI();
3180                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3181                         }
3182                         GW.imageFocus.mouseLastMovedAt = currentDateTime;
3183                 }
3184         });
3187 function resetFocusedImagePosition() {
3188         GWLog("resetFocusedImagePosition");
3189         let focusedImage = query("#image-focus-overlay img");
3190         if (!focusedImage) return;
3192         let sourceImage = query(GW.imageFocus.focusedImageSelector);
3194         // Make sure that initially, the image fits into the viewport.
3195         let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3196         let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3197         var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3198         let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3199         let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3200         focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3201         focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3203         // Remove modifications to position.
3204         focusedImage.style.left = "";
3205         focusedImage.style.top = "";
3207         // Set the cursor appropriately.
3208         setFocusedImageCursor();
3210 function setFocusedImageCursor() {
3211         let focusedImage = query("#image-focus-overlay img");
3212         if (!focusedImage) return;
3213         focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ? 
3214                                                                 'move' : '';
3217 function unfocusImageOverlay() {
3218         GWLog("unfocusImageOverlay");
3220         // Remove event listeners.
3221         window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3222         window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3223         // NOTE: The double-click listener does not need to be removed manually,
3224         // because the focused (cloned) image will be removed anyway.
3225         document.removeEventListener("keyup", GW.imageFocus.keyUp);
3226         document.removeEventListener("keydown", GW.imageFocus.keyDown);
3227         window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3228         window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3229         window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3231         // Set accesskey of currently focused image (if it's in the images overlay).
3232         let currentlyFocusedImage = query("#images-overlay img.focused");
3233         if (currentlyFocusedImage) {
3234                 currentlyFocusedImage.addClass("last-focused");
3235                 currentlyFocusedImage.accessKey = 'l';
3236         }
3238         // Remove focused image and hide overlay.
3239         let imageFocusOverlay = query("#image-focus-overlay");
3240         imageFocusOverlay.removeClass("engaged");
3241         removeElement(imageFocusOverlay.query("img"));
3243         // Un-blur content/etc.
3244         queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3245                 element.removeClass("blurred");
3246         });
3248         // Unset "focused" class of focused image.
3249         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3251         // Re-enable page scrolling.
3252         togglePageScrolling(true);
3254         // Reset the hash, if needed.
3255         if (location.hash.hasPrefix("#if_slide_"))
3256                 history.replaceState(window.history.state, null, "#");
3259 function getIndexOfFocusedImage() {
3260         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3261         var indexOfFocusedImage = -1;
3262         for (i = 0; i < images.length; i++) {
3263                 if (images[i].hasClass("focused")) {
3264                         indexOfFocusedImage = i;
3265                         break;
3266                 }
3267         }
3268         return indexOfFocusedImage;
3271 function focusNextImage(next = true) {
3272         GWLog("focusNextImage");
3273         let images = queryAll(GW.imageFocus.overlayImagesSelector);
3274         var indexOfFocusedImage = getIndexOfFocusedImage();
3276         if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3278         // Remove existing image.
3279         removeElement("#image-focus-overlay img");
3280         // Unset "focused" class of just-removed image.
3281         query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3283         // Create the focused version of the image.
3284         images[indexOfFocusedImage].addClass("focused");
3285         let imageFocusOverlay = query("#image-focus-overlay");
3286         let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3287         clonedImage.style = "";
3288         clonedImage.removeAttribute("width");
3289         clonedImage.removeAttribute("height");
3290         clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3291         imageFocusOverlay.appendChild(clonedImage);
3292         imageFocusOverlay.addClass("engaged");
3293         // Set image to default size and position.
3294         resetFocusedImagePosition();
3295         // Set state of next/previous buttons.
3296         imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3297         imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3298         // Set the image number display.
3299         query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3300         // Set the caption.
3301         setImageFocusCaption();
3302         // Replace the hash.
3303         history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3306 function setImageFocusCaption() {
3307         GWLog("setImageFocusCaption");
3308         var T = { }; // Temporary storage.
3310         // Clear existing caption, if any.
3311         let captionContainer = query("#image-focus-overlay .caption");
3312         Array.from(captionContainer.children).forEach(child => { child.remove(); });
3314         // Determine caption.
3315         let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3316         var captionHTML;
3317         if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) && 
3318                 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3319                 captionHTML = (T.figcaption.query("p")) ? 
3320                                           T.figcaption.innerHTML : 
3321                                           "<p>" + T.figcaption.innerHTML + "</p>"; 
3322         } else if (currentlyFocusedImage.title != "") {
3323                 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3324         }
3325         // Insert the caption, if any.
3326         if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3329 function hideImageFocusUI() {
3330         GWLog("hideImageFocusUI");
3331         let imageFocusOverlay = query("#image-focus-overlay");
3332         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3333                 element.addClass("hidden");
3334         });
3337 function unhideImageFocusUI() {
3338         GWLog("unhideImageFocusUI");
3339         let imageFocusOverlay = query("#image-focus-overlay");
3340         imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3341                 element.removeClass("hidden");
3342         });
3345 function cancelImageFocusHideUITimer() {
3346         clearTimeout(GW.imageFocus.hideUITimer);
3347         GW.imageFocus.hideUITimer = null;
3350 /*****************/
3351 /* KEYBOARD HELP */
3352 /*****************/
3354 function keyboardHelpSetup() {
3355         let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3356                 <div class='keyboard-help-container'>
3357                         <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'>&#xf00d;</button>
3358                         <h1>Keyboard shortcuts</h1>
3359                         <p class='note'>Keys shown in yellow (e.g., <code class='ak'>]</code>) are <a href='https://en.wikipedia.org/wiki/Access_key#Access_in_different_browsers' target='_blank'>accesskeys</a>, and require a browser-specific modifier key (or keys).</p>
3360                         <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3361                         <div class='keyboard-shortcuts-lists'>` + [ [
3362                                 "General",
3363                                 [ [ '?' ], "Show keyboard shortcuts" ],
3364                                 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3365                         ], [
3366                                 "Site navigation",
3367                                 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3368                                 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3369                                 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3370                                 [ [ 'ak-m' ], "Go to Meta view" ],
3371                                 [ [ 'ak-v' ], "Go to Tags view"],
3372                                 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3373                                 [ [ 'ak-r' ], "Go to Archive view" ],
3374                                 [ [ 'ak-q' ], "Go to Sequences view" ],
3375                                 [ [ 'ak-t' ], "Go to About page" ],
3376                                 [ [ 'ak-u' ], "Go to User or Login page" ],
3377                                 [ [ 'ak-o' ], "Go to Inbox page" ]
3378                         ], [
3379                                 "Page navigation",
3380                                 [ [ 'ak-,' ], "Jump up to top of page" ],
3381                                 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3382                                 [ [ 'ak-/' ], "Jump to top of comments section" ],
3383                                 [ [ 'ak-s' ], "Search" ],
3384                         ], [
3385                                 "Page actions",
3386                                 [ [ 'ak-n' ], "New post or comment" ],
3387                                 [ [ 'ak-e' ], "Edit current post" ]
3388                         ], [
3389                                 "Post/comment list views",
3390                                 [ [ '.' ], "Focus next entry in list" ],
3391                                 [ [ ',' ], "Focus previous entry in list" ],
3392                                 [ [ ';' ], "Cycle between links in focused entry" ],
3393                                 [ [ 'Enter' ], "Go to currently focused entry" ],
3394                                 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3395                                 [ [ 'ak-]' ], "Go to next page" ],
3396                                 [ [ 'ak-[' ], "Go to previous page" ],
3397                                 [ [ 'ak-\\' ], "Go to first page" ],
3398                                 [ [ 'ak-e' ], "Edit currently focused post" ]
3399                         ], [
3400                                 "Editor",
3401                                 [ [ 'ak-k' ], "Bold text" ],
3402                                 [ [ 'ak-i' ], "Italic text" ],
3403                                 [ [ 'ak-l' ], "Insert hyperlink" ],
3404                                 [ [ 'ak-q' ], "Blockquote text" ]
3405                         ], [                            
3406                                 "Appearance",
3407                                 [ [ 'ak-=' ], "Increase text size" ],
3408                                 [ [ 'ak--' ], "Decrease text size" ],
3409                                 [ [ 'ak-0' ], "Reset to default text size" ],
3410                                 [ [ 'ak-′' ], "Cycle through content width settings" ],
3411                                 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3412                                 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3413                                 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3414                                 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3415                                 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3416                                 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3417                                 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3418                                 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3419                                 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3420                                 [ [ 'ak-;' ], "Open theme tweaker" ],
3421                                 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3422                                 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3423                         ], [
3424                                 "Slide shows",
3425                                 [ [ 'ak-l' ], "Start/resume slideshow" ],
3426                                 [ [ 'Esc' ], "Exit slideshow" ],
3427                                 [ [ '&#x2192;', '&#x2193;' ], "Next slide" ],
3428                                 [ [ '&#x2190;', '&#x2191;' ], "Previous slide" ],
3429                                 [ [ 'Space' ], "Reset slide zoom" ]
3430                         ], [
3431                                 "Miscellaneous",
3432                                 [ [ 'ak-x' ], "Switch to next view on user page" ],
3433                                 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3434                                 [ [ 'ak-`&nbsp;' ], "Toggle compact comment list view" ],
3435                                 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3436                         ] ].map(section => 
3437                         `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3438                                 `<li>
3439                                         <span class='keys'>` + 
3440                                         entry[0].map(key =>
3441                                                 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3442                                         ).join("") + 
3443                                         `</span>
3444                                         <span class='action'>${entry[1]}</span>
3445                                 </li>`
3446                         ).join("\n") + `</ul>`).join("\n") + `
3447                         </ul></div>             
3448                 </div>
3449         ` + "</nav>");
3451         // Add listener to show the keyboard help overlay.
3452         document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3453                 GWLog("GW.keyboardHelpShowKeyPressed");
3454                 if (event.key == '?')
3455                         toggleKeyboardHelpOverlay(true);
3456         });
3458         // Clicking the background overlay closes the keyboard help overlay.
3459         keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3460                 GWLog("GW.keyboardHelpOverlayClicked");
3461                 if (event.type == 'mousedown') {
3462                         keyboardHelpOverlay.style.opacity = "0.01";
3463                 } else {
3464                         toggleKeyboardHelpOverlay(false);
3465                         keyboardHelpOverlay.style.opacity = "1.0";
3466                 }
3467         }, true);
3469         // Intercept clicks, so they don't "fall through" the background overlay.
3470         (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3472         // Clicking the close button closes the keyboard help overlay.
3473         keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3474                 toggleKeyboardHelpOverlay(false);
3475         });
3477         // Add button to open keyboard help.
3478         query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'>&#xf11c;</button>");
3479         query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3480                 GWLog("GW.openKeyboardHelpButtonClicked");
3481                 toggleKeyboardHelpOverlay(true);
3482                 event.target.blur();
3483         });
3486 function toggleKeyboardHelpOverlay(show) {
3487         console.log("toggleKeyboardHelpOverlay");
3489         let keyboardHelpOverlay = query("#keyboard-help-overlay");
3490         show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3491         keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3493         // Prevent scrolling the document when the overlay is visible.
3494         togglePageScrolling(!show);
3496         // Focus the close button as soon as we open.
3497         keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3499         if (show) {
3500                 // Add listener to show the keyboard help overlay.
3501                 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3502                         GWLog("GW.keyboardHelpHideKeyPressed");
3503                         if (event.key == 'Escape')
3504                                 toggleKeyboardHelpOverlay(false);
3505                 });
3506         } else {
3507                 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3508         }
3510         // Disable / enable tab-selection of the search box.
3511         setSearchBoxTabSelectable(!show);
3514 /**********************/
3515 /* PUSH NOTIFICATIONS */
3516 /**********************/
3518 function pushNotificationsSetup() {
3519         let pushNotificationsButton = query("#enable-push-notifications");
3520         if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3521                 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3522                 pushNotificationsButton.style.display = 'unset';
3523         }
3526 function urlBase64ToUint8Array(base64String) {
3527         const padding = '='.repeat((4 - base64String.length % 4) % 4);
3528         const base64 = (base64String + padding)
3529               .replace(/-/g, '+')
3530               .replace(/_/g, '/');
3531         
3532         const rawData = window.atob(base64);
3533         const outputArray = new Uint8Array(rawData.length);
3534         
3535         for (let i = 0; i < rawData.length; ++i) {
3536                 outputArray[i] = rawData.charCodeAt(i);
3537         }
3538         return outputArray;
3541 function pushNotificationsButtonClicked(event) {
3542         event.target.style.opacity = 0.33;
3543         event.target.style.pointerEvents = "none";
3545         let reEnable = (message) => {
3546                 if(message) alert(message);
3547                 event.target.style.opacity = 1;
3548                 event.target.style.pointerEvents = "unset";
3549         }
3551         if(event.target.dataset.enabled) {
3552                 fetch('/push/register', {
3553                         method: 'post',
3554                         headers: { 'Content-type': 'application/json' },
3555                         body: JSON.stringify({
3556                                 cancel: true
3557                         }),
3558                 }).then(() => {
3559                         event.target.innerHTML = "Enable push notifications";
3560                         event.target.dataset.enabled = "";
3561                         reEnable();
3562                 }).catch((err) => reEnable(err.message));
3563         } else {
3564                 Notification.requestPermission().then((permission) => {
3565                         navigator.serviceWorker.ready
3566                                 .then((registration) => {
3567                                         return registration.pushManager.getSubscription()
3568                                                 .then(async function(subscription) {
3569                                                         if (subscription) {
3570                                                                 return subscription;
3571                                                         }
3572                                                         return registration.pushManager.subscribe({
3573                                                                 userVisibleOnly: true,
3574                                                                 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
3575                                                         });
3576                                                 })
3577                                                 .catch((err) => reEnable(err.message));
3578                                 })
3579                                 .then((subscription) => {
3580                                         fetch('/push/register', {
3581                                                 method: 'post',
3582                                                 headers: {
3583                                                         'Content-type': 'application/json'
3584                                                 },
3585                                                 body: JSON.stringify({
3586                                                         subscription: subscription
3587                                                 }),
3588                                         });
3589                                 })
3590                                 .then(() => {
3591                                         event.target.innerHTML = "Disable push notifications";
3592                                         event.target.dataset.enabled = "true";
3593                                         reEnable();
3594                                 })
3595                                 .catch(function(err){ reEnable(err.message) });
3596                         
3597                 });
3598         }
3601 /*******************************/
3602 /* HTML TO MARKDOWN CONVERSION */
3603 /*******************************/
3605 function MarkdownFromHTML(text) {
3606         GWLog("MarkdownFromHTML");
3607         // Wrapper tags, paragraphs, bold, italic, code blocks.
3608         text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
3609                 switch(tag) {
3610                 case "html":
3611                 case "/html":
3612                 case "head":
3613                 case "/head":
3614                 case "body":
3615                 case "/body":
3616                 case "p":
3617                         return "";
3618                 case "/p":
3619                         return "\n";
3620                 case "strong":
3621                 case "/strong":
3622                         return "**";
3623                 case "em":
3624                 case "/em":
3625                         return "*";
3626                 default:
3627                         return match;
3628                 }
3629         });
3631         // <div> and <span>.
3632         text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
3633                 return `${text}\n`;
3634         }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
3635                 return `${text}\n`;
3636         });
3638         // Unordered lists.
3639         text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
3640                 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
3641                         return `* ${listItem}\n`;
3642                 });
3643         });
3645         // Ordered lists.
3646         text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
3647                 var countedItemValue = 0;
3648                 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
3649                         var itemValue;
3650                         if (typeof specifiedItemValue != "undefined") {
3651                                 specifiedItemValue = parseInt(specifiedItemValue);
3652                                 countedItemValue = itemValue = specifiedItemValue;
3653                         } else {
3654                                 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
3655                         }
3656                         return `${itemValue}. ${listItem.trim()}\n`;
3657                 });
3658         });
3660         // Headings.
3661         text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
3662                 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
3663         });
3665         // Blockquotes.
3666         text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
3667                 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
3668         });
3670         // Links.
3671         text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
3672                 return `[${text}](${href})`;
3673         }).trim();
3675         // Images.
3676         text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
3677                 return `![](${src})`;
3678         });
3680         // Horizontal rules.
3681         text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
3682                 return "\n---\n";
3683         });
3685         // Line breaks.
3686         text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
3687                 return "\\\n";
3688         });
3690         // Preformatted text (possibly with a code block inside).
3691         text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
3692                 return "```\n" + text + "\n```";
3693         });
3695         // Code blocks.
3696         text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
3697                 return "`" + text + "`";
3698         });
3700         // HTML entities.
3701         text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
3702                 switch(entity) {
3703                 case "gt":
3704                         return ">";
3705                 case "lt":
3706                         return "<";
3707                 case "amp":
3708                         return "&";
3709                 case "apos":
3710                         return "'";
3711                 case "quot":
3712                         return "\"";
3713                 default:
3714                         return match;
3715                 }
3716         });
3718         return text;
3721 /************************************/
3722 /* ANCHOR LINK SCROLLING WORKAROUND */
3723 /************************************/
3725 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
3726         let hash = location.hash;
3727         if(hash && hash !== "#top" && !document.query(hash)) {
3728                 let content = document.query("#content");
3729                 content.style.display = "none";
3730                 addTriggerListener("DOMReady", {priority: -1, fn: () => {
3731                         content.style.visibility = "hidden";
3732                         content.style.display = null;
3733                         requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
3734                 }});
3735         }
3736 }});
3738 /******************/
3739 /* INITIALIZATION */
3740 /******************/
3742 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
3743         GWLog("INITIALIZER earlyInitialize");
3744         // Check to see whether we're on a mobile device (which we define as a narrow screen)
3745         GW.isMobile = (window.innerWidth <= 1160);
3746         GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3748         // Backward compatibility
3749         let storedTheme = localStorage.getItem('selected-theme');
3750         if (storedTheme) {
3751                 setTheme(storedTheme);
3752                 localStorage.removeItem('selected-theme');
3753         }
3755         // Animate width & theme adjustments?
3756         GW.adjustmentTransitions = false;
3758         // Add the content width selector.
3759         injectContentWidthSelector();
3760         // Add the text size adjustment widget.
3761         injectTextSizeAdjustmentUI();
3762         // Add the theme selector.
3763         injectThemeSelector();
3764         // Add the theme tweaker.
3765         injectThemeTweaker();
3766         // Add the quick-nav UI.
3767         injectQuickNavUI();
3769         // Finish initializing when ready.
3770         addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
3771 }});
3773 function mainInitializer() {
3774         GWLog("INITIALIZER initialize");
3776         // This is for "qualified hyperlinking", i.e. "link without comments" and/or
3777         // "link without nav bars".
3778         if (getQueryVariable("hide-nav-bars") == "true") {
3779                 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'>&#xf129;</a></div>");
3780         }
3782         // If the page cannot have comments, remove the accesskey from the #comments
3783         // quick-nav button; and if the page can have comments, but does not, simply 
3784         // disable the #comments quick nav button.
3785         let content = query("#content");
3786         if (content.query("#comments") == null) {
3787                 query("#quick-nav-ui a[href='#comments']").accessKey = '';
3788         } else if (content.query("#comments .comment-thread") == null) {
3789                 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
3790         }
3792         // On edit post pages and conversation pages, add GUIEdit buttons to the 
3793         // textarea, expand it, and markdownify the existing text, if any (this is
3794         // needed if a post was last edited on LW).
3795         queryAll(".with-markdown-editor textarea").forEach(textarea => {
3796                 textarea.addTextareaFeatures();
3797                 expandTextarea(textarea);
3798                 textarea.value = MarkdownFromHTML(textarea.value);
3799         });
3800         // Focus the textarea.
3801         queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3803         // If we're on a comment thread page...
3804         if (query(".comments") != null) {
3805                 // Add comment-minimize buttons to every comment.
3806                 queryAll(".comment-meta").forEach(commentMeta => {
3807                         if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3808                                 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'>&#xf146;</div>");
3809                 });
3810                 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
3811                         // Format and activate comment-minimize buttons.
3812                         queryAll(".comment-minimize-button").forEach(button => {
3813                                 button.closest(".comment-item").setCommentThreadMaximized(false);
3814                                 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3815                                         event.target.closest(".comment-item").setCommentThreadMaximized(true);
3816                                 });
3817                         });
3818                 }
3819         }
3820         if (getQueryVariable("chrono") == "t") {
3821                 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
3822         }
3824         // On mobile, replace the labels for the checkboxes on the edit post form
3825         // with icons, to save space.
3826         if (GW.isMobile && query(".edit-post-page")) {
3827                 query("label[for='link-post']").innerHTML = "&#xf0c1";
3828                 query("label[for='question']").innerHTML = "&#xf128";
3829         }
3831         // Add error message (as placeholder) if user tries to click Search with
3832         // an empty search field.
3833         searchForm: {
3834                 let searchForm = query("#nav-item-search form");
3835                 if(!searchForm) break searchForm;
3836                 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3837                         let searchField = event.target.query("input");
3838                         if (searchField.value == "") {
3839                                 event.preventDefault();
3840                                 event.target.blur();
3841                                 searchField.placeholder = "Enter a search string!";
3842                                 searchField.focus();
3843                         }
3844                 });
3845                 // Remove the placeholder / error on any input.
3846                 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3847                         event.target.placeholder = "";
3848                 });
3849         }
3851         // Prevent conflict between various single-hotkey listeners and text fields
3852         queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3853                 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3854                 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
3855         });
3857         if (content.hasClass("post-page")) {
3858                 // Read and update last-visited-date.
3859                 let lastVisitedDate = getLastVisitedDate();
3860                 setLastVisitedDate(Date.now());
3862                 // Save the number of comments this post has when it's visited.
3863                 updateSavedCommentCount();
3865                 if (content.query(".comments .comment-thread") != null) {
3866                         // Add the new comments count & navigator.
3867                         injectNewCommentNavUI();
3869                         // Get the highlight-new-since date (as specified by URL parameter, if 
3870                         // present, or otherwise the date of the last visit).
3871                         let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3873                         // Highlight new comments since the specified date.                      
3874                         let newCommentsCount = highlightCommentsSince(hnsDate);
3876                         // Update the comment count display.
3877                         updateNewCommentNavUI(newCommentsCount, hnsDate);
3878                 }
3879         } else {
3880                 // On listing pages, make comment counts more informative.
3881                 badgePostsWithNewComments();
3882         }
3884         // Add the comments list mode selector widget (expanded vs. compact).
3885         injectCommentsListModeSelector();
3887         // Add the comments view selector widget (threaded vs. chrono).
3888 //      injectCommentsViewModeSelector();
3890         // Add the comments sort mode selector (top, hot, new, old).
3891         if (GW.useFancyFeatures) injectCommentsSortModeSelector();
3893         // Add the toggle for the post nav UI elements on mobile.
3894         if (GW.isMobile) injectPostNavUIToggle();
3896         // Add the toggle for the appearance adjustment UI elements on mobile.
3897         if (GW.isMobile) injectAppearanceAdjustUIToggle();
3899         // Add the antikibitzer.
3900         if (GW.useFancyFeatures) injectAntiKibitzer();
3902         // Add comment parent popups.
3903         injectPreviewPopupToggle();
3904         addCommentParentPopups();
3906         // Mark original poster's comments with a special class.
3907         markOriginalPosterComments();
3908         
3909         // On the All view, mark posts with non-positive karma with a special class.
3910         if (query("#content").hasClass("all-index-page")) {
3911                 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3912                         if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3914                         karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3915                 });
3916         }
3918         // Set the "submit" button on the edit post page to something more helpful.
3919         setEditPostPageSubmitButtonText();
3921         // Compute the text of the pagination UI tooltip text.
3922         queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3923                 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3924         });
3926         // Add event listeners for Escape and Enter, for the theme tweaker.
3927         let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3928         let themeTweakerUI = query("#theme-tweaker-ui");
3929         document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3930                 if (event.key == "Escape") {
3931                         if (themeTweakerHelpWindow.style.display != "none") {
3932                                 toggleThemeTweakerHelpWindow();
3933                                 themeTweakerResetSettings();
3934                         } else if (themeTweakerUI.style.display != "none") {
3935                                 toggleThemeTweakerUI();
3936                                 themeTweakReset();
3937                         }
3938                 } else if (event.key == "Enter") {
3939                         if (themeTweakerHelpWindow.style.display != "none") {
3940                                 toggleThemeTweakerHelpWindow();
3941                                 themeTweakerSaveSettings();
3942                         } else if (themeTweakerUI.style.display != "none") {
3943                                 toggleThemeTweakerUI();
3944                                 themeTweakSave();
3945                         }
3946                 }
3947         });
3949         // Add event listener for . , ; (for navigating listings pages).
3950         let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3951         if (!query(".comments") && listings.length > 0) {
3952                 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => { 
3953                         if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
3955                         if (event.key == "Escape") {
3956                                 if (document.activeElement.parentElement.hasClass("listing"))
3957                                         document.activeElement.blur();
3958                                 return;
3959                         }
3961                         if (event.key == ';') {
3962                                 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3963                                         let links = document.activeElement.parentElement.queryAll("a");
3964                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3965                                 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3966                                         let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3967                                         links[document.activeElement == links[0] ? 1 : 0].focus();
3968                                         document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3969                                 }
3970                                 return;
3971                         }
3973                         var indexOfActiveListing = -1;
3974                         for (i = 0; i < listings.length; i++) {
3975                                 if (document.activeElement.parentElement.hasClass("listing") && 
3976                                         listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3977                                         indexOfActiveListing = i;
3978                                         break;
3979                                 } else if (document.activeElement.parentElement.hasClass("comment-meta") && 
3980                                         listings[i] === document.activeElement.parentElement.query("a.date")) {
3981                                         indexOfActiveListing = i;
3982                                         break;
3983                                 }
3984                         }
3985                         // Remove edit accesskey from currently highlighted post by active user, if applicable.
3986                         if (indexOfActiveListing > -1) {
3987                                 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
3988                         }
3989                         let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3990                         if (indexOfNextListing < listings.length) {
3991                                 listings[indexOfNextListing].focus();
3993                                 if (listings[indexOfNextListing].closest(".comment-item")) {
3994                                         listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3995                                         listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3996                                 }
3997                         } else {
3998                                 document.activeElement.blur();
3999                         }
4000                         // Add edit accesskey to newly highlighted post by active user, if applicable.
4001                         (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4002                 });
4003                 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4004                         link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4005                                 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4006                         });
4007                 });
4008         }
4009         // Add event listener for ; (to focus the link on link posts).
4010         if (query("#content").hasClass("post-page") && 
4011                 query(".post").hasClass("link-post")) {
4012                 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4013                         if (event.key == ';') query("a.link-post-link").focus();
4014                 });
4015         }
4017         // Add accesskeys to user page view selector.
4018         let viewSelector = query("#content.user-page > .sublevel-nav");
4019         if (viewSelector) {
4020                 let currentView = viewSelector.query("span");
4021                 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4022                 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4023         }
4025         // Add accesskey to index page sort selector.
4026         (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4028         // Move MathJax style tags to <head>.
4029         var aggregatedStyles = "";
4030         queryAll("#content style").forEach(styleTag => {
4031                 aggregatedStyles += styleTag.innerHTML;
4032                 removeElement("style", styleTag.parentElement);
4033         });
4034         if (aggregatedStyles != "") {
4035                 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4036         }
4038         // Add listeners to switch between word count and read time.
4039         if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4040         queryAll(".post-meta .read-time").forEach(element => {
4041                 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4042                         let displayWordCount = localStorage.getItem("display-word-count");
4043                         toggleReadTimeOrWordCount(!displayWordCount);
4044                         if (displayWordCount) localStorage.removeItem("display-word-count");
4045                         else localStorage.setItem("display-word-count", true);
4046                 });
4047         });
4049         // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4050         query("#content").addEventListener("copy", GW.textCopied = (event) => {
4051                 if(event.target.matches("input, textarea")) return;
4052                 event.preventDefault();
4053                 const selectedHTML = getSelectionHTML();
4054                 const selectedText = getSelection().toString();
4055                 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4056                 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4057         });
4059         // Set up Image Focus feature.
4060         imageFocusSetup();
4062         // Set up keyboard shortcuts guide overlay.
4063         keyboardHelpSetup();
4065         // Show push notifications button if supported
4066         pushNotificationsSetup();
4068         // Show elements now that javascript is ready.
4069         removeElement("#hide-until-init");
4071         activateTrigger("pageLayoutFinished");
4074 /*************************/
4075 /* POST-LOAD ADJUSTMENTS */
4076 /*************************/
4078 window.addEventListener("pageshow", badgePostsWithNewComments);
4080 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4081         GWLog("INITIALIZER pageLayoutFinished");
4083         postSetThemeHousekeeping();
4085         focusImageSpecifiedByURL();
4087         // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4088 //      query("input[type='search']").value = GW.isMobile;
4089 //      insertHeadHTML("<style>" +
4090 //              `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` + 
4091 //              `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` + 
4092 //              "</style>");
4093 }});
4095 function generateImagesOverlay() {
4096         GWLog("generateImagesOverlay");
4097         // Don't do this on the about page.
4098         if (query(".about-page") != null) return;
4099         return;
4101         // Remove existing, if any.
4102         removeElement("#images-overlay");
4104         // Create new.
4105         query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4106         let imagesOverlay = query("#images-overlay");
4107         let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4108         queryAll(".post-body img").forEach(image => {
4109                 let clonedImageContainer = document.createElement("div");
4111                 let clonedImage = image.cloneNode(true);
4112                 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4113                 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4114                 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4115                 clonedImageContainer.appendChild(clonedImage);
4117                 let zoomLevel = parseFloat(GW.currentTextZoom);
4119                 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4120                 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4121                 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4122                 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4124                 imagesOverlay.appendChild(clonedImageContainer);
4125         });
4127         // Add the event listeners to focus each image.
4128         imageFocusSetup(true);
4131 function adjustUIForWindowSize() {
4132         GWLog("adjustUIForWindowSize");
4133         var bottomBarOffset;
4135         // Adjust bottom bar state.
4136         let bottomBar = query("#bottom-bar");
4137         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4138         if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4139                 bottomBar.removeClass("decorative");
4141                 bottomBar.query("#nav-item-top").style.display = "";
4142         } else if (bottomBar) {
4143                 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4144                 else bottomBar.addClass("decorative");
4146                 bottomBar.query("#nav-item-top").style.display = "none";
4147         }
4149         // Show quick-nav UI up/down buttons if content is taller than window.
4150         bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4151         queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4152                 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4153         });
4155         // Move anti-kibitzer toggle if content is very short.
4156         if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4158         // Update the visibility of the post nav UI.
4159         updatePostNavUIVisibility();
4162 function recomputeUIElementsContainerHeight(force = false) {
4163         GWLog("recomputeUIElementsContainerHeight");
4164         if (!GW.isMobile &&
4165                 (force || query("#ui-elements-container").style.height != "")) {
4166                 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4167                 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ? 
4168                                                                                                                 query("#content").clientHeight + "px" :
4169                                                                                                                 "100vh";
4170         }
4173 function focusImageSpecifiedByURL() {
4174         GWLog("focusImageSpecifiedByURL");
4175         if (location.hash.hasPrefix("#if_slide_")) {
4176                 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4177                         let images = queryAll(GW.imageFocus.overlayImagesSelector);
4178                         let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4179                         if (imageToFocus > 0 && imageToFocus <= images.length) {
4180                                 focusImage(images[imageToFocus - 1]);
4182                                 // Set timer to hide the image focus UI.
4183                                 unhideImageFocusUI();
4184                                 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4185                         }
4186                 });
4187         }
4190 /***********/
4191 /* GUIEDIT */
4192 /***********/
4194 function insertMarkup(event) {
4195         var mopen = '', mclose = '', mtext = '', func = false;
4196         if (typeof arguments[1] == 'function') {
4197                 func = arguments[1];
4198         } else {
4199                 mopen = arguments[1];
4200                 mclose = arguments[2];
4201                 mtext = arguments[3];
4202         }
4204         var textarea = event.target.closest("form").query("textarea");
4205         textarea.focus();
4206         var p0 = textarea.selectionStart;
4207         var p1 = textarea.selectionEnd;
4208         var cur0 = cur1 = p0;
4210         var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4211         str = func ? func(str, p0) : (mopen + str + mclose);
4213         // Determine selection.
4214         if (!func) {
4215                 cur0 += (p0 == p1) ? mopen.length : str.length;
4216                 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4217         } else {
4218                 cur0 = str[1];
4219                 cur1 = str[2];
4220                 str = str[0];
4221         }
4223         // Update textarea contents.
4224         document.execCommand("insertText", false, str);
4226         // Expand textarea, if needed.
4227         expandTextarea(textarea);
4229         // Set selection.
4230         textarea.selectionStart = cur0;
4231         textarea.selectionEnd = cur1;
4233         return;
4236 GW.guiEditButtons = [
4237         [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '&#xf032;' ],
4238         [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '&#xf033;' ],
4239         [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '&#xf0c1;' ],
4240         [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '&#xf03e;' ],
4241         [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '&#xf1dc;<sup>1</sup>' ],
4242         [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '&#xf1dc;<sup>2</sup>' ],
4243         [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '&#xf1dc;<sup>3</sup>' ],
4244         [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '&#xf10e;' ],
4245         [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '&#xf0ca;' ],
4246         [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '&#xf0cb;' ],
4247         [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '&#xf068;' ],
4248         [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '&#xf121;' ],
4249         [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '&#xf1c9;' ],
4250         [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '&#xf155;' ],
4251         [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '&#xf2fc;' ]
4254 function blockquote(text, startpos) {
4255         if (text == '') {
4256                 text = "> Quoted text";
4257                 return [ text, startpos + 2, startpos + text.length ];
4258         } else {
4259                 text = "> " + text.split("\n").join("\n> ") + "\n";
4260                 return [ text, startpos + text.length, startpos + text.length ];
4261         }
4264 function hyperlink(text, startpos) {
4265         var url = '', link_text = text, endpos = startpos;
4266         if (text.search(/^https?/) != -1) {
4267                 url = text;
4268                 link_text = "link text";
4269                 startpos = startpos + 1;
4270                 endpos = startpos + link_text.length;
4271         } else {
4272                 url = prompt("Link address (URL):");
4273                 if (!url) {
4274                         endpos = startpos + text.length;
4275                         return [ text, startpos, endpos ];
4276                 }
4277                 startpos = startpos + text.length + url.length + 4;
4278                 endpos = startpos;
4279         }
4281         return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4284 /******************/
4285 /* SERVICE WORKER */
4286 /******************/
4288 if(navigator.serviceWorker) {
4289         navigator.serviceWorker.register('/service-worker.js');
4290         setCookie("push", "t");
4293 /*********************/
4294 /* USER AUTOCOMPLETE */
4295 /*********************/
4297 function zLowerUIElements() {
4298         let uiElementsContainer = query("#ui-elements-container");
4299         if (uiElementsContainer)
4300                 uiElementsContainer.style.zIndex = "1";
4303 function zRaiseUIElements() {
4304         let uiElementsContainer = query("#ui-elements-container");
4305         if (uiElementsContainer)
4306                 uiElementsContainer.style.zIndex = "";
4309 var userAutocomplete = null;
4311 function abbreviatedInterval(date) {
4312         let seconds = Math.floor((new Date() - date) / 1000);
4313         let days = Math.floor(seconds / (60 * 60 * 24));
4314         let years = Math.floor(days / 365);
4315         if (years)
4316                 return years + "y";
4317         else if (days)
4318                 return days + "d";
4319         else
4320                 return "today";
4323 function beginAutocompletion(control, startIndex, endIndex) {
4324         if(userAutocomplete) abortAutocompletion(userAutocomplete);
4326         let complete = { control: control,
4327                          abortController: new AbortController(),
4328                          fetchAbortController: new AbortController(),
4329                          container: document.createElement("div") };
4331         endIndex = endIndex || control.selectionEnd;
4332         let valueLength = control.value.length;
4334         complete.container.className = "autocomplete-container "
4335                                                                  + "right "
4336                                                                  + (window.innerWidth > 1280
4337                                                                         ? "outside"
4338                                                                         : "inside");
4339         control.insertAdjacentElement("afterend", complete.container);
4340         zLowerUIElements();
4342         let makeReplacer = (userSlug, displayName) => {
4343                 return () => {
4344                         let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
4345                         control.value = control.value.substring(0, startIndex - 1) +
4346                                 replacement +
4347                                 control.value.substring(endIndex);
4348                         abortAutocompletion(complete);
4349                         complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
4350                         complete.control.focus();
4351                 };
4352         };
4354         let switchHighlight = (newHighlight) => {
4355                 if (!newHighlight)
4356                         return;
4358                 complete.highlighted.removeClass("highlighted");
4359                 newHighlight.addClass("highlighted");
4360                 complete.highlighted = newHighlight;
4362                 //      Scroll newly highlighted item into view, if need be.
4363                 if (  complete.highlighted.offsetTop + complete.highlighted.offsetHeight 
4364                         > complete.container.scrollTop + complete.container.clientHeight) {
4365                         complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
4366                 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
4367                         complete.container.scrollTo(0, complete.highlighted.offsetTop);
4368                 }
4369         };
4370         let highlightNext = () => {
4371                 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
4372         };
4373         let highlightPrev = () => {
4374                 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
4375         };
4377         let updateCompletions = () => {
4378                 let fragment = control.value.substring(startIndex, endIndex);
4380                 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
4381                       {signal: complete.fetchAbortController.signal})
4382                         .then((res) => res.json())
4383                         .then((res) => {
4384                                 if(res.error) return;
4385                                 if(res.length == 0) return abortAutocompletion(complete);
4387                                 complete.container.innerHTML = "";
4388                                 res.forEach(entry => {
4389                                         let entryContainer = document.createElement("div");
4390                                         [ [ entry.displayName, "name" ],
4391                                           [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
4392                                           [ (entry.karma || 0) + " karma", "karma" ]
4393                                         ].forEach(x => {
4394                                                 let e = document.createElement("span");
4395                                                 e.append(x[0]);
4396                                                 e.className = x[1];
4397                                                 entryContainer.append(e);
4398                                         });
4399                                         entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
4400                                         complete.container.append(entryContainer);
4401                                 });
4402                                 complete.highlighted = complete.container.children[0];
4403                                 complete.highlighted.classList.add("highlighted");
4404                                 complete.container.scrollTo(0, 0);
4405                                 })
4406                         .catch((e) => {});
4407         };
4409         document.body.addEventListener("click", (event) => {
4410                 if (!complete.container.contains(event.target)) {
4411                         abortAutocompletion(complete);
4412                         event.preventDefault();
4413                         event.stopPropagation();
4414                 }
4415         }, {signal: complete.abortController.signal,
4416             capture: true});
4417         
4418         control.addEventListener("keydown", (event) => {
4419                 switch (event.key) {
4420                 case "Escape":
4421                         abortAutocompletion(complete);
4422                         event.preventDefault();
4423                         return;
4424                 case "ArrowUp":
4425                         highlightPrev();
4426                         event.preventDefault();
4427                         return;
4428                 case "ArrowDown":
4429                         highlightNext();
4430                         event.preventDefault();
4431                         return;
4432                 case "Tab":
4433                         if (event.shiftKey)
4434                                 highlightPrev();
4435                         else
4436                                 highlightNext();
4437                         event.preventDefault();
4438                         return;
4439                 case "Enter":
4440                         complete.highlighted.onclick();
4441                         event.preventDefault();
4442                         return;
4443                 }
4444         }, {signal: complete.abortController.signal});
4446         control.addEventListener("selectionchange", (event) => {
4447                 if (control.selectionStart < startIndex ||
4448                     control.selectionEnd > endIndex) {
4449                         abortAutocompletion(complete);
4450                 }
4451         }, {signal: complete.abortController.signal});
4452         
4453         control.addEventListener("input", (event) => {
4454                 complete.fetchAbortController.abort();
4455                 complete.fetchAbortController = new AbortController();
4457                 endIndex += control.value.length - valueLength;
4458                 valueLength = control.value.length;
4460                 if (endIndex < startIndex) {
4461                         abortAutocompletion(complete);
4462                         return;
4463                 }
4464                 
4465                 updateCompletions();
4466         }, {signal: complete.abortController.signal});
4468         userAutocomplete = complete;
4470         if(startIndex != endIndex) updateCompletions();
4473 function abortAutocompletion(complete) {
4474         complete.fetchAbortController.abort();
4475         complete.abortController.abort();
4476         complete.container.remove();
4477         userAutocomplete = null;
4478         zRaiseUIElements();