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("click", (event) => {
309 if(!userAutocomplete) {
310 let start = textarea.selectionStart, end = textarea.selectionEnd;
311 let value = textarea.value;
312 if (start <= 1) return;
313 for (; value.charAt(start - 1) != "@"; start--) {
314 if (start <= 1) return;
315 if (value.charAt(start - 1) == " ") return;
317 for(; end < value.length && value.charAt(end) != " "; end++) { true }
318 beginAutocompletion(textarea, start, end);
322 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
323 textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
324 textarea.addEventListener("keydown", (event) => {
325 // Special case for alt+4
326 // Generalize this before adding more.
327 if(event.altKey && event.key === '4') {
328 insertMarkup(event, "$", "$", "LaTeX formula");
329 event.stopPropagation();
330 event.preventDefault();
334 let form = textarea.closest("form");
335 if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
337 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
338 let textareaContainer = textarea.closest(".textarea-container");
339 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
340 for (var button of GW.guiEditButtons) {
341 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
342 buttons_container.insertAdjacentHTML("beforeend",
343 "<button type='button' class='guiedit guiedit-"
346 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
347 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
348 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
349 + " onclick='insertMarkup(event,"
350 + ((typeof m_before_or_func == 'function') ?
351 m_before_or_func.name :
352 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
360 `<input type='checkbox' id='markdown-hints-checkbox'>
361 <label for='markdown-hints-checkbox'></label>
362 <div id='markdown-hints'>` +
363 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
364 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
365 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
366 "<span>Heading 1</span><code># Heading 1</code>",
367 "<span>Heading 2</span><code>## Heading 1</code>",
368 "<span>Heading 3</span><code>### Heading 1</code>",
369 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
371 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
373 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
374 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
375 GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
376 if (button.hasClass("guiedit-mobile-help-button")) {
377 toggleMarkdownHintsBox();
378 event.target.toggleClass("active");
379 query(".posting-controls:focus-within textarea").focus();
380 } else if (button.hasClass("guiedit-mobile-exit-button")) {
382 hideMarkdownHintsBox();
383 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
388 // On smartphone (narrow mobile) screens, when a textarea is focused (and
389 // automatically fullscreened), remove all the filters from the page, and
390 // then apply them *just* to the fixed editor UI elements. This is in order
391 // to get around the "children of elements with a filter applied cannot be
393 if (GW.isMobile && window.innerWidth <= 520) {
394 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
395 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
396 GWLog("GW.textareaFocusedMobile");
397 GW.savedFilters = GW.currentFilters;
398 GW.currentFilters = { };
399 applyFilters(GW.currentFilters);
400 fixedEditorElements.forEach(element => {
401 element.style.filter = filterStringFromFilters(GW.savedFilters);
404 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
405 GWLog("GW.textareaBlurredMobile");
406 GW.currentFilters = GW.savedFilters;
407 GW.savedFilters = { };
408 requestAnimationFrame(() => {
409 applyFilters(GW.currentFilters);
410 fixedEditorElements.forEach(element => {
411 element.style.filter = filterStringFromFilters(GW.savedFilters);
418 Element.prototype.injectReplyForm = function(editMarkdownSource) {
419 GWLog("injectReplyForm");
420 let commentControls = this;
421 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
422 let postId = commentControls.parentElement.dataset["postId"];
423 let tagId = commentControls.parentElement.dataset["tagId"];
424 let withparent = (!editMarkdownSource && commentControls.getCommentId());
425 let answer = commentControls.parentElement.id == "answers";
426 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
427 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
428 let parentCommentItem = commentControls.closest(".comment-item");
429 let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
430 (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
431 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
432 "<form method='post'>" +
433 "<div class='textarea-container'>" +
434 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
435 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
436 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
437 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
438 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
439 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
440 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
441 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
442 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
443 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
444 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
445 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
446 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
448 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
449 "<input type='submit' value='Submit'>" +
451 commentControls.onsubmit = disableBeforeUnload;
453 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
454 GWLog("GW.cancelCommentButtonClicked");
455 hideReplyForm(event.target.closest(".comment-controls"));
457 commentControls.scrollIntoViewIfNeeded();
458 commentControls.query("form").onsubmit = (event) => {
459 if (!event.target.text.value) {
460 alert("Please enter a comment.");
464 let textarea = commentControls.query("textarea");
465 textarea.value = MarkdownFromHTML(editMarkdownSource || "");
466 textarea.addTextareaFeatures();
470 function showCommentEditForm(commentItem) {
471 GWLog("showCommentEditForm");
473 let commentBody = commentItem.query(".comment-body");
474 commentBody.style.display = "none";
476 let commentControls = commentItem.query(".comment-controls");
477 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
478 commentControls.query("form").addClass("edit-existing-comment");
479 expandTextarea(commentControls.query("textarea"));
482 function showReplyForm(commentItem) {
483 GWLog("showReplyForm");
485 let commentControls = commentItem.query(".comment-controls");
486 commentControls.injectReplyForm(commentControls.dataset.enteredText);
489 function hideReplyForm(commentControls) {
490 GWLog("hideReplyForm");
491 // Are we editing a comment? If so, un-hide the existing comment body.
492 let containingComment = commentControls.closest(".comment-item");
493 if (containingComment) containingComment.query(".comment-body").style.display = "";
495 let enteredText = commentControls.query("textarea").value;
496 if (enteredText) commentControls.dataset.enteredText = enteredText;
498 disableBeforeUnload();
499 commentControls.constructCommentControls();
502 function expandTextarea(textarea) {
503 GWLog("expandTextarea");
504 if (window.innerWidth <= 520) return;
506 let totalBorderHeight = 30;
507 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
509 requestAnimationFrame(() => {
510 textarea.style.height = 'auto';
511 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
512 if (textarea.clientHeight < window.innerHeight) {
513 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
518 function doCommentAction(action, commentItem) {
519 GWLog("doCommentAction");
521 params[(action + "-comment-id")] = commentItem.getCommentId();
525 onSuccess: GW.commentActionPostSucceeded = (event) => {
526 GWLog("GW.commentActionPostSucceeded");
528 retract: () => { commentItem.firstChild.addClass("retracted") },
529 unretract: () => { commentItem.firstChild.removeClass("retracted") },
531 commentItem.firstChild.outerHTML = "<div class=\"comment deleted-comment\"><div class=\"comment-meta\"><span class=\"deleted-meta\">[ ]</span></div><div class=\"comment-body\">[deleted]</div></div>";
532 commentItem.removeChild(commentItem.query(".comment-controls"));
536 if(action != "delete")
537 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
546 function parseVoteType(voteType) {
547 GWLog("parseVoteType");
549 if (!voteType) return value;
550 value.up = /[Uu]pvote$/.test(voteType);
551 value.down = /[Dd]ownvote$/.test(voteType);
552 value.big = /^big/.test(voteType);
556 function makeVoteType(value) {
557 GWLog("makeVoteType");
558 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
561 function makeVoteClass(vote) {
562 GWLog("makeVoteClass");
563 if (vote.up || vote.down) {
564 return (vote.big ? 'selected big-vote' : 'selected');
570 function findVoteControls(targetType, targetId, voteAxis) {
571 var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
573 if(targetType == "Post") {
574 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
575 } else if(targetType == "Comment") {
576 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
580 function votesEqual(vote1, vote2) {
581 var allKeys = Object.assign({}, vote1);
582 Object.assign(allKeys, vote2);
584 for(k of allKeys.keys()) {
585 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
590 function addVoteButtons(element, vote, targetType) {
591 GWLog("addVoteButtons");
593 let voteAxis = element.parentElement.dataset.voteAxis || "karma";
594 let voteType = parseVoteType(vote[voteAxis]);
595 let voteClass = makeVoteClass(voteType);
597 element.parentElement.queryAll("button").forEach((button) => {
598 button.disabled = false;
600 if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
601 button.addClass(voteClass);
603 updateVoteButtonVisualState(button);
604 button.addActivateEvent(voteButtonClicked);
608 function updateVoteButtonVisualState(button) {
609 GWLog("updateVoteButtonVisualState");
611 button.removeClasses([ "none", "one", "two-temp", "two" ]);
614 button.addClass("none");
615 else if (button.hasClass("big-vote"))
616 button.addClass("two");
617 else if (button.hasClass("selected"))
618 button.addClass("one");
620 button.addClass("none");
623 function changeVoteButtonVisualState(button) {
624 GWLog("changeVoteButtonVisualState");
626 /* Interaction states are:
628 0 0· (neutral; +1 click)
629 1 1· (small vote; +1 click)
630 2 2· (big vote; +1 click)
632 Visual states are (with their state classes in [brackets]) are:
635 02 (small vote active) [one]
636 12 (small vote active, temporary indicator of big vote) [two-temp]
637 22 (big vote active) [two]
639 The following are the 9 possible interaction state transitions (and
640 the visual state transitions associated with them):
643 FROM TO FROM TO NOTES
644 ==== ==== ==== ==== =====
645 0 0· 01 12 first click
646 0· 1 12 02 one click without second
647 0· 2 12 22 second click
649 1 1· 02 12 first click
650 1· 0 12 01 one click without second
651 1· 2 12 22 second click
653 2 2· 22 12 first click
654 2· 1 12 02 one click without second
655 2· 0 12 01 second click
658 [ "big-vote two-temp clicked-twice", "none" ], // 2· => 0
659 [ "big-vote two-temp clicked-once", "one" ], // 2· => 1
660 [ "big-vote clicked-once", "two-temp" ], // 2 => 2·
662 [ "selected two-temp clicked-twice", "two" ], // 1· => 2
663 [ "selected two-temp clicked-once", "none" ], // 1· => 0
664 [ "selected clicked-once", "two-temp" ], // 1 => 1·
666 [ "two-temp clicked-twice", "two" ], // 0· => 2
667 [ "two-temp clicked-once", "one" ], // 0· => 1
668 [ "clicked-once", "two-temp" ], // 0 => 0·
670 for (let [ interactionClasses, visualStateClass ] of transitions) {
671 if (button.hasClasses(interactionClasses.split(" "))) {
672 button.removeClasses([ "none", "one", "two-temp", "two" ]);
673 button.addClass(visualStateClass);
679 function voteCompleteEvent(targetType, targetId, response) {
680 GWLog("voteCompleteEvent");
682 var currentVote = voteData[targetType][targetId] || {};
683 var desiredVote = voteDesired[targetType][targetId];
685 var controls = findVoteControls(targetType, targetId);
686 var controlsByAxis = new Object;
688 controls.forEach(control => {
689 const voteAxis = (control.dataset.voteAxis || "karma");
691 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
692 control.removeClass("waiting");
693 control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
696 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
697 controlsByAxis[voteAxis].push(control);
699 const voteType = currentVote[voteAxis];
700 const vote = parseVoteType(voteType);
701 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
702 const voteClass = makeVoteClass(vote);
704 if (response && response[voteAxis]) {
705 const [voteType, displayText, titleText] = response[voteAxis];
707 const displayTarget = control.query(".karma-value");
708 if (displayTarget.hasClass("redacted")) {
709 displayTarget.dataset["trueValue"] = displayText;
711 displayTarget.innerHTML = displayText;
713 displayTarget.setAttribute("title", titleText);
716 control.queryAll("button.vote").forEach(button => {
717 updateVoteButton(button, voteUpDown, voteClass);
722 function updateVoteButton(button, voteUpDown, voteClass) {
723 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
724 if (button.dataset.voteType == voteUpDown)
725 button.addClass(voteClass);
726 updateVoteButtonVisualState(button);
729 function makeVoteRequestCompleteEvent(targetType, targetId) {
731 var currentVote = {};
734 if (event.target.status == 200) {
735 response = JSON.parse(event.target.responseText);
736 for (const voteAxis of response.keys()) {
737 currentVote[voteAxis] = response[voteAxis][0];
739 voteData[targetType][targetId] = currentVote;
741 delete voteDesired[targetType][targetId];
742 currentVote = voteData[targetType][targetId];
745 var desiredVote = voteDesired[targetType][targetId];
747 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
748 sendVoteRequest(targetType, targetId);
750 delete voteDesired[targetType][targetId];
751 voteCompleteEvent(targetType, targetId, response);
756 function sendVoteRequest(targetType, targetId) {
757 GWLog("sendVoteRequest");
761 location: "/karma-vote",
762 params: { "target": targetId,
763 "target-type": targetType,
764 "vote": JSON.stringify(voteDesired[targetType][targetId]) },
765 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
769 function voteButtonClicked(event) {
770 GWLog("voteButtonClicked");
771 let voteButton = event.target;
773 // 500 ms (0.5 s) double-click timeout.
774 let doubleClickTimeout = 500;
776 if (!voteButton.clickedOnce) {
777 voteButton.clickedOnce = true;
778 voteButton.addClass("clicked-once");
779 changeVoteButtonVisualState(voteButton);
781 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
782 if (!voteButton.clickedOnce) return;
784 // Do single-click code.
785 voteButton.clickedOnce = false;
786 voteEvent(voteButton, 1);
787 }, doubleClickTimeout, voteButton);
789 voteButton.clickedOnce = false;
791 // Do double-click code.
792 voteButton.removeClass("clicked-once");
793 voteButton.addClass("clicked-twice");
794 voteEvent(voteButton, 2);
798 function voteEvent(voteButton, numClicks) {
802 let voteControl = voteButton.parentNode;
804 let targetType = voteButton.dataset.targetType;
805 let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
806 let voteAxis = voteControl.dataset.voteAxis || "karma";
807 let voteUpDown = voteButton.dataset.voteType;
810 if ( (numClicks == 2 && voteButton.hasClass("big-vote"))
811 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
812 voteType = "neutral";
814 let vote = parseVoteType(voteUpDown);
815 vote.big = (numClicks == 2);
816 voteType = makeVoteType(vote);
819 let voteControls = findVoteControls(targetType, targetId, voteAxis);
820 for (const voteControl of voteControls) {
821 voteControl.addClass("waiting");
822 voteControl.queryAll(".vote").forEach(button => {
823 button.addClass("waiting");
824 updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
828 let voteRequestPending = voteDesired[targetType][targetId];
829 let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
830 voteObject[voteAxis] = voteType;
831 voteDesired[targetType][targetId] = voteObject;
833 if (!voteRequestPending) sendVoteRequest(targetType, targetId);
836 function initializeVoteButtons() {
837 // Color the upvote/downvote buttons with an embedded style sheet.
838 query("head").insertAdjacentHTML("beforeend", "<style id='vote-buttons'>" + `
840 --GW-upvote-button-color: #00d800;
841 --GW-downvote-button-color: #eb4c2a;
845 function processVoteData(voteData) {
846 window.voteData = voteData;
848 window.voteDesired = new Object;
849 for(key of voteData.keys()) {
850 voteDesired[key] = new Object;
853 initializeVoteButtons();
855 addTriggerListener("postLoaded", {priority: 3000, fn: () => {
856 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
857 let postID = karmaValue.parentNode.dataset.postId;
858 addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
859 karmaValue.parentElement.addClass("active-controls");
863 addTriggerListener("DOMReady", {priority: 3000, fn: () => {
864 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
865 let commentID = karmaValue.getCommentId();
866 addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
867 karmaValue.parentElement.addClass("active-controls");
872 /*****************************************/
873 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
874 /*****************************************/
876 Element.prototype.getCommentDate = function() {
877 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
878 let dateElement = item && item.query(".date");
879 return (dateElement && parseInt(dateElement.dataset["jsDate"]));
881 function getCurrentVisibleComment() {
882 let px = window.innerWidth/2, py = 5;
883 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
884 let bottomBar = query("#bottom-bar");
885 let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
886 let atbottom = bottomOffset <= window.innerHeight;
888 let hashci = location.hash && query(location.hash);
889 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
890 commentItem = hashci;
896 function highlightCommentsSince(date) {
897 GWLog("highlightCommentsSince");
898 var newCommentsCount = 0;
899 GW.newComments = [ ];
900 let oldCommentsStack = [ ];
902 queryAll(".comment-item").forEach(commentItem => {
903 commentItem.prevNewComment = prevNewComment;
904 commentItem.nextNewComment = null;
905 if (commentItem.getCommentDate() > date) {
906 commentItem.addClass("new-comment");
908 GW.newComments.push(commentItem.getCommentId());
909 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
910 oldCommentsStack = [ commentItem ];
911 prevNewComment = commentItem;
913 commentItem.removeClass("new-comment");
914 oldCommentsStack.push(commentItem);
918 GW.newCommentScrollSet = (commentItem) => {
919 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
920 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
922 GW.newCommentScrollListener = () => {
923 let commentItem = getCurrentVisibleComment();
924 GW.newCommentScrollSet(commentItem);
927 addScrollListener(GW.newCommentScrollListener);
929 if (document.readyState=="complete") {
930 GW.newCommentScrollListener();
932 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
933 GW.newCommentScrollSet(commentItem);
936 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
938 return newCommentsCount;
941 function scrollToNewComment(next) {
942 GWLog("scrollToNewComment");
943 let commentItem = getCurrentVisibleComment();
944 let targetComment = null;
945 let targetCommentID = null;
947 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
949 targetCommentID = targetComment.getCommentId();
952 if (GW.newComments[0]) {
953 targetCommentID = GW.newComments[0];
954 targetComment = query("#comment-" + targetCommentID);
958 expandAncestorsOf(targetCommentID);
959 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
960 targetComment.scrollIntoView();
963 GW.newCommentScrollListener();
966 function getPostHash() {
967 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
968 return (postHash ? postHash[1] : false);
970 function setHistoryLastVisitedDate(date) {
971 window.history.replaceState({ lastVisited: date }, null);
973 function getLastVisitedDate() {
974 // Get the last visited date (or, if posting a comment, the previous last visited date).
975 if(window.history.state) return (window.history.state||{})['lastVisited'];
976 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
977 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
978 let currentVisited = localStorage.getItem(storageName);
979 setHistoryLastVisitedDate(currentVisited);
980 return currentVisited;
982 function setLastVisitedDate(date) {
983 GWLog("setLastVisitedDate");
984 // If NOT posting a comment, save the previous value for the last-visited-date
985 // (to recover it in case of posting a comment).
986 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
987 if (!aCommentHasJustBeenPosted) {
988 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
989 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
992 // Set the new value.
993 localStorage.setItem("last-visited-date_" + getPostHash(), date);
996 function updateSavedCommentCount() {
997 GWLog("updateSavedCommentCount");
998 let commentCount = queryAll(".comment").length;
999 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
1001 function badgePostsWithNewComments() {
1002 GWLog("badgePostsWithNewComments");
1003 if (getQueryVariable("show") == "conversations") return;
1005 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
1006 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
1008 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
1009 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1010 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1012 if (currentCommentCount > savedCommentCount)
1013 commentCountDisplay.addClass("new-comments");
1015 commentCountDisplay.removeClass("new-comments");
1016 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1020 /***********************************/
1021 /* CONTENT COLUMN WIDTH ADJUSTMENT */
1022 /***********************************/
1024 function injectContentWidthSelector() {
1025 GWLog("injectContentWidthSelector");
1026 // Get saved width setting (or default).
1027 let currentWidth = localStorage.getItem("selected-width") || 'normal';
1029 // Inject the content width selector widget and activate buttons.
1030 let widthSelector = addUIElement(
1031 "<div id='width-selector'>" +
1032 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
1033 let [name, desc, abbr] = widthOption;
1034 let selected = (name == currentWidth ? ' selected' : '');
1035 let disabled = (name == currentWidth ? ' disabled' : '');
1036 return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
1038 widthSelector.queryAll("button").forEach(button => {
1039 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
1040 GWLog("GW.widthAdjustButtonClicked");
1042 // Determine which setting was chosen (i.e., which button was clicked).
1043 let selectedWidth = event.target.dataset.name;
1045 // Save the new setting.
1046 if (selectedWidth == "normal") localStorage.removeItem("selected-width");
1047 else localStorage.setItem("selected-width", selectedWidth);
1049 // Save current visible comment
1050 let visibleComment = getCurrentVisibleComment();
1052 // Actually change the content width.
1053 setContentWidth(selectedWidth);
1054 event.target.parentElement.childNodes.forEach(button => {
1055 button.removeClass("selected");
1056 button.disabled = false;
1058 event.target.addClass("selected");
1059 event.target.disabled = true;
1061 // Make sure the accesskey (to cycle to the next width) is on the right button.
1062 setWidthAdjustButtonsAccesskey();
1064 // Regenerate images overlay.
1065 generateImagesOverlay();
1067 if(visibleComment) visibleComment.scrollIntoView();
1071 // Make sure the accesskey (to cycle to the next width) is on the right button.
1072 setWidthAdjustButtonsAccesskey();
1074 // Inject transitions CSS, if animating changes is enabled.
1075 if (GW.adjustmentTransitions) {
1077 "<style id='width-transition'>" +
1079 #ui-elements-container,
1082 max-width 0.3s ease;
1087 function setWidthAdjustButtonsAccesskey() {
1088 GWLog("setWidthAdjustButtonsAccesskey");
1089 let widthSelector = query("#width-selector");
1090 widthSelector.queryAll("button").forEach(button => {
1091 button.removeAttribute("accesskey");
1092 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1094 let selectedButton = widthSelector.query("button.selected");
1095 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
1096 nextButtonInCycle.accessKey = "'";
1097 nextButtonInCycle.title += ` [\']`;
1100 /*******************/
1101 /* THEME SELECTION */
1102 /*******************/
1104 function injectThemeSelector() {
1105 GWLog("injectThemeSelector");
1106 let currentTheme = readCookie("theme") || "default";
1107 let themeSelector = addUIElement(
1108 "<div id='theme-selector' class='theme-selector'>" +
1109 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
1110 let [name, desc, letter] = themeOption;
1111 let selected = (name == currentTheme ? ' selected' : '');
1112 let disabled = (name == currentTheme ? ' disabled' : '');
1113 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1114 return `<button type='button' class='select-theme-${name}${selected}'${disabled} title="${desc} [${accesskey}]" data-theme-name="${name}" data-theme-description="${desc}" accesskey='${accesskey}' tabindex='-1'>${letter}</button>`;})) +
1116 themeSelector.queryAll("button").forEach(button => {
1117 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
1118 GWLog("GW.themeSelectButtonClicked");
1119 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
1120 setSelectedTheme(themeName);
1121 if (GW.isMobile) toggleAppearanceAdjustUI();
1125 // Inject transitions CSS, if animating changes is enabled.
1126 if (GW.adjustmentTransitions) {
1128 "<style id='theme-fade-transition'>" +
1131 opacity 0.5s ease-out,
1132 background-color 0.3s ease-out;
1135 background-color: #777;
1138 opacity 0.5s ease-in,
1139 background-color 0.3s ease-in;
1144 function setSelectedTheme(themeName) {
1145 GWLog("setSelectedTheme");
1146 queryAll(".theme-selector button").forEach(button => {
1147 button.removeClass("selected");
1148 button.disabled = false;
1150 queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
1151 button.addClass("selected");
1152 button.disabled = true;
1154 setTheme(themeName);
1155 query("#theme-tweaker-ui .current-theme span").innerText = themeName;
1157 function setTheme(newThemeName) {
1158 var themeUnloadCallback = '';
1159 var oldThemeName = '';
1160 if (typeof(newThemeName) == 'undefined') {
1161 newThemeName = readCookie('theme');
1162 if (!newThemeName) return;
1164 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
1165 oldThemeName = readCookie('theme') || 'default';
1167 if (newThemeName == 'default') setCookie('theme', '');
1168 else setCookie('theme', newThemeName);
1170 if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
1172 let makeNewStyle = function(newThemeName, colorSchemePreference) {
1173 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
1174 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1176 let newStyle = document.createElement('link');
1177 newStyle.setAttribute('class', 'theme');
1178 if(colorSchemePreference)
1179 newStyle.setAttribute('media', '(prefers-color-scheme: ' + colorSchemePreference + ')');
1180 newStyle.setAttribute('rel', 'stylesheet');
1181 newStyle.setAttribute('href', '/css/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1185 let newMainStyle, newStyles;
1186 if(newThemeName === 'default') {
1187 newStyles = [makeNewStyle('dark', 'dark'), makeNewStyle('default', 'light')];
1188 newMainStyle = (window.matchMedia('prefers-color-scheme: dark').matches ? newStyles[0] : newStyles[1]);
1190 newStyles = [makeNewStyle(newThemeName)];
1191 newMainStyle = newStyles[0];
1194 let oldStyles = queryAll("head link.theme");
1195 newMainStyle.addEventListener('load', () => { oldStyles.forEach(x => removeElement(x)); });
1196 newMainStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
1198 if (GW.adjustmentTransitions) {
1199 pageFadeTransition(false);
1201 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1204 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1207 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
1208 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1209 document.body.addClass("theme-" + newThemeName);
1211 recomputeUIElementsContainerHeight(true);
1213 let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
1214 if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
1216 recomputeUIElementsContainerHeight();
1217 adjustUIForWindowSize();
1218 window.addEventListener('resize', GW.windowResized = (event) => {
1219 GWLog("GW.windowResized");
1220 adjustUIForWindowSize();
1221 recomputeUIElementsContainerHeight();
1224 generateImagesOverlay();
1226 if (window.adjustmentTransitions) pageFadeTransition(true);
1227 updateThemeTweakerSampleText();
1229 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
1230 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1234 function pageFadeTransition(fadeIn) {
1236 query("body").removeClass("transparent");
1238 query("body").addClass("transparent");
1242 GW.themeLoadCallback_less = (fromTheme = "") => {
1243 GWLog("themeLoadCallback_less");
1244 injectSiteNavUIToggle();
1246 injectPostNavUIToggle();
1247 injectAppearanceAdjustUIToggle();
1250 registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1251 let dtf = new Intl.DateTimeFormat([],
1252 (window.innerWidth < 1100) ?
1253 { month: 'short', day: 'numeric', year: 'numeric' } :
1254 { month: 'long', day: 'numeric', year: 'numeric' });
1255 let postDate = query(".top-post-meta .date");
1256 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1260 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1264 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1265 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1266 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1270 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1271 // If state is not set (user has never clicked on the Less theme's appearance
1272 // adjustment UI toggle) then show it, but then hide it after a short time.
1273 registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1274 toggleAppearanceAdjustUI();
1275 setTimeout(toggleAppearanceAdjustUI, 3000);
1279 if (fromTheme != "") {
1280 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1281 setTimeout(function () {
1282 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1284 setTimeout(function () {
1285 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1289 // Unset the height of the #ui-elements-container.
1290 query("#ui-elements-container").style.height = "";
1292 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1293 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1294 "#content #secondary-bar",
1295 "#content .post .top-post-meta .date",
1296 "#content .post .top-post-meta .comment-count",
1298 applyFilters(GW.currentFilters);
1301 // We pre-query the relevant elements, so we don't have to run querySelectorAll
1302 // on every firing of the scroll listener.
1304 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1305 "unbrokenDownScrollDistance": 0,
1306 "unbrokenUpScrollDistance": 0,
1307 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1308 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1309 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1311 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1314 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
1315 // otherwise, show it.
1316 function updatePostNavUIVisibility() {
1317 GWLog("updatePostNavUIVisibility");
1318 var hidePostNavUIToggle = true;
1319 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1320 if (getComputedStyle(element).visibility == "visible" ||
1321 element.style.visibility == "visible" ||
1322 element.style.visibility == "unset")
1323 hidePostNavUIToggle = false;
1325 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1326 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1330 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1331 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
1332 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1333 function updateSiteNavUIState(event) {
1334 GWLog("updateSiteNavUIState");
1335 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1336 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
1337 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
1339 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1340 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1342 GW.scrollState.lastScrollTop = newScrollTop;
1344 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1345 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1346 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1347 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1350 // On mobile, make site nav UI translucent on ANY scroll down.
1352 GW.scrollState.siteNavUIElements.forEach(element => {
1353 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1354 else element.removeClass("translucent-on-scroll");
1357 // Show site nav UI when scrolling a full page up, or to the top.
1358 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
1359 GW.scrollState.lastScrollTop == 0) &&
1360 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
1361 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1363 // On desktop, show appearance adjust UI when scrolling to the top.
1364 if ((!GW.isMobile) &&
1365 (GW.scrollState.lastScrollTop == 0) &&
1366 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
1367 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1370 GW.themeUnloadCallback_less = (toTheme = "") => {
1371 GWLog("themeUnloadCallback_less");
1372 removeSiteNavUIToggle();
1374 removePostNavUIToggle();
1375 removeAppearanceAdjustUIToggle();
1377 window.removeEventListener('resize', updatePostNavUIVisibility);
1379 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1381 removeElement("#theme-less-mobile-first-row-placeholder");
1385 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1386 element.innerHTML = element.firstChild.innerHTML;
1390 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1392 // Reset filtered elements selector to default.
1393 delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1394 applyFilters(GW.currentFilters);
1397 GW.themeLoadCallback_dark = (fromTheme = "") => {
1398 GWLog("themeLoadCallback_dark");
1400 "<style id='dark-theme-adjustments'>" +
1401 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` +
1402 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1404 registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1405 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1406 image.style.filter = "drop-shadow(0 0 0 #000) drop-shadow(0 0 0.5px #fff) drop-shadow(0 0 1px #fff) drop-shadow(0 0 2px #fff)";
1407 image.style.width = parseInt(image.style.width) + 12 + "px";
1408 image.style.height = parseInt(image.style.height) + 12 + "px";
1409 image.style.top = parseInt(image.style.top) - 6 + "px";
1410 image.style.left = parseInt(image.style.left) - 6 + "px";
1414 GW.themeUnloadCallback_dark = (toTheme = "") => {
1415 GWLog("themeUnloadCallback_dark");
1416 removeElement("#dark-theme-adjustments");
1419 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1420 GWLog("themeLoadCallback_brutalist");
1421 let bottomBarLinks = queryAll("#bottom-bar a");
1422 if (!GW.isMobile && bottomBarLinks.length == 5) {
1423 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1424 bottomBarLinks.forEach((link, i) => {
1425 link.dataset.originalText = link.textContent;
1426 link.textContent = newLinkTexts[i];
1430 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1431 GWLog("themeUnloadCallback_brutalist");
1432 let bottomBarLinks = queryAll("#bottom-bar a");
1433 if (!GW.isMobile && bottomBarLinks.length == 5) {
1434 bottomBarLinks.forEach(link => {
1435 link.textContent = link.dataset.originalText;
1440 GW.themeLoadCallback_classic = (fromTheme = "") => {
1441 GWLog("themeLoadCallback_classic");
1442 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1443 button.innerHTML = "";
1446 GW.themeUnloadCallback_classic = (toTheme = "") => {
1447 GWLog("themeUnloadCallback_classic");
1448 if (GW.isMobile && window.innerWidth <= 900) return;
1449 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1450 button.innerHTML = button.dataset.label;
1454 /********************************************/
1455 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1456 /********************************************/
1458 function injectThemeTweaker() {
1459 GWLog("injectThemeTweaker");
1460 let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" +
1461 `<div class='main-theme-tweaker-window'>
1462 <h1>Customize appearance</h1>
1463 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1464 <button type='button' class='help-button' tabindex='-1'></button>
1465 <p class='current-theme'>Current theme: <span>` +
1466 (readCookie("theme") || "default") +
1468 <p class='theme-selector'></p>
1469 <div class='controls-container'>
1470 <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1471 <div class='sample-text-container'><span class='sample-text'>
1472 <p>Less Wrong (text)</p>
1473 <p><a href="#">Less Wrong (link)</a></p>
1476 <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1477 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1478 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1479 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1481 <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1482 <input type='checkbox' id='theme-tweak-control-invert'></input>
1483 <label for='theme-tweak-control-invert'>Invert colors</label>
1485 <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1486 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1487 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1488 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1490 <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1491 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1492 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1493 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1495 <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1496 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1497 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1498 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1500 <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1501 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1502 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1503 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1506 <div class='buttons-container'>
1507 <button type="button" class="reset-defaults-button">Reset to defaults</button>
1508 <button type='button' class='ok-button default-button'>OK</button>
1509 <button type='button' class='cancel-button'>Cancel</button>
1512 <div class="clippy-container">
1513 <span class="hint">Hi, I'm Bobby the Basilisk! Click on the minimize button (<img src='data:image/gif;base64,R0lGODlhFgAUAPIAMQAAAAMDA394f7+4v9/Y3//4/2JX/38AACwAAAAAFgAUAAADRki63B6kyEkrFbCMzbvnWPSNXqiRqImm2Uqq7gfH3Uxv9p3TNuD/wFqLAywChCKi8Yc83XDD52AXCwmu2KxWG+h6v+BwNwEAOw==' />) to minimize the theme tweaker window, so that you can see what the page looks like with the current tweaked values. (But remember, <span>the changes won't be saved until you click "OK"!</span>)
1514 <div class='clippy'></div>
1515 <button type='button' class='clippy-close-button' tabindex='-1' title='Hide theme tweaker assistant (you can bring him back by clicking the ? button in the title bar)'></button>
1517 <div class='help-window' style='display: none;'>
1518 <h1>Theme tweaker help</h1>
1519 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1520 <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1521 <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1523 <div class='buttons-container'>
1524 <button type='button' class='ok-button default-button'>OK</button>
1525 <button type='button' class='cancel-button'>Cancel</button>
1530 // Clicking the background overlay closes the theme tweaker.
1531 themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1532 GWLog("GW.themeTweaker.UIOverlayClicked");
1533 if (event.type == 'mousedown') {
1534 themeTweakerUI.style.opacity = "0.01";
1536 toggleThemeTweakerUI();
1537 themeTweakerUI.style.opacity = "1.0";
1542 // Intercept clicks, so they don't "fall through" the background overlay.
1543 (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1545 let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1546 themeTweakerUI.queryAll("input").forEach(field => {
1547 // All input types in the theme tweaker receive a 'change' event when
1548 // their value is changed. (Range inputs, in particular, receive this
1549 // event when the user lets go of the handle.) This means we should
1550 // update the filters for the entire page, to match the new setting.
1551 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1552 GWLog("GW.themeTweaker.fieldValueChanged");
1553 if (event.target.id == 'theme-tweak-control-invert') {
1554 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1555 } else if (event.target.type == 'range') {
1556 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1557 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1558 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1559 } else if (event.target.id == 'theme-tweak-control-clippy') {
1560 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1562 // Clear the sample text filters.
1563 sampleTextContainer.style.filter = "";
1564 // Apply the new filters globally.
1565 applyFilters(GW.currentFilters);
1568 // Range inputs receive an 'input' event while being scrubbed, updating
1569 // "live" as the handle is moved. We don't want to change the filters
1570 // for the actual page while this is happening, but we do want to change
1571 // the filters for the *sample text*, so the user can see what effects
1572 // his changes are having, live, without having to let go of the handle.
1573 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1574 GWLog("GW.themeTweaker.fieldInputReceived");
1575 var sampleTextFilters = GW.currentFilters;
1577 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1578 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1579 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1581 sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1585 themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1586 GWLog("GW.themeTweaker.minimizeButtonClicked");
1587 let themeTweakerStyle = query("#theme-tweaker-style");
1589 if (event.target.hasClass("minimize")) {
1590 event.target.removeClass("minimize");
1591 themeTweakerStyle.innerHTML =
1592 `#theme-tweaker-ui .main-theme-tweaker-window {
1596 padding: 30px 0 0 0;
1601 #theme-tweaker-ui::after {
1605 #theme-tweaker-ui::before {
1609 #theme-tweaker-ui .clippy-container {
1612 #theme-tweaker-ui .clippy-container .hint span {
1618 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1619 pointer-events: none;
1621 event.target.addClass("maximize");
1623 event.target.removeClass("maximize");
1624 themeTweakerStyle.innerHTML =
1625 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1626 pointer-events: none;
1628 event.target.addClass("minimize");
1631 themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1632 GWLog("GW.themeTweaker.helpButtonClicked");
1633 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1634 toggleThemeTweakerHelpWindow();
1636 themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1637 GWLog("GW.themeTweaker.resetDefaultsButtonClicked");
1638 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1639 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1640 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1641 slider.value = slider.dataset['defaultValue'];
1642 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1644 GW.currentFilters = { };
1645 applyFilters(GW.currentFilters);
1647 GW.currentTextZoom = "1.0";
1648 setTextZoom(GW.currentTextZoom);
1650 setSelectedTheme("default");
1652 themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1653 GWLog("GW.themeTweaker.cancelButtonClicked");
1654 toggleThemeTweakerUI();
1657 themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1658 GWLog("GW.themeTweaker.OKButtonClicked");
1659 toggleThemeTweakerUI();
1662 themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1663 GWLog("GW.themeTweaker.helpWindowCancelButtonClicked");
1664 toggleThemeTweakerHelpWindow();
1665 themeTweakerResetSettings();
1667 themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1668 GWLog("GW.themeTweaker.helpWindowOKButtonClicked");
1669 toggleThemeTweakerHelpWindow();
1670 themeTweakerSaveSettings();
1673 themeTweakerUI.queryAll(".notch").forEach(notch => {
1674 notch.addActivateEvent(GW.themeTweaker.sliderNotchClicked = (event) => {
1675 GWLog("GW.themeTweaker.sliderNotchClicked");
1676 let slider = event.target.parentElement.query("input[type='range']");
1677 slider.value = slider.dataset['defaultValue'];
1678 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1679 GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1680 applyFilters(GW.currentFilters);
1684 themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1685 GWLog("GW.themeTweaker.clippyCloseButtonClicked");
1686 themeTweakerUI.query(".clippy-container").style.display = "none";
1687 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1688 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1691 query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1693 themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1694 themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1695 button.addActivateEvent(GW.themeSelectButtonClicked);
1698 themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1699 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1702 let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'></button></div>`);
1703 themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1704 GWLog("GW.themeTweaker.toggleButtonClicked");
1705 GW.themeTweakerStyleSheetAvailable = () => {
1706 GWLog("GW.themeTweakerStyleSheetAvailable");
1707 themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1709 themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1710 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1711 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1712 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1713 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1716 toggleThemeTweakerUI();
1717 event.target.disabled = true;
1720 if (query("link[href^='/css/theme_tweaker.css']")) {
1721 // Theme tweaker CSS is already loaded.
1722 GW.themeTweakerStyleSheetAvailable();
1724 // Load the theme tweaker CSS (if not loaded).
1725 let themeTweakerStyleSheet = document.createElement('link');
1726 themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1727 themeTweakerStyleSheet.setAttribute('href', '/css/theme_tweaker.css');
1728 themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1729 query("head").appendChild(themeTweakerStyleSheet);
1733 function toggleThemeTweakerUI() {
1734 GWLog("toggleThemeTweakerUI");
1735 let themeTweakerUI = query("#theme-tweaker-ui");
1736 themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1737 query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" :
1738 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1739 pointer-events: none;
1741 if (themeTweakerUI.style.display != "none") {
1742 // Save selected theme.
1743 GW.currentTheme = (readCookie("theme") || "default");
1744 // Focus invert checkbox.
1745 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1746 // Show sample text in appropriate font.
1747 updateThemeTweakerSampleText();
1748 // Disable tab-selection of the search box.
1749 setSearchBoxTabSelectable(false);
1750 // Disable scrolling of the page.
1751 togglePageScrolling(false);
1753 query("#theme-tweaker-toggle button").disabled = false;
1754 // Re-enable tab-selection of the search box.
1755 setSearchBoxTabSelectable(true);
1756 // Re-enable scrolling of the page.
1757 togglePageScrolling(true);
1759 // Set theme tweaker assistant visibility.
1760 query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1762 function setSearchBoxTabSelectable(selectable) {
1763 GWLog("setSearchBoxTabSelectable");
1764 query("input[type='search']").tabIndex = selectable ? "" : "-1";
1765 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1767 function toggleThemeTweakerHelpWindow() {
1768 GWLog("toggleThemeTweakerHelpWindow");
1769 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1770 themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1771 if (themeTweakerHelpWindow.style.display != "none") {
1772 // Focus theme tweaker assistant checkbox.
1773 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1774 // Disable interaction on main theme tweaker window.
1775 query("#theme-tweaker-ui").style.pointerEvents = "none";
1776 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1778 // Re-enable interaction on main theme tweaker window.
1779 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1780 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1783 function themeTweakReset() {
1784 GWLog("themeTweakReset");
1785 setSelectedTheme(GW.currentTheme);
1786 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1787 applyFilters(GW.currentFilters);
1788 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1789 setTextZoom(GW.currentTextZoom);
1791 function themeTweakSave() {
1792 GWLog("themeTweakSave");
1793 GW.currentTheme = (readCookie("theme") || "default");
1794 localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1795 localStorage.setItem("text-zoom", GW.currentTextZoom);
1798 function themeTweakerResetSettings() {
1799 GWLog("themeTweakerResetSettings");
1800 query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1801 query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1803 function themeTweakerSaveSettings() {
1804 GWLog("themeTweakerSaveSettings");
1805 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1807 function updateThemeTweakerSampleText() {
1808 GWLog("updateThemeTweakerSampleText");
1809 let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1811 // This causes the sample text to take on the properties of the body text of a post.
1812 sampleText.removeClass("body-text");
1813 let bodyTextElement = query(".post-body") || query(".comment-body");
1814 sampleText.addClass("body-text");
1815 sampleText.style.color = bodyTextElement ?
1816 getComputedStyle(bodyTextElement).color :
1817 getComputedStyle(query("#content")).color;
1819 // Here we find out what is the actual background color that will be visible behind
1820 // the body text of posts, and set the sample text’s background to that.
1821 let findStyleBackground = (selector) => {
1823 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
1824 if(rule.selectorText == selector)
1827 return x.style.backgroundColor;
1830 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
1833 /*********************/
1834 /* PAGE QUICK-NAV UI */
1835 /*********************/
1837 function injectQuickNavUI() {
1838 GWLog("injectQuickNavUI");
1839 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1840 `<a href='#top' title="Up to top [,]" accesskey=','></a>
1841 <a href='#comments' title="Comments [/]" accesskey='/'></a>
1842 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
1846 /**********************/
1847 /* NEW COMMENT NAV UI */
1848 /**********************/
1850 function injectNewCommentNavUI(newCommentsCount) {
1851 GWLog("injectNewCommentNavUI");
1852 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
1853 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
1854 <span class='new-comments-count'></span>
1855 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
1858 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1859 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1860 GWLog("GW.commentQuicknavButtonClicked");
1861 scrollToNewComment(/next/.test(event.target.className));
1862 event.target.blur();
1866 document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => {
1867 GWLog("GW.commentQuicknavKeyPressed");
1868 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1869 if (event.key == ",") scrollToNewComment(false);
1870 if (event.key == ".") scrollToNewComment(true)
1873 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1874 + `<span>Since:</span>`
1875 + `<input type='text' class='hns-date'></input>`
1878 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1879 GWLog("GW.hnsDatePickerValueChanged");
1880 let hnsDate = time_fromHuman(event.target.value);
1882 setHistoryLastVisitedDate(hnsDate);
1883 let newCommentsCount = highlightCommentsSince(hnsDate);
1884 updateNewCommentNavUI(newCommentsCount);
1888 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1889 GWLog("GW.newCommentsCountClicked");
1890 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1891 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1895 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1896 function time_fromHuman(string) {
1897 /* Convert a human-readable date into a JS timestamp */
1898 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1899 string = string.replace(' ', 'T'); // revert nice spacing
1900 string += ':00.000Z'; // complete ISO 8601 date
1901 time = Date.parse(string); // milliseconds since epoch
1903 // browsers handle ISO 8601 without explicit timezone differently
1904 // thus, we have to fix that by hand
1905 time += (new Date()).getTimezoneOffset() * 60e3;
1907 string = string.replace(' at', '');
1908 time = Date.parse(string); // milliseconds since epoch
1913 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1914 GWLog("updateNewCommentNavUI");
1915 // Update the new comments count.
1916 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1917 newCommentsCountLabel.innerText = newCommentsCount;
1918 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1920 // Update the date picker field.
1921 if (hnsDate != -1) {
1922 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1926 /***************************/
1927 /* TEXT SIZE ADJUSTMENT UI */
1928 /***************************/
1930 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1931 GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1932 var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1933 if (event.target.hasClass("decrease")) {
1934 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1935 } else if (event.target.hasClass("increase")) {
1936 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1940 setTextZoom(zoomFactor);
1941 GW.currentTextZoom = `${zoomFactor}`;
1943 if (event.target.parentElement.id == "text-size-adjustment-ui") {
1944 localStorage.setItem("text-zoom", GW.currentTextZoom);
1948 function injectTextSizeAdjustmentUIReal() {
1949 GWLog("injectTextSizeAdjustmentUIReal");
1950 let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1951 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1952 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1953 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1956 textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1957 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1960 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1963 function injectTextSizeAdjustmentUI() {
1964 GWLog("injectTextSizeAdjustmentUI");
1965 if (query("#text-size-adjustment-ui") != null) return;
1966 if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1967 else document.addEventListener("DOMContentLoaded", () => {
1968 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1972 /********************************/
1973 /* COMMENTS VIEW MODE SELECTION */
1974 /********************************/
1976 function injectCommentsViewModeSelector() {
1977 GWLog("injectCommentsViewModeSelector");
1978 let commentsContainer = query("#comments");
1979 if (commentsContainer == null) return;
1981 let currentModeThreaded = (location.href.search("chrono=t") == -1);
1982 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1984 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1985 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
1986 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
1989 // commentsViewModeSelector.queryAll("a").forEach(button => {
1990 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1993 if (!currentModeThreaded) {
1994 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1995 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1996 commentParentLink.addClass("inline-author");
1997 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2000 queryAll(".comment-child-links a").forEach(commentChildLink => {
2001 commentChildLink.textContent = commentChildLink.textContent.slice(1);
2002 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2005 rectifyChronoModeCommentChildLinks();
2007 commentsContainer.addClass("chrono");
2009 commentsContainer.addClass("threaded");
2012 // Remove extraneous top-level comment thread in chrono mode.
2013 let topLevelCommentThread = query("#comments > .comment-thread");
2014 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2017 // function commentsViewModeSelectorButtonClicked(event) {
2018 // event.preventDefault();
2021 // let request = new XMLHttpRequest();
2022 // request.open("GET", event.target.href);
2023 // request.onreadystatechange = () => {
2024 // if (request.readyState != 4) return;
2025 // newDocument = htmlToElement(request.response);
2027 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2029 // // Update the buttons.
2030 // event.target.addClass("selected");
2031 // event.target.parentElement.query("." + classes.old).removeClass("selected");
2033 // // Update the #comments container.
2034 // let commentsContainer = query("#comments");
2035 // commentsContainer.removeClass(classes.old);
2036 // commentsContainer.addClass(classes.new);
2038 // // Update the content.
2039 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2044 // function htmlToElement(html) {
2045 // var template = document.createElement('template');
2046 // template.innerHTML = html.trim();
2047 // return template.content;
2050 function rectifyChronoModeCommentChildLinks() {
2051 GWLog("rectifyChronoModeCommentChildLinks");
2052 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2053 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2054 let childLinks = commentChildLinksContainer.queryAll("a");
2055 childLinks.forEach((link, index) => {
2056 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2060 let childLinksArray = Array.from(childLinks)
2061 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2062 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2065 function childrenOfComment(commentID) {
2066 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2067 let commentParentLink = commentItem.query("a.comment-parent-link");
2068 return ((commentParentLink||{}).hash == "#" + commentID);
2072 /********************************/
2073 /* COMMENTS LIST MODE SELECTION */
2074 /********************************/
2076 function injectCommentsListModeSelector() {
2077 GWLog("injectCommentsListModeSelector");
2078 if (query("#content > .comment-thread") == null) return;
2080 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2081 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2082 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2085 if (query(".sublevel-nav") || query("#top-nav-bar")) {
2086 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2088 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2090 let commentsListModeSelector = query("#comments-list-mode-selector");
2092 commentsListModeSelector.queryAll("button").forEach(button => {
2093 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2094 GWLog("GW.commentsListModeSelectButtonClicked");
2095 event.target.parentElement.queryAll("button").forEach(button => {
2096 button.removeClass("selected");
2097 button.disabled = false;
2098 button.accessKey = '`';
2100 localStorage.setItem("comments-list-mode", event.target.className);
2101 event.target.addClass("selected");
2102 event.target.disabled = true;
2103 event.target.removeAttribute("accesskey");
2105 if (event.target.hasClass("expanded")) {
2106 query("#content").removeClass("compact");
2108 query("#content").addClass("compact");
2113 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2114 if (savedMode == "compact")
2115 query("#content").addClass("compact");
2116 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2117 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2118 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2121 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2122 commentParentLink.addActivateEvent(function (event) {
2123 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2124 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2130 /**********************/
2131 /* SITE NAV UI TOGGLE */
2132 /**********************/
2134 function injectSiteNavUIToggle() {
2135 GWLog("injectSiteNavUIToggle");
2136 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2137 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2138 GWLog("GW.siteNavUIToggleButtonClicked");
2140 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2143 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2145 function removeSiteNavUIToggle() {
2146 GWLog("removeSiteNavUIToggle");
2147 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2148 element.removeClass("engaged");
2150 removeElement("#site-nav-ui-toggle");
2152 function toggleSiteNavUI() {
2153 GWLog("toggleSiteNavUI");
2154 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2155 element.toggleClass("engaged");
2156 element.removeClass("translucent-on-scroll");
2160 /**********************/
2161 /* POST NAV UI TOGGLE */
2162 /**********************/
2164 function injectPostNavUIToggle() {
2165 GWLog("injectPostNavUIToggle");
2166 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2167 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2168 GWLog("GW.postNavUIToggleButtonClicked");
2170 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2173 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2175 function removePostNavUIToggle() {
2176 GWLog("removePostNavUIToggle");
2177 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2178 element.removeClass("engaged");
2180 removeElement("#post-nav-ui-toggle");
2182 function togglePostNavUI() {
2183 GWLog("togglePostNavUI");
2184 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2185 element.toggleClass("engaged");
2189 /*******************************/
2190 /* APPEARANCE ADJUST UI TOGGLE */
2191 /*******************************/
2193 function injectAppearanceAdjustUIToggle() {
2194 GWLog("injectAppearanceAdjustUIToggle");
2195 let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2196 appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
2197 GWLog("GW.appearanceAdjustUIToggleButtonClicked");
2198 toggleAppearanceAdjustUI();
2199 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
2203 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
2204 themeSelectorCloseButton.addClass("theme-selector-close-button");
2205 themeSelectorCloseButton.innerHTML = "";
2206 query("#theme-selector").appendChild(themeSelectorCloseButton);
2207 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
2209 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
2212 function removeAppearanceAdjustUIToggle() {
2213 GWLog("removeAppearanceAdjustUIToggle");
2214 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2215 element.removeClass("engaged");
2217 removeElement("#appearance-adjust-ui-toggle");
2219 function toggleAppearanceAdjustUI() {
2220 GWLog("toggleAppearanceAdjustUI");
2221 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2222 element.toggleClass("engaged");
2226 /**************************/
2227 /* WORD COUNT & READ TIME */
2228 /**************************/
2230 function toggleReadTimeOrWordCount(addWordCountClass) {
2231 GWLog("toggleReadTimeOrWordCount");
2232 queryAll(".post-meta .read-time").forEach(element => {
2233 if (addWordCountClass) element.addClass("word-count");
2234 else element.removeClass("word-count");
2236 let titleParts = /(\S+)(.+)$/.exec(element.title);
2237 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2241 /**************************/
2242 /* PROMPT TO SAVE CHANGES */
2243 /**************************/
2245 function enableBeforeUnload() {
2246 window.onbeforeunload = function () { return true; };
2248 function disableBeforeUnload() {
2249 window.onbeforeunload = null;
2252 /***************************/
2253 /* ORIGINAL POSTER BADGING */
2254 /***************************/
2256 function markOriginalPosterComments() {
2257 GWLog("markOriginalPosterComments");
2258 let postAuthor = query(".post .author");
2259 if (postAuthor == null) return;
2261 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2262 if (author.dataset.userid == postAuthor.dataset.userid ||
2263 (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2264 author.addClass("original-poster");
2265 author.title += "Original poster";
2270 /********************************/
2271 /* EDIT POST PAGE SUBMIT BUTTON */
2272 /********************************/
2274 function setEditPostPageSubmitButtonText() {
2275 GWLog("setEditPostPageSubmitButtonText");
2276 if (!query("#content").hasClass("edit-post-page")) return;
2278 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2279 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2280 GWLog("GW.postSectionSelectorValueChanged");
2281 updateEditPostPageSubmitButtonText();
2285 updateEditPostPageSubmitButtonText();
2287 function updateEditPostPageSubmitButtonText() {
2288 GWLog("updateEditPostPageSubmitButtonText");
2289 let submitButton = query("input[type='submit']");
2290 if (query("input#drafts").checked == true)
2291 submitButton.value = "Save Draft";
2292 else if (query(".posting-controls").hasClass("edit-existing-post"))
2293 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2295 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2302 function numToAlpha(n) {
2305 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2306 n = Math.floor((n / 26) - 1);
2311 function injectAntiKibitzer() {
2312 GWLog("injectAntiKibitzer");
2313 // Inject anti-kibitzer toggle controls.
2314 let antiKibitzerToggle = addUIElement("<div id='anti-kibitzer-toggle'><button type='button' tabindex='-1' accesskey='g' title='Toggle anti-kibitzer (show/hide authors & karma values) [g]'></button>");
2315 antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2316 GWLog("GW.antiKibitzerToggleButtonClicked");
2317 if (query("#anti-kibitzer-toggle").hasClass("engaged") &&
2319 !confirm("Are you sure you want to turn OFF the anti-kibitzer?\n\n(This will reveal the authors and karma values of all posts and comments!)")) {
2320 event.target.blur();
2324 toggleAntiKibitzerMode();
2325 event.target.blur();
2328 // Activate anti-kibitzer mode (if needed).
2329 if (localStorage.getItem("antikibitzer") == "true")
2330 toggleAntiKibitzerMode();
2332 // Remove temporary CSS that hides the authors and karma values.
2333 removeElement("#antikibitzer-temp");
2336 function toggleAntiKibitzerMode() {
2337 GWLog("toggleAntiKibitzerMode");
2338 // This will be the URL of the user's own page, if logged in, or the URL of
2339 // the login page otherwise.
2340 let userTabTarget = query("#nav-item-login .nav-inner").href;
2341 let pageHeadingElement = query("h1.page-main-heading");
2344 let userFakeName = { };
2346 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2348 let postAuthor = query(".post-page .post-meta .author");
2349 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2351 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2352 if (antiKibitzerToggle.hasClass("engaged")) {
2353 localStorage.setItem("antikibitzer", "false");
2355 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2356 if (redirectTarget) {
2357 window.location = redirectTarget;
2361 // Individual comment page title and header
2362 if (query(".individual-thread-page")) {
2363 let replacer = (node) => {
2365 node.firstChild.replaceWith(node.dataset["trueContent"]);
2367 replacer(query("title:not(.fake-title)"));
2368 replacer(query("#content > h1"));
2371 // Author names/links.
2372 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2373 author.textContent = author.dataset["trueName"];
2374 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2376 author.removeClass("redacted");
2378 // Post/comment karma values.
2379 queryAll(".karma-value.redacted").forEach(karmaValue => {
2380 karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2382 karmaValue.removeClass("redacted");
2384 // Link post domains.
2385 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2386 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2388 linkPostDomain.removeClass("redacted");
2391 antiKibitzerToggle.removeClass("engaged");
2393 localStorage.setItem("antikibitzer", "true");
2395 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2396 if (redirectTarget) {
2397 window.location = redirectTarget;
2401 // Individual comment page title and header
2402 if (query(".individual-thread-page")) {
2403 let replacer = (node) => {
2405 node.dataset["trueContent"] = node.firstChild.wholeText;
2406 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2407 node.firstChild.replaceWith(newText);
2409 replacer(query("title:not(.fake-title)"));
2410 replacer(query("#content > h1"));
2413 removeElement("title.fake-title");
2415 // Author names/links.
2416 queryAll(".author, .inline-author").forEach(author => {
2417 // Skip own posts/comments.
2418 if (author.hasClass("own-user-author"))
2421 let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2425 author.dataset["trueName"] = author.textContent;
2426 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2428 if (/\/user/.test(author.href)) {
2429 author.dataset["trueLink"] = author.pathname;
2430 author.href = "/user?id=" + author.dataset["userid"];
2433 author.addClass("redacted");
2435 // Post/comment karma values.
2436 queryAll(".karma-value").forEach(karmaValue => {
2437 // Skip own posts/comments.
2438 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2441 karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2442 karmaValue.innerHTML = "##<span> points</span>";
2444 karmaValue.addClass("redacted");
2446 // Link post domains.
2447 queryAll(".link-post-domain").forEach(linkPostDomain => {
2448 // Skip own posts/comments.
2449 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2452 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2453 linkPostDomain.textContent = "redacted.domain.tld";
2455 linkPostDomain.addClass("redacted");
2458 antiKibitzerToggle.addClass("engaged");
2462 /*******************************/
2463 /* COMMENT SORT MODE SELECTION */
2464 /*******************************/
2466 var CommentSortMode = Object.freeze({
2472 function sortComments(mode) {
2473 GWLog("sortComments");
2474 let commentsContainer = query("#comments");
2476 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2477 commentsContainer.addClass("sorting");
2479 GW.commentValues = { };
2480 let clonedCommentsContainer = commentsContainer.cloneNode(true);
2481 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2484 case CommentSortMode.NEW:
2485 comparator = (a,b) => commentDate(b) - commentDate(a);
2487 case CommentSortMode.OLD:
2488 comparator = (a,b) => commentDate(a) - commentDate(b);
2490 case CommentSortMode.HOT:
2491 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2493 case CommentSortMode.TOP:
2495 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2498 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2500 removeElement(commentsContainer.lastChild);
2501 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2502 GW.commentValues = { };
2504 if (loggedInUserId) {
2505 // Re-activate vote buttons.
2506 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2507 voteButton.addActivateEvent(voteButtonClicked);
2510 // Re-activate comment action buttons.
2511 commentsContainer.queryAll(".action-button").forEach(button => {
2512 button.addActivateEvent(GW.commentActionButtonClicked);
2516 // Re-activate comment-minimize buttons.
2517 queryAll(".comment-minimize-button").forEach(button => {
2518 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2521 // Re-add comment parent popups.
2522 addCommentParentPopups();
2524 // Redo new-comments highlighting.
2525 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2527 requestAnimationFrame(() => {
2528 commentsContainer.removeClass("sorting");
2529 commentsContainer.addClass("sorted-" + mode);
2532 function commentKarmaValue(commentOrSelector) {
2533 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2535 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2536 } catch(e) {return null};
2538 function commentDate(commentOrSelector) {
2539 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2541 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2542 } catch(e) {return null};
2544 function commentVoteCount(commentOrSelector) {
2545 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2547 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2548 } catch(e) {return null};
2551 function injectCommentsSortModeSelector() {
2552 GWLog("injectCommentsSortModeSelector");
2553 let topCommentThread = query("#comments > .comment-thread");
2554 if (topCommentThread == null) return;
2556 // Do not show sort mode selector if there is no branching in comment tree.
2557 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2559 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
2560 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
2562 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2563 let commentsSortModeSelector = query("#comments-sort-mode-selector");
2565 commentsSortModeSelector.queryAll("button").forEach(button => {
2566 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2567 GWLog("GW.commentsSortModeSelectButtonClicked");
2568 event.target.parentElement.queryAll("button").forEach(button => {
2569 button.removeClass("selected");
2570 button.disabled = false;
2572 event.target.addClass("selected");
2573 event.target.disabled = true;
2575 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2576 setCommentsSortModeSelectButtonsAccesskey();
2580 // TODO: Make this actually get the current sort mode (if that's saved).
2581 // TODO: Also change the condition here to properly get chrono/threaded mode,
2582 // when that is properly done with cookies.
2583 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2584 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2585 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2586 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2587 setCommentsSortModeSelectButtonsAccesskey();
2590 function setCommentsSortModeSelectButtonsAccesskey() {
2591 GWLog("setCommentsSortModeSelectButtonsAccesskey");
2592 queryAll("#comments-sort-mode-selector button").forEach(button => {
2593 button.removeAttribute("accesskey");
2594 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2596 let selectedButton = query("#comments-sort-mode-selector button.selected");
2597 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2598 nextButtonInCycle.accessKey = "z";
2599 nextButtonInCycle.title += " [z]";
2602 /*************************/
2603 /* COMMENT PARENT POPUPS */
2604 /*************************/
2606 function previewPopupsEnabled() {
2607 let isDisabled = localStorage.getItem("preview-popups-disabled");
2608 return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
2611 function setPreviewPopupsEnabled(state) {
2612 localStorage.setItem("preview-popups-disabled", !state);
2613 updatePreviewPopupToggle();
2616 function updatePreviewPopupToggle() {
2617 let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
2618 query("#preview-popup-toggle").setAttribute("style", style);
2621 function injectPreviewPopupToggle() {
2622 GWLog("injectPreviewPopupToggle");
2624 let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
2625 // This is required because Chrome can't use filters on an externally used SVG element.
2626 fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
2627 updatePreviewPopupToggle();
2628 toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
2631 var currentPreviewPopup = { };
2633 function removePreviewPopup(previewPopup) {
2634 if(previewPopup.element)
2635 removeElement(previewPopup.element);
2637 if(previewPopup.timeout)
2638 clearTimeout(previewPopup.timeout);
2640 if(currentPreviewPopup.pointerListener)
2641 window.removeEventListener("pointermove", previewPopup.pointerListener);
2643 if(currentPreviewPopup.mouseoutListener)
2644 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2646 if(currentPreviewPopup.scrollListener)
2647 window.removeEventListener("scroll", previewPopup.scrollListener);
2649 currentPreviewPopup = { };
2652 function addCommentParentPopups() {
2653 GWLog("addCommentParentPopups");
2654 //if (!query("#content").hasClass("comment-thread-page")) return;
2656 queryAll("a[href]").forEach(linkTag => {
2657 let linkHref = linkTag.getAttribute("href");
2660 try { url = new URL(linkHref, window.location.href); }
2664 if(GW.sites[url.host]) {
2665 let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
2667 if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
2668 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
2669 if(event.pointerType == "touch") return;
2670 GWLog("GW.commentParentLinkMouseOver");
2671 removePreviewPopup(currentPreviewPopup);
2672 let parentID = linkHref;
2674 if (!(parent = (query(parentID)||{}).firstChild)) return;
2675 var highlightClassName;
2676 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2677 parentHighlightClassName = "comment-item-highlight-faint";
2678 popup = parent.cloneNode(true);
2679 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2680 linkTag.addEventListener("mouseout", (event) => {
2681 removeElement(popup);
2683 linkTag.closest(".comments > .comment-thread").appendChild(popup);
2685 parentHighlightClassName = "comment-item-highlight";
2687 parent.parentNode.addClass(parentHighlightClassName);
2688 linkTag.addEventListener("mouseout", (event) => {
2689 parent.parentNode.removeClass(parentHighlightClassName);
2693 else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
2694 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
2695 && !(url.searchParams.get('format'))
2696 && !linkTag.closest("nav:not(.post-nav-links)")
2697 && (!url.hash || linkCommentId)
2698 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
2699 linkTag.addEventListener("pointerover", event => {
2700 if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
2701 if(currentPreviewPopup.linkTag) return;
2702 linkTag.createPreviewPopup();
2704 linkTag.createPreviewPopup = function() {
2705 removePreviewPopup(currentPreviewPopup);
2707 currentPreviewPopup = {linkTag: linkTag};
2709 let popup = document.createElement("iframe");
2710 currentPreviewPopup.element = popup;
2712 let popupTarget = linkHref;
2713 if(popupTarget.match(/#comment-/)) {
2714 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
2716 // 'theme' attribute is required for proper caching
2717 popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
2718 popup.addClass("preview-popup");
2720 let linkRect = linkTag.getBoundingClientRect();
2722 if(linkRect.right + 710 < window.innerWidth)
2723 popup.style.left = linkRect.right + 10 + "px";
2725 popup.style.right = "10px";
2727 popup.style.width = "700px";
2728 popup.style.height = "500px";
2729 popup.style.visibility = "hidden";
2730 popup.style.transition = "none";
2732 let recenter = function() {
2733 let popupHeight = 500;
2734 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
2735 let popupContent = popup.contentDocument.querySelector("#content");
2737 popupHeight = popupContent.clientHeight + 2;
2738 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
2739 popup.style.height = popupHeight + "px";
2742 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
2747 query('#content').insertAdjacentElement("beforeend", popup);
2749 let clickListener = event => {
2750 if(!event.target.closest("a, input, label")
2751 && !event.target.closest("popup-hide-button")) {
2752 window.location = linkHref;
2756 popup.addEventListener("load", () => {
2757 let hideButton = popup.contentDocument.createElement("div");
2758 hideButton.className = "popup-hide-button";
2759 hideButton.insertAdjacentText('beforeend', "\uF070");
2760 hideButton.onclick = (event) => {
2761 removePreviewPopup(currentPreviewPopup);
2762 setPreviewPopupsEnabled(false);
2763 event.stopPropagation();
2765 popup.contentDocument.body.appendChild(hideButton);
2767 let body = popup.contentDocument.body;
2768 body.addEventListener("click", clickListener);
2769 body.style.cursor = "pointer";
2774 popup.contentDocument.body.addEventListener("click", clickListener);
2776 currentPreviewPopup.timeout = setTimeout(() => {
2779 requestIdleCallback(() => {
2780 if(currentPreviewPopup.element === popup) {
2781 popup.scrolling = "";
2782 popup.style.visibility = "unset";
2783 popup.style.transition = null;
2786 { opacity: 0, transform: "translateY(10%)" },
2787 { opacity: 1, transform: "none" }
2788 ], { duration: 150, easing: "ease-out" });
2793 let pointerX, pointerY, mousePauseTimeout = null;
2795 currentPreviewPopup.pointerListener = (event) => {
2796 pointerX = event.clientX;
2797 pointerY = event.clientY;
2799 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
2800 mousePauseTimeout = null;
2802 let overElement = document.elementFromPoint(pointerX, pointerY);
2803 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
2805 if(mouseIsOverLink || overElement === popup
2806 || (pointerX < popup.getBoundingClientRect().left
2807 && event.movementX >= 0)) {
2808 if(!mouseIsOverLink && overElement !== popup) {
2809 if(overElement['createPreviewPopup']) {
2810 mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
2812 mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
2816 removePreviewPopup(currentPreviewPopup);
2817 if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
2820 window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
2822 currentPreviewPopup.mouseoutListener = (event) => {
2823 clearTimeout(mousePauseTimeout);
2824 mousePauseTimeout = null;
2826 document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2828 currentPreviewPopup.scrollListener = (event) => {
2829 let overElement = document.elementFromPoint(pointerX, pointerY);
2830 linkRect = linkTag.getBoundingClientRect();
2831 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
2832 removePreviewPopup(currentPreviewPopup);
2834 window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
2839 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2843 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2844 GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2845 "#content .comments .comment-thread"
2847 applyFilters(GW.currentFilters);
2854 function imageFocusSetup(imagesOverlayOnly = false) {
2855 if (typeof GW.imageFocus == "undefined")
2857 contentImagesSelector: "#content img",
2858 overlayImagesSelector: "#images-overlay img",
2859 focusedImageSelector: "#content img.focused, #images-overlay img.focused",
2860 pageContentSelector: "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
2862 hideUITimerDuration: 1500,
2863 hideUITimerExpired: () => {
2864 GWLog("GW.imageFocus.hideUITimerExpired");
2865 let currentTime = new Date();
2866 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
2867 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
2868 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
2871 cancelImageFocusHideUITimer();
2876 GWLog("imageFocusSetup");
2877 // Create event listener for clicking on images to focus them.
2878 GW.imageClickedToFocus = (event) => {
2879 GWLog("GW.imageClickedToFocus");
2880 focusImage(event.target);
2883 // Set timer to hide the image focus UI.
2884 unhideImageFocusUI();
2885 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
2888 // Add the listener to each image in the overlay (i.e., those in the post).
2889 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
2890 image.addActivateEvent(GW.imageClickedToFocus);
2892 // Accesskey-L starts the slideshow.
2893 (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
2894 // Count how many images there are in the post, and set the "… of X" label to that.
2895 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
2896 if (imagesOverlayOnly) return;
2897 // Add the listener to all other content images (including those in comments).
2898 queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
2899 image.addActivateEvent(GW.imageClickedToFocus);
2902 // Create the image focus overlay.
2903 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
2904 `<div class='help-overlay'>
2905 <p><strong>Arrow keys:</strong> Next/previous image</p>
2906 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2907 <p><strong>Space bar:</strong> Reset image size & position</p>
2908 <p><strong>Scroll</strong> to zoom in/out</p>
2909 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2911 <div class='image-number'></div>
2912 <div class='slideshow-buttons'>
2913 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
2914 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
2916 <div class='caption'></div>` +
2918 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2920 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2921 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
2922 GWLog("GW.imageFocus.slideshowButtonClicked");
2923 focusNextImage(event.target.hasClass("next"));
2924 event.target.blur();
2928 // On orientation change, reset the size & position.
2929 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2930 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2933 // UI starts out hidden.
2937 function focusImage(imageToFocus) {
2938 GWLog("focusImage");
2939 // Clear 'last-focused' class of last focused image.
2940 let lastFocusedImage = query("img.last-focused");
2941 if (lastFocusedImage) {
2942 lastFocusedImage.removeClass("last-focused");
2943 lastFocusedImage.removeAttribute("accesskey");
2946 // Create the focused version of the image.
2947 imageToFocus.addClass("focused");
2948 let imageFocusOverlay = query("#image-focus-overlay");
2949 let clonedImage = imageToFocus.cloneNode(true);
2950 clonedImage.style = "";
2951 clonedImage.removeAttribute("width");
2952 clonedImage.removeAttribute("height");
2953 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2954 imageFocusOverlay.appendChild(clonedImage);
2955 imageFocusOverlay.addClass("engaged");
2957 // Set image to default size and position.
2958 resetFocusedImagePosition();
2960 // Blur everything else.
2961 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
2962 element.addClass("blurred");
2965 // Add listener to zoom image with scroll wheel.
2966 window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
2967 GWLog("GW.imageFocus.scrollEvent");
2968 event.preventDefault();
2970 let image = query("#image-focus-overlay img");
2972 // Remove the filter.
2973 image.savedFilter = image.style.filter;
2974 image.style.filter = 'none';
2976 // Locate point under cursor.
2977 let imageBoundingBox = image.getBoundingClientRect();
2979 // Calculate resize factor.
2980 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2981 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2985 image.style.width = (event.deltaY < 0 ?
2986 (image.clientWidth * factor) :
2987 (image.clientWidth / factor))
2989 image.style.height = "";
2991 // Designate zoom origin.
2993 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2994 // the cursor is over the image.
2995 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2996 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2997 (imageBoundingBox.left <= event.clientX &&
2998 event.clientX <= imageBoundingBox.right &&
2999 imageBoundingBox.top <= event.clientY &&
3000 event.clientY <= imageBoundingBox.bottom);
3001 // Otherwise, if we're zooming OUT, zoom from window center; if we're
3002 // zooming IN, zoom from image center.
3003 let zoomingFromWindowCenter = event.deltaY > 0;
3004 if (zoomingFromCursor)
3005 zoomOrigin = { x: event.clientX,
3007 else if (zoomingFromWindowCenter)
3008 zoomOrigin = { x: window.innerWidth / 2,
3009 y: window.innerHeight / 2 };
3011 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
3012 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3014 // Calculate offset from zoom origin.
3015 let offsetOfImageFromZoomOrigin = {
3016 x: imageBoundingBox.x - zoomOrigin.x,
3017 y: imageBoundingBox.y - zoomOrigin.y
3019 // Calculate delta from centered zoom.
3020 let deltaFromCenteredZoom = {
3021 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3022 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3024 // Adjust image position appropriately.
3025 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3026 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3027 // Gradually re-center image, if it's smaller than the window.
3028 if (!imageSizeExceedsWindowBounds) {
3029 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
3030 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3031 let windowCenter = { x: window.innerWidth / 2,
3032 y: window.innerHeight / 2 }
3033 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3034 y: windowCenter.y - imageCenter.y }
3035 // Divide the offset by 10 because we're nudging the image toward center,
3036 // not jumping it there.
3037 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3038 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3041 // Put the filter back.
3042 image.style.filter = image.savedFilter;
3044 // Set the cursor appropriately.
3045 setFocusedImageCursor();
3047 window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3048 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3049 event.preventDefault();
3052 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3053 window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3054 GWLog("GW.imageFocus.mouseUp");
3055 window.onmousemove = '';
3057 // We only want to do anything on left-clicks.
3058 if (event.button != 0) return;
3060 // Don't unfocus if click was on a slideshow next/prev button!
3061 if (event.target.hasClass("slideshow-button")) return;
3063 // We also don't want to do anything if clicked on the help overlay.
3064 if (event.target.classList.contains("help-overlay") ||
3065 event.target.closest(".help-overlay"))
3068 let focusedImage = query("#image-focus-overlay img");
3069 if (event.target == focusedImage &&
3070 (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3071 // If the mouseup event was the end of a pan of an overside image,
3072 // put the filter back; do not unfocus.
3073 focusedImage.style.filter = focusedImage.savedFilter;
3075 unfocusImageOverlay();
3079 window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3080 GWLog("GW.imageFocus.mouseDown");
3081 event.preventDefault();
3083 let focusedImage = query("#image-focus-overlay img");
3084 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3085 let mouseCoordX = event.clientX;
3086 let mouseCoordY = event.clientY;
3088 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3089 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3092 focusedImage.savedFilter = focusedImage.style.filter;
3094 window.onmousemove = (event) => {
3095 // Remove the filter.
3096 focusedImage.style.filter = "none";
3097 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3098 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3104 // Double-click on the image unfocuses.
3105 clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3106 GWLog("GW.imageFocus.doubleClick");
3107 if (event.target.hasClass("slideshow-button")) return;
3109 unfocusImageOverlay();
3112 // Escape key unfocuses, spacebar resets.
3113 document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3114 GWLog("GW.imageFocus.keyUp");
3115 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3116 if (!allowedKeys.contains(event.key) ||
3117 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3119 event.preventDefault();
3121 switch (event.key) {
3124 unfocusImageOverlay();
3128 resetFocusedImagePosition();
3134 if (query("#images-overlay img.focused")) focusNextImage(true);
3140 if (query("#images-overlay img.focused")) focusNextImage(false);
3145 // Prevent spacebar or arrow keys from scrolling page when image focused.
3146 togglePageScrolling(false);
3148 // If the image comes from the images overlay, for the main post...
3149 if (imageToFocus.closest("#images-overlay")) {
3150 // Mark the overlay as being in slide show mode (to show buttons/count).
3151 imageFocusOverlay.addClass("slideshow");
3153 // Set state of next/previous buttons.
3154 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3155 var indexOfFocusedImage = getIndexOfFocusedImage();
3156 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3157 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3159 // Set the image number.
3160 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3162 // Replace the hash.
3163 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3165 imageFocusOverlay.removeClass("slideshow");
3169 setImageFocusCaption();
3171 // Moving mouse unhides image focus UI.
3172 window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3173 GWLog("GW.imageFocus.mouseMoved");
3174 let currentDateTime = new Date();
3175 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3176 cancelImageFocusHideUITimer();
3178 if (!GW.imageFocus.hideUITimer) {
3179 unhideImageFocusUI();
3180 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3182 GW.imageFocus.mouseLastMovedAt = currentDateTime;
3187 function resetFocusedImagePosition() {
3188 GWLog("resetFocusedImagePosition");
3189 let focusedImage = query("#image-focus-overlay img");
3190 if (!focusedImage) return;
3192 let sourceImage = query(GW.imageFocus.focusedImageSelector);
3194 // Make sure that initially, the image fits into the viewport.
3195 let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3196 let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3197 var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3198 let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3199 let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3200 focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3201 focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3203 // Remove modifications to position.
3204 focusedImage.style.left = "";
3205 focusedImage.style.top = "";
3207 // Set the cursor appropriately.
3208 setFocusedImageCursor();
3210 function setFocusedImageCursor() {
3211 let focusedImage = query("#image-focus-overlay img");
3212 if (!focusedImage) return;
3213 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
3217 function unfocusImageOverlay() {
3218 GWLog("unfocusImageOverlay");
3220 // Remove event listeners.
3221 window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3222 window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3223 // NOTE: The double-click listener does not need to be removed manually,
3224 // because the focused (cloned) image will be removed anyway.
3225 document.removeEventListener("keyup", GW.imageFocus.keyUp);
3226 document.removeEventListener("keydown", GW.imageFocus.keyDown);
3227 window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3228 window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3229 window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3231 // Set accesskey of currently focused image (if it's in the images overlay).
3232 let currentlyFocusedImage = query("#images-overlay img.focused");
3233 if (currentlyFocusedImage) {
3234 currentlyFocusedImage.addClass("last-focused");
3235 currentlyFocusedImage.accessKey = 'l';
3238 // Remove focused image and hide overlay.
3239 let imageFocusOverlay = query("#image-focus-overlay");
3240 imageFocusOverlay.removeClass("engaged");
3241 removeElement(imageFocusOverlay.query("img"));
3243 // Un-blur content/etc.
3244 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3245 element.removeClass("blurred");
3248 // Unset "focused" class of focused image.
3249 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3251 // Re-enable page scrolling.
3252 togglePageScrolling(true);
3254 // Reset the hash, if needed.
3255 if (location.hash.hasPrefix("#if_slide_"))
3256 history.replaceState(window.history.state, null, "#");
3259 function getIndexOfFocusedImage() {
3260 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3261 var indexOfFocusedImage = -1;
3262 for (i = 0; i < images.length; i++) {
3263 if (images[i].hasClass("focused")) {
3264 indexOfFocusedImage = i;
3268 return indexOfFocusedImage;
3271 function focusNextImage(next = true) {
3272 GWLog("focusNextImage");
3273 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3274 var indexOfFocusedImage = getIndexOfFocusedImage();
3276 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3278 // Remove existing image.
3279 removeElement("#image-focus-overlay img");
3280 // Unset "focused" class of just-removed image.
3281 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3283 // Create the focused version of the image.
3284 images[indexOfFocusedImage].addClass("focused");
3285 let imageFocusOverlay = query("#image-focus-overlay");
3286 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3287 clonedImage.style = "";
3288 clonedImage.removeAttribute("width");
3289 clonedImage.removeAttribute("height");
3290 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3291 imageFocusOverlay.appendChild(clonedImage);
3292 imageFocusOverlay.addClass("engaged");
3293 // Set image to default size and position.
3294 resetFocusedImagePosition();
3295 // Set state of next/previous buttons.
3296 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3297 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3298 // Set the image number display.
3299 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3301 setImageFocusCaption();
3302 // Replace the hash.
3303 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3306 function setImageFocusCaption() {
3307 GWLog("setImageFocusCaption");
3308 var T = { }; // Temporary storage.
3310 // Clear existing caption, if any.
3311 let captionContainer = query("#image-focus-overlay .caption");
3312 Array.from(captionContainer.children).forEach(child => { child.remove(); });
3314 // Determine caption.
3315 let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3317 if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
3318 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3319 captionHTML = (T.figcaption.query("p")) ?
3320 T.figcaption.innerHTML :
3321 "<p>" + T.figcaption.innerHTML + "</p>";
3322 } else if (currentlyFocusedImage.title != "") {
3323 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3325 // Insert the caption, if any.
3326 if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3329 function hideImageFocusUI() {
3330 GWLog("hideImageFocusUI");
3331 let imageFocusOverlay = query("#image-focus-overlay");
3332 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3333 element.addClass("hidden");
3337 function unhideImageFocusUI() {
3338 GWLog("unhideImageFocusUI");
3339 let imageFocusOverlay = query("#image-focus-overlay");
3340 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3341 element.removeClass("hidden");
3345 function cancelImageFocusHideUITimer() {
3346 clearTimeout(GW.imageFocus.hideUITimer);
3347 GW.imageFocus.hideUITimer = null;
3354 function keyboardHelpSetup() {
3355 let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3356 <div class='keyboard-help-container'>
3357 <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'></button>
3358 <h1>Keyboard shortcuts</h1>
3359 <p class='note'>Keys shown in yellow (e.g., <code class='ak'>]</code>) are <a href='https://en.wikipedia.org/wiki/Access_key#Access_in_different_browsers' target='_blank'>accesskeys</a>, and require a browser-specific modifier key (or keys).</p>
3360 <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3361 <div class='keyboard-shortcuts-lists'>` + [ [
3363 [ [ '?' ], "Show keyboard shortcuts" ],
3364 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3367 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3368 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3369 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3370 [ [ 'ak-m' ], "Go to Meta view" ],
3371 [ [ 'ak-v' ], "Go to Tags view"],
3372 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3373 [ [ 'ak-r' ], "Go to Archive view" ],
3374 [ [ 'ak-q' ], "Go to Sequences view" ],
3375 [ [ 'ak-t' ], "Go to About page" ],
3376 [ [ 'ak-u' ], "Go to User or Login page" ],
3377 [ [ 'ak-o' ], "Go to Inbox page" ]
3380 [ [ 'ak-,' ], "Jump up to top of page" ],
3381 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3382 [ [ 'ak-/' ], "Jump to top of comments section" ],
3383 [ [ 'ak-s' ], "Search" ],
3386 [ [ 'ak-n' ], "New post or comment" ],
3387 [ [ 'ak-e' ], "Edit current post" ]
3389 "Post/comment list views",
3390 [ [ '.' ], "Focus next entry in list" ],
3391 [ [ ',' ], "Focus previous entry in list" ],
3392 [ [ ';' ], "Cycle between links in focused entry" ],
3393 [ [ 'Enter' ], "Go to currently focused entry" ],
3394 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3395 [ [ 'ak-]' ], "Go to next page" ],
3396 [ [ 'ak-[' ], "Go to previous page" ],
3397 [ [ 'ak-\\' ], "Go to first page" ],
3398 [ [ 'ak-e' ], "Edit currently focused post" ]
3401 [ [ 'ak-k' ], "Bold text" ],
3402 [ [ 'ak-i' ], "Italic text" ],
3403 [ [ 'ak-l' ], "Insert hyperlink" ],
3404 [ [ 'ak-q' ], "Blockquote text" ]
3407 [ [ 'ak-=' ], "Increase text size" ],
3408 [ [ 'ak--' ], "Decrease text size" ],
3409 [ [ 'ak-0' ], "Reset to default text size" ],
3410 [ [ 'ak-′' ], "Cycle through content width settings" ],
3411 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3412 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3413 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3414 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3415 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3416 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3417 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3418 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3419 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3420 [ [ 'ak-;' ], "Open theme tweaker" ],
3421 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3422 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3425 [ [ 'ak-l' ], "Start/resume slideshow" ],
3426 [ [ 'Esc' ], "Exit slideshow" ],
3427 [ [ '→', '↓' ], "Next slide" ],
3428 [ [ '←', '↑' ], "Previous slide" ],
3429 [ [ 'Space' ], "Reset slide zoom" ]
3432 [ [ 'ak-x' ], "Switch to next view on user page" ],
3433 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3434 [ [ 'ak-` ' ], "Toggle compact comment list view" ],
3435 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3437 `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3439 <span class='keys'>` +
3441 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3444 <span class='action'>${entry[1]}</span>
3446 ).join("\n") + `</ul>`).join("\n") + `
3451 // Add listener to show the keyboard help overlay.
3452 document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3453 GWLog("GW.keyboardHelpShowKeyPressed");
3454 if (event.key == '?')
3455 toggleKeyboardHelpOverlay(true);
3458 // Clicking the background overlay closes the keyboard help overlay.
3459 keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3460 GWLog("GW.keyboardHelpOverlayClicked");
3461 if (event.type == 'mousedown') {
3462 keyboardHelpOverlay.style.opacity = "0.01";
3464 toggleKeyboardHelpOverlay(false);
3465 keyboardHelpOverlay.style.opacity = "1.0";
3469 // Intercept clicks, so they don't "fall through" the background overlay.
3470 (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3472 // Clicking the close button closes the keyboard help overlay.
3473 keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3474 toggleKeyboardHelpOverlay(false);
3477 // Add button to open keyboard help.
3478 query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'></button>");
3479 query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3480 GWLog("GW.openKeyboardHelpButtonClicked");
3481 toggleKeyboardHelpOverlay(true);
3482 event.target.blur();
3486 function toggleKeyboardHelpOverlay(show) {
3487 console.log("toggleKeyboardHelpOverlay");
3489 let keyboardHelpOverlay = query("#keyboard-help-overlay");
3490 show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3491 keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3493 // Prevent scrolling the document when the overlay is visible.
3494 togglePageScrolling(!show);
3496 // Focus the close button as soon as we open.
3497 keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3500 // Add listener to show the keyboard help overlay.
3501 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3502 GWLog("GW.keyboardHelpHideKeyPressed");
3503 if (event.key == 'Escape')
3504 toggleKeyboardHelpOverlay(false);
3507 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3510 // Disable / enable tab-selection of the search box.
3511 setSearchBoxTabSelectable(!show);
3514 /**********************/
3515 /* PUSH NOTIFICATIONS */
3516 /**********************/
3518 function pushNotificationsSetup() {
3519 let pushNotificationsButton = query("#enable-push-notifications");
3520 if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3521 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3522 pushNotificationsButton.style.display = 'unset';
3526 function urlBase64ToUint8Array(base64String) {
3527 const padding = '='.repeat((4 - base64String.length % 4) % 4);
3528 const base64 = (base64String + padding)
3530 .replace(/_/g, '/');
3532 const rawData = window.atob(base64);
3533 const outputArray = new Uint8Array(rawData.length);
3535 for (let i = 0; i < rawData.length; ++i) {
3536 outputArray[i] = rawData.charCodeAt(i);
3541 function pushNotificationsButtonClicked(event) {
3542 event.target.style.opacity = 0.33;
3543 event.target.style.pointerEvents = "none";
3545 let reEnable = (message) => {
3546 if(message) alert(message);
3547 event.target.style.opacity = 1;
3548 event.target.style.pointerEvents = "unset";
3551 if(event.target.dataset.enabled) {
3552 fetch('/push/register', {
3554 headers: { 'Content-type': 'application/json' },
3555 body: JSON.stringify({
3559 event.target.innerHTML = "Enable push notifications";
3560 event.target.dataset.enabled = "";
3562 }).catch((err) => reEnable(err.message));
3564 Notification.requestPermission().then((permission) => {
3565 navigator.serviceWorker.ready
3566 .then((registration) => {
3567 return registration.pushManager.getSubscription()
3568 .then(async function(subscription) {
3570 return subscription;
3572 return registration.pushManager.subscribe({
3573 userVisibleOnly: true,
3574 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
3577 .catch((err) => reEnable(err.message));
3579 .then((subscription) => {
3580 fetch('/push/register', {
3583 'Content-type': 'application/json'
3585 body: JSON.stringify({
3586 subscription: subscription
3591 event.target.innerHTML = "Disable push notifications";
3592 event.target.dataset.enabled = "true";
3595 .catch(function(err){ reEnable(err.message) });
3601 /*******************************/
3602 /* HTML TO MARKDOWN CONVERSION */
3603 /*******************************/
3605 function MarkdownFromHTML(text) {
3606 GWLog("MarkdownFromHTML");
3607 // Wrapper tags, paragraphs, bold, italic, code blocks.
3608 text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
3631 // <div> and <span>.
3632 text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
3634 }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
3639 text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
3640 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
3641 return `* ${listItem}\n`;
3646 text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
3647 var countedItemValue = 0;
3648 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
3650 if (typeof specifiedItemValue != "undefined") {
3651 specifiedItemValue = parseInt(specifiedItemValue);
3652 countedItemValue = itemValue = specifiedItemValue;
3654 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
3656 return `${itemValue}. ${listItem.trim()}\n`;
3661 text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
3662 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
3666 text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
3667 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
3671 text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
3672 return `[${text}](${href})`;
3676 text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
3677 return `![](${src})`;
3680 // Horizontal rules.
3681 text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
3686 text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
3690 // Preformatted text (possibly with a code block inside).
3691 text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
3692 return "```\n" + text + "\n```";
3696 text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
3697 return "`" + text + "`";
3701 text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
3721 /************************************/
3722 /* ANCHOR LINK SCROLLING WORKAROUND */
3723 /************************************/
3725 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
3726 let hash = location.hash;
3727 if(hash && hash !== "#top" && !document.query(hash)) {
3728 let content = document.query("#content");
3729 content.style.display = "none";
3730 addTriggerListener("DOMReady", {priority: -1, fn: () => {
3731 content.style.visibility = "hidden";
3732 content.style.display = null;
3733 requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
3738 /******************/
3739 /* INITIALIZATION */
3740 /******************/
3742 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
3743 GWLog("INITIALIZER earlyInitialize");
3744 // Check to see whether we're on a mobile device (which we define as a narrow screen)
3745 GW.isMobile = (window.innerWidth <= 1160);
3746 GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3748 // Backward compatibility
3749 let storedTheme = localStorage.getItem('selected-theme');
3751 setTheme(storedTheme);
3752 localStorage.removeItem('selected-theme');
3755 // Animate width & theme adjustments?
3756 GW.adjustmentTransitions = false;
3758 // Add the content width selector.
3759 injectContentWidthSelector();
3760 // Add the text size adjustment widget.
3761 injectTextSizeAdjustmentUI();
3762 // Add the theme selector.
3763 injectThemeSelector();
3764 // Add the theme tweaker.
3765 injectThemeTweaker();
3766 // Add the quick-nav UI.
3769 // Finish initializing when ready.
3770 addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
3773 function mainInitializer() {
3774 GWLog("INITIALIZER initialize");
3776 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
3777 // "link without nav bars".
3778 if (getQueryVariable("hide-nav-bars") == "true") {
3779 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
3782 // If the page cannot have comments, remove the accesskey from the #comments
3783 // quick-nav button; and if the page can have comments, but does not, simply
3784 // disable the #comments quick nav button.
3785 let content = query("#content");
3786 if (content.query("#comments") == null) {
3787 query("#quick-nav-ui a[href='#comments']").accessKey = '';
3788 } else if (content.query("#comments .comment-thread") == null) {
3789 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
3792 // On edit post pages and conversation pages, add GUIEdit buttons to the
3793 // textarea, expand it, and markdownify the existing text, if any (this is
3794 // needed if a post was last edited on LW).
3795 queryAll(".with-markdown-editor textarea").forEach(textarea => {
3796 textarea.addTextareaFeatures();
3797 expandTextarea(textarea);
3798 textarea.value = MarkdownFromHTML(textarea.value);
3800 // Focus the textarea.
3801 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3803 // If we're on a comment thread page...
3804 if (query(".comments") != null) {
3805 // Add comment-minimize buttons to every comment.
3806 queryAll(".comment-meta").forEach(commentMeta => {
3807 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3808 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
3810 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
3811 // Format and activate comment-minimize buttons.
3812 queryAll(".comment-minimize-button").forEach(button => {
3813 button.closest(".comment-item").setCommentThreadMaximized(false);
3814 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3815 event.target.closest(".comment-item").setCommentThreadMaximized(true);
3820 if (getQueryVariable("chrono") == "t") {
3821 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
3824 // On mobile, replace the labels for the checkboxes on the edit post form
3825 // with icons, to save space.
3826 if (GW.isMobile && query(".edit-post-page")) {
3827 query("label[for='link-post']").innerHTML = "";
3828 query("label[for='question']").innerHTML = "";
3831 // Add error message (as placeholder) if user tries to click Search with
3832 // an empty search field.
3834 let searchForm = query("#nav-item-search form");
3835 if(!searchForm) break searchForm;
3836 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3837 let searchField = event.target.query("input");
3838 if (searchField.value == "") {
3839 event.preventDefault();
3840 event.target.blur();
3841 searchField.placeholder = "Enter a search string!";
3842 searchField.focus();
3845 // Remove the placeholder / error on any input.
3846 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3847 event.target.placeholder = "";
3851 // Prevent conflict between various single-hotkey listeners and text fields
3852 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3853 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3854 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
3857 if (content.hasClass("post-page")) {
3858 // Read and update last-visited-date.
3859 let lastVisitedDate = getLastVisitedDate();
3860 setLastVisitedDate(Date.now());
3862 // Save the number of comments this post has when it's visited.
3863 updateSavedCommentCount();
3865 if (content.query(".comments .comment-thread") != null) {
3866 // Add the new comments count & navigator.
3867 injectNewCommentNavUI();
3869 // Get the highlight-new-since date (as specified by URL parameter, if
3870 // present, or otherwise the date of the last visit).
3871 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3873 // Highlight new comments since the specified date.
3874 let newCommentsCount = highlightCommentsSince(hnsDate);
3876 // Update the comment count display.
3877 updateNewCommentNavUI(newCommentsCount, hnsDate);
3880 // On listing pages, make comment counts more informative.
3881 badgePostsWithNewComments();
3884 // Add the comments list mode selector widget (expanded vs. compact).
3885 injectCommentsListModeSelector();
3887 // Add the comments view selector widget (threaded vs. chrono).
3888 // injectCommentsViewModeSelector();
3890 // Add the comments sort mode selector (top, hot, new, old).
3891 if (GW.useFancyFeatures) injectCommentsSortModeSelector();
3893 // Add the toggle for the post nav UI elements on mobile.
3894 if (GW.isMobile) injectPostNavUIToggle();
3896 // Add the toggle for the appearance adjustment UI elements on mobile.
3897 if (GW.isMobile) injectAppearanceAdjustUIToggle();
3899 // Add the antikibitzer.
3900 if (GW.useFancyFeatures) injectAntiKibitzer();
3902 // Add comment parent popups.
3903 injectPreviewPopupToggle();
3904 addCommentParentPopups();
3906 // Mark original poster's comments with a special class.
3907 markOriginalPosterComments();
3909 // On the All view, mark posts with non-positive karma with a special class.
3910 if (query("#content").hasClass("all-index-page")) {
3911 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3912 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3914 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3918 // Set the "submit" button on the edit post page to something more helpful.
3919 setEditPostPageSubmitButtonText();
3921 // Compute the text of the pagination UI tooltip text.
3922 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3923 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3926 // Add event listeners for Escape and Enter, for the theme tweaker.
3927 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3928 let themeTweakerUI = query("#theme-tweaker-ui");
3929 document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3930 if (event.key == "Escape") {
3931 if (themeTweakerHelpWindow.style.display != "none") {
3932 toggleThemeTweakerHelpWindow();
3933 themeTweakerResetSettings();
3934 } else if (themeTweakerUI.style.display != "none") {
3935 toggleThemeTweakerUI();
3938 } else if (event.key == "Enter") {
3939 if (themeTweakerHelpWindow.style.display != "none") {
3940 toggleThemeTweakerHelpWindow();
3941 themeTweakerSaveSettings();
3942 } else if (themeTweakerUI.style.display != "none") {
3943 toggleThemeTweakerUI();
3949 // Add event listener for . , ; (for navigating listings pages).
3950 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3951 if (!query(".comments") && listings.length > 0) {
3952 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
3953 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
3955 if (event.key == "Escape") {
3956 if (document.activeElement.parentElement.hasClass("listing"))
3957 document.activeElement.blur();
3961 if (event.key == ';') {
3962 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3963 let links = document.activeElement.parentElement.queryAll("a");
3964 links[document.activeElement == links[0] ? 1 : 0].focus();
3965 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3966 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3967 links[document.activeElement == links[0] ? 1 : 0].focus();
3968 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3973 var indexOfActiveListing = -1;
3974 for (i = 0; i < listings.length; i++) {
3975 if (document.activeElement.parentElement.hasClass("listing") &&
3976 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3977 indexOfActiveListing = i;
3979 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
3980 listings[i] === document.activeElement.parentElement.query("a.date")) {
3981 indexOfActiveListing = i;
3985 // Remove edit accesskey from currently highlighted post by active user, if applicable.
3986 if (indexOfActiveListing > -1) {
3987 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
3989 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3990 if (indexOfNextListing < listings.length) {
3991 listings[indexOfNextListing].focus();
3993 if (listings[indexOfNextListing].closest(".comment-item")) {
3994 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3995 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3998 document.activeElement.blur();
4000 // Add edit accesskey to newly highlighted post by active user, if applicable.
4001 (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4003 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4004 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4005 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4009 // Add event listener for ; (to focus the link on link posts).
4010 if (query("#content").hasClass("post-page") &&
4011 query(".post").hasClass("link-post")) {
4012 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4013 if (event.key == ';') query("a.link-post-link").focus();
4017 // Add accesskeys to user page view selector.
4018 let viewSelector = query("#content.user-page > .sublevel-nav");
4020 let currentView = viewSelector.query("span");
4021 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4022 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4025 // Add accesskey to index page sort selector.
4026 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4028 // Move MathJax style tags to <head>.
4029 var aggregatedStyles = "";
4030 queryAll("#content style").forEach(styleTag => {
4031 aggregatedStyles += styleTag.innerHTML;
4032 removeElement("style", styleTag.parentElement);
4034 if (aggregatedStyles != "") {
4035 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4038 // Add listeners to switch between word count and read time.
4039 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4040 queryAll(".post-meta .read-time").forEach(element => {
4041 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4042 let displayWordCount = localStorage.getItem("display-word-count");
4043 toggleReadTimeOrWordCount(!displayWordCount);
4044 if (displayWordCount) localStorage.removeItem("display-word-count");
4045 else localStorage.setItem("display-word-count", true);
4049 // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4050 query("#content").addEventListener("copy", GW.textCopied = (event) => {
4051 if(event.target.matches("input, textarea")) return;
4052 event.preventDefault();
4053 const selectedHTML = getSelectionHTML();
4054 const selectedText = getSelection().toString();
4055 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4056 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4059 // Set up Image Focus feature.
4062 // Set up keyboard shortcuts guide overlay.
4063 keyboardHelpSetup();
4065 // Show push notifications button if supported
4066 pushNotificationsSetup();
4068 // Show elements now that javascript is ready.
4069 removeElement("#hide-until-init");
4071 activateTrigger("pageLayoutFinished");
4074 /*************************/
4075 /* POST-LOAD ADJUSTMENTS */
4076 /*************************/
4078 window.addEventListener("pageshow", badgePostsWithNewComments);
4080 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4081 GWLog("INITIALIZER pageLayoutFinished");
4083 postSetThemeHousekeeping();
4085 focusImageSpecifiedByURL();
4087 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4088 // query("input[type='search']").value = GW.isMobile;
4089 // insertHeadHTML("<style>" +
4090 // `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` +
4091 // `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` +
4095 function generateImagesOverlay() {
4096 GWLog("generateImagesOverlay");
4097 // Don't do this on the about page.
4098 if (query(".about-page") != null) return;
4101 // Remove existing, if any.
4102 removeElement("#images-overlay");
4105 query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4106 let imagesOverlay = query("#images-overlay");
4107 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4108 queryAll(".post-body img").forEach(image => {
4109 let clonedImageContainer = document.createElement("div");
4111 let clonedImage = image.cloneNode(true);
4112 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4113 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4114 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4115 clonedImageContainer.appendChild(clonedImage);
4117 let zoomLevel = parseFloat(GW.currentTextZoom);
4119 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4120 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4121 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4122 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4124 imagesOverlay.appendChild(clonedImageContainer);
4127 // Add the event listeners to focus each image.
4128 imageFocusSetup(true);
4131 function adjustUIForWindowSize() {
4132 GWLog("adjustUIForWindowSize");
4133 var bottomBarOffset;
4135 // Adjust bottom bar state.
4136 let bottomBar = query("#bottom-bar");
4137 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4138 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4139 bottomBar.removeClass("decorative");
4141 bottomBar.query("#nav-item-top").style.display = "";
4142 } else if (bottomBar) {
4143 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4144 else bottomBar.addClass("decorative");
4146 bottomBar.query("#nav-item-top").style.display = "none";
4149 // Show quick-nav UI up/down buttons if content is taller than window.
4150 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4151 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4152 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4155 // Move anti-kibitzer toggle if content is very short.
4156 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4158 // Update the visibility of the post nav UI.
4159 updatePostNavUIVisibility();
4162 function recomputeUIElementsContainerHeight(force = false) {
4163 GWLog("recomputeUIElementsContainerHeight");
4165 (force || query("#ui-elements-container").style.height != "")) {
4166 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4167 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
4168 query("#content").clientHeight + "px" :
4173 function focusImageSpecifiedByURL() {
4174 GWLog("focusImageSpecifiedByURL");
4175 if (location.hash.hasPrefix("#if_slide_")) {
4176 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4177 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4178 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4179 if (imageToFocus > 0 && imageToFocus <= images.length) {
4180 focusImage(images[imageToFocus - 1]);
4182 // Set timer to hide the image focus UI.
4183 unhideImageFocusUI();
4184 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4194 function insertMarkup(event) {
4195 var mopen = '', mclose = '', mtext = '', func = false;
4196 if (typeof arguments[1] == 'function') {
4197 func = arguments[1];
4199 mopen = arguments[1];
4200 mclose = arguments[2];
4201 mtext = arguments[3];
4204 var textarea = event.target.closest("form").query("textarea");
4206 var p0 = textarea.selectionStart;
4207 var p1 = textarea.selectionEnd;
4208 var cur0 = cur1 = p0;
4210 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4211 str = func ? func(str, p0) : (mopen + str + mclose);
4213 // Determine selection.
4215 cur0 += (p0 == p1) ? mopen.length : str.length;
4216 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4223 // Update textarea contents.
4224 document.execCommand("insertText", false, str);
4226 // Expand textarea, if needed.
4227 expandTextarea(textarea);
4230 textarea.selectionStart = cur0;
4231 textarea.selectionEnd = cur1;
4236 GW.guiEditButtons = [
4237 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
4238 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
4239 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
4240 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
4241 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
4242 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
4243 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
4244 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
4245 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
4246 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
4247 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
4248 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
4249 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
4250 [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '' ],
4251 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
4254 function blockquote(text, startpos) {
4256 text = "> Quoted text";
4257 return [ text, startpos + 2, startpos + text.length ];
4259 text = "> " + text.split("\n").join("\n> ") + "\n";
4260 return [ text, startpos + text.length, startpos + text.length ];
4264 function hyperlink(text, startpos) {
4265 var url = '', link_text = text, endpos = startpos;
4266 if (text.search(/^https?/) != -1) {
4268 link_text = "link text";
4269 startpos = startpos + 1;
4270 endpos = startpos + link_text.length;
4272 url = prompt("Link address (URL):");
4274 endpos = startpos + text.length;
4275 return [ text, startpos, endpos ];
4277 startpos = startpos + text.length + url.length + 4;
4281 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4284 /******************/
4285 /* SERVICE WORKER */
4286 /******************/
4288 if(navigator.serviceWorker) {
4289 navigator.serviceWorker.register('/service-worker.js');
4290 setCookie("push", "t");
4293 /*********************/
4294 /* USER AUTOCOMPLETE */
4295 /*********************/
4297 function zLowerUIElements() {
4298 let uiElementsContainer = query("#ui-elements-container");
4299 if (uiElementsContainer)
4300 uiElementsContainer.style.zIndex = "1";
4303 function zRaiseUIElements() {
4304 let uiElementsContainer = query("#ui-elements-container");
4305 if (uiElementsContainer)
4306 uiElementsContainer.style.zIndex = "";
4309 var userAutocomplete = null;
4311 function abbreviatedInterval(date) {
4312 let seconds = Math.floor((new Date() - date) / 1000);
4313 let days = Math.floor(seconds / (60 * 60 * 24));
4314 let years = Math.floor(days / 365);
4323 function beginAutocompletion(control, startIndex, endIndex) {
4324 if(userAutocomplete) abortAutocompletion(userAutocomplete);
4326 let complete = { control: control,
4327 abortController: new AbortController(),
4328 fetchAbortController: new AbortController(),
4329 container: document.createElement("div") };
4331 endIndex = endIndex || control.selectionEnd;
4332 let valueLength = control.value.length;
4334 complete.container.className = "autocomplete-container "
4336 + (window.innerWidth > 1280
4339 control.insertAdjacentElement("afterend", complete.container);
4342 let makeReplacer = (userSlug, displayName) => {
4344 let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
4345 control.value = control.value.substring(0, startIndex - 1) +
4347 control.value.substring(endIndex);
4348 abortAutocompletion(complete);
4349 complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
4350 complete.control.focus();
4354 let switchHighlight = (newHighlight) => {
4358 complete.highlighted.removeClass("highlighted");
4359 newHighlight.addClass("highlighted");
4360 complete.highlighted = newHighlight;
4362 // Scroll newly highlighted item into view, if need be.
4363 if ( complete.highlighted.offsetTop + complete.highlighted.offsetHeight
4364 > complete.container.scrollTop + complete.container.clientHeight) {
4365 complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
4366 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
4367 complete.container.scrollTo(0, complete.highlighted.offsetTop);
4370 let highlightNext = () => {
4371 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
4373 let highlightPrev = () => {
4374 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
4377 let updateCompletions = () => {
4378 let fragment = control.value.substring(startIndex, endIndex);
4380 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
4381 {signal: complete.fetchAbortController.signal})
4382 .then((res) => res.json())
4384 if(res.error) return;
4385 if(res.length == 0) return abortAutocompletion(complete);
4387 complete.container.innerHTML = "";
4388 res.forEach(entry => {
4389 let entryContainer = document.createElement("div");
4390 [ [ entry.displayName, "name" ],
4391 [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
4392 [ (entry.karma || 0) + " karma", "karma" ]
4394 let e = document.createElement("span");
4397 entryContainer.append(e);
4399 entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
4400 complete.container.append(entryContainer);
4402 complete.highlighted = complete.container.children[0];
4403 complete.highlighted.classList.add("highlighted");
4404 complete.container.scrollTo(0, 0);
4409 document.body.addEventListener("click", (event) => {
4410 if (!complete.container.contains(event.target)) {
4411 abortAutocompletion(complete);
4412 event.preventDefault();
4413 event.stopPropagation();
4415 }, {signal: complete.abortController.signal,
4418 control.addEventListener("keydown", (event) => {
4419 switch (event.key) {
4421 abortAutocompletion(complete);
4422 event.preventDefault();
4426 event.preventDefault();
4430 event.preventDefault();
4437 event.preventDefault();
4440 complete.highlighted.onclick();
4441 event.preventDefault();
4444 }, {signal: complete.abortController.signal});
4446 control.addEventListener("selectionchange", (event) => {
4447 if (control.selectionStart < startIndex ||
4448 control.selectionEnd > endIndex) {
4449 abortAutocompletion(complete);
4451 }, {signal: complete.abortController.signal});
4453 control.addEventListener("input", (event) => {
4454 complete.fetchAbortController.abort();
4455 complete.fetchAbortController = new AbortController();
4457 endIndex += control.value.length - valueLength;
4458 valueLength = control.value.length;
4460 if (endIndex < startIndex) {
4461 abortAutocompletion(complete);
4465 updateCompletions();
4466 }, {signal: complete.abortController.signal});
4468 userAutocomplete = complete;
4470 if(startIndex != endIndex) updateCompletions();
4473 function abortAutocompletion(complete) {
4474 complete.fetchAbortController.abort();
4475 complete.abortController.abort();
4476 complete.container.remove();
4477 userAutocomplete = null;