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") alert("Error: " + JSON.parse(event.target.responseText)["error"]);
125 else alert("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 /* Simple mutex mechanism.
235 function doIfAllowed(f, passHolder, passName, releaseImmediately = false) {
236 if (passHolder[passName] == false)
239 passHolder[passName] = false;
243 if (releaseImmediately) {
244 passHolder[passName] = true;
246 requestAnimationFrame(() => {
247 passHolder[passName] = true;
253 /********************/
254 /* DEBUGGING OUTPUT */
255 /********************/
257 GW.enableLogging = (permanently = false) => {
259 localStorage.setItem("logging-enabled", "true");
261 GW.loggingEnabled = true;
263 GW.disableLogging = (permanently = false) => {
265 localStorage.removeItem("logging-enabled");
267 GW.loggingEnabled = false;
270 /*******************/
271 /* INBOX INDICATOR */
272 /*******************/
274 function processUserStatus(userStatus) {
275 window.userStatus = userStatus;
277 if(userStatus.notifications) {
278 let element = query('#inbox-indicator');
279 element.className = 'new-messages';
280 element.title = 'New messages [o]';
291 function toggleMarkdownHintsBox() {
292 GWLog("toggleMarkdownHintsBox");
293 let markdownHintsBox = query("#markdown-hints");
294 markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
296 function hideMarkdownHintsBox() {
297 GWLog("hideMarkdownHintsBox");
298 let markdownHintsBox = query("#markdown-hints");
299 if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
302 Element.prototype.addTextareaFeatures = function() {
303 GWLog("addTextareaFeatures");
306 textarea.addEventListener("focus", GW.textareaFocused = (event) => {
307 GWLog("GW.textareaFocused");
308 event.target.closest("form").scrollIntoViewIfNeeded();
310 textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
311 GWLog("GW.textareaInputReceived");
312 if (window.innerWidth > 520) {
313 // Expand textarea if needed.
314 expandTextarea(textarea);
316 // Remove markdown hints.
317 hideMarkdownHintsBox();
318 query(".guiedit-mobile-help-button").removeClass("active");
321 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
322 textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
324 let form = textarea.closest("form");
325 if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
327 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
328 let textareaContainer = textarea.closest(".textarea-container");
329 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
330 for (var button of GW.guiEditButtons) {
331 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
332 buttons_container.insertAdjacentHTML("beforeend",
333 "<button type='button' class='guiedit guiedit-"
336 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
337 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
338 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
339 + " onclick='insertMarkup(event,"
340 + ((typeof m_before_or_func == 'function') ?
341 m_before_or_func.name :
342 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
350 `<input type='checkbox' id='markdown-hints-checkbox'>
351 <label for='markdown-hints-checkbox'></label>
352 <div id='markdown-hints'>` +
353 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
354 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
355 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
356 "<span>Heading 1</span><code># Heading 1</code>",
357 "<span>Heading 2</span><code>## Heading 1</code>",
358 "<span>Heading 3</span><code>### Heading 1</code>",
359 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
361 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
363 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
364 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
365 GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
366 if (button.hasClass("guiedit-mobile-help-button")) {
367 toggleMarkdownHintsBox();
368 event.target.toggleClass("active");
369 query(".posting-controls:focus-within textarea").focus();
370 } else if (button.hasClass("guiedit-mobile-exit-button")) {
372 hideMarkdownHintsBox();
373 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
378 // On smartphone (narrow mobile) screens, when a textarea is focused (and
379 // automatically fullscreened), remove all the filters from the page, and
380 // then apply them *just* to the fixed editor UI elements. This is in order
381 // to get around the "children of elements with a filter applied cannot be
383 if (GW.isMobile && window.innerWidth <= 520) {
384 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
385 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
386 GWLog("GW.textareaFocusedMobile");
387 GW.savedFilters = GW.currentFilters;
388 GW.currentFilters = { };
389 applyFilters(GW.currentFilters);
390 fixedEditorElements.forEach(element => {
391 element.style.filter = filterStringFromFilters(GW.savedFilters);
394 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
395 GWLog("GW.textareaBlurredMobile");
396 GW.currentFilters = GW.savedFilters;
397 GW.savedFilters = { };
398 requestAnimationFrame(() => {
399 applyFilters(GW.currentFilters);
400 fixedEditorElements.forEach(element => {
401 element.style.filter = filterStringFromFilters(GW.savedFilters);
408 Element.prototype.injectReplyForm = function(editMarkdownSource) {
409 GWLog("injectReplyForm");
410 let commentControls = this;
411 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
412 let postId = commentControls.parentElement.dataset["postId"];
413 let tagId = commentControls.parentElement.dataset["tagId"];
414 let withparent = (!editMarkdownSource && commentControls.getCommentId());
415 let answer = commentControls.parentElement.id == "answers";
416 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
417 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
418 let parentCommentItem = commentControls.closest(".comment-item");
419 let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
420 (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
421 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
422 "<form method='post'>" +
423 "<div class='textarea-container'>" +
424 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
425 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
426 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
427 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
428 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
429 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
430 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
431 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
432 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
433 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
434 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
435 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
436 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
438 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
439 "<input type='submit' value='Submit'>" +
441 commentControls.onsubmit = disableBeforeUnload;
443 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
444 GWLog("GW.cancelCommentButtonClicked");
445 hideReplyForm(event.target.closest(".comment-controls"));
447 commentControls.scrollIntoViewIfNeeded();
448 commentControls.query("form").onsubmit = (event) => {
449 if (!event.target.text.value) {
450 alert("Please enter a comment.");
454 let textarea = commentControls.query("textarea");
455 textarea.value = MarkdownFromHTML(editMarkdownSource || "");
456 textarea.addTextareaFeatures();
460 function showCommentEditForm(commentItem) {
461 GWLog("showCommentEditForm");
463 let commentBody = commentItem.query(".comment-body");
464 commentBody.style.display = "none";
466 let commentControls = commentItem.query(".comment-controls");
467 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
468 commentControls.query("form").addClass("edit-existing-comment");
469 expandTextarea(commentControls.query("textarea"));
472 function showReplyForm(commentItem) {
473 GWLog("showReplyForm");
475 let commentControls = commentItem.query(".comment-controls");
476 commentControls.injectReplyForm(commentControls.dataset.enteredText);
479 function hideReplyForm(commentControls) {
480 GWLog("hideReplyForm");
481 // Are we editing a comment? If so, un-hide the existing comment body.
482 let containingComment = commentControls.closest(".comment-item");
483 if (containingComment) containingComment.query(".comment-body").style.display = "";
485 let enteredText = commentControls.query("textarea").value;
486 if (enteredText) commentControls.dataset.enteredText = enteredText;
488 disableBeforeUnload();
489 commentControls.constructCommentControls();
492 function expandTextarea(textarea) {
493 GWLog("expandTextarea");
494 if (window.innerWidth <= 520) return;
496 let totalBorderHeight = 30;
497 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
499 requestAnimationFrame(() => {
500 textarea.style.height = 'auto';
501 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
502 if (textarea.clientHeight < window.innerHeight) {
503 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
508 function doCommentAction(action, commentItem) {
509 GWLog("doCommentAction");
511 params[(action + "-comment-id")] = commentItem.getCommentId();
515 onSuccess: GW.commentActionPostSucceeded = (event) => {
516 GWLog("GW.commentActionPostSucceeded");
518 retract: () => { commentItem.firstChild.addClass("retracted") },
519 unretract: () => { commentItem.firstChild.removeClass("retracted") },
521 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>";
522 commentItem.removeChild(commentItem.query(".comment-controls"));
526 if(action != "delete")
527 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
536 function parseVoteType(voteType) {
537 GWLog("parseVoteType");
539 if (!voteType) return value;
540 value.up = /[Uu]pvote$/.test(voteType);
541 value.down = /[Dd]ownvote$/.test(voteType);
542 value.big = /^big/.test(voteType);
546 function makeVoteType(value) {
547 GWLog("makeVoteType");
548 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
551 function makeVoteClass(vote) {
552 GWLog("makeVoteClass");
553 if (vote.up || vote.down) {
554 return (vote.big ? 'selected big-vote' : 'selected');
560 function findVoteControls(targetType, targetId, voteAxis) {
561 var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
563 if(targetType == "Post") {
564 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
565 } else if(targetType == "Comment") {
566 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
570 function votesEqual(vote1, vote2) {
571 var allKeys = Object.assign({}, vote1);
572 Object.assign(allKeys, vote2);
574 for(k of allKeys.keys()) {
575 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
580 function addVoteButtons(element, vote, targetType) {
581 GWLog("addVoteButtons");
583 let voteAxis = element.parentElement.dataset.voteAxis || "karma";
584 let voteType = parseVoteType(vote[voteAxis]);
585 let voteClass = makeVoteClass(voteType);
587 element.parentElement.queryAll("button").forEach((button) => {
588 button.disabled = false;
590 if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
591 button.addClass(voteClass);
593 updateVoteButtonVisualState(button);
594 button.addActivateEvent(voteButtonClicked);
598 function updateVoteButtonVisualState(button) {
599 GWLog("updateVoteButtonVisualState");
601 button.removeClasses([ "none", "one", "two-temp", "two" ]);
604 button.addClass("none");
605 else if (button.hasClass("big-vote"))
606 button.addClass("two");
607 else if (button.hasClass("selected"))
608 button.addClass("one");
610 button.addClass("none");
613 function changeVoteButtonVisualState(button) {
614 GWLog("changeVoteButtonVisualState");
616 /* Interaction states are:
618 0 0· (neutral; +1 click)
619 1 1· (small vote; +1 click)
620 2 2· (big vote; +1 click)
622 Visual states are (with their state classes in [brackets]) are:
625 02 (small vote active) [one]
626 12 (small vote active, temporary indicator of big vote) [two-temp]
627 22 (big vote active) [two]
629 The following are the 9 possible interaction state transitions (and
630 the visual state transitions associated with them):
633 FROM TO FROM TO NOTES
634 ==== ==== ==== ==== =====
635 0 0· 01 12 first click
636 0· 1 12 02 one click without second
637 0· 2 12 22 second click
639 1 1· 02 12 first click
640 1· 0 12 01 one click without second
641 1· 2 12 22 second click
643 2 2· 22 12 first click
644 2· 1 12 02 one click without second
645 2· 0 12 01 second click
648 [ "big-vote two-temp clicked-twice", "none" ], // 2· => 0
649 [ "big-vote two-temp clicked-once", "one" ], // 2· => 1
650 [ "big-vote clicked-once", "two-temp" ], // 2 => 2·
652 [ "selected two-temp clicked-twice", "two" ], // 1· => 2
653 [ "selected two-temp clicked-once", "none" ], // 1· => 0
654 [ "selected clicked-once", "two-temp" ], // 1 => 1·
656 [ "two-temp clicked-twice", "two" ], // 0· => 2
657 [ "two-temp clicked-once", "one" ], // 0· => 1
658 [ "clicked-once", "two-temp" ], // 0 => 0·
660 for (let [ interactionClasses, visualStateClass ] of transitions) {
661 if (button.hasClasses(interactionClasses.split(" "))) {
662 button.removeClasses([ "none", "one", "two-temp", "two" ]);
663 button.addClass(visualStateClass);
669 function voteCompleteEvent(targetType, targetId, response) {
670 GWLog("voteCompleteEvent");
672 var currentVote = voteData[targetType][targetId] || {};
673 var desiredVote = voteDesired[targetType][targetId];
675 var controls = findVoteControls(targetType, targetId);
676 var controlsByAxis = new Object;
678 controls.forEach(control => {
679 const voteAxis = (control.dataset.voteAxis || "karma");
681 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
682 control.removeClass("waiting");
683 control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
686 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
687 controlsByAxis[voteAxis].push(control);
689 const voteType = currentVote[voteAxis];
690 const vote = parseVoteType(voteType);
691 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
692 const voteClass = makeVoteClass(vote);
694 if (response && response[voteAxis]) {
695 const [voteType, displayText, titleText] = response[voteAxis];
697 const displayTarget = control.query(".karma-value");
698 if (displayTarget.hasClass("redacted")) {
699 displayTarget.dataset["trueValue"] = displayText;
701 displayTarget.innerHTML = displayText;
703 displayTarget.setAttribute("title", titleText);
706 control.queryAll("button.vote").forEach(button => {
707 updateVoteButton(button, voteUpDown, voteClass);
712 function updateVoteButton(button, voteUpDown, voteClass) {
713 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
714 if (button.dataset.voteType == voteUpDown)
715 button.addClass(voteClass);
716 updateVoteButtonVisualState(button);
719 function makeVoteRequestCompleteEvent(targetType, targetId) {
721 var currentVote = {};
724 if (event.target.status == 200) {
725 response = JSON.parse(event.target.responseText);
726 for (const voteAxis of response.keys()) {
727 currentVote[voteAxis] = response[voteAxis][0];
729 voteData[targetType][targetId] = currentVote;
731 delete voteDesired[targetType][targetId];
732 currentVote = voteData[targetType][targetId];
735 var desiredVote = voteDesired[targetType][targetId];
737 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
738 sendVoteRequest(targetType, targetId);
740 delete voteDesired[targetType][targetId];
741 voteCompleteEvent(targetType, targetId, response);
746 function sendVoteRequest(targetType, targetId) {
747 GWLog("sendVoteRequest");
751 location: "/karma-vote",
752 params: { "target": targetId,
753 "target-type": targetType,
754 "vote": JSON.stringify(voteDesired[targetType][targetId]) },
755 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
759 function voteButtonClicked(event) {
760 GWLog("voteButtonClicked");
761 let voteButton = event.target;
763 // 500 ms (0.5 s) double-click timeout.
764 let doubleClickTimeout = 500;
766 if (!voteButton.clickedOnce) {
767 voteButton.clickedOnce = true;
768 voteButton.addClass("clicked-once");
769 changeVoteButtonVisualState(voteButton);
771 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
772 if (!voteButton.clickedOnce) return;
774 // Do single-click code.
775 voteButton.clickedOnce = false;
776 voteEvent(voteButton, 1);
777 }, doubleClickTimeout, voteButton);
779 voteButton.clickedOnce = false;
781 // Do double-click code.
782 voteButton.removeClass("clicked-once");
783 voteButton.addClass("clicked-twice");
784 voteEvent(voteButton, 2);
788 function voteEvent(voteButton, numClicks) {
792 let voteControl = voteButton.parentNode;
794 let targetType = voteButton.dataset.targetType;
795 let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
796 let voteAxis = voteControl.dataset.voteAxis || "karma";
797 let voteUpDown = voteButton.dataset.voteType;
800 if ( (numClicks == 2 && voteButton.hasClass("big-vote"))
801 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
802 voteType = "neutral";
804 let vote = parseVoteType(voteUpDown);
805 vote.big = (numClicks == 2);
806 voteType = makeVoteType(vote);
809 let voteControls = findVoteControls(targetType, targetId, voteAxis);
810 for (const voteControl of voteControls) {
811 voteControl.addClass("waiting");
812 voteControl.queryAll(".vote").forEach(button => {
813 button.addClass("waiting");
814 updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
818 let voteRequestPending = voteDesired[targetType][targetId];
819 let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
820 voteObject[voteAxis] = voteType;
821 voteDesired[targetType][targetId] = voteObject;
823 if (!voteRequestPending) sendVoteRequest(targetType, targetId);
826 function initializeVoteButtons() {
827 // Color the upvote/downvote buttons with an embedded style sheet.
828 query("head").insertAdjacentHTML("beforeend", "<style id='vote-buttons'>" + `
830 --GW-upvote-button-color: #00d800;
831 --GW-downvote-button-color: #eb4c2a;
835 function processVoteData(voteData) {
836 window.voteData = voteData;
838 window.voteDesired = new Object;
839 for(key of voteData.keys()) {
840 voteDesired[key] = new Object;
843 initializeVoteButtons();
845 addTriggerListener("postLoaded", {priority: 3000, fn: () => {
846 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
847 let postID = karmaValue.parentNode.dataset.postId;
848 addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
849 karmaValue.parentElement.addClass("active-controls");
853 addTriggerListener("DOMReady", {priority: 3000, fn: () => {
854 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
855 let commentID = karmaValue.getCommentId();
856 addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
857 karmaValue.parentElement.addClass("active-controls");
862 /*****************************************/
863 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
864 /*****************************************/
866 Element.prototype.getCommentDate = function() {
867 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
868 let dateElement = item && item.query(".date");
869 return (dateElement && parseInt(dateElement.dataset["jsDate"]));
871 function getCurrentVisibleComment() {
872 let px = window.innerWidth/2, py = 5;
873 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
874 let bottomBar = query("#bottom-bar");
875 let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
876 let atbottom = bottomOffset <= window.innerHeight;
878 let hashci = location.hash && query(location.hash);
879 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
880 commentItem = hashci;
886 function highlightCommentsSince(date) {
887 GWLog("highlightCommentsSince");
888 var newCommentsCount = 0;
889 GW.newComments = [ ];
890 let oldCommentsStack = [ ];
892 queryAll(".comment-item").forEach(commentItem => {
893 commentItem.prevNewComment = prevNewComment;
894 commentItem.nextNewComment = null;
895 if (commentItem.getCommentDate() > date) {
896 commentItem.addClass("new-comment");
898 GW.newComments.push(commentItem.getCommentId());
899 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
900 oldCommentsStack = [ commentItem ];
901 prevNewComment = commentItem;
903 commentItem.removeClass("new-comment");
904 oldCommentsStack.push(commentItem);
908 GW.newCommentScrollSet = (commentItem) => {
909 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
910 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
912 GW.newCommentScrollListener = () => {
913 let commentItem = getCurrentVisibleComment();
914 GW.newCommentScrollSet(commentItem);
917 addScrollListener(GW.newCommentScrollListener);
919 if (document.readyState=="complete") {
920 GW.newCommentScrollListener();
922 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
923 GW.newCommentScrollSet(commentItem);
926 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
928 return newCommentsCount;
931 function scrollToNewComment(next) {
932 GWLog("scrollToNewComment");
933 let commentItem = getCurrentVisibleComment();
934 let targetComment = null;
935 let targetCommentID = null;
937 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
939 targetCommentID = targetComment.getCommentId();
942 if (GW.newComments[0]) {
943 targetCommentID = GW.newComments[0];
944 targetComment = query("#comment-" + targetCommentID);
948 expandAncestorsOf(targetCommentID);
949 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
950 targetComment.scrollIntoView();
953 GW.newCommentScrollListener();
956 function getPostHash() {
957 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
958 return (postHash ? postHash[1] : false);
960 function setHistoryLastVisitedDate(date) {
961 window.history.replaceState({ lastVisited: date }, null);
963 function getLastVisitedDate() {
964 // Get the last visited date (or, if posting a comment, the previous last visited date).
965 if(window.history.state) return (window.history.state||{})['lastVisited'];
966 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
967 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
968 let currentVisited = localStorage.getItem(storageName);
969 setHistoryLastVisitedDate(currentVisited);
970 return currentVisited;
972 function setLastVisitedDate(date) {
973 GWLog("setLastVisitedDate");
974 // If NOT posting a comment, save the previous value for the last-visited-date
975 // (to recover it in case of posting a comment).
976 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
977 if (!aCommentHasJustBeenPosted) {
978 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
979 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
982 // Set the new value.
983 localStorage.setItem("last-visited-date_" + getPostHash(), date);
986 function updateSavedCommentCount() {
987 GWLog("updateSavedCommentCount");
988 let commentCount = queryAll(".comment").length;
989 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
991 function badgePostsWithNewComments() {
992 GWLog("badgePostsWithNewComments");
993 if (getQueryVariable("show") == "conversations") return;
995 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
996 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
998 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
999 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1000 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1002 if (currentCommentCount > savedCommentCount)
1003 commentCountDisplay.addClass("new-comments");
1005 commentCountDisplay.removeClass("new-comments");
1006 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1016 systemDarkModeActive: matchMedia("(prefers-color-scheme: dark)")
1020 /************************/
1021 /* ACTIVE MEDIA QUERIES */
1022 /************************/
1024 /* This function provides two slightly different versions of its functionality,
1025 depending on how many arguments it gets.
1027 If one function is given (in addition to the media query and its name), it
1028 is called whenever the media query changes (in either direction).
1030 If two functions are given (in addition to the media query and its name),
1031 then the first function is called whenever the media query starts matching,
1032 and the second function is called whenever the media query stops matching.
1034 If you want to call a function for a change in one direction only, pass an
1035 empty closure (NOT null!) as one of the function arguments.
1037 There is also an optional fifth argument. This should be a function to be
1038 called when the active media query is canceled.
1040 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1041 if (typeof GW.mediaQueryResponders == "undefined")
1042 GW.mediaQueryResponders = { };
1044 let mediaQueryResponder = (event, canceling = false) => {
1046 GWLog(`Canceling media query “${name}”`, "media queries", 1);
1048 if (whenCanceledDo != null)
1049 whenCanceledDo(mediaQuery);
1051 let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1053 GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1055 if ((otherwiseDo == null) || matches)
1056 ifMatchesOrAlwaysDo(mediaQuery);
1058 otherwiseDo(mediaQuery);
1061 mediaQueryResponder();
1062 mediaQuery.addListener(mediaQueryResponder);
1064 GW.mediaQueryResponders[name] = mediaQueryResponder;
1067 /* Deactivates and discards an active media query, after calling the function
1068 that was passed as the whenCanceledDo parameter when the media query was
1071 function cancelDoWhenMatchMedia(name) {
1072 GW.mediaQueryResponders[name](null, true);
1074 for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1075 mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1077 GW.mediaQueryResponders[name] = null;
1081 /******************************/
1082 /* DARK/LIGHT MODE ADJUSTMENT */
1083 /******************************/
1090 [ 'auto', '', 'Set light or dark mode automatically, according to system-wide setting (Win: Start → Personalization → Colors; Mac: Apple → System-Preferences → General → Appearance; iOS: Settings → Display-and-Brightness; Android: Settings → Display)' ],
1091 [ 'light', '', 'Light mode at all times (black-on-white)' ],
1092 [ 'dark', '', 'Dark mode at all times (inverted: white-on-black)' ]
1095 selectedModeOptionNote: " [This option is currently selected.]",
1097 /******************/
1102 modeSelectorInteractable: true,
1104 /******************/
1108 /* Returns current (saved) mode (light, dark, or auto).
1110 getSavedMode: () => {
1111 // NOTE: For testing only!
1114 return (localStorage.getItem("dark-mode-setting") || "auto");
1117 /* Saves specified mode (light, dark, or auto).
1119 saveMode: (mode) => {
1120 // NOTE: For testing only!
1124 localStorage.removeItem("dark-mode-setting");
1126 localStorage.setItem("dark-mode-setting", mode);
1129 /* Set specified color mode (light, dark, or auto).
1131 setMode: (selectedMode = DarkMode.getSavedMode()) => {
1132 GWLog("DarkMode.setMode");
1134 // The style block should be inlined (and already loaded).
1135 let darkModeStyles = document.querySelector("#inlined-dark-mode-styles");
1136 if (darkModeStyles == null)
1139 // Set `media` attribute of style block to match requested mode.
1140 if (selectedMode == 'auto') {
1141 darkModeStyles.media = "all and (prefers-color-scheme: dark)";
1142 } else if (selectedMode == 'dark') {
1143 darkModeStyles.media = "all";
1145 darkModeStyles.media = "not all";
1149 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1152 modeSelectorHTML: (inline = false) => {
1153 let selectorTagName = (inline ? "span" : "div");
1154 let selectorId = (inline ? "" : " id='dark-mode-selector'");
1155 let selectorClass = (" class='dark-mode-selector mode-selector" + (inline ? " mode-selector-inline" : "") + "'");
1157 // Get saved mode setting (or default).
1158 let currentMode = DarkMode.getSavedMode();
1160 return `<${selectorTagName}${selectorId}${selectorClass}>`
1161 + DarkMode.modeOptions.map(modeOption => {
1162 let [ name, label, desc ] = modeOption;
1163 let selected = (name == currentMode ? " selected" : "");
1164 let disabled = (name == currentMode ? " disabled" : "");
1165 let active = (( currentMode == "auto"
1166 && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1169 if (name == currentMode)
1170 desc += DarkMode.selectedModeOptionNote;
1173 class="select-mode-${name}${selected}${active}"
1178 >${label}</button>`;
1180 + `</${selectorTagName}>`;
1183 injectModeSelector: (replacedElement = null) => {
1184 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1186 // Inject the mode selector widget.
1188 if (replacedElement) {
1189 replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1190 modeSelector = replacedElement.firstElementChild;
1191 unwrap(replacedElement);
1193 modeSelector = DarkMode.modeSelector = addUIElement(DarkMode.modeSelectorHTML());
1196 // Add event listeners and update state.
1197 requestAnimationFrame(() => {
1198 // Activate mode selector widget buttons.
1199 modeSelector.querySelectorAll("button").forEach(button => {
1200 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1204 /* Add active media query to update mode selector state when system dark
1205 mode setting changes. (This is relevant only for the ‘auto’ setting.)
1207 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => {
1208 DarkMode.updateModeSelectorState(modeSelector);
1212 modeSelectButtonClicked: (event) => {
1213 GWLog("DarkMode.modeSelectButtonClicked");
1215 /* We don’t want clicks to go through if the transition
1216 between modes has not completed yet, so we disable the
1217 button temporarily while we’re transitioning between
1221 // Determine which setting was chosen (ie. which button was clicked).
1222 let selectedMode = event.target.dataset.name;
1224 // Save the new setting.
1225 DarkMode.saveMode(selectedMode);
1227 // Actually change the mode.
1228 DarkMode.setMode(selectedMode);
1229 }, DarkMode, "modeSelectorInteractable");
1231 event.target.blur();
1234 updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1235 GWLog("DarkMode.updateModeSelectorState");
1237 /* If the mode selector has not yet been injected, then do nothing.
1239 if (modeSelector == null)
1242 // Get saved mode setting (or default).
1243 let currentMode = DarkMode.getSavedMode();
1245 // Clear current buttons state.
1246 modeSelector.querySelectorAll("button").forEach(button => {
1247 button.classList.remove("active", "selected");
1248 button.disabled = false;
1249 if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1250 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1253 // Set the correct button to be selected.
1254 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1255 button.classList.add("selected");
1256 button.disabled = true;
1257 button.title += DarkMode.selectedModeOptionNote;
1260 /* Ensure the right button (light or dark) has the “currently active”
1261 indicator, if the current mode is ‘auto’.
1263 if (currentMode == "auto")
1264 modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1269 /***********************************/
1270 /* CONTENT COLUMN WIDTH ADJUSTMENT */
1271 /***********************************/
1273 function injectContentWidthSelector() {
1274 GWLog("injectContentWidthSelector");
1275 // Get saved width setting (or default).
1276 let currentWidth = localStorage.getItem("selected-width") || 'normal';
1278 // Inject the content width selector widget and activate buttons.
1279 let widthSelector = addUIElement(
1280 "<div id='width-selector'>" +
1281 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
1282 let [name, desc, abbr] = widthOption;
1283 let selected = (name == currentWidth ? ' selected' : '');
1284 let disabled = (name == currentWidth ? ' disabled' : '');
1285 return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
1287 widthSelector.queryAll("button").forEach(button => {
1288 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
1289 GWLog("GW.widthAdjustButtonClicked");
1291 // Determine which setting was chosen (i.e., which button was clicked).
1292 let selectedWidth = event.target.dataset.name;
1294 // Save the new setting.
1295 if (selectedWidth == "normal") localStorage.removeItem("selected-width");
1296 else localStorage.setItem("selected-width", selectedWidth);
1298 // Save current visible comment
1299 let visibleComment = getCurrentVisibleComment();
1301 // Actually change the content width.
1302 setContentWidth(selectedWidth);
1303 event.target.parentElement.childNodes.forEach(button => {
1304 button.removeClass("selected");
1305 button.disabled = false;
1307 event.target.addClass("selected");
1308 event.target.disabled = true;
1310 // Make sure the accesskey (to cycle to the next width) is on the right button.
1311 setWidthAdjustButtonsAccesskey();
1313 // Regenerate images overlay.
1314 generateImagesOverlay();
1316 if(visibleComment) visibleComment.scrollIntoView();
1320 // Make sure the accesskey (to cycle to the next width) is on the right button.
1321 setWidthAdjustButtonsAccesskey();
1323 // Inject transitions CSS, if animating changes is enabled.
1324 if (GW.adjustmentTransitions) {
1326 "<style id='width-transition'>" +
1328 #ui-elements-container,
1331 max-width 0.3s ease;
1336 function setWidthAdjustButtonsAccesskey() {
1337 GWLog("setWidthAdjustButtonsAccesskey");
1338 let widthSelector = query("#width-selector");
1339 widthSelector.queryAll("button").forEach(button => {
1340 button.removeAttribute("accesskey");
1341 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1343 let selectedButton = widthSelector.query("button.selected");
1344 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
1345 nextButtonInCycle.accessKey = "'";
1346 nextButtonInCycle.title += ` [\']`;
1349 /*******************/
1350 /* THEME SELECTION */
1351 /*******************/
1353 function injectThemeSelector() {
1354 GWLog("injectThemeSelector");
1355 let currentTheme = readCookie("theme") || "default";
1356 let themeSelector = addUIElement(
1357 "<div id='theme-selector' class='theme-selector'>" +
1358 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
1359 let [name, desc, letter] = themeOption;
1360 let selected = (name == currentTheme ? ' selected' : '');
1361 let disabled = (name == currentTheme ? ' disabled' : '');
1362 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1363 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>`;})) +
1365 themeSelector.queryAll("button").forEach(button => {
1366 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
1367 GWLog("GW.themeSelectButtonClicked");
1368 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
1369 setSelectedTheme(themeName);
1370 if (GW.isMobile) toggleAppearanceAdjustUI();
1374 // Inject transitions CSS, if animating changes is enabled.
1375 if (GW.adjustmentTransitions) {
1377 "<style id='theme-fade-transition'>" +
1380 opacity 0.5s ease-out,
1381 background-color 0.3s ease-out;
1384 background-color: #777;
1387 opacity 0.5s ease-in,
1388 background-color 0.3s ease-in;
1393 function setSelectedTheme(themeName) {
1394 GWLog("setSelectedTheme");
1395 queryAll(".theme-selector button").forEach(button => {
1396 button.removeClass("selected");
1397 button.disabled = false;
1399 queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
1400 button.addClass("selected");
1401 button.disabled = true;
1403 setTheme(themeName);
1404 query("#theme-tweaker-ui .current-theme span").innerText = themeName;
1406 function setTheme(newThemeName) {
1407 var themeUnloadCallback = '';
1408 var oldThemeName = '';
1409 if (typeof(newThemeName) == 'undefined') {
1410 newThemeName = readCookie('theme');
1411 if (!newThemeName) return;
1413 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
1414 oldThemeName = readCookie('theme') || 'default';
1416 if (newThemeName == 'default') setCookie('theme', '');
1417 else setCookie('theme', newThemeName);
1419 if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
1421 let makeNewStyle = function(newThemeName, colorSchemePreference) {
1422 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
1423 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1425 let newStyle = document.createElement('link');
1426 newStyle.setAttribute('class', 'theme');
1427 if(colorSchemePreference)
1428 newStyle.setAttribute('media', '(prefers-color-scheme: ' + colorSchemePreference + ')');
1429 newStyle.setAttribute('rel', 'stylesheet');
1430 newStyle.setAttribute('href', '/css/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1434 let newMainStyle, newStyles;
1435 if(newThemeName === 'default') {
1436 newStyles = [makeNewStyle('dark', 'dark'), makeNewStyle('default', 'light')];
1437 newMainStyle = (window.matchMedia('prefers-color-scheme: dark').matches ? newStyles[0] : newStyles[1]);
1439 newStyles = [makeNewStyle(newThemeName)];
1440 newMainStyle = newStyles[0];
1443 let oldStyles = queryAll("head link.theme");
1444 newMainStyle.addEventListener('load', () => { oldStyles.forEach(x => removeElement(x)); });
1445 newMainStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
1447 if (GW.adjustmentTransitions) {
1448 pageFadeTransition(false);
1450 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1453 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1456 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
1457 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1458 document.body.addClass("theme-" + newThemeName);
1460 recomputeUIElementsContainerHeight(true);
1462 let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
1463 if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
1465 recomputeUIElementsContainerHeight();
1466 adjustUIForWindowSize();
1467 window.addEventListener('resize', GW.windowResized = (event) => {
1468 GWLog("GW.windowResized");
1469 adjustUIForWindowSize();
1470 recomputeUIElementsContainerHeight();
1473 generateImagesOverlay();
1475 if (window.adjustmentTransitions) pageFadeTransition(true);
1476 updateThemeTweakerSampleText();
1478 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
1479 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1483 function pageFadeTransition(fadeIn) {
1485 query("body").removeClass("transparent");
1487 query("body").addClass("transparent");
1491 GW.themeLoadCallback_less = (fromTheme = "") => {
1492 GWLog("themeLoadCallback_less");
1493 injectSiteNavUIToggle();
1495 injectPostNavUIToggle();
1496 injectAppearanceAdjustUIToggle();
1499 registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1500 let dtf = new Intl.DateTimeFormat([],
1501 (window.innerWidth < 1100) ?
1502 { month: 'short', day: 'numeric', year: 'numeric' } :
1503 { month: 'long', day: 'numeric', year: 'numeric' });
1504 let postDate = query(".top-post-meta .date");
1505 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1509 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1513 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1514 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1515 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1519 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1520 // If state is not set (user has never clicked on the Less theme's appearance
1521 // adjustment UI toggle) then show it, but then hide it after a short time.
1522 registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1523 toggleAppearanceAdjustUI();
1524 setTimeout(toggleAppearanceAdjustUI, 3000);
1528 if (fromTheme != "") {
1529 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1530 setTimeout(function () {
1531 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1533 setTimeout(function () {
1534 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1538 // Unset the height of the #ui-elements-container.
1539 query("#ui-elements-container").style.height = "";
1541 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1542 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1543 "#content #secondary-bar",
1544 "#content .post .top-post-meta .date",
1545 "#content .post .top-post-meta .comment-count",
1547 applyFilters(GW.currentFilters);
1550 // We pre-query the relevant elements, so we don't have to run querySelectorAll
1551 // on every firing of the scroll listener.
1553 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1554 "unbrokenDownScrollDistance": 0,
1555 "unbrokenUpScrollDistance": 0,
1556 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1557 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1558 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1560 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1563 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
1564 // otherwise, show it.
1565 function updatePostNavUIVisibility() {
1566 GWLog("updatePostNavUIVisibility");
1567 var hidePostNavUIToggle = true;
1568 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1569 if (getComputedStyle(element).visibility == "visible" ||
1570 element.style.visibility == "visible" ||
1571 element.style.visibility == "unset")
1572 hidePostNavUIToggle = false;
1574 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1575 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1579 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1580 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
1581 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1582 function updateSiteNavUIState(event) {
1583 GWLog("updateSiteNavUIState");
1584 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1585 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
1586 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
1588 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1589 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1591 GW.scrollState.lastScrollTop = newScrollTop;
1593 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1594 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1595 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1596 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1599 // On mobile, make site nav UI translucent on ANY scroll down.
1601 GW.scrollState.siteNavUIElements.forEach(element => {
1602 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1603 else element.removeClass("translucent-on-scroll");
1606 // Show site nav UI when scrolling a full page up, or to the top.
1607 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
1608 GW.scrollState.lastScrollTop == 0) &&
1609 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
1610 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1612 // On desktop, show appearance adjust UI when scrolling to the top.
1613 if ((!GW.isMobile) &&
1614 (GW.scrollState.lastScrollTop == 0) &&
1615 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
1616 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1619 GW.themeUnloadCallback_less = (toTheme = "") => {
1620 GWLog("themeUnloadCallback_less");
1621 removeSiteNavUIToggle();
1623 removePostNavUIToggle();
1624 removeAppearanceAdjustUIToggle();
1626 window.removeEventListener('resize', updatePostNavUIVisibility);
1628 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1630 removeElement("#theme-less-mobile-first-row-placeholder");
1634 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1635 element.innerHTML = element.firstChild.innerHTML;
1639 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1641 // Reset filtered elements selector to default.
1642 delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1643 applyFilters(GW.currentFilters);
1646 GW.themeLoadCallback_dark = (fromTheme = "") => {
1647 GWLog("themeLoadCallback_dark");
1649 "<style id='dark-theme-adjustments'>" +
1650 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` +
1651 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1653 registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1654 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1655 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)";
1656 image.style.width = parseInt(image.style.width) + 12 + "px";
1657 image.style.height = parseInt(image.style.height) + 12 + "px";
1658 image.style.top = parseInt(image.style.top) - 6 + "px";
1659 image.style.left = parseInt(image.style.left) - 6 + "px";
1663 GW.themeUnloadCallback_dark = (toTheme = "") => {
1664 GWLog("themeUnloadCallback_dark");
1665 removeElement("#dark-theme-adjustments");
1668 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1669 GWLog("themeLoadCallback_brutalist");
1670 let bottomBarLinks = queryAll("#bottom-bar a");
1671 if (!GW.isMobile && bottomBarLinks.length == 5) {
1672 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1673 bottomBarLinks.forEach((link, i) => {
1674 link.dataset.originalText = link.textContent;
1675 link.textContent = newLinkTexts[i];
1679 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1680 GWLog("themeUnloadCallback_brutalist");
1681 let bottomBarLinks = queryAll("#bottom-bar a");
1682 if (!GW.isMobile && bottomBarLinks.length == 5) {
1683 bottomBarLinks.forEach(link => {
1684 link.textContent = link.dataset.originalText;
1689 GW.themeLoadCallback_classic = (fromTheme = "") => {
1690 GWLog("themeLoadCallback_classic");
1691 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1692 button.innerHTML = "";
1695 GW.themeUnloadCallback_classic = (toTheme = "") => {
1696 GWLog("themeUnloadCallback_classic");
1697 if (GW.isMobile && window.innerWidth <= 900) return;
1698 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1699 button.innerHTML = button.dataset.label;
1703 /********************************************/
1704 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1705 /********************************************/
1707 function injectThemeTweaker() {
1708 GWLog("injectThemeTweaker");
1709 let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" +
1710 `<div class='main-theme-tweaker-window'>
1711 <h1>Customize appearance</h1>
1712 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1713 <button type='button' class='help-button' tabindex='-1'></button>
1714 <p class='current-theme'>Current theme: <span>` +
1715 (readCookie("theme") || "default") +
1717 <p class='theme-selector'></p>
1718 <div class='controls-container'>
1719 <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1720 <div class='sample-text-container'><span class='sample-text'>
1721 <p>Less Wrong (text)</p>
1722 <p><a href="#">Less Wrong (link)</a></p>
1725 <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1726 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1727 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1728 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1730 <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1731 <input type='checkbox' id='theme-tweak-control-invert'></input>
1732 <label for='theme-tweak-control-invert'>Invert colors</label>
1734 <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1735 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1736 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1737 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1739 <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1740 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1741 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1742 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1744 <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1745 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1746 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1747 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1749 <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1750 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1751 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1752 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1755 <div class='buttons-container'>
1756 <button type="button" class="reset-defaults-button">Reset to defaults</button>
1757 <button type='button' class='ok-button default-button'>OK</button>
1758 <button type='button' class='cancel-button'>Cancel</button>
1761 <div class="clippy-container">
1762 <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>)
1763 <div class='clippy'></div>
1764 <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>
1766 <div class='help-window' style='display: none;'>
1767 <h1>Theme tweaker help</h1>
1768 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1769 <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1770 <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1772 <div class='buttons-container'>
1773 <button type='button' class='ok-button default-button'>OK</button>
1774 <button type='button' class='cancel-button'>Cancel</button>
1779 // Clicking the background overlay closes the theme tweaker.
1780 themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1781 GWLog("GW.themeTweaker.UIOverlayClicked");
1782 if (event.type == 'mousedown') {
1783 themeTweakerUI.style.opacity = "0.01";
1785 toggleThemeTweakerUI();
1786 themeTweakerUI.style.opacity = "1.0";
1791 // Intercept clicks, so they don't "fall through" the background overlay.
1792 (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1794 let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1795 themeTweakerUI.queryAll("input").forEach(field => {
1796 // All input types in the theme tweaker receive a 'change' event when
1797 // their value is changed. (Range inputs, in particular, receive this
1798 // event when the user lets go of the handle.) This means we should
1799 // update the filters for the entire page, to match the new setting.
1800 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1801 GWLog("GW.themeTweaker.fieldValueChanged");
1802 if (event.target.id == 'theme-tweak-control-invert') {
1803 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1804 } else if (event.target.type == 'range') {
1805 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1806 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1807 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1808 } else if (event.target.id == 'theme-tweak-control-clippy') {
1809 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1811 // Clear the sample text filters.
1812 sampleTextContainer.style.filter = "";
1813 // Apply the new filters globally.
1814 applyFilters(GW.currentFilters);
1817 // Range inputs receive an 'input' event while being scrubbed, updating
1818 // "live" as the handle is moved. We don't want to change the filters
1819 // for the actual page while this is happening, but we do want to change
1820 // the filters for the *sample text*, so the user can see what effects
1821 // his changes are having, live, without having to let go of the handle.
1822 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1823 GWLog("GW.themeTweaker.fieldInputReceived");
1824 var sampleTextFilters = GW.currentFilters;
1826 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1827 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1828 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1830 sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1834 themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1835 GWLog("GW.themeTweaker.minimizeButtonClicked");
1836 let themeTweakerStyle = query("#theme-tweaker-style");
1838 if (event.target.hasClass("minimize")) {
1839 event.target.removeClass("minimize");
1840 themeTweakerStyle.innerHTML =
1841 `#theme-tweaker-ui .main-theme-tweaker-window {
1845 padding: 30px 0 0 0;
1850 #theme-tweaker-ui::after {
1854 #theme-tweaker-ui::before {
1858 #theme-tweaker-ui .clippy-container {
1861 #theme-tweaker-ui .clippy-container .hint span {
1867 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1868 pointer-events: none;
1870 event.target.addClass("maximize");
1872 event.target.removeClass("maximize");
1873 themeTweakerStyle.innerHTML =
1874 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1875 pointer-events: none;
1877 event.target.addClass("minimize");
1880 themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1881 GWLog("GW.themeTweaker.helpButtonClicked");
1882 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1883 toggleThemeTweakerHelpWindow();
1885 themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1886 GWLog("GW.themeTweaker.resetDefaultsButtonClicked");
1887 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1888 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1889 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1890 slider.value = slider.dataset['defaultValue'];
1891 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1893 GW.currentFilters = { };
1894 applyFilters(GW.currentFilters);
1896 GW.currentTextZoom = "1.0";
1897 setTextZoom(GW.currentTextZoom);
1899 setSelectedTheme("default");
1901 themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1902 GWLog("GW.themeTweaker.cancelButtonClicked");
1903 toggleThemeTweakerUI();
1906 themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1907 GWLog("GW.themeTweaker.OKButtonClicked");
1908 toggleThemeTweakerUI();
1911 themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1912 GWLog("GW.themeTweaker.helpWindowCancelButtonClicked");
1913 toggleThemeTweakerHelpWindow();
1914 themeTweakerResetSettings();
1916 themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1917 GWLog("GW.themeTweaker.helpWindowOKButtonClicked");
1918 toggleThemeTweakerHelpWindow();
1919 themeTweakerSaveSettings();
1922 themeTweakerUI.queryAll(".notch").forEach(notch => {
1923 notch.addActivateEvent(GW.themeTweaker.sliderNotchClicked = (event) => {
1924 GWLog("GW.themeTweaker.sliderNotchClicked");
1925 let slider = event.target.parentElement.query("input[type='range']");
1926 slider.value = slider.dataset['defaultValue'];
1927 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1928 GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1929 applyFilters(GW.currentFilters);
1933 themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1934 GWLog("GW.themeTweaker.clippyCloseButtonClicked");
1935 themeTweakerUI.query(".clippy-container").style.display = "none";
1936 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1937 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1940 query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1942 themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1943 themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1944 button.addActivateEvent(GW.themeSelectButtonClicked);
1947 themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1948 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1951 let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'></button></div>`);
1952 themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1953 GWLog("GW.themeTweaker.toggleButtonClicked");
1954 GW.themeTweakerStyleSheetAvailable = () => {
1955 GWLog("GW.themeTweakerStyleSheetAvailable");
1956 themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1958 themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1959 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1960 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1961 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1962 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1965 toggleThemeTweakerUI();
1966 event.target.disabled = true;
1969 if (query("link[href^='/css/theme_tweaker.css']")) {
1970 // Theme tweaker CSS is already loaded.
1971 GW.themeTweakerStyleSheetAvailable();
1973 // Load the theme tweaker CSS (if not loaded).
1974 let themeTweakerStyleSheet = document.createElement('link');
1975 themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1976 themeTweakerStyleSheet.setAttribute('href', '/css/theme_tweaker.css');
1977 themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1978 query("head").appendChild(themeTweakerStyleSheet);
1982 function toggleThemeTweakerUI() {
1983 GWLog("toggleThemeTweakerUI");
1984 let themeTweakerUI = query("#theme-tweaker-ui");
1985 themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1986 query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" :
1987 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1988 pointer-events: none;
1990 if (themeTweakerUI.style.display != "none") {
1991 // Save selected theme.
1992 GW.currentTheme = (readCookie("theme") || "default");
1993 // Focus invert checkbox.
1994 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1995 // Show sample text in appropriate font.
1996 updateThemeTweakerSampleText();
1997 // Disable tab-selection of the search box.
1998 setSearchBoxTabSelectable(false);
1999 // Disable scrolling of the page.
2000 togglePageScrolling(false);
2002 query("#theme-tweaker-toggle button").disabled = false;
2003 // Re-enable tab-selection of the search box.
2004 setSearchBoxTabSelectable(true);
2005 // Re-enable scrolling of the page.
2006 togglePageScrolling(true);
2008 // Set theme tweaker assistant visibility.
2009 query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
2011 function setSearchBoxTabSelectable(selectable) {
2012 GWLog("setSearchBoxTabSelectable");
2013 query("input[type='search']").tabIndex = selectable ? "" : "-1";
2014 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2016 function toggleThemeTweakerHelpWindow() {
2017 GWLog("toggleThemeTweakerHelpWindow");
2018 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
2019 themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
2020 if (themeTweakerHelpWindow.style.display != "none") {
2021 // Focus theme tweaker assistant checkbox.
2022 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
2023 // Disable interaction on main theme tweaker window.
2024 query("#theme-tweaker-ui").style.pointerEvents = "none";
2025 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
2027 // Re-enable interaction on main theme tweaker window.
2028 query("#theme-tweaker-ui").style.pointerEvents = "auto";
2029 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
2032 function themeTweakReset() {
2033 GWLog("themeTweakReset");
2034 setSelectedTheme(GW.currentTheme);
2035 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
2036 applyFilters(GW.currentFilters);
2037 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
2038 setTextZoom(GW.currentTextZoom);
2040 function themeTweakSave() {
2041 GWLog("themeTweakSave");
2042 GW.currentTheme = (readCookie("theme") || "default");
2043 localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
2044 localStorage.setItem("text-zoom", GW.currentTextZoom);
2047 function themeTweakerResetSettings() {
2048 GWLog("themeTweakerResetSettings");
2049 query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
2050 query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
2052 function themeTweakerSaveSettings() {
2053 GWLog("themeTweakerSaveSettings");
2054 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
2056 function updateThemeTweakerSampleText() {
2057 GWLog("updateThemeTweakerSampleText");
2058 let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
2060 // This causes the sample text to take on the properties of the body text of a post.
2061 sampleText.removeClass("body-text");
2062 let bodyTextElement = query(".post-body") || query(".comment-body");
2063 sampleText.addClass("body-text");
2064 sampleText.style.color = bodyTextElement ?
2065 getComputedStyle(bodyTextElement).color :
2066 getComputedStyle(query("#content")).color;
2068 // Here we find out what is the actual background color that will be visible behind
2069 // the body text of posts, and set the sample text’s background to that.
2070 let findStyleBackground = (selector) => {
2072 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2073 if(rule.selectorText == selector)
2076 return x.style.backgroundColor;
2079 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2082 /*********************/
2083 /* PAGE QUICK-NAV UI */
2084 /*********************/
2086 function injectQuickNavUI() {
2087 GWLog("injectQuickNavUI");
2088 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2089 `<a href='#top' title="Up to top [,]" accesskey=','></a>
2090 <a href='#comments' title="Comments [/]" accesskey='/'></a>
2091 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
2095 /**********************/
2096 /* NEW COMMENT NAV UI */
2097 /**********************/
2099 function injectNewCommentNavUI(newCommentsCount) {
2100 GWLog("injectNewCommentNavUI");
2101 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
2102 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
2103 <span class='new-comments-count'></span>
2104 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
2107 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2108 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2109 GWLog("GW.commentQuicknavButtonClicked");
2110 scrollToNewComment(/next/.test(event.target.className));
2111 event.target.blur();
2115 document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => {
2116 GWLog("GW.commentQuicknavKeyPressed");
2117 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2118 if (event.key == ",") scrollToNewComment(false);
2119 if (event.key == ".") scrollToNewComment(true)
2122 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2123 + `<span>Since:</span>`
2124 + `<input type='text' class='hns-date'></input>`
2127 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2128 GWLog("GW.hnsDatePickerValueChanged");
2129 let hnsDate = time_fromHuman(event.target.value);
2131 setHistoryLastVisitedDate(hnsDate);
2132 let newCommentsCount = highlightCommentsSince(hnsDate);
2133 updateNewCommentNavUI(newCommentsCount);
2137 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2138 GWLog("GW.newCommentsCountClicked");
2139 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2140 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2144 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2145 function time_fromHuman(string) {
2146 /* Convert a human-readable date into a JS timestamp */
2147 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2148 string = string.replace(' ', 'T'); // revert nice spacing
2149 string += ':00.000Z'; // complete ISO 8601 date
2150 time = Date.parse(string); // milliseconds since epoch
2152 // browsers handle ISO 8601 without explicit timezone differently
2153 // thus, we have to fix that by hand
2154 time += (new Date()).getTimezoneOffset() * 60e3;
2156 string = string.replace(' at', '');
2157 time = Date.parse(string); // milliseconds since epoch
2162 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2163 GWLog("updateNewCommentNavUI");
2164 // Update the new comments count.
2165 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2166 newCommentsCountLabel.innerText = newCommentsCount;
2167 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2169 // Update the date picker field.
2170 if (hnsDate != -1) {
2171 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2175 /***************************/
2176 /* TEXT SIZE ADJUSTMENT UI */
2177 /***************************/
2179 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
2180 GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
2181 var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
2182 if (event.target.hasClass("decrease")) {
2183 zoomFactor = (zoomFactor - 0.05).toFixed(2);
2184 } else if (event.target.hasClass("increase")) {
2185 zoomFactor = (zoomFactor + 0.05).toFixed(2);
2189 setTextZoom(zoomFactor);
2190 GW.currentTextZoom = `${zoomFactor}`;
2192 if (event.target.parentElement.id == "text-size-adjustment-ui") {
2193 localStorage.setItem("text-zoom", GW.currentTextZoom);
2197 function injectTextSizeAdjustmentUIReal() {
2198 GWLog("injectTextSizeAdjustmentUIReal");
2199 let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
2200 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
2201 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
2202 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
2205 textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
2206 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
2209 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
2212 function injectTextSizeAdjustmentUI() {
2213 GWLog("injectTextSizeAdjustmentUI");
2214 if (query("#text-size-adjustment-ui") != null) return;
2215 if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
2216 else document.addEventListener("DOMContentLoaded", () => {
2217 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
2221 /********************************/
2222 /* COMMENTS VIEW MODE SELECTION */
2223 /********************************/
2225 function injectCommentsViewModeSelector() {
2226 GWLog("injectCommentsViewModeSelector");
2227 let commentsContainer = query("#comments");
2228 if (commentsContainer == null) return;
2230 let currentModeThreaded = (location.href.search("chrono=t") == -1);
2231 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2233 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2234 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
2235 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
2238 // commentsViewModeSelector.queryAll("a").forEach(button => {
2239 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2242 if (!currentModeThreaded) {
2243 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2244 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2245 commentParentLink.addClass("inline-author");
2246 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2249 queryAll(".comment-child-links a").forEach(commentChildLink => {
2250 commentChildLink.textContent = commentChildLink.textContent.slice(1);
2251 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2254 rectifyChronoModeCommentChildLinks();
2256 commentsContainer.addClass("chrono");
2258 commentsContainer.addClass("threaded");
2261 // Remove extraneous top-level comment thread in chrono mode.
2262 let topLevelCommentThread = query("#comments > .comment-thread");
2263 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2266 // function commentsViewModeSelectorButtonClicked(event) {
2267 // event.preventDefault();
2270 // let request = new XMLHttpRequest();
2271 // request.open("GET", event.target.href);
2272 // request.onreadystatechange = () => {
2273 // if (request.readyState != 4) return;
2274 // newDocument = htmlToElement(request.response);
2276 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2278 // // Update the buttons.
2279 // event.target.addClass("selected");
2280 // event.target.parentElement.query("." + classes.old).removeClass("selected");
2282 // // Update the #comments container.
2283 // let commentsContainer = query("#comments");
2284 // commentsContainer.removeClass(classes.old);
2285 // commentsContainer.addClass(classes.new);
2287 // // Update the content.
2288 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2293 // function htmlToElement(html) {
2294 // var template = document.createElement('template');
2295 // template.innerHTML = html.trim();
2296 // return template.content;
2299 function rectifyChronoModeCommentChildLinks() {
2300 GWLog("rectifyChronoModeCommentChildLinks");
2301 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2302 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2303 let childLinks = commentChildLinksContainer.queryAll("a");
2304 childLinks.forEach((link, index) => {
2305 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2309 let childLinksArray = Array.from(childLinks)
2310 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2311 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2314 function childrenOfComment(commentID) {
2315 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2316 let commentParentLink = commentItem.query("a.comment-parent-link");
2317 return ((commentParentLink||{}).hash == "#" + commentID);
2321 /********************************/
2322 /* COMMENTS LIST MODE SELECTION */
2323 /********************************/
2325 function injectCommentsListModeSelector() {
2326 GWLog("injectCommentsListModeSelector");
2327 if (query("#content > .comment-thread") == null) return;
2329 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2330 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2331 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2334 if (query(".sublevel-nav") || query("#top-nav-bar")) {
2335 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2337 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2339 let commentsListModeSelector = query("#comments-list-mode-selector");
2341 commentsListModeSelector.queryAll("button").forEach(button => {
2342 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2343 GWLog("GW.commentsListModeSelectButtonClicked");
2344 event.target.parentElement.queryAll("button").forEach(button => {
2345 button.removeClass("selected");
2346 button.disabled = false;
2347 button.accessKey = '`';
2349 localStorage.setItem("comments-list-mode", event.target.className);
2350 event.target.addClass("selected");
2351 event.target.disabled = true;
2352 event.target.removeAttribute("accesskey");
2354 if (event.target.hasClass("expanded")) {
2355 query("#content").removeClass("compact");
2357 query("#content").addClass("compact");
2362 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2363 if (savedMode == "compact")
2364 query("#content").addClass("compact");
2365 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2366 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2367 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2370 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2371 commentParentLink.addActivateEvent(function (event) {
2372 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2373 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2379 /**********************/
2380 /* SITE NAV UI TOGGLE */
2381 /**********************/
2383 function injectSiteNavUIToggle() {
2384 GWLog("injectSiteNavUIToggle");
2385 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2386 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2387 GWLog("GW.siteNavUIToggleButtonClicked");
2389 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2392 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2394 function removeSiteNavUIToggle() {
2395 GWLog("removeSiteNavUIToggle");
2396 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2397 element.removeClass("engaged");
2399 removeElement("#site-nav-ui-toggle");
2401 function toggleSiteNavUI() {
2402 GWLog("toggleSiteNavUI");
2403 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2404 element.toggleClass("engaged");
2405 element.removeClass("translucent-on-scroll");
2409 /**********************/
2410 /* POST NAV UI TOGGLE */
2411 /**********************/
2413 function injectPostNavUIToggle() {
2414 GWLog("injectPostNavUIToggle");
2415 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2416 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2417 GWLog("GW.postNavUIToggleButtonClicked");
2419 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2422 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2424 function removePostNavUIToggle() {
2425 GWLog("removePostNavUIToggle");
2426 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2427 element.removeClass("engaged");
2429 removeElement("#post-nav-ui-toggle");
2431 function togglePostNavUI() {
2432 GWLog("togglePostNavUI");
2433 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2434 element.toggleClass("engaged");
2438 /*******************************/
2439 /* APPEARANCE ADJUST UI TOGGLE */
2440 /*******************************/
2442 function injectAppearanceAdjustUIToggle() {
2443 GWLog("injectAppearanceAdjustUIToggle");
2444 let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2445 appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
2446 GWLog("GW.appearanceAdjustUIToggleButtonClicked");
2447 toggleAppearanceAdjustUI();
2448 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
2452 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
2453 themeSelectorCloseButton.addClass("theme-selector-close-button");
2454 themeSelectorCloseButton.innerHTML = "";
2455 query("#theme-selector").appendChild(themeSelectorCloseButton);
2456 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
2458 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
2461 function removeAppearanceAdjustUIToggle() {
2462 GWLog("removeAppearanceAdjustUIToggle");
2463 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2464 element.removeClass("engaged");
2466 removeElement("#appearance-adjust-ui-toggle");
2468 function toggleAppearanceAdjustUI() {
2469 GWLog("toggleAppearanceAdjustUI");
2470 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2471 element.toggleClass("engaged");
2475 /**************************/
2476 /* WORD COUNT & READ TIME */
2477 /**************************/
2479 function toggleReadTimeOrWordCount(addWordCountClass) {
2480 GWLog("toggleReadTimeOrWordCount");
2481 queryAll(".post-meta .read-time").forEach(element => {
2482 if (addWordCountClass) element.addClass("word-count");
2483 else element.removeClass("word-count");
2485 let titleParts = /(\S+)(.+)$/.exec(element.title);
2486 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2490 /**************************/
2491 /* PROMPT TO SAVE CHANGES */
2492 /**************************/
2494 function enableBeforeUnload() {
2495 window.onbeforeunload = function () { return true; };
2497 function disableBeforeUnload() {
2498 window.onbeforeunload = null;
2501 /***************************/
2502 /* ORIGINAL POSTER BADGING */
2503 /***************************/
2505 function markOriginalPosterComments() {
2506 GWLog("markOriginalPosterComments");
2507 let postAuthor = query(".post .author");
2508 if (postAuthor == null) return;
2510 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2511 if (author.dataset.userid == postAuthor.dataset.userid ||
2512 (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2513 author.addClass("original-poster");
2514 author.title += "Original poster";
2519 /********************************/
2520 /* EDIT POST PAGE SUBMIT BUTTON */
2521 /********************************/
2523 function setEditPostPageSubmitButtonText() {
2524 GWLog("setEditPostPageSubmitButtonText");
2525 if (!query("#content").hasClass("edit-post-page")) return;
2527 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2528 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2529 GWLog("GW.postSectionSelectorValueChanged");
2530 updateEditPostPageSubmitButtonText();
2534 updateEditPostPageSubmitButtonText();
2536 function updateEditPostPageSubmitButtonText() {
2537 GWLog("updateEditPostPageSubmitButtonText");
2538 let submitButton = query("input[type='submit']");
2539 if (query("input#drafts").checked == true)
2540 submitButton.value = "Save Draft";
2541 else if (query(".posting-controls").hasClass("edit-existing-post"))
2542 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2544 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2551 function numToAlpha(n) {
2554 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2555 n = Math.floor((n / 26) - 1);
2560 function injectAntiKibitzer() {
2561 GWLog("injectAntiKibitzer");
2562 // Inject anti-kibitzer toggle controls.
2563 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>");
2564 antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2565 GWLog("GW.antiKibitzerToggleButtonClicked");
2566 if (query("#anti-kibitzer-toggle").hasClass("engaged") &&
2568 !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!)")) {
2569 event.target.blur();
2573 toggleAntiKibitzerMode();
2574 event.target.blur();
2577 // Activate anti-kibitzer mode (if needed).
2578 if (localStorage.getItem("antikibitzer") == "true")
2579 toggleAntiKibitzerMode();
2581 // Remove temporary CSS that hides the authors and karma values.
2582 removeElement("#antikibitzer-temp");
2585 function toggleAntiKibitzerMode() {
2586 GWLog("toggleAntiKibitzerMode");
2587 // This will be the URL of the user's own page, if logged in, or the URL of
2588 // the login page otherwise.
2589 let userTabTarget = query("#nav-item-login .nav-inner").href;
2590 let pageHeadingElement = query("h1.page-main-heading");
2593 let userFakeName = { };
2595 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2597 let postAuthor = query(".post-page .post-meta .author");
2598 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2600 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2601 if (antiKibitzerToggle.hasClass("engaged")) {
2602 localStorage.setItem("antikibitzer", "false");
2604 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2605 if (redirectTarget) {
2606 window.location = redirectTarget;
2610 // Individual comment page title and header
2611 if (query(".individual-thread-page")) {
2612 let replacer = (node) => {
2614 node.firstChild.replaceWith(node.dataset["trueContent"]);
2616 replacer(query("title:not(.fake-title)"));
2617 replacer(query("#content > h1"));
2620 // Author names/links.
2621 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2622 author.textContent = author.dataset["trueName"];
2623 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2625 author.removeClass("redacted");
2627 // Post/comment karma values.
2628 queryAll(".karma-value.redacted").forEach(karmaValue => {
2629 karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2631 karmaValue.removeClass("redacted");
2633 // Link post domains.
2634 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2635 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2637 linkPostDomain.removeClass("redacted");
2640 antiKibitzerToggle.removeClass("engaged");
2642 localStorage.setItem("antikibitzer", "true");
2644 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2645 if (redirectTarget) {
2646 window.location = redirectTarget;
2650 // Individual comment page title and header
2651 if (query(".individual-thread-page")) {
2652 let replacer = (node) => {
2654 node.dataset["trueContent"] = node.firstChild.wholeText;
2655 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2656 node.firstChild.replaceWith(newText);
2658 replacer(query("title:not(.fake-title)"));
2659 replacer(query("#content > h1"));
2662 removeElement("title.fake-title");
2664 // Author names/links.
2665 queryAll(".author, .inline-author").forEach(author => {
2666 // Skip own posts/comments.
2667 if (author.hasClass("own-user-author"))
2670 let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2674 author.dataset["trueName"] = author.textContent;
2675 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2677 if (/\/user/.test(author.href)) {
2678 author.dataset["trueLink"] = author.pathname;
2679 author.href = "/user?id=" + author.dataset["userid"];
2682 author.addClass("redacted");
2684 // Post/comment karma values.
2685 queryAll(".karma-value").forEach(karmaValue => {
2686 // Skip own posts/comments.
2687 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2690 karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2691 karmaValue.innerHTML = "##<span> points</span>";
2693 karmaValue.addClass("redacted");
2695 // Link post domains.
2696 queryAll(".link-post-domain").forEach(linkPostDomain => {
2697 // Skip own posts/comments.
2698 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2701 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2702 linkPostDomain.textContent = "redacted.domain.tld";
2704 linkPostDomain.addClass("redacted");
2707 antiKibitzerToggle.addClass("engaged");
2711 /*******************************/
2712 /* COMMENT SORT MODE SELECTION */
2713 /*******************************/
2715 var CommentSortMode = Object.freeze({
2721 function sortComments(mode) {
2722 GWLog("sortComments");
2723 let commentsContainer = query("#comments");
2725 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2726 commentsContainer.addClass("sorting");
2728 GW.commentValues = { };
2729 let clonedCommentsContainer = commentsContainer.cloneNode(true);
2730 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2733 case CommentSortMode.NEW:
2734 comparator = (a,b) => commentDate(b) - commentDate(a);
2736 case CommentSortMode.OLD:
2737 comparator = (a,b) => commentDate(a) - commentDate(b);
2739 case CommentSortMode.HOT:
2740 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2742 case CommentSortMode.TOP:
2744 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2747 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2749 removeElement(commentsContainer.lastChild);
2750 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2751 GW.commentValues = { };
2753 if (loggedInUserId) {
2754 // Re-activate vote buttons.
2755 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2756 voteButton.addActivateEvent(voteButtonClicked);
2759 // Re-activate comment action buttons.
2760 commentsContainer.queryAll(".action-button").forEach(button => {
2761 button.addActivateEvent(GW.commentActionButtonClicked);
2765 // Re-activate comment-minimize buttons.
2766 queryAll(".comment-minimize-button").forEach(button => {
2767 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2770 // Re-add comment parent popups.
2771 addCommentParentPopups();
2773 // Redo new-comments highlighting.
2774 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2776 requestAnimationFrame(() => {
2777 commentsContainer.removeClass("sorting");
2778 commentsContainer.addClass("sorted-" + mode);
2781 function commentKarmaValue(commentOrSelector) {
2782 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2783 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2785 function commentDate(commentOrSelector) {
2786 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2787 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2789 function commentVoteCount(commentOrSelector) {
2790 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2791 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2794 function injectCommentsSortModeSelector() {
2795 GWLog("injectCommentsSortModeSelector");
2796 let topCommentThread = query("#comments > .comment-thread");
2797 if (topCommentThread == null) return;
2799 // Do not show sort mode selector if there is no branching in comment tree.
2800 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2802 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
2803 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
2805 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2806 let commentsSortModeSelector = query("#comments-sort-mode-selector");
2808 commentsSortModeSelector.queryAll("button").forEach(button => {
2809 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2810 GWLog("GW.commentsSortModeSelectButtonClicked");
2811 event.target.parentElement.queryAll("button").forEach(button => {
2812 button.removeClass("selected");
2813 button.disabled = false;
2815 event.target.addClass("selected");
2816 event.target.disabled = true;
2818 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2819 setCommentsSortModeSelectButtonsAccesskey();
2823 // TODO: Make this actually get the current sort mode (if that's saved).
2824 // TODO: Also change the condition here to properly get chrono/threaded mode,
2825 // when that is properly done with cookies.
2826 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2827 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2828 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2829 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2830 setCommentsSortModeSelectButtonsAccesskey();
2833 function setCommentsSortModeSelectButtonsAccesskey() {
2834 GWLog("setCommentsSortModeSelectButtonsAccesskey");
2835 queryAll("#comments-sort-mode-selector button").forEach(button => {
2836 button.removeAttribute("accesskey");
2837 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2839 let selectedButton = query("#comments-sort-mode-selector button.selected");
2840 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2841 nextButtonInCycle.accessKey = "z";
2842 nextButtonInCycle.title += " [z]";
2845 /*************************/
2846 /* COMMENT PARENT POPUPS */
2847 /*************************/
2849 function previewPopupsEnabled() {
2850 let isDisabled = localStorage.getItem("preview-popups-disabled");
2851 return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
2854 function setPreviewPopupsEnabled(state) {
2855 localStorage.setItem("preview-popups-disabled", !state);
2856 updatePreviewPopupToggle();
2859 function updatePreviewPopupToggle() {
2860 let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
2861 query("#preview-popup-toggle").setAttribute("style", style);
2864 function injectPreviewPopupToggle() {
2865 GWLog("injectPreviewPopupToggle");
2867 let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
2868 // This is required because Chrome can't use filters on an externally used SVG element.
2869 fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
2870 updatePreviewPopupToggle();
2871 toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
2874 var currentPreviewPopup = { };
2876 function removePreviewPopup(previewPopup) {
2877 if(previewPopup.element)
2878 removeElement(previewPopup.element);
2880 if(previewPopup.timeout)
2881 clearTimeout(previewPopup.timeout);
2883 if(currentPreviewPopup.pointerListener)
2884 window.removeEventListener("pointermove", previewPopup.pointerListener);
2886 if(currentPreviewPopup.mouseoutListener)
2887 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2889 if(currentPreviewPopup.scrollListener)
2890 window.removeEventListener("scroll", previewPopup.scrollListener);
2892 currentPreviewPopup = { };
2895 function addCommentParentPopups() {
2896 GWLog("addCommentParentPopups");
2897 //if (!query("#content").hasClass("comment-thread-page")) return;
2899 queryAll("a[href]").forEach(linkTag => {
2900 let linkHref = linkTag.getAttribute("href");
2903 try { url = new URL(linkHref, window.location.href); }
2907 if(GW.sites[url.host]) {
2908 let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
2910 if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
2911 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
2912 if(event.pointerType == "touch") return;
2913 GWLog("GW.commentParentLinkMouseOver");
2914 removePreviewPopup(currentPreviewPopup);
2915 let parentID = linkHref;
2917 if (!(parent = (query(parentID)||{}).firstChild)) return;
2918 var highlightClassName;
2919 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2920 parentHighlightClassName = "comment-item-highlight-faint";
2921 popup = parent.cloneNode(true);
2922 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2923 linkTag.addEventListener("mouseout", (event) => {
2924 removeElement(popup);
2926 linkTag.closest(".comments > .comment-thread").appendChild(popup);
2928 parentHighlightClassName = "comment-item-highlight";
2930 parent.parentNode.addClass(parentHighlightClassName);
2931 linkTag.addEventListener("mouseout", (event) => {
2932 parent.parentNode.removeClass(parentHighlightClassName);
2936 else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
2937 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
2938 && !(url.searchParams.get('format'))
2939 && !linkTag.closest("nav:not(.post-nav-links)")
2940 && (!url.hash || linkCommentId)
2941 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
2942 linkTag.addEventListener("pointerover", event => {
2943 if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
2944 if(currentPreviewPopup.linkTag) return;
2945 linkTag.createPreviewPopup();
2947 linkTag.createPreviewPopup = function() {
2948 removePreviewPopup(currentPreviewPopup);
2950 currentPreviewPopup = {linkTag: linkTag};
2952 let popup = document.createElement("iframe");
2953 currentPreviewPopup.element = popup;
2955 let popupTarget = linkHref;
2956 if(popupTarget.match(/#comment-/)) {
2957 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
2959 // 'theme' attribute is required for proper caching
2960 popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
2961 popup.addClass("preview-popup");
2963 let linkRect = linkTag.getBoundingClientRect();
2965 if(linkRect.right + 710 < window.innerWidth)
2966 popup.style.left = linkRect.right + 10 + "px";
2968 popup.style.right = "10px";
2970 popup.style.width = "700px";
2971 popup.style.height = "500px";
2972 popup.style.visibility = "hidden";
2973 popup.style.transition = "none";
2975 let recenter = function() {
2976 let popupHeight = 500;
2977 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
2978 let popupContent = popup.contentDocument.querySelector("#content");
2980 popupHeight = popupContent.clientHeight + 2;
2981 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
2982 popup.style.height = popupHeight + "px";
2985 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
2990 query('#content').insertAdjacentElement("beforeend", popup);
2992 let clickListener = event => {
2993 if(!event.target.closest("a, input, label")
2994 && !event.target.closest("popup-hide-button")) {
2995 window.location = linkHref;
2999 popup.addEventListener("load", () => {
3000 let hideButton = popup.contentDocument.createElement("div");
3001 hideButton.className = "popup-hide-button";
3002 hideButton.insertAdjacentText('beforeend', "\uF070");
3003 hideButton.onclick = (event) => {
3004 removePreviewPopup(currentPreviewPopup);
3005 setPreviewPopupsEnabled(false);
3006 event.stopPropagation();
3008 popup.contentDocument.body.appendChild(hideButton);
3010 let body = popup.contentDocument.body;
3011 body.addEventListener("click", clickListener);
3012 body.style.cursor = "pointer";
3017 popup.contentDocument.body.addEventListener("click", clickListener);
3019 currentPreviewPopup.timeout = setTimeout(() => {
3022 requestIdleCallback(() => {
3023 if(currentPreviewPopup.element === popup) {
3024 popup.scrolling = "";
3025 popup.style.visibility = "unset";
3026 popup.style.transition = null;
3029 { opacity: 0, transform: "translateY(10%)" },
3030 { opacity: 1, transform: "none" }
3031 ], { duration: 150, easing: "ease-out" });
3036 let pointerX, pointerY, mousePauseTimeout = null;
3038 currentPreviewPopup.pointerListener = (event) => {
3039 pointerX = event.clientX;
3040 pointerY = event.clientY;
3042 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3043 mousePauseTimeout = null;
3045 let overElement = document.elementFromPoint(pointerX, pointerY);
3046 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3048 if(mouseIsOverLink || overElement === popup
3049 || (pointerX < popup.getBoundingClientRect().left
3050 && event.movementX >= 0)) {
3051 if(!mouseIsOverLink && overElement !== popup) {
3052 if(overElement['createPreviewPopup']) {
3053 mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3055 mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3059 removePreviewPopup(currentPreviewPopup);
3060 if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3063 window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3065 currentPreviewPopup.mouseoutListener = (event) => {
3066 clearTimeout(mousePauseTimeout);
3067 mousePauseTimeout = null;
3069 document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3071 currentPreviewPopup.scrollListener = (event) => {
3072 let overElement = document.elementFromPoint(pointerX, pointerY);
3073 linkRect = linkTag.getBoundingClientRect();
3074 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3075 removePreviewPopup(currentPreviewPopup);
3077 window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3082 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3086 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3087 GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
3088 "#content .comments .comment-thread"
3090 applyFilters(GW.currentFilters);
3097 function imageFocusSetup(imagesOverlayOnly = false) {
3098 if (typeof GW.imageFocus == "undefined")
3100 contentImagesSelector: "#content img",
3101 overlayImagesSelector: "#images-overlay img",
3102 focusedImageSelector: "#content img.focused, #images-overlay img.focused",
3103 pageContentSelector: "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3105 hideUITimerDuration: 1500,
3106 hideUITimerExpired: () => {
3107 GWLog("GW.imageFocus.hideUITimerExpired");
3108 let currentTime = new Date();
3109 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3110 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3111 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3114 cancelImageFocusHideUITimer();
3119 GWLog("imageFocusSetup");
3120 // Create event listener for clicking on images to focus them.
3121 GW.imageClickedToFocus = (event) => {
3122 GWLog("GW.imageClickedToFocus");
3123 focusImage(event.target);
3126 // Set timer to hide the image focus UI.
3127 unhideImageFocusUI();
3128 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3131 // Add the listener to each image in the overlay (i.e., those in the post).
3132 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3133 image.addActivateEvent(GW.imageClickedToFocus);
3135 // Accesskey-L starts the slideshow.
3136 (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3137 // Count how many images there are in the post, and set the "… of X" label to that.
3138 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3139 if (imagesOverlayOnly) return;
3140 // Add the listener to all other content images (including those in comments).
3141 queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3142 image.addActivateEvent(GW.imageClickedToFocus);
3145 // Create the image focus overlay.
3146 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
3147 `<div class='help-overlay'>
3148 <p><strong>Arrow keys:</strong> Next/previous image</p>
3149 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3150 <p><strong>Space bar:</strong> Reset image size & position</p>
3151 <p><strong>Scroll</strong> to zoom in/out</p>
3152 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3154 <div class='image-number'></div>
3155 <div class='slideshow-buttons'>
3156 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
3157 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
3159 <div class='caption'></div>` +
3161 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3163 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3164 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3165 GWLog("GW.imageFocus.slideshowButtonClicked");
3166 focusNextImage(event.target.hasClass("next"));
3167 event.target.blur();
3171 // On orientation change, reset the size & position.
3172 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3173 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3176 // UI starts out hidden.
3180 function focusImage(imageToFocus) {
3181 GWLog("focusImage");
3182 // Clear 'last-focused' class of last focused image.
3183 let lastFocusedImage = query("img.last-focused");
3184 if (lastFocusedImage) {
3185 lastFocusedImage.removeClass("last-focused");
3186 lastFocusedImage.removeAttribute("accesskey");
3189 // Create the focused version of the image.
3190 imageToFocus.addClass("focused");
3191 let imageFocusOverlay = query("#image-focus-overlay");
3192 let clonedImage = imageToFocus.cloneNode(true);
3193 clonedImage.style = "";
3194 clonedImage.removeAttribute("width");
3195 clonedImage.removeAttribute("height");
3196 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3197 imageFocusOverlay.appendChild(clonedImage);
3198 imageFocusOverlay.addClass("engaged");
3200 // Set image to default size and position.
3201 resetFocusedImagePosition();
3203 // Blur everything else.
3204 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3205 element.addClass("blurred");
3208 // Add listener to zoom image with scroll wheel.
3209 window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3210 GWLog("GW.imageFocus.scrollEvent");
3211 event.preventDefault();
3213 let image = query("#image-focus-overlay img");
3215 // Remove the filter.
3216 image.savedFilter = image.style.filter;
3217 image.style.filter = 'none';
3219 // Locate point under cursor.
3220 let imageBoundingBox = image.getBoundingClientRect();
3222 // Calculate resize factor.
3223 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3224 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3228 image.style.width = (event.deltaY < 0 ?
3229 (image.clientWidth * factor) :
3230 (image.clientWidth / factor))
3232 image.style.height = "";
3234 // Designate zoom origin.
3236 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3237 // the cursor is over the image.
3238 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3239 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3240 (imageBoundingBox.left <= event.clientX &&
3241 event.clientX <= imageBoundingBox.right &&
3242 imageBoundingBox.top <= event.clientY &&
3243 event.clientY <= imageBoundingBox.bottom);
3244 // Otherwise, if we're zooming OUT, zoom from window center; if we're
3245 // zooming IN, zoom from image center.
3246 let zoomingFromWindowCenter = event.deltaY > 0;
3247 if (zoomingFromCursor)
3248 zoomOrigin = { x: event.clientX,
3250 else if (zoomingFromWindowCenter)
3251 zoomOrigin = { x: window.innerWidth / 2,
3252 y: window.innerHeight / 2 };
3254 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
3255 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3257 // Calculate offset from zoom origin.
3258 let offsetOfImageFromZoomOrigin = {
3259 x: imageBoundingBox.x - zoomOrigin.x,
3260 y: imageBoundingBox.y - zoomOrigin.y
3262 // Calculate delta from centered zoom.
3263 let deltaFromCenteredZoom = {
3264 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3265 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3267 // Adjust image position appropriately.
3268 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3269 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3270 // Gradually re-center image, if it's smaller than the window.
3271 if (!imageSizeExceedsWindowBounds) {
3272 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
3273 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3274 let windowCenter = { x: window.innerWidth / 2,
3275 y: window.innerHeight / 2 }
3276 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3277 y: windowCenter.y - imageCenter.y }
3278 // Divide the offset by 10 because we're nudging the image toward center,
3279 // not jumping it there.
3280 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3281 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3284 // Put the filter back.
3285 image.style.filter = image.savedFilter;
3287 // Set the cursor appropriately.
3288 setFocusedImageCursor();
3290 window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3291 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3292 event.preventDefault();
3295 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3296 window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3297 GWLog("GW.imageFocus.mouseUp");
3298 window.onmousemove = '';
3300 // We only want to do anything on left-clicks.
3301 if (event.button != 0) return;
3303 // Don't unfocus if click was on a slideshow next/prev button!
3304 if (event.target.hasClass("slideshow-button")) return;
3306 // We also don't want to do anything if clicked on the help overlay.
3307 if (event.target.classList.contains("help-overlay") ||
3308 event.target.closest(".help-overlay"))
3311 let focusedImage = query("#image-focus-overlay img");
3312 if (event.target == focusedImage &&
3313 (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3314 // If the mouseup event was the end of a pan of an overside image,
3315 // put the filter back; do not unfocus.
3316 focusedImage.style.filter = focusedImage.savedFilter;
3318 unfocusImageOverlay();
3322 window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3323 GWLog("GW.imageFocus.mouseDown");
3324 event.preventDefault();
3326 let focusedImage = query("#image-focus-overlay img");
3327 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3328 let mouseCoordX = event.clientX;
3329 let mouseCoordY = event.clientY;
3331 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3332 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3335 focusedImage.savedFilter = focusedImage.style.filter;
3337 window.onmousemove = (event) => {
3338 // Remove the filter.
3339 focusedImage.style.filter = "none";
3340 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3341 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3347 // Double-click on the image unfocuses.
3348 clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3349 GWLog("GW.imageFocus.doubleClick");
3350 if (event.target.hasClass("slideshow-button")) return;
3352 unfocusImageOverlay();
3355 // Escape key unfocuses, spacebar resets.
3356 document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3357 GWLog("GW.imageFocus.keyUp");
3358 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3359 if (!allowedKeys.contains(event.key) ||
3360 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3362 event.preventDefault();
3364 switch (event.key) {
3367 unfocusImageOverlay();
3371 resetFocusedImagePosition();
3377 if (query("#images-overlay img.focused")) focusNextImage(true);
3383 if (query("#images-overlay img.focused")) focusNextImage(false);
3388 // Prevent spacebar or arrow keys from scrolling page when image focused.
3389 togglePageScrolling(false);
3391 // If the image comes from the images overlay, for the main post...
3392 if (imageToFocus.closest("#images-overlay")) {
3393 // Mark the overlay as being in slide show mode (to show buttons/count).
3394 imageFocusOverlay.addClass("slideshow");
3396 // Set state of next/previous buttons.
3397 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3398 var indexOfFocusedImage = getIndexOfFocusedImage();
3399 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3400 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3402 // Set the image number.
3403 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3405 // Replace the hash.
3406 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3408 imageFocusOverlay.removeClass("slideshow");
3412 setImageFocusCaption();
3414 // Moving mouse unhides image focus UI.
3415 window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3416 GWLog("GW.imageFocus.mouseMoved");
3417 let currentDateTime = new Date();
3418 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3419 cancelImageFocusHideUITimer();
3421 if (!GW.imageFocus.hideUITimer) {
3422 unhideImageFocusUI();
3423 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3425 GW.imageFocus.mouseLastMovedAt = currentDateTime;
3430 function resetFocusedImagePosition() {
3431 GWLog("resetFocusedImagePosition");
3432 let focusedImage = query("#image-focus-overlay img");
3433 if (!focusedImage) return;
3435 let sourceImage = query(GW.imageFocus.focusedImageSelector);
3437 // Make sure that initially, the image fits into the viewport.
3438 let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3439 let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3440 var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3441 let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3442 let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3443 focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3444 focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3446 // Remove modifications to position.
3447 focusedImage.style.left = "";
3448 focusedImage.style.top = "";
3450 // Set the cursor appropriately.
3451 setFocusedImageCursor();
3453 function setFocusedImageCursor() {
3454 let focusedImage = query("#image-focus-overlay img");
3455 if (!focusedImage) return;
3456 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
3460 function unfocusImageOverlay() {
3461 GWLog("unfocusImageOverlay");
3463 // Remove event listeners.
3464 window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3465 window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3466 // NOTE: The double-click listener does not need to be removed manually,
3467 // because the focused (cloned) image will be removed anyway.
3468 document.removeEventListener("keyup", GW.imageFocus.keyUp);
3469 document.removeEventListener("keydown", GW.imageFocus.keyDown);
3470 window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3471 window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3472 window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3474 // Set accesskey of currently focused image (if it's in the images overlay).
3475 let currentlyFocusedImage = query("#images-overlay img.focused");
3476 if (currentlyFocusedImage) {
3477 currentlyFocusedImage.addClass("last-focused");
3478 currentlyFocusedImage.accessKey = 'l';
3481 // Remove focused image and hide overlay.
3482 let imageFocusOverlay = query("#image-focus-overlay");
3483 imageFocusOverlay.removeClass("engaged");
3484 removeElement(imageFocusOverlay.query("img"));
3486 // Un-blur content/etc.
3487 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3488 element.removeClass("blurred");
3491 // Unset "focused" class of focused image.
3492 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3494 // Re-enable page scrolling.
3495 togglePageScrolling(true);
3497 // Reset the hash, if needed.
3498 if (location.hash.hasPrefix("#if_slide_"))
3499 history.replaceState(window.history.state, null, "#");
3502 function getIndexOfFocusedImage() {
3503 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3504 var indexOfFocusedImage = -1;
3505 for (i = 0; i < images.length; i++) {
3506 if (images[i].hasClass("focused")) {
3507 indexOfFocusedImage = i;
3511 return indexOfFocusedImage;
3514 function focusNextImage(next = true) {
3515 GWLog("focusNextImage");
3516 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3517 var indexOfFocusedImage = getIndexOfFocusedImage();
3519 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3521 // Remove existing image.
3522 removeElement("#image-focus-overlay img");
3523 // Unset "focused" class of just-removed image.
3524 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3526 // Create the focused version of the image.
3527 images[indexOfFocusedImage].addClass("focused");
3528 let imageFocusOverlay = query("#image-focus-overlay");
3529 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3530 clonedImage.style = "";
3531 clonedImage.removeAttribute("width");
3532 clonedImage.removeAttribute("height");
3533 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3534 imageFocusOverlay.appendChild(clonedImage);
3535 imageFocusOverlay.addClass("engaged");
3536 // Set image to default size and position.
3537 resetFocusedImagePosition();
3538 // Set state of next/previous buttons.
3539 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3540 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3541 // Set the image number display.
3542 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3544 setImageFocusCaption();
3545 // Replace the hash.
3546 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3549 function setImageFocusCaption() {
3550 GWLog("setImageFocusCaption");
3551 var T = { }; // Temporary storage.
3553 // Clear existing caption, if any.
3554 let captionContainer = query("#image-focus-overlay .caption");
3555 Array.from(captionContainer.children).forEach(child => { child.remove(); });
3557 // Determine caption.
3558 let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3560 if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
3561 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3562 captionHTML = (T.figcaption.query("p")) ?
3563 T.figcaption.innerHTML :
3564 "<p>" + T.figcaption.innerHTML + "</p>";
3565 } else if (currentlyFocusedImage.title != "") {
3566 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3568 // Insert the caption, if any.
3569 if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3572 function hideImageFocusUI() {
3573 GWLog("hideImageFocusUI");
3574 let imageFocusOverlay = query("#image-focus-overlay");
3575 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3576 element.addClass("hidden");
3580 function unhideImageFocusUI() {
3581 GWLog("unhideImageFocusUI");
3582 let imageFocusOverlay = query("#image-focus-overlay");
3583 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3584 element.removeClass("hidden");
3588 function cancelImageFocusHideUITimer() {
3589 clearTimeout(GW.imageFocus.hideUITimer);
3590 GW.imageFocus.hideUITimer = null;
3597 function keyboardHelpSetup() {
3598 let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3599 <div class='keyboard-help-container'>
3600 <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'></button>
3601 <h1>Keyboard shortcuts</h1>
3602 <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>
3603 <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3604 <div class='keyboard-shortcuts-lists'>` + [ [
3606 [ [ '?' ], "Show keyboard shortcuts" ],
3607 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3610 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3611 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3612 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3613 [ [ 'ak-m' ], "Go to Meta view" ],
3614 [ [ 'ak-v' ], "Go to Tags view"],
3615 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3616 [ [ 'ak-r' ], "Go to Archive view" ],
3617 [ [ 'ak-q' ], "Go to Sequences view" ],
3618 [ [ 'ak-t' ], "Go to About page" ],
3619 [ [ 'ak-u' ], "Go to User or Login page" ],
3620 [ [ 'ak-o' ], "Go to Inbox page" ]
3623 [ [ 'ak-,' ], "Jump up to top of page" ],
3624 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3625 [ [ 'ak-/' ], "Jump to top of comments section" ],
3626 [ [ 'ak-s' ], "Search" ],
3629 [ [ 'ak-n' ], "New post or comment" ],
3630 [ [ 'ak-e' ], "Edit current post" ]
3632 "Post/comment list views",
3633 [ [ '.' ], "Focus next entry in list" ],
3634 [ [ ',' ], "Focus previous entry in list" ],
3635 [ [ ';' ], "Cycle between links in focused entry" ],
3636 [ [ 'Enter' ], "Go to currently focused entry" ],
3637 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3638 [ [ 'ak-]' ], "Go to next page" ],
3639 [ [ 'ak-[' ], "Go to previous page" ],
3640 [ [ 'ak-\\' ], "Go to first page" ],
3641 [ [ 'ak-e' ], "Edit currently focused post" ]
3644 [ [ 'ak-k' ], "Bold text" ],
3645 [ [ 'ak-i' ], "Italic text" ],
3646 [ [ 'ak-l' ], "Insert hyperlink" ],
3647 [ [ 'ak-q' ], "Blockquote text" ]
3650 [ [ 'ak-=' ], "Increase text size" ],
3651 [ [ 'ak--' ], "Decrease text size" ],
3652 [ [ 'ak-0' ], "Reset to default text size" ],
3653 [ [ 'ak-′' ], "Cycle through content width settings" ],
3654 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3655 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3656 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3657 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3658 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3659 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3660 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3661 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3662 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3663 [ [ 'ak-;' ], "Open theme tweaker" ],
3664 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3665 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3668 [ [ 'ak-l' ], "Start/resume slideshow" ],
3669 [ [ 'Esc' ], "Exit slideshow" ],
3670 [ [ '→', '↓' ], "Next slide" ],
3671 [ [ '←', '↑' ], "Previous slide" ],
3672 [ [ 'Space' ], "Reset slide zoom" ]
3675 [ [ 'ak-x' ], "Switch to next view on user page" ],
3676 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3677 [ [ 'ak-` ' ], "Toggle compact comment list view" ],
3678 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3680 `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3682 <span class='keys'>` +
3684 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3687 <span class='action'>${entry[1]}</span>
3689 ).join("\n") + `</ul>`).join("\n") + `
3694 // Add listener to show the keyboard help overlay.
3695 document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3696 GWLog("GW.keyboardHelpShowKeyPressed");
3697 if (event.key == '?')
3698 toggleKeyboardHelpOverlay(true);
3701 // Clicking the background overlay closes the keyboard help overlay.
3702 keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3703 GWLog("GW.keyboardHelpOverlayClicked");
3704 if (event.type == 'mousedown') {
3705 keyboardHelpOverlay.style.opacity = "0.01";
3707 toggleKeyboardHelpOverlay(false);
3708 keyboardHelpOverlay.style.opacity = "1.0";
3712 // Intercept clicks, so they don't "fall through" the background overlay.
3713 (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3715 // Clicking the close button closes the keyboard help overlay.
3716 keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3717 toggleKeyboardHelpOverlay(false);
3720 // Add button to open keyboard help.
3721 query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'></button>");
3722 query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3723 GWLog("GW.openKeyboardHelpButtonClicked");
3724 toggleKeyboardHelpOverlay(true);
3725 event.target.blur();
3729 function toggleKeyboardHelpOverlay(show) {
3730 console.log("toggleKeyboardHelpOverlay");
3732 let keyboardHelpOverlay = query("#keyboard-help-overlay");
3733 show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3734 keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3736 // Prevent scrolling the document when the overlay is visible.
3737 togglePageScrolling(!show);
3739 // Focus the close button as soon as we open.
3740 keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3743 // Add listener to show the keyboard help overlay.
3744 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3745 GWLog("GW.keyboardHelpHideKeyPressed");
3746 if (event.key == 'Escape')
3747 toggleKeyboardHelpOverlay(false);
3750 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3753 // Disable / enable tab-selection of the search box.
3754 setSearchBoxTabSelectable(!show);
3757 /**********************/
3758 /* PUSH NOTIFICATIONS */
3759 /**********************/
3761 function pushNotificationsSetup() {
3762 let pushNotificationsButton = query("#enable-push-notifications");
3763 if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3764 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3765 pushNotificationsButton.style.display = 'unset';
3769 function urlBase64ToUint8Array(base64String) {
3770 const padding = '='.repeat((4 - base64String.length % 4) % 4);
3771 const base64 = (base64String + padding)
3773 .replace(/_/g, '/');
3775 const rawData = window.atob(base64);
3776 const outputArray = new Uint8Array(rawData.length);
3778 for (let i = 0; i < rawData.length; ++i) {
3779 outputArray[i] = rawData.charCodeAt(i);
3784 function pushNotificationsButtonClicked(event) {
3785 event.target.style.opacity = 0.33;
3786 event.target.style.pointerEvents = "none";
3788 let reEnable = (message) => {
3789 if(message) alert(message);
3790 event.target.style.opacity = 1;
3791 event.target.style.pointerEvents = "unset";
3794 if(event.target.dataset.enabled) {
3795 fetch('/push/register', {
3797 headers: { 'Content-type': 'application/json' },
3798 body: JSON.stringify({
3802 event.target.innerHTML = "Enable push notifications";
3803 event.target.dataset.enabled = "";
3805 }).catch((err) => reEnable(err.message));
3807 Notification.requestPermission().then((permission) => {
3808 navigator.serviceWorker.ready
3809 .then((registration) => {
3810 return registration.pushManager.getSubscription()
3811 .then(async function(subscription) {
3813 return subscription;
3815 return registration.pushManager.subscribe({
3816 userVisibleOnly: true,
3817 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
3820 .catch((err) => reEnable(err.message));
3822 .then((subscription) => {
3823 fetch('/push/register', {
3826 'Content-type': 'application/json'
3828 body: JSON.stringify({
3829 subscription: subscription
3834 event.target.innerHTML = "Disable push notifications";
3835 event.target.dataset.enabled = "true";
3838 .catch(function(err){ reEnable(err.message) });
3844 /*******************************/
3845 /* HTML TO MARKDOWN CONVERSION */
3846 /*******************************/
3848 function MarkdownFromHTML(text) {
3849 GWLog("MarkdownFromHTML");
3850 // Wrapper tags, paragraphs, bold, italic, code blocks.
3851 text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
3874 // <div> and <span>.
3875 text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
3877 }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
3882 text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
3883 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
3884 return `* ${listItem}\n`;
3889 text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
3890 var countedItemValue = 0;
3891 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
3893 if (typeof specifiedItemValue != "undefined") {
3894 specifiedItemValue = parseInt(specifiedItemValue);
3895 countedItemValue = itemValue = specifiedItemValue;
3897 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
3899 return `${itemValue}. ${listItem.trim()}\n`;
3904 text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
3905 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
3909 text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
3910 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
3914 text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
3915 return `[${text}](${href})`;
3919 text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
3920 return `![](${src})`;
3923 // Horizontal rules.
3924 text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
3929 text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
3933 // Preformatted text (possibly with a code block inside).
3934 text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
3935 return "```\n" + text + "\n```";
3939 text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
3940 return "`" + text + "`";
3944 text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
3964 /************************************/
3965 /* ANCHOR LINK SCROLLING WORKAROUND */
3966 /************************************/
3968 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
3969 let hash = location.hash;
3970 if(hash && hash !== "#top" && !document.query(hash)) {
3971 let content = document.query("#content");
3972 content.style.display = "none";
3973 addTriggerListener("DOMReady", {priority: -1, fn: () => {
3974 content.style.visibility = "hidden";
3975 content.style.display = null;
3976 requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
3981 /******************/
3982 /* INITIALIZATION */
3983 /******************/
3985 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
3986 GWLog("INITIALIZER earlyInitialize");
3987 // Check to see whether we're on a mobile device (which we define as a narrow screen)
3988 GW.isMobile = (window.innerWidth <= 1160);
3989 GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3991 // Backward compatibility
3992 let storedTheme = localStorage.getItem('selected-theme');
3994 setTheme(storedTheme);
3995 localStorage.removeItem('selected-theme');
3998 // Animate width & theme adjustments?
3999 GW.adjustmentTransitions = false;
4001 // Add the content width selector.
4002 injectContentWidthSelector();
4003 // Add the text size adjustment widget.
4004 injectTextSizeAdjustmentUI();
4005 // Add the dark mode selector.
4006 DarkMode.injectModeSelector();
4007 // Add the theme selector.
4008 injectThemeSelector();
4009 // Add the theme tweaker.
4010 injectThemeTweaker();
4011 // Add the quick-nav UI.
4014 // Finish initializing when ready.
4015 addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4018 function mainInitializer() {
4019 GWLog("INITIALIZER initialize");
4021 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4022 // "link without nav bars".
4023 if (getQueryVariable("hide-nav-bars") == "true") {
4024 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
4027 // If the page cannot have comments, remove the accesskey from the #comments
4028 // quick-nav button; and if the page can have comments, but does not, simply
4029 // disable the #comments quick nav button.
4030 let content = query("#content");
4031 if (content.query("#comments") == null) {
4032 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4033 } else if (content.query("#comments .comment-thread") == null) {
4034 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4037 // On edit post pages and conversation pages, add GUIEdit buttons to the
4038 // textarea, expand it, and markdownify the existing text, if any (this is
4039 // needed if a post was last edited on LW).
4040 queryAll(".with-markdown-editor textarea").forEach(textarea => {
4041 textarea.addTextareaFeatures();
4042 expandTextarea(textarea);
4043 textarea.value = MarkdownFromHTML(textarea.value);
4045 // Focus the textarea.
4046 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4049 queryAll(".contents-list li a").forEach(tocLink => {
4050 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
4051 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
4052 tocLink.innerText = tocLink.innerText.replace(/^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})\. /i, '');
4053 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
4056 // If we're on a comment thread page...
4057 if (query(".comments") != null) {
4058 // Add comment-minimize buttons to every comment.
4059 queryAll(".comment-meta").forEach(commentMeta => {
4060 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4061 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
4063 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4064 // Format and activate comment-minimize buttons.
4065 queryAll(".comment-minimize-button").forEach(button => {
4066 button.closest(".comment-item").setCommentThreadMaximized(false);
4067 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4068 event.target.closest(".comment-item").setCommentThreadMaximized(true);
4073 if (getQueryVariable("chrono") == "t") {
4074 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
4077 // On mobile, replace the labels for the checkboxes on the edit post form
4078 // with icons, to save space.
4079 if (GW.isMobile && query(".edit-post-page")) {
4080 query("label[for='link-post']").innerHTML = "";
4081 query("label[for='question']").innerHTML = "";
4084 // Add error message (as placeholder) if user tries to click Search with
4085 // an empty search field.
4087 let searchForm = query("#nav-item-search form");
4088 if(!searchForm) break searchForm;
4089 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4090 let searchField = event.target.query("input");
4091 if (searchField.value == "") {
4092 event.preventDefault();
4093 event.target.blur();
4094 searchField.placeholder = "Enter a search string!";
4095 searchField.focus();
4098 // Remove the placeholder / error on any input.
4099 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4100 event.target.placeholder = "";
4104 // Prevent conflict between various single-hotkey listeners and text fields
4105 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4106 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4107 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4110 if (content.hasClass("post-page")) {
4111 // Read and update last-visited-date.
4112 let lastVisitedDate = getLastVisitedDate();
4113 setLastVisitedDate(Date.now());
4115 // Save the number of comments this post has when it's visited.
4116 updateSavedCommentCount();
4118 if (content.query(".comments .comment-thread") != null) {
4119 // Add the new comments count & navigator.
4120 injectNewCommentNavUI();
4122 // Get the highlight-new-since date (as specified by URL parameter, if
4123 // present, or otherwise the date of the last visit).
4124 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4126 // Highlight new comments since the specified date.
4127 let newCommentsCount = highlightCommentsSince(hnsDate);
4129 // Update the comment count display.
4130 updateNewCommentNavUI(newCommentsCount, hnsDate);
4133 // On listing pages, make comment counts more informative.
4134 badgePostsWithNewComments();
4137 // Add the comments list mode selector widget (expanded vs. compact).
4138 injectCommentsListModeSelector();
4140 // Add the comments view selector widget (threaded vs. chrono).
4141 // injectCommentsViewModeSelector();
4143 // Add the comments sort mode selector (top, hot, new, old).
4144 if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4146 // Add the toggle for the post nav UI elements on mobile.
4147 if (GW.isMobile) injectPostNavUIToggle();
4149 // Add the toggle for the appearance adjustment UI elements on mobile.
4150 if (GW.isMobile) injectAppearanceAdjustUIToggle();
4152 // Add the antikibitzer.
4153 if (GW.useFancyFeatures) injectAntiKibitzer();
4155 // Add comment parent popups.
4156 injectPreviewPopupToggle();
4157 addCommentParentPopups();
4159 // Mark original poster's comments with a special class.
4160 markOriginalPosterComments();
4162 // On the All view, mark posts with non-positive karma with a special class.
4163 if (query("#content").hasClass("all-index-page")) {
4164 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4165 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4167 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4171 // Set the "submit" button on the edit post page to something more helpful.
4172 setEditPostPageSubmitButtonText();
4174 // Compute the text of the pagination UI tooltip text.
4175 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4176 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4179 // Add event listeners for Escape and Enter, for the theme tweaker.
4180 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
4181 let themeTweakerUI = query("#theme-tweaker-ui");
4182 document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
4183 if (event.key == "Escape") {
4184 if (themeTweakerHelpWindow.style.display != "none") {
4185 toggleThemeTweakerHelpWindow();
4186 themeTweakerResetSettings();
4187 } else if (themeTweakerUI.style.display != "none") {
4188 toggleThemeTweakerUI();
4191 } else if (event.key == "Enter") {
4192 if (themeTweakerHelpWindow.style.display != "none") {
4193 toggleThemeTweakerHelpWindow();
4194 themeTweakerSaveSettings();
4195 } else if (themeTweakerUI.style.display != "none") {
4196 toggleThemeTweakerUI();
4202 // Add event listener for . , ; (for navigating listings pages).
4203 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4204 if (!query(".comments") && listings.length > 0) {
4205 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
4206 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4208 if (event.key == "Escape") {
4209 if (document.activeElement.parentElement.hasClass("listing"))
4210 document.activeElement.blur();
4214 if (event.key == ';') {
4215 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4216 let links = document.activeElement.parentElement.queryAll("a");
4217 links[document.activeElement == links[0] ? 1 : 0].focus();
4218 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4219 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4220 links[document.activeElement == links[0] ? 1 : 0].focus();
4221 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4226 var indexOfActiveListing = -1;
4227 for (i = 0; i < listings.length; i++) {
4228 if (document.activeElement.parentElement.hasClass("listing") &&
4229 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4230 indexOfActiveListing = i;
4232 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
4233 listings[i] === document.activeElement.parentElement.query("a.date")) {
4234 indexOfActiveListing = i;
4238 // Remove edit accesskey from currently highlighted post by active user, if applicable.
4239 if (indexOfActiveListing > -1) {
4240 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4242 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4243 if (indexOfNextListing < listings.length) {
4244 listings[indexOfNextListing].focus();
4246 if (listings[indexOfNextListing].closest(".comment-item")) {
4247 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4248 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4251 document.activeElement.blur();
4253 // Add edit accesskey to newly highlighted post by active user, if applicable.
4254 (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4256 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4257 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4258 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4262 // Add event listener for ; (to focus the link on link posts).
4263 if (query("#content").hasClass("post-page") &&
4264 query(".post").hasClass("link-post")) {
4265 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4266 if (event.key == ';') query("a.link-post-link").focus();
4270 // Add accesskeys to user page view selector.
4271 let viewSelector = query("#content.user-page > .sublevel-nav");
4273 let currentView = viewSelector.query("span");
4274 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4275 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4278 // Add accesskey to index page sort selector.
4279 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4281 // Move MathJax style tags to <head>.
4282 var aggregatedStyles = "";
4283 queryAll("#content style").forEach(styleTag => {
4284 aggregatedStyles += styleTag.innerHTML;
4285 removeElement("style", styleTag.parentElement);
4287 if (aggregatedStyles != "") {
4288 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4291 // Add listeners to switch between word count and read time.
4292 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4293 queryAll(".post-meta .read-time").forEach(element => {
4294 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4295 let displayWordCount = localStorage.getItem("display-word-count");
4296 toggleReadTimeOrWordCount(!displayWordCount);
4297 if (displayWordCount) localStorage.removeItem("display-word-count");
4298 else localStorage.setItem("display-word-count", true);
4302 // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4303 query("#content").addEventListener("copy", GW.textCopied = (event) => {
4304 if(event.target.matches("input, textarea")) return;
4305 event.preventDefault();
4306 const selectedHTML = getSelectionHTML();
4307 const selectedText = getSelection().toString();
4308 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4309 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4312 // Set up Image Focus feature.
4315 // Set up keyboard shortcuts guide overlay.
4316 keyboardHelpSetup();
4318 // Show push notifications button if supported
4319 pushNotificationsSetup();
4321 // Show elements now that javascript is ready.
4322 removeElement("#hide-until-init");
4324 activateTrigger("pageLayoutFinished");
4327 /*************************/
4328 /* POST-LOAD ADJUSTMENTS */
4329 /*************************/
4331 window.addEventListener("pageshow", badgePostsWithNewComments);
4333 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4334 GWLog("INITIALIZER pageLayoutFinished");
4336 postSetThemeHousekeeping();
4338 focusImageSpecifiedByURL();
4340 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4341 // query("input[type='search']").value = GW.isMobile;
4342 // insertHeadHTML("<style>" +
4343 // `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` +
4344 // `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` +
4348 function generateImagesOverlay() {
4349 GWLog("generateImagesOverlay");
4350 // Don't do this on the about page.
4351 if (query(".about-page") != null) return;
4354 // Remove existing, if any.
4355 removeElement("#images-overlay");
4358 query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4359 let imagesOverlay = query("#images-overlay");
4360 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4361 queryAll(".post-body img").forEach(image => {
4362 let clonedImageContainer = document.createElement("div");
4364 let clonedImage = image.cloneNode(true);
4365 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4366 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4367 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4368 clonedImageContainer.appendChild(clonedImage);
4370 let zoomLevel = parseFloat(GW.currentTextZoom);
4372 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4373 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4374 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4375 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4377 imagesOverlay.appendChild(clonedImageContainer);
4380 // Add the event listeners to focus each image.
4381 imageFocusSetup(true);
4384 function adjustUIForWindowSize() {
4385 GWLog("adjustUIForWindowSize");
4386 var bottomBarOffset;
4388 // Adjust bottom bar state.
4389 let bottomBar = query("#bottom-bar");
4390 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4391 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4392 bottomBar.removeClass("decorative");
4394 bottomBar.query("#nav-item-top").style.display = "";
4395 } else if (bottomBar) {
4396 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4397 else bottomBar.addClass("decorative");
4399 bottomBar.query("#nav-item-top").style.display = "none";
4402 // Show quick-nav UI up/down buttons if content is taller than window.
4403 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4404 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4405 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4408 // Move anti-kibitzer toggle if content is very short.
4409 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4411 // Update the visibility of the post nav UI.
4412 updatePostNavUIVisibility();
4415 function recomputeUIElementsContainerHeight(force = false) {
4416 GWLog("recomputeUIElementsContainerHeight");
4418 (force || query("#ui-elements-container").style.height != "")) {
4419 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4420 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
4421 query("#content").clientHeight + "px" :
4426 function focusImageSpecifiedByURL() {
4427 GWLog("focusImageSpecifiedByURL");
4428 if (location.hash.hasPrefix("#if_slide_")) {
4429 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4430 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4431 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4432 if (imageToFocus > 0 && imageToFocus <= images.length) {
4433 focusImage(images[imageToFocus - 1]);
4435 // Set timer to hide the image focus UI.
4436 unhideImageFocusUI();
4437 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4447 function insertMarkup(event) {
4448 var mopen = '', mclose = '', mtext = '', func = false;
4449 if (typeof arguments[1] == 'function') {
4450 func = arguments[1];
4452 mopen = arguments[1];
4453 mclose = arguments[2];
4454 mtext = arguments[3];
4457 var textarea = event.target.closest("form").query("textarea");
4459 var p0 = textarea.selectionStart;
4460 var p1 = textarea.selectionEnd;
4461 var cur0 = cur1 = p0;
4463 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4464 str = func ? func(str, p0) : (mopen + str + mclose);
4466 // Determine selection.
4468 cur0 += (p0 == p1) ? mopen.length : str.length;
4469 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4476 // Update textarea contents.
4477 // The document.execCommand API is broken in Firefox
4478 // ( https://bugzilla.mozilla.org/show_bug.cgi?id=1220696 ), but using it
4479 // allows native undo/redo to work; so we enable it in other browsers.
4481 textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
4483 document.execCommand("insertText", false, str);
4485 // Expand textarea, if needed.
4486 expandTextarea(textarea);
4489 textarea.selectionStart = cur0;
4490 textarea.selectionEnd = cur1;
4495 GW.guiEditButtons = [
4496 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
4497 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
4498 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
4499 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
4500 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
4501 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
4502 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
4503 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
4504 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
4505 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
4506 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
4507 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
4508 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
4509 [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '' ],
4510 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
4513 function blockquote(text, startpos) {
4515 text = "> Quoted text";
4516 return [ text, startpos + 2, startpos + text.length ];
4518 text = "> " + text.split("\n").join("\n> ") + "\n";
4519 return [ text, startpos + text.length, startpos + text.length ];
4523 function hyperlink(text, startpos) {
4524 var url = '', link_text = text, endpos = startpos;
4525 if (text.search(/^https?/) != -1) {
4527 link_text = "link text";
4528 startpos = startpos + 1;
4529 endpos = startpos + link_text.length;
4531 url = prompt("Link address (URL):");
4533 endpos = startpos + text.length;
4534 return [ text, startpos, endpos ];
4536 startpos = startpos + text.length + url.length + 4;
4540 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4543 if(navigator.serviceWorker) {
4544 navigator.serviceWorker.register('/service-worker.js');
4545 setCookie("push", "t");