1 /***************************/
2 /* INITIALIZATION REGISTRY */
3 /***************************/
6 GW.initializersDone = { };
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()) {
15 setTimeout(() => requestIdleCallback(wrapper, {timeout: 1000}), 50);
17 document.addEventListener("readystatechange", wrapper, {once: true});
21 GW.initializersDone[name] = true;
25 requestIdleCallback(wrapper, {timeout: 1000});
27 document.addEventListener("readystatechange", wrapper, {once: true});
28 requestIdleCallback(wrapper);
31 function forceInitializer(name) {
32 if (GW.initializersDone[name]) return;
33 GW.initializersDone[name] = true;
34 GW.initializers[name]();
42 function setCookie(name, value, days) {
44 if (!days) days = 36500;
46 var date = new Date();
47 date.setTime(date.getTime() + (days*24*60*60*1000));
48 expires = "; expires=" + date.toUTCString();
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++) {
60 while (c.charAt(0)==' ') c = c.substring(1, c.length);
61 if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
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(() => {
84 document.addEventListener("scroll", wrapper, {once: true, passive: true});
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")
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;
107 }(Element.prototype.scrollIntoView);
109 /* If top of element is not at or above the top of the screen, scroll it into
111 Element.prototype.scrollIntoViewIfNeeded = function() {
112 GWLog("scrollIntoViewIfNeeded");
113 if (this.getBoundingClientRect().bottom > window.innerHeight &&
114 this.getBoundingClientRect().top > 0) {
115 this.scrollIntoView(false);
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);
135 if(params["onFailure"]) params.onFailure(event);
136 else handleAjaxError(event);
138 if(params["onFinish"]) params.onFinish(event);
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));
150 function activateReadyStateTriggers() {
151 if(document.readyState == 'interactive') {
152 activateTrigger('DOMReady');
153 } else if(document.readyState == 'complete') {
154 activateTrigger('DOMReady');
155 activateTrigger('DOMComplete');
159 document.addEventListener('readystatechange', activateReadyStateTriggers);
160 activateReadyStateTriggers();
162 function callWithServerData(fname, uri) {
165 onSuccess: (event) => {
166 let response = JSON.parse(event.target.responseText);
167 window[fname](response);
172 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
173 deferredCalls = null;
175 /* Return the currently selected text, as HTML (rather than unstyled text).
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.
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);
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.
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.
210 String.prototype.hasPrefix = function (prefix) {
211 return (this.lastIndexOf(prefix, 0) === 0);
214 /* Toggles whether the page is scrollable.
216 function togglePageScrolling(enable) {
217 let body = query("body");
219 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
220 body.addClass("no-scroll");
221 body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
223 body.removeClass("no-scroll");
224 body.removeAttribute("style");
225 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
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) => {
239 localStorage.setItem("logging-enabled", "true");
241 GW.loggingEnabled = true;
243 GW.disableLogging = (permanently = false) => {
245 localStorage.removeItem("logging-enabled");
247 GW.loggingEnabled = false;
250 /*******************/
251 /* INBOX INDICATOR */
252 /*******************/
254 function processUserStatus(userStatus) {
255 window.userStatus = userStatus;
257 if(userStatus.notifications) {
258 let element = query('#inbox-indicator');
259 element.className = 'new-messages';
260 element.title = 'New messages [o]';
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");
286 textarea.addEventListener("focus", GW.textareaFocused = (event) => {
287 GWLog("GW.textareaFocused");
288 event.target.closest("form").scrollIntoViewIfNeeded();
290 textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
291 GWLog("GW.textareaInputReceived");
292 if (window.innerWidth > 520) {
293 // Expand textarea if needed.
294 expandTextarea(textarea);
296 // Remove markdown hints.
297 hideMarkdownHintsBox();
298 query(".guiedit-mobile-help-button").removeClass("active");
300 // User mentions autocomplete
301 if(textarea.value.charAt(textarea.selectionStart - 1) === "@") {
302 beginAutocompletion(textarea, textarea.selectionStart);
305 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
306 textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
307 textarea.addEventListener("keydown", (event) => {
308 // Special case for alt+4
309 // Generalize this before adding more.
310 if(event.altKey && event.key === '4') {
311 insertMarkup(event, "$", "$", "LaTeX formula");
312 event.stopPropagation();
313 event.preventDefault();
317 let form = textarea.closest("form");
318 if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
320 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
321 let textareaContainer = textarea.closest(".textarea-container");
322 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
323 for (var button of GW.guiEditButtons) {
324 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
325 buttons_container.insertAdjacentHTML("beforeend",
326 "<button type='button' class='guiedit guiedit-"
329 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
330 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
331 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
332 + " onclick='insertMarkup(event,"
333 + ((typeof m_before_or_func == 'function') ?
334 m_before_or_func.name :
335 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
343 `<input type='checkbox' id='markdown-hints-checkbox'>
344 <label for='markdown-hints-checkbox'></label>
345 <div id='markdown-hints'>` +
346 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
347 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
348 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
349 "<span>Heading 1</span><code># Heading 1</code>",
350 "<span>Heading 2</span><code>## Heading 1</code>",
351 "<span>Heading 3</span><code>### Heading 1</code>",
352 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
354 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
356 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
357 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
358 GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
359 if (button.hasClass("guiedit-mobile-help-button")) {
360 toggleMarkdownHintsBox();
361 event.target.toggleClass("active");
362 query(".posting-controls:focus-within textarea").focus();
363 } else if (button.hasClass("guiedit-mobile-exit-button")) {
365 hideMarkdownHintsBox();
366 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
371 // On smartphone (narrow mobile) screens, when a textarea is focused (and
372 // automatically fullscreened), remove all the filters from the page, and
373 // then apply them *just* to the fixed editor UI elements. This is in order
374 // to get around the "children of elements with a filter applied cannot be
376 if (GW.isMobile && window.innerWidth <= 520) {
377 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
378 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
379 GWLog("GW.textareaFocusedMobile");
380 GW.savedFilters = GW.currentFilters;
381 GW.currentFilters = { };
382 applyFilters(GW.currentFilters);
383 fixedEditorElements.forEach(element => {
384 element.style.filter = filterStringFromFilters(GW.savedFilters);
387 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
388 GWLog("GW.textareaBlurredMobile");
389 GW.currentFilters = GW.savedFilters;
390 GW.savedFilters = { };
391 requestAnimationFrame(() => {
392 applyFilters(GW.currentFilters);
393 fixedEditorElements.forEach(element => {
394 element.style.filter = filterStringFromFilters(GW.savedFilters);
401 Element.prototype.injectReplyForm = function(editMarkdownSource) {
402 GWLog("injectReplyForm");
403 let commentControls = this;
404 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
405 let postId = commentControls.parentElement.dataset["postId"];
406 let tagId = commentControls.parentElement.dataset["tagId"];
407 let withparent = (!editMarkdownSource && commentControls.getCommentId());
408 let answer = commentControls.parentElement.id == "answers";
409 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
410 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
411 let parentCommentItem = commentControls.closest(".comment-item");
412 let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
413 (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
414 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
415 "<form method='post'>" +
416 "<div class='textarea-container'>" +
417 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
418 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
419 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
420 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
421 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
422 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
423 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
424 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
425 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
426 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
427 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
428 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
429 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
431 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
432 "<input type='submit' value='Submit'>" +
434 commentControls.onsubmit = disableBeforeUnload;
436 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
437 GWLog("GW.cancelCommentButtonClicked");
438 hideReplyForm(event.target.closest(".comment-controls"));
440 commentControls.scrollIntoViewIfNeeded();
441 commentControls.query("form").onsubmit = (event) => {
442 if (!event.target.text.value) {
443 alert("Please enter a comment.");
447 let textarea = commentControls.query("textarea");
448 textarea.value = MarkdownFromHTML(editMarkdownSource || "");
449 textarea.addTextareaFeatures();
453 function showCommentEditForm(commentItem) {
454 GWLog("showCommentEditForm");
456 let commentBody = commentItem.query(".comment-body");
457 commentBody.style.display = "none";
459 let commentControls = commentItem.query(".comment-controls");
460 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
461 commentControls.query("form").addClass("edit-existing-comment");
462 expandTextarea(commentControls.query("textarea"));
465 function showReplyForm(commentItem) {
466 GWLog("showReplyForm");
468 let commentControls = commentItem.query(".comment-controls");
469 commentControls.injectReplyForm(commentControls.dataset.enteredText);
472 function hideReplyForm(commentControls) {
473 GWLog("hideReplyForm");
474 // Are we editing a comment? If so, un-hide the existing comment body.
475 let containingComment = commentControls.closest(".comment-item");
476 if (containingComment) containingComment.query(".comment-body").style.display = "";
478 let enteredText = commentControls.query("textarea").value;
479 if (enteredText) commentControls.dataset.enteredText = enteredText;
481 disableBeforeUnload();
482 commentControls.constructCommentControls();
485 function expandTextarea(textarea) {
486 GWLog("expandTextarea");
487 if (window.innerWidth <= 520) return;
489 let totalBorderHeight = 30;
490 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
492 requestAnimationFrame(() => {
493 textarea.style.height = 'auto';
494 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
495 if (textarea.clientHeight < window.innerHeight) {
496 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
501 function doCommentAction(action, commentItem) {
502 GWLog("doCommentAction");
504 params[(action + "-comment-id")] = commentItem.getCommentId();
508 onSuccess: GW.commentActionPostSucceeded = (event) => {
509 GWLog("GW.commentActionPostSucceeded");
511 retract: () => { commentItem.firstChild.addClass("retracted") },
512 unretract: () => { commentItem.firstChild.removeClass("retracted") },
514 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>";
515 commentItem.removeChild(commentItem.query(".comment-controls"));
519 if(action != "delete")
520 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
529 function parseVoteType(voteType) {
530 GWLog("parseVoteType");
532 if (!voteType) return value;
533 value.up = /[Uu]pvote$/.test(voteType);
534 value.down = /[Dd]ownvote$/.test(voteType);
535 value.big = /^big/.test(voteType);
539 function makeVoteType(value) {
540 GWLog("makeVoteType");
541 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
544 function makeVoteClass(vote) {
545 GWLog("makeVoteClass");
546 if (vote.up || vote.down) {
547 return (vote.big ? 'selected big-vote' : 'selected');
553 function findVoteControls(targetType, targetId, voteAxis) {
554 var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
556 if(targetType == "Post") {
557 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
558 } else if(targetType == "Comment") {
559 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
563 function votesEqual(vote1, vote2) {
564 var allKeys = Object.assign({}, vote1);
565 Object.assign(allKeys, vote2);
567 for(k of allKeys.keys()) {
568 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
573 function addVoteButtons(element, vote, targetType) {
574 GWLog("addVoteButtons");
576 let voteAxis = element.parentElement.dataset.voteAxis || "karma";
577 let voteType = parseVoteType(vote[voteAxis]);
578 let voteClass = makeVoteClass(voteType);
580 element.parentElement.queryAll("button").forEach((button) => {
581 button.disabled = false;
583 if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
584 button.addClass(voteClass);
586 updateVoteButtonVisualState(button);
587 button.addActivateEvent(voteButtonClicked);
591 function updateVoteButtonVisualState(button) {
592 GWLog("updateVoteButtonVisualState");
594 button.removeClasses([ "none", "one", "two-temp", "two" ]);
597 button.addClass("none");
598 else if (button.hasClass("big-vote"))
599 button.addClass("two");
600 else if (button.hasClass("selected"))
601 button.addClass("one");
603 button.addClass("none");
606 function changeVoteButtonVisualState(button) {
607 GWLog("changeVoteButtonVisualState");
609 /* Interaction states are:
611 0 0· (neutral; +1 click)
612 1 1· (small vote; +1 click)
613 2 2· (big vote; +1 click)
615 Visual states are (with their state classes in [brackets]) are:
618 02 (small vote active) [one]
619 12 (small vote active, temporary indicator of big vote) [two-temp]
620 22 (big vote active) [two]
622 The following are the 9 possible interaction state transitions (and
623 the visual state transitions associated with them):
626 FROM TO FROM TO NOTES
627 ==== ==== ==== ==== =====
628 0 0· 01 12 first click
629 0· 1 12 02 one click without second
630 0· 2 12 22 second click
632 1 1· 02 12 first click
633 1· 0 12 01 one click without second
634 1· 2 12 22 second click
636 2 2· 22 12 first click
637 2· 1 12 02 one click without second
638 2· 0 12 01 second click
641 [ "big-vote two-temp clicked-twice", "none" ], // 2· => 0
642 [ "big-vote two-temp clicked-once", "one" ], // 2· => 1
643 [ "big-vote clicked-once", "two-temp" ], // 2 => 2·
645 [ "selected two-temp clicked-twice", "two" ], // 1· => 2
646 [ "selected two-temp clicked-once", "none" ], // 1· => 0
647 [ "selected clicked-once", "two-temp" ], // 1 => 1·
649 [ "two-temp clicked-twice", "two" ], // 0· => 2
650 [ "two-temp clicked-once", "one" ], // 0· => 1
651 [ "clicked-once", "two-temp" ], // 0 => 0·
653 for (let [ interactionClasses, visualStateClass ] of transitions) {
654 if (button.hasClasses(interactionClasses.split(" "))) {
655 button.removeClasses([ "none", "one", "two-temp", "two" ]);
656 button.addClass(visualStateClass);
662 function voteCompleteEvent(targetType, targetId, response) {
663 GWLog("voteCompleteEvent");
665 var currentVote = voteData[targetType][targetId] || {};
666 var desiredVote = voteDesired[targetType][targetId];
668 var controls = findVoteControls(targetType, targetId);
669 var controlsByAxis = new Object;
671 controls.forEach(control => {
672 const voteAxis = (control.dataset.voteAxis || "karma");
674 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
675 control.removeClass("waiting");
676 control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
679 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
680 controlsByAxis[voteAxis].push(control);
682 const voteType = currentVote[voteAxis];
683 const vote = parseVoteType(voteType);
684 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
685 const voteClass = makeVoteClass(vote);
687 if (response && response[voteAxis]) {
688 const [voteType, displayText, titleText] = response[voteAxis];
690 const displayTarget = control.query(".karma-value");
691 if (displayTarget.hasClass("redacted")) {
692 displayTarget.dataset["trueValue"] = displayText;
694 displayTarget.innerHTML = displayText;
696 displayTarget.setAttribute("title", titleText);
699 control.queryAll("button.vote").forEach(button => {
700 updateVoteButton(button, voteUpDown, voteClass);
705 function updateVoteButton(button, voteUpDown, voteClass) {
706 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
707 if (button.dataset.voteType == voteUpDown)
708 button.addClass(voteClass);
709 updateVoteButtonVisualState(button);
712 function makeVoteRequestCompleteEvent(targetType, targetId) {
714 var currentVote = {};
717 if (event.target.status == 200) {
718 response = JSON.parse(event.target.responseText);
719 for (const voteAxis of response.keys()) {
720 currentVote[voteAxis] = response[voteAxis][0];
722 voteData[targetType][targetId] = currentVote;
724 delete voteDesired[targetType][targetId];
725 currentVote = voteData[targetType][targetId];
728 var desiredVote = voteDesired[targetType][targetId];
730 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
731 sendVoteRequest(targetType, targetId);
733 delete voteDesired[targetType][targetId];
734 voteCompleteEvent(targetType, targetId, response);
739 function sendVoteRequest(targetType, targetId) {
740 GWLog("sendVoteRequest");
744 location: "/karma-vote",
745 params: { "target": targetId,
746 "target-type": targetType,
747 "vote": JSON.stringify(voteDesired[targetType][targetId]) },
748 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
752 function voteButtonClicked(event) {
753 GWLog("voteButtonClicked");
754 let voteButton = event.target;
756 // 500 ms (0.5 s) double-click timeout.
757 let doubleClickTimeout = 500;
759 if (!voteButton.clickedOnce) {
760 voteButton.clickedOnce = true;
761 voteButton.addClass("clicked-once");
762 changeVoteButtonVisualState(voteButton);
764 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
765 if (!voteButton.clickedOnce) return;
767 // Do single-click code.
768 voteButton.clickedOnce = false;
769 voteEvent(voteButton, 1);
770 }, doubleClickTimeout, voteButton);
772 voteButton.clickedOnce = false;
774 // Do double-click code.
775 voteButton.removeClass("clicked-once");
776 voteButton.addClass("clicked-twice");
777 voteEvent(voteButton, 2);
781 function voteEvent(voteButton, numClicks) {
785 let voteControl = voteButton.parentNode;
787 let targetType = voteButton.dataset.targetType;
788 let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
789 let voteAxis = voteControl.dataset.voteAxis || "karma";
790 let voteUpDown = voteButton.dataset.voteType;
793 if ( (numClicks == 2 && voteButton.hasClass("big-vote"))
794 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
795 voteType = "neutral";
797 let vote = parseVoteType(voteUpDown);
798 vote.big = (numClicks == 2);
799 voteType = makeVoteType(vote);
802 let voteControls = findVoteControls(targetType, targetId, voteAxis);
803 for (const voteControl of voteControls) {
804 voteControl.addClass("waiting");
805 voteControl.queryAll(".vote").forEach(button => {
806 button.addClass("waiting");
807 updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
811 let voteRequestPending = voteDesired[targetType][targetId];
812 let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
813 voteObject[voteAxis] = voteType;
814 voteDesired[targetType][targetId] = voteObject;
816 if (!voteRequestPending) sendVoteRequest(targetType, targetId);
819 function initializeVoteButtons() {
820 // Color the upvote/downvote buttons with an embedded style sheet.
821 query("head").insertAdjacentHTML("beforeend", "<style id='vote-buttons'>" + `
823 --GW-upvote-button-color: #00d800;
824 --GW-downvote-button-color: #eb4c2a;
828 function processVoteData(voteData) {
829 window.voteData = voteData;
831 window.voteDesired = new Object;
832 for(key of voteData.keys()) {
833 voteDesired[key] = new Object;
836 initializeVoteButtons();
838 addTriggerListener("postLoaded", {priority: 3000, fn: () => {
839 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
840 let postID = karmaValue.parentNode.dataset.postId;
841 addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
842 karmaValue.parentElement.addClass("active-controls");
846 addTriggerListener("DOMReady", {priority: 3000, fn: () => {
847 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
848 let commentID = karmaValue.getCommentId();
849 addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
850 karmaValue.parentElement.addClass("active-controls");
855 /*****************************************/
856 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
857 /*****************************************/
859 Element.prototype.getCommentDate = function() {
860 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
861 let dateElement = item && item.query(".date");
862 return (dateElement && parseInt(dateElement.dataset["jsDate"]));
864 function getCurrentVisibleComment() {
865 let px = window.innerWidth/2, py = 5;
866 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
867 let bottomBar = query("#bottom-bar");
868 let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
869 let atbottom = bottomOffset <= window.innerHeight;
871 let hashci = location.hash && query(location.hash);
872 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
873 commentItem = hashci;
879 function highlightCommentsSince(date) {
880 GWLog("highlightCommentsSince");
881 var newCommentsCount = 0;
882 GW.newComments = [ ];
883 let oldCommentsStack = [ ];
885 queryAll(".comment-item").forEach(commentItem => {
886 commentItem.prevNewComment = prevNewComment;
887 commentItem.nextNewComment = null;
888 if (commentItem.getCommentDate() > date) {
889 commentItem.addClass("new-comment");
891 GW.newComments.push(commentItem.getCommentId());
892 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
893 oldCommentsStack = [ commentItem ];
894 prevNewComment = commentItem;
896 commentItem.removeClass("new-comment");
897 oldCommentsStack.push(commentItem);
901 GW.newCommentScrollSet = (commentItem) => {
902 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
903 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
905 GW.newCommentScrollListener = () => {
906 let commentItem = getCurrentVisibleComment();
907 GW.newCommentScrollSet(commentItem);
910 addScrollListener(GW.newCommentScrollListener);
912 if (document.readyState=="complete") {
913 GW.newCommentScrollListener();
915 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
916 GW.newCommentScrollSet(commentItem);
919 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
921 return newCommentsCount;
924 function scrollToNewComment(next) {
925 GWLog("scrollToNewComment");
926 let commentItem = getCurrentVisibleComment();
927 let targetComment = null;
928 let targetCommentID = null;
930 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
932 targetCommentID = targetComment.getCommentId();
935 if (GW.newComments[0]) {
936 targetCommentID = GW.newComments[0];
937 targetComment = query("#comment-" + targetCommentID);
941 expandAncestorsOf(targetCommentID);
942 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
943 targetComment.scrollIntoView();
946 GW.newCommentScrollListener();
949 function getPostHash() {
950 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
951 return (postHash ? postHash[1] : false);
953 function setHistoryLastVisitedDate(date) {
954 window.history.replaceState({ lastVisited: date }, null);
956 function getLastVisitedDate() {
957 // Get the last visited date (or, if posting a comment, the previous last visited date).
958 if(window.history.state) return (window.history.state||{})['lastVisited'];
959 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
960 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
961 let currentVisited = localStorage.getItem(storageName);
962 setHistoryLastVisitedDate(currentVisited);
963 return currentVisited;
965 function setLastVisitedDate(date) {
966 GWLog("setLastVisitedDate");
967 // If NOT posting a comment, save the previous value for the last-visited-date
968 // (to recover it in case of posting a comment).
969 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
970 if (!aCommentHasJustBeenPosted) {
971 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
972 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
975 // Set the new value.
976 localStorage.setItem("last-visited-date_" + getPostHash(), date);
979 function updateSavedCommentCount() {
980 GWLog("updateSavedCommentCount");
981 let commentCount = queryAll(".comment").length;
982 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
984 function badgePostsWithNewComments() {
985 GWLog("badgePostsWithNewComments");
986 if (getQueryVariable("show") == "conversations") return;
988 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
989 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
991 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
992 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
993 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
995 if (currentCommentCount > savedCommentCount)
996 commentCountDisplay.addClass("new-comments");
998 commentCountDisplay.removeClass("new-comments");
999 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1003 /***********************************/
1004 /* CONTENT COLUMN WIDTH ADJUSTMENT */
1005 /***********************************/
1007 function injectContentWidthSelector() {
1008 GWLog("injectContentWidthSelector");
1009 // Get saved width setting (or default).
1010 let currentWidth = localStorage.getItem("selected-width") || 'normal';
1012 // Inject the content width selector widget and activate buttons.
1013 let widthSelector = addUIElement(
1014 "<div id='width-selector'>" +
1015 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
1016 let [name, desc, abbr] = widthOption;
1017 let selected = (name == currentWidth ? ' selected' : '');
1018 let disabled = (name == currentWidth ? ' disabled' : '');
1019 return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
1021 widthSelector.queryAll("button").forEach(button => {
1022 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
1023 GWLog("GW.widthAdjustButtonClicked");
1025 // Determine which setting was chosen (i.e., which button was clicked).
1026 let selectedWidth = event.target.dataset.name;
1028 // Save the new setting.
1029 if (selectedWidth == "normal") localStorage.removeItem("selected-width");
1030 else localStorage.setItem("selected-width", selectedWidth);
1032 // Save current visible comment
1033 let visibleComment = getCurrentVisibleComment();
1035 // Actually change the content width.
1036 setContentWidth(selectedWidth);
1037 event.target.parentElement.childNodes.forEach(button => {
1038 button.removeClass("selected");
1039 button.disabled = false;
1041 event.target.addClass("selected");
1042 event.target.disabled = true;
1044 // Make sure the accesskey (to cycle to the next width) is on the right button.
1045 setWidthAdjustButtonsAccesskey();
1047 // Regenerate images overlay.
1048 generateImagesOverlay();
1050 if(visibleComment) visibleComment.scrollIntoView();
1054 // Make sure the accesskey (to cycle to the next width) is on the right button.
1055 setWidthAdjustButtonsAccesskey();
1057 // Inject transitions CSS, if animating changes is enabled.
1058 if (GW.adjustmentTransitions) {
1060 "<style id='width-transition'>" +
1062 #ui-elements-container,
1065 max-width 0.3s ease;
1070 function setWidthAdjustButtonsAccesskey() {
1071 GWLog("setWidthAdjustButtonsAccesskey");
1072 let widthSelector = query("#width-selector");
1073 widthSelector.queryAll("button").forEach(button => {
1074 button.removeAttribute("accesskey");
1075 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1077 let selectedButton = widthSelector.query("button.selected");
1078 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
1079 nextButtonInCycle.accessKey = "'";
1080 nextButtonInCycle.title += ` [\']`;
1083 /*******************/
1084 /* THEME SELECTION */
1085 /*******************/
1087 function injectThemeSelector() {
1088 GWLog("injectThemeSelector");
1089 let currentTheme = readCookie("theme") || "default";
1090 let themeSelector = addUIElement(
1091 "<div id='theme-selector' class='theme-selector'>" +
1092 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
1093 let [name, desc, letter] = themeOption;
1094 let selected = (name == currentTheme ? ' selected' : '');
1095 let disabled = (name == currentTheme ? ' disabled' : '');
1096 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1097 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>`;})) +
1099 themeSelector.queryAll("button").forEach(button => {
1100 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
1101 GWLog("GW.themeSelectButtonClicked");
1102 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
1103 setSelectedTheme(themeName);
1104 if (GW.isMobile) toggleAppearanceAdjustUI();
1108 // Inject transitions CSS, if animating changes is enabled.
1109 if (GW.adjustmentTransitions) {
1111 "<style id='theme-fade-transition'>" +
1114 opacity 0.5s ease-out,
1115 background-color 0.3s ease-out;
1118 background-color: #777;
1121 opacity 0.5s ease-in,
1122 background-color 0.3s ease-in;
1127 function setSelectedTheme(themeName) {
1128 GWLog("setSelectedTheme");
1129 queryAll(".theme-selector button").forEach(button => {
1130 button.removeClass("selected");
1131 button.disabled = false;
1133 queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
1134 button.addClass("selected");
1135 button.disabled = true;
1137 setTheme(themeName);
1138 query("#theme-tweaker-ui .current-theme span").innerText = themeName;
1140 function setTheme(newThemeName) {
1141 var themeUnloadCallback = '';
1142 var oldThemeName = '';
1143 if (typeof(newThemeName) == 'undefined') {
1144 newThemeName = readCookie('theme');
1145 if (!newThemeName) return;
1147 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
1148 oldThemeName = readCookie('theme') || 'default';
1150 if (newThemeName == 'default') setCookie('theme', '');
1151 else setCookie('theme', newThemeName);
1153 if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
1155 let makeNewStyle = function(newThemeName, colorSchemePreference) {
1156 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
1157 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1159 let newStyle = document.createElement('link');
1160 newStyle.setAttribute('class', 'theme');
1161 if(colorSchemePreference)
1162 newStyle.setAttribute('media', '(prefers-color-scheme: ' + colorSchemePreference + ')');
1163 newStyle.setAttribute('rel', 'stylesheet');
1164 newStyle.setAttribute('href', '/css/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1168 let newMainStyle, newStyles;
1169 if(newThemeName === 'default') {
1170 newStyles = [makeNewStyle('dark', 'dark'), makeNewStyle('default', 'light')];
1171 newMainStyle = (window.matchMedia('prefers-color-scheme: dark').matches ? newStyles[0] : newStyles[1]);
1173 newStyles = [makeNewStyle(newThemeName)];
1174 newMainStyle = newStyles[0];
1177 let oldStyles = queryAll("head link.theme");
1178 newMainStyle.addEventListener('load', () => { oldStyles.forEach(x => removeElement(x)); });
1179 newMainStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
1181 if (GW.adjustmentTransitions) {
1182 pageFadeTransition(false);
1184 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1187 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1190 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
1191 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1192 document.body.addClass("theme-" + newThemeName);
1194 recomputeUIElementsContainerHeight(true);
1196 let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
1197 if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
1199 recomputeUIElementsContainerHeight();
1200 adjustUIForWindowSize();
1201 window.addEventListener('resize', GW.windowResized = (event) => {
1202 GWLog("GW.windowResized");
1203 adjustUIForWindowSize();
1204 recomputeUIElementsContainerHeight();
1207 generateImagesOverlay();
1209 if (window.adjustmentTransitions) pageFadeTransition(true);
1210 updateThemeTweakerSampleText();
1212 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
1213 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1217 function pageFadeTransition(fadeIn) {
1219 query("body").removeClass("transparent");
1221 query("body").addClass("transparent");
1225 GW.themeLoadCallback_less = (fromTheme = "") => {
1226 GWLog("themeLoadCallback_less");
1227 injectSiteNavUIToggle();
1229 injectPostNavUIToggle();
1230 injectAppearanceAdjustUIToggle();
1233 registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1234 let dtf = new Intl.DateTimeFormat([],
1235 (window.innerWidth < 1100) ?
1236 { month: 'short', day: 'numeric', year: 'numeric' } :
1237 { month: 'long', day: 'numeric', year: 'numeric' });
1238 let postDate = query(".top-post-meta .date");
1239 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1243 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1247 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1248 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1249 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1253 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1254 // If state is not set (user has never clicked on the Less theme's appearance
1255 // adjustment UI toggle) then show it, but then hide it after a short time.
1256 registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1257 toggleAppearanceAdjustUI();
1258 setTimeout(toggleAppearanceAdjustUI, 3000);
1262 if (fromTheme != "") {
1263 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1264 setTimeout(function () {
1265 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1267 setTimeout(function () {
1268 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1272 // Unset the height of the #ui-elements-container.
1273 query("#ui-elements-container").style.height = "";
1275 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1276 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1277 "#content #secondary-bar",
1278 "#content .post .top-post-meta .date",
1279 "#content .post .top-post-meta .comment-count",
1281 applyFilters(GW.currentFilters);
1284 // We pre-query the relevant elements, so we don't have to run querySelectorAll
1285 // on every firing of the scroll listener.
1287 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1288 "unbrokenDownScrollDistance": 0,
1289 "unbrokenUpScrollDistance": 0,
1290 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1291 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1292 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1294 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1297 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
1298 // otherwise, show it.
1299 function updatePostNavUIVisibility() {
1300 GWLog("updatePostNavUIVisibility");
1301 var hidePostNavUIToggle = true;
1302 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1303 if (getComputedStyle(element).visibility == "visible" ||
1304 element.style.visibility == "visible" ||
1305 element.style.visibility == "unset")
1306 hidePostNavUIToggle = false;
1308 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1309 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1313 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1314 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
1315 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1316 function updateSiteNavUIState(event) {
1317 GWLog("updateSiteNavUIState");
1318 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1319 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
1320 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
1322 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1323 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1325 GW.scrollState.lastScrollTop = newScrollTop;
1327 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1328 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1329 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1330 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1333 // On mobile, make site nav UI translucent on ANY scroll down.
1335 GW.scrollState.siteNavUIElements.forEach(element => {
1336 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1337 else element.removeClass("translucent-on-scroll");
1340 // Show site nav UI when scrolling a full page up, or to the top.
1341 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
1342 GW.scrollState.lastScrollTop == 0) &&
1343 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
1344 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1346 // On desktop, show appearance adjust UI when scrolling to the top.
1347 if ((!GW.isMobile) &&
1348 (GW.scrollState.lastScrollTop == 0) &&
1349 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
1350 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1353 GW.themeUnloadCallback_less = (toTheme = "") => {
1354 GWLog("themeUnloadCallback_less");
1355 removeSiteNavUIToggle();
1357 removePostNavUIToggle();
1358 removeAppearanceAdjustUIToggle();
1360 window.removeEventListener('resize', updatePostNavUIVisibility);
1362 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1364 removeElement("#theme-less-mobile-first-row-placeholder");
1368 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1369 element.innerHTML = element.firstChild.innerHTML;
1373 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1375 // Reset filtered elements selector to default.
1376 delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1377 applyFilters(GW.currentFilters);
1380 GW.themeLoadCallback_dark = (fromTheme = "") => {
1381 GWLog("themeLoadCallback_dark");
1383 "<style id='dark-theme-adjustments'>" +
1384 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` +
1385 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1387 registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1388 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1389 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)";
1390 image.style.width = parseInt(image.style.width) + 12 + "px";
1391 image.style.height = parseInt(image.style.height) + 12 + "px";
1392 image.style.top = parseInt(image.style.top) - 6 + "px";
1393 image.style.left = parseInt(image.style.left) - 6 + "px";
1397 GW.themeUnloadCallback_dark = (toTheme = "") => {
1398 GWLog("themeUnloadCallback_dark");
1399 removeElement("#dark-theme-adjustments");
1402 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1403 GWLog("themeLoadCallback_brutalist");
1404 let bottomBarLinks = queryAll("#bottom-bar a");
1405 if (!GW.isMobile && bottomBarLinks.length == 5) {
1406 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1407 bottomBarLinks.forEach((link, i) => {
1408 link.dataset.originalText = link.textContent;
1409 link.textContent = newLinkTexts[i];
1413 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1414 GWLog("themeUnloadCallback_brutalist");
1415 let bottomBarLinks = queryAll("#bottom-bar a");
1416 if (!GW.isMobile && bottomBarLinks.length == 5) {
1417 bottomBarLinks.forEach(link => {
1418 link.textContent = link.dataset.originalText;
1423 GW.themeLoadCallback_classic = (fromTheme = "") => {
1424 GWLog("themeLoadCallback_classic");
1425 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1426 button.innerHTML = "";
1429 GW.themeUnloadCallback_classic = (toTheme = "") => {
1430 GWLog("themeUnloadCallback_classic");
1431 if (GW.isMobile && window.innerWidth <= 900) return;
1432 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1433 button.innerHTML = button.dataset.label;
1437 /********************************************/
1438 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1439 /********************************************/
1441 function injectThemeTweaker() {
1442 GWLog("injectThemeTweaker");
1443 let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" +
1444 `<div class='main-theme-tweaker-window'>
1445 <h1>Customize appearance</h1>
1446 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1447 <button type='button' class='help-button' tabindex='-1'></button>
1448 <p class='current-theme'>Current theme: <span>` +
1449 (readCookie("theme") || "default") +
1451 <p class='theme-selector'></p>
1452 <div class='controls-container'>
1453 <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1454 <div class='sample-text-container'><span class='sample-text'>
1455 <p>Less Wrong (text)</p>
1456 <p><a href="#">Less Wrong (link)</a></p>
1459 <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1460 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1461 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1462 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1464 <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1465 <input type='checkbox' id='theme-tweak-control-invert'></input>
1466 <label for='theme-tweak-control-invert'>Invert colors</label>
1468 <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1469 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1470 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1471 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1473 <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1474 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1475 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1476 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1478 <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1479 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1480 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1481 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1483 <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1484 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1485 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1486 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1489 <div class='buttons-container'>
1490 <button type="button" class="reset-defaults-button">Reset to defaults</button>
1491 <button type='button' class='ok-button default-button'>OK</button>
1492 <button type='button' class='cancel-button'>Cancel</button>
1495 <div class="clippy-container">
1496 <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>)
1497 <div class='clippy'></div>
1498 <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>
1500 <div class='help-window' style='display: none;'>
1501 <h1>Theme tweaker help</h1>
1502 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1503 <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1504 <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1506 <div class='buttons-container'>
1507 <button type='button' class='ok-button default-button'>OK</button>
1508 <button type='button' class='cancel-button'>Cancel</button>
1513 // Clicking the background overlay closes the theme tweaker.
1514 themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1515 GWLog("GW.themeTweaker.UIOverlayClicked");
1516 if (event.type == 'mousedown') {
1517 themeTweakerUI.style.opacity = "0.01";
1519 toggleThemeTweakerUI();
1520 themeTweakerUI.style.opacity = "1.0";
1525 // Intercept clicks, so they don't "fall through" the background overlay.
1526 (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1528 let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1529 themeTweakerUI.queryAll("input").forEach(field => {
1530 // All input types in the theme tweaker receive a 'change' event when
1531 // their value is changed. (Range inputs, in particular, receive this
1532 // event when the user lets go of the handle.) This means we should
1533 // update the filters for the entire page, to match the new setting.
1534 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1535 GWLog("GW.themeTweaker.fieldValueChanged");
1536 if (event.target.id == 'theme-tweak-control-invert') {
1537 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1538 } else if (event.target.type == 'range') {
1539 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1540 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1541 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1542 } else if (event.target.id == 'theme-tweak-control-clippy') {
1543 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1545 // Clear the sample text filters.
1546 sampleTextContainer.style.filter = "";
1547 // Apply the new filters globally.
1548 applyFilters(GW.currentFilters);
1551 // Range inputs receive an 'input' event while being scrubbed, updating
1552 // "live" as the handle is moved. We don't want to change the filters
1553 // for the actual page while this is happening, but we do want to change
1554 // the filters for the *sample text*, so the user can see what effects
1555 // his changes are having, live, without having to let go of the handle.
1556 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1557 GWLog("GW.themeTweaker.fieldInputReceived");
1558 var sampleTextFilters = GW.currentFilters;
1560 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1561 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1562 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1564 sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1568 themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1569 GWLog("GW.themeTweaker.minimizeButtonClicked");
1570 let themeTweakerStyle = query("#theme-tweaker-style");
1572 if (event.target.hasClass("minimize")) {
1573 event.target.removeClass("minimize");
1574 themeTweakerStyle.innerHTML =
1575 `#theme-tweaker-ui .main-theme-tweaker-window {
1579 padding: 30px 0 0 0;
1584 #theme-tweaker-ui::after {
1588 #theme-tweaker-ui::before {
1592 #theme-tweaker-ui .clippy-container {
1595 #theme-tweaker-ui .clippy-container .hint span {
1601 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1602 pointer-events: none;
1604 event.target.addClass("maximize");
1606 event.target.removeClass("maximize");
1607 themeTweakerStyle.innerHTML =
1608 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1609 pointer-events: none;
1611 event.target.addClass("minimize");
1614 themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1615 GWLog("GW.themeTweaker.helpButtonClicked");
1616 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1617 toggleThemeTweakerHelpWindow();
1619 themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1620 GWLog("GW.themeTweaker.resetDefaultsButtonClicked");
1621 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1622 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1623 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1624 slider.value = slider.dataset['defaultValue'];
1625 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1627 GW.currentFilters = { };
1628 applyFilters(GW.currentFilters);
1630 GW.currentTextZoom = "1.0";
1631 setTextZoom(GW.currentTextZoom);
1633 setSelectedTheme("default");
1635 themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1636 GWLog("GW.themeTweaker.cancelButtonClicked");
1637 toggleThemeTweakerUI();
1640 themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1641 GWLog("GW.themeTweaker.OKButtonClicked");
1642 toggleThemeTweakerUI();
1645 themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1646 GWLog("GW.themeTweaker.helpWindowCancelButtonClicked");
1647 toggleThemeTweakerHelpWindow();
1648 themeTweakerResetSettings();
1650 themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1651 GWLog("GW.themeTweaker.helpWindowOKButtonClicked");
1652 toggleThemeTweakerHelpWindow();
1653 themeTweakerSaveSettings();
1656 themeTweakerUI.queryAll(".notch").forEach(notch => {
1657 notch.addActivateEvent(GW.themeTweaker.sliderNotchClicked = (event) => {
1658 GWLog("GW.themeTweaker.sliderNotchClicked");
1659 let slider = event.target.parentElement.query("input[type='range']");
1660 slider.value = slider.dataset['defaultValue'];
1661 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1662 GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1663 applyFilters(GW.currentFilters);
1667 themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1668 GWLog("GW.themeTweaker.clippyCloseButtonClicked");
1669 themeTweakerUI.query(".clippy-container").style.display = "none";
1670 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1671 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1674 query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1676 themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1677 themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1678 button.addActivateEvent(GW.themeSelectButtonClicked);
1681 themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1682 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1685 let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'></button></div>`);
1686 themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1687 GWLog("GW.themeTweaker.toggleButtonClicked");
1688 GW.themeTweakerStyleSheetAvailable = () => {
1689 GWLog("GW.themeTweakerStyleSheetAvailable");
1690 themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1692 themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1693 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1694 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1695 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1696 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1699 toggleThemeTweakerUI();
1700 event.target.disabled = true;
1703 if (query("link[href^='/css/theme_tweaker.css']")) {
1704 // Theme tweaker CSS is already loaded.
1705 GW.themeTweakerStyleSheetAvailable();
1707 // Load the theme tweaker CSS (if not loaded).
1708 let themeTweakerStyleSheet = document.createElement('link');
1709 themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1710 themeTweakerStyleSheet.setAttribute('href', '/css/theme_tweaker.css');
1711 themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1712 query("head").appendChild(themeTweakerStyleSheet);
1716 function toggleThemeTweakerUI() {
1717 GWLog("toggleThemeTweakerUI");
1718 let themeTweakerUI = query("#theme-tweaker-ui");
1719 themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1720 query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" :
1721 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1722 pointer-events: none;
1724 if (themeTweakerUI.style.display != "none") {
1725 // Save selected theme.
1726 GW.currentTheme = (readCookie("theme") || "default");
1727 // Focus invert checkbox.
1728 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1729 // Show sample text in appropriate font.
1730 updateThemeTweakerSampleText();
1731 // Disable tab-selection of the search box.
1732 setSearchBoxTabSelectable(false);
1733 // Disable scrolling of the page.
1734 togglePageScrolling(false);
1736 query("#theme-tweaker-toggle button").disabled = false;
1737 // Re-enable tab-selection of the search box.
1738 setSearchBoxTabSelectable(true);
1739 // Re-enable scrolling of the page.
1740 togglePageScrolling(true);
1742 // Set theme tweaker assistant visibility.
1743 query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1745 function setSearchBoxTabSelectable(selectable) {
1746 GWLog("setSearchBoxTabSelectable");
1747 query("input[type='search']").tabIndex = selectable ? "" : "-1";
1748 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1750 function toggleThemeTweakerHelpWindow() {
1751 GWLog("toggleThemeTweakerHelpWindow");
1752 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1753 themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1754 if (themeTweakerHelpWindow.style.display != "none") {
1755 // Focus theme tweaker assistant checkbox.
1756 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1757 // Disable interaction on main theme tweaker window.
1758 query("#theme-tweaker-ui").style.pointerEvents = "none";
1759 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1761 // Re-enable interaction on main theme tweaker window.
1762 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1763 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1766 function themeTweakReset() {
1767 GWLog("themeTweakReset");
1768 setSelectedTheme(GW.currentTheme);
1769 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1770 applyFilters(GW.currentFilters);
1771 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1772 setTextZoom(GW.currentTextZoom);
1774 function themeTweakSave() {
1775 GWLog("themeTweakSave");
1776 GW.currentTheme = (readCookie("theme") || "default");
1777 localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1778 localStorage.setItem("text-zoom", GW.currentTextZoom);
1781 function themeTweakerResetSettings() {
1782 GWLog("themeTweakerResetSettings");
1783 query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1784 query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1786 function themeTweakerSaveSettings() {
1787 GWLog("themeTweakerSaveSettings");
1788 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1790 function updateThemeTweakerSampleText() {
1791 GWLog("updateThemeTweakerSampleText");
1792 let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1794 // This causes the sample text to take on the properties of the body text of a post.
1795 sampleText.removeClass("body-text");
1796 let bodyTextElement = query(".post-body") || query(".comment-body");
1797 sampleText.addClass("body-text");
1798 sampleText.style.color = bodyTextElement ?
1799 getComputedStyle(bodyTextElement).color :
1800 getComputedStyle(query("#content")).color;
1802 // Here we find out what is the actual background color that will be visible behind
1803 // the body text of posts, and set the sample text’s background to that.
1804 let findStyleBackground = (selector) => {
1806 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
1807 if(rule.selectorText == selector)
1810 return x.style.backgroundColor;
1813 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
1816 /*********************/
1817 /* PAGE QUICK-NAV UI */
1818 /*********************/
1820 function injectQuickNavUI() {
1821 GWLog("injectQuickNavUI");
1822 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1823 `<a href='#top' title="Up to top [,]" accesskey=','></a>
1824 <a href='#comments' title="Comments [/]" accesskey='/'></a>
1825 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
1829 /**********************/
1830 /* NEW COMMENT NAV UI */
1831 /**********************/
1833 function injectNewCommentNavUI(newCommentsCount) {
1834 GWLog("injectNewCommentNavUI");
1835 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
1836 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
1837 <span class='new-comments-count'></span>
1838 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
1841 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1842 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1843 GWLog("GW.commentQuicknavButtonClicked");
1844 scrollToNewComment(/next/.test(event.target.className));
1845 event.target.blur();
1849 document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => {
1850 GWLog("GW.commentQuicknavKeyPressed");
1851 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1852 if (event.key == ",") scrollToNewComment(false);
1853 if (event.key == ".") scrollToNewComment(true)
1856 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1857 + `<span>Since:</span>`
1858 + `<input type='text' class='hns-date'></input>`
1861 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1862 GWLog("GW.hnsDatePickerValueChanged");
1863 let hnsDate = time_fromHuman(event.target.value);
1865 setHistoryLastVisitedDate(hnsDate);
1866 let newCommentsCount = highlightCommentsSince(hnsDate);
1867 updateNewCommentNavUI(newCommentsCount);
1871 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1872 GWLog("GW.newCommentsCountClicked");
1873 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1874 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1878 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1879 function time_fromHuman(string) {
1880 /* Convert a human-readable date into a JS timestamp */
1881 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1882 string = string.replace(' ', 'T'); // revert nice spacing
1883 string += ':00.000Z'; // complete ISO 8601 date
1884 time = Date.parse(string); // milliseconds since epoch
1886 // browsers handle ISO 8601 without explicit timezone differently
1887 // thus, we have to fix that by hand
1888 time += (new Date()).getTimezoneOffset() * 60e3;
1890 string = string.replace(' at', '');
1891 time = Date.parse(string); // milliseconds since epoch
1896 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1897 GWLog("updateNewCommentNavUI");
1898 // Update the new comments count.
1899 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1900 newCommentsCountLabel.innerText = newCommentsCount;
1901 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1903 // Update the date picker field.
1904 if (hnsDate != -1) {
1905 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1909 /***************************/
1910 /* TEXT SIZE ADJUSTMENT UI */
1911 /***************************/
1913 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1914 GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1915 var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1916 if (event.target.hasClass("decrease")) {
1917 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1918 } else if (event.target.hasClass("increase")) {
1919 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1923 setTextZoom(zoomFactor);
1924 GW.currentTextZoom = `${zoomFactor}`;
1926 if (event.target.parentElement.id == "text-size-adjustment-ui") {
1927 localStorage.setItem("text-zoom", GW.currentTextZoom);
1931 function injectTextSizeAdjustmentUIReal() {
1932 GWLog("injectTextSizeAdjustmentUIReal");
1933 let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1934 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1935 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1936 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1939 textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1940 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1943 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1946 function injectTextSizeAdjustmentUI() {
1947 GWLog("injectTextSizeAdjustmentUI");
1948 if (query("#text-size-adjustment-ui") != null) return;
1949 if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1950 else document.addEventListener("DOMContentLoaded", () => {
1951 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1955 /********************************/
1956 /* COMMENTS VIEW MODE SELECTION */
1957 /********************************/
1959 function injectCommentsViewModeSelector() {
1960 GWLog("injectCommentsViewModeSelector");
1961 let commentsContainer = query("#comments");
1962 if (commentsContainer == null) return;
1964 let currentModeThreaded = (location.href.search("chrono=t") == -1);
1965 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1967 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1968 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
1969 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
1972 // commentsViewModeSelector.queryAll("a").forEach(button => {
1973 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1976 if (!currentModeThreaded) {
1977 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1978 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1979 commentParentLink.addClass("inline-author");
1980 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1983 queryAll(".comment-child-links a").forEach(commentChildLink => {
1984 commentChildLink.textContent = commentChildLink.textContent.slice(1);
1985 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
1988 rectifyChronoModeCommentChildLinks();
1990 commentsContainer.addClass("chrono");
1992 commentsContainer.addClass("threaded");
1995 // Remove extraneous top-level comment thread in chrono mode.
1996 let topLevelCommentThread = query("#comments > .comment-thread");
1997 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2000 // function commentsViewModeSelectorButtonClicked(event) {
2001 // event.preventDefault();
2004 // let request = new XMLHttpRequest();
2005 // request.open("GET", event.target.href);
2006 // request.onreadystatechange = () => {
2007 // if (request.readyState != 4) return;
2008 // newDocument = htmlToElement(request.response);
2010 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2012 // // Update the buttons.
2013 // event.target.addClass("selected");
2014 // event.target.parentElement.query("." + classes.old).removeClass("selected");
2016 // // Update the #comments container.
2017 // let commentsContainer = query("#comments");
2018 // commentsContainer.removeClass(classes.old);
2019 // commentsContainer.addClass(classes.new);
2021 // // Update the content.
2022 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2027 // function htmlToElement(html) {
2028 // var template = document.createElement('template');
2029 // template.innerHTML = html.trim();
2030 // return template.content;
2033 function rectifyChronoModeCommentChildLinks() {
2034 GWLog("rectifyChronoModeCommentChildLinks");
2035 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2036 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2037 let childLinks = commentChildLinksContainer.queryAll("a");
2038 childLinks.forEach((link, index) => {
2039 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2043 let childLinksArray = Array.from(childLinks)
2044 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2045 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2048 function childrenOfComment(commentID) {
2049 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2050 let commentParentLink = commentItem.query("a.comment-parent-link");
2051 return ((commentParentLink||{}).hash == "#" + commentID);
2055 /********************************/
2056 /* COMMENTS LIST MODE SELECTION */
2057 /********************************/
2059 function injectCommentsListModeSelector() {
2060 GWLog("injectCommentsListModeSelector");
2061 if (query("#content > .comment-thread") == null) return;
2063 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2064 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2065 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2068 if (query(".sublevel-nav") || query("#top-nav-bar")) {
2069 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2071 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2073 let commentsListModeSelector = query("#comments-list-mode-selector");
2075 commentsListModeSelector.queryAll("button").forEach(button => {
2076 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2077 GWLog("GW.commentsListModeSelectButtonClicked");
2078 event.target.parentElement.queryAll("button").forEach(button => {
2079 button.removeClass("selected");
2080 button.disabled = false;
2081 button.accessKey = '`';
2083 localStorage.setItem("comments-list-mode", event.target.className);
2084 event.target.addClass("selected");
2085 event.target.disabled = true;
2086 event.target.removeAttribute("accesskey");
2088 if (event.target.hasClass("expanded")) {
2089 query("#content").removeClass("compact");
2091 query("#content").addClass("compact");
2096 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2097 if (savedMode == "compact")
2098 query("#content").addClass("compact");
2099 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2100 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2101 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2104 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2105 commentParentLink.addActivateEvent(function (event) {
2106 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2107 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2113 /**********************/
2114 /* SITE NAV UI TOGGLE */
2115 /**********************/
2117 function injectSiteNavUIToggle() {
2118 GWLog("injectSiteNavUIToggle");
2119 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2120 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2121 GWLog("GW.siteNavUIToggleButtonClicked");
2123 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2126 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2128 function removeSiteNavUIToggle() {
2129 GWLog("removeSiteNavUIToggle");
2130 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2131 element.removeClass("engaged");
2133 removeElement("#site-nav-ui-toggle");
2135 function toggleSiteNavUI() {
2136 GWLog("toggleSiteNavUI");
2137 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2138 element.toggleClass("engaged");
2139 element.removeClass("translucent-on-scroll");
2143 /**********************/
2144 /* POST NAV UI TOGGLE */
2145 /**********************/
2147 function injectPostNavUIToggle() {
2148 GWLog("injectPostNavUIToggle");
2149 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2150 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2151 GWLog("GW.postNavUIToggleButtonClicked");
2153 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2156 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2158 function removePostNavUIToggle() {
2159 GWLog("removePostNavUIToggle");
2160 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2161 element.removeClass("engaged");
2163 removeElement("#post-nav-ui-toggle");
2165 function togglePostNavUI() {
2166 GWLog("togglePostNavUI");
2167 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2168 element.toggleClass("engaged");
2172 /*******************************/
2173 /* APPEARANCE ADJUST UI TOGGLE */
2174 /*******************************/
2176 function injectAppearanceAdjustUIToggle() {
2177 GWLog("injectAppearanceAdjustUIToggle");
2178 let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2179 appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
2180 GWLog("GW.appearanceAdjustUIToggleButtonClicked");
2181 toggleAppearanceAdjustUI();
2182 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
2186 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
2187 themeSelectorCloseButton.addClass("theme-selector-close-button");
2188 themeSelectorCloseButton.innerHTML = "";
2189 query("#theme-selector").appendChild(themeSelectorCloseButton);
2190 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
2192 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
2195 function removeAppearanceAdjustUIToggle() {
2196 GWLog("removeAppearanceAdjustUIToggle");
2197 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2198 element.removeClass("engaged");
2200 removeElement("#appearance-adjust-ui-toggle");
2202 function toggleAppearanceAdjustUI() {
2203 GWLog("toggleAppearanceAdjustUI");
2204 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2205 element.toggleClass("engaged");
2209 /**************************/
2210 /* WORD COUNT & READ TIME */
2211 /**************************/
2213 function toggleReadTimeOrWordCount(addWordCountClass) {
2214 GWLog("toggleReadTimeOrWordCount");
2215 queryAll(".post-meta .read-time").forEach(element => {
2216 if (addWordCountClass) element.addClass("word-count");
2217 else element.removeClass("word-count");
2219 let titleParts = /(\S+)(.+)$/.exec(element.title);
2220 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2224 /**************************/
2225 /* PROMPT TO SAVE CHANGES */
2226 /**************************/
2228 function enableBeforeUnload() {
2229 window.onbeforeunload = function () { return true; };
2231 function disableBeforeUnload() {
2232 window.onbeforeunload = null;
2235 /***************************/
2236 /* ORIGINAL POSTER BADGING */
2237 /***************************/
2239 function markOriginalPosterComments() {
2240 GWLog("markOriginalPosterComments");
2241 let postAuthor = query(".post .author");
2242 if (postAuthor == null) return;
2244 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2245 if (author.dataset.userid == postAuthor.dataset.userid ||
2246 (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2247 author.addClass("original-poster");
2248 author.title += "Original poster";
2253 /********************************/
2254 /* EDIT POST PAGE SUBMIT BUTTON */
2255 /********************************/
2257 function setEditPostPageSubmitButtonText() {
2258 GWLog("setEditPostPageSubmitButtonText");
2259 if (!query("#content").hasClass("edit-post-page")) return;
2261 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2262 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2263 GWLog("GW.postSectionSelectorValueChanged");
2264 updateEditPostPageSubmitButtonText();
2268 updateEditPostPageSubmitButtonText();
2270 function updateEditPostPageSubmitButtonText() {
2271 GWLog("updateEditPostPageSubmitButtonText");
2272 let submitButton = query("input[type='submit']");
2273 if (query("input#drafts").checked == true)
2274 submitButton.value = "Save Draft";
2275 else if (query(".posting-controls").hasClass("edit-existing-post"))
2276 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2278 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2285 function numToAlpha(n) {
2288 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2289 n = Math.floor((n / 26) - 1);
2294 function injectAntiKibitzer() {
2295 GWLog("injectAntiKibitzer");
2296 // Inject anti-kibitzer toggle controls.
2297 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>");
2298 antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2299 GWLog("GW.antiKibitzerToggleButtonClicked");
2300 if (query("#anti-kibitzer-toggle").hasClass("engaged") &&
2302 !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!)")) {
2303 event.target.blur();
2307 toggleAntiKibitzerMode();
2308 event.target.blur();
2311 // Activate anti-kibitzer mode (if needed).
2312 if (localStorage.getItem("antikibitzer") == "true")
2313 toggleAntiKibitzerMode();
2315 // Remove temporary CSS that hides the authors and karma values.
2316 removeElement("#antikibitzer-temp");
2319 function toggleAntiKibitzerMode() {
2320 GWLog("toggleAntiKibitzerMode");
2321 // This will be the URL of the user's own page, if logged in, or the URL of
2322 // the login page otherwise.
2323 let userTabTarget = query("#nav-item-login .nav-inner").href;
2324 let pageHeadingElement = query("h1.page-main-heading");
2327 let userFakeName = { };
2329 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2331 let postAuthor = query(".post-page .post-meta .author");
2332 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2334 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2335 if (antiKibitzerToggle.hasClass("engaged")) {
2336 localStorage.setItem("antikibitzer", "false");
2338 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2339 if (redirectTarget) {
2340 window.location = redirectTarget;
2344 // Individual comment page title and header
2345 if (query(".individual-thread-page")) {
2346 let replacer = (node) => {
2348 node.firstChild.replaceWith(node.dataset["trueContent"]);
2350 replacer(query("title:not(.fake-title)"));
2351 replacer(query("#content > h1"));
2354 // Author names/links.
2355 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2356 author.textContent = author.dataset["trueName"];
2357 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2359 author.removeClass("redacted");
2361 // Post/comment karma values.
2362 queryAll(".karma-value.redacted").forEach(karmaValue => {
2363 karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2365 karmaValue.removeClass("redacted");
2367 // Link post domains.
2368 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2369 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2371 linkPostDomain.removeClass("redacted");
2374 antiKibitzerToggle.removeClass("engaged");
2376 localStorage.setItem("antikibitzer", "true");
2378 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2379 if (redirectTarget) {
2380 window.location = redirectTarget;
2384 // Individual comment page title and header
2385 if (query(".individual-thread-page")) {
2386 let replacer = (node) => {
2388 node.dataset["trueContent"] = node.firstChild.wholeText;
2389 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2390 node.firstChild.replaceWith(newText);
2392 replacer(query("title:not(.fake-title)"));
2393 replacer(query("#content > h1"));
2396 removeElement("title.fake-title");
2398 // Author names/links.
2399 queryAll(".author, .inline-author").forEach(author => {
2400 // Skip own posts/comments.
2401 if (author.hasClass("own-user-author"))
2404 let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2408 author.dataset["trueName"] = author.textContent;
2409 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2411 if (/\/user/.test(author.href)) {
2412 author.dataset["trueLink"] = author.pathname;
2413 author.href = "/user?id=" + author.dataset["userid"];
2416 author.addClass("redacted");
2418 // Post/comment karma values.
2419 queryAll(".karma-value").forEach(karmaValue => {
2420 // Skip own posts/comments.
2421 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2424 karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2425 karmaValue.innerHTML = "##<span> points</span>";
2427 karmaValue.addClass("redacted");
2429 // Link post domains.
2430 queryAll(".link-post-domain").forEach(linkPostDomain => {
2431 // Skip own posts/comments.
2432 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2435 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2436 linkPostDomain.textContent = "redacted.domain.tld";
2438 linkPostDomain.addClass("redacted");
2441 antiKibitzerToggle.addClass("engaged");
2445 /*******************************/
2446 /* COMMENT SORT MODE SELECTION */
2447 /*******************************/
2449 var CommentSortMode = Object.freeze({
2455 function sortComments(mode) {
2456 GWLog("sortComments");
2457 let commentsContainer = query("#comments");
2459 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2460 commentsContainer.addClass("sorting");
2462 GW.commentValues = { };
2463 let clonedCommentsContainer = commentsContainer.cloneNode(true);
2464 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2467 case CommentSortMode.NEW:
2468 comparator = (a,b) => commentDate(b) - commentDate(a);
2470 case CommentSortMode.OLD:
2471 comparator = (a,b) => commentDate(a) - commentDate(b);
2473 case CommentSortMode.HOT:
2474 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2476 case CommentSortMode.TOP:
2478 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2481 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2483 removeElement(commentsContainer.lastChild);
2484 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2485 GW.commentValues = { };
2487 if (loggedInUserId) {
2488 // Re-activate vote buttons.
2489 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2490 voteButton.addActivateEvent(voteButtonClicked);
2493 // Re-activate comment action buttons.
2494 commentsContainer.queryAll(".action-button").forEach(button => {
2495 button.addActivateEvent(GW.commentActionButtonClicked);
2499 // Re-activate comment-minimize buttons.
2500 queryAll(".comment-minimize-button").forEach(button => {
2501 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2504 // Re-add comment parent popups.
2505 addCommentParentPopups();
2507 // Redo new-comments highlighting.
2508 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2510 requestAnimationFrame(() => {
2511 commentsContainer.removeClass("sorting");
2512 commentsContainer.addClass("sorted-" + mode);
2515 function commentKarmaValue(commentOrSelector) {
2516 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2517 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2519 function commentDate(commentOrSelector) {
2520 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2521 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2523 function commentVoteCount(commentOrSelector) {
2524 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2525 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2528 function injectCommentsSortModeSelector() {
2529 GWLog("injectCommentsSortModeSelector");
2530 let topCommentThread = query("#comments > .comment-thread");
2531 if (topCommentThread == null) return;
2533 // Do not show sort mode selector if there is no branching in comment tree.
2534 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2536 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
2537 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
2539 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2540 let commentsSortModeSelector = query("#comments-sort-mode-selector");
2542 commentsSortModeSelector.queryAll("button").forEach(button => {
2543 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2544 GWLog("GW.commentsSortModeSelectButtonClicked");
2545 event.target.parentElement.queryAll("button").forEach(button => {
2546 button.removeClass("selected");
2547 button.disabled = false;
2549 event.target.addClass("selected");
2550 event.target.disabled = true;
2552 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2553 setCommentsSortModeSelectButtonsAccesskey();
2557 // TODO: Make this actually get the current sort mode (if that's saved).
2558 // TODO: Also change the condition here to properly get chrono/threaded mode,
2559 // when that is properly done with cookies.
2560 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2561 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2562 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2563 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2564 setCommentsSortModeSelectButtonsAccesskey();
2567 function setCommentsSortModeSelectButtonsAccesskey() {
2568 GWLog("setCommentsSortModeSelectButtonsAccesskey");
2569 queryAll("#comments-sort-mode-selector button").forEach(button => {
2570 button.removeAttribute("accesskey");
2571 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2573 let selectedButton = query("#comments-sort-mode-selector button.selected");
2574 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2575 nextButtonInCycle.accessKey = "z";
2576 nextButtonInCycle.title += " [z]";
2579 /*************************/
2580 /* COMMENT PARENT POPUPS */
2581 /*************************/
2583 function previewPopupsEnabled() {
2584 let isDisabled = localStorage.getItem("preview-popups-disabled");
2585 return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
2588 function setPreviewPopupsEnabled(state) {
2589 localStorage.setItem("preview-popups-disabled", !state);
2590 updatePreviewPopupToggle();
2593 function updatePreviewPopupToggle() {
2594 let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
2595 query("#preview-popup-toggle").setAttribute("style", style);
2598 function injectPreviewPopupToggle() {
2599 GWLog("injectPreviewPopupToggle");
2601 let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
2602 // This is required because Chrome can't use filters on an externally used SVG element.
2603 fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
2604 updatePreviewPopupToggle();
2605 toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
2608 var currentPreviewPopup = { };
2610 function removePreviewPopup(previewPopup) {
2611 if(previewPopup.element)
2612 removeElement(previewPopup.element);
2614 if(previewPopup.timeout)
2615 clearTimeout(previewPopup.timeout);
2617 if(currentPreviewPopup.pointerListener)
2618 window.removeEventListener("pointermove", previewPopup.pointerListener);
2620 if(currentPreviewPopup.mouseoutListener)
2621 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2623 if(currentPreviewPopup.scrollListener)
2624 window.removeEventListener("scroll", previewPopup.scrollListener);
2626 currentPreviewPopup = { };
2629 function addCommentParentPopups() {
2630 GWLog("addCommentParentPopups");
2631 //if (!query("#content").hasClass("comment-thread-page")) return;
2633 queryAll("a[href]").forEach(linkTag => {
2634 let linkHref = linkTag.getAttribute("href");
2637 try { url = new URL(linkHref, window.location.href); }
2641 if(GW.sites[url.host]) {
2642 let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
2644 if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
2645 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
2646 if(event.pointerType == "touch") return;
2647 GWLog("GW.commentParentLinkMouseOver");
2648 removePreviewPopup(currentPreviewPopup);
2649 let parentID = linkHref;
2651 if (!(parent = (query(parentID)||{}).firstChild)) return;
2652 var highlightClassName;
2653 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2654 parentHighlightClassName = "comment-item-highlight-faint";
2655 popup = parent.cloneNode(true);
2656 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2657 linkTag.addEventListener("mouseout", (event) => {
2658 removeElement(popup);
2660 linkTag.closest(".comments > .comment-thread").appendChild(popup);
2662 parentHighlightClassName = "comment-item-highlight";
2664 parent.parentNode.addClass(parentHighlightClassName);
2665 linkTag.addEventListener("mouseout", (event) => {
2666 parent.parentNode.removeClass(parentHighlightClassName);
2670 else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
2671 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
2672 && !(url.searchParams.get('format'))
2673 && !linkTag.closest("nav:not(.post-nav-links)")
2674 && (!url.hash || linkCommentId)
2675 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
2676 linkTag.addEventListener("pointerover", event => {
2677 if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
2678 if(currentPreviewPopup.linkTag) return;
2679 linkTag.createPreviewPopup();
2681 linkTag.createPreviewPopup = function() {
2682 removePreviewPopup(currentPreviewPopup);
2684 currentPreviewPopup = {linkTag: linkTag};
2686 let popup = document.createElement("iframe");
2687 currentPreviewPopup.element = popup;
2689 let popupTarget = linkHref;
2690 if(popupTarget.match(/#comment-/)) {
2691 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
2693 // 'theme' attribute is required for proper caching
2694 popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
2695 popup.addClass("preview-popup");
2697 let linkRect = linkTag.getBoundingClientRect();
2699 if(linkRect.right + 710 < window.innerWidth)
2700 popup.style.left = linkRect.right + 10 + "px";
2702 popup.style.right = "10px";
2704 popup.style.width = "700px";
2705 popup.style.height = "500px";
2706 popup.style.visibility = "hidden";
2707 popup.style.transition = "none";
2709 let recenter = function() {
2710 let popupHeight = 500;
2711 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
2712 let popupContent = popup.contentDocument.querySelector("#content");
2714 popupHeight = popupContent.clientHeight + 2;
2715 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
2716 popup.style.height = popupHeight + "px";
2719 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
2724 query('#content').insertAdjacentElement("beforeend", popup);
2726 let clickListener = event => {
2727 if(!event.target.closest("a, input, label")
2728 && !event.target.closest("popup-hide-button")) {
2729 window.location = linkHref;
2733 popup.addEventListener("load", () => {
2734 let hideButton = popup.contentDocument.createElement("div");
2735 hideButton.className = "popup-hide-button";
2736 hideButton.insertAdjacentText('beforeend', "\uF070");
2737 hideButton.onclick = (event) => {
2738 removePreviewPopup(currentPreviewPopup);
2739 setPreviewPopupsEnabled(false);
2740 event.stopPropagation();
2742 popup.contentDocument.body.appendChild(hideButton);
2744 let body = popup.contentDocument.body;
2745 body.addEventListener("click", clickListener);
2746 body.style.cursor = "pointer";
2751 popup.contentDocument.body.addEventListener("click", clickListener);
2753 currentPreviewPopup.timeout = setTimeout(() => {
2756 requestIdleCallback(() => {
2757 if(currentPreviewPopup.element === popup) {
2758 popup.scrolling = "";
2759 popup.style.visibility = "unset";
2760 popup.style.transition = null;
2763 { opacity: 0, transform: "translateY(10%)" },
2764 { opacity: 1, transform: "none" }
2765 ], { duration: 150, easing: "ease-out" });
2770 let pointerX, pointerY, mousePauseTimeout = null;
2772 currentPreviewPopup.pointerListener = (event) => {
2773 pointerX = event.clientX;
2774 pointerY = event.clientY;
2776 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
2777 mousePauseTimeout = null;
2779 let overElement = document.elementFromPoint(pointerX, pointerY);
2780 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
2782 if(mouseIsOverLink || overElement === popup
2783 || (pointerX < popup.getBoundingClientRect().left
2784 && event.movementX >= 0)) {
2785 if(!mouseIsOverLink && overElement !== popup) {
2786 if(overElement['createPreviewPopup']) {
2787 mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
2789 mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
2793 removePreviewPopup(currentPreviewPopup);
2794 if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
2797 window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
2799 currentPreviewPopup.mouseoutListener = (event) => {
2800 clearTimeout(mousePauseTimeout);
2801 mousePauseTimeout = null;
2803 document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2805 currentPreviewPopup.scrollListener = (event) => {
2806 let overElement = document.elementFromPoint(pointerX, pointerY);
2807 linkRect = linkTag.getBoundingClientRect();
2808 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
2809 removePreviewPopup(currentPreviewPopup);
2811 window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
2816 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2820 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2821 GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2822 "#content .comments .comment-thread"
2824 applyFilters(GW.currentFilters);
2831 function imageFocusSetup(imagesOverlayOnly = false) {
2832 if (typeof GW.imageFocus == "undefined")
2834 contentImagesSelector: "#content img",
2835 overlayImagesSelector: "#images-overlay img",
2836 focusedImageSelector: "#content img.focused, #images-overlay img.focused",
2837 pageContentSelector: "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
2839 hideUITimerDuration: 1500,
2840 hideUITimerExpired: () => {
2841 GWLog("GW.imageFocus.hideUITimerExpired");
2842 let currentTime = new Date();
2843 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
2844 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
2845 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
2848 cancelImageFocusHideUITimer();
2853 GWLog("imageFocusSetup");
2854 // Create event listener for clicking on images to focus them.
2855 GW.imageClickedToFocus = (event) => {
2856 GWLog("GW.imageClickedToFocus");
2857 focusImage(event.target);
2860 // Set timer to hide the image focus UI.
2861 unhideImageFocusUI();
2862 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
2865 // Add the listener to each image in the overlay (i.e., those in the post).
2866 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
2867 image.addActivateEvent(GW.imageClickedToFocus);
2869 // Accesskey-L starts the slideshow.
2870 (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
2871 // Count how many images there are in the post, and set the "… of X" label to that.
2872 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
2873 if (imagesOverlayOnly) return;
2874 // Add the listener to all other content images (including those in comments).
2875 queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
2876 image.addActivateEvent(GW.imageClickedToFocus);
2879 // Create the image focus overlay.
2880 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
2881 `<div class='help-overlay'>
2882 <p><strong>Arrow keys:</strong> Next/previous image</p>
2883 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2884 <p><strong>Space bar:</strong> Reset image size & position</p>
2885 <p><strong>Scroll</strong> to zoom in/out</p>
2886 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2888 <div class='image-number'></div>
2889 <div class='slideshow-buttons'>
2890 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
2891 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
2893 <div class='caption'></div>` +
2895 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2897 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2898 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
2899 GWLog("GW.imageFocus.slideshowButtonClicked");
2900 focusNextImage(event.target.hasClass("next"));
2901 event.target.blur();
2905 // On orientation change, reset the size & position.
2906 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2907 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2910 // UI starts out hidden.
2914 function focusImage(imageToFocus) {
2915 GWLog("focusImage");
2916 // Clear 'last-focused' class of last focused image.
2917 let lastFocusedImage = query("img.last-focused");
2918 if (lastFocusedImage) {
2919 lastFocusedImage.removeClass("last-focused");
2920 lastFocusedImage.removeAttribute("accesskey");
2923 // Create the focused version of the image.
2924 imageToFocus.addClass("focused");
2925 let imageFocusOverlay = query("#image-focus-overlay");
2926 let clonedImage = imageToFocus.cloneNode(true);
2927 clonedImage.style = "";
2928 clonedImage.removeAttribute("width");
2929 clonedImage.removeAttribute("height");
2930 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2931 imageFocusOverlay.appendChild(clonedImage);
2932 imageFocusOverlay.addClass("engaged");
2934 // Set image to default size and position.
2935 resetFocusedImagePosition();
2937 // Blur everything else.
2938 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
2939 element.addClass("blurred");
2942 // Add listener to zoom image with scroll wheel.
2943 window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
2944 GWLog("GW.imageFocus.scrollEvent");
2945 event.preventDefault();
2947 let image = query("#image-focus-overlay img");
2949 // Remove the filter.
2950 image.savedFilter = image.style.filter;
2951 image.style.filter = 'none';
2953 // Locate point under cursor.
2954 let imageBoundingBox = image.getBoundingClientRect();
2956 // Calculate resize factor.
2957 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2958 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2962 image.style.width = (event.deltaY < 0 ?
2963 (image.clientWidth * factor) :
2964 (image.clientWidth / factor))
2966 image.style.height = "";
2968 // Designate zoom origin.
2970 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2971 // the cursor is over the image.
2972 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2973 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2974 (imageBoundingBox.left <= event.clientX &&
2975 event.clientX <= imageBoundingBox.right &&
2976 imageBoundingBox.top <= event.clientY &&
2977 event.clientY <= imageBoundingBox.bottom);
2978 // Otherwise, if we're zooming OUT, zoom from window center; if we're
2979 // zooming IN, zoom from image center.
2980 let zoomingFromWindowCenter = event.deltaY > 0;
2981 if (zoomingFromCursor)
2982 zoomOrigin = { x: event.clientX,
2984 else if (zoomingFromWindowCenter)
2985 zoomOrigin = { x: window.innerWidth / 2,
2986 y: window.innerHeight / 2 };
2988 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
2989 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
2991 // Calculate offset from zoom origin.
2992 let offsetOfImageFromZoomOrigin = {
2993 x: imageBoundingBox.x - zoomOrigin.x,
2994 y: imageBoundingBox.y - zoomOrigin.y
2996 // Calculate delta from centered zoom.
2997 let deltaFromCenteredZoom = {
2998 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
2999 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3001 // Adjust image position appropriately.
3002 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3003 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3004 // Gradually re-center image, if it's smaller than the window.
3005 if (!imageSizeExceedsWindowBounds) {
3006 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
3007 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3008 let windowCenter = { x: window.innerWidth / 2,
3009 y: window.innerHeight / 2 }
3010 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3011 y: windowCenter.y - imageCenter.y }
3012 // Divide the offset by 10 because we're nudging the image toward center,
3013 // not jumping it there.
3014 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3015 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3018 // Put the filter back.
3019 image.style.filter = image.savedFilter;
3021 // Set the cursor appropriately.
3022 setFocusedImageCursor();
3024 window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3025 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3026 event.preventDefault();
3029 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3030 window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3031 GWLog("GW.imageFocus.mouseUp");
3032 window.onmousemove = '';
3034 // We only want to do anything on left-clicks.
3035 if (event.button != 0) return;
3037 // Don't unfocus if click was on a slideshow next/prev button!
3038 if (event.target.hasClass("slideshow-button")) return;
3040 // We also don't want to do anything if clicked on the help overlay.
3041 if (event.target.classList.contains("help-overlay") ||
3042 event.target.closest(".help-overlay"))
3045 let focusedImage = query("#image-focus-overlay img");
3046 if (event.target == focusedImage &&
3047 (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3048 // If the mouseup event was the end of a pan of an overside image,
3049 // put the filter back; do not unfocus.
3050 focusedImage.style.filter = focusedImage.savedFilter;
3052 unfocusImageOverlay();
3056 window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3057 GWLog("GW.imageFocus.mouseDown");
3058 event.preventDefault();
3060 let focusedImage = query("#image-focus-overlay img");
3061 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3062 let mouseCoordX = event.clientX;
3063 let mouseCoordY = event.clientY;
3065 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3066 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3069 focusedImage.savedFilter = focusedImage.style.filter;
3071 window.onmousemove = (event) => {
3072 // Remove the filter.
3073 focusedImage.style.filter = "none";
3074 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3075 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3081 // Double-click on the image unfocuses.
3082 clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3083 GWLog("GW.imageFocus.doubleClick");
3084 if (event.target.hasClass("slideshow-button")) return;
3086 unfocusImageOverlay();
3089 // Escape key unfocuses, spacebar resets.
3090 document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3091 GWLog("GW.imageFocus.keyUp");
3092 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3093 if (!allowedKeys.contains(event.key) ||
3094 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3096 event.preventDefault();
3098 switch (event.key) {
3101 unfocusImageOverlay();
3105 resetFocusedImagePosition();
3111 if (query("#images-overlay img.focused")) focusNextImage(true);
3117 if (query("#images-overlay img.focused")) focusNextImage(false);
3122 // Prevent spacebar or arrow keys from scrolling page when image focused.
3123 togglePageScrolling(false);
3125 // If the image comes from the images overlay, for the main post...
3126 if (imageToFocus.closest("#images-overlay")) {
3127 // Mark the overlay as being in slide show mode (to show buttons/count).
3128 imageFocusOverlay.addClass("slideshow");
3130 // Set state of next/previous buttons.
3131 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3132 var indexOfFocusedImage = getIndexOfFocusedImage();
3133 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3134 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3136 // Set the image number.
3137 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3139 // Replace the hash.
3140 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3142 imageFocusOverlay.removeClass("slideshow");
3146 setImageFocusCaption();
3148 // Moving mouse unhides image focus UI.
3149 window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3150 GWLog("GW.imageFocus.mouseMoved");
3151 let currentDateTime = new Date();
3152 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3153 cancelImageFocusHideUITimer();
3155 if (!GW.imageFocus.hideUITimer) {
3156 unhideImageFocusUI();
3157 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3159 GW.imageFocus.mouseLastMovedAt = currentDateTime;
3164 function resetFocusedImagePosition() {
3165 GWLog("resetFocusedImagePosition");
3166 let focusedImage = query("#image-focus-overlay img");
3167 if (!focusedImage) return;
3169 let sourceImage = query(GW.imageFocus.focusedImageSelector);
3171 // Make sure that initially, the image fits into the viewport.
3172 let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3173 let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3174 var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3175 let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3176 let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3177 focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3178 focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3180 // Remove modifications to position.
3181 focusedImage.style.left = "";
3182 focusedImage.style.top = "";
3184 // Set the cursor appropriately.
3185 setFocusedImageCursor();
3187 function setFocusedImageCursor() {
3188 let focusedImage = query("#image-focus-overlay img");
3189 if (!focusedImage) return;
3190 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
3194 function unfocusImageOverlay() {
3195 GWLog("unfocusImageOverlay");
3197 // Remove event listeners.
3198 window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3199 window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3200 // NOTE: The double-click listener does not need to be removed manually,
3201 // because the focused (cloned) image will be removed anyway.
3202 document.removeEventListener("keyup", GW.imageFocus.keyUp);
3203 document.removeEventListener("keydown", GW.imageFocus.keyDown);
3204 window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3205 window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3206 window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3208 // Set accesskey of currently focused image (if it's in the images overlay).
3209 let currentlyFocusedImage = query("#images-overlay img.focused");
3210 if (currentlyFocusedImage) {
3211 currentlyFocusedImage.addClass("last-focused");
3212 currentlyFocusedImage.accessKey = 'l';
3215 // Remove focused image and hide overlay.
3216 let imageFocusOverlay = query("#image-focus-overlay");
3217 imageFocusOverlay.removeClass("engaged");
3218 removeElement(imageFocusOverlay.query("img"));
3220 // Un-blur content/etc.
3221 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3222 element.removeClass("blurred");
3225 // Unset "focused" class of focused image.
3226 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3228 // Re-enable page scrolling.
3229 togglePageScrolling(true);
3231 // Reset the hash, if needed.
3232 if (location.hash.hasPrefix("#if_slide_"))
3233 history.replaceState(window.history.state, null, "#");
3236 function getIndexOfFocusedImage() {
3237 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3238 var indexOfFocusedImage = -1;
3239 for (i = 0; i < images.length; i++) {
3240 if (images[i].hasClass("focused")) {
3241 indexOfFocusedImage = i;
3245 return indexOfFocusedImage;
3248 function focusNextImage(next = true) {
3249 GWLog("focusNextImage");
3250 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3251 var indexOfFocusedImage = getIndexOfFocusedImage();
3253 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3255 // Remove existing image.
3256 removeElement("#image-focus-overlay img");
3257 // Unset "focused" class of just-removed image.
3258 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3260 // Create the focused version of the image.
3261 images[indexOfFocusedImage].addClass("focused");
3262 let imageFocusOverlay = query("#image-focus-overlay");
3263 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3264 clonedImage.style = "";
3265 clonedImage.removeAttribute("width");
3266 clonedImage.removeAttribute("height");
3267 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3268 imageFocusOverlay.appendChild(clonedImage);
3269 imageFocusOverlay.addClass("engaged");
3270 // Set image to default size and position.
3271 resetFocusedImagePosition();
3272 // Set state of next/previous buttons.
3273 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3274 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3275 // Set the image number display.
3276 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3278 setImageFocusCaption();
3279 // Replace the hash.
3280 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3283 function setImageFocusCaption() {
3284 GWLog("setImageFocusCaption");
3285 var T = { }; // Temporary storage.
3287 // Clear existing caption, if any.
3288 let captionContainer = query("#image-focus-overlay .caption");
3289 Array.from(captionContainer.children).forEach(child => { child.remove(); });
3291 // Determine caption.
3292 let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3294 if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
3295 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3296 captionHTML = (T.figcaption.query("p")) ?
3297 T.figcaption.innerHTML :
3298 "<p>" + T.figcaption.innerHTML + "</p>";
3299 } else if (currentlyFocusedImage.title != "") {
3300 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3302 // Insert the caption, if any.
3303 if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3306 function hideImageFocusUI() {
3307 GWLog("hideImageFocusUI");
3308 let imageFocusOverlay = query("#image-focus-overlay");
3309 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3310 element.addClass("hidden");
3314 function unhideImageFocusUI() {
3315 GWLog("unhideImageFocusUI");
3316 let imageFocusOverlay = query("#image-focus-overlay");
3317 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3318 element.removeClass("hidden");
3322 function cancelImageFocusHideUITimer() {
3323 clearTimeout(GW.imageFocus.hideUITimer);
3324 GW.imageFocus.hideUITimer = null;
3331 function keyboardHelpSetup() {
3332 let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3333 <div class='keyboard-help-container'>
3334 <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'></button>
3335 <h1>Keyboard shortcuts</h1>
3336 <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>
3337 <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3338 <div class='keyboard-shortcuts-lists'>` + [ [
3340 [ [ '?' ], "Show keyboard shortcuts" ],
3341 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3344 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3345 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3346 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3347 [ [ 'ak-m' ], "Go to Meta view" ],
3348 [ [ 'ak-v' ], "Go to Tags view"],
3349 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3350 [ [ 'ak-r' ], "Go to Archive view" ],
3351 [ [ 'ak-q' ], "Go to Sequences view" ],
3352 [ [ 'ak-t' ], "Go to About page" ],
3353 [ [ 'ak-u' ], "Go to User or Login page" ],
3354 [ [ 'ak-o' ], "Go to Inbox page" ]
3357 [ [ 'ak-,' ], "Jump up to top of page" ],
3358 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3359 [ [ 'ak-/' ], "Jump to top of comments section" ],
3360 [ [ 'ak-s' ], "Search" ],
3363 [ [ 'ak-n' ], "New post or comment" ],
3364 [ [ 'ak-e' ], "Edit current post" ]
3366 "Post/comment list views",
3367 [ [ '.' ], "Focus next entry in list" ],
3368 [ [ ',' ], "Focus previous entry in list" ],
3369 [ [ ';' ], "Cycle between links in focused entry" ],
3370 [ [ 'Enter' ], "Go to currently focused entry" ],
3371 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3372 [ [ 'ak-]' ], "Go to next page" ],
3373 [ [ 'ak-[' ], "Go to previous page" ],
3374 [ [ 'ak-\\' ], "Go to first page" ],
3375 [ [ 'ak-e' ], "Edit currently focused post" ]
3378 [ [ 'ak-k' ], "Bold text" ],
3379 [ [ 'ak-i' ], "Italic text" ],
3380 [ [ 'ak-l' ], "Insert hyperlink" ],
3381 [ [ 'ak-q' ], "Blockquote text" ]
3384 [ [ 'ak-=' ], "Increase text size" ],
3385 [ [ 'ak--' ], "Decrease text size" ],
3386 [ [ 'ak-0' ], "Reset to default text size" ],
3387 [ [ 'ak-′' ], "Cycle through content width settings" ],
3388 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3389 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3390 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3391 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3392 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3393 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3394 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3395 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3396 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3397 [ [ 'ak-;' ], "Open theme tweaker" ],
3398 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3399 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3402 [ [ 'ak-l' ], "Start/resume slideshow" ],
3403 [ [ 'Esc' ], "Exit slideshow" ],
3404 [ [ '→', '↓' ], "Next slide" ],
3405 [ [ '←', '↑' ], "Previous slide" ],
3406 [ [ 'Space' ], "Reset slide zoom" ]
3409 [ [ 'ak-x' ], "Switch to next view on user page" ],
3410 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3411 [ [ 'ak-` ' ], "Toggle compact comment list view" ],
3412 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3414 `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3416 <span class='keys'>` +
3418 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3421 <span class='action'>${entry[1]}</span>
3423 ).join("\n") + `</ul>`).join("\n") + `
3428 // Add listener to show the keyboard help overlay.
3429 document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3430 GWLog("GW.keyboardHelpShowKeyPressed");
3431 if (event.key == '?')
3432 toggleKeyboardHelpOverlay(true);
3435 // Clicking the background overlay closes the keyboard help overlay.
3436 keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3437 GWLog("GW.keyboardHelpOverlayClicked");
3438 if (event.type == 'mousedown') {
3439 keyboardHelpOverlay.style.opacity = "0.01";
3441 toggleKeyboardHelpOverlay(false);
3442 keyboardHelpOverlay.style.opacity = "1.0";
3446 // Intercept clicks, so they don't "fall through" the background overlay.
3447 (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3449 // Clicking the close button closes the keyboard help overlay.
3450 keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3451 toggleKeyboardHelpOverlay(false);
3454 // Add button to open keyboard help.
3455 query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'></button>");
3456 query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3457 GWLog("GW.openKeyboardHelpButtonClicked");
3458 toggleKeyboardHelpOverlay(true);
3459 event.target.blur();
3463 function toggleKeyboardHelpOverlay(show) {
3464 console.log("toggleKeyboardHelpOverlay");
3466 let keyboardHelpOverlay = query("#keyboard-help-overlay");
3467 show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3468 keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3470 // Prevent scrolling the document when the overlay is visible.
3471 togglePageScrolling(!show);
3473 // Focus the close button as soon as we open.
3474 keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3477 // Add listener to show the keyboard help overlay.
3478 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3479 GWLog("GW.keyboardHelpHideKeyPressed");
3480 if (event.key == 'Escape')
3481 toggleKeyboardHelpOverlay(false);
3484 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3487 // Disable / enable tab-selection of the search box.
3488 setSearchBoxTabSelectable(!show);
3491 /**********************/
3492 /* PUSH NOTIFICATIONS */
3493 /**********************/
3495 function pushNotificationsSetup() {
3496 let pushNotificationsButton = query("#enable-push-notifications");
3497 if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3498 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3499 pushNotificationsButton.style.display = 'unset';
3503 function urlBase64ToUint8Array(base64String) {
3504 const padding = '='.repeat((4 - base64String.length % 4) % 4);
3505 const base64 = (base64String + padding)
3507 .replace(/_/g, '/');
3509 const rawData = window.atob(base64);
3510 const outputArray = new Uint8Array(rawData.length);
3512 for (let i = 0; i < rawData.length; ++i) {
3513 outputArray[i] = rawData.charCodeAt(i);
3518 function pushNotificationsButtonClicked(event) {
3519 event.target.style.opacity = 0.33;
3520 event.target.style.pointerEvents = "none";
3522 let reEnable = (message) => {
3523 if(message) alert(message);
3524 event.target.style.opacity = 1;
3525 event.target.style.pointerEvents = "unset";
3528 if(event.target.dataset.enabled) {
3529 fetch('/push/register', {
3531 headers: { 'Content-type': 'application/json' },
3532 body: JSON.stringify({
3536 event.target.innerHTML = "Enable push notifications";
3537 event.target.dataset.enabled = "";
3539 }).catch((err) => reEnable(err.message));
3541 Notification.requestPermission().then((permission) => {
3542 navigator.serviceWorker.ready
3543 .then((registration) => {
3544 return registration.pushManager.getSubscription()
3545 .then(async function(subscription) {
3547 return subscription;
3549 return registration.pushManager.subscribe({
3550 userVisibleOnly: true,
3551 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
3554 .catch((err) => reEnable(err.message));
3556 .then((subscription) => {
3557 fetch('/push/register', {
3560 'Content-type': 'application/json'
3562 body: JSON.stringify({
3563 subscription: subscription
3568 event.target.innerHTML = "Disable push notifications";
3569 event.target.dataset.enabled = "true";
3572 .catch(function(err){ reEnable(err.message) });
3578 /*******************************/
3579 /* HTML TO MARKDOWN CONVERSION */
3580 /*******************************/
3582 function MarkdownFromHTML(text) {
3583 GWLog("MarkdownFromHTML");
3584 // Wrapper tags, paragraphs, bold, italic, code blocks.
3585 text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
3608 // <div> and <span>.
3609 text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
3611 }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
3616 text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
3617 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
3618 return `* ${listItem}\n`;
3623 text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
3624 var countedItemValue = 0;
3625 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
3627 if (typeof specifiedItemValue != "undefined") {
3628 specifiedItemValue = parseInt(specifiedItemValue);
3629 countedItemValue = itemValue = specifiedItemValue;
3631 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
3633 return `${itemValue}. ${listItem.trim()}\n`;
3638 text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
3639 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
3643 text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
3644 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
3648 text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
3649 return `[${text}](${href})`;
3653 text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
3654 return `![](${src})`;
3657 // Horizontal rules.
3658 text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
3663 text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
3667 // Preformatted text (possibly with a code block inside).
3668 text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
3669 return "```\n" + text + "\n```";
3673 text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
3674 return "`" + text + "`";
3678 text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
3698 /************************************/
3699 /* ANCHOR LINK SCROLLING WORKAROUND */
3700 /************************************/
3702 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
3703 let hash = location.hash;
3704 if(hash && hash !== "#top" && !document.query(hash)) {
3705 let content = document.query("#content");
3706 content.style.display = "none";
3707 addTriggerListener("DOMReady", {priority: -1, fn: () => {
3708 content.style.visibility = "hidden";
3709 content.style.display = null;
3710 requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
3715 /******************/
3716 /* INITIALIZATION */
3717 /******************/
3719 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
3720 GWLog("INITIALIZER earlyInitialize");
3721 // Check to see whether we're on a mobile device (which we define as a narrow screen)
3722 GW.isMobile = (window.innerWidth <= 1160);
3723 GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3725 // Backward compatibility
3726 let storedTheme = localStorage.getItem('selected-theme');
3728 setTheme(storedTheme);
3729 localStorage.removeItem('selected-theme');
3732 // Animate width & theme adjustments?
3733 GW.adjustmentTransitions = false;
3735 // Add the content width selector.
3736 injectContentWidthSelector();
3737 // Add the text size adjustment widget.
3738 injectTextSizeAdjustmentUI();
3739 // Add the theme selector.
3740 injectThemeSelector();
3741 // Add the theme tweaker.
3742 injectThemeTweaker();
3743 // Add the quick-nav UI.
3746 // Finish initializing when ready.
3747 addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
3750 function mainInitializer() {
3751 GWLog("INITIALIZER initialize");
3753 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
3754 // "link without nav bars".
3755 if (getQueryVariable("hide-nav-bars") == "true") {
3756 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
3759 // If the page cannot have comments, remove the accesskey from the #comments
3760 // quick-nav button; and if the page can have comments, but does not, simply
3761 // disable the #comments quick nav button.
3762 let content = query("#content");
3763 if (content.query("#comments") == null) {
3764 query("#quick-nav-ui a[href='#comments']").accessKey = '';
3765 } else if (content.query("#comments .comment-thread") == null) {
3766 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
3769 // On edit post pages and conversation pages, add GUIEdit buttons to the
3770 // textarea, expand it, and markdownify the existing text, if any (this is
3771 // needed if a post was last edited on LW).
3772 queryAll(".with-markdown-editor textarea").forEach(textarea => {
3773 textarea.addTextareaFeatures();
3774 expandTextarea(textarea);
3775 textarea.value = MarkdownFromHTML(textarea.value);
3777 // Focus the textarea.
3778 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3780 // If we're on a comment thread page...
3781 if (query(".comments") != null) {
3782 // Add comment-minimize buttons to every comment.
3783 queryAll(".comment-meta").forEach(commentMeta => {
3784 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3785 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
3787 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
3788 // Format and activate comment-minimize buttons.
3789 queryAll(".comment-minimize-button").forEach(button => {
3790 button.closest(".comment-item").setCommentThreadMaximized(false);
3791 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3792 event.target.closest(".comment-item").setCommentThreadMaximized(true);
3797 if (getQueryVariable("chrono") == "t") {
3798 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
3801 // On mobile, replace the labels for the checkboxes on the edit post form
3802 // with icons, to save space.
3803 if (GW.isMobile && query(".edit-post-page")) {
3804 query("label[for='link-post']").innerHTML = "";
3805 query("label[for='question']").innerHTML = "";
3808 // Add error message (as placeholder) if user tries to click Search with
3809 // an empty search field.
3811 let searchForm = query("#nav-item-search form");
3812 if(!searchForm) break searchForm;
3813 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3814 let searchField = event.target.query("input");
3815 if (searchField.value == "") {
3816 event.preventDefault();
3817 event.target.blur();
3818 searchField.placeholder = "Enter a search string!";
3819 searchField.focus();
3822 // Remove the placeholder / error on any input.
3823 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3824 event.target.placeholder = "";
3828 // Prevent conflict between various single-hotkey listeners and text fields
3829 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3830 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3831 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
3834 if (content.hasClass("post-page")) {
3835 // Read and update last-visited-date.
3836 let lastVisitedDate = getLastVisitedDate();
3837 setLastVisitedDate(Date.now());
3839 // Save the number of comments this post has when it's visited.
3840 updateSavedCommentCount();
3842 if (content.query(".comments .comment-thread") != null) {
3843 // Add the new comments count & navigator.
3844 injectNewCommentNavUI();
3846 // Get the highlight-new-since date (as specified by URL parameter, if
3847 // present, or otherwise the date of the last visit).
3848 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3850 // Highlight new comments since the specified date.
3851 let newCommentsCount = highlightCommentsSince(hnsDate);
3853 // Update the comment count display.
3854 updateNewCommentNavUI(newCommentsCount, hnsDate);
3857 // On listing pages, make comment counts more informative.
3858 badgePostsWithNewComments();
3861 // Add the comments list mode selector widget (expanded vs. compact).
3862 injectCommentsListModeSelector();
3864 // Add the comments view selector widget (threaded vs. chrono).
3865 // injectCommentsViewModeSelector();
3867 // Add the comments sort mode selector (top, hot, new, old).
3868 if (GW.useFancyFeatures) injectCommentsSortModeSelector();
3870 // Add the toggle for the post nav UI elements on mobile.
3871 if (GW.isMobile) injectPostNavUIToggle();
3873 // Add the toggle for the appearance adjustment UI elements on mobile.
3874 if (GW.isMobile) injectAppearanceAdjustUIToggle();
3876 // Add the antikibitzer.
3877 if (GW.useFancyFeatures) injectAntiKibitzer();
3879 // Add comment parent popups.
3880 injectPreviewPopupToggle();
3881 addCommentParentPopups();
3883 // Mark original poster's comments with a special class.
3884 markOriginalPosterComments();
3886 // On the All view, mark posts with non-positive karma with a special class.
3887 if (query("#content").hasClass("all-index-page")) {
3888 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3889 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3891 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3895 // Set the "submit" button on the edit post page to something more helpful.
3896 setEditPostPageSubmitButtonText();
3898 // Compute the text of the pagination UI tooltip text.
3899 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3900 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3903 // Add event listeners for Escape and Enter, for the theme tweaker.
3904 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3905 let themeTweakerUI = query("#theme-tweaker-ui");
3906 document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3907 if (event.key == "Escape") {
3908 if (themeTweakerHelpWindow.style.display != "none") {
3909 toggleThemeTweakerHelpWindow();
3910 themeTweakerResetSettings();
3911 } else if (themeTweakerUI.style.display != "none") {
3912 toggleThemeTweakerUI();
3915 } else if (event.key == "Enter") {
3916 if (themeTweakerHelpWindow.style.display != "none") {
3917 toggleThemeTweakerHelpWindow();
3918 themeTweakerSaveSettings();
3919 } else if (themeTweakerUI.style.display != "none") {
3920 toggleThemeTweakerUI();
3926 // Add event listener for . , ; (for navigating listings pages).
3927 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3928 if (!query(".comments") && listings.length > 0) {
3929 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
3930 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
3932 if (event.key == "Escape") {
3933 if (document.activeElement.parentElement.hasClass("listing"))
3934 document.activeElement.blur();
3938 if (event.key == ';') {
3939 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3940 let links = document.activeElement.parentElement.queryAll("a");
3941 links[document.activeElement == links[0] ? 1 : 0].focus();
3942 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3943 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3944 links[document.activeElement == links[0] ? 1 : 0].focus();
3945 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3950 var indexOfActiveListing = -1;
3951 for (i = 0; i < listings.length; i++) {
3952 if (document.activeElement.parentElement.hasClass("listing") &&
3953 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3954 indexOfActiveListing = i;
3956 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
3957 listings[i] === document.activeElement.parentElement.query("a.date")) {
3958 indexOfActiveListing = i;
3962 // Remove edit accesskey from currently highlighted post by active user, if applicable.
3963 if (indexOfActiveListing > -1) {
3964 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
3966 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3967 if (indexOfNextListing < listings.length) {
3968 listings[indexOfNextListing].focus();
3970 if (listings[indexOfNextListing].closest(".comment-item")) {
3971 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3972 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3975 document.activeElement.blur();
3977 // Add edit accesskey to newly highlighted post by active user, if applicable.
3978 (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
3980 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
3981 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
3982 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
3986 // Add event listener for ; (to focus the link on link posts).
3987 if (query("#content").hasClass("post-page") &&
3988 query(".post").hasClass("link-post")) {
3989 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
3990 if (event.key == ';') query("a.link-post-link").focus();
3994 // Add accesskeys to user page view selector.
3995 let viewSelector = query("#content.user-page > .sublevel-nav");
3997 let currentView = viewSelector.query("span");
3998 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
3999 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4002 // Add accesskey to index page sort selector.
4003 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4005 // Move MathJax style tags to <head>.
4006 var aggregatedStyles = "";
4007 queryAll("#content style").forEach(styleTag => {
4008 aggregatedStyles += styleTag.innerHTML;
4009 removeElement("style", styleTag.parentElement);
4011 if (aggregatedStyles != "") {
4012 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4015 // Add listeners to switch between word count and read time.
4016 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4017 queryAll(".post-meta .read-time").forEach(element => {
4018 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4019 let displayWordCount = localStorage.getItem("display-word-count");
4020 toggleReadTimeOrWordCount(!displayWordCount);
4021 if (displayWordCount) localStorage.removeItem("display-word-count");
4022 else localStorage.setItem("display-word-count", true);
4026 // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4027 query("#content").addEventListener("copy", GW.textCopied = (event) => {
4028 if(event.target.matches("input, textarea")) return;
4029 event.preventDefault();
4030 const selectedHTML = getSelectionHTML();
4031 const selectedText = getSelection().toString();
4032 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4033 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4036 // Set up Image Focus feature.
4039 // Set up keyboard shortcuts guide overlay.
4040 keyboardHelpSetup();
4042 // Show push notifications button if supported
4043 pushNotificationsSetup();
4045 // Show elements now that javascript is ready.
4046 removeElement("#hide-until-init");
4048 activateTrigger("pageLayoutFinished");
4051 /*************************/
4052 /* POST-LOAD ADJUSTMENTS */
4053 /*************************/
4055 window.addEventListener("pageshow", badgePostsWithNewComments);
4057 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4058 GWLog("INITIALIZER pageLayoutFinished");
4060 postSetThemeHousekeeping();
4062 focusImageSpecifiedByURL();
4064 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4065 // query("input[type='search']").value = GW.isMobile;
4066 // insertHeadHTML("<style>" +
4067 // `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` +
4068 // `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` +
4072 function generateImagesOverlay() {
4073 GWLog("generateImagesOverlay");
4074 // Don't do this on the about page.
4075 if (query(".about-page") != null) return;
4078 // Remove existing, if any.
4079 removeElement("#images-overlay");
4082 query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4083 let imagesOverlay = query("#images-overlay");
4084 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4085 queryAll(".post-body img").forEach(image => {
4086 let clonedImageContainer = document.createElement("div");
4088 let clonedImage = image.cloneNode(true);
4089 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4090 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4091 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4092 clonedImageContainer.appendChild(clonedImage);
4094 let zoomLevel = parseFloat(GW.currentTextZoom);
4096 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4097 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4098 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4099 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4101 imagesOverlay.appendChild(clonedImageContainer);
4104 // Add the event listeners to focus each image.
4105 imageFocusSetup(true);
4108 function adjustUIForWindowSize() {
4109 GWLog("adjustUIForWindowSize");
4110 var bottomBarOffset;
4112 // Adjust bottom bar state.
4113 let bottomBar = query("#bottom-bar");
4114 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4115 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4116 bottomBar.removeClass("decorative");
4118 bottomBar.query("#nav-item-top").style.display = "";
4119 } else if (bottomBar) {
4120 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4121 else bottomBar.addClass("decorative");
4123 bottomBar.query("#nav-item-top").style.display = "none";
4126 // Show quick-nav UI up/down buttons if content is taller than window.
4127 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4128 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4129 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4132 // Move anti-kibitzer toggle if content is very short.
4133 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4135 // Update the visibility of the post nav UI.
4136 updatePostNavUIVisibility();
4139 function recomputeUIElementsContainerHeight(force = false) {
4140 GWLog("recomputeUIElementsContainerHeight");
4142 (force || query("#ui-elements-container").style.height != "")) {
4143 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4144 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
4145 query("#content").clientHeight + "px" :
4150 function focusImageSpecifiedByURL() {
4151 GWLog("focusImageSpecifiedByURL");
4152 if (location.hash.hasPrefix("#if_slide_")) {
4153 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4154 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4155 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4156 if (imageToFocus > 0 && imageToFocus <= images.length) {
4157 focusImage(images[imageToFocus - 1]);
4159 // Set timer to hide the image focus UI.
4160 unhideImageFocusUI();
4161 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4171 function insertMarkup(event) {
4172 var mopen = '', mclose = '', mtext = '', func = false;
4173 if (typeof arguments[1] == 'function') {
4174 func = arguments[1];
4176 mopen = arguments[1];
4177 mclose = arguments[2];
4178 mtext = arguments[3];
4181 var textarea = event.target.closest("form").query("textarea");
4183 var p0 = textarea.selectionStart;
4184 var p1 = textarea.selectionEnd;
4185 var cur0 = cur1 = p0;
4187 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4188 str = func ? func(str, p0) : (mopen + str + mclose);
4190 // Determine selection.
4192 cur0 += (p0 == p1) ? mopen.length : str.length;
4193 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4200 // Update textarea contents.
4201 document.execCommand("insertText", false, str);
4203 // Expand textarea, if needed.
4204 expandTextarea(textarea);
4207 textarea.selectionStart = cur0;
4208 textarea.selectionEnd = cur1;
4213 GW.guiEditButtons = [
4214 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
4215 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
4216 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
4217 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
4218 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
4219 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
4220 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
4221 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
4222 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
4223 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
4224 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
4225 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
4226 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
4227 [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '' ],
4228 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
4231 function blockquote(text, startpos) {
4233 text = "> Quoted text";
4234 return [ text, startpos + 2, startpos + text.length ];
4236 text = "> " + text.split("\n").join("\n> ") + "\n";
4237 return [ text, startpos + text.length, startpos + text.length ];
4241 function hyperlink(text, startpos) {
4242 var url = '', link_text = text, endpos = startpos;
4243 if (text.search(/^https?/) != -1) {
4245 link_text = "link text";
4246 startpos = startpos + 1;
4247 endpos = startpos + link_text.length;
4249 url = prompt("Link address (URL):");
4251 endpos = startpos + text.length;
4252 return [ text, startpos, endpos ];
4254 startpos = startpos + text.length + url.length + 4;
4258 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4261 /******************/
4262 /* SERVICE WORKER */
4263 /******************/
4265 if(navigator.serviceWorker) {
4266 navigator.serviceWorker.register('/service-worker.js');
4267 setCookie("push", "t");
4270 /*********************/
4271 /* USER AUTOCOMPLETE */
4272 /*********************/
4274 var userAutocomplete = null;
4276 function abbreviatedInterval(date) {
4277 let seconds = Math.floor((new Date() - date) / 1000);
4278 let days = Math.floor(seconds / (60 * 60 * 24));
4279 let years = Math.floor(days / 365);
4288 function beginAutocompletion(control, startIndex) {
4289 if(userAutocomplete) abortAutocompletion(userAutocomplete);
4291 complete = { control: control,
4292 abortController: new AbortController(),
4293 container: document.createElement("div") };
4295 complete.container.className = "autocomplete-container "
4297 + (window.innerWidth > 1200
4300 control.insertAdjacentElement("afterend", complete.container);
4302 let makeReplacer = (userSlug, displayName) => {
4304 let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
4305 control.value = control.value.substring(0, startIndex - 1) +
4307 control.value.substring(control.selectionEnd);
4308 abortAutocompletion(complete);
4309 complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
4310 complete.control.focus();
4314 let switchHighlight = (newHighlight) => {
4318 complete.highlighted.removeClass("highlighted");
4319 newHighlight.addClass("highlighted");
4320 complete.highlighted = newHighlight;
4322 // Scroll newly highlighted item into view, if need be.
4323 if ( complete.highlighted.offsetTop + complete.highlighted.offsetHeight
4324 > complete.container.scrollTop + complete.container.clientHeight) {
4325 complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
4326 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
4327 complete.container.scrollTo(0, complete.highlighted.offsetTop);
4330 let highlightNext = () => {
4331 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
4333 let highlightPrev = () => {
4334 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
4337 document.body.addEventListener("click", complete.abortClickListener = (event) => {
4338 if (!complete.container.contains(event.target)) {
4339 abortAutocompletion(complete);
4340 event.preventDefault();
4342 }, {capture: true});
4344 control.addEventListener("keydown", complete.eventListener = (event) => {
4345 switch (event.key) {
4347 abortAutocompletion(complete);
4348 event.preventDefault();
4352 event.preventDefault();
4356 event.preventDefault();
4363 event.preventDefault();
4366 complete.highlighted.onclick();
4367 event.preventDefault();
4371 if (event.key.length > 1) return;
4373 complete.abortController.abort();
4374 complete.abortController = new AbortController();
4376 let fragment = control.value.substring(startIndex, control.selectionEnd) + event.key;
4378 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
4379 {signal: complete.abortController.signal})
4380 .then((res) => res.json())
4382 if(res.error) return;
4383 if(res.length == 0) return abortAutocompletion(complete);
4385 complete.container.innerHTML = "";
4386 res.forEach(entry => {
4387 let entryContainer = document.createElement("div");
4388 [ [ entry.displayName, "name" ],
4389 [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
4390 [ (entry.karma || 0) + " karma", "karma" ]
4392 let e = document.createElement("span");
4395 entryContainer.append(e);
4397 entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
4398 complete.container.append(entryContainer);
4400 complete.highlighted = complete.container.children[0];
4401 complete.highlighted.classList.add("highlighted");
4406 userAutocomplete = complete;
4409 function abortAutocompletion(complete) {
4410 complete.control.removeEventListener("keydown", complete.eventListener);
4411 document.body.removeEventListener("click", complete.abortClickListener);
4412 complete.container.remove();
4413 userAutocomplete = null;