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 /*******************************/
54 /* EVENT LISTENER MANIPULATION */
55 /*******************************/
57 /* Removes event listener from a clickable element, automatically detaching it
58 from all relevant event types. */
59 Element.prototype.removeActivateEvent = function() {
60 let ael = this.activateEventListener;
61 this.removeEventListener("mousedown", ael);
62 this.removeEventListener("click", ael);
63 this.removeEventListener("keyup", ael);
66 /* Adds a scroll event listener to the page. */
67 function addScrollListener(fn, name) {
68 let wrapper = (event) => {
69 requestAnimationFrame(() => {
71 document.addEventListener("scroll", wrapper, {once: true, passive: true});
74 document.addEventListener("scroll", wrapper, {once: true, passive: true});
76 // Retain a reference to the scroll listener, if a name is provided.
77 if (typeof name != "undefined")
85 // Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=325942
86 Element.prototype.scrollIntoView = function(realSIV) {
87 return function(bottom) {
88 realSIV.call(this, bottom);
89 if(fixTarget = this.closest("input[id^='expand'] ~ .comment-thread")) {
90 window.scrollBy(0, fixTarget.scrollTop);
91 fixTarget.scrollTop = 0;
94 }(Element.prototype.scrollIntoView);
96 /* If top of element is not at or above the top of the screen, scroll it into
98 Element.prototype.scrollIntoViewIfNeeded = function() {
99 GWLog("scrollIntoViewIfNeeded");
100 if (this.getBoundingClientRect().bottom > window.innerHeight &&
101 this.getBoundingClientRect().top > 0) {
102 this.scrollIntoView(false);
106 function urlEncodeQuery(params) {
107 return params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params[x])}).join("&");
110 function handleAjaxError(event) {
111 if(event.target.getResponseHeader("Content-Type") === "application/json") console.log("doAjax error: " + JSON.parse(event.target.responseText)["error"]);
112 else console.log("doAjax error: Something bad happened :(");
115 function doAjax(params) {
116 let req = new XMLHttpRequest();
117 let requestMethod = params["method"] || "GET";
118 req.addEventListener("load", (event) => {
119 if(event.target.status < 400) {
120 if(params["onSuccess"]) params.onSuccess(event);
122 if(params["onFailure"]) params.onFailure(event);
123 else handleAjaxError(event);
125 if(params["onFinish"]) params.onFinish(event);
127 req.open(requestMethod, (params.location || document.location) + ((requestMethod == "GET" && params.params) ? "?" + urlEncodeQuery(params.params) : ""));
128 if(requestMethod == "POST") {
129 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
130 params["params"]["csrf-token"] = GW.csrfToken;
131 req.send(urlEncodeQuery(params.params));
137 function activateReadyStateTriggers() {
138 if(document.readyState == 'interactive') {
139 activateTrigger('DOMReady');
140 } else if(document.readyState == 'complete') {
141 activateTrigger('DOMReady');
142 activateTrigger('DOMComplete');
146 document.addEventListener('readystatechange', activateReadyStateTriggers);
147 activateReadyStateTriggers();
149 function callWithServerData(fname, uri) {
152 onSuccess: (event) => {
153 let response = JSON.parse(event.target.responseText);
154 window[fname](response);
159 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
160 deferredCalls = null;
162 /* Return the currently selected text, as HTML (rather than unstyled text).
164 function getSelectionHTML() {
165 let container = newElement("DIV");
166 container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
167 return container.innerHTML;
170 /* Given an HTML string, creates an element from that HTML, adds it to
171 #ui-elements-container (creating the latter if it does not exist), and
172 returns the created element.
174 function addUIElement(element_html) {
175 let ui_elements_container = query("#ui-elements-container");
176 if (ui_elements_container == null)
177 ui_elements_container = document.body.appendChild(newElement("NAV", { "id": "ui-elements-container" }));
179 ui_elements_container.insertAdjacentHTML("beforeend", element_html);
180 return ui_elements_container.lastElementChild;
183 /* Given an element or a selector, removes that element (or the element
184 identified by the selector).
185 If multiple elements match the selector, only the first is removed.
187 function removeElement(elementOrSelector, ancestor = document) {
188 if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
189 if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
192 /* Returns true if the string begins with the given prefix.
194 String.prototype.hasPrefix = function (prefix) {
195 return (this.lastIndexOf(prefix, 0) === 0);
198 /* Toggles whether the page is scrollable.
200 function togglePageScrolling(enable) {
202 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
203 document.body.addClass("no-scroll");
204 document.body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
206 document.body.removeClass("no-scroll");
207 document.body.removeAttribute("style");
208 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
212 DOMRectReadOnly.prototype.isInside = function (x, y) {
213 return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
216 /* Simple mutex mechanism.
218 function doIfAllowed(f, passHolder, passName, releaseImmediately = false) {
219 if (passHolder[passName] == false)
222 passHolder[passName] = false;
226 if (releaseImmediately) {
227 passHolder[passName] = true;
229 requestAnimationFrame(() => {
230 passHolder[passName] = true;
235 /*******************/
236 /* COPY PROCESSORS */
237 /*******************/
239 /*********************************************************************/
240 /* Workaround for Firefox weirdness, based on more Firefox weirdness.
242 DocumentFragment.prototype.getSelection = function () {
243 return document.getSelection();
246 /******************************************************************************/
247 /* Returns true if the node contains only whitespace and/or other empty nodes.
249 function isNodeEmpty(node) {
250 if (node.nodeType == Node.TEXT_NODE)
251 return (node.textContent.match(/\S/) == null);
253 if ( node.nodeType == Node.ELEMENT_NODE
254 && [ "IMG", "VIDEO", "AUDIO", "IFRAME", "OBJECT" ].includes(node.tagName))
257 if (node.childNodes.length == 0)
260 for (childNode of node.childNodes)
261 if (isNodeEmpty(childNode) == false)
267 /***************************************************************/
268 /* Returns a DocumentFragment containing the current selection.
270 function getSelectionAsDocument(doc = document) {
271 let docFrag = doc.getSelection().getRangeAt(0).cloneContents();
273 // Strip whitespace (remove top-level empty nodes).
274 let nodesToRemove = [ ];
275 docFrag.childNodes.forEach(node => {
276 if (isNodeEmpty(node))
277 nodesToRemove.push(node);
279 nodesToRemove.forEach(node => {
280 docFrag.removeChild(node);
286 /*****************************************************************************/
287 /* Adds the given copy processor, appending it to the existing array thereof.
289 Each copy processor should take two arguments: the copy event, and the
290 DocumentFragment which holds the selection as it is being processed by each
291 successive copy processor.
293 A copy processor should return true if processing should continue after it’s
294 done, false otherwise (e.g. if it has entirely replaced the contents of the
295 selection object with what the final clipboard contents should be).
297 function addCopyProcessor(processor) {
298 if (GW.copyProcessors == null)
299 GW.copyProcessors = [ ];
301 GW.copyProcessors.push(processor);
304 /******************************************************************************/
305 /* Set up the copy processor system by registering a ‘copy’ event handler to
306 call copy processors. (Must be set up for the main document, and separately
307 for any shadow roots.)
309 function registerCopyProcessorsForDocument(doc) {
310 GWLog("registerCopyProcessorsForDocument", "rewrite.js", 1);
312 doc.addEventListener("copy", (event) => {
313 if ( GW.copyProcessors == null
314 || GW.copyProcessors.length == 0)
317 // Don't apply copy processors to input fields.
318 if (({'TEXTAREA': true, 'INPUT': true})[document.activeElement.tagName]) {
322 event.preventDefault();
323 event.stopPropagation();
325 let selection = getSelectionAsDocument(doc);
328 while ( i < GW.copyProcessors.length
329 && GW.copyProcessors[i++](event, selection));
331 // This is necessary for .innerText to work properly.
332 let wrapper = newElement("DIV");
333 wrapper.appendChild(selection);
334 document.body.appendChild(wrapper);
336 let makeLinksAbsolute = (node) => {
337 if(node['attributes']) {
338 for(attr of ['src', 'href']) {
340 node[attr] = node[attr];
343 node.childNodes.forEach(makeLinksAbsolute);
345 makeLinksAbsolute(wrapper);
347 event.clipboardData.setData("text/plain", wrapper.innerText);
348 event.clipboardData.setData("text/html", wrapper.innerHTML);
350 document.body.removeChild(wrapper);
354 /*******************************************/
355 /* Set up copy processors in main document.
357 registerCopyProcessorsForDocument(document);
359 /*****************************************************************************/
360 /* Makes it so that copying a rendered equation or other math element copies
361 the LaTeX source, instead of the useless gibberish that is the contents of
362 the text nodes of the HTML representation of the equation.
364 addCopyProcessor((event, selection) => {
365 if (event.target.closest(".mjx-math")) {
366 selection.replaceChildren(event.target.closest(".mjx-math").getAttribute("aria-label"));
371 selection.querySelectorAll(".mjx-chtml").forEach(mathBlock => {
372 mathBlock.innerHTML = " " + mathBlock.querySelector(".mjx-math").getAttribute("aria-label") + " ";
378 /************************************************************************/
379 /* Remove soft hyphens and other extraneous characters from copied text.
381 addCopyProcessor((event, selection) => {
382 let replaceText = (node) => {
383 if(node.nodeType == Node.TEXT_NODE) {
384 node.nodeValue = node.nodeValue.replace(/\u00AD|\u200b/g, "");
387 node.childNodes.forEach(replaceText);
389 replaceText(selection);
395 /********************/
396 /* DEBUGGING OUTPUT */
397 /********************/
399 GW.enableLogging = (permanently = false) => {
401 localStorage.setItem("logging-enabled", "true");
403 GW.loggingEnabled = true;
405 GW.disableLogging = (permanently = false) => {
407 localStorage.removeItem("logging-enabled");
409 GW.loggingEnabled = false;
412 /*******************/
413 /* INBOX INDICATOR */
414 /*******************/
416 function processUserStatus(userStatus) {
417 window.userStatus = userStatus;
419 if(userStatus.notifications) {
420 let element = query('#inbox-indicator');
421 element.className = 'new-messages';
422 element.title = 'New messages [o]';
433 function toggleMarkdownHintsBox() {
434 GWLog("toggleMarkdownHintsBox");
435 let markdownHintsBox = query("#markdown-hints");
436 markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
438 function hideMarkdownHintsBox() {
439 GWLog("hideMarkdownHintsBox");
440 let markdownHintsBox = query("#markdown-hints");
441 if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
444 Element.prototype.addTextareaFeatures = function() {
445 GWLog("addTextareaFeatures");
448 textarea.addEventListener("focus", GW.textareaFocused = (event) => {
449 GWLog("GW.textareaFocused");
450 event.target.closest("form").scrollIntoViewIfNeeded();
452 textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
453 GWLog("GW.textareaInputReceived");
454 if (window.innerWidth > 520) {
455 // Expand textarea if needed.
456 expandTextarea(textarea);
458 // Remove markdown hints.
459 hideMarkdownHintsBox();
460 query(".guiedit-mobile-help-button").removeClass("active");
462 // User mentions autocomplete
463 if(!userAutocomplete &&
464 textarea.value.charAt(textarea.selectionStart - 1) === "@" &&
465 (textarea.selectionStart === 1 ||
466 !textarea.value.charAt(textarea.selectionStart - 2).match(/[a-zA-Z0-9]/))) {
467 beginAutocompletion(textarea, textarea.selectionStart);
470 textarea.addEventListener("click", (event) => {
471 if(!userAutocomplete) {
472 let start = textarea.selectionStart, end = textarea.selectionEnd;
473 let value = textarea.value;
474 if (start <= 1) return;
475 for (; value.charAt(start - 1) != "@"; start--) {
476 if (start <= 1) return;
477 if (value.charAt(start - 1) == " ") return;
479 for(; end < value.length && value.charAt(end) != " "; end++) { true }
480 beginAutocompletion(textarea, start, end);
484 textarea.addEventListener("paste", (event) => {
485 let html = event.clipboardData.getData("text/html");
487 html = html.replace(/\n|\r/gm, "");
488 let isQuoted = textarea.selectionStart >= 2 &&
489 textarea.value.substring(textarea.selectionStart - 2, textarea.selectionStart) == "> ";
490 document.execCommand("insertText", false, MarkdownFromHTML(html, (isQuoted ? "> " : null)));
491 event.preventDefault();
495 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
496 textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
497 textarea.addEventListener("keydown", (event) => {
498 // Special case for alt+4
499 // Generalize this before adding more.
500 if(event.altKey && event.key === '4') {
501 insertMarkup(event, "$", "$", "LaTeX formula");
502 event.stopPropagation();
503 event.preventDefault();
507 let form = textarea.closest("form");
509 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
510 let textareaContainer = textarea.closest(".textarea-container");
511 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
512 for (var button of GW.guiEditButtons) {
513 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
514 buttons_container.insertAdjacentHTML("beforeend",
515 "<button type='button' class='guiedit guiedit-"
518 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
519 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
520 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
521 + " onclick='insertMarkup(event,"
522 + ((typeof m_before_or_func == 'function') ?
523 m_before_or_func.name :
524 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
532 `<input type='checkbox' id='markdown-hints-checkbox'>
533 <label for='markdown-hints-checkbox'></label>
534 <div id='markdown-hints'>` +
535 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
536 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
537 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
538 "<span>Heading 1</span><code># Heading 1</code>",
539 "<span>Heading 2</span><code>## Heading 1</code>",
540 "<span>Heading 3</span><code>### Heading 1</code>",
541 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
543 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
545 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
546 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
547 GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
548 if (button.hasClass("guiedit-mobile-help-button")) {
549 toggleMarkdownHintsBox();
550 event.target.toggleClass("active");
551 query(".posting-controls:focus-within textarea").focus();
552 } else if (button.hasClass("guiedit-mobile-exit-button")) {
554 hideMarkdownHintsBox();
555 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
560 // On smartphone (narrow mobile) screens, when a textarea is focused (and
561 // automatically fullscreened), remove all the filters from the page, and
562 // then apply them *just* to the fixed editor UI elements. This is in order
563 // to get around the “children of elements with a filter applied cannot be
565 if (GW.isMobile && window.innerWidth <= 520) {
566 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
567 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
568 GWLog("GW.textareaFocusedMobile");
569 Appearance.savedFilters = Appearance.currentFilters;
570 Appearance.applyFilters(Appearance.noFilters);
571 fixedEditorElements.forEach(element => {
572 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
575 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
576 GWLog("GW.textareaBlurredMobile");
577 requestAnimationFrame(() => {
578 Appearance.applyFilters(Appearance.savedFilters);
579 Appearance.savedFilters = null;
580 fixedEditorElements.forEach(element => {
581 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
588 Element.prototype.injectReplyForm = function(editMarkdownSource) {
589 GWLog("injectReplyForm");
590 let commentControls = this;
591 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
592 let postId = commentControls.parentElement.dataset["postId"];
593 let tagId = commentControls.parentElement.dataset["tagId"];
594 let withparent = (!editMarkdownSource && commentControls.getCommentId());
595 let answer = commentControls.parentElement.id == "answers";
596 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
597 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
598 let parentCommentItem = commentControls.closest(".comment-item");
599 let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
600 (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
601 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
602 "<form method='post'>" +
603 "<div class='textarea-container'>" +
604 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
605 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
606 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
607 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
608 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
609 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
610 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
611 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
612 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
613 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
614 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
615 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
616 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
618 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
619 "<input type='submit' value='Submit'>" +
621 commentControls.onsubmit = disableBeforeUnload;
623 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
624 GWLog("GW.cancelCommentButtonClicked");
625 hideReplyForm(event.target.closest(".comment-controls"));
627 commentControls.scrollIntoViewIfNeeded();
628 commentControls.query("form").onsubmit = (event) => {
629 if (!event.target.text.value) {
630 alert("Please enter a comment.");
634 let textarea = commentControls.query("textarea");
635 if(editMarkdownSource) textarea.value = editMarkdownSource;
636 textarea.addTextareaFeatures();
640 function showCommentEditForm(commentItem) {
641 GWLog("showCommentEditForm");
643 let commentBody = commentItem.query(".comment-body");
644 commentBody.style.display = "none";
646 let commentControls = commentItem.query(".comment-controls");
647 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
648 commentControls.query("form").addClass("edit-existing-comment");
649 expandTextarea(commentControls.query("textarea"));
652 function showReplyForm(commentItem) {
653 GWLog("showReplyForm");
655 let commentControls = commentItem.query(".comment-controls");
656 commentControls.injectReplyForm(commentControls.dataset.enteredText);
659 function hideReplyForm(commentControls) {
660 GWLog("hideReplyForm");
661 // Are we editing a comment? If so, un-hide the existing comment body.
662 let containingComment = commentControls.closest(".comment-item");
663 if (containingComment) containingComment.query(".comment-body").style.display = "";
665 let enteredText = commentControls.query("textarea").value;
666 if (enteredText) commentControls.dataset.enteredText = enteredText;
668 disableBeforeUnload();
669 commentControls.constructCommentControls();
672 function expandTextarea(textarea) {
673 GWLog("expandTextarea");
674 if (window.innerWidth <= 520) return;
676 let totalBorderHeight = 30;
677 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
679 requestAnimationFrame(() => {
680 textarea.style.height = 'auto';
681 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
682 if (textarea.clientHeight < window.innerHeight) {
683 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
688 function doCommentAction(action, commentItem) {
689 GWLog("doCommentAction");
691 params[(action + "-comment-id")] = commentItem.getCommentId();
695 onSuccess: GW.commentActionPostSucceeded = (event) => {
696 GWLog("GW.commentActionPostSucceeded");
698 retract: () => { commentItem.firstChild.addClass("retracted") },
699 unretract: () => { commentItem.firstChild.removeClass("retracted") },
701 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>";
702 commentItem.removeChild(commentItem.query(".comment-controls"));
706 if(action != "delete")
707 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
716 function parseVoteType(voteType) {
717 GWLog("parseVoteType");
719 if (!voteType) return value;
720 value.up = /[Uu]pvote$/.test(voteType);
721 value.down = /[Dd]ownvote$/.test(voteType);
722 value.big = /^big/.test(voteType);
726 function makeVoteType(value) {
727 GWLog("makeVoteType");
728 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
731 function makeVoteClass(vote) {
732 GWLog("makeVoteClass");
733 if (vote.up || vote.down) {
734 return (vote.big ? 'selected big-vote' : 'selected');
740 function findVoteControls(targetType, targetId, voteAxis) {
741 var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
743 if(targetType == "Post") {
744 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
745 } else if(targetType == "Comment") {
746 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
750 function votesEqual(vote1, vote2) {
751 var allKeys = Object.assign({}, vote1);
752 Object.assign(allKeys, vote2);
754 for(k of allKeys.keys()) {
755 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
760 function addVoteButtons(element, vote, targetType) {
761 GWLog("addVoteButtons");
763 let voteAxis = element.parentElement.dataset.voteAxis || "karma";
764 let voteType = parseVoteType(vote[voteAxis]);
765 let voteClass = makeVoteClass(voteType);
767 element.parentElement.queryAll("button").forEach((button) => {
768 button.disabled = false;
770 if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
771 button.addClass(voteClass);
773 updateVoteButtonVisualState(button);
774 button.addActivateEvent(voteButtonClicked);
778 function updateVoteButtonVisualState(button) {
779 GWLog("updateVoteButtonVisualState");
781 button.removeClasses([ "none", "one", "two-temp", "two" ]);
784 button.addClass("none");
785 else if (button.hasClass("big-vote"))
786 button.addClass("two");
787 else if (button.hasClass("selected"))
788 button.addClass("one");
790 button.addClass("none");
793 function changeVoteButtonVisualState(button) {
794 GWLog("changeVoteButtonVisualState");
796 /* Interaction states are:
798 0 0· (neutral; +1 click)
799 1 1· (small vote; +1 click)
800 2 2· (big vote; +1 click)
802 Visual states are (with their state classes in [brackets]) are:
805 02 (small vote active) [one]
806 12 (small vote active, temporary indicator of big vote) [two-temp]
807 22 (big vote active) [two]
809 The following are the 9 possible interaction state transitions (and
810 the visual state transitions associated with them):
813 FROM TO FROM TO NOTES
814 ==== ==== ==== ==== =====
815 0 0· 01 12 first click
816 0· 1 12 02 one click without second
817 0· 2 12 22 second click
819 1 1· 02 12 first click
820 1· 0 12 01 one click without second
821 1· 2 12 22 second click
823 2 2· 22 12 first click
824 2· 1 12 02 one click without second
825 2· 0 12 01 second click
828 [ "big-vote two-temp clicked-twice", "none" ], // 2· => 0
829 [ "big-vote two-temp clicked-once", "one" ], // 2· => 1
830 [ "big-vote clicked-once", "two-temp" ], // 2 => 2·
832 [ "selected two-temp clicked-twice", "two" ], // 1· => 2
833 [ "selected two-temp clicked-once", "none" ], // 1· => 0
834 [ "selected clicked-once", "two-temp" ], // 1 => 1·
836 [ "two-temp clicked-twice", "two" ], // 0· => 2
837 [ "two-temp clicked-once", "one" ], // 0· => 1
838 [ "clicked-once", "two-temp" ], // 0 => 0·
840 for (let [ interactionClasses, visualStateClass ] of transitions) {
841 if (button.hasClasses(interactionClasses.split(" "))) {
842 button.removeClasses([ "none", "one", "two-temp", "two" ]);
843 button.addClass(visualStateClass);
849 function voteCompleteEvent(targetType, targetId, response) {
850 GWLog("voteCompleteEvent");
852 var currentVote = voteData[targetType][targetId] || {};
853 var desiredVote = voteDesired[targetType][targetId];
855 var controls = findVoteControls(targetType, targetId);
856 var controlsByAxis = new Object;
858 controls.forEach(control => {
859 const voteAxis = (control.dataset.voteAxis || "karma");
861 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
862 control.removeClass("waiting");
863 control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
866 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
867 controlsByAxis[voteAxis].push(control);
869 const voteType = currentVote[voteAxis];
870 const vote = parseVoteType(voteType);
871 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
872 const voteClass = makeVoteClass(vote);
874 if (response && response[voteAxis]) {
875 const [voteType, displayText, titleText] = response[voteAxis];
877 const displayTarget = control.query(".karma-value");
878 if (displayTarget.hasClass("redacted")) {
879 displayTarget.dataset["trueValue"] = displayText;
881 displayTarget.innerHTML = displayText;
883 displayTarget.setAttribute("title", titleText);
886 control.queryAll("button.vote").forEach(button => {
887 updateVoteButton(button, voteUpDown, voteClass);
892 function updateVoteButton(button, voteUpDown, voteClass) {
893 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
894 if (button.dataset.voteType == voteUpDown)
895 button.addClass(voteClass);
896 updateVoteButtonVisualState(button);
899 function makeVoteRequestCompleteEvent(targetType, targetId) {
901 var currentVote = {};
904 if (event.target.status == 200) {
905 response = JSON.parse(event.target.responseText);
906 for (const voteAxis of response.keys()) {
907 currentVote[voteAxis] = response[voteAxis][0];
909 voteData[targetType][targetId] = currentVote;
911 delete voteDesired[targetType][targetId];
912 currentVote = voteData[targetType][targetId];
915 var desiredVote = voteDesired[targetType][targetId];
917 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
918 sendVoteRequest(targetType, targetId);
920 delete voteDesired[targetType][targetId];
921 voteCompleteEvent(targetType, targetId, response);
926 function sendVoteRequest(targetType, targetId) {
927 GWLog("sendVoteRequest");
931 location: "/karma-vote",
932 params: { "target": targetId,
933 "target-type": targetType,
934 "vote": JSON.stringify(voteDesired[targetType][targetId]) },
935 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
939 function voteButtonClicked(event) {
940 GWLog("voteButtonClicked");
941 let voteButton = event.target;
943 // 500 ms (0.5 s) double-click timeout.
944 let doubleClickTimeout = 500;
946 if (!voteButton.clickedOnce) {
947 voteButton.clickedOnce = true;
948 voteButton.addClass("clicked-once");
949 changeVoteButtonVisualState(voteButton);
951 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
952 if (!voteButton.clickedOnce) return;
954 // Do single-click code.
955 voteButton.clickedOnce = false;
956 voteEvent(voteButton, 1);
957 }, doubleClickTimeout, voteButton);
959 voteButton.clickedOnce = false;
961 // Do double-click code.
962 voteButton.removeClass("clicked-once");
963 voteButton.addClass("clicked-twice");
964 voteEvent(voteButton, 2);
968 function voteEvent(voteButton, numClicks) {
972 let voteControl = voteButton.parentNode;
974 let targetType = voteButton.dataset.targetType;
975 let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
976 let voteAxis = voteControl.dataset.voteAxis || "karma";
977 let voteUpDown = voteButton.dataset.voteType;
980 if ( (numClicks == 2 && voteButton.hasClass("big-vote"))
981 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
982 voteType = "neutral";
984 let vote = parseVoteType(voteUpDown);
985 vote.big = (numClicks == 2);
986 voteType = makeVoteType(vote);
989 let voteControls = findVoteControls(targetType, targetId, voteAxis);
990 for (const voteControl of voteControls) {
991 voteControl.addClass("waiting");
992 voteControl.queryAll(".vote").forEach(button => {
993 button.addClass("waiting");
994 updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
998 let voteRequestPending = voteDesired[targetType][targetId];
999 let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
1000 voteObject[voteAxis] = voteType;
1001 voteDesired[targetType][targetId] = voteObject;
1003 if (!voteRequestPending) sendVoteRequest(targetType, targetId);
1006 function initializeVoteButtons() {
1007 // Color the upvote/downvote buttons with an embedded style sheet.
1008 insertHeadHTML(`<style id="vote-buttons">
1010 --GW-upvote-button-color: #00d800;
1011 --GW-downvote-button-color: #eb4c2a;
1016 function processVoteData(voteData) {
1017 window.voteData = voteData;
1019 window.voteDesired = new Object;
1020 for(key of voteData.keys()) {
1021 voteDesired[key] = new Object;
1024 initializeVoteButtons();
1026 addTriggerListener("postLoaded", {priority: 3000, fn: () => {
1027 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
1028 let postID = karmaValue.parentNode.dataset.postId;
1029 addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
1030 karmaValue.parentElement.addClass("active-controls");
1034 addTriggerListener("DOMReady", {priority: 3000, fn: () => {
1035 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
1036 let commentID = karmaValue.getCommentId();
1037 addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
1038 karmaValue.parentElement.addClass("active-controls");
1043 /*****************************************/
1044 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
1045 /*****************************************/
1047 Element.prototype.getCommentDate = function() {
1048 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
1049 let dateElement = item && item.query(".date");
1050 return (dateElement && parseInt(dateElement.dataset["jsDate"]));
1052 function getCurrentVisibleComment() {
1053 let px = window.innerWidth/2, py = 5;
1054 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
1055 let bottomBar = query("#bottom-bar");
1056 let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : document.body.getBoundingClientRect().bottom);
1057 let atbottom = bottomOffset <= window.innerHeight;
1059 let hashci = location.hash && query(location.hash);
1060 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
1061 commentItem = hashci;
1067 function highlightCommentsSince(date) {
1068 GWLog("highlightCommentsSince");
1069 var newCommentsCount = 0;
1070 GW.newComments = [ ];
1071 let oldCommentsStack = [ ];
1073 queryAll(".comment-item").forEach(commentItem => {
1074 commentItem.prevNewComment = prevNewComment;
1075 commentItem.nextNewComment = null;
1076 if (commentItem.getCommentDate() > date) {
1077 commentItem.addClass("new-comment");
1079 GW.newComments.push(commentItem.getCommentId());
1080 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
1081 oldCommentsStack = [ commentItem ];
1082 prevNewComment = commentItem;
1084 commentItem.removeClass("new-comment");
1085 oldCommentsStack.push(commentItem);
1089 GW.newCommentScrollSet = (commentItem) => {
1090 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
1091 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
1093 GW.newCommentScrollListener = () => {
1094 let commentItem = getCurrentVisibleComment();
1095 GW.newCommentScrollSet(commentItem);
1098 addScrollListener(GW.newCommentScrollListener);
1100 if (document.readyState=="complete") {
1101 GW.newCommentScrollListener();
1103 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
1104 GW.newCommentScrollSet(commentItem);
1107 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
1109 return newCommentsCount;
1112 function scrollToNewComment(next) {
1113 GWLog("scrollToNewComment");
1114 let commentItem = getCurrentVisibleComment();
1115 let targetComment = null;
1116 let targetCommentID = null;
1118 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
1119 if (targetComment) {
1120 targetCommentID = targetComment.getCommentId();
1123 if (GW.newComments[0]) {
1124 targetCommentID = GW.newComments[0];
1125 targetComment = query("#comment-" + targetCommentID);
1128 if (targetComment) {
1129 expandAncestorsOf(targetCommentID);
1130 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
1131 targetComment.scrollIntoView();
1134 GW.newCommentScrollListener();
1137 function getPostHash() {
1138 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
1139 return (postHash ? postHash[1] : false);
1141 function setHistoryLastVisitedDate(date) {
1142 window.history.replaceState({ lastVisited: date }, null);
1144 function getLastVisitedDate() {
1145 // Get the last visited date (or, if posting a comment, the previous last visited date).
1146 if(window.history.state) return (window.history.state||{})['lastVisited'];
1147 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1148 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
1149 let currentVisited = localStorage.getItem(storageName);
1150 setHistoryLastVisitedDate(currentVisited);
1151 return currentVisited;
1153 function setLastVisitedDate(date) {
1154 GWLog("setLastVisitedDate");
1155 // If NOT posting a comment, save the previous value for the last-visited-date
1156 // (to recover it in case of posting a comment).
1157 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1158 if (!aCommentHasJustBeenPosted) {
1159 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
1160 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
1163 // Set the new value.
1164 localStorage.setItem("last-visited-date_" + getPostHash(), date);
1167 function updateSavedCommentCount() {
1168 GWLog("updateSavedCommentCount");
1169 let commentCount = queryAll(".comment").length;
1170 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
1172 function badgePostsWithNewComments() {
1173 GWLog("badgePostsWithNewComments");
1174 if (getQueryVariable("show") == "conversations") return;
1176 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
1177 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
1179 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
1180 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1181 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1183 if (currentCommentCount > savedCommentCount)
1184 commentCountDisplay.addClass("new-comments");
1186 commentCountDisplay.removeClass("new-comments");
1187 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1197 systemDarkModeActive: matchMedia("(prefers-color-scheme: dark)")
1201 /************************/
1202 /* ACTIVE MEDIA QUERIES */
1203 /************************/
1205 /* This function provides two slightly different versions of its functionality,
1206 depending on how many arguments it gets.
1208 If one function is given (in addition to the media query and its name), it
1209 is called whenever the media query changes (in either direction).
1211 If two functions are given (in addition to the media query and its name),
1212 then the first function is called whenever the media query starts matching,
1213 and the second function is called whenever the media query stops matching.
1215 If you want to call a function for a change in one direction only, pass an
1216 empty closure (NOT null!) as one of the function arguments.
1218 There is also an optional fifth argument. This should be a function to be
1219 called when the active media query is canceled.
1221 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1222 if (typeof GW.mediaQueryResponders == "undefined")
1223 GW.mediaQueryResponders = { };
1225 let mediaQueryResponder = (event, canceling = false) => {
1227 GWLog(`Canceling media query “${name}”`, "media queries", 1);
1229 if (whenCanceledDo != null)
1230 whenCanceledDo(mediaQuery);
1232 let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1234 GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1236 if ((otherwiseDo == null) || matches)
1237 ifMatchesOrAlwaysDo(mediaQuery);
1239 otherwiseDo(mediaQuery);
1242 mediaQueryResponder();
1243 mediaQuery.addListener(mediaQueryResponder);
1245 GW.mediaQueryResponders[name] = mediaQueryResponder;
1248 /* Deactivates and discards an active media query, after calling the function
1249 that was passed as the whenCanceledDo parameter when the media query was
1252 function cancelDoWhenMatchMedia(name) {
1253 GW.mediaQueryResponders[name](null, true);
1255 for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1256 mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1258 GW.mediaQueryResponders[name] = null;
1262 /******************************/
1263 /* DARK/LIGHT MODE ADJUSTMENT */
1264 /******************************/
1271 [ "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)" ],
1272 [ "light", "", "Light mode at all times (black-on-white)" ],
1273 [ "dark", "", "Dark mode at all times (inverted: white-on-black)" ]
1276 selectedModeOptionNote: " [This option is currently selected.]",
1278 /******************/
1283 modeSelectorInteractable: true,
1285 /******************/
1289 /* Returns current (saved) mode (light, dark, or auto).
1291 getSavedMode: () => {
1292 return (readCookie("dark-mode") || (readCookie("theme") === "dark" && "dark") || "auto");
1295 /* Saves specified mode (light, dark, or auto).
1297 saveMode: (mode) => {
1298 GWLog("DarkMode.setMode");
1301 setCookie("dark-mode", "");
1303 setCookie("dark-mode", mode);
1306 getMediaQuery: (selectedMode = DarkMode.getSavedMode()) => {
1307 if (selectedMode == "auto") {
1308 return "all and (prefers-color-scheme: dark)";
1309 } else if (selectedMode == "dark") {
1316 /* Set specified color mode (light, dark, or auto).
1318 setMode: (selectedMode = DarkMode.getSavedMode()) => {
1319 GWLog("DarkMode.setMode");
1321 document.body.removeClasses(["force-dark-mode", "force-light-mode"]);
1322 if(selectedMode === "dark" || selectedMode === "light")
1323 document.body.addClass("force-" + selectedMode + "-mode");
1325 let media = DarkMode.getMediaQuery(selectedMode);
1326 let darkModeStyles = document.querySelector("link.dark-mode");
1327 if (darkModeStyles) {
1328 // Set `media` attribute of style block to match requested mode.
1329 darkModeStyles.media = media;
1332 for(elem of document.querySelectorAll("picture.invertible source")) {
1333 // Update invertible images.
1338 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1341 modeSelectorHTML: (inline = false) => {
1342 let selectorTagName = (inline ? "span" : "div");
1343 let selectorId = (inline ? `` : ` id="dark-mode-selector"`);
1344 let selectorClass = (` class="dark-mode-selector mode-selector` + (inline ? ` mode-selector-inline` : ``) + `"`);
1346 // Get saved mode setting (or default).
1347 let currentMode = DarkMode.getSavedMode();
1349 return `<${selectorTagName}${selectorId}${selectorClass}>`
1350 + DarkMode.modeOptions.map(modeOption => {
1351 let [ name, label, desc ] = modeOption;
1352 let selected = (name == currentMode ? " selected" : "");
1353 let disabled = (name == currentMode ? " disabled" : "");
1354 let active = (( currentMode == "auto"
1355 && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1358 if (name == currentMode)
1359 desc += DarkMode.selectedModeOptionNote;
1362 class="select-mode-${name}${selected}${active}"
1367 >${label}</button>`;
1369 + `</${selectorTagName}>`;
1372 injectModeSelector: (replacedElement = null) => {
1373 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1375 // Inject the mode selector widget.
1377 if (replacedElement) {
1378 replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1379 modeSelector = replacedElement.firstElementChild;
1380 unwrap(replacedElement);
1383 if (Appearance.themeSelector == null)
1386 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", DarkMode.modeSelectorHTML());
1388 addUIElement(DarkMode.modeSelectorHTML());
1391 modeSelector = DarkMode.modeSelector = query("#dark-mode-selector");
1394 // Add event listeners and update state.
1395 requestAnimationFrame(() => {
1396 // Activate mode selector widget buttons.
1397 modeSelector.querySelectorAll("button").forEach(button => {
1398 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1402 /* Add active media query to update mode selector state when system dark
1403 mode setting changes. (This is relevant only for the ‘auto’ setting.)
1405 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => {
1406 DarkMode.updateModeSelectorState(modeSelector);
1410 modeSelectButtonClicked: (event) => {
1411 GWLog("DarkMode.modeSelectButtonClicked");
1413 /* We don’t want clicks to go through if the transition
1414 between modes has not completed yet, so we disable the
1415 button temporarily while we’re transitioning between
1419 // Determine which setting was chosen (ie. which button was clicked).
1420 let selectedMode = event.target.dataset.name;
1422 // Save the new setting.
1423 DarkMode.saveMode(selectedMode);
1425 // Actually change the mode.
1426 DarkMode.setMode(selectedMode);
1427 }, DarkMode, "modeSelectorInteractable");
1429 event.target.blur();
1432 updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1433 GWLog("DarkMode.updateModeSelectorState");
1435 /* If the mode selector has not yet been injected, then do nothing.
1437 if (modeSelector == null)
1440 // Get saved mode setting (or default).
1441 let currentMode = DarkMode.getSavedMode();
1443 // Clear current buttons state.
1444 modeSelector.querySelectorAll("button").forEach(button => {
1445 button.classList.remove("active", "selected");
1446 button.disabled = false;
1447 if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1448 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1451 // Set the correct button to be selected.
1452 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1453 button.classList.add("selected");
1454 button.disabled = true;
1455 button.title += DarkMode.selectedModeOptionNote;
1458 /* Ensure the right button (light or dark) has the “currently active”
1459 indicator, if the current mode is ‘auto’.
1461 if (currentMode == "auto")
1462 modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1467 /****************************/
1468 /* APPEARANCE CUSTOMIZATION */
1469 /****************************/
1471 Appearance = { ...Appearance,
1472 /**************************************************************************/
1478 themeSelector: null,
1479 themeSelectorAuxiliaryControlsContainer: null,
1480 themeSelectorInteractionBlockerOverlay: null,
1481 themeSelectorInteractableTimer: null,
1483 themeTweakerToggle: null,
1485 themeTweakerStyleBlock: null,
1487 themeTweakerUI: null,
1488 themeTweakerUIMainWindow: null,
1489 themeTweakerUIHelpWindow: null,
1490 themeTweakerUISampleTextContainer: null,
1491 themeTweakerUIClippyContainer: null,
1492 themeTweakerUIClippyControl: null,
1494 widthSelector: null,
1496 textSizeAdjustmentWidget: null,
1498 appearanceAdjustUIToggle: null,
1500 /**************************************************************************/
1504 /* Return a new <link> element linking a style sheet (.css file) for the
1505 given theme name and color scheme preference (i.e., value for the
1506 ‘media’ attribute; may be “light”, “dark”, or “” [empty string]).
1508 makeNewStyle: (newThemeName) => {
1509 let styleSheetNameSuffix = newThemeName == Appearance.defaultTheme
1511 : ("-" + newThemeName);
1512 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1514 return [["style", "theme"], ["colors", "theme light-mode"], ["inverted", "theme dark-mode", DarkMode.getMediaQuery()]].map(args => {
1515 let [baseName, className, mediaQuery] = args;
1516 return newElement("LINK", {
1518 "rel": "stylesheet",
1519 "href": ("/generated-css/" + baseName + styleSheetNameSuffix + currentStyleSheetNameComponents[1]),
1520 "media": mediaQuery || null,
1521 "blocking": "render"
1526 setTheme: (newThemeName, save = true) => {
1527 GWLog("Appearance.setTheme");
1529 let oldThemeName = "";
1530 if (typeof(newThemeName) == "undefined") {
1531 /* If no theme name to set is given, that means we’re setting the
1532 theme initially, on page load. The .currentTheme value will have
1533 been set by .setup().
1535 newThemeName = Appearance.currentTheme;
1537 /* If the selected (saved) theme is the default theme, then there’s
1540 if (newThemeName == Appearance.defaultTheme)
1543 oldThemeName = Appearance.currentTheme;
1545 /* When the unload callback runs, the .currentTheme value is still
1546 that of the old theme.
1548 let themeUnloadCallback = Appearance.themeUnloadCallbacks[oldThemeName];
1549 if (themeUnloadCallback != null)
1550 themeUnloadCallback(newThemeName);
1552 /* The old .currentTheme value is saved in oldThemeName.
1554 Appearance.currentTheme = newThemeName;
1556 /* The ‘save’ parameter might be false if this function is called
1557 from the theme tweaker, in which case we want to switch only
1558 temporarily, and preserve the saved setting until the user
1562 Appearance.saveCurrentTheme();
1565 let newStyles = Appearance.makeNewStyle(newThemeName);
1566 let loadingStyleCount = newStyles.length;
1568 let oldStyles = queryAll("head link.theme");
1570 let onNewStylesLoaded = (event) => {
1571 loadingStyleCount--;
1572 if(loadingStyleCount === 0) {
1573 for(oldStyle of oldStyles) removeElement(oldStyle);
1574 Appearance.postSetThemeHousekeeping(oldThemeName, newThemeName);
1578 for(newStyle of newStyles) newStyle.addEventListener("load", onNewStylesLoaded);
1580 if (Appearance.adjustmentTransitions) {
1581 pageFadeTransition(false);
1583 document.head.prepend(...newStyles);
1586 document.head.prepend(...newStyles);
1589 // Update UI state of all theme selectors.
1590 Appearance.updateThemeSelectorsState();
1593 postSetThemeHousekeeping: (oldThemeName = "", newThemeName = null) => {
1594 GWLog("Appearance.postSetThemeHousekeeping");
1596 if (newThemeName == null)
1597 newThemeName = Appearance.getSavedTheme();
1599 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1600 document.body.addClass("theme-" + newThemeName);
1602 recomputeUIElementsContainerHeight(true);
1604 let themeLoadCallback = Appearance.themeLoadCallbacks[newThemeName];
1605 if (themeLoadCallback != null)
1606 themeLoadCallback(oldThemeName);
1608 recomputeUIElementsContainerHeight();
1609 adjustUIForWindowSize();
1610 window.addEventListener("resize", GW.windowResized = (event) => {
1611 GWLog("GW.windowResized");
1612 adjustUIForWindowSize();
1613 recomputeUIElementsContainerHeight();
1616 generateImagesOverlay();
1618 if (Appearance.adjustmentTransitions)
1619 pageFadeTransition(true);
1620 Appearance.updateThemeTweakerSampleText();
1622 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== "undefined") {
1623 window.matchMedia("(orientation: portrait)").addListener(generateImagesOverlay);
1627 themeLoadCallbacks: {
1628 brutalist: (fromTheme = "") => {
1629 GWLog("Appearance.themeLoadCallbacks.brutalist");
1631 let bottomBarLinks = queryAll("#bottom-bar a");
1632 if (!GW.isMobile && bottomBarLinks.length == 5) {
1633 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1634 bottomBarLinks.forEach((link, i) => {
1635 link.dataset.originalText = link.textContent;
1636 link.textContent = newLinkTexts[i];
1641 classic: (fromTheme = "") => {
1642 GWLog("Appearance.themeLoadCallbacks.classic");
1644 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1645 button.innerHTML = "";
1649 dark: (fromTheme = "") => {
1650 GWLog("Appearance.themeLoadCallbacks.dark");
1652 insertHeadHTML(`<style id="dark-theme-adjustments">
1653 .markdown-reference-link a { color: #d200cf; filter: invert(100%); }
1654 #bottom-bar.decorative::before { filter: invert(100%); }
1656 registerInitializer("makeImagesGlow", true, () => query("#images-overlay") != null, () => {
1657 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1658 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)";
1659 image.style.width = parseInt(image.style.width) + 12 + "px";
1660 image.style.height = parseInt(image.style.height) + 12 + "px";
1661 image.style.top = parseInt(image.style.top) - 6 + "px";
1662 image.style.left = parseInt(image.style.left) - 6 + "px";
1667 less: (fromTheme = "") => {
1668 GWLog("Appearance.themeLoadCallbacks.less");
1670 injectSiteNavUIToggle();
1672 injectPostNavUIToggle();
1673 Appearance.injectAppearanceAdjustUIToggle();
1676 registerInitializer("shortenDate", true, () => query(".top-post-meta") != null, function () {
1677 let dtf = new Intl.DateTimeFormat([],
1678 (window.innerWidth < 1100) ?
1679 { month: "short", day: "numeric", year: "numeric" } :
1680 { month: "long", day: "numeric", year: "numeric" });
1681 let postDate = query(".top-post-meta .date");
1682 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1686 query("#content").insertAdjacentHTML("beforeend", `<div id="theme-less-mobile-first-row-placeholder"></div>`);
1690 registerInitializer("addSpans", true, () => query(".top-post-meta") != null, function () {
1691 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1692 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1696 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1697 // If state is not set (user has never clicked on the Less theme’s appearance
1698 // adjustment UI toggle) then show it, but then hide it after a short time.
1699 registerInitializer("engageAppearanceAdjustUI", true, () => query("#ui-elements-container") != null, function () {
1700 Appearance.toggleAppearanceAdjustUI();
1701 setTimeout(Appearance.toggleAppearanceAdjustUI, 3000);
1705 if (fromTheme != "") {
1706 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1707 setTimeout(function () {
1708 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1710 setTimeout(function () {
1711 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1715 // Unset the height of the #ui-elements-container.
1716 query("#ui-elements-container").style.height = "";
1718 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1719 Appearance.filtersExclusionPaths.themeLess = [
1720 "#content #secondary-bar",
1721 "#content .post .top-post-meta .date",
1722 "#content .post .top-post-meta .comment-count",
1724 Appearance.applyFilters();
1727 // We pre-query the relevant elements, so we don’t have to run querySelectorAll
1728 // on every firing of the scroll listener.
1730 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1731 "unbrokenDownScrollDistance": 0,
1732 "unbrokenUpScrollDistance": 0,
1733 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1734 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1735 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1737 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1741 themeUnloadCallbacks: {
1742 brutalist: (toTheme = "") => {
1743 GWLog("Appearance.themeUnloadCallbacks.brutalist");
1745 let bottomBarLinks = queryAll("#bottom-bar a");
1746 if (!GW.isMobile && bottomBarLinks.length == 5) {
1747 bottomBarLinks.forEach(link => {
1748 link.textContent = link.dataset.originalText;
1753 classic: (toTheme = "") => {
1754 GWLog("Appearance.themeUnloadCallbacks.classic");
1756 if (GW.isMobile && window.innerWidth <= 900)
1759 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1760 button.innerHTML = button.dataset.label;
1764 dark: (toTheme = "") => {
1765 GWLog("Appearance.themeUnloadCallbacks.dark");
1767 removeElement("#dark-theme-adjustments");
1770 less: (toTheme = "") => {
1771 GWLog("Appearance.themeUnloadCallbacks.less");
1773 removeSiteNavUIToggle();
1775 removePostNavUIToggle();
1776 Appearance.removeAppearanceAdjustUIToggle();
1779 window.removeEventListener("resize", updatePostNavUIVisibility);
1781 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1783 removeElement("#theme-less-mobile-first-row-placeholder");
1787 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1788 element.innerHTML = element.firstChild.innerHTML;
1792 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1794 // Reset filtered elements selector to default.
1795 delete Appearance.filtersExclusionPaths.themeLess;
1796 Appearance.applyFilters();
1800 pageFadeTransition: (fadeIn) => {
1802 document.body.removeClass("transparent");
1804 document.body.addClass("transparent");
1808 /* Set the saved theme setting to the currently active theme.
1810 saveCurrentTheme: () => {
1811 GWLog("Appearance.saveCurrentTheme");
1813 if (Appearance.currentTheme == Appearance.defaultTheme)
1814 setCookie("theme", "");
1816 setCookie("theme", Appearance.currentTheme);
1819 /* Reset theme, theme tweak filters, and text zoom to their saved settings.
1821 themeTweakReset: () => {
1822 GWLog("Appearance.themeTweakReset");
1824 Appearance.setTheme(Appearance.getSavedTheme());
1825 Appearance.applyFilters(Appearance.getSavedFilters());
1826 Appearance.setTextZoom(Appearance.getSavedTextZoom());
1829 /* Set the saved theme, theme tweak filter, and text zoom settings to their
1830 currently active values.
1832 themeTweakSave: () => {
1833 GWLog("Appearance.themeTweakSave");
1835 Appearance.saveCurrentTheme();
1836 Appearance.saveCurrentFilters();
1837 Appearance.saveCurrentTextZoom();
1840 /* Reset theme, theme tweak filters, and text zoom to their default levels.
1841 (Do not save the new settings, however.)
1843 themeTweakResetDefaults: () => {
1844 GWLog("Appearance.themeTweakResetDefaults");
1846 Appearance.setTheme(Appearance.defaultTheme, false);
1847 Appearance.applyFilters(Appearance.defaultFilters);
1848 Appearance.setTextZoom(Appearance.defaultTextZoom, false);
1851 themeTweakerResetSettings: () => {
1852 GWLog("Appearance.themeTweakerResetSettings");
1854 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
1855 Appearance.themeTweakerUIClippyContainer.style.display = Appearance.themeTweakerUIClippyControl.checked
1860 themeTweakerSaveSettings: () => {
1861 GWLog("Appearance.themeTweakerSaveSettings");
1863 Appearance.saveThemeTweakerClippyState();
1866 getSavedThemeTweakerClippyState: () => {
1867 return (JSON.parse(localStorage.getItem("theme-tweaker-settings") || `{ "showClippy": ${Appearance.defaultThemeTweakerClippyState} }` )["showClippy"]);
1870 saveThemeTweakerClippyState: () => {
1871 GWLog("Appearance.saveThemeTweakerClippyState");
1873 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ "showClippy": Appearance.themeTweakerUIClippyControl.checked }));
1876 getSavedAppearanceAdjustUIToggleState: () => {
1877 return ((localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") || Appearance.defaultAppearanceAdjustUIToggleState);
1880 saveAppearanceAdjustUIToggleState: () => {
1881 GWLog("Appearance.saveAppearanceAdjustUIToggleState");
1883 localStorage.setItem("appearance-adjust-ui-toggle-engaged", Appearance.appearanceAdjustUIToggle.query("button").hasClass("engaged"));
1886 /**************************************************************************/
1887 /* UI CONSTRUCTION & MANIPULATION
1890 contentWidthSelectorHTML: () => {
1891 return ("<div id='width-selector'>"
1892 + String.prototype.concat.apply("", Appearance.widthOptions.map(widthOption => {
1893 let [name, desc, abbr] = widthOption;
1894 let selected = (name == Appearance.currentWidth ? " selected" : "");
1895 let disabled = (name == Appearance.currentWidth ? " disabled" : "");
1896 return `<button type="button" class="select-width-${name}${selected}"${disabled} title="${desc}" tabindex="-1" data-name="${name}">${abbr}</button>`
1901 injectContentWidthSelector: () => {
1902 GWLog("Appearance.injectContentWidthSelector");
1904 // Inject the content width selector widget and activate buttons.
1905 Appearance.widthSelector = addUIElement(Appearance.contentWidthSelectorHTML());
1906 Appearance.widthSelector.queryAll("button").forEach(button => {
1907 button.addActivateEvent(Appearance.widthAdjustButtonClicked);
1910 // Make sure the accesskey (to cycle to the next width) is on the right button.
1911 Appearance.setWidthAdjustButtonsAccesskey();
1913 // Inject transitions CSS, if animating changes is enabled.
1914 if (Appearance.adjustmentTransitions) {
1916 `<style id="width-transition">
1918 #ui-elements-container,
1921 max-width 0.3s ease;
1927 setWidthAdjustButtonsAccesskey: () => {
1928 GWLog("Appearance.setWidthAdjustButtonsAccesskey");
1930 Appearance.widthSelector.queryAll("button").forEach(button => {
1931 button.removeAttribute("accesskey");
1932 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1934 let selectedButton = Appearance.widthSelector.query("button.selected");
1935 let nextButtonInCycle = selectedButton == selectedButton.parentElement.lastChild
1936 ? selectedButton.parentElement.firstChild
1937 : selectedButton.nextSibling;
1938 nextButtonInCycle.accessKey = "'";
1939 nextButtonInCycle.title += ` [\']`;
1942 injectTextSizeAdjustmentUI: () => {
1943 GWLog("Appearance.injectTextSizeAdjustmentUI");
1945 if (Appearance.textSizeAdjustmentWidget != null)
1948 let inject = () => {
1949 GWLog("Appearance.injectTextSizeAdjustmentUI [INJECTING]");
1951 Appearance.textSizeAdjustmentWidget = addUIElement("<div id='text-size-adjustment-ui'>"
1952 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1953 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1954 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1957 Appearance.textSizeAdjustmentWidget.queryAll("button").forEach(button => {
1958 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1962 if (query("#content.post-page") != null) {
1965 document.addEventListener("DOMContentLoaded", () => {
1966 if (!( query(".post-body") == null
1967 && query(".comment-body") == null))
1973 themeSelectorHTML: () => {
1974 return ("<div id='theme-selector' class='theme-selector'>"
1975 + String.prototype.concat.apply("", Appearance.themeOptions.map(themeOption => {
1976 let [name, desc, letter] = themeOption;
1977 let selected = (name == Appearance.currentTheme ? ' selected' : '');
1978 let disabled = (name == Appearance.currentTheme ? ' disabled' : '');
1979 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1980 return `<button type='button' class='select-theme select-theme-${name}${selected}'${disabled} title="${desc} [${accesskey}]" data-theme-name="${name}" data-theme-description="${desc}" accesskey='${accesskey}' tabindex='-1'>${letter}</button>`;
1985 injectThemeSelector: () => {
1986 GWLog("Appearance.injectThemeSelector");
1988 Appearance.themeSelector = addUIElement(Appearance.themeSelectorHTML());
1989 Appearance.themeSelector.queryAll("button").forEach(button => {
1990 button.addActivateEvent(Appearance.themeSelectButtonClicked);
1994 // Add close button.
1995 let themeSelectorCloseButton = newElement("BUTTON", { "class": "theme-selector-close-button" }, { "innerHTML": "" });
1996 themeSelectorCloseButton.addActivateEvent(Appearance.themeSelectorCloseButtonClicked);
1997 Appearance.themeSelector.appendChild(themeSelectorCloseButton);
1999 // Inject auxiliary controls container.
2000 Appearance.themeSelectorAuxiliaryControlsContainer = newElement("DIV", { "class": "auxiliary-controls-container" });
2001 Appearance.themeSelector.appendChild(Appearance.themeSelectorAuxiliaryControlsContainer);
2003 // Inject mobile versions of various UI elements.
2004 Appearance.injectThemeTweakerToggle();
2005 injectAntiKibitzerToggle();
2006 DarkMode.injectModeSelector();
2008 // Inject interaction blocker overlay.
2009 Appearance.themeSelectorInteractionBlockerOverlay = Appearance.themeSelector.appendChild(newElement("DIV", { "class": "interaction-blocker-overlay" }));
2010 Appearance.themeSelectorInteractionBlockerOverlay.addActivateEvent(event => { event.stopPropagation(); });
2013 // Inject transitions CSS, if animating changes is enabled.
2014 if (Appearance.adjustmentTransitions) {
2015 insertHeadHTML(`<style id="theme-fade-transition">
2018 opacity 0.5s ease-out,
2019 background-color 0.3s ease-out;
2022 background-color: #777;
2025 opacity 0.5s ease-in,
2026 background-color 0.3s ease-in;
2032 updateThemeSelectorsState: () => {
2033 GWLog("Appearance.updateThemeSelectorsState");
2035 queryAll(".theme-selector button.select-theme").forEach(button => {
2036 button.removeClass("selected");
2037 button.disabled = false;
2039 queryAll(".theme-selector button.select-theme-" + Appearance.currentTheme).forEach(button => {
2040 button.addClass("selected");
2041 button.disabled = true;
2044 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.currentTheme;
2047 setThemeSelectorInteractable: (interactable) => {
2048 GWLog("Appearance.setThemeSelectorInteractable");
2050 Appearance.themeSelectorInteractionBlockerOverlay.classList.toggle("enabled", (interactable == false));
2053 themeTweakerUIHTML: () => {
2054 return (`<div id="theme-tweaker-ui" style="display: none;">\n`
2055 + `<div class="theme-tweaker-window main-window">
2056 <div class="theme-tweaker-window-title-bar">
2057 <div class="theme-tweaker-window-title">
2058 <h1>Customize appearance</h1>
2060 <div class="theme-tweaker-window-title-bar-buttons-container">
2061 <button type="button" class="help-button" tabindex="-1"></button>
2062 <button type="button" class="minimize-button minimize" tabindex="-1"></button>
2063 <button type="button" class="close-button" tabindex="-1"></button>
2066 <div class="theme-tweaker-window-content-view">
2067 <div class="theme-select">
2068 <p class="current-theme">Current theme:
2069 <span>${Appearance.getSavedTheme()}</span>
2071 <div class="theme-selector"></div>
2073 <div class="controls-container">
2074 <div id="theme-tweak-section-sample-text" class="section" data-label="Sample text">
2075 <div class="sample-text-container"><span class="sample-text">
2076 <p>Less Wrong (text)</p>
2077 <p><a href="#">Less Wrong (link)</a></p>
2080 <div id="theme-tweak-section-text-size-adjust" class="section" data-label="Text size">
2081 <button type="button" class="text-size-adjust-button decrease" title="Decrease text size"></button>
2082 <button type="button" class="text-size-adjust-button default" title="Reset to default text size"></button>
2083 <button type="button" class="text-size-adjust-button increase" title="Increase text size"></button>
2085 <div id="theme-tweak-section-invert" class="section" data-label="Invert (photo-negative)">
2086 <input type="checkbox" id="theme-tweak-control-invert"></input>
2087 <label for="theme-tweak-control-invert">Invert colors</label>
2089 <div id="theme-tweak-section-saturate" class="section" data-label="Saturation">
2090 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2091 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
2092 <div class="notch theme-tweak-slider-notch-saturate" title="Reset saturation to default value (100%)"></div>
2094 <div id="theme-tweak-section-brightness" class="section" data-label="Brightness">
2095 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2096 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
2097 <div class="notch theme-tweak-slider-notch-brightness" title="Reset brightness to default value (100%)"></div>
2099 <div id="theme-tweak-section-contrast" class="section" data-label="Contrast">
2100 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2101 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
2102 <div class="notch theme-tweak-slider-notch-contrast" title="Reset contrast to default value (100%)"></div>
2104 <div id="theme-tweak-section-hue-rotate" class="section" data-label="Hue rotation">
2105 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
2106 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
2107 <div class="notch theme-tweak-slider-notch-hue-rotate" title="Reset hue to default (0° away from standard colors for theme)"></div>
2110 <div class="buttons-container">
2111 <button type="button" class="reset-defaults-button">Reset to defaults</button>
2112 <button type="button" class="ok-button default-button">OK</button>
2113 <button type="button" class="cancel-button">Cancel</button>
2117 <div class="clippy-container">
2118 <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>)
2119 <div class="clippy"></div>
2120 <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>
2122 <div class="theme-tweaker-window help-window" style="display: none;">
2123 <div class="theme-tweaker-window-title-bar">
2124 <div class="theme-tweaker-window-title">
2125 <h1>Theme tweaker help</h1>
2128 <div class="theme-tweaker-window-content-view">
2129 <div id="theme-tweak-section-clippy" class="section" data-label="Theme Tweaker Assistant">
2130 <input type="checkbox" id="theme-tweak-control-clippy" checked="checked"></input>
2131 <label for="theme-tweak-control-clippy">Show Bobby the Basilisk</label>
2133 <div class="buttons-container">
2134 <button type="button" class="ok-button default-button">OK</button>
2135 <button type="button" class="cancel-button">Cancel</button>
2142 injectThemeTweaker: () => {
2143 GWLog("Appearance.injectThemeTweaker");
2145 Appearance.themeTweakerUI = addUIElement(Appearance.themeTweakerUIHTML());
2146 Appearance.themeTweakerUIMainWindow = Appearance.themeTweakerUI.firstElementChild;
2147 Appearance.themeTweakerUIHelpWindow = Appearance.themeTweakerUI.query(".help-window");
2148 Appearance.themeTweakerUISampleTextContainer = Appearance.themeTweakerUI.query("#theme-tweak-section-sample-text .sample-text-container");
2149 Appearance.themeTweakerUIClippyContainer = Appearance.themeTweakerUI.query(".clippy-container");
2150 Appearance.themeTweakerUIClippyControl = Appearance.themeTweakerUI.query("#theme-tweak-control-clippy");
2152 // Clicking the background overlay closes the theme tweaker.
2153 Appearance.themeTweakerUI.addActivateEvent(Appearance.themeTweakerUIOverlayClicked, true);
2155 // Intercept clicks, so they don’t “fall through” the background overlay.
2156 Array.from(Appearance.themeTweakerUI.children).forEach(themeTweakerUIWindow => {
2157 themeTweakerUIWindow.addActivateEvent((event) => {
2158 event.stopPropagation();
2162 Appearance.themeTweakerUI.queryAll("input").forEach(field => {
2163 /* All input types in the theme tweaker receive a ‘change’ event
2164 when their value is changed. (Range inputs, in particular,
2165 receive this event when the user lets go of the handle.) This
2166 means we should update the filters for the entire page, to match
2169 field.addEventListener("change", Appearance.themeTweakerUIFieldValueChanged);
2171 /* Range inputs receive an ‘input’ event while being scrubbed,
2172 updating “live” as the handle is moved. We don’t want to change
2173 the filters for the actual page while this is happening, but we
2174 do want to change the filters for the *sample text*, so the user
2175 can see what effects his changes are having, live, without
2176 having to let go of the handle.
2178 if (field.type == "range")
2179 field.addEventListener("input", Appearance.themeTweakerUIFieldInputReceived);
2182 Appearance.themeTweakerUI.query(".help-button").addActivateEvent(Appearance.themeTweakerUIHelpButtonClicked);
2183 Appearance.themeTweakerUI.query(".minimize-button").addActivateEvent(Appearance.themeTweakerUIMinimizeButtonClicked);
2184 Appearance.themeTweakerUI.query(".close-button").addActivateEvent(Appearance.themeTweakerUICloseButtonClicked);
2185 Appearance.themeTweakerUI.query(".reset-defaults-button").addActivateEvent(Appearance.themeTweakerUIResetDefaultsButtonClicked);
2186 Appearance.themeTweakerUI.query(".main-window .cancel-button").addActivateEvent(Appearance.themeTweakerUICancelButtonClicked);
2187 Appearance.themeTweakerUI.query(".main-window .ok-button").addActivateEvent(Appearance.themeTweakerUIOKButtonClicked);
2188 Appearance.themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowCancelButtonClicked);
2189 Appearance.themeTweakerUI.query(".help-window .ok-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowOKButtonClicked);
2191 Appearance.themeTweakerUI.queryAll(".notch").forEach(notch => {
2192 notch.addActivateEvent(Appearance.themeTweakerUISliderNotchClicked);
2195 Appearance.themeTweakerUI.query(".clippy-close-button").addActivateEvent(Appearance.themeTweakerUIClippyCloseButtonClicked);
2197 insertHeadHTML(`<style id="theme-tweaker-style"></style>`);
2198 Appearance.themeTweakerStyleBlock = document.head.query("#theme-tweaker-style");
2200 Appearance.themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
2201 Appearance.themeTweakerUI.queryAll(".theme-selector > *:not(.select-theme)").forEach(element => {
2204 Appearance.themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
2205 button.addActivateEvent(Appearance.themeSelectButtonClicked);
2208 Appearance.themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
2209 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
2212 if (GW.isMobile == false)
2213 Appearance.injectThemeTweakerToggle();
2216 themeTweakerToggleHTML: () => {
2217 return (`<div id="theme-tweaker-toggle">`
2221 title="Customize appearance [;]"
2227 injectThemeTweakerToggle: () => {
2228 GWLog("Appearance.injectThemeTweakerToggle");
2231 if (Appearance.themeSelector == null)
2234 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", Appearance.themeTweakerToggleHTML());
2235 Appearance.themeTweakerToggle = Appearance.themeSelector.query("#theme-tweaker-toggle");
2237 Appearance.themeTweakerToggle = addUIElement(Appearance.themeTweakerToggleHTML());
2240 Appearance.themeTweakerToggle.query("button").addActivateEvent(Appearance.themeTweakerToggleClicked);
2243 showThemeTweakerUI: () => {
2244 GWLog("Appearance.showThemeTweakerUI");
2246 if (query("link[href^='/css/theme_tweaker.css']") == null) {
2247 // Theme tweaker CSS needs to be loaded.
2249 let themeTweakerStyleSheet = newElement("LINK", {
2250 "rel": "stylesheet",
2251 "href": "/css/theme_tweaker.css"
2254 themeTweakerStyleSheet.addEventListener("load", (event) => {
2255 requestAnimationFrame(() => {
2256 themeTweakerStyleSheet.disabled = false;
2258 Appearance.showThemeTweakerUI();
2261 document.head.appendChild(themeTweakerStyleSheet);
2266 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.getSavedTheme();
2268 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = (Appearance.currentFilters["invert"] == "100%");
2269 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2270 let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2271 slider.value = /^[0-9]+/.exec(Appearance.currentFilters[sliderName]) || slider.dataset["defaultValue"];
2272 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2275 Appearance.toggleThemeTweakerUI();
2278 toggleThemeTweakerUI: () => {
2279 GWLog("Appearance.toggleThemeTweakerUI");
2281 let show = (Appearance.themeTweakerUI.style.display == "none");
2283 Appearance.themeTweakerUI.style.display = show ? "block" : "none";
2284 Appearance.setThemeTweakerWindowMinimized(false);
2285 Appearance.themeTweakerStyleBlock.innerHTML = show ? `#content, #ui-elements-container > div:not(#theme-tweaker-ui) { pointer-events: none; user-select: none; }` : "";
2289 Appearance.themeTweakerToggle.query("button").disabled = true;
2290 // Focus invert checkbox.
2291 Appearance.themeTweakerUI.query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
2292 // Show sample text in appropriate font.
2293 Appearance.updateThemeTweakerSampleText();
2294 // Disable tab-selection of the search box.
2295 setSearchBoxTabSelectable(false);
2296 // Disable scrolling of the page.
2297 togglePageScrolling(false);
2299 // Re-enable button.
2300 Appearance.themeTweakerToggle.query("button").disabled = false;
2301 // Re-enable tab-selection of the search box.
2302 setSearchBoxTabSelectable(true);
2303 // Re-enable scrolling of the page.
2304 togglePageScrolling(true);
2307 // Set theme tweaker assistant visibility.
2308 Appearance.themeTweakerUIClippyContainer.style.display = (Appearance.getSavedThemeTweakerClippyState() == true) ? "block" : "none";
2311 setThemeTweakerWindowMinimized: (minimize) => {
2312 GWLog("Appearance.setThemeTweakerWindowMinimized");
2314 Appearance.themeTweakerUIMainWindow.query(".minimize-button").swapClasses([ "minimize", "maximize" ], (minimize ? 1 : 0));
2315 Appearance.themeTweakerUIMainWindow.classList.toggle("minimized", minimize);
2316 Appearance.themeTweakerUI.classList.toggle("main-window-minimized", minimize);
2319 toggleThemeTweakerHelpWindow: () => {
2320 GWLog("Appearance.toggleThemeTweakerHelpWindow");
2322 Appearance.themeTweakerUIHelpWindow.style.display = Appearance.themeTweakerUIHelpWindow.style.display == "none"
2325 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2326 // Focus theme tweaker assistant checkbox.
2327 Appearance.themeTweakerUI.query("#theme-tweak-control-clippy").focus();
2328 // Disable interaction on main theme tweaker window.
2329 Appearance.themeTweakerUI.style.pointerEvents = "none";
2330 Appearance.themeTweakerUIMainWindow.style.pointerEvents = "none";
2332 // Re-enable interaction on main theme tweaker window.
2333 Appearance.themeTweakerUI.style.pointerEvents = "auto";
2334 Appearance.themeTweakerUIMainWindow.style.pointerEvents = "auto";
2338 resetThemeTweakerUIDefaultState: () => {
2339 GWLog("Appearance.resetThemeTweakerUIDefaultState");
2341 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
2343 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2344 let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2345 slider.value = slider.dataset["defaultValue"];
2346 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2350 updateThemeTweakerSampleText: () => {
2351 GWLog("Appearance.updateThemeTweakerSampleText");
2353 let sampleText = Appearance.themeTweakerUISampleTextContainer.query("#theme-tweak-section-sample-text .sample-text");
2355 // This causes the sample text to take on the properties of the body text of a post.
2356 sampleText.removeClass("body-text");
2357 let bodyTextElement = query(".post-body") || query(".comment-body");
2358 sampleText.addClass("body-text");
2359 sampleText.style.color = bodyTextElement ?
2360 getComputedStyle(bodyTextElement).color :
2361 getComputedStyle(query("#content")).color;
2363 // Here we find out what is the actual background color that will be visible behind
2364 // the body text of posts, and set the sample text’s background to that.
2365 let findStyleBackground = (selector) => {
2366 return "#fff"; // FIXME
2368 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2369 if (rule.selectorText == selector)
2372 return x.style.backgroundColor;
2375 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2378 injectAppearanceAdjustUIToggle: () => {
2379 GWLog("Appearance.injectAppearanceAdjustUIToggle");
2381 Appearance.appearanceAdjustUIToggle = addUIElement(`<div id="appearance-adjust-ui-toggle"><button type="button" tabindex="-1"></button></div>`);
2382 Appearance.appearanceAdjustUIToggle.query("button").addActivateEvent(Appearance.appearanceAdjustUIToggleButtonClicked);
2385 && Appearance.getSavedAppearanceAdjustUIToggleState() == true) {
2386 Appearance.toggleAppearanceAdjustUI();
2390 removeAppearanceAdjustUIToggle: () => {
2391 GWLog("Appearance.removeAppearanceAdjustUIToggle");
2393 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2394 element.removeClass("engaged");
2396 removeElement("#appearance-adjust-ui-toggle");
2399 toggleAppearanceAdjustUI: () => {
2400 GWLog("Appearance.toggleAppearanceAdjustUI");
2402 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2403 element.toggleClass("engaged");
2407 clearTimeout(Appearance.themeSelectorInteractableTimer);
2408 Appearance.setThemeSelectorInteractable(false);
2409 Appearance.themeSelectorInteractableTimer = setTimeout(() => {
2410 Appearance.setThemeSelectorInteractable(true);
2415 /**************************************************************************/
2419 /* Theme selector close button (on mobile version of theme selector).
2421 themeSelectorCloseButtonClicked: (event) => {
2422 GWLog("Appearance.themeSelectorCloseButtonClicked");
2424 Appearance.toggleAppearanceAdjustUI();
2425 Appearance.saveAppearanceAdjustUIToggleState();
2428 /* “Cog” button (to toggle the appearance adjust UI widgets in “less”
2429 theme, or theme selector UI on mobile).
2431 appearanceAdjustUIToggleButtonClicked: (event) => {
2432 GWLog("Appearance.appearanceAdjustUIToggleButtonClicked");
2434 Appearance.toggleAppearanceAdjustUI();
2435 Appearance.saveAppearanceAdjustUIToggleState();
2438 /* Width adjust buttons (“normal”, “wide”, “fluid”).
2440 widthAdjustButtonClicked: (event) => {
2441 GWLog("Appearance.widthAdjustButtonClicked");
2443 // Determine which setting was chosen (i.e., which button was clicked).
2444 let selectedWidth = event.target.dataset.name;
2447 Appearance.currentWidth = selectedWidth;
2449 // Save the new setting.
2450 Appearance.saveCurrentWidth();
2452 // Save current visible comment
2453 let visibleComment = getCurrentVisibleComment();
2455 // Actually change the content width.
2456 Appearance.setContentWidth(selectedWidth);
2457 event.target.parentElement.childNodes.forEach(button => {
2458 button.removeClass("selected");
2459 button.disabled = false;
2461 event.target.addClass("selected");
2462 event.target.disabled = true;
2464 // Make sure the accesskey (to cycle to the next width) is on the right button.
2465 Appearance.setWidthAdjustButtonsAccesskey();
2467 // Regenerate images overlay.
2468 generateImagesOverlay();
2471 visibleComment.scrollIntoView();
2474 /* Theme selector buttons (“A” through “I”).
2476 themeSelectButtonClicked: (event) => {
2477 GWLog("Appearance.themeSelectButtonClicked");
2479 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
2480 let save = (Appearance.themeTweakerUI.contains(event.target) == false);
2481 Appearance.setTheme(themeName, save);
2483 Appearance.toggleAppearanceAdjustUI();
2486 /* The text size adjust (“-”, “A”, “+”) buttons.
2488 textSizeAdjustButtonClicked: (event) => {
2489 GWLog("Appearance.textSizeAdjustButtonClicked");
2491 var zoomFactor = Appearance.currentTextZoom;
2492 if (event.target.hasClass("decrease")) {
2494 } else if (event.target.hasClass("increase")) {
2497 zoomFactor = Appearance.defaultTextZoom;
2500 let save = ( Appearance.textSizeAdjustmentWidget != null
2501 && Appearance.textSizeAdjustmentWidget.contains(event.target));
2502 Appearance.setTextZoom(zoomFactor, save);
2505 /* Theme tweaker toggle button.
2507 themeTweakerToggleClicked: (event) => {
2508 GWLog("Appearance.themeTweakerToggleClicked");
2510 Appearance.showThemeTweakerUI();
2513 /***************************/
2514 /* Theme tweaker UI events.
2517 /* Key pressed while theme tweaker is open.
2519 themeTweakerUIKeyPressed: (event) => {
2520 GWLog("Appearance.themeTweakerUIKeyPressed");
2522 if (event.key == "Escape") {
2523 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2524 Appearance.toggleThemeTweakerHelpWindow();
2525 Appearance.themeTweakerResetSettings();
2526 } else if (Appearance.themeTweakerUI.style.display != "none") {
2527 Appearance.toggleThemeTweakerUI();
2528 Appearance.themeTweakReset();
2530 } else if (event.key == "Enter") {
2531 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2532 Appearance.toggleThemeTweakerHelpWindow();
2533 Appearance.themeTweakerSaveSettings();
2534 } else if (Appearance.themeTweakerUI.style.display != "none") {
2535 Appearance.toggleThemeTweakerUI();
2536 Appearance.themeTweakSave();
2541 /* Theme tweaker overlay clicked.
2543 themeTweakerUIOverlayClicked: (event) => {
2544 GWLog("Appearance.themeTweakerUIOverlayClicked");
2546 if (event.type == "mousedown") {
2547 Appearance.themeTweakerUI.style.opacity = "0.01";
2549 Appearance.toggleThemeTweakerUI();
2550 Appearance.themeTweakerUI.style.opacity = "1.0";
2551 Appearance.themeTweakReset();
2555 /* In the theme tweaker, a slider clicked, or released after drag; or a
2556 checkbox clicked (either in the main theme tweaker UI, or in the help
2559 themeTweakerUIFieldValueChanged: (event) => {
2560 GWLog("Appearance.themeTweakerUIFieldValueChanged");
2562 if (event.target.id == "theme-tweak-control-invert") {
2563 Appearance.currentFilters["invert"] = event.target.checked ? "100%" : "0%";
2564 } else if (event.target.type == "range") {
2565 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2566 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2567 Appearance.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2568 } else if (event.target.id == "theme-tweak-control-clippy") {
2569 Appearance.themeTweakerUIClippyContainer.style.display = event.target.checked ? "block" : "none";
2572 // Clear the sample text filters.
2573 Appearance.themeTweakerUISampleTextContainer.style.filter = "";
2575 // Apply the new filters globally.
2576 Appearance.applyFilters();
2579 /* Theme tweaker slider dragged (live-update event).
2581 themeTweakerUIFieldInputReceived: (event) => {
2582 GWLog("Appearance.themeTweakerUIFieldInputReceived");
2584 let sampleTextFilters = Appearance.currentFilters;
2585 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2586 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2587 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2589 Appearance.themeTweakerUISampleTextContainer.style.filter = Appearance.filterStringFromFilters(sampleTextFilters);
2592 /* Close button in main theme tweaker UI (title bar).
2594 themeTweakerUICloseButtonClicked: (event) => {
2595 GWLog("Appearance.themeTweakerUICloseButtonClicked");
2597 Appearance.toggleThemeTweakerUI();
2598 Appearance.themeTweakReset();
2601 /* Minimize button in main theme tweaker UI (title bar).
2603 themeTweakerUIMinimizeButtonClicked: (event) => {
2604 GWLog("Appearance.themeTweakerUIMinimizeButtonClicked");
2606 Appearance.setThemeTweakerWindowMinimized(event.target.hasClass("minimize"));
2609 /* Help (“?”) button in main theme tweaker UI (title bar).
2611 themeTweakerUIHelpButtonClicked: (event) => {
2612 GWLog("Appearance.themeTweakerUIHelpButtonClicked");
2614 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
2615 Appearance.toggleThemeTweakerHelpWindow();
2618 /* “Reset Defaults” button in main theme tweaker UI.
2620 themeTweakerUIResetDefaultsButtonClicked: (event) => {
2621 GWLog("Appearance.themeTweakerUIResetDefaultsButtonClicked");
2623 Appearance.themeTweakResetDefaults();
2624 Appearance.resetThemeTweakerUIDefaultState();
2627 /* “Cancel” button in main theme tweaker UI.
2629 themeTweakerUICancelButtonClicked: (event) => {
2630 GWLog("Appearance.themeTweakerUICancelButtonClicked");
2632 Appearance.toggleThemeTweakerUI();
2633 Appearance.themeTweakReset();
2636 /* “OK” button in main theme tweaker UI.
2638 themeTweakerUIOKButtonClicked: (event) => {
2639 GWLog("Appearance.themeTweakerUIOKButtonClicked");
2641 Appearance.toggleThemeTweakerUI();
2642 Appearance.themeTweakSave();
2645 /* “Cancel” button in theme tweaker help window.
2647 themeTweakerUIHelpWindowCancelButtonClicked: (event) => {
2648 GWLog("Appearance.themeTweakerUIHelpWindowCancelButtonClicked");
2650 Appearance.toggleThemeTweakerHelpWindow();
2651 Appearance.themeTweakerResetSettings();
2654 /* “OK” button in theme tweaker help window.
2656 themeTweakerUIHelpWindowOKButtonClicked: (event) => {
2657 GWLog("Appearance.themeTweakerUIHelpWindowOKButtonClicked");
2659 Appearance.toggleThemeTweakerHelpWindow();
2660 Appearance.themeTweakerSaveSettings();
2663 /* The notch in the theme tweaker sliders (to reset the slider to its
2666 themeTweakerUISliderNotchClicked: (event) => {
2667 GWLog("Appearance.themeTweakerUISliderNotchClicked");
2669 let slider = event.target.parentElement.query("input[type='range']");
2670 slider.value = slider.dataset["defaultValue"];
2671 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset["labelSuffix"];
2672 Appearance.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset["valueSuffix"];
2673 Appearance.applyFilters();
2676 /* The close button in the “Bobby the Basilisk” help message.
2678 themeTweakerUIClippyCloseButtonClicked: (event) => {
2679 GWLog("Appearance.themeTweakerUIClippyCloseButtonClicked");
2681 Appearance.themeTweakerUIClippyContainer.style.display = "none";
2682 Appearance.themeTweakerUIClippyControl.checked = false;
2683 Appearance.saveThemeTweakerClippyState();
2687 function setSearchBoxTabSelectable(selectable) {
2688 GWLog("setSearchBoxTabSelectable");
2689 query("input[type='search']").tabIndex = selectable ? "" : "-1";
2690 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2693 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
2694 // otherwise, show it.
2695 function updatePostNavUIVisibility() {
2696 GWLog("updatePostNavUIVisibility");
2697 var hidePostNavUIToggle = true;
2698 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
2699 if (getComputedStyle(element).visibility == "visible" ||
2700 element.style.visibility == "visible" ||
2701 element.style.visibility == "unset")
2702 hidePostNavUIToggle = false;
2704 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
2705 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
2709 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
2710 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
2711 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
2712 function updateSiteNavUIState(event) {
2713 GWLog("updateSiteNavUIState");
2714 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
2715 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
2716 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
2718 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
2719 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
2721 GW.scrollState.lastScrollTop = newScrollTop;
2723 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
2724 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
2725 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
2726 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged"))
2727 Appearance.toggleAppearanceAdjustUI();
2730 // On mobile, make site nav UI translucent on ANY scroll down.
2732 GW.scrollState.siteNavUIElements.forEach(element => {
2733 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
2734 else element.removeClass("translucent-on-scroll");
2737 // Show site nav UI when scrolling a full page up, or to the top.
2738 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
2739 GW.scrollState.lastScrollTop == 0) &&
2740 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
2741 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
2743 // On desktop, show appearance adjust UI when scrolling to the top.
2744 if ((!GW.isMobile) &&
2745 (GW.scrollState.lastScrollTop == 0) &&
2746 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
2747 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false"))
2748 Appearance.toggleAppearanceAdjustUI();
2751 /*********************/
2752 /* PAGE QUICK-NAV UI */
2753 /*********************/
2755 function injectQuickNavUI() {
2756 GWLog("injectQuickNavUI");
2757 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2758 `<a href='#top' title="Up to top [,]" accesskey=','></a>
2759 <a href='#comments' title="Comments [/]" accesskey='/'></a>
2760 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
2764 /**********************/
2765 /* NEW COMMENT NAV UI */
2766 /**********************/
2768 function injectNewCommentNavUI(newCommentsCount) {
2769 GWLog("injectNewCommentNavUI");
2770 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
2771 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
2772 <span class='new-comments-count'></span>
2773 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
2776 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2777 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2778 GWLog("GW.commentQuicknavButtonClicked");
2779 scrollToNewComment(/next/.test(event.target.className));
2780 event.target.blur();
2784 document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => {
2785 GWLog("GW.commentQuicknavKeyPressed");
2786 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2787 if (event.key == ",") scrollToNewComment(false);
2788 if (event.key == ".") scrollToNewComment(true)
2791 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2792 + `<span>Since:</span>`
2793 + `<input type='text' class='hns-date'></input>`
2796 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2797 GWLog("GW.hnsDatePickerValueChanged");
2798 let hnsDate = time_fromHuman(event.target.value);
2800 setHistoryLastVisitedDate(hnsDate);
2801 let newCommentsCount = highlightCommentsSince(hnsDate);
2802 updateNewCommentNavUI(newCommentsCount);
2806 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2807 GWLog("GW.newCommentsCountClicked");
2808 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2809 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2813 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2814 function time_fromHuman(string) {
2815 /* Convert a human-readable date into a JS timestamp */
2816 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2817 string = string.replace(' ', 'T'); // revert nice spacing
2818 string += ':00.000Z'; // complete ISO 8601 date
2819 time = Date.parse(string); // milliseconds since epoch
2821 // browsers handle ISO 8601 without explicit timezone differently
2822 // thus, we have to fix that by hand
2823 time += (new Date()).getTimezoneOffset() * 60e3;
2825 string = string.replace(' at', '');
2826 time = Date.parse(string); // milliseconds since epoch
2831 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2832 GWLog("updateNewCommentNavUI");
2833 // Update the new comments count.
2834 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2835 newCommentsCountLabel.innerText = newCommentsCount;
2836 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2838 // Update the date picker field.
2839 if (hnsDate != -1) {
2840 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2844 /********************************/
2845 /* COMMENTS VIEW MODE SELECTION */
2846 /********************************/
2848 function injectCommentsViewModeSelector() {
2849 GWLog("injectCommentsViewModeSelector");
2850 let commentsContainer = query("#comments");
2851 if (commentsContainer == null) return;
2853 let currentModeThreaded = (location.href.search("chrono=t") == -1);
2854 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2856 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2857 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
2858 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
2861 // commentsViewModeSelector.queryAll("a").forEach(button => {
2862 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2865 if (!currentModeThreaded) {
2866 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2867 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2868 commentParentLink.addClass("inline-author");
2869 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2872 queryAll(".comment-child-links a").forEach(commentChildLink => {
2873 commentChildLink.textContent = commentChildLink.textContent.slice(1);
2874 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2877 rectifyChronoModeCommentChildLinks();
2879 commentsContainer.addClass("chrono");
2881 commentsContainer.addClass("threaded");
2884 // Remove extraneous top-level comment thread in chrono mode.
2885 let topLevelCommentThread = query("#comments > .comment-thread");
2886 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2889 // function commentsViewModeSelectorButtonClicked(event) {
2890 // event.preventDefault();
2893 // let request = new XMLHttpRequest();
2894 // request.open("GET", event.target.href);
2895 // request.onreadystatechange = () => {
2896 // if (request.readyState != 4) return;
2897 // newDocument = htmlToElement(request.response);
2899 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2901 // // Update the buttons.
2902 // event.target.addClass("selected");
2903 // event.target.parentElement.query("." + classes.old).removeClass("selected");
2905 // // Update the #comments container.
2906 // let commentsContainer = query("#comments");
2907 // commentsContainer.removeClass(classes.old);
2908 // commentsContainer.addClass(classes.new);
2910 // // Update the content.
2911 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2916 // function htmlToElement(html) {
2917 // let template = newElement("TEMPLATE", { }, { "innerHTML": html.trim() });
2918 // return template.content;
2921 function rectifyChronoModeCommentChildLinks() {
2922 GWLog("rectifyChronoModeCommentChildLinks");
2923 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2924 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2925 let childLinks = commentChildLinksContainer.queryAll("a");
2926 childLinks.forEach((link, index) => {
2927 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2931 let childLinksArray = Array.from(childLinks)
2932 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2933 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2936 function childrenOfComment(commentID) {
2937 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2938 let commentParentLink = commentItem.query("a.comment-parent-link");
2939 return ((commentParentLink||{}).hash == "#" + commentID);
2943 /********************************/
2944 /* COMMENTS LIST MODE SELECTION */
2945 /********************************/
2947 function injectCommentsListModeSelector() {
2948 GWLog("injectCommentsListModeSelector");
2949 if (query("#content > .comment-thread") == null) return;
2951 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2952 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2953 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2956 if (query(".sublevel-nav") || query("#top-nav-bar")) {
2957 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2959 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2961 let commentsListModeSelector = query("#comments-list-mode-selector");
2963 commentsListModeSelector.queryAll("button").forEach(button => {
2964 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2965 GWLog("GW.commentsListModeSelectButtonClicked");
2966 event.target.parentElement.queryAll("button").forEach(button => {
2967 button.removeClass("selected");
2968 button.disabled = false;
2969 button.accessKey = '`';
2971 localStorage.setItem("comments-list-mode", event.target.className);
2972 event.target.addClass("selected");
2973 event.target.disabled = true;
2974 event.target.removeAttribute("accesskey");
2976 if (event.target.hasClass("expanded")) {
2977 query("#content").removeClass("compact");
2979 query("#content").addClass("compact");
2984 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2985 if (savedMode == "compact")
2986 query("#content").addClass("compact");
2987 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2988 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2989 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2992 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2993 commentParentLink.addActivateEvent(function (event) {
2994 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2995 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
3001 /**********************/
3002 /* SITE NAV UI TOGGLE */
3003 /**********************/
3005 function injectSiteNavUIToggle() {
3006 GWLog("injectSiteNavUIToggle");
3007 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
3008 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
3009 GWLog("GW.siteNavUIToggleButtonClicked");
3011 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
3014 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
3016 function removeSiteNavUIToggle() {
3017 GWLog("removeSiteNavUIToggle");
3018 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3019 element.removeClass("engaged");
3021 removeElement("#site-nav-ui-toggle");
3023 function toggleSiteNavUI() {
3024 GWLog("toggleSiteNavUI");
3025 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3026 element.toggleClass("engaged");
3027 element.removeClass("translucent-on-scroll");
3031 /**********************/
3032 /* POST NAV UI TOGGLE */
3033 /**********************/
3035 function injectPostNavUIToggle() {
3036 GWLog("injectPostNavUIToggle");
3037 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
3038 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
3039 GWLog("GW.postNavUIToggleButtonClicked");
3041 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
3044 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
3046 function removePostNavUIToggle() {
3047 GWLog("removePostNavUIToggle");
3048 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3049 element.removeClass("engaged");
3051 removeElement("#post-nav-ui-toggle");
3053 function togglePostNavUI() {
3054 GWLog("togglePostNavUI");
3055 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3056 element.toggleClass("engaged");
3060 /**************************/
3061 /* WORD COUNT & READ TIME */
3062 /**************************/
3064 function toggleReadTimeOrWordCount(addWordCountClass) {
3065 GWLog("toggleReadTimeOrWordCount");
3066 queryAll(".post-meta .read-time").forEach(element => {
3067 if (addWordCountClass) element.addClass("word-count");
3068 else element.removeClass("word-count");
3070 let titleParts = /(\S+)(.+)$/.exec(element.title);
3071 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
3075 /**************************/
3076 /* PROMPT TO SAVE CHANGES */
3077 /**************************/
3079 function enableBeforeUnload() {
3080 window.onbeforeunload = function () { return true; };
3082 function disableBeforeUnload() {
3083 window.onbeforeunload = null;
3086 /***************************/
3087 /* ORIGINAL POSTER BADGING */
3088 /***************************/
3090 function markOriginalPosterComments() {
3091 GWLog("markOriginalPosterComments");
3092 let postAuthor = query(".post .author");
3093 if (postAuthor == null) return;
3095 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
3096 if (author.dataset.userid == postAuthor.dataset.userid ||
3097 (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
3098 author.addClass("original-poster");
3099 author.title += "Original poster";
3104 /********************************/
3105 /* EDIT POST PAGE SUBMIT BUTTON */
3106 /********************************/
3108 function setEditPostPageSubmitButtonText() {
3109 GWLog("setEditPostPageSubmitButtonText");
3110 if (!query("#content").hasClass("edit-post-page")) return;
3112 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
3113 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
3114 GWLog("GW.postSectionSelectorValueChanged");
3115 updateEditPostPageSubmitButtonText();
3119 updateEditPostPageSubmitButtonText();
3121 function updateEditPostPageSubmitButtonText() {
3122 GWLog("updateEditPostPageSubmitButtonText");
3123 let submitButton = query("input[type='submit']");
3124 if (query("input#drafts").checked == true)
3125 submitButton.value = "Save Draft";
3126 else if (query(".posting-controls").hasClass("edit-existing-post"))
3127 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
3129 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
3136 function numToAlpha(n) {
3139 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
3140 n = Math.floor((n / 26) - 1);
3145 function activateAntiKibitzer() {
3146 GWLog("activateAntiKibitzer");
3148 // Activate anti-kibitzer mode (if needed).
3149 if (localStorage.getItem("antikibitzer") == "true")
3150 toggleAntiKibitzerMode();
3152 // Remove temporary CSS that hides the authors and karma values.
3153 removeElement("#antikibitzer-temp");
3155 // Inject controls (if desktop).
3156 if (GW.isMobile == false)
3157 injectAntiKibitzerToggle();
3160 function injectAntiKibitzerToggle() {
3161 GWLog("injectAntiKibitzerToggle");
3163 let antiKibitzerHTML = `<div id="anti-kibitzer-toggle">
3164 <button type="button" tabindex="-1" accesskey="g" title="Toggle anti-kibitzer (show/hide authors & karma values) [g]"></button>
3168 if (Appearance.themeSelector == null)
3171 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", antiKibitzerHTML);
3173 addUIElement(antiKibitzerHTML);
3176 // Activate anti-kibitzer toggle button.
3177 query("#anti-kibitzer-toggle button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
3178 GWLog("GW.antiKibitzerToggleButtonClicked");
3179 if ( query("#anti-kibitzer-toggle").hasClass("engaged")
3181 && !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!)")) {
3182 event.target.blur();
3186 toggleAntiKibitzerMode();
3187 event.target.blur();
3191 function toggleAntiKibitzerMode() {
3192 GWLog("toggleAntiKibitzerMode");
3193 // This will be the URL of the user's own page, if logged in, or the URL of
3194 // the login page otherwise.
3195 let userTabTarget = query("#nav-item-login .nav-inner").href;
3196 let pageHeadingElement = query("h1.page-main-heading");
3199 let userFakeName = { };
3201 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
3203 let postAuthor = query(".post-page .post-meta .author");
3204 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
3206 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
3207 if (antiKibitzerToggle.hasClass("engaged")) {
3208 localStorage.setItem("antikibitzer", "false");
3210 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
3211 if (redirectTarget) {
3212 window.location = redirectTarget;
3216 // Individual comment page title and header
3217 if (query(".individual-thread-page")) {
3218 let replacer = (node) => {
3220 node.firstChild.replaceWith(node.dataset["trueContent"]);
3222 replacer(query("title:not(.fake-title)"));
3223 replacer(query("#content > h1"));
3226 // Author names/links.
3227 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
3228 author.textContent = author.dataset["trueName"];
3229 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
3231 author.removeClass("redacted");
3233 // Post/comment karma values.
3234 queryAll(".karma-value.redacted").forEach(karmaValue => {
3235 karmaValue.innerHTML = karmaValue.dataset["trueValue"];
3237 karmaValue.removeClass("redacted");
3239 // Link post domains.
3240 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
3241 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
3243 linkPostDomain.removeClass("redacted");
3246 antiKibitzerToggle.removeClass("engaged");
3248 localStorage.setItem("antikibitzer", "true");
3250 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
3251 if (redirectTarget) {
3252 window.location = redirectTarget;
3256 // Individual comment page title and header
3257 if (query(".individual-thread-page")) {
3258 let replacer = (node) => {
3260 node.dataset["trueContent"] = node.firstChild.wholeText;
3261 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
3262 node.firstChild.replaceWith(newText);
3264 replacer(query("title:not(.fake-title)"));
3265 replacer(query("#content > h1"));
3268 removeElement("title.fake-title");
3270 // Author names/links.
3271 queryAll(".author, .inline-author").forEach(author => {
3272 // Skip own posts/comments.
3273 if (author.hasClass("own-user-author"))
3276 let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
3280 author.dataset["trueName"] = author.textContent;
3281 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
3283 if (/\/user/.test(author.href)) {
3284 author.dataset["trueLink"] = author.pathname;
3285 author.href = "/user?id=" + author.dataset["userid"];
3288 author.addClass("redacted");
3290 // Post/comment karma values.
3291 queryAll(".karma-value").forEach(karmaValue => {
3292 // Skip own posts/comments.
3293 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
3296 karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
3297 karmaValue.innerHTML = "##<span> points</span>";
3299 karmaValue.addClass("redacted");
3301 // Link post domains.
3302 queryAll(".link-post-domain").forEach(linkPostDomain => {
3303 // Skip own posts/comments.
3304 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
3307 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
3308 linkPostDomain.textContent = "redacted.domain.tld";
3310 linkPostDomain.addClass("redacted");
3313 antiKibitzerToggle.addClass("engaged");
3317 /*******************************/
3318 /* COMMENT SORT MODE SELECTION */
3319 /*******************************/
3321 var CommentSortMode = Object.freeze({
3327 function sortComments(mode) {
3328 GWLog("sortComments");
3329 let commentsContainer = query("#comments");
3331 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
3332 commentsContainer.addClass("sorting");
3334 GW.commentValues = { };
3335 let clonedCommentsContainer = commentsContainer.cloneNode(true);
3336 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
3339 case CommentSortMode.NEW:
3340 comparator = (a,b) => commentDate(b) - commentDate(a);
3342 case CommentSortMode.OLD:
3343 comparator = (a,b) => commentDate(a) - commentDate(b);
3345 case CommentSortMode.HOT:
3346 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
3348 case CommentSortMode.TOP:
3350 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
3353 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
3355 removeElement(commentsContainer.lastChild);
3356 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
3357 GW.commentValues = { };
3359 if (loggedInUserId) {
3360 // Re-activate vote buttons.
3361 commentsContainer.queryAll("button.vote").forEach(voteButton => {
3362 voteButton.addActivateEvent(voteButtonClicked);
3365 // Re-activate comment action buttons.
3366 commentsContainer.queryAll(".action-button").forEach(button => {
3367 button.addActivateEvent(GW.commentActionButtonClicked);
3371 // Re-activate comment-minimize buttons.
3372 queryAll(".comment-minimize-button").forEach(button => {
3373 button.addActivateEvent(GW.commentMinimizeButtonClicked);
3376 // Re-add comment parent popups.
3377 addCommentParentPopups();
3379 // Redo new-comments highlighting.
3380 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
3382 requestAnimationFrame(() => {
3383 commentsContainer.removeClass("sorting");
3384 commentsContainer.addClass("sorted-" + mode);
3387 function commentKarmaValue(commentOrSelector) {
3388 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3390 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
3391 } catch(e) {return null};
3393 function commentDate(commentOrSelector) {
3394 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3396 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
3397 } catch(e) {return null};
3399 function commentVoteCount(commentOrSelector) {
3400 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3402 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
3403 } catch(e) {return null};
3406 function injectCommentsSortModeSelector() {
3407 GWLog("injectCommentsSortModeSelector");
3408 let topCommentThread = query("#comments > .comment-thread");
3409 if (topCommentThread == null) return;
3411 // Do not show sort mode selector if there is no branching in comment tree.
3412 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
3414 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
3415 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
3417 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
3418 let commentsSortModeSelector = query("#comments-sort-mode-selector");
3420 commentsSortModeSelector.queryAll("button").forEach(button => {
3421 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
3422 GWLog("GW.commentsSortModeSelectButtonClicked");
3423 event.target.parentElement.queryAll("button").forEach(button => {
3424 button.removeClass("selected");
3425 button.disabled = false;
3427 event.target.addClass("selected");
3428 event.target.disabled = true;
3430 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
3431 setCommentsSortModeSelectButtonsAccesskey();
3435 // TODO: Make this actually get the current sort mode (if that's saved).
3436 // TODO: Also change the condition here to properly get chrono/threaded mode,
3437 // when that is properly done with cookies.
3438 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
3439 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
3440 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
3441 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
3442 setCommentsSortModeSelectButtonsAccesskey();
3445 function setCommentsSortModeSelectButtonsAccesskey() {
3446 GWLog("setCommentsSortModeSelectButtonsAccesskey");
3447 queryAll("#comments-sort-mode-selector button").forEach(button => {
3448 button.removeAttribute("accesskey");
3449 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
3451 let selectedButton = query("#comments-sort-mode-selector button.selected");
3452 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
3453 nextButtonInCycle.accessKey = "z";
3454 nextButtonInCycle.title += " [z]";
3457 /*************************/
3458 /* COMMENT PARENT POPUPS */
3459 /*************************/
3461 function previewPopupsEnabled() {
3462 let isDisabled = localStorage.getItem("preview-popups-disabled");
3463 return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
3466 function setPreviewPopupsEnabled(state) {
3467 localStorage.setItem("preview-popups-disabled", !state);
3468 updatePreviewPopupToggle();
3471 function updatePreviewPopupToggle() {
3472 let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
3473 query("#preview-popup-toggle").setAttribute("style", style);
3476 function injectPreviewPopupToggle() {
3477 GWLog("injectPreviewPopupToggle");
3479 let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
3480 // This is required because Chrome can't use filters on an externally used SVG element.
3481 fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
3482 updatePreviewPopupToggle();
3483 toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
3486 var currentPreviewPopup = { };
3488 function removePreviewPopup(previewPopup) {
3489 if(previewPopup.element)
3490 removeElement(previewPopup.element);
3492 if(previewPopup.timeout)
3493 clearTimeout(previewPopup.timeout);
3495 if(currentPreviewPopup.pointerListener)
3496 window.removeEventListener("pointermove", previewPopup.pointerListener);
3498 if(currentPreviewPopup.mouseoutListener)
3499 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3501 if(currentPreviewPopup.scrollListener)
3502 window.removeEventListener("scroll", previewPopup.scrollListener);
3504 currentPreviewPopup = { };
3507 document.addEventListener("visibilitychange", () => {
3508 if(document.visibilityState != "visible") {
3509 removePreviewPopup(currentPreviewPopup);
3513 function addCommentParentPopups() {
3514 GWLog("addCommentParentPopups");
3515 //if (!query("#content").hasClass("comment-thread-page")) return;
3517 queryAll("a[href]").forEach(linkTag => {
3518 let linkHref = linkTag.getAttribute("href");
3521 try { url = new URL(linkHref, window.location.href); }
3525 if(GW.sites[url.host]) {
3526 let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
3528 if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
3529 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
3530 if(event.pointerType == "touch") return;
3531 GWLog("GW.commentParentLinkMouseOver");
3532 removePreviewPopup(currentPreviewPopup);
3533 let parentID = linkHref;
3535 if (!(parent = (query(parentID)||{}).firstChild)) return;
3536 var highlightClassName;
3537 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
3538 parentHighlightClassName = "comment-item-highlight-faint";
3539 popup = parent.cloneNode(true);
3540 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
3541 linkTag.addEventListener("mouseout", (event) => {
3542 removeElement(popup);
3544 linkTag.closest(".comments > .comment-thread").appendChild(popup);
3546 parentHighlightClassName = "comment-item-highlight";
3548 parent.parentNode.addClass(parentHighlightClassName);
3549 linkTag.addEventListener("mouseout", (event) => {
3550 parent.parentNode.removeClass(parentHighlightClassName);
3554 else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
3555 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
3556 && !(url.searchParams.get('format'))
3557 && !linkTag.closest("nav:not(.post-nav-links)")
3558 && (!url.hash || linkCommentId)
3559 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
3560 linkTag.addEventListener("pointerover", event => {
3561 if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
3562 if(currentPreviewPopup.linkTag) return;
3563 linkTag.createPreviewPopup();
3565 linkTag.createPreviewPopup = function() {
3566 removePreviewPopup(currentPreviewPopup);
3568 currentPreviewPopup = {linkTag: linkTag};
3570 let popup = newElement("IFRAME");
3571 currentPreviewPopup.element = popup;
3573 let popupTarget = linkHref;
3574 if(popupTarget.match(/#comment-/)) {
3575 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
3577 // 'theme' attribute is required for proper caching
3578 popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview");
3579 popup.addClass("preview-popup");
3581 let linkRect = linkTag.getBoundingClientRect();
3583 if(linkRect.right + 710 < window.innerWidth)
3584 popup.style.left = linkRect.right + 10 + "px";
3586 popup.style.right = "10px";
3588 popup.style.width = "700px";
3589 popup.style.height = "500px";
3590 popup.style.visibility = "hidden";
3591 popup.style.transition = "none";
3593 let recenter = function() {
3594 let popupHeight = 500;
3595 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
3596 let popupContent = popup.contentDocument.querySelector("#content");
3598 popupHeight = popupContent.clientHeight + 2;
3599 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
3600 popup.style.height = popupHeight + "px";
3603 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
3608 query('#content').insertAdjacentElement("beforeend", popup);
3610 let clickListener = event => {
3611 if(!event.target.closest("a, input, label")
3612 && !event.target.closest("popup-hide-button")) {
3613 window.location = linkHref;
3617 popup.addEventListener("load", () => {
3618 let hideButton = newElement("DIV", {
3619 "class": "popup-hide-button"
3621 "innerHTML": ""
3623 hideButton.onclick = (event) => {
3624 removePreviewPopup(currentPreviewPopup);
3625 setPreviewPopupsEnabled(false);
3626 event.stopPropagation();
3628 popup.contentDocument.body.appendChild(hideButton);
3630 let popupBody = popup.contentDocument.body;
3631 popupBody.addEventListener("click", clickListener);
3632 popupBody.style.cursor = "pointer";
3637 popup.contentDocument.body.addEventListener("click", clickListener);
3639 currentPreviewPopup.timeout = setTimeout(() => {
3642 requestIdleCallback(() => {
3643 if(currentPreviewPopup.element === popup) {
3644 popup.scrolling = "";
3645 popup.style.visibility = "unset";
3646 popup.style.transition = null;
3649 { opacity: 0, transform: "translateY(10%)" },
3650 { opacity: 1, transform: "none" }
3651 ], { duration: 150, easing: "ease-out" });
3656 let pointerX, pointerY, mousePauseTimeout = null;
3658 currentPreviewPopup.pointerListener = (event) => {
3659 pointerX = event.clientX;
3660 pointerY = event.clientY;
3662 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3663 mousePauseTimeout = null;
3665 let overElement = document.elementFromPoint(pointerX, pointerY);
3666 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3668 if(mouseIsOverLink || overElement === popup
3669 || (pointerX < popup.getBoundingClientRect().left
3670 && event.movementX >= 0)) {
3671 if(!mouseIsOverLink && overElement !== popup) {
3672 if(overElement['createPreviewPopup']) {
3673 mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3675 mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3679 removePreviewPopup(currentPreviewPopup);
3680 if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3683 window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3685 currentPreviewPopup.mouseoutListener = (event) => {
3686 clearTimeout(mousePauseTimeout);
3687 mousePauseTimeout = null;
3689 document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3691 currentPreviewPopup.scrollListener = (event) => {
3692 let overElement = document.elementFromPoint(pointerX, pointerY);
3693 linkRect = linkTag.getBoundingClientRect();
3694 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3695 removePreviewPopup(currentPreviewPopup);
3697 window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3702 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3706 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3707 Appearance.filtersExclusionPaths.commentParentPopups = [
3708 "#content .comments .comment-thread"
3710 Appearance.applyFilters();
3717 function imageFocusSetup(imagesOverlayOnly = false) {
3718 if (typeof GW.imageFocus == "undefined")
3720 contentImagesSelector: "#content img",
3721 overlayImagesSelector: "#images-overlay img",
3722 focusedImageSelector: "#content img.focused, #images-overlay img.focused",
3723 pageContentSelector: "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3725 hideUITimerDuration: 1500,
3726 hideUITimerExpired: () => {
3727 GWLog("GW.imageFocus.hideUITimerExpired");
3728 let currentTime = new Date();
3729 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3730 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3731 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3734 cancelImageFocusHideUITimer();
3739 GWLog("imageFocusSetup");
3740 // Create event listener for clicking on images to focus them.
3741 GW.imageClickedToFocus = (event) => {
3742 GWLog("GW.imageClickedToFocus");
3743 focusImage(event.target);
3746 // Set timer to hide the image focus UI.
3747 unhideImageFocusUI();
3748 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3751 // Add the listener to each image in the overlay (i.e., those in the post).
3752 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3753 image.addActivateEvent(GW.imageClickedToFocus);
3755 // Accesskey-L starts the slideshow.
3756 (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3757 // Count how many images there are in the post, and set the "… of X" label to that.
3758 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3759 if (imagesOverlayOnly) return;
3760 // Add the listener to all other content images (including those in comments).
3761 queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3762 image.addActivateEvent(GW.imageClickedToFocus);
3765 // Create the image focus overlay.
3766 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
3767 `<div class='help-overlay'>
3768 <p><strong>Arrow keys:</strong> Next/previous image</p>
3769 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3770 <p><strong>Space bar:</strong> Reset image size & position</p>
3771 <p><strong>Scroll</strong> to zoom in/out</p>
3772 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3774 <div class='image-number'></div>
3775 <div class='slideshow-buttons'>
3776 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
3777 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
3779 <div class='caption'></div>` +
3781 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3783 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3784 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3785 GWLog("GW.imageFocus.slideshowButtonClicked");
3786 focusNextImage(event.target.hasClass("next"));
3787 event.target.blur();
3791 // On orientation change, reset the size & position.
3792 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3793 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3796 // UI starts out hidden.
3800 function focusImage(imageToFocus) {
3801 GWLog("focusImage");
3802 // Clear 'last-focused' class of last focused image.
3803 let lastFocusedImage = query("img.last-focused");
3804 if (lastFocusedImage) {
3805 lastFocusedImage.removeClass("last-focused");
3806 lastFocusedImage.removeAttribute("accesskey");
3809 // Create the focused version of the image.
3810 imageToFocus.addClass("focused");
3811 let imageFocusOverlay = query("#image-focus-overlay");
3812 let clonedImage = imageToFocus.cloneNode(true);
3813 clonedImage.style = "";
3814 clonedImage.removeAttribute("width");
3815 clonedImage.removeAttribute("height");
3816 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3817 imageFocusOverlay.appendChild(clonedImage);
3818 imageFocusOverlay.addClass("engaged");
3820 // Set image to default size and position.
3821 resetFocusedImagePosition();
3823 // Blur everything else.
3824 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3825 element.addClass("blurred");
3828 // Add listener to zoom image with scroll wheel.
3829 window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3830 GWLog("GW.imageFocus.scrollEvent");
3831 event.preventDefault();
3833 let image = query("#image-focus-overlay img");
3835 // Remove the filter.
3836 image.savedFilter = image.style.filter;
3837 image.style.filter = 'none';
3839 // Locate point under cursor.
3840 let imageBoundingBox = image.getBoundingClientRect();
3842 // Calculate resize factor.
3843 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3844 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3848 image.style.width = (event.deltaY < 0 ?
3849 (image.clientWidth * factor) :
3850 (image.clientWidth / factor))
3852 image.style.height = "";
3854 // Designate zoom origin.
3856 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3857 // the cursor is over the image.
3858 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3859 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3860 (imageBoundingBox.left <= event.clientX &&
3861 event.clientX <= imageBoundingBox.right &&
3862 imageBoundingBox.top <= event.clientY &&
3863 event.clientY <= imageBoundingBox.bottom);
3864 // Otherwise, if we're zooming OUT, zoom from window center; if we're
3865 // zooming IN, zoom from image center.
3866 let zoomingFromWindowCenter = event.deltaY > 0;
3867 if (zoomingFromCursor)
3868 zoomOrigin = { x: event.clientX,
3870 else if (zoomingFromWindowCenter)
3871 zoomOrigin = { x: window.innerWidth / 2,
3872 y: window.innerHeight / 2 };
3874 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
3875 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3877 // Calculate offset from zoom origin.
3878 let offsetOfImageFromZoomOrigin = {
3879 x: imageBoundingBox.x - zoomOrigin.x,
3880 y: imageBoundingBox.y - zoomOrigin.y
3882 // Calculate delta from centered zoom.
3883 let deltaFromCenteredZoom = {
3884 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3885 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3887 // Adjust image position appropriately.
3888 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3889 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3890 // Gradually re-center image, if it's smaller than the window.
3891 if (!imageSizeExceedsWindowBounds) {
3892 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
3893 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3894 let windowCenter = { x: window.innerWidth / 2,
3895 y: window.innerHeight / 2 }
3896 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3897 y: windowCenter.y - imageCenter.y }
3898 // Divide the offset by 10 because we're nudging the image toward center,
3899 // not jumping it there.
3900 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3901 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3904 // Put the filter back.
3905 image.style.filter = image.savedFilter;
3907 // Set the cursor appropriately.
3908 setFocusedImageCursor();
3910 window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3911 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3912 event.preventDefault();
3915 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3916 window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3917 GWLog("GW.imageFocus.mouseUp");
3918 window.onmousemove = '';
3920 // We only want to do anything on left-clicks.
3921 if (event.button != 0) return;
3923 // Don't unfocus if click was on a slideshow next/prev button!
3924 if (event.target.hasClass("slideshow-button")) return;
3926 // We also don't want to do anything if clicked on the help overlay.
3927 if (event.target.classList.contains("help-overlay") ||
3928 event.target.closest(".help-overlay"))
3931 let focusedImage = query("#image-focus-overlay img");
3932 if (event.target == focusedImage &&
3933 (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3934 // If the mouseup event was the end of a pan of an overside image,
3935 // put the filter back; do not unfocus.
3936 focusedImage.style.filter = focusedImage.savedFilter;
3938 unfocusImageOverlay();
3942 window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3943 GWLog("GW.imageFocus.mouseDown");
3944 event.preventDefault();
3946 let focusedImage = query("#image-focus-overlay img");
3947 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3948 let mouseCoordX = event.clientX;
3949 let mouseCoordY = event.clientY;
3951 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3952 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3955 focusedImage.savedFilter = focusedImage.style.filter;
3957 window.onmousemove = (event) => {
3958 // Remove the filter.
3959 focusedImage.style.filter = "none";
3960 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3961 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3967 // Double-click on the image unfocuses.
3968 clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3969 GWLog("GW.imageFocus.doubleClick");
3970 if (event.target.hasClass("slideshow-button")) return;
3972 unfocusImageOverlay();
3975 // Escape key unfocuses, spacebar resets.
3976 document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3977 GWLog("GW.imageFocus.keyUp");
3978 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3979 if (!allowedKeys.contains(event.key) ||
3980 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3982 event.preventDefault();
3984 switch (event.key) {
3987 unfocusImageOverlay();
3991 resetFocusedImagePosition();
3997 if (query("#images-overlay img.focused")) focusNextImage(true);
4003 if (query("#images-overlay img.focused")) focusNextImage(false);
4008 // Prevent spacebar or arrow keys from scrolling page when image focused.
4009 togglePageScrolling(false);
4011 // If the image comes from the images overlay, for the main post...
4012 if (imageToFocus.closest("#images-overlay")) {
4013 // Mark the overlay as being in slide show mode (to show buttons/count).
4014 imageFocusOverlay.addClass("slideshow");
4016 // Set state of next/previous buttons.
4017 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4018 var indexOfFocusedImage = getIndexOfFocusedImage();
4019 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4020 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4022 // Set the image number.
4023 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4025 // Replace the hash.
4026 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4028 imageFocusOverlay.removeClass("slideshow");
4032 setImageFocusCaption();
4034 // Moving mouse unhides image focus UI.
4035 window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
4036 GWLog("GW.imageFocus.mouseMoved");
4037 let currentDateTime = new Date();
4038 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
4039 cancelImageFocusHideUITimer();
4041 if (!GW.imageFocus.hideUITimer) {
4042 unhideImageFocusUI();
4043 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4045 GW.imageFocus.mouseLastMovedAt = currentDateTime;
4050 function resetFocusedImagePosition() {
4051 GWLog("resetFocusedImagePosition");
4052 let focusedImage = query("#image-focus-overlay img");
4053 if (!focusedImage) return;
4055 let sourceImage = query(GW.imageFocus.focusedImageSelector);
4057 // Make sure that initially, the image fits into the viewport.
4058 let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
4059 let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
4060 var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
4061 let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
4062 let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
4063 focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
4064 focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
4066 // Remove modifications to position.
4067 focusedImage.style.left = "";
4068 focusedImage.style.top = "";
4070 // Set the cursor appropriately.
4071 setFocusedImageCursor();
4073 function setFocusedImageCursor() {
4074 let focusedImage = query("#image-focus-overlay img");
4075 if (!focusedImage) return;
4076 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
4080 function unfocusImageOverlay() {
4081 GWLog("unfocusImageOverlay");
4083 // Remove event listeners.
4084 window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
4085 window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
4086 // NOTE: The double-click listener does not need to be removed manually,
4087 // because the focused (cloned) image will be removed anyway.
4088 document.removeEventListener("keyup", GW.imageFocus.keyUp);
4089 document.removeEventListener("keydown", GW.imageFocus.keyDown);
4090 window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
4091 window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
4092 window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
4094 // Set accesskey of currently focused image (if it's in the images overlay).
4095 let currentlyFocusedImage = query("#images-overlay img.focused");
4096 if (currentlyFocusedImage) {
4097 currentlyFocusedImage.addClass("last-focused");
4098 currentlyFocusedImage.accessKey = 'l';
4101 // Remove focused image and hide overlay.
4102 let imageFocusOverlay = query("#image-focus-overlay");
4103 imageFocusOverlay.removeClass("engaged");
4104 removeElement(imageFocusOverlay.query("img"));
4106 // Un-blur content/etc.
4107 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
4108 element.removeClass("blurred");
4111 // Unset "focused" class of focused image.
4112 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4114 // Re-enable page scrolling.
4115 togglePageScrolling(true);
4117 // Reset the hash, if needed.
4118 if (location.hash.hasPrefix("#if_slide_"))
4119 history.replaceState(window.history.state, null, "#");
4122 function getIndexOfFocusedImage() {
4123 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4124 var indexOfFocusedImage = -1;
4125 for (i = 0; i < images.length; i++) {
4126 if (images[i].hasClass("focused")) {
4127 indexOfFocusedImage = i;
4131 return indexOfFocusedImage;
4134 function focusNextImage(next = true) {
4135 GWLog("focusNextImage");
4136 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4137 var indexOfFocusedImage = getIndexOfFocusedImage();
4139 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
4141 // Remove existing image.
4142 removeElement("#image-focus-overlay img");
4143 // Unset "focused" class of just-removed image.
4144 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4146 // Create the focused version of the image.
4147 images[indexOfFocusedImage].addClass("focused");
4148 let imageFocusOverlay = query("#image-focus-overlay");
4149 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
4150 clonedImage.style = "";
4151 clonedImage.removeAttribute("width");
4152 clonedImage.removeAttribute("height");
4153 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
4154 imageFocusOverlay.appendChild(clonedImage);
4155 imageFocusOverlay.addClass("engaged");
4156 // Set image to default size and position.
4157 resetFocusedImagePosition();
4158 // Set state of next/previous buttons.
4159 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4160 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4161 // Set the image number display.
4162 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4164 setImageFocusCaption();
4165 // Replace the hash.
4166 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4169 function setImageFocusCaption() {
4170 GWLog("setImageFocusCaption");
4171 var T = { }; // Temporary storage.
4173 // Clear existing caption, if any.
4174 let captionContainer = query("#image-focus-overlay .caption");
4175 Array.from(captionContainer.children).forEach(child => { child.remove(); });
4177 // Determine caption.
4178 let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
4180 if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
4181 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
4182 captionHTML = (T.figcaption.query("p")) ?
4183 T.figcaption.innerHTML :
4184 "<p>" + T.figcaption.innerHTML + "</p>";
4185 } else if (currentlyFocusedImage.title != "") {
4186 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
4188 // Insert the caption, if any.
4189 if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
4192 function hideImageFocusUI() {
4193 GWLog("hideImageFocusUI");
4194 let imageFocusOverlay = query("#image-focus-overlay");
4195 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4196 element.addClass("hidden");
4200 function unhideImageFocusUI() {
4201 GWLog("unhideImageFocusUI");
4202 let imageFocusOverlay = query("#image-focus-overlay");
4203 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4204 element.removeClass("hidden");
4208 function cancelImageFocusHideUITimer() {
4209 clearTimeout(GW.imageFocus.hideUITimer);
4210 GW.imageFocus.hideUITimer = null;
4217 function keyboardHelpSetup() {
4218 let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
4219 <div class='keyboard-help-container'>
4220 <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'></button>
4221 <h1>Keyboard shortcuts</h1>
4222 <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>
4223 <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
4224 <div class='keyboard-shortcuts-lists'>` + [ [
4226 [ [ '?' ], "Show keyboard shortcuts" ],
4227 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
4230 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
4231 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
4232 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
4233 [ [ 'ak-m' ], "Go to Meta view" ],
4234 [ [ 'ak-v' ], "Go to Tags view"],
4235 [ [ 'ak-c' ], "Go to Recent Comments view" ],
4236 [ [ 'ak-r' ], "Go to Archive view" ],
4237 [ [ 'ak-q' ], "Go to Sequences view" ],
4238 [ [ 'ak-t' ], "Go to About page" ],
4239 [ [ 'ak-u' ], "Go to User or Login page" ],
4240 [ [ 'ak-o' ], "Go to Inbox page" ]
4243 [ [ 'ak-,' ], "Jump up to top of page" ],
4244 [ [ 'ak-.' ], "Jump down to bottom of page" ],
4245 [ [ 'ak-/' ], "Jump to top of comments section" ],
4246 [ [ 'ak-s' ], "Search" ],
4249 [ [ 'ak-n' ], "New post or comment" ],
4250 [ [ 'ak-e' ], "Edit current post" ]
4252 "Post/comment list views",
4253 [ [ '.' ], "Focus next entry in list" ],
4254 [ [ ',' ], "Focus previous entry in list" ],
4255 [ [ ';' ], "Cycle between links in focused entry" ],
4256 [ [ 'Enter' ], "Go to currently focused entry" ],
4257 [ [ 'Esc' ], "Unfocus currently focused entry" ],
4258 [ [ 'ak-]' ], "Go to next page" ],
4259 [ [ 'ak-[' ], "Go to previous page" ],
4260 [ [ 'ak-\\' ], "Go to first page" ],
4261 [ [ 'ak-e' ], "Edit currently focused post" ]
4264 [ [ 'ak-k' ], "Bold text" ],
4265 [ [ 'ak-i' ], "Italic text" ],
4266 [ [ 'ak-l' ], "Insert hyperlink" ],
4267 [ [ 'ak-q' ], "Blockquote text" ]
4270 [ [ 'ak-=' ], "Increase text size" ],
4271 [ [ 'ak--' ], "Decrease text size" ],
4272 [ [ 'ak-0' ], "Reset to default text size" ],
4273 [ [ 'ak-′' ], "Cycle through content width settings" ],
4274 [ [ 'ak-1' ], "Switch to default theme [A]" ],
4275 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
4276 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
4277 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
4278 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
4279 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
4280 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
4281 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
4282 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
4283 [ [ 'ak-;' ], "Open theme tweaker" ],
4284 [ [ 'Enter' ], "Save changes and close theme tweaker "],
4285 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
4288 [ [ 'ak-l' ], "Start/resume slideshow" ],
4289 [ [ 'Esc' ], "Exit slideshow" ],
4290 [ [ '→', '↓' ], "Next slide" ],
4291 [ [ '←', '↑' ], "Previous slide" ],
4292 [ [ 'Space' ], "Reset slide zoom" ]
4295 [ [ 'ak-x' ], "Switch to next view on user page" ],
4296 [ [ 'ak-z' ], "Switch to previous view on user page" ],
4297 [ [ 'ak-` ' ], "Toggle compact comment list view" ],
4298 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
4300 `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
4302 <span class='keys'>` +
4304 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
4307 <span class='action'>${entry[1]}</span>
4309 ).join("\n") + `</ul>`).join("\n") + `
4314 // Add listener to show the keyboard help overlay.
4315 document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
4316 GWLog("GW.keyboardHelpShowKeyPressed");
4317 if (event.key == '?')
4318 toggleKeyboardHelpOverlay(true);
4321 // Clicking the background overlay closes the keyboard help overlay.
4322 keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
4323 GWLog("GW.keyboardHelpOverlayClicked");
4324 if (event.type == "mousedown") {
4325 keyboardHelpOverlay.style.opacity = "0.01";
4327 toggleKeyboardHelpOverlay(false);
4328 keyboardHelpOverlay.style.opacity = "1.0";
4332 // Intercept clicks, so they don't "fall through" the background overlay.
4333 (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
4335 // Clicking the close button closes the keyboard help overlay.
4336 keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
4337 toggleKeyboardHelpOverlay(false);
4340 // Add button to open keyboard help.
4341 query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'></button>");
4342 query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
4343 GWLog("GW.openKeyboardHelpButtonClicked");
4344 toggleKeyboardHelpOverlay(true);
4345 event.target.blur();
4349 function toggleKeyboardHelpOverlay(show) {
4350 console.log("toggleKeyboardHelpOverlay");
4352 let keyboardHelpOverlay = query("#keyboard-help-overlay");
4353 show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
4354 keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
4356 // Prevent scrolling the document when the overlay is visible.
4357 togglePageScrolling(!show);
4359 // Focus the close button as soon as we open.
4360 keyboardHelpOverlay.query("button.close-keyboard-help").focus();
4363 // Add listener to show the keyboard help overlay.
4364 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
4365 GWLog("GW.keyboardHelpHideKeyPressed");
4366 if (event.key == 'Escape')
4367 toggleKeyboardHelpOverlay(false);
4370 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
4373 // Disable / enable tab-selection of the search box.
4374 setSearchBoxTabSelectable(!show);
4377 /**********************/
4378 /* PUSH NOTIFICATIONS */
4379 /**********************/
4381 function pushNotificationsSetup() {
4382 let pushNotificationsButton = query("#enable-push-notifications");
4383 if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
4384 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
4385 pushNotificationsButton.style.display = 'unset';
4389 function urlBase64ToUint8Array(base64String) {
4390 const padding = '='.repeat((4 - base64String.length % 4) % 4);
4391 const base64 = (base64String + padding)
4393 .replace(/_/g, '/');
4395 const rawData = window.atob(base64);
4396 const outputArray = new Uint8Array(rawData.length);
4398 for (let i = 0; i < rawData.length; ++i) {
4399 outputArray[i] = rawData.charCodeAt(i);
4404 function pushNotificationsButtonClicked(event) {
4405 event.target.style.opacity = 0.33;
4406 event.target.style.pointerEvents = "none";
4408 let reEnable = (message) => {
4409 if(message) alert(message);
4410 event.target.style.opacity = 1;
4411 event.target.style.pointerEvents = "unset";
4414 if(event.target.dataset.enabled) {
4415 fetch('/push/register', {
4417 headers: { 'Content-type': 'application/json' },
4418 body: JSON.stringify({
4422 event.target.innerHTML = "Enable push notifications";
4423 event.target.dataset.enabled = "";
4425 }).catch((err) => reEnable(err.message));
4427 Notification.requestPermission().then((permission) => {
4428 navigator.serviceWorker.ready
4429 .then((registration) => {
4430 return registration.pushManager.getSubscription()
4431 .then(async function(subscription) {
4433 return subscription;
4435 return registration.pushManager.subscribe({
4436 userVisibleOnly: true,
4437 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
4440 .catch((err) => reEnable(err.message));
4442 .then((subscription) => {
4443 fetch('/push/register', {
4446 'Content-type': 'application/json'
4448 body: JSON.stringify({
4449 subscription: subscription
4454 event.target.innerHTML = "Disable push notifications";
4455 event.target.dataset.enabled = "true";
4458 .catch(function(err){ reEnable(err.message) });
4464 /*******************************/
4465 /* HTML TO MARKDOWN CONVERSION */
4466 /*******************************/
4468 function MarkdownFromHTML(text, linePrefix) {
4469 GWLog("MarkdownFromHTML");
4471 let docFrag = document.createRange().createContextualFragment(text);
4474 let atLineBeginning = true;
4475 linePrefix = linePrefix || "";
4479 output += ("\n" + linePrefix).repeat(owedLines);
4483 atLineBeginning = false;
4485 let outText = text => {
4486 if(atLineBeginning) text = text.trimStart();
4487 text = text.replace(/\s+/gm, " ");
4491 let forceLine = n => {
4493 out(("\n" + linePrefix).repeat(n));
4494 atLineBeginning = true;
4496 let newLine = (n) => {
4498 if(owedLines >= 0 && owedLines < n) {
4501 atLineBeginning = true;
4503 let newParagraph = () => {
4506 let withPrefix = (prefix, fn) => {
4507 let oldPrefix = linePrefix;
4508 linePrefix += prefix;
4512 linePrefix = oldPrefix;
4515 let doConversion = (node) => {
4516 if(node.nodeType == Node.TEXT_NODE) {
4517 outText(node.nodeValue.replace(/[\][*\\#<>]/g, "\\$&"));
4519 else if(node.nodeType == Node.ELEMENT_NODE) {
4520 switch(node.tagName) {
4526 node.childNodes.forEach(doConversion);
4540 node.childNodes.forEach(doConversion);
4546 node.childNodes.forEach(doConversion);
4552 if(node.parentElement.tagName == "OL") {
4554 for(let e = node; e = e.previousElementSibling;) { i++ }
4555 listPrefix = "" + i + ". ";
4561 withPrefix(" ".repeat(listPrefix.length), () => node.childNodes.forEach(doConversion));
4571 out("#".repeat(node.tagName.charAt(1)) + " ");
4572 node.childNodes.forEach(doConversion);
4576 let href = node.getAttribute("href");
4578 node.childNodes.forEach(doConversion);
4582 let src = node.getAttribute("src");
4583 let alt = node.alt || "";
4584 out(`![${alt}](${src})`);
4589 withPrefix("> ", () => node.childNodes.forEach(doConversion));
4596 out(node.innerText);
4603 node.childNodes.forEach(doConversion);
4610 node.childNodes.forEach(doConversion);
4613 node.childNodes.forEach(doConversion);
4616 doConversion(docFrag);
4621 /************************************/
4622 /* ANCHOR LINK SCROLLING WORKAROUND */
4623 /************************************/
4625 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
4626 let hash = location.hash;
4627 if(hash && hash !== "#top" && !document.query(hash)) {
4628 let content = document.query("#content");
4629 content.style.display = "none";
4630 addTriggerListener("DOMReady", {priority: -1, fn: () => {
4631 content.style.visibility = "hidden";
4632 content.style.display = null;
4633 requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
4638 /******************/
4639 /* INITIALIZATION */
4640 /******************/
4642 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
4643 GWLog("INITIALIZER earlyInitialize");
4644 // Check to see whether we're on a mobile device (which we define as a narrow screen)
4645 GW.isMobile = (window.innerWidth <= 1160);
4646 GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
4648 // Backward compatibility
4649 let storedTheme = localStorage.getItem("selected-theme");
4651 Appearance.setTheme(storedTheme);
4652 localStorage.removeItem("selected-theme");
4655 // Animate width & theme adjustments?
4656 Appearance.adjustmentTransitions = false;
4657 // Add the content width selector.
4658 Appearance.injectContentWidthSelector();
4659 // Add the text size adjustment widget.
4660 Appearance.injectTextSizeAdjustmentUI();
4661 // Add the theme selector.
4662 Appearance.injectThemeSelector();
4663 // Add the theme tweaker.
4664 Appearance.injectThemeTweaker();
4666 // Add the dark mode selector (if desktop).
4667 if (GW.isMobile == false)
4668 DarkMode.injectModeSelector();
4670 // Add the quick-nav UI.
4673 // Finish initializing when ready.
4674 addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4677 function mainInitializer() {
4678 GWLog("INITIALIZER initialize");
4680 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4681 // "link without nav bars".
4682 if (getQueryVariable("hide-nav-bars") == "true") {
4683 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
4686 // If the page cannot have comments, remove the accesskey from the #comments
4687 // quick-nav button; and if the page can have comments, but does not, simply
4688 // disable the #comments quick nav button.
4689 let content = query("#content");
4690 if (content.query("#comments") == null) {
4691 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4692 } else if (content.query("#comments .comment-thread") == null) {
4693 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4696 // On edit post pages and conversation pages, add GUIEdit buttons to the
4697 // textarea and expand it.
4698 queryAll(".with-markdown-editor textarea").forEach(textarea => {
4699 textarea.addTextareaFeatures();
4700 expandTextarea(textarea);
4702 // Focus the textarea.
4703 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4705 // If we're on a comment thread page...
4706 if (query(".comments") != null) {
4707 // Add comment-minimize buttons to every comment.
4708 queryAll(".comment-meta").forEach(commentMeta => {
4709 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4710 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
4712 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4713 // Format and activate comment-minimize buttons.
4714 queryAll(".comment-minimize-button").forEach(button => {
4715 button.closest(".comment-item").setCommentThreadMaximized(false);
4716 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4717 event.target.closest(".comment-item").setCommentThreadMaximized(true);
4722 if (getQueryVariable("chrono") == "t") {
4723 insertHeadHTML(`<style> .comment-minimize-button::after { display: none; } </style>`);
4726 // On mobile, replace the labels for the checkboxes on the edit post form
4727 // with icons, to save space.
4728 if (GW.isMobile && query(".edit-post-page")) {
4729 query("label[for='link-post']").innerHTML = "";
4730 query("label[for='question']").innerHTML = "";
4733 // Add error message (as placeholder) if user tries to click Search with
4734 // an empty search field.
4736 let searchForm = query("#nav-item-search form");
4737 if(!searchForm) break searchForm;
4738 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4739 let searchField = event.target.query("input");
4740 if (searchField.value == "") {
4741 event.preventDefault();
4742 event.target.blur();
4743 searchField.placeholder = "Enter a search string!";
4744 searchField.focus();
4747 // Remove the placeholder / error on any input.
4748 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4749 event.target.placeholder = "";
4753 // Prevent conflict between various single-hotkey listeners and text fields
4754 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4755 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4756 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4759 if (content.hasClass("post-page")) {
4760 // Read and update last-visited-date.
4761 let lastVisitedDate = getLastVisitedDate();
4762 setLastVisitedDate(Date.now());
4764 // Save the number of comments this post has when it's visited.
4765 updateSavedCommentCount();
4767 if (content.query(".comments .comment-thread") != null) {
4768 // Add the new comments count & navigator.
4769 injectNewCommentNavUI();
4771 // Get the highlight-new-since date (as specified by URL parameter, if
4772 // present, or otherwise the date of the last visit).
4773 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4775 // Highlight new comments since the specified date.
4776 let newCommentsCount = highlightCommentsSince(hnsDate);
4778 // Update the comment count display.
4779 updateNewCommentNavUI(newCommentsCount, hnsDate);
4782 // On listing pages, make comment counts more informative.
4783 badgePostsWithNewComments();
4786 // Add the comments list mode selector widget (expanded vs. compact).
4787 injectCommentsListModeSelector();
4789 // Add the comments view selector widget (threaded vs. chrono).
4790 // injectCommentsViewModeSelector();
4792 // Add the comments sort mode selector (top, hot, new, old).
4793 if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4795 // Add the toggle for the post nav UI elements on mobile.
4796 if (GW.isMobile) injectPostNavUIToggle();
4798 // Add the toggle for the appearance adjustment UI elements on mobile.
4800 Appearance.injectAppearanceAdjustUIToggle();
4802 // Activate the antikibitzer.
4803 if (GW.useFancyFeatures)
4804 activateAntiKibitzer();
4806 // Add comment parent popups.
4807 injectPreviewPopupToggle();
4808 addCommentParentPopups();
4810 // Mark original poster's comments with a special class.
4811 markOriginalPosterComments();
4813 // On the All view, mark posts with non-positive karma with a special class.
4814 if (query("#content").hasClass("all-index-page")) {
4815 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4816 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4818 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4822 // Set the "submit" button on the edit post page to something more helpful.
4823 setEditPostPageSubmitButtonText();
4825 // Compute the text of the pagination UI tooltip text.
4826 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4827 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4830 // Add event listeners for Escape and Enter, for the theme tweaker.
4831 document.addEventListener("keyup", Appearance.themeTweakerUIKeyPressed);
4833 // Add event listener for . , ; (for navigating listings pages).
4834 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4835 if (!query(".comments") && listings.length > 0) {
4836 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
4837 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4839 if (event.key == "Escape") {
4840 if (document.activeElement.parentElement.hasClass("listing"))
4841 document.activeElement.blur();
4845 if (event.key == ';') {
4846 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4847 let links = document.activeElement.parentElement.queryAll("a");
4848 links[document.activeElement == links[0] ? 1 : 0].focus();
4849 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4850 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4851 links[document.activeElement == links[0] ? 1 : 0].focus();
4852 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4857 var indexOfActiveListing = -1;
4858 for (i = 0; i < listings.length; i++) {
4859 if (document.activeElement.parentElement.hasClass("listing") &&
4860 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4861 indexOfActiveListing = i;
4863 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
4864 listings[i] === document.activeElement.parentElement.query("a.date")) {
4865 indexOfActiveListing = i;
4869 // Remove edit accesskey from currently highlighted post by active user, if applicable.
4870 if (indexOfActiveListing > -1) {
4871 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4873 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4874 if (indexOfNextListing < listings.length) {
4875 listings[indexOfNextListing].focus();
4877 if (listings[indexOfNextListing].closest(".comment-item")) {
4878 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4879 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4882 document.activeElement.blur();
4884 // Add edit accesskey to newly highlighted post by active user, if applicable.
4885 (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4887 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4888 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4889 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4893 // Add event listener for ; (to focus the link on link posts).
4894 if (query("#content").hasClass("post-page") &&
4895 query(".post").hasClass("link-post")) {
4896 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4897 if (event.key == ';') query("a.link-post-link").focus();
4901 // Add accesskeys to user page view selector.
4902 let viewSelector = query("#content.user-page > .sublevel-nav");
4904 let currentView = viewSelector.query("span");
4905 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4906 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4909 // Add accesskey to index page sort selector.
4910 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4912 // Move MathJax style tags to <head>.
4913 var aggregatedStyles = "";
4914 queryAll("#content style").forEach(styleTag => {
4915 aggregatedStyles += styleTag.innerHTML;
4916 removeElement("style", styleTag.parentElement);
4918 if (aggregatedStyles != "") {
4919 insertHeadHTML(`<style id="mathjax-styles"> ${aggregatedStyles} </style>`);
4922 /* Makes double-clicking on a math element select the entire math element.
4923 (This actually makes no difference to the behavior of the copy listener
4924 which copies the entire LaTeX source of the full equation no matter how
4925 much of said equation is selected when the copy command is sent;
4926 however, it ensures that the UI communicates the actual behavior in a
4927 more accurate and understandable way.)
4929 query("#content").querySelectorAll(".mjpage").forEach(mathBlock => {
4930 mathBlock.addEventListener("dblclick", (event) => {
4931 document.getSelection().selectAllChildren(mathBlock.querySelector(".mjx-chtml"));
4933 mathBlock.title = mathBlock.classList.contains("mjpage__block")
4934 ? "Double-click to select equation, then copy, to get LaTeX source"
4935 : "Double-click to select equation; copy to get LaTeX source";
4938 // Add listeners to switch between word count and read time.
4939 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4940 queryAll(".post-meta .read-time").forEach(element => {
4941 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4942 let displayWordCount = localStorage.getItem("display-word-count");
4943 toggleReadTimeOrWordCount(!displayWordCount);
4944 if (displayWordCount) localStorage.removeItem("display-word-count");
4945 else localStorage.setItem("display-word-count", true);
4949 // Set up Image Focus feature.
4952 // Set up keyboard shortcuts guide overlay.
4953 keyboardHelpSetup();
4955 // Show push notifications button if supported
4956 pushNotificationsSetup();
4958 // Show elements now that javascript is ready.
4959 removeElement("#hide-until-init");
4961 activateTrigger("pageLayoutFinished");
4964 /*************************/
4965 /* POST-LOAD ADJUSTMENTS */
4966 /*************************/
4968 window.addEventListener("pageshow", badgePostsWithNewComments);
4970 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4971 GWLog("INITIALIZER pageLayoutFinished");
4973 Appearance.postSetThemeHousekeeping();
4975 focusImageSpecifiedByURL();
4977 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4978 // query("input[type='search']").value = GW.isMobile;
4979 // insertHeadHTML(`<style>
4980 // @media only screen and (hover:none) { #nav-item-search input { background-color: red; }}
4981 // @media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}
4985 function generateImagesOverlay() {
4986 GWLog("generateImagesOverlay");
4987 // Don’t do this on the about page.
4988 if (query(".about-page") != null) return;
4991 // Remove existing, if any.
4992 removeElement("#images-overlay");
4995 document.body.insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4996 let imagesOverlay = query("#images-overlay");
4997 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4998 queryAll(".post-body img").forEach(image => {
4999 let clonedImageContainer = newElement("DIV");
5001 let clonedImage = image.cloneNode(true);
5002 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
5003 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
5004 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
5005 clonedImageContainer.appendChild(clonedImage);
5007 let zoomLevel = Appearance.currentTextZoom;
5009 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
5010 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
5011 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
5012 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
5014 imagesOverlay.appendChild(clonedImageContainer);
5017 // Add the event listeners to focus each image.
5018 imageFocusSetup(true);
5021 function adjustUIForWindowSize() {
5022 GWLog("adjustUIForWindowSize");
5023 var bottomBarOffset;
5025 // Adjust bottom bar state.
5026 let bottomBar = query("#bottom-bar");
5027 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5028 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
5029 bottomBar.removeClass("decorative");
5031 bottomBar.query("#nav-item-top").style.display = "";
5032 } else if (bottomBar) {
5033 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
5034 else bottomBar.addClass("decorative");
5036 bottomBar.query("#nav-item-top").style.display = "none";
5039 // Show quick-nav UI up/down buttons if content is taller than window.
5040 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5041 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
5042 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
5045 // Move anti-kibitzer toggle if content is very short.
5046 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
5048 // Update the visibility of the post nav UI.
5049 updatePostNavUIVisibility();
5052 function recomputeUIElementsContainerHeight(force = false) {
5053 GWLog("recomputeUIElementsContainerHeight");
5055 (force || query("#ui-elements-container").style.height != "")) {
5056 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
5057 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
5058 query("#content").clientHeight + "px" :
5063 function focusImageSpecifiedByURL() {
5064 GWLog("focusImageSpecifiedByURL");
5065 if (location.hash.hasPrefix("#if_slide_")) {
5066 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
5067 let images = queryAll(GW.imageFocus.overlayImagesSelector);
5068 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
5069 if (imageToFocus > 0 && imageToFocus <= images.length) {
5070 focusImage(images[imageToFocus - 1]);
5072 // Set timer to hide the image focus UI.
5073 unhideImageFocusUI();
5074 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
5084 function insertMarkup(event) {
5085 var mopen = '', mclose = '', mtext = '', func = false;
5086 if (typeof arguments[1] == 'function') {
5087 func = arguments[1];
5089 mopen = arguments[1];
5090 mclose = arguments[2];
5091 mtext = arguments[3];
5094 var textarea = event.target.closest("form").query("textarea");
5096 var p0 = textarea.selectionStart;
5097 var p1 = textarea.selectionEnd;
5098 var cur0 = cur1 = p0;
5100 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
5101 str = func ? func(str, p0) : (mopen + str + mclose);
5103 // Determine selection.
5105 cur0 += (p0 == p1) ? mopen.length : str.length;
5106 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
5113 // Update textarea contents.
5114 document.execCommand("insertText", false, str);
5116 // Expand textarea, if needed.
5117 expandTextarea(textarea);
5120 textarea.selectionStart = cur0;
5121 textarea.selectionEnd = cur1;
5126 GW.guiEditButtons = [
5127 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
5128 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
5129 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
5130 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
5131 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
5132 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
5133 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
5134 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
5135 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
5136 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
5137 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
5138 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
5139 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
5140 [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '' ],
5141 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
5144 function blockquote(text, startpos) {
5146 text = "> Quoted text";
5147 return [ text, startpos + 2, startpos + text.length ];
5149 text = "> " + text.split("\n").join("\n> ") + "\n";
5150 return [ text, startpos + text.length, startpos + text.length ];
5154 function hyperlink(text, startpos) {
5155 var url = '', link_text = text, endpos = startpos;
5156 if (text.search(/^https?/) != -1) {
5158 link_text = "link text";
5159 startpos = startpos + 1;
5160 endpos = startpos + link_text.length;
5162 url = prompt("Link address (URL):");
5164 endpos = startpos + text.length;
5165 return [ text, startpos, endpos ];
5167 startpos = startpos + text.length + url.length + 4;
5171 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
5174 /******************/
5175 /* SERVICE WORKER */
5176 /******************/
5178 if(navigator.serviceWorker) {
5179 navigator.serviceWorker.register('/service-worker.js');
5180 setCookie("push", "t");
5183 /*********************/
5184 /* USER AUTOCOMPLETE */
5185 /*********************/
5187 function zLowerUIElements() {
5188 let uiElementsContainer = query("#ui-elements-container");
5189 if (uiElementsContainer)
5190 uiElementsContainer.style.zIndex = "1";
5193 function zRaiseUIElements() {
5194 let uiElementsContainer = query("#ui-elements-container");
5195 if (uiElementsContainer)
5196 uiElementsContainer.style.zIndex = "";
5199 var userAutocomplete = null;
5201 function abbreviatedInterval(date) {
5202 let seconds = Math.floor((new Date() - date) / 1000);
5203 let days = Math.floor(seconds / (60 * 60 * 24));
5204 let years = Math.floor(days / 365);
5213 function beginAutocompletion(control, startIndex, endIndex) {
5214 if(userAutocomplete) abortAutocompletion(userAutocomplete);
5216 let complete = { control: control,
5217 abortController: new AbortController(),
5218 fetchAbortController: new AbortController(),
5219 container: document.createElement("div") };
5221 endIndex = endIndex || control.selectionEnd;
5222 let valueLength = control.value.length;
5224 complete.container.className = "autocomplete-container "
5226 + (window.innerWidth > 1280
5229 control.insertAdjacentElement("afterend", complete.container);
5232 let makeReplacer = (userSlug, displayName) => {
5234 let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
5235 control.value = control.value.substring(0, startIndex - 1) +
5237 control.value.substring(endIndex);
5238 abortAutocompletion(complete);
5239 complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
5240 complete.control.focus();
5244 let switchHighlight = (newHighlight) => {
5248 complete.highlighted.removeClass("highlighted");
5249 newHighlight.addClass("highlighted");
5250 complete.highlighted = newHighlight;
5252 // Scroll newly highlighted item into view, if need be.
5253 if ( complete.highlighted.offsetTop + complete.highlighted.offsetHeight
5254 > complete.container.scrollTop + complete.container.clientHeight) {
5255 complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
5256 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
5257 complete.container.scrollTo(0, complete.highlighted.offsetTop);
5260 let highlightNext = () => {
5261 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
5263 let highlightPrev = () => {
5264 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
5267 let updateCompletions = () => {
5268 let fragment = control.value.substring(startIndex, endIndex);
5270 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
5271 {signal: complete.fetchAbortController.signal})
5272 .then((res) => res.json())
5274 if(res.error) return;
5275 if(res.length == 0) return abortAutocompletion(complete);
5277 complete.container.innerHTML = "";
5278 res.forEach(entry => {
5279 let entryContainer = document.createElement("div");
5280 [ [ entry.displayName, "name" ],
5281 [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
5282 [ (entry.karma || 0) + " karma", "karma" ]
5284 let e = document.createElement("span");
5287 entryContainer.append(e);
5289 entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
5290 complete.container.append(entryContainer);
5292 complete.highlighted = complete.container.children[0];
5293 complete.highlighted.classList.add("highlighted");
5294 complete.container.scrollTo(0, 0);
5299 document.body.addEventListener("click", (event) => {
5300 if (!complete.container.contains(event.target)) {
5301 abortAutocompletion(complete);
5302 event.preventDefault();
5303 event.stopPropagation();
5305 }, {signal: complete.abortController.signal,
5308 control.addEventListener("keydown", (event) => {
5309 switch (event.key) {
5311 abortAutocompletion(complete);
5312 event.preventDefault();
5316 event.preventDefault();
5320 event.preventDefault();
5327 event.preventDefault();
5330 complete.highlighted.onclick();
5331 event.preventDefault();
5334 }, {signal: complete.abortController.signal});
5336 control.addEventListener("selectionchange", (event) => {
5337 if (control.selectionStart < startIndex ||
5338 control.selectionEnd > endIndex) {
5339 abortAutocompletion(complete);
5341 }, {signal: complete.abortController.signal});
5343 control.addEventListener("input", (event) => {
5344 complete.fetchAbortController.abort();
5345 complete.fetchAbortController = new AbortController();
5347 endIndex += control.value.length - valueLength;
5348 valueLength = control.value.length;
5350 if (endIndex < startIndex) {
5351 abortAutocompletion(complete);
5355 updateCompletions();
5356 }, {signal: complete.abortController.signal});
5358 userAutocomplete = complete;
5360 if(startIndex != endIndex) updateCompletions();
5363 function abortAutocompletion(complete) {
5364 complete.fetchAbortController.abort();
5365 complete.abortController.abort();
5366 complete.container.remove();
5367 userAutocomplete = null;