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(!userAutocomplete &&
302 textarea.value.charAt(textarea.selectionStart - 1) === "@" &&
303 (textarea.selectionStart === 1 ||
304 !textarea.value.charAt(textarea.selectionStart - 2).match(/[a-zA-Z0-9]/))) {
305 beginAutocompletion(textarea, textarea.selectionStart);
308 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
309 textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
310 textarea.addEventListener("keydown", (event) => {
311 // Special case for alt+4
312 // Generalize this before adding more.
313 if(event.altKey && event.key === '4') {
314 insertMarkup(event, "$", "$", "LaTeX formula");
315 event.stopPropagation();
316 event.preventDefault();
320 let form = textarea.closest("form");
321 if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
323 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
324 let textareaContainer = textarea.closest(".textarea-container");
325 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
326 for (var button of GW.guiEditButtons) {
327 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
328 buttons_container.insertAdjacentHTML("beforeend",
329 "<button type='button' class='guiedit guiedit-"
332 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
333 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
334 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
335 + " onclick='insertMarkup(event,"
336 + ((typeof m_before_or_func == 'function') ?
337 m_before_or_func.name :
338 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
346 `<input type='checkbox' id='markdown-hints-checkbox'>
347 <label for='markdown-hints-checkbox'></label>
348 <div id='markdown-hints'>` +
349 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
350 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
351 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
352 "<span>Heading 1</span><code># Heading 1</code>",
353 "<span>Heading 2</span><code>## Heading 1</code>",
354 "<span>Heading 3</span><code>### Heading 1</code>",
355 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
357 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
359 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
360 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
361 GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
362 if (button.hasClass("guiedit-mobile-help-button")) {
363 toggleMarkdownHintsBox();
364 event.target.toggleClass("active");
365 query(".posting-controls:focus-within textarea").focus();
366 } else if (button.hasClass("guiedit-mobile-exit-button")) {
368 hideMarkdownHintsBox();
369 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
374 // On smartphone (narrow mobile) screens, when a textarea is focused (and
375 // automatically fullscreened), remove all the filters from the page, and
376 // then apply them *just* to the fixed editor UI elements. This is in order
377 // to get around the "children of elements with a filter applied cannot be
379 if (GW.isMobile && window.innerWidth <= 520) {
380 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
381 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
382 GWLog("GW.textareaFocusedMobile");
383 GW.savedFilters = GW.currentFilters;
384 GW.currentFilters = { };
385 applyFilters(GW.currentFilters);
386 fixedEditorElements.forEach(element => {
387 element.style.filter = filterStringFromFilters(GW.savedFilters);
390 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
391 GWLog("GW.textareaBlurredMobile");
392 GW.currentFilters = GW.savedFilters;
393 GW.savedFilters = { };
394 requestAnimationFrame(() => {
395 applyFilters(GW.currentFilters);
396 fixedEditorElements.forEach(element => {
397 element.style.filter = filterStringFromFilters(GW.savedFilters);
404 Element.prototype.injectReplyForm = function(editMarkdownSource) {
405 GWLog("injectReplyForm");
406 let commentControls = this;
407 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
408 let postId = commentControls.parentElement.dataset["postId"];
409 let tagId = commentControls.parentElement.dataset["tagId"];
410 let withparent = (!editMarkdownSource && commentControls.getCommentId());
411 let answer = commentControls.parentElement.id == "answers";
412 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
413 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
414 let parentCommentItem = commentControls.closest(".comment-item");
415 let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
416 (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
417 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
418 "<form method='post'>" +
419 "<div class='textarea-container'>" +
420 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
421 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
422 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
423 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
424 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
425 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
426 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
427 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
428 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
429 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
430 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
431 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
432 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
434 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
435 "<input type='submit' value='Submit'>" +
437 commentControls.onsubmit = disableBeforeUnload;
439 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
440 GWLog("GW.cancelCommentButtonClicked");
441 hideReplyForm(event.target.closest(".comment-controls"));
443 commentControls.scrollIntoViewIfNeeded();
444 commentControls.query("form").onsubmit = (event) => {
445 if (!event.target.text.value) {
446 alert("Please enter a comment.");
450 let textarea = commentControls.query("textarea");
451 textarea.value = MarkdownFromHTML(editMarkdownSource || "");
452 textarea.addTextareaFeatures();
456 function showCommentEditForm(commentItem) {
457 GWLog("showCommentEditForm");
459 let commentBody = commentItem.query(".comment-body");
460 commentBody.style.display = "none";
462 let commentControls = commentItem.query(".comment-controls");
463 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
464 commentControls.query("form").addClass("edit-existing-comment");
465 expandTextarea(commentControls.query("textarea"));
468 function showReplyForm(commentItem) {
469 GWLog("showReplyForm");
471 let commentControls = commentItem.query(".comment-controls");
472 commentControls.injectReplyForm(commentControls.dataset.enteredText);
475 function hideReplyForm(commentControls) {
476 GWLog("hideReplyForm");
477 // Are we editing a comment? If so, un-hide the existing comment body.
478 let containingComment = commentControls.closest(".comment-item");
479 if (containingComment) containingComment.query(".comment-body").style.display = "";
481 let enteredText = commentControls.query("textarea").value;
482 if (enteredText) commentControls.dataset.enteredText = enteredText;
484 disableBeforeUnload();
485 commentControls.constructCommentControls();
488 function expandTextarea(textarea) {
489 GWLog("expandTextarea");
490 if (window.innerWidth <= 520) return;
492 let totalBorderHeight = 30;
493 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
495 requestAnimationFrame(() => {
496 textarea.style.height = 'auto';
497 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
498 if (textarea.clientHeight < window.innerHeight) {
499 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
504 function doCommentAction(action, commentItem) {
505 GWLog("doCommentAction");
507 params[(action + "-comment-id")] = commentItem.getCommentId();
511 onSuccess: GW.commentActionPostSucceeded = (event) => {
512 GWLog("GW.commentActionPostSucceeded");
514 retract: () => { commentItem.firstChild.addClass("retracted") },
515 unretract: () => { commentItem.firstChild.removeClass("retracted") },
517 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>";
518 commentItem.removeChild(commentItem.query(".comment-controls"));
522 if(action != "delete")
523 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
532 function parseVoteType(voteType) {
533 GWLog("parseVoteType");
535 if (!voteType) return value;
536 value.up = /[Uu]pvote$/.test(voteType);
537 value.down = /[Dd]ownvote$/.test(voteType);
538 value.big = /^big/.test(voteType);
542 function makeVoteType(value) {
543 GWLog("makeVoteType");
544 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
547 function makeVoteClass(vote) {
548 GWLog("makeVoteClass");
549 if (vote.up || vote.down) {
550 return (vote.big ? 'selected big-vote' : 'selected');
556 function findVoteControls(targetType, targetId, voteAxis) {
557 var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
559 if(targetType == "Post") {
560 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
561 } else if(targetType == "Comment") {
562 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
566 function votesEqual(vote1, vote2) {
567 var allKeys = Object.assign({}, vote1);
568 Object.assign(allKeys, vote2);
570 for(k of allKeys.keys()) {
571 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
576 function addVoteButtons(element, vote, targetType) {
577 GWLog("addVoteButtons");
579 let voteAxis = element.parentElement.dataset.voteAxis || "karma";
580 let voteType = parseVoteType(vote[voteAxis]);
581 let voteClass = makeVoteClass(voteType);
583 element.parentElement.queryAll("button").forEach((button) => {
584 button.disabled = false;
586 if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
587 button.addClass(voteClass);
589 updateVoteButtonVisualState(button);
590 button.addActivateEvent(voteButtonClicked);
594 function updateVoteButtonVisualState(button) {
595 GWLog("updateVoteButtonVisualState");
597 button.removeClasses([ "none", "one", "two-temp", "two" ]);
600 button.addClass("none");
601 else if (button.hasClass("big-vote"))
602 button.addClass("two");
603 else if (button.hasClass("selected"))
604 button.addClass("one");
606 button.addClass("none");
609 function changeVoteButtonVisualState(button) {
610 GWLog("changeVoteButtonVisualState");
612 /* Interaction states are:
614 0 0· (neutral; +1 click)
615 1 1· (small vote; +1 click)
616 2 2· (big vote; +1 click)
618 Visual states are (with their state classes in [brackets]) are:
621 02 (small vote active) [one]
622 12 (small vote active, temporary indicator of big vote) [two-temp]
623 22 (big vote active) [two]
625 The following are the 9 possible interaction state transitions (and
626 the visual state transitions associated with them):
629 FROM TO FROM TO NOTES
630 ==== ==== ==== ==== =====
631 0 0· 01 12 first click
632 0· 1 12 02 one click without second
633 0· 2 12 22 second click
635 1 1· 02 12 first click
636 1· 0 12 01 one click without second
637 1· 2 12 22 second click
639 2 2· 22 12 first click
640 2· 1 12 02 one click without second
641 2· 0 12 01 second click
644 [ "big-vote two-temp clicked-twice", "none" ], // 2· => 0
645 [ "big-vote two-temp clicked-once", "one" ], // 2· => 1
646 [ "big-vote clicked-once", "two-temp" ], // 2 => 2·
648 [ "selected two-temp clicked-twice", "two" ], // 1· => 2
649 [ "selected two-temp clicked-once", "none" ], // 1· => 0
650 [ "selected clicked-once", "two-temp" ], // 1 => 1·
652 [ "two-temp clicked-twice", "two" ], // 0· => 2
653 [ "two-temp clicked-once", "one" ], // 0· => 1
654 [ "clicked-once", "two-temp" ], // 0 => 0·
656 for (let [ interactionClasses, visualStateClass ] of transitions) {
657 if (button.hasClasses(interactionClasses.split(" "))) {
658 button.removeClasses([ "none", "one", "two-temp", "two" ]);
659 button.addClass(visualStateClass);
665 function voteCompleteEvent(targetType, targetId, response) {
666 GWLog("voteCompleteEvent");
668 var currentVote = voteData[targetType][targetId] || {};
669 var desiredVote = voteDesired[targetType][targetId];
671 var controls = findVoteControls(targetType, targetId);
672 var controlsByAxis = new Object;
674 controls.forEach(control => {
675 const voteAxis = (control.dataset.voteAxis || "karma");
677 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
678 control.removeClass("waiting");
679 control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
682 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
683 controlsByAxis[voteAxis].push(control);
685 const voteType = currentVote[voteAxis];
686 const vote = parseVoteType(voteType);
687 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
688 const voteClass = makeVoteClass(vote);
690 if (response && response[voteAxis]) {
691 const [voteType, displayText, titleText] = response[voteAxis];
693 const displayTarget = control.query(".karma-value");
694 if (displayTarget.hasClass("redacted")) {
695 displayTarget.dataset["trueValue"] = displayText;
697 displayTarget.innerHTML = displayText;
699 displayTarget.setAttribute("title", titleText);
702 control.queryAll("button.vote").forEach(button => {
703 updateVoteButton(button, voteUpDown, voteClass);
708 function updateVoteButton(button, voteUpDown, voteClass) {
709 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
710 if (button.dataset.voteType == voteUpDown)
711 button.addClass(voteClass);
712 updateVoteButtonVisualState(button);
715 function makeVoteRequestCompleteEvent(targetType, targetId) {
717 var currentVote = {};
720 if (event.target.status == 200) {
721 response = JSON.parse(event.target.responseText);
722 for (const voteAxis of response.keys()) {
723 currentVote[voteAxis] = response[voteAxis][0];
725 voteData[targetType][targetId] = currentVote;
727 delete voteDesired[targetType][targetId];
728 currentVote = voteData[targetType][targetId];
731 var desiredVote = voteDesired[targetType][targetId];
733 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
734 sendVoteRequest(targetType, targetId);
736 delete voteDesired[targetType][targetId];
737 voteCompleteEvent(targetType, targetId, response);
742 function sendVoteRequest(targetType, targetId) {
743 GWLog("sendVoteRequest");
747 location: "/karma-vote",
748 params: { "target": targetId,
749 "target-type": targetType,
750 "vote": JSON.stringify(voteDesired[targetType][targetId]) },
751 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
755 function voteButtonClicked(event) {
756 GWLog("voteButtonClicked");
757 let voteButton = event.target;
759 // 500 ms (0.5 s) double-click timeout.
760 let doubleClickTimeout = 500;
762 if (!voteButton.clickedOnce) {
763 voteButton.clickedOnce = true;
764 voteButton.addClass("clicked-once");
765 changeVoteButtonVisualState(voteButton);
767 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
768 if (!voteButton.clickedOnce) return;
770 // Do single-click code.
771 voteButton.clickedOnce = false;
772 voteEvent(voteButton, 1);
773 }, doubleClickTimeout, voteButton);
775 voteButton.clickedOnce = false;
777 // Do double-click code.
778 voteButton.removeClass("clicked-once");
779 voteButton.addClass("clicked-twice");
780 voteEvent(voteButton, 2);
784 function voteEvent(voteButton, numClicks) {
788 let voteControl = voteButton.parentNode;
790 let targetType = voteButton.dataset.targetType;
791 let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
792 let voteAxis = voteControl.dataset.voteAxis || "karma";
793 let voteUpDown = voteButton.dataset.voteType;
796 if ( (numClicks == 2 && voteButton.hasClass("big-vote"))
797 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
798 voteType = "neutral";
800 let vote = parseVoteType(voteUpDown);
801 vote.big = (numClicks == 2);
802 voteType = makeVoteType(vote);
805 let voteControls = findVoteControls(targetType, targetId, voteAxis);
806 for (const voteControl of voteControls) {
807 voteControl.addClass("waiting");
808 voteControl.queryAll(".vote").forEach(button => {
809 button.addClass("waiting");
810 updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
814 let voteRequestPending = voteDesired[targetType][targetId];
815 let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
816 voteObject[voteAxis] = voteType;
817 voteDesired[targetType][targetId] = voteObject;
819 if (!voteRequestPending) sendVoteRequest(targetType, targetId);
822 function initializeVoteButtons() {
823 // Color the upvote/downvote buttons with an embedded style sheet.
824 query("head").insertAdjacentHTML("beforeend", "<style id='vote-buttons'>" + `
826 --GW-upvote-button-color: #00d800;
827 --GW-downvote-button-color: #eb4c2a;
831 function processVoteData(voteData) {
832 window.voteData = voteData;
834 window.voteDesired = new Object;
835 for(key of voteData.keys()) {
836 voteDesired[key] = new Object;
839 initializeVoteButtons();
841 addTriggerListener("postLoaded", {priority: 3000, fn: () => {
842 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
843 let postID = karmaValue.parentNode.dataset.postId;
844 addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
845 karmaValue.parentElement.addClass("active-controls");
849 addTriggerListener("DOMReady", {priority: 3000, fn: () => {
850 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
851 let commentID = karmaValue.getCommentId();
852 addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
853 karmaValue.parentElement.addClass("active-controls");
858 /*****************************************/
859 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
860 /*****************************************/
862 Element.prototype.getCommentDate = function() {
863 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
864 let dateElement = item && item.query(".date");
865 return (dateElement && parseInt(dateElement.dataset["jsDate"]));
867 function getCurrentVisibleComment() {
868 let px = window.innerWidth/2, py = 5;
869 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
870 let bottomBar = query("#bottom-bar");
871 let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
872 let atbottom = bottomOffset <= window.innerHeight;
874 let hashci = location.hash && query(location.hash);
875 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
876 commentItem = hashci;
882 function highlightCommentsSince(date) {
883 GWLog("highlightCommentsSince");
884 var newCommentsCount = 0;
885 GW.newComments = [ ];
886 let oldCommentsStack = [ ];
888 queryAll(".comment-item").forEach(commentItem => {
889 commentItem.prevNewComment = prevNewComment;
890 commentItem.nextNewComment = null;
891 if (commentItem.getCommentDate() > date) {
892 commentItem.addClass("new-comment");
894 GW.newComments.push(commentItem.getCommentId());
895 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
896 oldCommentsStack = [ commentItem ];
897 prevNewComment = commentItem;
899 commentItem.removeClass("new-comment");
900 oldCommentsStack.push(commentItem);
904 GW.newCommentScrollSet = (commentItem) => {
905 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
906 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
908 GW.newCommentScrollListener = () => {
909 let commentItem = getCurrentVisibleComment();
910 GW.newCommentScrollSet(commentItem);
913 addScrollListener(GW.newCommentScrollListener);
915 if (document.readyState=="complete") {
916 GW.newCommentScrollListener();
918 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
919 GW.newCommentScrollSet(commentItem);
922 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
924 return newCommentsCount;
927 function scrollToNewComment(next) {
928 GWLog("scrollToNewComment");
929 let commentItem = getCurrentVisibleComment();
930 let targetComment = null;
931 let targetCommentID = null;
933 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
935 targetCommentID = targetComment.getCommentId();
938 if (GW.newComments[0]) {
939 targetCommentID = GW.newComments[0];
940 targetComment = query("#comment-" + targetCommentID);
944 expandAncestorsOf(targetCommentID);
945 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
946 targetComment.scrollIntoView();
949 GW.newCommentScrollListener();
952 function getPostHash() {
953 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
954 return (postHash ? postHash[1] : false);
956 function setHistoryLastVisitedDate(date) {
957 window.history.replaceState({ lastVisited: date }, null);
959 function getLastVisitedDate() {
960 // Get the last visited date (or, if posting a comment, the previous last visited date).
961 if(window.history.state) return (window.history.state||{})['lastVisited'];
962 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
963 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
964 let currentVisited = localStorage.getItem(storageName);
965 setHistoryLastVisitedDate(currentVisited);
966 return currentVisited;
968 function setLastVisitedDate(date) {
969 GWLog("setLastVisitedDate");
970 // If NOT posting a comment, save the previous value for the last-visited-date
971 // (to recover it in case of posting a comment).
972 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
973 if (!aCommentHasJustBeenPosted) {
974 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
975 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
978 // Set the new value.
979 localStorage.setItem("last-visited-date_" + getPostHash(), date);
982 function updateSavedCommentCount() {
983 GWLog("updateSavedCommentCount");
984 let commentCount = queryAll(".comment").length;
985 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
987 function badgePostsWithNewComments() {
988 GWLog("badgePostsWithNewComments");
989 if (getQueryVariable("show") == "conversations") return;
991 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
992 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
994 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
995 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
996 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
998 if (currentCommentCount > savedCommentCount)
999 commentCountDisplay.addClass("new-comments");
1001 commentCountDisplay.removeClass("new-comments");
1002 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1006 /***********************************/
1007 /* CONTENT COLUMN WIDTH ADJUSTMENT */
1008 /***********************************/
1010 function injectContentWidthSelector() {
1011 GWLog("injectContentWidthSelector");
1012 // Get saved width setting (or default).
1013 let currentWidth = localStorage.getItem("selected-width") || 'normal';
1015 // Inject the content width selector widget and activate buttons.
1016 let widthSelector = addUIElement(
1017 "<div id='width-selector'>" +
1018 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
1019 let [name, desc, abbr] = widthOption;
1020 let selected = (name == currentWidth ? ' selected' : '');
1021 let disabled = (name == currentWidth ? ' disabled' : '');
1022 return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
1024 widthSelector.queryAll("button").forEach(button => {
1025 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
1026 GWLog("GW.widthAdjustButtonClicked");
1028 // Determine which setting was chosen (i.e., which button was clicked).
1029 let selectedWidth = event.target.dataset.name;
1031 // Save the new setting.
1032 if (selectedWidth == "normal") localStorage.removeItem("selected-width");
1033 else localStorage.setItem("selected-width", selectedWidth);
1035 // Save current visible comment
1036 let visibleComment = getCurrentVisibleComment();
1038 // Actually change the content width.
1039 setContentWidth(selectedWidth);
1040 event.target.parentElement.childNodes.forEach(button => {
1041 button.removeClass("selected");
1042 button.disabled = false;
1044 event.target.addClass("selected");
1045 event.target.disabled = true;
1047 // Make sure the accesskey (to cycle to the next width) is on the right button.
1048 setWidthAdjustButtonsAccesskey();
1050 // Regenerate images overlay.
1051 generateImagesOverlay();
1053 if(visibleComment) visibleComment.scrollIntoView();
1057 // Make sure the accesskey (to cycle to the next width) is on the right button.
1058 setWidthAdjustButtonsAccesskey();
1060 // Inject transitions CSS, if animating changes is enabled.
1061 if (GW.adjustmentTransitions) {
1063 "<style id='width-transition'>" +
1065 #ui-elements-container,
1068 max-width 0.3s ease;
1073 function setWidthAdjustButtonsAccesskey() {
1074 GWLog("setWidthAdjustButtonsAccesskey");
1075 let widthSelector = query("#width-selector");
1076 widthSelector.queryAll("button").forEach(button => {
1077 button.removeAttribute("accesskey");
1078 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1080 let selectedButton = widthSelector.query("button.selected");
1081 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
1082 nextButtonInCycle.accessKey = "'";
1083 nextButtonInCycle.title += ` [\']`;
1086 /*******************/
1087 /* THEME SELECTION */
1088 /*******************/
1090 function injectThemeSelector() {
1091 GWLog("injectThemeSelector");
1092 let currentTheme = readCookie("theme") || "default";
1093 let themeSelector = addUIElement(
1094 "<div id='theme-selector' class='theme-selector'>" +
1095 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
1096 let [name, desc, letter] = themeOption;
1097 let selected = (name == currentTheme ? ' selected' : '');
1098 let disabled = (name == currentTheme ? ' disabled' : '');
1099 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1100 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>`;})) +
1102 themeSelector.queryAll("button").forEach(button => {
1103 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
1104 GWLog("GW.themeSelectButtonClicked");
1105 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
1106 setSelectedTheme(themeName);
1107 if (GW.isMobile) toggleAppearanceAdjustUI();
1111 // Inject transitions CSS, if animating changes is enabled.
1112 if (GW.adjustmentTransitions) {
1114 "<style id='theme-fade-transition'>" +
1117 opacity 0.5s ease-out,
1118 background-color 0.3s ease-out;
1121 background-color: #777;
1124 opacity 0.5s ease-in,
1125 background-color 0.3s ease-in;
1130 function setSelectedTheme(themeName) {
1131 GWLog("setSelectedTheme");
1132 queryAll(".theme-selector button").forEach(button => {
1133 button.removeClass("selected");
1134 button.disabled = false;
1136 queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
1137 button.addClass("selected");
1138 button.disabled = true;
1140 setTheme(themeName);
1141 query("#theme-tweaker-ui .current-theme span").innerText = themeName;
1143 function setTheme(newThemeName) {
1144 var themeUnloadCallback = '';
1145 var oldThemeName = '';
1146 if (typeof(newThemeName) == 'undefined') {
1147 newThemeName = readCookie('theme');
1148 if (!newThemeName) return;
1150 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
1151 oldThemeName = readCookie('theme') || 'default';
1153 if (newThemeName == 'default') setCookie('theme', '');
1154 else setCookie('theme', newThemeName);
1156 if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
1158 let makeNewStyle = function(newThemeName, colorSchemePreference) {
1159 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
1160 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1162 let newStyle = document.createElement('link');
1163 newStyle.setAttribute('class', 'theme');
1164 if(colorSchemePreference)
1165 newStyle.setAttribute('media', '(prefers-color-scheme: ' + colorSchemePreference + ')');
1166 newStyle.setAttribute('rel', 'stylesheet');
1167 newStyle.setAttribute('href', '/css/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1171 let newMainStyle, newStyles;
1172 if(newThemeName === 'default') {
1173 newStyles = [makeNewStyle('dark', 'dark'), makeNewStyle('default', 'light')];
1174 newMainStyle = (window.matchMedia('prefers-color-scheme: dark').matches ? newStyles[0] : newStyles[1]);
1176 newStyles = [makeNewStyle(newThemeName)];
1177 newMainStyle = newStyles[0];
1180 let oldStyles = queryAll("head link.theme");
1181 newMainStyle.addEventListener('load', () => { oldStyles.forEach(x => removeElement(x)); });
1182 newMainStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
1184 if (GW.adjustmentTransitions) {
1185 pageFadeTransition(false);
1187 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1190 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1193 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
1194 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1195 document.body.addClass("theme-" + newThemeName);
1197 recomputeUIElementsContainerHeight(true);
1199 let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
1200 if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
1202 recomputeUIElementsContainerHeight();
1203 adjustUIForWindowSize();
1204 window.addEventListener('resize', GW.windowResized = (event) => {
1205 GWLog("GW.windowResized");
1206 adjustUIForWindowSize();
1207 recomputeUIElementsContainerHeight();
1210 generateImagesOverlay();
1212 if (window.adjustmentTransitions) pageFadeTransition(true);
1213 updateThemeTweakerSampleText();
1215 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
1216 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1220 function pageFadeTransition(fadeIn) {
1222 query("body").removeClass("transparent");
1224 query("body").addClass("transparent");
1228 GW.themeLoadCallback_less = (fromTheme = "") => {
1229 GWLog("themeLoadCallback_less");
1230 injectSiteNavUIToggle();
1232 injectPostNavUIToggle();
1233 injectAppearanceAdjustUIToggle();
1236 registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1237 let dtf = new Intl.DateTimeFormat([],
1238 (window.innerWidth < 1100) ?
1239 { month: 'short', day: 'numeric', year: 'numeric' } :
1240 { month: 'long', day: 'numeric', year: 'numeric' });
1241 let postDate = query(".top-post-meta .date");
1242 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1246 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1250 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1251 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1252 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1256 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1257 // If state is not set (user has never clicked on the Less theme's appearance
1258 // adjustment UI toggle) then show it, but then hide it after a short time.
1259 registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1260 toggleAppearanceAdjustUI();
1261 setTimeout(toggleAppearanceAdjustUI, 3000);
1265 if (fromTheme != "") {
1266 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1267 setTimeout(function () {
1268 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1270 setTimeout(function () {
1271 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1275 // Unset the height of the #ui-elements-container.
1276 query("#ui-elements-container").style.height = "";
1278 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1279 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1280 "#content #secondary-bar",
1281 "#content .post .top-post-meta .date",
1282 "#content .post .top-post-meta .comment-count",
1284 applyFilters(GW.currentFilters);
1287 // We pre-query the relevant elements, so we don't have to run querySelectorAll
1288 // on every firing of the scroll listener.
1290 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1291 "unbrokenDownScrollDistance": 0,
1292 "unbrokenUpScrollDistance": 0,
1293 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1294 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1295 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1297 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1300 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
1301 // otherwise, show it.
1302 function updatePostNavUIVisibility() {
1303 GWLog("updatePostNavUIVisibility");
1304 var hidePostNavUIToggle = true;
1305 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1306 if (getComputedStyle(element).visibility == "visible" ||
1307 element.style.visibility == "visible" ||
1308 element.style.visibility == "unset")
1309 hidePostNavUIToggle = false;
1311 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1312 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1316 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1317 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
1318 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1319 function updateSiteNavUIState(event) {
1320 GWLog("updateSiteNavUIState");
1321 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1322 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
1323 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
1325 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1326 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1328 GW.scrollState.lastScrollTop = newScrollTop;
1330 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1331 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1332 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1333 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1336 // On mobile, make site nav UI translucent on ANY scroll down.
1338 GW.scrollState.siteNavUIElements.forEach(element => {
1339 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1340 else element.removeClass("translucent-on-scroll");
1343 // Show site nav UI when scrolling a full page up, or to the top.
1344 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
1345 GW.scrollState.lastScrollTop == 0) &&
1346 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
1347 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1349 // On desktop, show appearance adjust UI when scrolling to the top.
1350 if ((!GW.isMobile) &&
1351 (GW.scrollState.lastScrollTop == 0) &&
1352 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
1353 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1356 GW.themeUnloadCallback_less = (toTheme = "") => {
1357 GWLog("themeUnloadCallback_less");
1358 removeSiteNavUIToggle();
1360 removePostNavUIToggle();
1361 removeAppearanceAdjustUIToggle();
1363 window.removeEventListener('resize', updatePostNavUIVisibility);
1365 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1367 removeElement("#theme-less-mobile-first-row-placeholder");
1371 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1372 element.innerHTML = element.firstChild.innerHTML;
1376 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1378 // Reset filtered elements selector to default.
1379 delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1380 applyFilters(GW.currentFilters);
1383 GW.themeLoadCallback_dark = (fromTheme = "") => {
1384 GWLog("themeLoadCallback_dark");
1386 "<style id='dark-theme-adjustments'>" +
1387 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` +
1388 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1390 registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1391 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1392 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)";
1393 image.style.width = parseInt(image.style.width) + 12 + "px";
1394 image.style.height = parseInt(image.style.height) + 12 + "px";
1395 image.style.top = parseInt(image.style.top) - 6 + "px";
1396 image.style.left = parseInt(image.style.left) - 6 + "px";
1400 GW.themeUnloadCallback_dark = (toTheme = "") => {
1401 GWLog("themeUnloadCallback_dark");
1402 removeElement("#dark-theme-adjustments");
1405 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1406 GWLog("themeLoadCallback_brutalist");
1407 let bottomBarLinks = queryAll("#bottom-bar a");
1408 if (!GW.isMobile && bottomBarLinks.length == 5) {
1409 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1410 bottomBarLinks.forEach((link, i) => {
1411 link.dataset.originalText = link.textContent;
1412 link.textContent = newLinkTexts[i];
1416 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1417 GWLog("themeUnloadCallback_brutalist");
1418 let bottomBarLinks = queryAll("#bottom-bar a");
1419 if (!GW.isMobile && bottomBarLinks.length == 5) {
1420 bottomBarLinks.forEach(link => {
1421 link.textContent = link.dataset.originalText;
1426 GW.themeLoadCallback_classic = (fromTheme = "") => {
1427 GWLog("themeLoadCallback_classic");
1428 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1429 button.innerHTML = "";
1432 GW.themeUnloadCallback_classic = (toTheme = "") => {
1433 GWLog("themeUnloadCallback_classic");
1434 if (GW.isMobile && window.innerWidth <= 900) return;
1435 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1436 button.innerHTML = button.dataset.label;
1440 /********************************************/
1441 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1442 /********************************************/
1444 function injectThemeTweaker() {
1445 GWLog("injectThemeTweaker");
1446 let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" +
1447 `<div class='main-theme-tweaker-window'>
1448 <h1>Customize appearance</h1>
1449 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1450 <button type='button' class='help-button' tabindex='-1'></button>
1451 <p class='current-theme'>Current theme: <span>` +
1452 (readCookie("theme") || "default") +
1454 <p class='theme-selector'></p>
1455 <div class='controls-container'>
1456 <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1457 <div class='sample-text-container'><span class='sample-text'>
1458 <p>Less Wrong (text)</p>
1459 <p><a href="#">Less Wrong (link)</a></p>
1462 <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1463 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1464 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1465 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1467 <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1468 <input type='checkbox' id='theme-tweak-control-invert'></input>
1469 <label for='theme-tweak-control-invert'>Invert colors</label>
1471 <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1472 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1473 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1474 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1476 <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1477 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1478 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1479 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1481 <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1482 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1483 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1484 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1486 <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1487 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1488 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1489 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1492 <div class='buttons-container'>
1493 <button type="button" class="reset-defaults-button">Reset to defaults</button>
1494 <button type='button' class='ok-button default-button'>OK</button>
1495 <button type='button' class='cancel-button'>Cancel</button>
1498 <div class="clippy-container">
1499 <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>)
1500 <div class='clippy'></div>
1501 <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>
1503 <div class='help-window' style='display: none;'>
1504 <h1>Theme tweaker help</h1>
1505 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1506 <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1507 <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1509 <div class='buttons-container'>
1510 <button type='button' class='ok-button default-button'>OK</button>
1511 <button type='button' class='cancel-button'>Cancel</button>
1516 // Clicking the background overlay closes the theme tweaker.
1517 themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1518 GWLog("GW.themeTweaker.UIOverlayClicked");
1519 if (event.type == 'mousedown') {
1520 themeTweakerUI.style.opacity = "0.01";
1522 toggleThemeTweakerUI();
1523 themeTweakerUI.style.opacity = "1.0";
1528 // Intercept clicks, so they don't "fall through" the background overlay.
1529 (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1531 let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1532 themeTweakerUI.queryAll("input").forEach(field => {
1533 // All input types in the theme tweaker receive a 'change' event when
1534 // their value is changed. (Range inputs, in particular, receive this
1535 // event when the user lets go of the handle.) This means we should
1536 // update the filters for the entire page, to match the new setting.
1537 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1538 GWLog("GW.themeTweaker.fieldValueChanged");
1539 if (event.target.id == 'theme-tweak-control-invert') {
1540 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1541 } else if (event.target.type == 'range') {
1542 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1543 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1544 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1545 } else if (event.target.id == 'theme-tweak-control-clippy') {
1546 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1548 // Clear the sample text filters.
1549 sampleTextContainer.style.filter = "";
1550 // Apply the new filters globally.
1551 applyFilters(GW.currentFilters);
1554 // Range inputs receive an 'input' event while being scrubbed, updating
1555 // "live" as the handle is moved. We don't want to change the filters
1556 // for the actual page while this is happening, but we do want to change
1557 // the filters for the *sample text*, so the user can see what effects
1558 // his changes are having, live, without having to let go of the handle.
1559 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1560 GWLog("GW.themeTweaker.fieldInputReceived");
1561 var sampleTextFilters = GW.currentFilters;
1563 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1564 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1565 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1567 sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1571 themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1572 GWLog("GW.themeTweaker.minimizeButtonClicked");
1573 let themeTweakerStyle = query("#theme-tweaker-style");
1575 if (event.target.hasClass("minimize")) {
1576 event.target.removeClass("minimize");
1577 themeTweakerStyle.innerHTML =
1578 `#theme-tweaker-ui .main-theme-tweaker-window {
1582 padding: 30px 0 0 0;
1587 #theme-tweaker-ui::after {
1591 #theme-tweaker-ui::before {
1595 #theme-tweaker-ui .clippy-container {
1598 #theme-tweaker-ui .clippy-container .hint span {
1604 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1605 pointer-events: none;
1607 event.target.addClass("maximize");
1609 event.target.removeClass("maximize");
1610 themeTweakerStyle.innerHTML =
1611 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1612 pointer-events: none;
1614 event.target.addClass("minimize");
1617 themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1618 GWLog("GW.themeTweaker.helpButtonClicked");
1619 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1620 toggleThemeTweakerHelpWindow();
1622 themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1623 GWLog("GW.themeTweaker.resetDefaultsButtonClicked");
1624 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1625 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1626 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1627 slider.value = slider.dataset['defaultValue'];
1628 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1630 GW.currentFilters = { };
1631 applyFilters(GW.currentFilters);
1633 GW.currentTextZoom = "1.0";
1634 setTextZoom(GW.currentTextZoom);
1636 setSelectedTheme("default");
1638 themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1639 GWLog("GW.themeTweaker.cancelButtonClicked");
1640 toggleThemeTweakerUI();
1643 themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1644 GWLog("GW.themeTweaker.OKButtonClicked");
1645 toggleThemeTweakerUI();
1648 themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1649 GWLog("GW.themeTweaker.helpWindowCancelButtonClicked");
1650 toggleThemeTweakerHelpWindow();
1651 themeTweakerResetSettings();
1653 themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1654 GWLog("GW.themeTweaker.helpWindowOKButtonClicked");
1655 toggleThemeTweakerHelpWindow();
1656 themeTweakerSaveSettings();
1659 themeTweakerUI.queryAll(".notch").forEach(notch => {
1660 notch.addActivateEvent(GW.themeTweaker.sliderNotchClicked = (event) => {
1661 GWLog("GW.themeTweaker.sliderNotchClicked");
1662 let slider = event.target.parentElement.query("input[type='range']");
1663 slider.value = slider.dataset['defaultValue'];
1664 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1665 GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1666 applyFilters(GW.currentFilters);
1670 themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1671 GWLog("GW.themeTweaker.clippyCloseButtonClicked");
1672 themeTweakerUI.query(".clippy-container").style.display = "none";
1673 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1674 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1677 query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1679 themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1680 themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1681 button.addActivateEvent(GW.themeSelectButtonClicked);
1684 themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1685 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1688 let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'></button></div>`);
1689 themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1690 GWLog("GW.themeTweaker.toggleButtonClicked");
1691 GW.themeTweakerStyleSheetAvailable = () => {
1692 GWLog("GW.themeTweakerStyleSheetAvailable");
1693 themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1695 themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1696 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1697 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1698 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1699 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1702 toggleThemeTweakerUI();
1703 event.target.disabled = true;
1706 if (query("link[href^='/css/theme_tweaker.css']")) {
1707 // Theme tweaker CSS is already loaded.
1708 GW.themeTweakerStyleSheetAvailable();
1710 // Load the theme tweaker CSS (if not loaded).
1711 let themeTweakerStyleSheet = document.createElement('link');
1712 themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1713 themeTweakerStyleSheet.setAttribute('href', '/css/theme_tweaker.css');
1714 themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1715 query("head").appendChild(themeTweakerStyleSheet);
1719 function toggleThemeTweakerUI() {
1720 GWLog("toggleThemeTweakerUI");
1721 let themeTweakerUI = query("#theme-tweaker-ui");
1722 themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1723 query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" :
1724 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1725 pointer-events: none;
1727 if (themeTweakerUI.style.display != "none") {
1728 // Save selected theme.
1729 GW.currentTheme = (readCookie("theme") || "default");
1730 // Focus invert checkbox.
1731 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1732 // Show sample text in appropriate font.
1733 updateThemeTweakerSampleText();
1734 // Disable tab-selection of the search box.
1735 setSearchBoxTabSelectable(false);
1736 // Disable scrolling of the page.
1737 togglePageScrolling(false);
1739 query("#theme-tweaker-toggle button").disabled = false;
1740 // Re-enable tab-selection of the search box.
1741 setSearchBoxTabSelectable(true);
1742 // Re-enable scrolling of the page.
1743 togglePageScrolling(true);
1745 // Set theme tweaker assistant visibility.
1746 query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1748 function setSearchBoxTabSelectable(selectable) {
1749 GWLog("setSearchBoxTabSelectable");
1750 query("input[type='search']").tabIndex = selectable ? "" : "-1";
1751 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1753 function toggleThemeTweakerHelpWindow() {
1754 GWLog("toggleThemeTweakerHelpWindow");
1755 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1756 themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1757 if (themeTweakerHelpWindow.style.display != "none") {
1758 // Focus theme tweaker assistant checkbox.
1759 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1760 // Disable interaction on main theme tweaker window.
1761 query("#theme-tweaker-ui").style.pointerEvents = "none";
1762 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1764 // Re-enable interaction on main theme tweaker window.
1765 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1766 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1769 function themeTweakReset() {
1770 GWLog("themeTweakReset");
1771 setSelectedTheme(GW.currentTheme);
1772 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1773 applyFilters(GW.currentFilters);
1774 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1775 setTextZoom(GW.currentTextZoom);
1777 function themeTweakSave() {
1778 GWLog("themeTweakSave");
1779 GW.currentTheme = (readCookie("theme") || "default");
1780 localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1781 localStorage.setItem("text-zoom", GW.currentTextZoom);
1784 function themeTweakerResetSettings() {
1785 GWLog("themeTweakerResetSettings");
1786 query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1787 query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1789 function themeTweakerSaveSettings() {
1790 GWLog("themeTweakerSaveSettings");
1791 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1793 function updateThemeTweakerSampleText() {
1794 GWLog("updateThemeTweakerSampleText");
1795 let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1797 // This causes the sample text to take on the properties of the body text of a post.
1798 sampleText.removeClass("body-text");
1799 let bodyTextElement = query(".post-body") || query(".comment-body");
1800 sampleText.addClass("body-text");
1801 sampleText.style.color = bodyTextElement ?
1802 getComputedStyle(bodyTextElement).color :
1803 getComputedStyle(query("#content")).color;
1805 // Here we find out what is the actual background color that will be visible behind
1806 // the body text of posts, and set the sample text’s background to that.
1807 let findStyleBackground = (selector) => {
1809 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
1810 if(rule.selectorText == selector)
1813 return x.style.backgroundColor;
1816 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
1819 /*********************/
1820 /* PAGE QUICK-NAV UI */
1821 /*********************/
1823 function injectQuickNavUI() {
1824 GWLog("injectQuickNavUI");
1825 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1826 `<a href='#top' title="Up to top [,]" accesskey=','></a>
1827 <a href='#comments' title="Comments [/]" accesskey='/'></a>
1828 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
1832 /**********************/
1833 /* NEW COMMENT NAV UI */
1834 /**********************/
1836 function injectNewCommentNavUI(newCommentsCount) {
1837 GWLog("injectNewCommentNavUI");
1838 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
1839 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
1840 <span class='new-comments-count'></span>
1841 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
1844 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1845 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1846 GWLog("GW.commentQuicknavButtonClicked");
1847 scrollToNewComment(/next/.test(event.target.className));
1848 event.target.blur();
1852 document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => {
1853 GWLog("GW.commentQuicknavKeyPressed");
1854 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1855 if (event.key == ",") scrollToNewComment(false);
1856 if (event.key == ".") scrollToNewComment(true)
1859 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1860 + `<span>Since:</span>`
1861 + `<input type='text' class='hns-date'></input>`
1864 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1865 GWLog("GW.hnsDatePickerValueChanged");
1866 let hnsDate = time_fromHuman(event.target.value);
1868 setHistoryLastVisitedDate(hnsDate);
1869 let newCommentsCount = highlightCommentsSince(hnsDate);
1870 updateNewCommentNavUI(newCommentsCount);
1874 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1875 GWLog("GW.newCommentsCountClicked");
1876 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1877 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1881 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1882 function time_fromHuman(string) {
1883 /* Convert a human-readable date into a JS timestamp */
1884 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1885 string = string.replace(' ', 'T'); // revert nice spacing
1886 string += ':00.000Z'; // complete ISO 8601 date
1887 time = Date.parse(string); // milliseconds since epoch
1889 // browsers handle ISO 8601 without explicit timezone differently
1890 // thus, we have to fix that by hand
1891 time += (new Date()).getTimezoneOffset() * 60e3;
1893 string = string.replace(' at', '');
1894 time = Date.parse(string); // milliseconds since epoch
1899 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1900 GWLog("updateNewCommentNavUI");
1901 // Update the new comments count.
1902 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1903 newCommentsCountLabel.innerText = newCommentsCount;
1904 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1906 // Update the date picker field.
1907 if (hnsDate != -1) {
1908 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1912 /***************************/
1913 /* TEXT SIZE ADJUSTMENT UI */
1914 /***************************/
1916 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1917 GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1918 var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1919 if (event.target.hasClass("decrease")) {
1920 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1921 } else if (event.target.hasClass("increase")) {
1922 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1926 setTextZoom(zoomFactor);
1927 GW.currentTextZoom = `${zoomFactor}`;
1929 if (event.target.parentElement.id == "text-size-adjustment-ui") {
1930 localStorage.setItem("text-zoom", GW.currentTextZoom);
1934 function injectTextSizeAdjustmentUIReal() {
1935 GWLog("injectTextSizeAdjustmentUIReal");
1936 let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1937 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1938 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1939 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1942 textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1943 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1946 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1949 function injectTextSizeAdjustmentUI() {
1950 GWLog("injectTextSizeAdjustmentUI");
1951 if (query("#text-size-adjustment-ui") != null) return;
1952 if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1953 else document.addEventListener("DOMContentLoaded", () => {
1954 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1958 /********************************/
1959 /* COMMENTS VIEW MODE SELECTION */
1960 /********************************/
1962 function injectCommentsViewModeSelector() {
1963 GWLog("injectCommentsViewModeSelector");
1964 let commentsContainer = query("#comments");
1965 if (commentsContainer == null) return;
1967 let currentModeThreaded = (location.href.search("chrono=t") == -1);
1968 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1970 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1971 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
1972 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
1975 // commentsViewModeSelector.queryAll("a").forEach(button => {
1976 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1979 if (!currentModeThreaded) {
1980 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1981 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1982 commentParentLink.addClass("inline-author");
1983 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1986 queryAll(".comment-child-links a").forEach(commentChildLink => {
1987 commentChildLink.textContent = commentChildLink.textContent.slice(1);
1988 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
1991 rectifyChronoModeCommentChildLinks();
1993 commentsContainer.addClass("chrono");
1995 commentsContainer.addClass("threaded");
1998 // Remove extraneous top-level comment thread in chrono mode.
1999 let topLevelCommentThread = query("#comments > .comment-thread");
2000 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2003 // function commentsViewModeSelectorButtonClicked(event) {
2004 // event.preventDefault();
2007 // let request = new XMLHttpRequest();
2008 // request.open("GET", event.target.href);
2009 // request.onreadystatechange = () => {
2010 // if (request.readyState != 4) return;
2011 // newDocument = htmlToElement(request.response);
2013 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2015 // // Update the buttons.
2016 // event.target.addClass("selected");
2017 // event.target.parentElement.query("." + classes.old).removeClass("selected");
2019 // // Update the #comments container.
2020 // let commentsContainer = query("#comments");
2021 // commentsContainer.removeClass(classes.old);
2022 // commentsContainer.addClass(classes.new);
2024 // // Update the content.
2025 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2030 // function htmlToElement(html) {
2031 // var template = document.createElement('template');
2032 // template.innerHTML = html.trim();
2033 // return template.content;
2036 function rectifyChronoModeCommentChildLinks() {
2037 GWLog("rectifyChronoModeCommentChildLinks");
2038 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2039 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2040 let childLinks = commentChildLinksContainer.queryAll("a");
2041 childLinks.forEach((link, index) => {
2042 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2046 let childLinksArray = Array.from(childLinks)
2047 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2048 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2051 function childrenOfComment(commentID) {
2052 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2053 let commentParentLink = commentItem.query("a.comment-parent-link");
2054 return ((commentParentLink||{}).hash == "#" + commentID);
2058 /********************************/
2059 /* COMMENTS LIST MODE SELECTION */
2060 /********************************/
2062 function injectCommentsListModeSelector() {
2063 GWLog("injectCommentsListModeSelector");
2064 if (query("#content > .comment-thread") == null) return;
2066 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2067 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2068 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2071 if (query(".sublevel-nav") || query("#top-nav-bar")) {
2072 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2074 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2076 let commentsListModeSelector = query("#comments-list-mode-selector");
2078 commentsListModeSelector.queryAll("button").forEach(button => {
2079 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2080 GWLog("GW.commentsListModeSelectButtonClicked");
2081 event.target.parentElement.queryAll("button").forEach(button => {
2082 button.removeClass("selected");
2083 button.disabled = false;
2084 button.accessKey = '`';
2086 localStorage.setItem("comments-list-mode", event.target.className);
2087 event.target.addClass("selected");
2088 event.target.disabled = true;
2089 event.target.removeAttribute("accesskey");
2091 if (event.target.hasClass("expanded")) {
2092 query("#content").removeClass("compact");
2094 query("#content").addClass("compact");
2099 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2100 if (savedMode == "compact")
2101 query("#content").addClass("compact");
2102 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2103 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2104 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2107 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2108 commentParentLink.addActivateEvent(function (event) {
2109 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2110 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2116 /**********************/
2117 /* SITE NAV UI TOGGLE */
2118 /**********************/
2120 function injectSiteNavUIToggle() {
2121 GWLog("injectSiteNavUIToggle");
2122 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2123 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2124 GWLog("GW.siteNavUIToggleButtonClicked");
2126 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2129 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2131 function removeSiteNavUIToggle() {
2132 GWLog("removeSiteNavUIToggle");
2133 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2134 element.removeClass("engaged");
2136 removeElement("#site-nav-ui-toggle");
2138 function toggleSiteNavUI() {
2139 GWLog("toggleSiteNavUI");
2140 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2141 element.toggleClass("engaged");
2142 element.removeClass("translucent-on-scroll");
2146 /**********************/
2147 /* POST NAV UI TOGGLE */
2148 /**********************/
2150 function injectPostNavUIToggle() {
2151 GWLog("injectPostNavUIToggle");
2152 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2153 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2154 GWLog("GW.postNavUIToggleButtonClicked");
2156 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2159 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2161 function removePostNavUIToggle() {
2162 GWLog("removePostNavUIToggle");
2163 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2164 element.removeClass("engaged");
2166 removeElement("#post-nav-ui-toggle");
2168 function togglePostNavUI() {
2169 GWLog("togglePostNavUI");
2170 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2171 element.toggleClass("engaged");
2175 /*******************************/
2176 /* APPEARANCE ADJUST UI TOGGLE */
2177 /*******************************/
2179 function injectAppearanceAdjustUIToggle() {
2180 GWLog("injectAppearanceAdjustUIToggle");
2181 let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2182 appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
2183 GWLog("GW.appearanceAdjustUIToggleButtonClicked");
2184 toggleAppearanceAdjustUI();
2185 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
2189 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
2190 themeSelectorCloseButton.addClass("theme-selector-close-button");
2191 themeSelectorCloseButton.innerHTML = "";
2192 query("#theme-selector").appendChild(themeSelectorCloseButton);
2193 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
2195 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
2198 function removeAppearanceAdjustUIToggle() {
2199 GWLog("removeAppearanceAdjustUIToggle");
2200 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2201 element.removeClass("engaged");
2203 removeElement("#appearance-adjust-ui-toggle");
2205 function toggleAppearanceAdjustUI() {
2206 GWLog("toggleAppearanceAdjustUI");
2207 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2208 element.toggleClass("engaged");
2212 /**************************/
2213 /* WORD COUNT & READ TIME */
2214 /**************************/
2216 function toggleReadTimeOrWordCount(addWordCountClass) {
2217 GWLog("toggleReadTimeOrWordCount");
2218 queryAll(".post-meta .read-time").forEach(element => {
2219 if (addWordCountClass) element.addClass("word-count");
2220 else element.removeClass("word-count");
2222 let titleParts = /(\S+)(.+)$/.exec(element.title);
2223 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2227 /**************************/
2228 /* PROMPT TO SAVE CHANGES */
2229 /**************************/
2231 function enableBeforeUnload() {
2232 window.onbeforeunload = function () { return true; };
2234 function disableBeforeUnload() {
2235 window.onbeforeunload = null;
2238 /***************************/
2239 /* ORIGINAL POSTER BADGING */
2240 /***************************/
2242 function markOriginalPosterComments() {
2243 GWLog("markOriginalPosterComments");
2244 let postAuthor = query(".post .author");
2245 if (postAuthor == null) return;
2247 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2248 if (author.dataset.userid == postAuthor.dataset.userid ||
2249 (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2250 author.addClass("original-poster");
2251 author.title += "Original poster";
2256 /********************************/
2257 /* EDIT POST PAGE SUBMIT BUTTON */
2258 /********************************/
2260 function setEditPostPageSubmitButtonText() {
2261 GWLog("setEditPostPageSubmitButtonText");
2262 if (!query("#content").hasClass("edit-post-page")) return;
2264 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2265 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2266 GWLog("GW.postSectionSelectorValueChanged");
2267 updateEditPostPageSubmitButtonText();
2271 updateEditPostPageSubmitButtonText();
2273 function updateEditPostPageSubmitButtonText() {
2274 GWLog("updateEditPostPageSubmitButtonText");
2275 let submitButton = query("input[type='submit']");
2276 if (query("input#drafts").checked == true)
2277 submitButton.value = "Save Draft";
2278 else if (query(".posting-controls").hasClass("edit-existing-post"))
2279 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2281 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2288 function numToAlpha(n) {
2291 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2292 n = Math.floor((n / 26) - 1);
2297 function injectAntiKibitzer() {
2298 GWLog("injectAntiKibitzer");
2299 // Inject anti-kibitzer toggle controls.
2300 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>");
2301 antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2302 GWLog("GW.antiKibitzerToggleButtonClicked");
2303 if (query("#anti-kibitzer-toggle").hasClass("engaged") &&
2305 !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!)")) {
2306 event.target.blur();
2310 toggleAntiKibitzerMode();
2311 event.target.blur();
2314 // Activate anti-kibitzer mode (if needed).
2315 if (localStorage.getItem("antikibitzer") == "true")
2316 toggleAntiKibitzerMode();
2318 // Remove temporary CSS that hides the authors and karma values.
2319 removeElement("#antikibitzer-temp");
2322 function toggleAntiKibitzerMode() {
2323 GWLog("toggleAntiKibitzerMode");
2324 // This will be the URL of the user's own page, if logged in, or the URL of
2325 // the login page otherwise.
2326 let userTabTarget = query("#nav-item-login .nav-inner").href;
2327 let pageHeadingElement = query("h1.page-main-heading");
2330 let userFakeName = { };
2332 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2334 let postAuthor = query(".post-page .post-meta .author");
2335 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2337 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2338 if (antiKibitzerToggle.hasClass("engaged")) {
2339 localStorage.setItem("antikibitzer", "false");
2341 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2342 if (redirectTarget) {
2343 window.location = redirectTarget;
2347 // Individual comment page title and header
2348 if (query(".individual-thread-page")) {
2349 let replacer = (node) => {
2351 node.firstChild.replaceWith(node.dataset["trueContent"]);
2353 replacer(query("title:not(.fake-title)"));
2354 replacer(query("#content > h1"));
2357 // Author names/links.
2358 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2359 author.textContent = author.dataset["trueName"];
2360 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2362 author.removeClass("redacted");
2364 // Post/comment karma values.
2365 queryAll(".karma-value.redacted").forEach(karmaValue => {
2366 karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2368 karmaValue.removeClass("redacted");
2370 // Link post domains.
2371 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2372 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2374 linkPostDomain.removeClass("redacted");
2377 antiKibitzerToggle.removeClass("engaged");
2379 localStorage.setItem("antikibitzer", "true");
2381 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2382 if (redirectTarget) {
2383 window.location = redirectTarget;
2387 // Individual comment page title and header
2388 if (query(".individual-thread-page")) {
2389 let replacer = (node) => {
2391 node.dataset["trueContent"] = node.firstChild.wholeText;
2392 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2393 node.firstChild.replaceWith(newText);
2395 replacer(query("title:not(.fake-title)"));
2396 replacer(query("#content > h1"));
2399 removeElement("title.fake-title");
2401 // Author names/links.
2402 queryAll(".author, .inline-author").forEach(author => {
2403 // Skip own posts/comments.
2404 if (author.hasClass("own-user-author"))
2407 let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2411 author.dataset["trueName"] = author.textContent;
2412 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2414 if (/\/user/.test(author.href)) {
2415 author.dataset["trueLink"] = author.pathname;
2416 author.href = "/user?id=" + author.dataset["userid"];
2419 author.addClass("redacted");
2421 // Post/comment karma values.
2422 queryAll(".karma-value").forEach(karmaValue => {
2423 // Skip own posts/comments.
2424 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2427 karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2428 karmaValue.innerHTML = "##<span> points</span>";
2430 karmaValue.addClass("redacted");
2432 // Link post domains.
2433 queryAll(".link-post-domain").forEach(linkPostDomain => {
2434 // Skip own posts/comments.
2435 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2438 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2439 linkPostDomain.textContent = "redacted.domain.tld";
2441 linkPostDomain.addClass("redacted");
2444 antiKibitzerToggle.addClass("engaged");
2448 /*******************************/
2449 /* COMMENT SORT MODE SELECTION */
2450 /*******************************/
2452 var CommentSortMode = Object.freeze({
2458 function sortComments(mode) {
2459 GWLog("sortComments");
2460 let commentsContainer = query("#comments");
2462 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2463 commentsContainer.addClass("sorting");
2465 GW.commentValues = { };
2466 let clonedCommentsContainer = commentsContainer.cloneNode(true);
2467 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2470 case CommentSortMode.NEW:
2471 comparator = (a,b) => commentDate(b) - commentDate(a);
2473 case CommentSortMode.OLD:
2474 comparator = (a,b) => commentDate(a) - commentDate(b);
2476 case CommentSortMode.HOT:
2477 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2479 case CommentSortMode.TOP:
2481 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2484 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2486 removeElement(commentsContainer.lastChild);
2487 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2488 GW.commentValues = { };
2490 if (loggedInUserId) {
2491 // Re-activate vote buttons.
2492 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2493 voteButton.addActivateEvent(voteButtonClicked);
2496 // Re-activate comment action buttons.
2497 commentsContainer.queryAll(".action-button").forEach(button => {
2498 button.addActivateEvent(GW.commentActionButtonClicked);
2502 // Re-activate comment-minimize buttons.
2503 queryAll(".comment-minimize-button").forEach(button => {
2504 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2507 // Re-add comment parent popups.
2508 addCommentParentPopups();
2510 // Redo new-comments highlighting.
2511 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2513 requestAnimationFrame(() => {
2514 commentsContainer.removeClass("sorting");
2515 commentsContainer.addClass("sorted-" + mode);
2518 function commentKarmaValue(commentOrSelector) {
2519 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2520 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2522 function commentDate(commentOrSelector) {
2523 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2524 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2526 function commentVoteCount(commentOrSelector) {
2527 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2528 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2531 function injectCommentsSortModeSelector() {
2532 GWLog("injectCommentsSortModeSelector");
2533 let topCommentThread = query("#comments > .comment-thread");
2534 if (topCommentThread == null) return;
2536 // Do not show sort mode selector if there is no branching in comment tree.
2537 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2539 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
2540 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
2542 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2543 let commentsSortModeSelector = query("#comments-sort-mode-selector");
2545 commentsSortModeSelector.queryAll("button").forEach(button => {
2546 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2547 GWLog("GW.commentsSortModeSelectButtonClicked");
2548 event.target.parentElement.queryAll("button").forEach(button => {
2549 button.removeClass("selected");
2550 button.disabled = false;
2552 event.target.addClass("selected");
2553 event.target.disabled = true;
2555 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2556 setCommentsSortModeSelectButtonsAccesskey();
2560 // TODO: Make this actually get the current sort mode (if that's saved).
2561 // TODO: Also change the condition here to properly get chrono/threaded mode,
2562 // when that is properly done with cookies.
2563 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2564 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2565 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2566 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2567 setCommentsSortModeSelectButtonsAccesskey();
2570 function setCommentsSortModeSelectButtonsAccesskey() {
2571 GWLog("setCommentsSortModeSelectButtonsAccesskey");
2572 queryAll("#comments-sort-mode-selector button").forEach(button => {
2573 button.removeAttribute("accesskey");
2574 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2576 let selectedButton = query("#comments-sort-mode-selector button.selected");
2577 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2578 nextButtonInCycle.accessKey = "z";
2579 nextButtonInCycle.title += " [z]";
2582 /*************************/
2583 /* COMMENT PARENT POPUPS */
2584 /*************************/
2586 function previewPopupsEnabled() {
2587 let isDisabled = localStorage.getItem("preview-popups-disabled");
2588 return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
2591 function setPreviewPopupsEnabled(state) {
2592 localStorage.setItem("preview-popups-disabled", !state);
2593 updatePreviewPopupToggle();
2596 function updatePreviewPopupToggle() {
2597 let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
2598 query("#preview-popup-toggle").setAttribute("style", style);
2601 function injectPreviewPopupToggle() {
2602 GWLog("injectPreviewPopupToggle");
2604 let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
2605 // This is required because Chrome can't use filters on an externally used SVG element.
2606 fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
2607 updatePreviewPopupToggle();
2608 toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
2611 var currentPreviewPopup = { };
2613 function removePreviewPopup(previewPopup) {
2614 if(previewPopup.element)
2615 removeElement(previewPopup.element);
2617 if(previewPopup.timeout)
2618 clearTimeout(previewPopup.timeout);
2620 if(currentPreviewPopup.pointerListener)
2621 window.removeEventListener("pointermove", previewPopup.pointerListener);
2623 if(currentPreviewPopup.mouseoutListener)
2624 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2626 if(currentPreviewPopup.scrollListener)
2627 window.removeEventListener("scroll", previewPopup.scrollListener);
2629 currentPreviewPopup = { };
2632 function addCommentParentPopups() {
2633 GWLog("addCommentParentPopups");
2634 //if (!query("#content").hasClass("comment-thread-page")) return;
2636 queryAll("a[href]").forEach(linkTag => {
2637 let linkHref = linkTag.getAttribute("href");
2640 try { url = new URL(linkHref, window.location.href); }
2644 if(GW.sites[url.host]) {
2645 let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
2647 if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
2648 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
2649 if(event.pointerType == "touch") return;
2650 GWLog("GW.commentParentLinkMouseOver");
2651 removePreviewPopup(currentPreviewPopup);
2652 let parentID = linkHref;
2654 if (!(parent = (query(parentID)||{}).firstChild)) return;
2655 var highlightClassName;
2656 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2657 parentHighlightClassName = "comment-item-highlight-faint";
2658 popup = parent.cloneNode(true);
2659 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2660 linkTag.addEventListener("mouseout", (event) => {
2661 removeElement(popup);
2663 linkTag.closest(".comments > .comment-thread").appendChild(popup);
2665 parentHighlightClassName = "comment-item-highlight";
2667 parent.parentNode.addClass(parentHighlightClassName);
2668 linkTag.addEventListener("mouseout", (event) => {
2669 parent.parentNode.removeClass(parentHighlightClassName);
2673 else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
2674 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
2675 && !(url.searchParams.get('format'))
2676 && !linkTag.closest("nav:not(.post-nav-links)")
2677 && (!url.hash || linkCommentId)
2678 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
2679 linkTag.addEventListener("pointerover", event => {
2680 if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
2681 if(currentPreviewPopup.linkTag) return;
2682 linkTag.createPreviewPopup();
2684 linkTag.createPreviewPopup = function() {
2685 removePreviewPopup(currentPreviewPopup);
2687 currentPreviewPopup = {linkTag: linkTag};
2689 let popup = document.createElement("iframe");
2690 currentPreviewPopup.element = popup;
2692 let popupTarget = linkHref;
2693 if(popupTarget.match(/#comment-/)) {
2694 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
2696 // 'theme' attribute is required for proper caching
2697 popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
2698 popup.addClass("preview-popup");
2700 let linkRect = linkTag.getBoundingClientRect();
2702 if(linkRect.right + 710 < window.innerWidth)
2703 popup.style.left = linkRect.right + 10 + "px";
2705 popup.style.right = "10px";
2707 popup.style.width = "700px";
2708 popup.style.height = "500px";
2709 popup.style.visibility = "hidden";
2710 popup.style.transition = "none";
2712 let recenter = function() {
2713 let popupHeight = 500;
2714 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
2715 let popupContent = popup.contentDocument.querySelector("#content");
2717 popupHeight = popupContent.clientHeight + 2;
2718 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
2719 popup.style.height = popupHeight + "px";
2722 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
2727 query('#content').insertAdjacentElement("beforeend", popup);
2729 let clickListener = event => {
2730 if(!event.target.closest("a, input, label")
2731 && !event.target.closest("popup-hide-button")) {
2732 window.location = linkHref;
2736 popup.addEventListener("load", () => {
2737 let hideButton = popup.contentDocument.createElement("div");
2738 hideButton.className = "popup-hide-button";
2739 hideButton.insertAdjacentText('beforeend', "\uF070");
2740 hideButton.onclick = (event) => {
2741 removePreviewPopup(currentPreviewPopup);
2742 setPreviewPopupsEnabled(false);
2743 event.stopPropagation();
2745 popup.contentDocument.body.appendChild(hideButton);
2747 let body = popup.contentDocument.body;
2748 body.addEventListener("click", clickListener);
2749 body.style.cursor = "pointer";
2754 popup.contentDocument.body.addEventListener("click", clickListener);
2756 currentPreviewPopup.timeout = setTimeout(() => {
2759 requestIdleCallback(() => {
2760 if(currentPreviewPopup.element === popup) {
2761 popup.scrolling = "";
2762 popup.style.visibility = "unset";
2763 popup.style.transition = null;
2766 { opacity: 0, transform: "translateY(10%)" },
2767 { opacity: 1, transform: "none" }
2768 ], { duration: 150, easing: "ease-out" });
2773 let pointerX, pointerY, mousePauseTimeout = null;
2775 currentPreviewPopup.pointerListener = (event) => {
2776 pointerX = event.clientX;
2777 pointerY = event.clientY;
2779 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
2780 mousePauseTimeout = null;
2782 let overElement = document.elementFromPoint(pointerX, pointerY);
2783 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
2785 if(mouseIsOverLink || overElement === popup
2786 || (pointerX < popup.getBoundingClientRect().left
2787 && event.movementX >= 0)) {
2788 if(!mouseIsOverLink && overElement !== popup) {
2789 if(overElement['createPreviewPopup']) {
2790 mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
2792 mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
2796 removePreviewPopup(currentPreviewPopup);
2797 if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
2800 window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
2802 currentPreviewPopup.mouseoutListener = (event) => {
2803 clearTimeout(mousePauseTimeout);
2804 mousePauseTimeout = null;
2806 document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2808 currentPreviewPopup.scrollListener = (event) => {
2809 let overElement = document.elementFromPoint(pointerX, pointerY);
2810 linkRect = linkTag.getBoundingClientRect();
2811 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
2812 removePreviewPopup(currentPreviewPopup);
2814 window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
2819 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2823 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2824 GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2825 "#content .comments .comment-thread"
2827 applyFilters(GW.currentFilters);
2834 function imageFocusSetup(imagesOverlayOnly = false) {
2835 if (typeof GW.imageFocus == "undefined")
2837 contentImagesSelector: "#content img",
2838 overlayImagesSelector: "#images-overlay img",
2839 focusedImageSelector: "#content img.focused, #images-overlay img.focused",
2840 pageContentSelector: "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
2842 hideUITimerDuration: 1500,
2843 hideUITimerExpired: () => {
2844 GWLog("GW.imageFocus.hideUITimerExpired");
2845 let currentTime = new Date();
2846 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
2847 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
2848 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
2851 cancelImageFocusHideUITimer();
2856 GWLog("imageFocusSetup");
2857 // Create event listener for clicking on images to focus them.
2858 GW.imageClickedToFocus = (event) => {
2859 GWLog("GW.imageClickedToFocus");
2860 focusImage(event.target);
2863 // Set timer to hide the image focus UI.
2864 unhideImageFocusUI();
2865 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
2868 // Add the listener to each image in the overlay (i.e., those in the post).
2869 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
2870 image.addActivateEvent(GW.imageClickedToFocus);
2872 // Accesskey-L starts the slideshow.
2873 (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
2874 // Count how many images there are in the post, and set the "… of X" label to that.
2875 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
2876 if (imagesOverlayOnly) return;
2877 // Add the listener to all other content images (including those in comments).
2878 queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
2879 image.addActivateEvent(GW.imageClickedToFocus);
2882 // Create the image focus overlay.
2883 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
2884 `<div class='help-overlay'>
2885 <p><strong>Arrow keys:</strong> Next/previous image</p>
2886 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2887 <p><strong>Space bar:</strong> Reset image size & position</p>
2888 <p><strong>Scroll</strong> to zoom in/out</p>
2889 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2891 <div class='image-number'></div>
2892 <div class='slideshow-buttons'>
2893 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
2894 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
2896 <div class='caption'></div>` +
2898 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2900 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2901 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
2902 GWLog("GW.imageFocus.slideshowButtonClicked");
2903 focusNextImage(event.target.hasClass("next"));
2904 event.target.blur();
2908 // On orientation change, reset the size & position.
2909 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2910 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2913 // UI starts out hidden.
2917 function focusImage(imageToFocus) {
2918 GWLog("focusImage");
2919 // Clear 'last-focused' class of last focused image.
2920 let lastFocusedImage = query("img.last-focused");
2921 if (lastFocusedImage) {
2922 lastFocusedImage.removeClass("last-focused");
2923 lastFocusedImage.removeAttribute("accesskey");
2926 // Create the focused version of the image.
2927 imageToFocus.addClass("focused");
2928 let imageFocusOverlay = query("#image-focus-overlay");
2929 let clonedImage = imageToFocus.cloneNode(true);
2930 clonedImage.style = "";
2931 clonedImage.removeAttribute("width");
2932 clonedImage.removeAttribute("height");
2933 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2934 imageFocusOverlay.appendChild(clonedImage);
2935 imageFocusOverlay.addClass("engaged");
2937 // Set image to default size and position.
2938 resetFocusedImagePosition();
2940 // Blur everything else.
2941 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
2942 element.addClass("blurred");
2945 // Add listener to zoom image with scroll wheel.
2946 window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
2947 GWLog("GW.imageFocus.scrollEvent");
2948 event.preventDefault();
2950 let image = query("#image-focus-overlay img");
2952 // Remove the filter.
2953 image.savedFilter = image.style.filter;
2954 image.style.filter = 'none';
2956 // Locate point under cursor.
2957 let imageBoundingBox = image.getBoundingClientRect();
2959 // Calculate resize factor.
2960 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2961 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2965 image.style.width = (event.deltaY < 0 ?
2966 (image.clientWidth * factor) :
2967 (image.clientWidth / factor))
2969 image.style.height = "";
2971 // Designate zoom origin.
2973 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2974 // the cursor is over the image.
2975 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2976 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2977 (imageBoundingBox.left <= event.clientX &&
2978 event.clientX <= imageBoundingBox.right &&
2979 imageBoundingBox.top <= event.clientY &&
2980 event.clientY <= imageBoundingBox.bottom);
2981 // Otherwise, if we're zooming OUT, zoom from window center; if we're
2982 // zooming IN, zoom from image center.
2983 let zoomingFromWindowCenter = event.deltaY > 0;
2984 if (zoomingFromCursor)
2985 zoomOrigin = { x: event.clientX,
2987 else if (zoomingFromWindowCenter)
2988 zoomOrigin = { x: window.innerWidth / 2,
2989 y: window.innerHeight / 2 };
2991 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
2992 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
2994 // Calculate offset from zoom origin.
2995 let offsetOfImageFromZoomOrigin = {
2996 x: imageBoundingBox.x - zoomOrigin.x,
2997 y: imageBoundingBox.y - zoomOrigin.y
2999 // Calculate delta from centered zoom.
3000 let deltaFromCenteredZoom = {
3001 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3002 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3004 // Adjust image position appropriately.
3005 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3006 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3007 // Gradually re-center image, if it's smaller than the window.
3008 if (!imageSizeExceedsWindowBounds) {
3009 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
3010 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3011 let windowCenter = { x: window.innerWidth / 2,
3012 y: window.innerHeight / 2 }
3013 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3014 y: windowCenter.y - imageCenter.y }
3015 // Divide the offset by 10 because we're nudging the image toward center,
3016 // not jumping it there.
3017 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3018 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3021 // Put the filter back.
3022 image.style.filter = image.savedFilter;
3024 // Set the cursor appropriately.
3025 setFocusedImageCursor();
3027 window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3028 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3029 event.preventDefault();
3032 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3033 window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3034 GWLog("GW.imageFocus.mouseUp");
3035 window.onmousemove = '';
3037 // We only want to do anything on left-clicks.
3038 if (event.button != 0) return;
3040 // Don't unfocus if click was on a slideshow next/prev button!
3041 if (event.target.hasClass("slideshow-button")) return;
3043 // We also don't want to do anything if clicked on the help overlay.
3044 if (event.target.classList.contains("help-overlay") ||
3045 event.target.closest(".help-overlay"))
3048 let focusedImage = query("#image-focus-overlay img");
3049 if (event.target == focusedImage &&
3050 (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3051 // If the mouseup event was the end of a pan of an overside image,
3052 // put the filter back; do not unfocus.
3053 focusedImage.style.filter = focusedImage.savedFilter;
3055 unfocusImageOverlay();
3059 window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3060 GWLog("GW.imageFocus.mouseDown");
3061 event.preventDefault();
3063 let focusedImage = query("#image-focus-overlay img");
3064 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3065 let mouseCoordX = event.clientX;
3066 let mouseCoordY = event.clientY;
3068 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3069 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3072 focusedImage.savedFilter = focusedImage.style.filter;
3074 window.onmousemove = (event) => {
3075 // Remove the filter.
3076 focusedImage.style.filter = "none";
3077 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3078 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3084 // Double-click on the image unfocuses.
3085 clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3086 GWLog("GW.imageFocus.doubleClick");
3087 if (event.target.hasClass("slideshow-button")) return;
3089 unfocusImageOverlay();
3092 // Escape key unfocuses, spacebar resets.
3093 document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3094 GWLog("GW.imageFocus.keyUp");
3095 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3096 if (!allowedKeys.contains(event.key) ||
3097 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3099 event.preventDefault();
3101 switch (event.key) {
3104 unfocusImageOverlay();
3108 resetFocusedImagePosition();
3114 if (query("#images-overlay img.focused")) focusNextImage(true);
3120 if (query("#images-overlay img.focused")) focusNextImage(false);
3125 // Prevent spacebar or arrow keys from scrolling page when image focused.
3126 togglePageScrolling(false);
3128 // If the image comes from the images overlay, for the main post...
3129 if (imageToFocus.closest("#images-overlay")) {
3130 // Mark the overlay as being in slide show mode (to show buttons/count).
3131 imageFocusOverlay.addClass("slideshow");
3133 // Set state of next/previous buttons.
3134 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3135 var indexOfFocusedImage = getIndexOfFocusedImage();
3136 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3137 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3139 // Set the image number.
3140 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3142 // Replace the hash.
3143 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3145 imageFocusOverlay.removeClass("slideshow");
3149 setImageFocusCaption();
3151 // Moving mouse unhides image focus UI.
3152 window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3153 GWLog("GW.imageFocus.mouseMoved");
3154 let currentDateTime = new Date();
3155 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3156 cancelImageFocusHideUITimer();
3158 if (!GW.imageFocus.hideUITimer) {
3159 unhideImageFocusUI();
3160 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3162 GW.imageFocus.mouseLastMovedAt = currentDateTime;
3167 function resetFocusedImagePosition() {
3168 GWLog("resetFocusedImagePosition");
3169 let focusedImage = query("#image-focus-overlay img");
3170 if (!focusedImage) return;
3172 let sourceImage = query(GW.imageFocus.focusedImageSelector);
3174 // Make sure that initially, the image fits into the viewport.
3175 let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3176 let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3177 var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3178 let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3179 let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3180 focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3181 focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3183 // Remove modifications to position.
3184 focusedImage.style.left = "";
3185 focusedImage.style.top = "";
3187 // Set the cursor appropriately.
3188 setFocusedImageCursor();
3190 function setFocusedImageCursor() {
3191 let focusedImage = query("#image-focus-overlay img");
3192 if (!focusedImage) return;
3193 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
3197 function unfocusImageOverlay() {
3198 GWLog("unfocusImageOverlay");
3200 // Remove event listeners.
3201 window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3202 window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3203 // NOTE: The double-click listener does not need to be removed manually,
3204 // because the focused (cloned) image will be removed anyway.
3205 document.removeEventListener("keyup", GW.imageFocus.keyUp);
3206 document.removeEventListener("keydown", GW.imageFocus.keyDown);
3207 window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3208 window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3209 window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3211 // Set accesskey of currently focused image (if it's in the images overlay).
3212 let currentlyFocusedImage = query("#images-overlay img.focused");
3213 if (currentlyFocusedImage) {
3214 currentlyFocusedImage.addClass("last-focused");
3215 currentlyFocusedImage.accessKey = 'l';
3218 // Remove focused image and hide overlay.
3219 let imageFocusOverlay = query("#image-focus-overlay");
3220 imageFocusOverlay.removeClass("engaged");
3221 removeElement(imageFocusOverlay.query("img"));
3223 // Un-blur content/etc.
3224 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3225 element.removeClass("blurred");
3228 // Unset "focused" class of focused image.
3229 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3231 // Re-enable page scrolling.
3232 togglePageScrolling(true);
3234 // Reset the hash, if needed.
3235 if (location.hash.hasPrefix("#if_slide_"))
3236 history.replaceState(window.history.state, null, "#");
3239 function getIndexOfFocusedImage() {
3240 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3241 var indexOfFocusedImage = -1;
3242 for (i = 0; i < images.length; i++) {
3243 if (images[i].hasClass("focused")) {
3244 indexOfFocusedImage = i;
3248 return indexOfFocusedImage;
3251 function focusNextImage(next = true) {
3252 GWLog("focusNextImage");
3253 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3254 var indexOfFocusedImage = getIndexOfFocusedImage();
3256 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3258 // Remove existing image.
3259 removeElement("#image-focus-overlay img");
3260 // Unset "focused" class of just-removed image.
3261 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3263 // Create the focused version of the image.
3264 images[indexOfFocusedImage].addClass("focused");
3265 let imageFocusOverlay = query("#image-focus-overlay");
3266 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3267 clonedImage.style = "";
3268 clonedImage.removeAttribute("width");
3269 clonedImage.removeAttribute("height");
3270 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3271 imageFocusOverlay.appendChild(clonedImage);
3272 imageFocusOverlay.addClass("engaged");
3273 // Set image to default size and position.
3274 resetFocusedImagePosition();
3275 // Set state of next/previous buttons.
3276 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3277 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3278 // Set the image number display.
3279 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3281 setImageFocusCaption();
3282 // Replace the hash.
3283 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3286 function setImageFocusCaption() {
3287 GWLog("setImageFocusCaption");
3288 var T = { }; // Temporary storage.
3290 // Clear existing caption, if any.
3291 let captionContainer = query("#image-focus-overlay .caption");
3292 Array.from(captionContainer.children).forEach(child => { child.remove(); });
3294 // Determine caption.
3295 let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3297 if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
3298 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3299 captionHTML = (T.figcaption.query("p")) ?
3300 T.figcaption.innerHTML :
3301 "<p>" + T.figcaption.innerHTML + "</p>";
3302 } else if (currentlyFocusedImage.title != "") {
3303 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3305 // Insert the caption, if any.
3306 if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3309 function hideImageFocusUI() {
3310 GWLog("hideImageFocusUI");
3311 let imageFocusOverlay = query("#image-focus-overlay");
3312 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3313 element.addClass("hidden");
3317 function unhideImageFocusUI() {
3318 GWLog("unhideImageFocusUI");
3319 let imageFocusOverlay = query("#image-focus-overlay");
3320 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3321 element.removeClass("hidden");
3325 function cancelImageFocusHideUITimer() {
3326 clearTimeout(GW.imageFocus.hideUITimer);
3327 GW.imageFocus.hideUITimer = null;
3334 function keyboardHelpSetup() {
3335 let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3336 <div class='keyboard-help-container'>
3337 <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'></button>
3338 <h1>Keyboard shortcuts</h1>
3339 <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>
3340 <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3341 <div class='keyboard-shortcuts-lists'>` + [ [
3343 [ [ '?' ], "Show keyboard shortcuts" ],
3344 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3347 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3348 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3349 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3350 [ [ 'ak-m' ], "Go to Meta view" ],
3351 [ [ 'ak-v' ], "Go to Tags view"],
3352 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3353 [ [ 'ak-r' ], "Go to Archive view" ],
3354 [ [ 'ak-q' ], "Go to Sequences view" ],
3355 [ [ 'ak-t' ], "Go to About page" ],
3356 [ [ 'ak-u' ], "Go to User or Login page" ],
3357 [ [ 'ak-o' ], "Go to Inbox page" ]
3360 [ [ 'ak-,' ], "Jump up to top of page" ],
3361 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3362 [ [ 'ak-/' ], "Jump to top of comments section" ],
3363 [ [ 'ak-s' ], "Search" ],
3366 [ [ 'ak-n' ], "New post or comment" ],
3367 [ [ 'ak-e' ], "Edit current post" ]
3369 "Post/comment list views",
3370 [ [ '.' ], "Focus next entry in list" ],
3371 [ [ ',' ], "Focus previous entry in list" ],
3372 [ [ ';' ], "Cycle between links in focused entry" ],
3373 [ [ 'Enter' ], "Go to currently focused entry" ],
3374 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3375 [ [ 'ak-]' ], "Go to next page" ],
3376 [ [ 'ak-[' ], "Go to previous page" ],
3377 [ [ 'ak-\\' ], "Go to first page" ],
3378 [ [ 'ak-e' ], "Edit currently focused post" ]
3381 [ [ 'ak-k' ], "Bold text" ],
3382 [ [ 'ak-i' ], "Italic text" ],
3383 [ [ 'ak-l' ], "Insert hyperlink" ],
3384 [ [ 'ak-q' ], "Blockquote text" ]
3387 [ [ 'ak-=' ], "Increase text size" ],
3388 [ [ 'ak--' ], "Decrease text size" ],
3389 [ [ 'ak-0' ], "Reset to default text size" ],
3390 [ [ 'ak-′' ], "Cycle through content width settings" ],
3391 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3392 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3393 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3394 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3395 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3396 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3397 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3398 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3399 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3400 [ [ 'ak-;' ], "Open theme tweaker" ],
3401 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3402 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3405 [ [ 'ak-l' ], "Start/resume slideshow" ],
3406 [ [ 'Esc' ], "Exit slideshow" ],
3407 [ [ '→', '↓' ], "Next slide" ],
3408 [ [ '←', '↑' ], "Previous slide" ],
3409 [ [ 'Space' ], "Reset slide zoom" ]
3412 [ [ 'ak-x' ], "Switch to next view on user page" ],
3413 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3414 [ [ 'ak-` ' ], "Toggle compact comment list view" ],
3415 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3417 `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3419 <span class='keys'>` +
3421 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3424 <span class='action'>${entry[1]}</span>
3426 ).join("\n") + `</ul>`).join("\n") + `
3431 // Add listener to show the keyboard help overlay.
3432 document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3433 GWLog("GW.keyboardHelpShowKeyPressed");
3434 if (event.key == '?')
3435 toggleKeyboardHelpOverlay(true);
3438 // Clicking the background overlay closes the keyboard help overlay.
3439 keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3440 GWLog("GW.keyboardHelpOverlayClicked");
3441 if (event.type == 'mousedown') {
3442 keyboardHelpOverlay.style.opacity = "0.01";
3444 toggleKeyboardHelpOverlay(false);
3445 keyboardHelpOverlay.style.opacity = "1.0";
3449 // Intercept clicks, so they don't "fall through" the background overlay.
3450 (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3452 // Clicking the close button closes the keyboard help overlay.
3453 keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3454 toggleKeyboardHelpOverlay(false);
3457 // Add button to open keyboard help.
3458 query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'></button>");
3459 query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3460 GWLog("GW.openKeyboardHelpButtonClicked");
3461 toggleKeyboardHelpOverlay(true);
3462 event.target.blur();
3466 function toggleKeyboardHelpOverlay(show) {
3467 console.log("toggleKeyboardHelpOverlay");
3469 let keyboardHelpOverlay = query("#keyboard-help-overlay");
3470 show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3471 keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3473 // Prevent scrolling the document when the overlay is visible.
3474 togglePageScrolling(!show);
3476 // Focus the close button as soon as we open.
3477 keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3480 // Add listener to show the keyboard help overlay.
3481 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3482 GWLog("GW.keyboardHelpHideKeyPressed");
3483 if (event.key == 'Escape')
3484 toggleKeyboardHelpOverlay(false);
3487 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3490 // Disable / enable tab-selection of the search box.
3491 setSearchBoxTabSelectable(!show);
3494 /**********************/
3495 /* PUSH NOTIFICATIONS */
3496 /**********************/
3498 function pushNotificationsSetup() {
3499 let pushNotificationsButton = query("#enable-push-notifications");
3500 if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3501 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3502 pushNotificationsButton.style.display = 'unset';
3506 function urlBase64ToUint8Array(base64String) {
3507 const padding = '='.repeat((4 - base64String.length % 4) % 4);
3508 const base64 = (base64String + padding)
3510 .replace(/_/g, '/');
3512 const rawData = window.atob(base64);
3513 const outputArray = new Uint8Array(rawData.length);
3515 for (let i = 0; i < rawData.length; ++i) {
3516 outputArray[i] = rawData.charCodeAt(i);
3521 function pushNotificationsButtonClicked(event) {
3522 event.target.style.opacity = 0.33;
3523 event.target.style.pointerEvents = "none";
3525 let reEnable = (message) => {
3526 if(message) alert(message);
3527 event.target.style.opacity = 1;
3528 event.target.style.pointerEvents = "unset";
3531 if(event.target.dataset.enabled) {
3532 fetch('/push/register', {
3534 headers: { 'Content-type': 'application/json' },
3535 body: JSON.stringify({
3539 event.target.innerHTML = "Enable push notifications";
3540 event.target.dataset.enabled = "";
3542 }).catch((err) => reEnable(err.message));
3544 Notification.requestPermission().then((permission) => {
3545 navigator.serviceWorker.ready
3546 .then((registration) => {
3547 return registration.pushManager.getSubscription()
3548 .then(async function(subscription) {
3550 return subscription;
3552 return registration.pushManager.subscribe({
3553 userVisibleOnly: true,
3554 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
3557 .catch((err) => reEnable(err.message));
3559 .then((subscription) => {
3560 fetch('/push/register', {
3563 'Content-type': 'application/json'
3565 body: JSON.stringify({
3566 subscription: subscription
3571 event.target.innerHTML = "Disable push notifications";
3572 event.target.dataset.enabled = "true";
3575 .catch(function(err){ reEnable(err.message) });
3581 /*******************************/
3582 /* HTML TO MARKDOWN CONVERSION */
3583 /*******************************/
3585 function MarkdownFromHTML(text) {
3586 GWLog("MarkdownFromHTML");
3587 // Wrapper tags, paragraphs, bold, italic, code blocks.
3588 text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
3611 // <div> and <span>.
3612 text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
3614 }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
3619 text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
3620 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
3621 return `* ${listItem}\n`;
3626 text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
3627 var countedItemValue = 0;
3628 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
3630 if (typeof specifiedItemValue != "undefined") {
3631 specifiedItemValue = parseInt(specifiedItemValue);
3632 countedItemValue = itemValue = specifiedItemValue;
3634 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
3636 return `${itemValue}. ${listItem.trim()}\n`;
3641 text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
3642 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
3646 text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
3647 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
3651 text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
3652 return `[${text}](${href})`;
3656 text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
3657 return `![](${src})`;
3660 // Horizontal rules.
3661 text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
3666 text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
3670 // Preformatted text (possibly with a code block inside).
3671 text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
3672 return "```\n" + text + "\n```";
3676 text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
3677 return "`" + text + "`";
3681 text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
3701 /************************************/
3702 /* ANCHOR LINK SCROLLING WORKAROUND */
3703 /************************************/
3705 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
3706 let hash = location.hash;
3707 if(hash && hash !== "#top" && !document.query(hash)) {
3708 let content = document.query("#content");
3709 content.style.display = "none";
3710 addTriggerListener("DOMReady", {priority: -1, fn: () => {
3711 content.style.visibility = "hidden";
3712 content.style.display = null;
3713 requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
3718 /******************/
3719 /* INITIALIZATION */
3720 /******************/
3722 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
3723 GWLog("INITIALIZER earlyInitialize");
3724 // Check to see whether we're on a mobile device (which we define as a narrow screen)
3725 GW.isMobile = (window.innerWidth <= 1160);
3726 GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3728 // Backward compatibility
3729 let storedTheme = localStorage.getItem('selected-theme');
3731 setTheme(storedTheme);
3732 localStorage.removeItem('selected-theme');
3735 // Animate width & theme adjustments?
3736 GW.adjustmentTransitions = false;
3738 // Add the content width selector.
3739 injectContentWidthSelector();
3740 // Add the text size adjustment widget.
3741 injectTextSizeAdjustmentUI();
3742 // Add the theme selector.
3743 injectThemeSelector();
3744 // Add the theme tweaker.
3745 injectThemeTweaker();
3746 // Add the quick-nav UI.
3749 // Finish initializing when ready.
3750 addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
3753 function mainInitializer() {
3754 GWLog("INITIALIZER initialize");
3756 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
3757 // "link without nav bars".
3758 if (getQueryVariable("hide-nav-bars") == "true") {
3759 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
3762 // If the page cannot have comments, remove the accesskey from the #comments
3763 // quick-nav button; and if the page can have comments, but does not, simply
3764 // disable the #comments quick nav button.
3765 let content = query("#content");
3766 if (content.query("#comments") == null) {
3767 query("#quick-nav-ui a[href='#comments']").accessKey = '';
3768 } else if (content.query("#comments .comment-thread") == null) {
3769 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
3772 // On edit post pages and conversation pages, add GUIEdit buttons to the
3773 // textarea, expand it, and markdownify the existing text, if any (this is
3774 // needed if a post was last edited on LW).
3775 queryAll(".with-markdown-editor textarea").forEach(textarea => {
3776 textarea.addTextareaFeatures();
3777 expandTextarea(textarea);
3778 textarea.value = MarkdownFromHTML(textarea.value);
3780 // Focus the textarea.
3781 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3783 // If we're on a comment thread page...
3784 if (query(".comments") != null) {
3785 // Add comment-minimize buttons to every comment.
3786 queryAll(".comment-meta").forEach(commentMeta => {
3787 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3788 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
3790 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
3791 // Format and activate comment-minimize buttons.
3792 queryAll(".comment-minimize-button").forEach(button => {
3793 button.closest(".comment-item").setCommentThreadMaximized(false);
3794 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3795 event.target.closest(".comment-item").setCommentThreadMaximized(true);
3800 if (getQueryVariable("chrono") == "t") {
3801 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
3804 // On mobile, replace the labels for the checkboxes on the edit post form
3805 // with icons, to save space.
3806 if (GW.isMobile && query(".edit-post-page")) {
3807 query("label[for='link-post']").innerHTML = "";
3808 query("label[for='question']").innerHTML = "";
3811 // Add error message (as placeholder) if user tries to click Search with
3812 // an empty search field.
3814 let searchForm = query("#nav-item-search form");
3815 if(!searchForm) break searchForm;
3816 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3817 let searchField = event.target.query("input");
3818 if (searchField.value == "") {
3819 event.preventDefault();
3820 event.target.blur();
3821 searchField.placeholder = "Enter a search string!";
3822 searchField.focus();
3825 // Remove the placeholder / error on any input.
3826 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3827 event.target.placeholder = "";
3831 // Prevent conflict between various single-hotkey listeners and text fields
3832 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3833 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3834 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
3837 if (content.hasClass("post-page")) {
3838 // Read and update last-visited-date.
3839 let lastVisitedDate = getLastVisitedDate();
3840 setLastVisitedDate(Date.now());
3842 // Save the number of comments this post has when it's visited.
3843 updateSavedCommentCount();
3845 if (content.query(".comments .comment-thread") != null) {
3846 // Add the new comments count & navigator.
3847 injectNewCommentNavUI();
3849 // Get the highlight-new-since date (as specified by URL parameter, if
3850 // present, or otherwise the date of the last visit).
3851 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3853 // Highlight new comments since the specified date.
3854 let newCommentsCount = highlightCommentsSince(hnsDate);
3856 // Update the comment count display.
3857 updateNewCommentNavUI(newCommentsCount, hnsDate);
3860 // On listing pages, make comment counts more informative.
3861 badgePostsWithNewComments();
3864 // Add the comments list mode selector widget (expanded vs. compact).
3865 injectCommentsListModeSelector();
3867 // Add the comments view selector widget (threaded vs. chrono).
3868 // injectCommentsViewModeSelector();
3870 // Add the comments sort mode selector (top, hot, new, old).
3871 if (GW.useFancyFeatures) injectCommentsSortModeSelector();
3873 // Add the toggle for the post nav UI elements on mobile.
3874 if (GW.isMobile) injectPostNavUIToggle();
3876 // Add the toggle for the appearance adjustment UI elements on mobile.
3877 if (GW.isMobile) injectAppearanceAdjustUIToggle();
3879 // Add the antikibitzer.
3880 if (GW.useFancyFeatures) injectAntiKibitzer();
3882 // Add comment parent popups.
3883 injectPreviewPopupToggle();
3884 addCommentParentPopups();
3886 // Mark original poster's comments with a special class.
3887 markOriginalPosterComments();
3889 // On the All view, mark posts with non-positive karma with a special class.
3890 if (query("#content").hasClass("all-index-page")) {
3891 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3892 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3894 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3898 // Set the "submit" button on the edit post page to something more helpful.
3899 setEditPostPageSubmitButtonText();
3901 // Compute the text of the pagination UI tooltip text.
3902 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3903 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3906 // Add event listeners for Escape and Enter, for the theme tweaker.
3907 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3908 let themeTweakerUI = query("#theme-tweaker-ui");
3909 document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3910 if (event.key == "Escape") {
3911 if (themeTweakerHelpWindow.style.display != "none") {
3912 toggleThemeTweakerHelpWindow();
3913 themeTweakerResetSettings();
3914 } else if (themeTweakerUI.style.display != "none") {
3915 toggleThemeTweakerUI();
3918 } else if (event.key == "Enter") {
3919 if (themeTweakerHelpWindow.style.display != "none") {
3920 toggleThemeTweakerHelpWindow();
3921 themeTweakerSaveSettings();
3922 } else if (themeTweakerUI.style.display != "none") {
3923 toggleThemeTweakerUI();
3929 // Add event listener for . , ; (for navigating listings pages).
3930 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3931 if (!query(".comments") && listings.length > 0) {
3932 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
3933 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
3935 if (event.key == "Escape") {
3936 if (document.activeElement.parentElement.hasClass("listing"))
3937 document.activeElement.blur();
3941 if (event.key == ';') {
3942 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3943 let links = document.activeElement.parentElement.queryAll("a");
3944 links[document.activeElement == links[0] ? 1 : 0].focus();
3945 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3946 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3947 links[document.activeElement == links[0] ? 1 : 0].focus();
3948 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3953 var indexOfActiveListing = -1;
3954 for (i = 0; i < listings.length; i++) {
3955 if (document.activeElement.parentElement.hasClass("listing") &&
3956 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3957 indexOfActiveListing = i;
3959 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
3960 listings[i] === document.activeElement.parentElement.query("a.date")) {
3961 indexOfActiveListing = i;
3965 // Remove edit accesskey from currently highlighted post by active user, if applicable.
3966 if (indexOfActiveListing > -1) {
3967 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
3969 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3970 if (indexOfNextListing < listings.length) {
3971 listings[indexOfNextListing].focus();
3973 if (listings[indexOfNextListing].closest(".comment-item")) {
3974 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3975 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3978 document.activeElement.blur();
3980 // Add edit accesskey to newly highlighted post by active user, if applicable.
3981 (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
3983 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
3984 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
3985 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
3989 // Add event listener for ; (to focus the link on link posts).
3990 if (query("#content").hasClass("post-page") &&
3991 query(".post").hasClass("link-post")) {
3992 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
3993 if (event.key == ';') query("a.link-post-link").focus();
3997 // Add accesskeys to user page view selector.
3998 let viewSelector = query("#content.user-page > .sublevel-nav");
4000 let currentView = viewSelector.query("span");
4001 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4002 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4005 // Add accesskey to index page sort selector.
4006 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4008 // Move MathJax style tags to <head>.
4009 var aggregatedStyles = "";
4010 queryAll("#content style").forEach(styleTag => {
4011 aggregatedStyles += styleTag.innerHTML;
4012 removeElement("style", styleTag.parentElement);
4014 if (aggregatedStyles != "") {
4015 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4018 // Add listeners to switch between word count and read time.
4019 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4020 queryAll(".post-meta .read-time").forEach(element => {
4021 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4022 let displayWordCount = localStorage.getItem("display-word-count");
4023 toggleReadTimeOrWordCount(!displayWordCount);
4024 if (displayWordCount) localStorage.removeItem("display-word-count");
4025 else localStorage.setItem("display-word-count", true);
4029 // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4030 query("#content").addEventListener("copy", GW.textCopied = (event) => {
4031 if(event.target.matches("input, textarea")) return;
4032 event.preventDefault();
4033 const selectedHTML = getSelectionHTML();
4034 const selectedText = getSelection().toString();
4035 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4036 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4039 // Set up Image Focus feature.
4042 // Set up keyboard shortcuts guide overlay.
4043 keyboardHelpSetup();
4045 // Show push notifications button if supported
4046 pushNotificationsSetup();
4048 // Show elements now that javascript is ready.
4049 removeElement("#hide-until-init");
4051 activateTrigger("pageLayoutFinished");
4054 /*************************/
4055 /* POST-LOAD ADJUSTMENTS */
4056 /*************************/
4058 window.addEventListener("pageshow", badgePostsWithNewComments);
4060 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4061 GWLog("INITIALIZER pageLayoutFinished");
4063 postSetThemeHousekeeping();
4065 focusImageSpecifiedByURL();
4067 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4068 // query("input[type='search']").value = GW.isMobile;
4069 // insertHeadHTML("<style>" +
4070 // `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` +
4071 // `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` +
4075 function generateImagesOverlay() {
4076 GWLog("generateImagesOverlay");
4077 // Don't do this on the about page.
4078 if (query(".about-page") != null) return;
4081 // Remove existing, if any.
4082 removeElement("#images-overlay");
4085 query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4086 let imagesOverlay = query("#images-overlay");
4087 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4088 queryAll(".post-body img").forEach(image => {
4089 let clonedImageContainer = document.createElement("div");
4091 let clonedImage = image.cloneNode(true);
4092 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4093 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4094 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4095 clonedImageContainer.appendChild(clonedImage);
4097 let zoomLevel = parseFloat(GW.currentTextZoom);
4099 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4100 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4101 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4102 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4104 imagesOverlay.appendChild(clonedImageContainer);
4107 // Add the event listeners to focus each image.
4108 imageFocusSetup(true);
4111 function adjustUIForWindowSize() {
4112 GWLog("adjustUIForWindowSize");
4113 var bottomBarOffset;
4115 // Adjust bottom bar state.
4116 let bottomBar = query("#bottom-bar");
4117 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4118 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4119 bottomBar.removeClass("decorative");
4121 bottomBar.query("#nav-item-top").style.display = "";
4122 } else if (bottomBar) {
4123 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4124 else bottomBar.addClass("decorative");
4126 bottomBar.query("#nav-item-top").style.display = "none";
4129 // Show quick-nav UI up/down buttons if content is taller than window.
4130 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4131 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4132 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4135 // Move anti-kibitzer toggle if content is very short.
4136 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4138 // Update the visibility of the post nav UI.
4139 updatePostNavUIVisibility();
4142 function recomputeUIElementsContainerHeight(force = false) {
4143 GWLog("recomputeUIElementsContainerHeight");
4145 (force || query("#ui-elements-container").style.height != "")) {
4146 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4147 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
4148 query("#content").clientHeight + "px" :
4153 function focusImageSpecifiedByURL() {
4154 GWLog("focusImageSpecifiedByURL");
4155 if (location.hash.hasPrefix("#if_slide_")) {
4156 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4157 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4158 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4159 if (imageToFocus > 0 && imageToFocus <= images.length) {
4160 focusImage(images[imageToFocus - 1]);
4162 // Set timer to hide the image focus UI.
4163 unhideImageFocusUI();
4164 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4174 function insertMarkup(event) {
4175 var mopen = '', mclose = '', mtext = '', func = false;
4176 if (typeof arguments[1] == 'function') {
4177 func = arguments[1];
4179 mopen = arguments[1];
4180 mclose = arguments[2];
4181 mtext = arguments[3];
4184 var textarea = event.target.closest("form").query("textarea");
4186 var p0 = textarea.selectionStart;
4187 var p1 = textarea.selectionEnd;
4188 var cur0 = cur1 = p0;
4190 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4191 str = func ? func(str, p0) : (mopen + str + mclose);
4193 // Determine selection.
4195 cur0 += (p0 == p1) ? mopen.length : str.length;
4196 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4203 // Update textarea contents.
4204 document.execCommand("insertText", false, str);
4206 // Expand textarea, if needed.
4207 expandTextarea(textarea);
4210 textarea.selectionStart = cur0;
4211 textarea.selectionEnd = cur1;
4216 GW.guiEditButtons = [
4217 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
4218 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
4219 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
4220 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
4221 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
4222 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
4223 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
4224 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
4225 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
4226 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
4227 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
4228 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
4229 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
4230 [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '' ],
4231 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
4234 function blockquote(text, startpos) {
4236 text = "> Quoted text";
4237 return [ text, startpos + 2, startpos + text.length ];
4239 text = "> " + text.split("\n").join("\n> ") + "\n";
4240 return [ text, startpos + text.length, startpos + text.length ];
4244 function hyperlink(text, startpos) {
4245 var url = '', link_text = text, endpos = startpos;
4246 if (text.search(/^https?/) != -1) {
4248 link_text = "link text";
4249 startpos = startpos + 1;
4250 endpos = startpos + link_text.length;
4252 url = prompt("Link address (URL):");
4254 endpos = startpos + text.length;
4255 return [ text, startpos, endpos ];
4257 startpos = startpos + text.length + url.length + 4;
4261 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4264 /******************/
4265 /* SERVICE WORKER */
4266 /******************/
4268 if(navigator.serviceWorker) {
4269 navigator.serviceWorker.register('/service-worker.js');
4270 setCookie("push", "t");
4273 /*********************/
4274 /* USER AUTOCOMPLETE */
4275 /*********************/
4277 var userAutocomplete = null;
4279 function abbreviatedInterval(date) {
4280 let seconds = Math.floor((new Date() - date) / 1000);
4281 let days = Math.floor(seconds / (60 * 60 * 24));
4282 let years = Math.floor(days / 365);
4291 function beginAutocompletion(control, startIndex) {
4292 if(userAutocomplete) abortAutocompletion(userAutocomplete);
4294 let complete = { control: control,
4295 abortController: new AbortController(),
4296 fetchAbortController: new AbortController(),
4297 container: document.createElement("div") };
4299 let endIndex = control.selectionEnd;
4300 let valueLength = control.value.length;
4302 complete.container.className = "autocomplete-container "
4304 + (window.innerWidth > 1200
4307 control.insertAdjacentElement("afterend", complete.container);
4309 let makeReplacer = (userSlug, displayName) => {
4311 let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
4312 control.value = control.value.substring(0, startIndex - 1) +
4314 control.value.substring(endIndex);
4315 abortAutocompletion(complete);
4316 complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
4317 complete.control.focus();
4321 let switchHighlight = (newHighlight) => {
4325 complete.highlighted.removeClass("highlighted");
4326 newHighlight.addClass("highlighted");
4327 complete.highlighted = newHighlight;
4329 // Scroll newly highlighted item into view, if need be.
4330 if ( complete.highlighted.offsetTop + complete.highlighted.offsetHeight
4331 > complete.container.scrollTop + complete.container.clientHeight) {
4332 complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
4333 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
4334 complete.container.scrollTo(0, complete.highlighted.offsetTop);
4337 let highlightNext = () => {
4338 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
4340 let highlightPrev = () => {
4341 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
4344 document.body.addEventListener("click", (event) => {
4345 if (!complete.container.contains(event.target)) {
4346 abortAutocompletion(complete);
4347 event.preventDefault();
4349 }, {signal: complete.abortController.signal,
4352 control.addEventListener("keydown", (event) => {
4353 switch (event.key) {
4355 abortAutocompletion(complete);
4356 event.preventDefault();
4360 event.preventDefault();
4364 event.preventDefault();
4371 event.preventDefault();
4374 complete.highlighted.onclick();
4375 event.preventDefault();
4378 }, {signal: complete.abortController.signal});
4380 control.addEventListener("selectionchange", (event) => {
4381 if (control.selectionStart < startIndex ||
4382 control.selectionEnd > endIndex) {
4383 abortAutocompletion(complete);
4385 }, {signal: complete.abortController.signal});
4387 control.addEventListener("input", (event) => {
4388 complete.fetchAbortController.abort();
4389 complete.fetchAbortController = new AbortController();
4391 endIndex += control.value.length - valueLength;
4392 valueLength = control.value.length;
4394 if (endIndex < startIndex) {
4395 abortAutocompletion(complete);
4399 let fragment = control.value.substring(startIndex, endIndex);
4401 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
4402 {signal: complete.fetchAbortController.signal})
4403 .then((res) => res.json())
4405 if(res.error) return;
4406 if(res.length == 0) return abortAutocompletion(complete);
4408 complete.container.innerHTML = "";
4409 res.forEach(entry => {
4410 let entryContainer = document.createElement("div");
4411 [ [ entry.displayName, "name" ],
4412 [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
4413 [ (entry.karma || 0) + " karma", "karma" ]
4415 let e = document.createElement("span");
4418 entryContainer.append(e);
4420 entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
4421 complete.container.append(entryContainer);
4423 complete.highlighted = complete.container.children[0];
4424 complete.highlighted.classList.add("highlighted");
4427 }, {signal: complete.abortController.signal});
4429 userAutocomplete = complete;
4432 function abortAutocompletion(complete) {
4433 complete.fetchAbortController.abort();
4434 complete.abortController.abort();
4435 complete.container.remove();
4436 userAutocomplete = null;