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) {
150 setTimeout(function () {
153 onSuccess: (event) => {
154 let response = JSON.parse(event.target.responseText);
155 window[fname](response);
161 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
162 deferredCalls = null;
164 /* Return the currently selected text, as HTML (rather than unstyled text).
166 function getSelectionHTML() {
167 let container = newElement("DIV");
168 container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
169 return container.innerHTML;
172 /* Given an HTML string, creates an element from that HTML, adds it to
173 #ui-elements-container (creating the latter if it does not exist), and
174 returns the created element.
176 function addUIElement(element_html) {
177 let ui_elements_container = query("#ui-elements-container");
178 if (ui_elements_container == null)
179 ui_elements_container = document.body.appendChild(newElement("NAV", { "id": "ui-elements-container" }));
181 ui_elements_container.insertAdjacentHTML("beforeend", element_html);
182 return ui_elements_container.lastElementChild;
185 /* Given an element or a selector, removes that element (or the element
186 identified by the selector).
187 If multiple elements match the selector, only the first is removed.
189 function removeElement(elementOrSelector, ancestor = document) {
190 if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
191 if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
194 /* Returns true if the string begins with the given prefix.
196 String.prototype.hasPrefix = function (prefix) {
197 return (this.lastIndexOf(prefix, 0) === 0);
200 /* Toggles whether the page is scrollable.
202 function togglePageScrolling(enable) {
204 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
205 document.body.addClass("no-scroll");
206 document.body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
208 document.body.removeClass("no-scroll");
209 document.body.removeAttribute("style");
210 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
214 DOMRectReadOnly.prototype.isInside = function (x, y) {
215 return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
218 /* Simple mutex mechanism.
220 function doIfAllowed(f, passHolder, passName, releaseImmediately = false) {
221 if (passHolder[passName] == false)
224 passHolder[passName] = false;
228 if (releaseImmediately) {
229 passHolder[passName] = true;
231 requestAnimationFrame(() => {
232 passHolder[passName] = true;
237 /*******************/
238 /* COPY PROCESSORS */
239 /*******************/
241 /*********************************************************************/
242 /* Workaround for Firefox weirdness, based on more Firefox weirdness.
244 DocumentFragment.prototype.getSelection = function () {
245 return document.getSelection();
248 /******************************************************************************/
249 /* Returns true if the node contains only whitespace and/or other empty nodes.
251 function isNodeEmpty(node) {
252 if (node.nodeType == Node.TEXT_NODE)
253 return (node.textContent.match(/\S/) == null);
255 if ( node.nodeType == Node.ELEMENT_NODE
256 && [ "IMG", "VIDEO", "AUDIO", "IFRAME", "OBJECT" ].includes(node.tagName))
259 if (node.childNodes.length == 0)
262 for (childNode of node.childNodes)
263 if (isNodeEmpty(childNode) == false)
269 /***************************************************************/
270 /* Returns a DocumentFragment containing the current selection.
272 function getSelectionAsDocument(doc = document) {
273 let docFrag = doc.getSelection().getRangeAt(0).cloneContents();
275 // Strip whitespace (remove top-level empty nodes).
276 let nodesToRemove = [ ];
277 docFrag.childNodes.forEach(node => {
278 if (isNodeEmpty(node))
279 nodesToRemove.push(node);
281 nodesToRemove.forEach(node => {
282 docFrag.removeChild(node);
288 /*****************************************************************************/
289 /* Adds the given copy processor, appending it to the existing array thereof.
291 Each copy processor should take two arguments: the copy event, and the
292 DocumentFragment which holds the selection as it is being processed by each
293 successive copy processor.
295 A copy processor should return true if processing should continue after it’s
296 done, false otherwise (e.g. if it has entirely replaced the contents of the
297 selection object with what the final clipboard contents should be).
299 function addCopyProcessor(processor) {
300 if (GW.copyProcessors == null)
301 GW.copyProcessors = [ ];
303 GW.copyProcessors.push(processor);
306 /******************************************************************************/
307 /* Set up the copy processor system by registering a ‘copy’ event handler to
308 call copy processors. (Must be set up for the main document, and separately
309 for any shadow roots.)
311 function registerCopyProcessorsForDocument(doc) {
312 GWLog("registerCopyProcessorsForDocument", "rewrite.js", 1);
314 doc.addEventListener("copy", (event) => {
315 if ( GW.copyProcessors == null
316 || GW.copyProcessors.length == 0)
319 // Don't apply copy processors to input fields.
320 if (({'TEXTAREA': true, 'INPUT': true})[document.activeElement.tagName]) {
324 event.preventDefault();
325 event.stopPropagation();
327 let selection = getSelectionAsDocument(doc);
330 while ( i < GW.copyProcessors.length
331 && GW.copyProcessors[i++](event, selection));
333 // This is necessary for .innerText to work properly.
334 let wrapper = newElement("DIV");
335 wrapper.appendChild(selection);
336 document.body.appendChild(wrapper);
338 let makeLinksAbsolute = (node) => {
339 if(node['attributes']) {
340 for(attr of ['src', 'href']) {
342 node[attr] = node[attr];
345 node.childNodes.forEach(makeLinksAbsolute);
347 makeLinksAbsolute(wrapper);
349 event.clipboardData.setData("text/plain", wrapper.innerText);
350 event.clipboardData.setData("text/html", wrapper.innerHTML);
352 document.body.removeChild(wrapper);
356 /*******************************************/
357 /* Set up copy processors in main document.
359 registerCopyProcessorsForDocument(document);
361 /*****************************************************************************/
362 /* Makes it so that copying a rendered equation or other math element copies
363 the LaTeX source, instead of the useless gibberish that is the contents of
364 the text nodes of the HTML representation of the equation.
366 addCopyProcessor((event, selection) => {
367 if (event.target.closest(".mjx-math")) {
368 selection.replaceChildren(event.target.closest(".mjx-math").getAttribute("aria-label"));
373 selection.querySelectorAll(".mjx-chtml").forEach(mathBlock => {
374 mathBlock.innerHTML = " " + mathBlock.querySelector(".mjx-math").getAttribute("aria-label") + " ";
380 /************************************************************************/
381 /* Remove soft hyphens and other extraneous characters from copied text.
383 addCopyProcessor((event, selection) => {
384 let replaceText = (node) => {
385 if(node.nodeType == Node.TEXT_NODE) {
386 node.nodeValue = node.nodeValue.replace(/\u00AD|\u200b/g, "");
389 node.childNodes.forEach(replaceText);
391 replaceText(selection);
397 /********************/
398 /* DEBUGGING OUTPUT */
399 /********************/
401 GW.enableLogging = (permanently = false) => {
403 localStorage.setItem("logging-enabled", "true");
405 GW.loggingEnabled = true;
407 GW.disableLogging = (permanently = false) => {
409 localStorage.removeItem("logging-enabled");
411 GW.loggingEnabled = false;
414 /*******************/
415 /* INBOX INDICATOR */
416 /*******************/
418 function processUserStatus(userStatus) {
419 window.userStatus = userStatus;
421 if(userStatus.notifications) {
422 let element = query('#inbox-indicator');
423 element.className = 'new-messages';
424 element.title = 'New messages [o]';
429 activateTrigger("userStatusReady");
436 function toggleMarkdownHintsBox() {
437 GWLog("toggleMarkdownHintsBox");
438 let markdownHintsBox = query("#markdown-hints");
439 markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
441 function hideMarkdownHintsBox() {
442 GWLog("hideMarkdownHintsBox");
443 let markdownHintsBox = query("#markdown-hints");
444 if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
447 Element.prototype.addTextareaFeatures = function() {
448 GWLog("addTextareaFeatures");
451 textarea.addEventListener("focus", GW.textareaFocused = (event) => {
452 GWLog("GW.textareaFocused");
453 event.target.closest("form").scrollIntoViewIfNeeded();
455 textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
456 GWLog("GW.textareaInputReceived");
457 if (window.innerWidth > 520) {
458 // Expand textarea if needed.
459 expandTextarea(textarea);
461 // Remove markdown hints.
462 hideMarkdownHintsBox();
463 query(".guiedit-mobile-help-button").removeClass("active");
465 // User mentions autocomplete
466 if(!userAutocomplete &&
467 textarea.value.charAt(textarea.selectionStart - 1) === "@" &&
468 (textarea.selectionStart === 1 ||
469 !textarea.value.charAt(textarea.selectionStart - 2).match(/[a-zA-Z0-9]/))) {
470 beginAutocompletion(textarea, textarea.selectionStart);
473 textarea.addEventListener("click", (event) => {
474 if(!userAutocomplete) {
475 let start = textarea.selectionStart, end = textarea.selectionEnd;
476 let value = textarea.value;
477 if (start <= 1) return;
478 for (; value.charAt(start - 1) != "@"; start--) {
479 if (start <= 1) return;
480 if (value.charAt(start - 1) == " ") return;
482 for(; end < value.length && value.charAt(end) != " "; end++) { true }
483 beginAutocompletion(textarea, start, end);
487 textarea.addEventListener("paste", (event) => {
488 let html = event.clipboardData.getData("text/html");
490 html = html.replace(/\n|\r/gm, "");
491 let isQuoted = textarea.selectionStart >= 2 &&
492 textarea.value.substring(textarea.selectionStart - 2, textarea.selectionStart) == "> ";
493 document.execCommand("insertText", false, MarkdownFromHTML(html, (isQuoted ? "> " : null)));
494 event.preventDefault();
498 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
499 textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
500 textarea.addEventListener("keydown", (event) => {
501 // Special case for alt+4
502 // Generalize this before adding more.
503 if(event.altKey && event.key === '4') {
504 insertMarkup(event, "$", "$", "LaTeX formula");
505 event.stopPropagation();
506 event.preventDefault();
510 let form = textarea.closest("form");
512 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
513 let textareaContainer = textarea.closest(".textarea-container");
514 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
515 for (var button of GW.guiEditButtons) {
516 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
517 buttons_container.insertAdjacentHTML("beforeend",
518 "<button type='button' class='guiedit guiedit-"
521 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
522 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
523 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
524 + " onclick='insertMarkup(event,"
525 + ((typeof m_before_or_func == 'function') ?
526 m_before_or_func.name :
527 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
535 `<input type='checkbox' id='markdown-hints-checkbox'>
536 <label for='markdown-hints-checkbox'></label>
537 <div id='markdown-hints'>` +
538 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
539 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
540 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
541 "<span>Heading 1</span><code># Heading 1</code>",
542 "<span>Heading 2</span><code>## Heading 1</code>",
543 "<span>Heading 3</span><code>### Heading 1</code>",
544 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
546 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
548 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
549 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
550 GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
551 if (button.hasClass("guiedit-mobile-help-button")) {
552 toggleMarkdownHintsBox();
553 event.target.toggleClass("active");
554 query(".posting-controls:focus-within textarea").focus();
555 } else if (button.hasClass("guiedit-mobile-exit-button")) {
557 hideMarkdownHintsBox();
558 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
563 // On smartphone (narrow mobile) screens, when a textarea is focused (and
564 // automatically fullscreened), remove all the filters from the page, and
565 // then apply them *just* to the fixed editor UI elements. This is in order
566 // to get around the “children of elements with a filter applied cannot be
568 if (GW.isMobile && window.innerWidth <= 520) {
569 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
570 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
571 GWLog("GW.textareaFocusedMobile");
572 Appearance.savedFilters = Appearance.currentFilters;
573 Appearance.applyFilters(Appearance.noFilters);
574 fixedEditorElements.forEach(element => {
575 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
578 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
579 GWLog("GW.textareaBlurredMobile");
580 requestAnimationFrame(() => {
581 Appearance.applyFilters(Appearance.savedFilters);
582 Appearance.savedFilters = null;
583 fixedEditorElements.forEach(element => {
584 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
591 Element.prototype.injectReplyForm = function(editMarkdownSource) {
592 GWLog("injectReplyForm");
593 let commentControls = this;
594 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
595 let postId = commentControls.parentElement.dataset["postId"];
596 let tagId = commentControls.parentElement.dataset["tagId"];
597 let withparent = (!editMarkdownSource && commentControls.getCommentId());
598 let answer = commentControls.parentElement.id == "answers";
599 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
600 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
601 let parentCommentItem = commentControls.closest(".comment-item");
602 let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
603 (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
604 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
605 "<form method='post'>" +
606 "<div class='textarea-container'>" +
607 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
608 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
609 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
610 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
611 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
612 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
613 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
614 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
615 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
616 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
617 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
618 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
619 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
621 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
622 "<input type='submit' value='Submit'>" +
624 commentControls.onsubmit = disableBeforeUnload;
626 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
627 GWLog("GW.cancelCommentButtonClicked");
628 hideReplyForm(event.target.closest(".comment-controls"));
630 commentControls.scrollIntoViewIfNeeded();
631 commentControls.query("form").onsubmit = (event) => {
632 if (!event.target.text.value) {
633 alert("Please enter a comment.");
637 let textarea = commentControls.query("textarea");
638 if(editMarkdownSource) textarea.value = editMarkdownSource;
639 textarea.addTextareaFeatures();
643 function showCommentEditForm(commentItem) {
644 GWLog("showCommentEditForm");
646 addTriggerListener("userStatusReady", {priority: -1, fn: () => {
647 let commentBody = commentItem.query(".comment-body");
648 commentBody.style.display = "none";
650 let commentControls = commentItem.query(".comment-controls");
651 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
652 commentControls.query("form").addClass("edit-existing-comment");
653 expandTextarea(commentControls.query("textarea"));
657 function showReplyForm(commentItem) {
658 GWLog("showReplyForm");
660 addTriggerListener("userStatusReady", {priority: -1, fn: () => {
661 let commentControls = commentItem.query(".comment-controls");
662 commentControls.injectReplyForm(commentControls.dataset.enteredText);
666 function hideReplyForm(commentControls) {
667 GWLog("hideReplyForm");
668 // Are we editing a comment? If so, un-hide the existing comment body.
669 let containingComment = commentControls.closest(".comment-item");
670 if (containingComment) containingComment.query(".comment-body").style.display = "";
672 let enteredText = commentControls.query("textarea").value;
673 if (enteredText) commentControls.dataset.enteredText = enteredText;
675 disableBeforeUnload();
676 commentControls.constructCommentControls();
679 function expandTextarea(textarea) {
680 GWLog("expandTextarea");
681 if (window.innerWidth <= 520) return;
683 let totalBorderHeight = 30;
684 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
686 requestAnimationFrame(() => {
687 textarea.style.height = 'auto';
688 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
689 if (textarea.clientHeight < window.innerHeight) {
690 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
695 function doCommentAction(action, commentItem) {
696 GWLog("doCommentAction");
698 params[(action + "-comment-id")] = commentItem.getCommentId();
702 onSuccess: GW.commentActionPostSucceeded = (event) => {
703 GWLog("GW.commentActionPostSucceeded");
705 retract: () => { commentItem.firstChild.addClass("retracted") },
706 unretract: () => { commentItem.firstChild.removeClass("retracted") },
708 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>";
709 commentItem.removeChild(commentItem.query(".comment-controls"));
713 if(action != "delete")
714 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
723 function parseVoteType(voteType) {
724 GWLog("parseVoteType");
726 if (!voteType) return value;
727 value.up = /[Uu]pvote$/.test(voteType);
728 value.down = /[Dd]ownvote$/.test(voteType);
729 value.big = /^big/.test(voteType);
733 function makeVoteType(value) {
734 GWLog("makeVoteType");
735 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
738 function makeVoteClass(vote) {
739 GWLog("makeVoteClass");
740 if (vote.up || vote.down) {
741 return (vote.big ? 'selected big-vote' : 'selected');
747 function findVoteControls(targetType, targetId, voteAxis) {
748 var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
750 if(targetType == "Post") {
751 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
752 } else if(targetType == "Comment") {
753 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
757 function votesEqual(vote1, vote2) {
758 var allKeys = Object.assign({}, vote1);
759 Object.assign(allKeys, vote2);
761 for(k of allKeys.keys()) {
762 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
767 function addVoteButtons(element, vote, targetType) {
768 GWLog("addVoteButtons");
770 let voteAxis = element.parentElement.dataset.voteAxis || "karma";
771 let voteType = parseVoteType(vote[voteAxis]);
772 let voteClass = makeVoteClass(voteType);
774 element.parentElement.queryAll("button").forEach((button) => {
775 button.disabled = false;
777 if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
778 button.addClass(voteClass);
780 updateVoteButtonVisualState(button);
781 button.addActivateEvent(voteButtonClicked);
785 function updateVoteButtonVisualState(button) {
786 GWLog("updateVoteButtonVisualState");
788 button.removeClasses([ "none", "one", "two-temp", "two" ]);
791 button.addClass("none");
792 else if (button.hasClass("big-vote"))
793 button.addClass("two");
794 else if (button.hasClass("selected"))
795 button.addClass("one");
797 button.addClass("none");
800 function changeVoteButtonVisualState(button) {
801 GWLog("changeVoteButtonVisualState");
803 /* Interaction states are:
805 0 0· (neutral; +1 click)
806 1 1· (small vote; +1 click)
807 2 2· (big vote; +1 click)
809 Visual states are (with their state classes in [brackets]) are:
812 02 (small vote active) [one]
813 12 (small vote active, temporary indicator of big vote) [two-temp]
814 22 (big vote active) [two]
816 The following are the 9 possible interaction state transitions (and
817 the visual state transitions associated with them):
820 FROM TO FROM TO NOTES
821 ==== ==== ==== ==== =====
822 0 0· 01 12 first click
823 0· 1 12 02 one click without second
824 0· 2 12 22 second click
826 1 1· 02 12 first click
827 1· 0 12 01 one click without second
828 1· 2 12 22 second click
830 2 2· 22 12 first click
831 2· 1 12 02 one click without second
832 2· 0 12 01 second click
835 [ "big-vote two-temp clicked-twice", "none" ], // 2· => 0
836 [ "big-vote two-temp clicked-once", "one" ], // 2· => 1
837 [ "big-vote clicked-once", "two-temp" ], // 2 => 2·
839 [ "selected two-temp clicked-twice", "two" ], // 1· => 2
840 [ "selected two-temp clicked-once", "none" ], // 1· => 0
841 [ "selected clicked-once", "two-temp" ], // 1 => 1·
843 [ "two-temp clicked-twice", "two" ], // 0· => 2
844 [ "two-temp clicked-once", "one" ], // 0· => 1
845 [ "clicked-once", "two-temp" ], // 0 => 0·
847 for (let [ interactionClasses, visualStateClass ] of transitions) {
848 if (button.hasClasses(interactionClasses.split(" "))) {
849 button.removeClasses([ "none", "one", "two-temp", "two" ]);
850 button.addClass(visualStateClass);
856 function voteCompleteEvent(targetType, targetId, response) {
857 GWLog("voteCompleteEvent");
859 var currentVote = voteData[targetType][targetId] || {};
860 var desiredVote = voteDesired[targetType][targetId];
862 var controls = findVoteControls(targetType, targetId);
863 var controlsByAxis = new Object;
865 controls.forEach(control => {
866 const voteAxis = (control.dataset.voteAxis || "karma");
868 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
869 control.removeClass("waiting");
870 control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
873 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
874 controlsByAxis[voteAxis].push(control);
876 const voteType = currentVote[voteAxis];
877 const vote = parseVoteType(voteType);
878 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
879 const voteClass = makeVoteClass(vote);
881 if (response && response[voteAxis]) {
882 const [voteType, displayText, titleText] = response[voteAxis];
884 const displayTarget = control.query(".karma-value");
885 if (displayTarget.hasClass("redacted")) {
886 displayTarget.dataset["trueValue"] = displayText;
888 displayTarget.innerHTML = displayText;
890 displayTarget.setAttribute("title", titleText);
893 control.queryAll("button.vote").forEach(button => {
894 updateVoteButton(button, voteUpDown, voteClass);
899 function updateVoteButton(button, voteUpDown, voteClass) {
900 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
901 if (button.dataset.voteType == voteUpDown)
902 button.addClass(voteClass);
903 updateVoteButtonVisualState(button);
906 function makeVoteRequestCompleteEvent(targetType, targetId) {
908 var currentVote = {};
911 if (event.target.status == 200) {
912 response = JSON.parse(event.target.responseText);
913 for (const voteAxis of response.keys()) {
914 currentVote[voteAxis] = response[voteAxis][0];
916 voteData[targetType][targetId] = currentVote;
918 delete voteDesired[targetType][targetId];
919 currentVote = voteData[targetType][targetId];
922 var desiredVote = voteDesired[targetType][targetId];
924 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
925 sendVoteRequest(targetType, targetId);
927 delete voteDesired[targetType][targetId];
928 voteCompleteEvent(targetType, targetId, response);
933 function sendVoteRequest(targetType, targetId) {
934 GWLog("sendVoteRequest");
938 location: "/karma-vote",
939 params: { "target": targetId,
940 "target-type": targetType,
941 "vote": JSON.stringify(voteDesired[targetType][targetId]) },
942 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
946 function voteButtonClicked(event) {
947 GWLog("voteButtonClicked");
948 let voteButton = event.target;
950 // 500 ms (0.5 s) double-click timeout.
951 let doubleClickTimeout = 500;
953 if (!voteButton.clickedOnce) {
954 voteButton.clickedOnce = true;
955 voteButton.addClass("clicked-once");
956 changeVoteButtonVisualState(voteButton);
958 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
959 if (!voteButton.clickedOnce) return;
961 // Do single-click code.
962 voteButton.clickedOnce = false;
963 voteEvent(voteButton, 1);
964 }, doubleClickTimeout, voteButton);
966 voteButton.clickedOnce = false;
968 // Do double-click code.
969 voteButton.removeClass("clicked-once");
970 voteButton.addClass("clicked-twice");
971 voteEvent(voteButton, 2);
975 function voteEvent(voteButton, numClicks) {
979 let voteControl = voteButton.parentNode;
981 let targetType = voteButton.dataset.targetType;
982 let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
983 let voteAxis = voteControl.dataset.voteAxis || "karma";
984 let voteUpDown = voteButton.dataset.voteType;
987 if ( (numClicks == 2 && voteButton.hasClass("big-vote"))
988 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
989 voteType = "neutral";
991 let vote = parseVoteType(voteUpDown);
992 vote.big = (numClicks == 2);
993 voteType = makeVoteType(vote);
996 let voteControls = findVoteControls(targetType, targetId, voteAxis);
997 for (const voteControl of voteControls) {
998 voteControl.addClass("waiting");
999 voteControl.queryAll(".vote").forEach(button => {
1000 button.addClass("waiting");
1001 updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
1005 let voteRequestPending = voteDesired[targetType][targetId];
1006 let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
1007 voteObject[voteAxis] = voteType;
1008 voteDesired[targetType][targetId] = voteObject;
1010 if (!voteRequestPending) sendVoteRequest(targetType, targetId);
1013 function initializeVoteButtons() {
1014 // Color the upvote/downvote buttons with an embedded style sheet.
1015 insertHeadHTML(`<style id="vote-buttons">
1017 --GW-upvote-button-color: #00d800;
1018 --GW-downvote-button-color: #eb4c2a;
1023 function processVoteData(voteData) {
1024 window.voteData = voteData;
1026 window.voteDesired = new Object;
1027 for(key of voteData.keys()) {
1028 voteDesired[key] = new Object;
1031 initializeVoteButtons();
1033 addTriggerListener("postLoaded", {priority: 3000, fn: () => {
1034 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
1035 let postID = karmaValue.parentNode.dataset.postId;
1036 addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
1037 karmaValue.parentElement.addClass("active-controls");
1041 addTriggerListener("DOMReady", {priority: 3000, fn: () => {
1042 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
1043 let commentID = karmaValue.getCommentId();
1044 addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
1045 karmaValue.parentElement.addClass("active-controls");
1050 /*****************************************/
1051 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
1052 /*****************************************/
1054 Element.prototype.getCommentDate = function() {
1055 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
1056 let dateElement = item && item.query(".date");
1057 return (dateElement && parseInt(dateElement.dataset["jsDate"]));
1059 function getCurrentVisibleComment() {
1060 let px = window.innerWidth/2, py = 5;
1061 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
1062 let bottomBar = query("#bottom-bar");
1063 let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : document.body.getBoundingClientRect().bottom);
1064 let atbottom = bottomOffset <= window.innerHeight;
1066 let hashci = location.hash && query(location.hash);
1067 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
1068 commentItem = hashci;
1074 function highlightCommentsSince(date) {
1075 GWLog("highlightCommentsSince");
1076 var newCommentsCount = 0;
1077 GW.newComments = [ ];
1078 let oldCommentsStack = [ ];
1080 queryAll(".comment-item").forEach(commentItem => {
1081 commentItem.prevNewComment = prevNewComment;
1082 commentItem.nextNewComment = null;
1083 if (commentItem.getCommentDate() > date) {
1084 commentItem.addClass("new-comment");
1086 GW.newComments.push(commentItem.getCommentId());
1087 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
1088 oldCommentsStack = [ commentItem ];
1089 prevNewComment = commentItem;
1091 commentItem.removeClass("new-comment");
1092 oldCommentsStack.push(commentItem);
1096 GW.newCommentScrollSet = (commentItem) => {
1097 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
1098 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
1100 GW.newCommentScrollListener = () => {
1101 let commentItem = getCurrentVisibleComment();
1102 GW.newCommentScrollSet(commentItem);
1105 addScrollListener(GW.newCommentScrollListener);
1107 if (document.readyState=="complete") {
1108 GW.newCommentScrollListener();
1110 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
1111 GW.newCommentScrollSet(commentItem);
1114 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
1116 return newCommentsCount;
1119 function scrollToNewComment(next) {
1120 GWLog("scrollToNewComment");
1121 let commentItem = getCurrentVisibleComment();
1122 let targetComment = null;
1123 let targetCommentID = null;
1125 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
1126 if (targetComment) {
1127 targetCommentID = targetComment.getCommentId();
1130 if (GW.newComments[0]) {
1131 targetCommentID = GW.newComments[0];
1132 targetComment = query("#comment-" + targetCommentID);
1135 if (targetComment) {
1136 expandAncestorsOf(targetCommentID);
1137 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
1138 targetComment.scrollIntoView();
1141 GW.newCommentScrollListener();
1144 function getPostHash() {
1145 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
1146 return (postHash ? postHash[1] : false);
1148 function setHistoryLastVisitedDate(date) {
1149 window.history.replaceState({ lastVisited: date }, null);
1151 function getLastVisitedDate() {
1152 // Get the last visited date (or, if posting a comment, the previous last visited date).
1153 if(window.history.state) return (window.history.state||{})['lastVisited'];
1154 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1155 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
1156 let currentVisited = localStorage.getItem(storageName);
1157 setHistoryLastVisitedDate(currentVisited);
1158 return currentVisited;
1160 function setLastVisitedDate(date) {
1161 GWLog("setLastVisitedDate");
1162 // If NOT posting a comment, save the previous value for the last-visited-date
1163 // (to recover it in case of posting a comment).
1164 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1165 if (!aCommentHasJustBeenPosted) {
1166 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
1167 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
1170 // Set the new value.
1171 localStorage.setItem("last-visited-date_" + getPostHash(), date);
1174 function updateSavedCommentCount() {
1175 GWLog("updateSavedCommentCount");
1176 let commentCount = queryAll(".comment").length;
1177 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
1179 function badgePostsWithNewComments() {
1180 GWLog("badgePostsWithNewComments");
1181 if (getQueryVariable("show") == "conversations") return;
1183 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
1184 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
1186 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
1187 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1188 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1190 if (currentCommentCount > savedCommentCount)
1191 commentCountDisplay.addClass("new-comments");
1193 commentCountDisplay.removeClass("new-comments");
1194 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1204 systemDarkModeActive: matchMedia("(prefers-color-scheme: dark)")
1208 /************************/
1209 /* ACTIVE MEDIA QUERIES */
1210 /************************/
1212 /* This function provides two slightly different versions of its functionality,
1213 depending on how many arguments it gets.
1215 If one function is given (in addition to the media query and its name), it
1216 is called whenever the media query changes (in either direction).
1218 If two functions are given (in addition to the media query and its name),
1219 then the first function is called whenever the media query starts matching,
1220 and the second function is called whenever the media query stops matching.
1222 If you want to call a function for a change in one direction only, pass an
1223 empty closure (NOT null!) as one of the function arguments.
1225 There is also an optional fifth argument. This should be a function to be
1226 called when the active media query is canceled.
1228 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1229 if (typeof GW.mediaQueryResponders == "undefined")
1230 GW.mediaQueryResponders = { };
1232 let mediaQueryResponder = (event, canceling = false) => {
1234 GWLog(`Canceling media query “${name}”`, "media queries", 1);
1236 if (whenCanceledDo != null)
1237 whenCanceledDo(mediaQuery);
1239 let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1241 GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1243 if ((otherwiseDo == null) || matches)
1244 ifMatchesOrAlwaysDo(mediaQuery);
1246 otherwiseDo(mediaQuery);
1249 mediaQueryResponder();
1250 mediaQuery.addListener(mediaQueryResponder);
1252 GW.mediaQueryResponders[name] = mediaQueryResponder;
1255 /* Deactivates and discards an active media query, after calling the function
1256 that was passed as the whenCanceledDo parameter when the media query was
1259 function cancelDoWhenMatchMedia(name) {
1260 GW.mediaQueryResponders[name](null, true);
1262 for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1263 mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1265 GW.mediaQueryResponders[name] = null;
1269 /******************************/
1270 /* DARK/LIGHT MODE ADJUSTMENT */
1271 /******************************/
1278 [ "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)" ],
1279 [ "light", "", "Light mode at all times (black-on-white)" ],
1280 [ "dark", "", "Dark mode at all times (inverted: white-on-black)" ]
1283 selectedModeOptionNote: " [This option is currently selected.]",
1285 /******************/
1290 modeSelectorInteractable: true,
1292 /******************/
1296 /* Returns current (saved) mode (light, dark, or auto).
1298 getSavedMode: () => {
1299 return (readCookie("dark-mode") || (readCookie("theme") === "dark" && "dark") || "auto");
1302 /* Saves specified mode (light, dark, or auto).
1304 saveMode: (mode) => {
1305 GWLog("DarkMode.setMode");
1308 setCookie("dark-mode", "");
1310 setCookie("dark-mode", mode);
1313 getMediaQuery: (selectedMode = DarkMode.getSavedMode()) => {
1314 if (selectedMode == "auto") {
1315 return "all and (prefers-color-scheme: dark)";
1316 } else if (selectedMode == "dark") {
1323 /* Set specified color mode (light, dark, or auto).
1325 setMode: (selectedMode = DarkMode.getSavedMode()) => {
1326 GWLog("DarkMode.setMode");
1328 document.body.removeClasses(["force-dark-mode", "force-light-mode"]);
1329 if(selectedMode === "dark" || selectedMode === "light")
1330 document.body.addClass("force-" + selectedMode + "-mode");
1332 let media = DarkMode.getMediaQuery(selectedMode);
1333 let darkModeStyles = document.querySelector("link.dark-mode");
1334 if (darkModeStyles) {
1335 // Set `media` attribute of style block to match requested mode.
1336 darkModeStyles.media = media;
1339 for(elem of document.querySelectorAll("picture.invertible source")) {
1340 // Update invertible images.
1345 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1348 modeSelectorHTML: (inline = false) => {
1349 let selectorTagName = (inline ? "span" : "div");
1350 let selectorId = (inline ? `` : ` id="dark-mode-selector"`);
1351 let selectorClass = (` class="dark-mode-selector mode-selector` + (inline ? ` mode-selector-inline` : ``) + `"`);
1353 // Get saved mode setting (or default).
1354 let currentMode = DarkMode.getSavedMode();
1356 return `<${selectorTagName}${selectorId}${selectorClass}>`
1357 + DarkMode.modeOptions.map(modeOption => {
1358 let [ name, label, desc ] = modeOption;
1359 let selected = (name == currentMode ? " selected" : "");
1360 let disabled = (name == currentMode ? " disabled" : "");
1361 let active = (( currentMode == "auto"
1362 && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1365 if (name == currentMode)
1366 desc += DarkMode.selectedModeOptionNote;
1369 class="select-mode-${name}${selected}${active}"
1374 >${label}</button>`;
1376 + `</${selectorTagName}>`;
1379 injectModeSelector: (replacedElement = null) => {
1380 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1382 // Inject the mode selector widget.
1384 if (replacedElement) {
1385 replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1386 modeSelector = replacedElement.firstElementChild;
1387 unwrap(replacedElement);
1390 if (Appearance.themeSelector == null)
1393 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", DarkMode.modeSelectorHTML());
1395 addUIElement(DarkMode.modeSelectorHTML());
1398 modeSelector = DarkMode.modeSelector = query("#dark-mode-selector");
1401 // Add event listeners and update state.
1402 requestAnimationFrame(() => {
1403 // Activate mode selector widget buttons.
1404 modeSelector.querySelectorAll("button").forEach(button => {
1405 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1409 /* Add active media query to update mode selector state when system dark
1410 mode setting changes. (This is relevant only for the ‘auto’ setting.)
1412 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => {
1413 DarkMode.updateModeSelectorState(modeSelector);
1417 modeSelectButtonClicked: (event) => {
1418 GWLog("DarkMode.modeSelectButtonClicked");
1420 /* We don’t want clicks to go through if the transition
1421 between modes has not completed yet, so we disable the
1422 button temporarily while we’re transitioning between
1426 // Determine which setting was chosen (ie. which button was clicked).
1427 let selectedMode = event.target.dataset.name;
1429 // Save the new setting.
1430 DarkMode.saveMode(selectedMode);
1432 // Actually change the mode.
1433 DarkMode.setMode(selectedMode);
1434 }, DarkMode, "modeSelectorInteractable");
1436 event.target.blur();
1439 updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1440 GWLog("DarkMode.updateModeSelectorState");
1442 /* If the mode selector has not yet been injected, then do nothing.
1444 if (modeSelector == null)
1447 // Get saved mode setting (or default).
1448 let currentMode = DarkMode.getSavedMode();
1450 // Clear current buttons state.
1451 modeSelector.querySelectorAll("button").forEach(button => {
1452 button.classList.remove("active", "selected");
1453 button.disabled = false;
1454 if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1455 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1458 // Set the correct button to be selected.
1459 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1460 button.classList.add("selected");
1461 button.disabled = true;
1462 button.title += DarkMode.selectedModeOptionNote;
1465 /* Ensure the right button (light or dark) has the “currently active”
1466 indicator, if the current mode is ‘auto’.
1468 if (currentMode == "auto")
1469 modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1474 /****************************/
1475 /* APPEARANCE CUSTOMIZATION */
1476 /****************************/
1478 Appearance = { ...Appearance,
1479 /**************************************************************************/
1485 themeSelector: null,
1486 themeSelectorAuxiliaryControlsContainer: null,
1487 themeSelectorInteractionBlockerOverlay: null,
1488 themeSelectorInteractableTimer: null,
1490 themeTweakerToggle: null,
1492 themeTweakerStyleBlock: null,
1494 themeTweakerUI: null,
1495 themeTweakerUIMainWindow: null,
1496 themeTweakerUIHelpWindow: null,
1497 themeTweakerUISampleTextContainer: null,
1498 themeTweakerUIClippyContainer: null,
1499 themeTweakerUIClippyControl: null,
1501 widthSelector: null,
1503 textSizeAdjustmentWidget: null,
1505 appearanceAdjustUIToggle: null,
1507 /**************************************************************************/
1511 /* Return a new <link> element linking a style sheet (.css file) for the
1512 given theme name and color scheme preference (i.e., value for the
1513 ‘media’ attribute; may be “light”, “dark”, or “” [empty string]).
1515 makeNewStyle: (newThemeName) => {
1516 let styleSheetNameSuffix = newThemeName == Appearance.defaultTheme
1518 : ("-" + newThemeName);
1519 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1521 return [["style", "theme"], ["colors", "theme light-mode"], ["inverted", "theme dark-mode", DarkMode.getMediaQuery()]].map(args => {
1522 let [baseName, className, mediaQuery] = args;
1523 return newElement("LINK", {
1525 "rel": "stylesheet",
1526 "href": ("/generated-css/" + baseName + styleSheetNameSuffix + currentStyleSheetNameComponents[1]),
1527 "media": mediaQuery || null,
1528 "blocking": "render"
1533 setTheme: (newThemeName, save = true) => {
1534 GWLog("Appearance.setTheme");
1536 let oldThemeName = "";
1537 if (typeof(newThemeName) == "undefined") {
1538 /* If no theme name to set is given, that means we’re setting the
1539 theme initially, on page load. The .currentTheme value will have
1540 been set by .setup().
1542 newThemeName = Appearance.currentTheme;
1544 /* If the selected (saved) theme is the default theme, then there’s
1547 if (newThemeName == Appearance.defaultTheme)
1550 oldThemeName = Appearance.currentTheme;
1552 /* When the unload callback runs, the .currentTheme value is still
1553 that of the old theme.
1555 let themeUnloadCallback = Appearance.themeUnloadCallbacks[oldThemeName];
1556 if (themeUnloadCallback != null)
1557 themeUnloadCallback(newThemeName);
1559 /* The old .currentTheme value is saved in oldThemeName.
1561 Appearance.currentTheme = newThemeName;
1563 /* The ‘save’ parameter might be false if this function is called
1564 from the theme tweaker, in which case we want to switch only
1565 temporarily, and preserve the saved setting until the user
1569 Appearance.saveCurrentTheme();
1572 let newStyles = Appearance.makeNewStyle(newThemeName);
1573 let loadingStyleCount = newStyles.length;
1575 let oldStyles = queryAll("head link.theme");
1577 let onNewStylesLoaded = (event) => {
1578 loadingStyleCount--;
1579 if(loadingStyleCount === 0) {
1580 for(oldStyle of oldStyles) removeElement(oldStyle);
1581 Appearance.postSetThemeHousekeeping(oldThemeName, newThemeName);
1585 for(newStyle of newStyles) newStyle.addEventListener("load", onNewStylesLoaded);
1587 if (Appearance.adjustmentTransitions) {
1588 pageFadeTransition(false);
1590 document.head.prepend(...newStyles);
1593 document.head.prepend(...newStyles);
1596 // Update UI state of all theme selectors.
1597 Appearance.updateThemeSelectorsState();
1600 postSetThemeHousekeeping: (oldThemeName = "", newThemeName = null) => {
1601 GWLog("Appearance.postSetThemeHousekeeping");
1603 if (newThemeName == null)
1604 newThemeName = Appearance.getSavedTheme();
1606 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1607 document.body.addClass("theme-" + newThemeName);
1609 recomputeUIElementsContainerHeight(true);
1611 let themeLoadCallback = Appearance.themeLoadCallbacks[newThemeName];
1612 if (themeLoadCallback != null)
1613 themeLoadCallback(oldThemeName);
1615 recomputeUIElementsContainerHeight();
1616 adjustUIForWindowSize();
1617 window.addEventListener("resize", GW.windowResized = (event) => {
1618 GWLog("GW.windowResized");
1619 adjustUIForWindowSize();
1620 recomputeUIElementsContainerHeight();
1623 generateImagesOverlay();
1625 if (Appearance.adjustmentTransitions)
1626 pageFadeTransition(true);
1627 Appearance.updateThemeTweakerSampleText();
1629 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== "undefined") {
1630 window.matchMedia("(orientation: portrait)").addListener(generateImagesOverlay);
1634 themeLoadCallbacks: {
1635 brutalist: (fromTheme = "") => {
1636 GWLog("Appearance.themeLoadCallbacks.brutalist");
1638 let bottomBarLinks = queryAll("#bottom-bar a");
1639 if (!GW.isMobile && bottomBarLinks.length == 5) {
1640 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1641 bottomBarLinks.forEach((link, i) => {
1642 link.dataset.originalText = link.textContent;
1643 link.textContent = newLinkTexts[i];
1648 classic: (fromTheme = "") => {
1649 GWLog("Appearance.themeLoadCallbacks.classic");
1651 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1652 button.innerHTML = "";
1656 dark: (fromTheme = "") => {
1657 GWLog("Appearance.themeLoadCallbacks.dark");
1659 insertHeadHTML(`<style id="dark-theme-adjustments">
1660 .markdown-reference-link a { color: #d200cf; filter: invert(100%); }
1661 #bottom-bar.decorative::before { filter: invert(100%); }
1663 registerInitializer("makeImagesGlow", true, () => query("#images-overlay") != null, () => {
1664 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1665 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)";
1666 image.style.width = parseInt(image.style.width) + 12 + "px";
1667 image.style.height = parseInt(image.style.height) + 12 + "px";
1668 image.style.top = parseInt(image.style.top) - 6 + "px";
1669 image.style.left = parseInt(image.style.left) - 6 + "px";
1674 less: (fromTheme = "") => {
1675 GWLog("Appearance.themeLoadCallbacks.less");
1677 injectSiteNavUIToggle();
1679 injectPostNavUIToggle();
1680 Appearance.injectAppearanceAdjustUIToggle();
1683 registerInitializer("shortenDate", true, () => query(".top-post-meta") != null, function () {
1684 let dtf = new Intl.DateTimeFormat([],
1685 (window.innerWidth < 1100) ?
1686 { month: "short", day: "numeric", year: "numeric" } :
1687 { month: "long", day: "numeric", year: "numeric" });
1688 let postDate = query(".top-post-meta .date");
1689 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1693 query("#content").insertAdjacentHTML("beforeend", `<div id="theme-less-mobile-first-row-placeholder"></div>`);
1697 registerInitializer("addSpans", true, () => query(".top-post-meta") != null, function () {
1698 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1699 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1703 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1704 // If state is not set (user has never clicked on the Less theme’s appearance
1705 // adjustment UI toggle) then show it, but then hide it after a short time.
1706 registerInitializer("engageAppearanceAdjustUI", true, () => query("#ui-elements-container") != null, function () {
1707 Appearance.toggleAppearanceAdjustUI();
1708 setTimeout(Appearance.toggleAppearanceAdjustUI, 3000);
1712 if (fromTheme != "") {
1713 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1714 setTimeout(function () {
1715 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1717 setTimeout(function () {
1718 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1722 // Unset the height of the #ui-elements-container.
1723 query("#ui-elements-container").style.height = "";
1725 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1726 Appearance.filtersExclusionPaths.themeLess = [
1727 "#content #secondary-bar",
1728 "#content .post .top-post-meta .date",
1729 "#content .post .top-post-meta .comment-count",
1731 Appearance.applyFilters();
1734 // We pre-query the relevant elements, so we don’t have to run querySelectorAll
1735 // on every firing of the scroll listener.
1737 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1738 "unbrokenDownScrollDistance": 0,
1739 "unbrokenUpScrollDistance": 0,
1740 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1741 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1742 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1744 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1748 themeUnloadCallbacks: {
1749 brutalist: (toTheme = "") => {
1750 GWLog("Appearance.themeUnloadCallbacks.brutalist");
1752 let bottomBarLinks = queryAll("#bottom-bar a");
1753 if (!GW.isMobile && bottomBarLinks.length == 5) {
1754 bottomBarLinks.forEach(link => {
1755 link.textContent = link.dataset.originalText;
1760 classic: (toTheme = "") => {
1761 GWLog("Appearance.themeUnloadCallbacks.classic");
1763 if (GW.isMobile && window.innerWidth <= 900)
1766 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1767 button.innerHTML = button.dataset.label;
1771 dark: (toTheme = "") => {
1772 GWLog("Appearance.themeUnloadCallbacks.dark");
1774 removeElement("#dark-theme-adjustments");
1777 less: (toTheme = "") => {
1778 GWLog("Appearance.themeUnloadCallbacks.less");
1780 removeSiteNavUIToggle();
1782 removePostNavUIToggle();
1783 Appearance.removeAppearanceAdjustUIToggle();
1786 window.removeEventListener("resize", updatePostNavUIVisibility);
1788 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1790 removeElement("#theme-less-mobile-first-row-placeholder");
1794 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1795 element.innerHTML = element.firstChild.innerHTML;
1799 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1801 // Reset filtered elements selector to default.
1802 delete Appearance.filtersExclusionPaths.themeLess;
1803 Appearance.applyFilters();
1807 pageFadeTransition: (fadeIn) => {
1809 document.body.removeClass("transparent");
1811 document.body.addClass("transparent");
1815 /* Set the saved theme setting to the currently active theme.
1817 saveCurrentTheme: () => {
1818 GWLog("Appearance.saveCurrentTheme");
1820 if (Appearance.currentTheme == Appearance.defaultTheme)
1821 setCookie("theme", "");
1823 setCookie("theme", Appearance.currentTheme);
1826 /* Reset theme, theme tweak filters, and text zoom to their saved settings.
1828 themeTweakReset: () => {
1829 GWLog("Appearance.themeTweakReset");
1831 Appearance.setTheme(Appearance.getSavedTheme());
1832 Appearance.applyFilters(Appearance.getSavedFilters());
1833 Appearance.setTextZoom(Appearance.getSavedTextZoom());
1836 /* Set the saved theme, theme tweak filter, and text zoom settings to their
1837 currently active values.
1839 themeTweakSave: () => {
1840 GWLog("Appearance.themeTweakSave");
1842 Appearance.saveCurrentTheme();
1843 Appearance.saveCurrentFilters();
1844 Appearance.saveCurrentTextZoom();
1847 /* Reset theme, theme tweak filters, and text zoom to their default levels.
1848 (Do not save the new settings, however.)
1850 themeTweakResetDefaults: () => {
1851 GWLog("Appearance.themeTweakResetDefaults");
1853 Appearance.setTheme(Appearance.defaultTheme, false);
1854 Appearance.applyFilters(Appearance.defaultFilters);
1855 Appearance.setTextZoom(Appearance.defaultTextZoom, false);
1858 themeTweakerResetSettings: () => {
1859 GWLog("Appearance.themeTweakerResetSettings");
1861 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
1862 Appearance.themeTweakerUIClippyContainer.style.display = Appearance.themeTweakerUIClippyControl.checked
1867 themeTweakerSaveSettings: () => {
1868 GWLog("Appearance.themeTweakerSaveSettings");
1870 Appearance.saveThemeTweakerClippyState();
1873 getSavedThemeTweakerClippyState: () => {
1874 return (JSON.parse(localStorage.getItem("theme-tweaker-settings") || `{ "showClippy": ${Appearance.defaultThemeTweakerClippyState} }` )["showClippy"]);
1877 saveThemeTweakerClippyState: () => {
1878 GWLog("Appearance.saveThemeTweakerClippyState");
1880 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ "showClippy": Appearance.themeTweakerUIClippyControl.checked }));
1883 getSavedAppearanceAdjustUIToggleState: () => {
1884 return ((localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") || Appearance.defaultAppearanceAdjustUIToggleState);
1887 saveAppearanceAdjustUIToggleState: () => {
1888 GWLog("Appearance.saveAppearanceAdjustUIToggleState");
1890 localStorage.setItem("appearance-adjust-ui-toggle-engaged", Appearance.appearanceAdjustUIToggle.query("button").hasClass("engaged"));
1893 /**************************************************************************/
1894 /* UI CONSTRUCTION & MANIPULATION
1897 contentWidthSelectorHTML: () => {
1898 return ("<div id='width-selector'>"
1899 + String.prototype.concat.apply("", Appearance.widthOptions.map(widthOption => {
1900 let [name, desc, abbr] = widthOption;
1901 let selected = (name == Appearance.currentWidth ? " selected" : "");
1902 let disabled = (name == Appearance.currentWidth ? " disabled" : "");
1903 return `<button type="button" class="select-width-${name}${selected}"${disabled} title="${desc}" tabindex="-1" data-name="${name}">${abbr}</button>`
1908 injectContentWidthSelector: () => {
1909 GWLog("Appearance.injectContentWidthSelector");
1911 // Inject the content width selector widget and activate buttons.
1912 Appearance.widthSelector = addUIElement(Appearance.contentWidthSelectorHTML());
1913 Appearance.widthSelector.queryAll("button").forEach(button => {
1914 button.addActivateEvent(Appearance.widthAdjustButtonClicked);
1917 // Make sure the accesskey (to cycle to the next width) is on the right button.
1918 Appearance.setWidthAdjustButtonsAccesskey();
1920 // Inject transitions CSS, if animating changes is enabled.
1921 if (Appearance.adjustmentTransitions) {
1923 `<style id="width-transition">
1925 #ui-elements-container,
1928 max-width 0.3s ease;
1934 setWidthAdjustButtonsAccesskey: () => {
1935 GWLog("Appearance.setWidthAdjustButtonsAccesskey");
1937 Appearance.widthSelector.queryAll("button").forEach(button => {
1938 button.removeAttribute("accesskey");
1939 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1941 let selectedButton = Appearance.widthSelector.query("button.selected");
1942 let nextButtonInCycle = selectedButton == selectedButton.parentElement.lastChild
1943 ? selectedButton.parentElement.firstChild
1944 : selectedButton.nextSibling;
1945 nextButtonInCycle.accessKey = "'";
1946 nextButtonInCycle.title += ` [\']`;
1949 injectTextSizeAdjustmentUI: () => {
1950 GWLog("Appearance.injectTextSizeAdjustmentUI");
1952 if (Appearance.textSizeAdjustmentWidget != null)
1955 let inject = () => {
1956 GWLog("Appearance.injectTextSizeAdjustmentUI [INJECTING]");
1958 Appearance.textSizeAdjustmentWidget = addUIElement("<div id='text-size-adjustment-ui'>"
1959 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1960 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1961 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1964 Appearance.textSizeAdjustmentWidget.queryAll("button").forEach(button => {
1965 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1969 if (query("#content.post-page") != null) {
1972 document.addEventListener("DOMContentLoaded", () => {
1973 if (!( query(".post-body") == null
1974 && query(".comment-body") == null))
1980 themeSelectorHTML: () => {
1981 return ("<div id='theme-selector' class='theme-selector'>"
1982 + String.prototype.concat.apply("", Appearance.themeOptions.map(themeOption => {
1983 let [name, desc, letter] = themeOption;
1984 let selected = (name == Appearance.currentTheme ? ' selected' : '');
1985 let disabled = (name == Appearance.currentTheme ? ' disabled' : '');
1986 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1987 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>`;
1992 injectThemeSelector: () => {
1993 GWLog("Appearance.injectThemeSelector");
1995 Appearance.themeSelector = addUIElement(Appearance.themeSelectorHTML());
1996 Appearance.themeSelector.queryAll("button").forEach(button => {
1997 button.addActivateEvent(Appearance.themeSelectButtonClicked);
2001 // Add close button.
2002 let themeSelectorCloseButton = newElement("BUTTON", { "class": "theme-selector-close-button" }, { "innerHTML": "" });
2003 themeSelectorCloseButton.addActivateEvent(Appearance.themeSelectorCloseButtonClicked);
2004 Appearance.themeSelector.appendChild(themeSelectorCloseButton);
2006 // Inject auxiliary controls container.
2007 Appearance.themeSelectorAuxiliaryControlsContainer = newElement("DIV", { "class": "auxiliary-controls-container" });
2008 Appearance.themeSelector.appendChild(Appearance.themeSelectorAuxiliaryControlsContainer);
2010 // Inject mobile versions of various UI elements.
2011 Appearance.injectThemeTweakerToggle();
2012 injectAntiKibitzerToggle();
2013 DarkMode.injectModeSelector();
2015 // Inject interaction blocker overlay.
2016 Appearance.themeSelectorInteractionBlockerOverlay = Appearance.themeSelector.appendChild(newElement("DIV", { "class": "interaction-blocker-overlay" }));
2017 Appearance.themeSelectorInteractionBlockerOverlay.addActivateEvent(event => { event.stopPropagation(); });
2020 // Inject transitions CSS, if animating changes is enabled.
2021 if (Appearance.adjustmentTransitions) {
2022 insertHeadHTML(`<style id="theme-fade-transition">
2025 opacity 0.5s ease-out,
2026 background-color 0.3s ease-out;
2029 background-color: #777;
2032 opacity 0.5s ease-in,
2033 background-color 0.3s ease-in;
2039 updateThemeSelectorsState: () => {
2040 GWLog("Appearance.updateThemeSelectorsState");
2042 queryAll(".theme-selector button.select-theme").forEach(button => {
2043 button.removeClass("selected");
2044 button.disabled = false;
2046 queryAll(".theme-selector button.select-theme-" + Appearance.currentTheme).forEach(button => {
2047 button.addClass("selected");
2048 button.disabled = true;
2051 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.currentTheme;
2054 setThemeSelectorInteractable: (interactable) => {
2055 GWLog("Appearance.setThemeSelectorInteractable");
2057 Appearance.themeSelectorInteractionBlockerOverlay.classList.toggle("enabled", (interactable == false));
2060 themeTweakerUIHTML: () => {
2061 return (`<div id="theme-tweaker-ui" style="display: none;">\n`
2062 + `<div class="theme-tweaker-window main-window">
2063 <div class="theme-tweaker-window-title-bar">
2064 <div class="theme-tweaker-window-title">
2065 <h1>Customize appearance</h1>
2067 <div class="theme-tweaker-window-title-bar-buttons-container">
2068 <button type="button" class="help-button" tabindex="-1"></button>
2069 <button type="button" class="minimize-button minimize" tabindex="-1"></button>
2070 <button type="button" class="close-button" tabindex="-1"></button>
2073 <div class="theme-tweaker-window-content-view">
2074 <div class="theme-select">
2075 <p class="current-theme">Current theme:
2076 <span>${Appearance.getSavedTheme()}</span>
2078 <div class="theme-selector"></div>
2080 <div class="controls-container">
2081 <div id="theme-tweak-section-sample-text" class="section" data-label="Sample text">
2082 <div class="sample-text-container"><span class="sample-text">
2083 <p>Less Wrong (text)</p>
2084 <p><a href="#">Less Wrong (link)</a></p>
2087 <div id="theme-tweak-section-text-size-adjust" class="section" data-label="Text size">
2088 <button type="button" class="text-size-adjust-button decrease" title="Decrease text size"></button>
2089 <button type="button" class="text-size-adjust-button default" title="Reset to default text size"></button>
2090 <button type="button" class="text-size-adjust-button increase" title="Increase text size"></button>
2092 <div id="theme-tweak-section-invert" class="section" data-label="Invert (photo-negative)">
2093 <input type="checkbox" id="theme-tweak-control-invert"></input>
2094 <label for="theme-tweak-control-invert">Invert colors</label>
2096 <div id="theme-tweak-section-saturate" class="section" data-label="Saturation">
2097 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2098 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
2099 <div class="notch theme-tweak-slider-notch-saturate" title="Reset saturation to default value (100%)"></div>
2101 <div id="theme-tweak-section-brightness" class="section" data-label="Brightness">
2102 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2103 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
2104 <div class="notch theme-tweak-slider-notch-brightness" title="Reset brightness to default value (100%)"></div>
2106 <div id="theme-tweak-section-contrast" class="section" data-label="Contrast">
2107 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2108 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
2109 <div class="notch theme-tweak-slider-notch-contrast" title="Reset contrast to default value (100%)"></div>
2111 <div id="theme-tweak-section-hue-rotate" class="section" data-label="Hue rotation">
2112 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
2113 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
2114 <div class="notch theme-tweak-slider-notch-hue-rotate" title="Reset hue to default (0° away from standard colors for theme)"></div>
2117 <div class="buttons-container">
2118 <button type="button" class="reset-defaults-button">Reset to defaults</button>
2119 <button type="button" class="ok-button default-button">OK</button>
2120 <button type="button" class="cancel-button">Cancel</button>
2124 <div class="clippy-container">
2125 <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>)
2126 <div class="clippy"></div>
2127 <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>
2129 <div class="theme-tweaker-window help-window" style="display: none;">
2130 <div class="theme-tweaker-window-title-bar">
2131 <div class="theme-tweaker-window-title">
2132 <h1>Theme tweaker help</h1>
2135 <div class="theme-tweaker-window-content-view">
2136 <div id="theme-tweak-section-clippy" class="section" data-label="Theme Tweaker Assistant">
2137 <input type="checkbox" id="theme-tweak-control-clippy" checked="checked"></input>
2138 <label for="theme-tweak-control-clippy">Show Bobby the Basilisk</label>
2140 <div class="buttons-container">
2141 <button type="button" class="ok-button default-button">OK</button>
2142 <button type="button" class="cancel-button">Cancel</button>
2149 injectThemeTweaker: () => {
2150 GWLog("Appearance.injectThemeTweaker");
2152 Appearance.themeTweakerUI = addUIElement(Appearance.themeTweakerUIHTML());
2153 Appearance.themeTweakerUIMainWindow = Appearance.themeTweakerUI.firstElementChild;
2154 Appearance.themeTweakerUIHelpWindow = Appearance.themeTweakerUI.query(".help-window");
2155 Appearance.themeTweakerUISampleTextContainer = Appearance.themeTweakerUI.query("#theme-tweak-section-sample-text .sample-text-container");
2156 Appearance.themeTweakerUIClippyContainer = Appearance.themeTweakerUI.query(".clippy-container");
2157 Appearance.themeTweakerUIClippyControl = Appearance.themeTweakerUI.query("#theme-tweak-control-clippy");
2159 // Clicking the background overlay closes the theme tweaker.
2160 Appearance.themeTweakerUI.addActivateEvent(Appearance.themeTweakerUIOverlayClicked, true);
2162 // Intercept clicks, so they don’t “fall through” the background overlay.
2163 Array.from(Appearance.themeTweakerUI.children).forEach(themeTweakerUIWindow => {
2164 themeTweakerUIWindow.addActivateEvent((event) => {
2165 event.stopPropagation();
2169 Appearance.themeTweakerUI.queryAll("input").forEach(field => {
2170 /* All input types in the theme tweaker receive a ‘change’ event
2171 when their value is changed. (Range inputs, in particular,
2172 receive this event when the user lets go of the handle.) This
2173 means we should update the filters for the entire page, to match
2176 field.addEventListener("change", Appearance.themeTweakerUIFieldValueChanged);
2178 /* Range inputs receive an ‘input’ event while being scrubbed,
2179 updating “live” as the handle is moved. We don’t want to change
2180 the filters for the actual page while this is happening, but we
2181 do want to change the filters for the *sample text*, so the user
2182 can see what effects his changes are having, live, without
2183 having to let go of the handle.
2185 if (field.type == "range")
2186 field.addEventListener("input", Appearance.themeTweakerUIFieldInputReceived);
2189 Appearance.themeTweakerUI.query(".help-button").addActivateEvent(Appearance.themeTweakerUIHelpButtonClicked);
2190 Appearance.themeTweakerUI.query(".minimize-button").addActivateEvent(Appearance.themeTweakerUIMinimizeButtonClicked);
2191 Appearance.themeTweakerUI.query(".close-button").addActivateEvent(Appearance.themeTweakerUICloseButtonClicked);
2192 Appearance.themeTweakerUI.query(".reset-defaults-button").addActivateEvent(Appearance.themeTweakerUIResetDefaultsButtonClicked);
2193 Appearance.themeTweakerUI.query(".main-window .cancel-button").addActivateEvent(Appearance.themeTweakerUICancelButtonClicked);
2194 Appearance.themeTweakerUI.query(".main-window .ok-button").addActivateEvent(Appearance.themeTweakerUIOKButtonClicked);
2195 Appearance.themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowCancelButtonClicked);
2196 Appearance.themeTweakerUI.query(".help-window .ok-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowOKButtonClicked);
2198 Appearance.themeTweakerUI.queryAll(".notch").forEach(notch => {
2199 notch.addActivateEvent(Appearance.themeTweakerUISliderNotchClicked);
2202 Appearance.themeTweakerUI.query(".clippy-close-button").addActivateEvent(Appearance.themeTweakerUIClippyCloseButtonClicked);
2204 insertHeadHTML(`<style id="theme-tweaker-style"></style>`);
2205 Appearance.themeTweakerStyleBlock = document.head.query("#theme-tweaker-style");
2207 Appearance.themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
2208 Appearance.themeTweakerUI.queryAll(".theme-selector > *:not(.select-theme)").forEach(element => {
2211 Appearance.themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
2212 button.addActivateEvent(Appearance.themeSelectButtonClicked);
2215 Appearance.themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
2216 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
2219 if (GW.isMobile == false)
2220 Appearance.injectThemeTweakerToggle();
2223 themeTweakerToggleHTML: () => {
2224 return (`<div id="theme-tweaker-toggle">`
2228 title="Customize appearance [;]"
2234 injectThemeTweakerToggle: () => {
2235 GWLog("Appearance.injectThemeTweakerToggle");
2238 if (Appearance.themeSelector == null)
2241 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", Appearance.themeTweakerToggleHTML());
2242 Appearance.themeTweakerToggle = Appearance.themeSelector.query("#theme-tweaker-toggle");
2244 Appearance.themeTweakerToggle = addUIElement(Appearance.themeTweakerToggleHTML());
2247 Appearance.themeTweakerToggle.query("button").addActivateEvent(Appearance.themeTweakerToggleClicked);
2250 showThemeTweakerUI: () => {
2251 GWLog("Appearance.showThemeTweakerUI");
2253 if (query("link[href^='/css/theme_tweaker.css']") == null) {
2254 // Theme tweaker CSS needs to be loaded.
2256 let themeTweakerStyleSheet = newElement("LINK", {
2257 "rel": "stylesheet",
2258 "href": "/css/theme_tweaker.css"
2261 themeTweakerStyleSheet.addEventListener("load", (event) => {
2262 requestAnimationFrame(() => {
2263 themeTweakerStyleSheet.disabled = false;
2265 Appearance.showThemeTweakerUI();
2268 document.head.appendChild(themeTweakerStyleSheet);
2273 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.getSavedTheme();
2275 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = (Appearance.currentFilters["invert"] == "100%");
2276 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2277 let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2278 slider.value = /^[0-9]+/.exec(Appearance.currentFilters[sliderName]) || slider.dataset["defaultValue"];
2279 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2282 Appearance.toggleThemeTweakerUI();
2285 toggleThemeTweakerUI: () => {
2286 GWLog("Appearance.toggleThemeTweakerUI");
2288 let show = (Appearance.themeTweakerUI.style.display == "none");
2290 Appearance.themeTweakerUI.style.display = show ? "block" : "none";
2291 Appearance.setThemeTweakerWindowMinimized(false);
2292 Appearance.themeTweakerStyleBlock.innerHTML = show ? `#content, #ui-elements-container > div:not(#theme-tweaker-ui) { pointer-events: none; user-select: none; }` : "";
2296 Appearance.themeTweakerToggle.query("button").disabled = true;
2297 // Focus invert checkbox.
2298 Appearance.themeTweakerUI.query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
2299 // Show sample text in appropriate font.
2300 Appearance.updateThemeTweakerSampleText();
2301 // Disable tab-selection of the search box.
2302 setSearchBoxTabSelectable(false);
2303 // Disable scrolling of the page.
2304 togglePageScrolling(false);
2306 // Re-enable button.
2307 Appearance.themeTweakerToggle.query("button").disabled = false;
2308 // Re-enable tab-selection of the search box.
2309 setSearchBoxTabSelectable(true);
2310 // Re-enable scrolling of the page.
2311 togglePageScrolling(true);
2314 // Set theme tweaker assistant visibility.
2315 Appearance.themeTweakerUIClippyContainer.style.display = (Appearance.getSavedThemeTweakerClippyState() == true) ? "block" : "none";
2318 setThemeTweakerWindowMinimized: (minimize) => {
2319 GWLog("Appearance.setThemeTweakerWindowMinimized");
2321 Appearance.themeTweakerUIMainWindow.query(".minimize-button").swapClasses([ "minimize", "maximize" ], (minimize ? 1 : 0));
2322 Appearance.themeTweakerUIMainWindow.classList.toggle("minimized", minimize);
2323 Appearance.themeTweakerUI.classList.toggle("main-window-minimized", minimize);
2326 toggleThemeTweakerHelpWindow: () => {
2327 GWLog("Appearance.toggleThemeTweakerHelpWindow");
2329 Appearance.themeTweakerUIHelpWindow.style.display = Appearance.themeTweakerUIHelpWindow.style.display == "none"
2332 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2333 // Focus theme tweaker assistant checkbox.
2334 Appearance.themeTweakerUI.query("#theme-tweak-control-clippy").focus();
2335 // Disable interaction on main theme tweaker window.
2336 Appearance.themeTweakerUI.style.pointerEvents = "none";
2337 Appearance.themeTweakerUIMainWindow.style.pointerEvents = "none";
2339 // Re-enable interaction on main theme tweaker window.
2340 Appearance.themeTweakerUI.style.pointerEvents = "auto";
2341 Appearance.themeTweakerUIMainWindow.style.pointerEvents = "auto";
2345 resetThemeTweakerUIDefaultState: () => {
2346 GWLog("Appearance.resetThemeTweakerUIDefaultState");
2348 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
2350 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2351 let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2352 slider.value = slider.dataset["defaultValue"];
2353 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2357 updateThemeTweakerSampleText: () => {
2358 GWLog("Appearance.updateThemeTweakerSampleText");
2360 let sampleText = Appearance.themeTweakerUISampleTextContainer.query("#theme-tweak-section-sample-text .sample-text");
2362 // This causes the sample text to take on the properties of the body text of a post.
2363 sampleText.removeClass("body-text");
2364 let bodyTextElement = query(".post-body") || query(".comment-body");
2365 sampleText.addClass("body-text");
2366 sampleText.style.color = bodyTextElement ?
2367 getComputedStyle(bodyTextElement).color :
2368 getComputedStyle(query("#content")).color;
2370 // Here we find out what is the actual background color that will be visible behind
2371 // the body text of posts, and set the sample text’s background to that.
2372 let findStyleBackground = (selector) => {
2373 return "#fff"; // FIXME
2375 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2376 if (rule.selectorText == selector)
2379 return x.style.backgroundColor;
2382 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2385 injectAppearanceAdjustUIToggle: () => {
2386 GWLog("Appearance.injectAppearanceAdjustUIToggle");
2388 Appearance.appearanceAdjustUIToggle = addUIElement(`<div id="appearance-adjust-ui-toggle"><button type="button" tabindex="-1"></button></div>`);
2389 Appearance.appearanceAdjustUIToggle.query("button").addActivateEvent(Appearance.appearanceAdjustUIToggleButtonClicked);
2392 && Appearance.getSavedAppearanceAdjustUIToggleState() == true) {
2393 Appearance.toggleAppearanceAdjustUI();
2397 removeAppearanceAdjustUIToggle: () => {
2398 GWLog("Appearance.removeAppearanceAdjustUIToggle");
2400 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2401 element.removeClass("engaged");
2403 removeElement("#appearance-adjust-ui-toggle");
2406 toggleAppearanceAdjustUI: () => {
2407 GWLog("Appearance.toggleAppearanceAdjustUI");
2409 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2410 element.toggleClass("engaged");
2414 clearTimeout(Appearance.themeSelectorInteractableTimer);
2415 Appearance.setThemeSelectorInteractable(false);
2416 Appearance.themeSelectorInteractableTimer = setTimeout(() => {
2417 Appearance.setThemeSelectorInteractable(true);
2422 /**************************************************************************/
2426 /* Theme selector close button (on mobile version of theme selector).
2428 themeSelectorCloseButtonClicked: (event) => {
2429 GWLog("Appearance.themeSelectorCloseButtonClicked");
2431 Appearance.toggleAppearanceAdjustUI();
2432 Appearance.saveAppearanceAdjustUIToggleState();
2435 /* “Cog” button (to toggle the appearance adjust UI widgets in “less”
2436 theme, or theme selector UI on mobile).
2438 appearanceAdjustUIToggleButtonClicked: (event) => {
2439 GWLog("Appearance.appearanceAdjustUIToggleButtonClicked");
2441 Appearance.toggleAppearanceAdjustUI();
2442 Appearance.saveAppearanceAdjustUIToggleState();
2445 /* Width adjust buttons (“normal”, “wide”, “fluid”).
2447 widthAdjustButtonClicked: (event) => {
2448 GWLog("Appearance.widthAdjustButtonClicked");
2450 // Determine which setting was chosen (i.e., which button was clicked).
2451 let selectedWidth = event.target.dataset.name;
2454 Appearance.currentWidth = selectedWidth;
2456 // Save the new setting.
2457 Appearance.saveCurrentWidth();
2459 // Save current visible comment
2460 let visibleComment = getCurrentVisibleComment();
2462 // Actually change the content width.
2463 Appearance.setContentWidth(selectedWidth);
2464 event.target.parentElement.childNodes.forEach(button => {
2465 button.removeClass("selected");
2466 button.disabled = false;
2468 event.target.addClass("selected");
2469 event.target.disabled = true;
2471 // Make sure the accesskey (to cycle to the next width) is on the right button.
2472 Appearance.setWidthAdjustButtonsAccesskey();
2474 // Regenerate images overlay.
2475 generateImagesOverlay();
2478 visibleComment.scrollIntoView();
2481 /* Theme selector buttons (“A” through “I”).
2483 themeSelectButtonClicked: (event) => {
2484 GWLog("Appearance.themeSelectButtonClicked");
2486 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
2487 let save = (Appearance.themeTweakerUI.contains(event.target) == false);
2488 Appearance.setTheme(themeName, save);
2490 Appearance.toggleAppearanceAdjustUI();
2493 /* The text size adjust (“-”, “A”, “+”) buttons.
2495 textSizeAdjustButtonClicked: (event) => {
2496 GWLog("Appearance.textSizeAdjustButtonClicked");
2498 var zoomFactor = Appearance.currentTextZoom;
2499 if (event.target.hasClass("decrease")) {
2501 } else if (event.target.hasClass("increase")) {
2504 zoomFactor = Appearance.defaultTextZoom;
2507 let save = ( Appearance.textSizeAdjustmentWidget != null
2508 && Appearance.textSizeAdjustmentWidget.contains(event.target));
2509 Appearance.setTextZoom(zoomFactor, save);
2512 /* Theme tweaker toggle button.
2514 themeTweakerToggleClicked: (event) => {
2515 GWLog("Appearance.themeTweakerToggleClicked");
2517 Appearance.showThemeTweakerUI();
2520 /***************************/
2521 /* Theme tweaker UI events.
2524 /* Key pressed while theme tweaker is open.
2526 themeTweakerUIKeyPressed: (event) => {
2527 GWLog("Appearance.themeTweakerUIKeyPressed");
2529 if (event.key == "Escape") {
2530 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2531 Appearance.toggleThemeTweakerHelpWindow();
2532 Appearance.themeTweakerResetSettings();
2533 } else if (Appearance.themeTweakerUI.style.display != "none") {
2534 Appearance.toggleThemeTweakerUI();
2535 Appearance.themeTweakReset();
2537 } else if (event.key == "Enter") {
2538 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2539 Appearance.toggleThemeTweakerHelpWindow();
2540 Appearance.themeTweakerSaveSettings();
2541 } else if (Appearance.themeTweakerUI.style.display != "none") {
2542 Appearance.toggleThemeTweakerUI();
2543 Appearance.themeTweakSave();
2548 /* Theme tweaker overlay clicked.
2550 themeTweakerUIOverlayClicked: (event) => {
2551 GWLog("Appearance.themeTweakerUIOverlayClicked");
2553 if (event.type == "mousedown") {
2554 Appearance.themeTweakerUI.style.opacity = "0.01";
2556 Appearance.toggleThemeTweakerUI();
2557 Appearance.themeTweakerUI.style.opacity = "1.0";
2558 Appearance.themeTweakReset();
2562 /* In the theme tweaker, a slider clicked, or released after drag; or a
2563 checkbox clicked (either in the main theme tweaker UI, or in the help
2566 themeTweakerUIFieldValueChanged: (event) => {
2567 GWLog("Appearance.themeTweakerUIFieldValueChanged");
2569 if (event.target.id == "theme-tweak-control-invert") {
2570 Appearance.currentFilters["invert"] = event.target.checked ? "100%" : "0%";
2571 } else if (event.target.type == "range") {
2572 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2573 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2574 Appearance.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2575 } else if (event.target.id == "theme-tweak-control-clippy") {
2576 Appearance.themeTweakerUIClippyContainer.style.display = event.target.checked ? "block" : "none";
2579 // Clear the sample text filters.
2580 Appearance.themeTweakerUISampleTextContainer.style.filter = "";
2582 // Apply the new filters globally.
2583 Appearance.applyFilters();
2586 /* Theme tweaker slider dragged (live-update event).
2588 themeTweakerUIFieldInputReceived: (event) => {
2589 GWLog("Appearance.themeTweakerUIFieldInputReceived");
2591 let sampleTextFilters = Appearance.currentFilters;
2592 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2593 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2594 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2596 Appearance.themeTweakerUISampleTextContainer.style.filter = Appearance.filterStringFromFilters(sampleTextFilters);
2599 /* Close button in main theme tweaker UI (title bar).
2601 themeTweakerUICloseButtonClicked: (event) => {
2602 GWLog("Appearance.themeTweakerUICloseButtonClicked");
2604 Appearance.toggleThemeTweakerUI();
2605 Appearance.themeTweakReset();
2608 /* Minimize button in main theme tweaker UI (title bar).
2610 themeTweakerUIMinimizeButtonClicked: (event) => {
2611 GWLog("Appearance.themeTweakerUIMinimizeButtonClicked");
2613 Appearance.setThemeTweakerWindowMinimized(event.target.hasClass("minimize"));
2616 /* Help (“?”) button in main theme tweaker UI (title bar).
2618 themeTweakerUIHelpButtonClicked: (event) => {
2619 GWLog("Appearance.themeTweakerUIHelpButtonClicked");
2621 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
2622 Appearance.toggleThemeTweakerHelpWindow();
2625 /* “Reset Defaults” button in main theme tweaker UI.
2627 themeTweakerUIResetDefaultsButtonClicked: (event) => {
2628 GWLog("Appearance.themeTweakerUIResetDefaultsButtonClicked");
2630 Appearance.themeTweakResetDefaults();
2631 Appearance.resetThemeTweakerUIDefaultState();
2634 /* “Cancel” button in main theme tweaker UI.
2636 themeTweakerUICancelButtonClicked: (event) => {
2637 GWLog("Appearance.themeTweakerUICancelButtonClicked");
2639 Appearance.toggleThemeTweakerUI();
2640 Appearance.themeTweakReset();
2643 /* “OK” button in main theme tweaker UI.
2645 themeTweakerUIOKButtonClicked: (event) => {
2646 GWLog("Appearance.themeTweakerUIOKButtonClicked");
2648 Appearance.toggleThemeTweakerUI();
2649 Appearance.themeTweakSave();
2652 /* “Cancel” button in theme tweaker help window.
2654 themeTweakerUIHelpWindowCancelButtonClicked: (event) => {
2655 GWLog("Appearance.themeTweakerUIHelpWindowCancelButtonClicked");
2657 Appearance.toggleThemeTweakerHelpWindow();
2658 Appearance.themeTweakerResetSettings();
2661 /* “OK” button in theme tweaker help window.
2663 themeTweakerUIHelpWindowOKButtonClicked: (event) => {
2664 GWLog("Appearance.themeTweakerUIHelpWindowOKButtonClicked");
2666 Appearance.toggleThemeTweakerHelpWindow();
2667 Appearance.themeTweakerSaveSettings();
2670 /* The notch in the theme tweaker sliders (to reset the slider to its
2673 themeTweakerUISliderNotchClicked: (event) => {
2674 GWLog("Appearance.themeTweakerUISliderNotchClicked");
2676 let slider = event.target.parentElement.query("input[type='range']");
2677 slider.value = slider.dataset["defaultValue"];
2678 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset["labelSuffix"];
2679 Appearance.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset["valueSuffix"];
2680 Appearance.applyFilters();
2683 /* The close button in the “Bobby the Basilisk” help message.
2685 themeTweakerUIClippyCloseButtonClicked: (event) => {
2686 GWLog("Appearance.themeTweakerUIClippyCloseButtonClicked");
2688 Appearance.themeTweakerUIClippyContainer.style.display = "none";
2689 Appearance.themeTweakerUIClippyControl.checked = false;
2690 Appearance.saveThemeTweakerClippyState();
2694 function setSearchBoxTabSelectable(selectable) {
2695 GWLog("setSearchBoxTabSelectable");
2696 query("input[type='search']").tabIndex = selectable ? "" : "-1";
2697 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2700 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
2701 // otherwise, show it.
2702 function updatePostNavUIVisibility() {
2703 GWLog("updatePostNavUIVisibility");
2704 var hidePostNavUIToggle = true;
2705 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
2706 if (getComputedStyle(element).visibility == "visible" ||
2707 element.style.visibility == "visible" ||
2708 element.style.visibility == "unset")
2709 hidePostNavUIToggle = false;
2711 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
2712 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
2716 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
2717 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
2718 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
2719 function updateSiteNavUIState(event) {
2720 GWLog("updateSiteNavUIState");
2721 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
2722 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
2723 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
2725 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
2726 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
2728 GW.scrollState.lastScrollTop = newScrollTop;
2730 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
2731 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
2732 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
2733 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged"))
2734 Appearance.toggleAppearanceAdjustUI();
2737 // On mobile, make site nav UI translucent on ANY scroll down.
2739 GW.scrollState.siteNavUIElements.forEach(element => {
2740 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
2741 else element.removeClass("translucent-on-scroll");
2744 // Show site nav UI when scrolling a full page up, or to the top.
2745 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
2746 GW.scrollState.lastScrollTop == 0) &&
2747 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
2748 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
2750 // On desktop, show appearance adjust UI when scrolling to the top.
2751 if ((!GW.isMobile) &&
2752 (GW.scrollState.lastScrollTop == 0) &&
2753 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
2754 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false"))
2755 Appearance.toggleAppearanceAdjustUI();
2758 /*********************/
2759 /* PAGE QUICK-NAV UI */
2760 /*********************/
2762 function injectQuickNavUI() {
2763 GWLog("injectQuickNavUI");
2764 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2765 `<a href='#top' title="Up to top [,]" accesskey=','></a>
2766 <a href='#comments' title="Comments [/]" accesskey='/'></a>
2767 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
2771 /**********************/
2772 /* NEW COMMENT NAV UI */
2773 /**********************/
2775 function injectNewCommentNavUI(newCommentsCount) {
2776 GWLog("injectNewCommentNavUI");
2777 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
2778 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
2779 <span class='new-comments-count'></span>
2780 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
2783 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2784 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2785 GWLog("GW.commentQuicknavButtonClicked");
2786 scrollToNewComment(/next/.test(event.target.className));
2787 event.target.blur();
2791 document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => {
2792 GWLog("GW.commentQuicknavKeyPressed");
2793 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2794 if (event.key == ",") scrollToNewComment(false);
2795 if (event.key == ".") scrollToNewComment(true)
2798 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2799 + `<span>Since:</span>`
2800 + `<input type='text' class='hns-date'></input>`
2803 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2804 GWLog("GW.hnsDatePickerValueChanged");
2805 let hnsDate = time_fromHuman(event.target.value);
2807 setHistoryLastVisitedDate(hnsDate);
2808 let newCommentsCount = highlightCommentsSince(hnsDate);
2809 updateNewCommentNavUI(newCommentsCount);
2813 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2814 GWLog("GW.newCommentsCountClicked");
2815 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2816 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2820 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2821 function time_fromHuman(string) {
2822 /* Convert a human-readable date into a JS timestamp */
2823 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2824 string = string.replace(' ', 'T'); // revert nice spacing
2825 string += ':00.000Z'; // complete ISO 8601 date
2826 time = Date.parse(string); // milliseconds since epoch
2828 // browsers handle ISO 8601 without explicit timezone differently
2829 // thus, we have to fix that by hand
2830 time += (new Date()).getTimezoneOffset() * 60e3;
2832 string = string.replace(' at', '');
2833 time = Date.parse(string); // milliseconds since epoch
2838 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2839 GWLog("updateNewCommentNavUI");
2840 // Update the new comments count.
2841 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2842 newCommentsCountLabel.innerText = newCommentsCount;
2843 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2845 // Update the date picker field.
2846 if (hnsDate != -1) {
2847 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2851 /********************************/
2852 /* COMMENTS VIEW MODE SELECTION */
2853 /********************************/
2855 function injectCommentsViewModeSelector() {
2856 GWLog("injectCommentsViewModeSelector");
2857 let commentsContainer = query("#comments");
2858 if (commentsContainer == null) return;
2860 let currentModeThreaded = (location.href.search("chrono=t") == -1);
2861 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2863 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2864 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
2865 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
2868 // commentsViewModeSelector.queryAll("a").forEach(button => {
2869 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2872 if (!currentModeThreaded) {
2873 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2874 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2875 commentParentLink.addClass("inline-author");
2876 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2879 queryAll(".comment-child-links a").forEach(commentChildLink => {
2880 commentChildLink.textContent = commentChildLink.textContent.slice(1);
2881 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2884 rectifyChronoModeCommentChildLinks();
2886 commentsContainer.addClass("chrono");
2888 commentsContainer.addClass("threaded");
2891 // Remove extraneous top-level comment thread in chrono mode.
2892 let topLevelCommentThread = query("#comments > .comment-thread");
2893 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2896 // function commentsViewModeSelectorButtonClicked(event) {
2897 // event.preventDefault();
2900 // let request = new XMLHttpRequest();
2901 // request.open("GET", event.target.href);
2902 // request.onreadystatechange = () => {
2903 // if (request.readyState != 4) return;
2904 // newDocument = htmlToElement(request.response);
2906 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2908 // // Update the buttons.
2909 // event.target.addClass("selected");
2910 // event.target.parentElement.query("." + classes.old).removeClass("selected");
2912 // // Update the #comments container.
2913 // let commentsContainer = query("#comments");
2914 // commentsContainer.removeClass(classes.old);
2915 // commentsContainer.addClass(classes.new);
2917 // // Update the content.
2918 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2923 // function htmlToElement(html) {
2924 // let template = newElement("TEMPLATE", { }, { "innerHTML": html.trim() });
2925 // return template.content;
2928 function rectifyChronoModeCommentChildLinks() {
2929 GWLog("rectifyChronoModeCommentChildLinks");
2930 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2931 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2932 let childLinks = commentChildLinksContainer.queryAll("a");
2933 childLinks.forEach((link, index) => {
2934 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2938 let childLinksArray = Array.from(childLinks)
2939 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2940 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2943 function childrenOfComment(commentID) {
2944 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2945 let commentParentLink = commentItem.query("a.comment-parent-link");
2946 return ((commentParentLink||{}).hash == "#" + commentID);
2950 /********************************/
2951 /* COMMENTS LIST MODE SELECTION */
2952 /********************************/
2954 function injectCommentsListModeSelector() {
2955 GWLog("injectCommentsListModeSelector");
2956 if (query("#content > .comment-thread") == null) return;
2958 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2959 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2960 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2963 if (query(".sublevel-nav") || query("#top-nav-bar")) {
2964 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2966 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2968 let commentsListModeSelector = query("#comments-list-mode-selector");
2970 commentsListModeSelector.queryAll("button").forEach(button => {
2971 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2972 GWLog("GW.commentsListModeSelectButtonClicked");
2973 event.target.parentElement.queryAll("button").forEach(button => {
2974 button.removeClass("selected");
2975 button.disabled = false;
2976 button.accessKey = '`';
2978 localStorage.setItem("comments-list-mode", event.target.className);
2979 event.target.addClass("selected");
2980 event.target.disabled = true;
2981 event.target.removeAttribute("accesskey");
2983 if (event.target.hasClass("expanded")) {
2984 query("#content").removeClass("compact");
2986 query("#content").addClass("compact");
2991 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2992 if (savedMode == "compact")
2993 query("#content").addClass("compact");
2994 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2995 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2996 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2999 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
3000 commentParentLink.addActivateEvent(function (event) {
3001 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
3002 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
3008 /**********************/
3009 /* SITE NAV UI TOGGLE */
3010 /**********************/
3012 function injectSiteNavUIToggle() {
3013 GWLog("injectSiteNavUIToggle");
3014 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
3015 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
3016 GWLog("GW.siteNavUIToggleButtonClicked");
3018 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
3021 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
3023 function removeSiteNavUIToggle() {
3024 GWLog("removeSiteNavUIToggle");
3025 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3026 element.removeClass("engaged");
3028 removeElement("#site-nav-ui-toggle");
3030 function toggleSiteNavUI() {
3031 GWLog("toggleSiteNavUI");
3032 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3033 element.toggleClass("engaged");
3034 element.removeClass("translucent-on-scroll");
3038 /**********************/
3039 /* POST NAV UI TOGGLE */
3040 /**********************/
3042 function injectPostNavUIToggle() {
3043 GWLog("injectPostNavUIToggle");
3044 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
3045 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
3046 GWLog("GW.postNavUIToggleButtonClicked");
3048 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
3051 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
3053 function removePostNavUIToggle() {
3054 GWLog("removePostNavUIToggle");
3055 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3056 element.removeClass("engaged");
3058 removeElement("#post-nav-ui-toggle");
3060 function togglePostNavUI() {
3061 GWLog("togglePostNavUI");
3062 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3063 element.toggleClass("engaged");
3067 /**************************/
3068 /* WORD COUNT & READ TIME */
3069 /**************************/
3071 function toggleReadTimeOrWordCount(addWordCountClass) {
3072 GWLog("toggleReadTimeOrWordCount");
3073 queryAll(".post-meta .read-time").forEach(element => {
3074 if (addWordCountClass) element.addClass("word-count");
3075 else element.removeClass("word-count");
3077 let titleParts = /(\S+)(.+)$/.exec(element.title);
3078 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
3082 /**************************/
3083 /* PROMPT TO SAVE CHANGES */
3084 /**************************/
3086 function enableBeforeUnload() {
3087 window.onbeforeunload = function () { return true; };
3089 function disableBeforeUnload() {
3090 window.onbeforeunload = null;
3093 /***************************/
3094 /* ORIGINAL POSTER BADGING */
3095 /***************************/
3097 function markOriginalPosterComments() {
3098 GWLog("markOriginalPosterComments");
3099 let postAuthor = query(".post .author");
3100 if (postAuthor == null) return;
3102 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
3103 if (author.dataset.userid == postAuthor.dataset.userid ||
3104 (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
3105 author.addClass("original-poster");
3106 author.title += "Original poster";
3111 /********************************/
3112 /* EDIT POST PAGE SUBMIT BUTTON */
3113 /********************************/
3115 function setEditPostPageSubmitButtonText() {
3116 GWLog("setEditPostPageSubmitButtonText");
3117 if (!query("#content").hasClass("edit-post-page")) return;
3119 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
3120 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
3121 GWLog("GW.postSectionSelectorValueChanged");
3122 updateEditPostPageSubmitButtonText();
3126 updateEditPostPageSubmitButtonText();
3128 function updateEditPostPageSubmitButtonText() {
3129 GWLog("updateEditPostPageSubmitButtonText");
3130 let submitButton = query("input[type='submit']");
3131 if (query("input#drafts").checked == true)
3132 submitButton.value = "Save Draft";
3133 else if (query(".posting-controls").hasClass("edit-existing-post"))
3134 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
3136 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
3143 function numToAlpha(n) {
3146 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
3147 n = Math.floor((n / 26) - 1);
3152 function activateAntiKibitzer() {
3153 GWLog("activateAntiKibitzer");
3155 // Activate anti-kibitzer mode (if needed).
3156 if (localStorage.getItem("antikibitzer") == "true")
3157 toggleAntiKibitzerMode();
3159 // Remove temporary CSS that hides the authors and karma values.
3160 removeElement("#antikibitzer-temp");
3162 // Inject controls (if desktop).
3163 if (GW.isMobile == false)
3164 injectAntiKibitzerToggle();
3167 function injectAntiKibitzerToggle() {
3168 GWLog("injectAntiKibitzerToggle");
3170 let antiKibitzerHTML = `<div id="anti-kibitzer-toggle">
3171 <button type="button" tabindex="-1" accesskey="g" title="Toggle anti-kibitzer (show/hide authors & karma values) [g]"></button>
3175 if (Appearance.themeSelector == null)
3178 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", antiKibitzerHTML);
3180 addUIElement(antiKibitzerHTML);
3183 // Activate anti-kibitzer toggle button.
3184 query("#anti-kibitzer-toggle button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
3185 GWLog("GW.antiKibitzerToggleButtonClicked");
3186 if ( query("#anti-kibitzer-toggle").hasClass("engaged")
3188 && !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!)")) {
3189 event.target.blur();
3193 toggleAntiKibitzerMode();
3194 event.target.blur();
3198 function toggleAntiKibitzerMode() {
3199 GWLog("toggleAntiKibitzerMode");
3200 // This will be the URL of the user's own page, if logged in, or the URL of
3201 // the login page otherwise.
3202 let userTabTarget = query("#nav-item-login .nav-inner").href;
3203 let pageHeadingElement = query("h1.page-main-heading");
3206 let userFakeName = { };
3208 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
3210 let postAuthor = query(".post-page .post-meta .author");
3211 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
3213 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
3214 if (antiKibitzerToggle.hasClass("engaged")) {
3215 localStorage.setItem("antikibitzer", "false");
3217 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
3218 if (redirectTarget) {
3219 window.location = redirectTarget;
3223 // Individual comment page title and header
3224 if (query(".individual-thread-page")) {
3225 let replacer = (node) => {
3227 node.firstChild.replaceWith(node.dataset["trueContent"]);
3229 replacer(query("title:not(.fake-title)"));
3230 replacer(query("#content > h1"));
3233 // Author names/links.
3234 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
3235 author.textContent = author.dataset["trueName"];
3236 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
3238 author.removeClass("redacted");
3240 // Post/comment karma values.
3241 queryAll(".karma-value.redacted").forEach(karmaValue => {
3242 karmaValue.innerHTML = karmaValue.dataset["trueValue"];
3244 karmaValue.removeClass("redacted");
3246 // Link post domains.
3247 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
3248 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
3250 linkPostDomain.removeClass("redacted");
3253 antiKibitzerToggle.removeClass("engaged");
3255 localStorage.setItem("antikibitzer", "true");
3257 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
3258 if (redirectTarget) {
3259 window.location = redirectTarget;
3263 // Individual comment page title and header
3264 if (query(".individual-thread-page")) {
3265 let replacer = (node) => {
3267 node.dataset["trueContent"] = node.firstChild.wholeText;
3268 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
3269 node.firstChild.replaceWith(newText);
3271 replacer(query("title:not(.fake-title)"));
3272 replacer(query("#content > h1"));
3275 removeElement("title.fake-title");
3277 // Author names/links.
3278 queryAll(".author, .inline-author").forEach(author => {
3279 // Skip own posts/comments.
3280 if (author.hasClass("own-user-author"))
3283 let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
3287 author.dataset["trueName"] = author.textContent;
3288 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
3290 if (/\/user/.test(author.href)) {
3291 author.dataset["trueLink"] = author.pathname;
3292 author.href = "/user?id=" + author.dataset["userid"];
3295 author.addClass("redacted");
3297 // Post/comment karma values.
3298 queryAll(".karma-value").forEach(karmaValue => {
3299 // Skip own posts/comments.
3300 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
3303 karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
3304 karmaValue.innerHTML = "##<span> points</span>";
3306 karmaValue.addClass("redacted");
3308 // Link post domains.
3309 queryAll(".link-post-domain").forEach(linkPostDomain => {
3310 // Skip own posts/comments.
3311 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
3314 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
3315 linkPostDomain.textContent = "redacted.domain.tld";
3317 linkPostDomain.addClass("redacted");
3320 antiKibitzerToggle.addClass("engaged");
3324 /*******************************/
3325 /* COMMENT SORT MODE SELECTION */
3326 /*******************************/
3328 var CommentSortMode = Object.freeze({
3334 function sortComments(mode) {
3335 GWLog("sortComments");
3336 let commentsContainer = query("#comments");
3338 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
3339 commentsContainer.addClass("sorting");
3341 GW.commentValues = { };
3342 let clonedCommentsContainer = commentsContainer.cloneNode(true);
3343 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
3346 case CommentSortMode.NEW:
3347 comparator = (a,b) => commentDate(b) - commentDate(a);
3349 case CommentSortMode.OLD:
3350 comparator = (a,b) => commentDate(a) - commentDate(b);
3352 case CommentSortMode.HOT:
3353 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
3355 case CommentSortMode.TOP:
3357 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
3360 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
3362 removeElement(commentsContainer.lastChild);
3363 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
3364 GW.commentValues = { };
3366 if (loggedInUserId) {
3367 // Re-activate vote buttons.
3368 commentsContainer.queryAll("button.vote").forEach(voteButton => {
3369 voteButton.addActivateEvent(voteButtonClicked);
3372 // Re-activate comment action buttons.
3373 commentsContainer.queryAll(".action-button").forEach(button => {
3374 button.addActivateEvent(GW.commentActionButtonClicked);
3378 // Re-activate comment-minimize buttons.
3379 queryAll(".comment-minimize-button").forEach(button => {
3380 button.addActivateEvent(GW.commentMinimizeButtonClicked);
3383 // Re-add comment parent popups.
3384 addCommentParentPopups();
3386 // Redo new-comments highlighting.
3387 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
3389 requestAnimationFrame(() => {
3390 commentsContainer.removeClass("sorting");
3391 commentsContainer.addClass("sorted-" + mode);
3394 function commentKarmaValue(commentOrSelector) {
3395 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3397 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
3398 } catch(e) {return null};
3400 function commentDate(commentOrSelector) {
3401 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3403 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
3404 } catch(e) {return null};
3406 function commentVoteCount(commentOrSelector) {
3407 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3409 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
3410 } catch(e) {return null};
3413 function injectCommentsSortModeSelector() {
3414 GWLog("injectCommentsSortModeSelector");
3415 let topCommentThread = query("#comments > .comment-thread");
3416 if (topCommentThread == null) return;
3418 // Do not show sort mode selector if there is no branching in comment tree.
3419 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
3421 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
3422 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
3424 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
3425 let commentsSortModeSelector = query("#comments-sort-mode-selector");
3427 commentsSortModeSelector.queryAll("button").forEach(button => {
3428 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
3429 GWLog("GW.commentsSortModeSelectButtonClicked");
3430 event.target.parentElement.queryAll("button").forEach(button => {
3431 button.removeClass("selected");
3432 button.disabled = false;
3434 event.target.addClass("selected");
3435 event.target.disabled = true;
3437 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
3438 setCommentsSortModeSelectButtonsAccesskey();
3442 // TODO: Make this actually get the current sort mode (if that's saved).
3443 // TODO: Also change the condition here to properly get chrono/threaded mode,
3444 // when that is properly done with cookies.
3445 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
3446 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
3447 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
3448 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
3449 setCommentsSortModeSelectButtonsAccesskey();
3452 function setCommentsSortModeSelectButtonsAccesskey() {
3453 GWLog("setCommentsSortModeSelectButtonsAccesskey");
3454 queryAll("#comments-sort-mode-selector button").forEach(button => {
3455 button.removeAttribute("accesskey");
3456 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
3458 let selectedButton = query("#comments-sort-mode-selector button.selected");
3459 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
3460 nextButtonInCycle.accessKey = "z";
3461 nextButtonInCycle.title += " [z]";
3464 /*************************/
3465 /* COMMENT PARENT POPUPS */
3466 /*************************/
3468 function previewPopupsEnabled() {
3469 let isDisabled = localStorage.getItem("preview-popups-disabled");
3470 return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
3473 function setPreviewPopupsEnabled(state) {
3474 localStorage.setItem("preview-popups-disabled", !state);
3475 updatePreviewPopupToggle();
3478 function updatePreviewPopupToggle() {
3479 let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
3480 query("#preview-popup-toggle").setAttribute("style", style);
3483 function injectPreviewPopupToggle() {
3484 GWLog("injectPreviewPopupToggle");
3486 let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
3487 // This is required because Chrome can't use filters on an externally used SVG element.
3488 fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
3489 updatePreviewPopupToggle();
3490 toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
3493 var currentPreviewPopup = { };
3495 function removePreviewPopup(previewPopup) {
3496 if(previewPopup.element)
3497 removeElement(previewPopup.element);
3499 if(previewPopup.timeout)
3500 clearTimeout(previewPopup.timeout);
3502 if(currentPreviewPopup.pointerListener)
3503 window.removeEventListener("pointermove", previewPopup.pointerListener);
3505 if(currentPreviewPopup.mouseoutListener)
3506 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3508 if(currentPreviewPopup.scrollListener)
3509 window.removeEventListener("scroll", previewPopup.scrollListener);
3511 currentPreviewPopup = { };
3514 document.addEventListener("visibilitychange", () => {
3515 if(document.visibilityState != "visible") {
3516 removePreviewPopup(currentPreviewPopup);
3520 function addCommentParentPopups() {
3521 GWLog("addCommentParentPopups");
3522 //if (!query("#content").hasClass("comment-thread-page")) return;
3524 queryAll("a[href]").forEach(linkTag => {
3525 let linkHref = linkTag.getAttribute("href");
3528 try { url = new URL(linkHref, window.location.href); }
3532 if(GW.sites[url.host]) {
3533 let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
3535 if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
3536 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
3537 if(event.pointerType == "touch") return;
3538 GWLog("GW.commentParentLinkMouseOver");
3539 removePreviewPopup(currentPreviewPopup);
3540 let parentID = linkHref;
3542 if (!(parent = (query(parentID)||{}).firstChild)) return;
3543 var highlightClassName;
3544 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
3545 parentHighlightClassName = "comment-item-highlight-faint";
3546 popup = parent.cloneNode(true);
3547 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
3548 linkTag.addEventListener("mouseout", (event) => {
3549 removeElement(popup);
3551 linkTag.closest(".comments > .comment-thread").appendChild(popup);
3553 parentHighlightClassName = "comment-item-highlight";
3555 parent.parentNode.addClass(parentHighlightClassName);
3556 linkTag.addEventListener("mouseout", (event) => {
3557 parent.parentNode.removeClass(parentHighlightClassName);
3561 else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
3562 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
3563 && !(url.searchParams.get('format'))
3564 && !linkTag.closest("nav:not(.post-nav-links)")
3565 && (!url.hash || linkCommentId)
3566 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
3567 linkTag.addEventListener("pointerover", event => {
3568 if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
3569 if(currentPreviewPopup.linkTag) return;
3570 linkTag.createPreviewPopup();
3572 linkTag.createPreviewPopup = function() {
3573 removePreviewPopup(currentPreviewPopup);
3575 currentPreviewPopup = {linkTag: linkTag};
3577 let popup = newElement("IFRAME");
3578 currentPreviewPopup.element = popup;
3580 let popupTarget = linkHref;
3581 if(popupTarget.match(/#comment-/)) {
3582 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
3584 // 'theme' attribute is required for proper caching
3585 popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview");
3586 popup.addClass("preview-popup");
3588 let linkRect = linkTag.getBoundingClientRect();
3590 if(linkRect.right + 710 < window.innerWidth)
3591 popup.style.left = linkRect.right + 10 + "px";
3593 popup.style.right = "10px";
3595 popup.style.width = "700px";
3596 popup.style.height = "500px";
3597 popup.style.visibility = "hidden";
3598 popup.style.transition = "none";
3600 let recenter = function() {
3601 let popupHeight = 500;
3602 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
3603 let popupContent = popup.contentDocument.querySelector("#content");
3605 popupHeight = popupContent.clientHeight + 2;
3606 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
3607 popup.style.height = popupHeight + "px";
3610 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
3615 query('#content').insertAdjacentElement("beforeend", popup);
3617 let clickListener = event => {
3618 if(!event.target.closest("a, input, label")
3619 && !event.target.closest("popup-hide-button")) {
3620 window.location = linkHref;
3624 popup.addEventListener("load", () => {
3625 let hideButton = newElement("DIV", {
3626 "class": "popup-hide-button"
3628 "innerHTML": ""
3630 hideButton.onclick = (event) => {
3631 removePreviewPopup(currentPreviewPopup);
3632 setPreviewPopupsEnabled(false);
3633 event.stopPropagation();
3635 popup.contentDocument.body.appendChild(hideButton);
3637 let popupBody = popup.contentDocument.body;
3638 popupBody.addEventListener("click", clickListener);
3639 popupBody.style.cursor = "pointer";
3644 popup.contentDocument.body.addEventListener("click", clickListener);
3646 currentPreviewPopup.timeout = setTimeout(() => {
3649 requestIdleCallback(() => {
3650 if(currentPreviewPopup.element === popup) {
3651 popup.scrolling = "";
3652 popup.style.visibility = "unset";
3653 popup.style.transition = null;
3656 { opacity: 0, transform: "translateY(10%)" },
3657 { opacity: 1, transform: "none" }
3658 ], { duration: 150, easing: "ease-out" });
3663 let pointerX, pointerY, mousePauseTimeout = null;
3665 currentPreviewPopup.pointerListener = (event) => {
3666 pointerX = event.clientX;
3667 pointerY = event.clientY;
3669 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3670 mousePauseTimeout = null;
3672 let overElement = document.elementFromPoint(pointerX, pointerY);
3673 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3675 if(mouseIsOverLink || overElement === popup
3676 || (pointerX < popup.getBoundingClientRect().left
3677 && event.movementX >= 0)) {
3678 if(!mouseIsOverLink && overElement !== popup) {
3679 if(overElement['createPreviewPopup']) {
3680 mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3682 mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3686 removePreviewPopup(currentPreviewPopup);
3687 if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3690 window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3692 currentPreviewPopup.mouseoutListener = (event) => {
3693 clearTimeout(mousePauseTimeout);
3694 mousePauseTimeout = null;
3696 document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3698 currentPreviewPopup.scrollListener = (event) => {
3699 let overElement = document.elementFromPoint(pointerX, pointerY);
3700 linkRect = linkTag.getBoundingClientRect();
3701 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3702 removePreviewPopup(currentPreviewPopup);
3704 window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3709 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3713 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3714 Appearance.filtersExclusionPaths.commentParentPopups = [
3715 "#content .comments .comment-thread"
3717 Appearance.applyFilters();
3724 function imageFocusSetup(imagesOverlayOnly = false) {
3725 if (typeof GW.imageFocus == "undefined")
3727 contentImagesSelector: "#content img",
3728 overlayImagesSelector: "#images-overlay img",
3729 focusedImageSelector: "#content img.focused, #images-overlay img.focused",
3730 pageContentSelector: "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3732 hideUITimerDuration: 1500,
3733 hideUITimerExpired: () => {
3734 GWLog("GW.imageFocus.hideUITimerExpired");
3735 let currentTime = new Date();
3736 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3737 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3738 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3741 cancelImageFocusHideUITimer();
3746 GWLog("imageFocusSetup");
3747 // Create event listener for clicking on images to focus them.
3748 GW.imageClickedToFocus = (event) => {
3749 GWLog("GW.imageClickedToFocus");
3750 focusImage(event.target);
3753 // Set timer to hide the image focus UI.
3754 unhideImageFocusUI();
3755 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3758 // Add the listener to each image in the overlay (i.e., those in the post).
3759 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3760 image.addActivateEvent(GW.imageClickedToFocus);
3762 // Accesskey-L starts the slideshow.
3763 (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3764 // Count how many images there are in the post, and set the "… of X" label to that.
3765 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3766 if (imagesOverlayOnly) return;
3767 // Add the listener to all other content images (including those in comments).
3768 queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3769 image.addActivateEvent(GW.imageClickedToFocus);
3772 // Create the image focus overlay.
3773 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
3774 `<div class='help-overlay'>
3775 <p><strong>Arrow keys:</strong> Next/previous image</p>
3776 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3777 <p><strong>Space bar:</strong> Reset image size & position</p>
3778 <p><strong>Scroll</strong> to zoom in/out</p>
3779 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3781 <div class='image-number'></div>
3782 <div class='slideshow-buttons'>
3783 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
3784 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
3786 <div class='caption'></div>` +
3788 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3790 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3791 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3792 GWLog("GW.imageFocus.slideshowButtonClicked");
3793 focusNextImage(event.target.hasClass("next"));
3794 event.target.blur();
3798 // On orientation change, reset the size & position.
3799 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3800 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3803 // UI starts out hidden.
3807 function focusImage(imageToFocus) {
3808 GWLog("focusImage");
3809 // Clear 'last-focused' class of last focused image.
3810 let lastFocusedImage = query("img.last-focused");
3811 if (lastFocusedImage) {
3812 lastFocusedImage.removeClass("last-focused");
3813 lastFocusedImage.removeAttribute("accesskey");
3816 // Create the focused version of the image.
3817 imageToFocus.addClass("focused");
3818 let imageFocusOverlay = query("#image-focus-overlay");
3819 let clonedImage = imageToFocus.cloneNode(true);
3820 clonedImage.style = "";
3821 clonedImage.removeAttribute("width");
3822 clonedImage.removeAttribute("height");
3823 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3824 imageFocusOverlay.appendChild(clonedImage);
3825 imageFocusOverlay.addClass("engaged");
3827 // Set image to default size and position.
3828 resetFocusedImagePosition();
3830 // Blur everything else.
3831 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3832 element.addClass("blurred");
3835 // Add listener to zoom image with scroll wheel.
3836 window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3837 GWLog("GW.imageFocus.scrollEvent");
3838 event.preventDefault();
3840 let image = query("#image-focus-overlay img");
3842 // Remove the filter.
3843 image.savedFilter = image.style.filter;
3844 image.style.filter = 'none';
3846 // Locate point under cursor.
3847 let imageBoundingBox = image.getBoundingClientRect();
3849 // Calculate resize factor.
3850 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3851 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3855 image.style.width = (event.deltaY < 0 ?
3856 (image.clientWidth * factor) :
3857 (image.clientWidth / factor))
3859 image.style.height = "";
3861 // Designate zoom origin.
3863 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3864 // the cursor is over the image.
3865 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3866 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3867 (imageBoundingBox.left <= event.clientX &&
3868 event.clientX <= imageBoundingBox.right &&
3869 imageBoundingBox.top <= event.clientY &&
3870 event.clientY <= imageBoundingBox.bottom);
3871 // Otherwise, if we're zooming OUT, zoom from window center; if we're
3872 // zooming IN, zoom from image center.
3873 let zoomingFromWindowCenter = event.deltaY > 0;
3874 if (zoomingFromCursor)
3875 zoomOrigin = { x: event.clientX,
3877 else if (zoomingFromWindowCenter)
3878 zoomOrigin = { x: window.innerWidth / 2,
3879 y: window.innerHeight / 2 };
3881 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
3882 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3884 // Calculate offset from zoom origin.
3885 let offsetOfImageFromZoomOrigin = {
3886 x: imageBoundingBox.x - zoomOrigin.x,
3887 y: imageBoundingBox.y - zoomOrigin.y
3889 // Calculate delta from centered zoom.
3890 let deltaFromCenteredZoom = {
3891 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3892 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3894 // Adjust image position appropriately.
3895 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3896 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3897 // Gradually re-center image, if it's smaller than the window.
3898 if (!imageSizeExceedsWindowBounds) {
3899 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
3900 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3901 let windowCenter = { x: window.innerWidth / 2,
3902 y: window.innerHeight / 2 }
3903 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3904 y: windowCenter.y - imageCenter.y }
3905 // Divide the offset by 10 because we're nudging the image toward center,
3906 // not jumping it there.
3907 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3908 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3911 // Put the filter back.
3912 image.style.filter = image.savedFilter;
3914 // Set the cursor appropriately.
3915 setFocusedImageCursor();
3917 window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3918 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3919 event.preventDefault();
3922 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3923 window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3924 GWLog("GW.imageFocus.mouseUp");
3925 window.onmousemove = '';
3927 // We only want to do anything on left-clicks.
3928 if (event.button != 0) return;
3930 // Don't unfocus if click was on a slideshow next/prev button!
3931 if (event.target.hasClass("slideshow-button")) return;
3933 // We also don't want to do anything if clicked on the help overlay.
3934 if (event.target.classList.contains("help-overlay") ||
3935 event.target.closest(".help-overlay"))
3938 let focusedImage = query("#image-focus-overlay img");
3939 if (event.target == focusedImage &&
3940 (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3941 // If the mouseup event was the end of a pan of an overside image,
3942 // put the filter back; do not unfocus.
3943 focusedImage.style.filter = focusedImage.savedFilter;
3945 unfocusImageOverlay();
3949 window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3950 GWLog("GW.imageFocus.mouseDown");
3951 event.preventDefault();
3953 let focusedImage = query("#image-focus-overlay img");
3954 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3955 let mouseCoordX = event.clientX;
3956 let mouseCoordY = event.clientY;
3958 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3959 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3962 focusedImage.savedFilter = focusedImage.style.filter;
3964 window.onmousemove = (event) => {
3965 // Remove the filter.
3966 focusedImage.style.filter = "none";
3967 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3968 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3974 // Double-click on the image unfocuses.
3975 clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3976 GWLog("GW.imageFocus.doubleClick");
3977 if (event.target.hasClass("slideshow-button")) return;
3979 unfocusImageOverlay();
3982 // Escape key unfocuses, spacebar resets.
3983 document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3984 GWLog("GW.imageFocus.keyUp");
3985 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3986 if (!allowedKeys.contains(event.key) ||
3987 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3989 event.preventDefault();
3991 switch (event.key) {
3994 unfocusImageOverlay();
3998 resetFocusedImagePosition();
4004 if (query("#images-overlay img.focused")) focusNextImage(true);
4010 if (query("#images-overlay img.focused")) focusNextImage(false);
4015 // Prevent spacebar or arrow keys from scrolling page when image focused.
4016 togglePageScrolling(false);
4018 // If the image comes from the images overlay, for the main post...
4019 if (imageToFocus.closest("#images-overlay")) {
4020 // Mark the overlay as being in slide show mode (to show buttons/count).
4021 imageFocusOverlay.addClass("slideshow");
4023 // Set state of next/previous buttons.
4024 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4025 var indexOfFocusedImage = getIndexOfFocusedImage();
4026 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4027 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4029 // Set the image number.
4030 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4032 // Replace the hash.
4033 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4035 imageFocusOverlay.removeClass("slideshow");
4039 setImageFocusCaption();
4041 // Moving mouse unhides image focus UI.
4042 window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
4043 GWLog("GW.imageFocus.mouseMoved");
4044 let currentDateTime = new Date();
4045 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
4046 cancelImageFocusHideUITimer();
4048 if (!GW.imageFocus.hideUITimer) {
4049 unhideImageFocusUI();
4050 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4052 GW.imageFocus.mouseLastMovedAt = currentDateTime;
4057 function resetFocusedImagePosition() {
4058 GWLog("resetFocusedImagePosition");
4059 let focusedImage = query("#image-focus-overlay img");
4060 if (!focusedImage) return;
4062 let sourceImage = query(GW.imageFocus.focusedImageSelector);
4064 // Make sure that initially, the image fits into the viewport.
4065 let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
4066 let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
4067 var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
4068 let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
4069 let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
4070 focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
4071 focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
4073 // Remove modifications to position.
4074 focusedImage.style.left = "";
4075 focusedImage.style.top = "";
4077 // Set the cursor appropriately.
4078 setFocusedImageCursor();
4080 function setFocusedImageCursor() {
4081 let focusedImage = query("#image-focus-overlay img");
4082 if (!focusedImage) return;
4083 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
4087 function unfocusImageOverlay() {
4088 GWLog("unfocusImageOverlay");
4090 // Remove event listeners.
4091 window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
4092 window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
4093 // NOTE: The double-click listener does not need to be removed manually,
4094 // because the focused (cloned) image will be removed anyway.
4095 document.removeEventListener("keyup", GW.imageFocus.keyUp);
4096 document.removeEventListener("keydown", GW.imageFocus.keyDown);
4097 window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
4098 window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
4099 window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
4101 // Set accesskey of currently focused image (if it's in the images overlay).
4102 let currentlyFocusedImage = query("#images-overlay img.focused");
4103 if (currentlyFocusedImage) {
4104 currentlyFocusedImage.addClass("last-focused");
4105 currentlyFocusedImage.accessKey = 'l';
4108 // Remove focused image and hide overlay.
4109 let imageFocusOverlay = query("#image-focus-overlay");
4110 imageFocusOverlay.removeClass("engaged");
4111 removeElement(imageFocusOverlay.query("img"));
4113 // Un-blur content/etc.
4114 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
4115 element.removeClass("blurred");
4118 // Unset "focused" class of focused image.
4119 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4121 // Re-enable page scrolling.
4122 togglePageScrolling(true);
4124 // Reset the hash, if needed.
4125 if (location.hash.hasPrefix("#if_slide_"))
4126 history.replaceState(window.history.state, null, "#");
4129 function getIndexOfFocusedImage() {
4130 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4131 var indexOfFocusedImage = -1;
4132 for (i = 0; i < images.length; i++) {
4133 if (images[i].hasClass("focused")) {
4134 indexOfFocusedImage = i;
4138 return indexOfFocusedImage;
4141 function focusNextImage(next = true) {
4142 GWLog("focusNextImage");
4143 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4144 var indexOfFocusedImage = getIndexOfFocusedImage();
4146 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
4148 // Remove existing image.
4149 removeElement("#image-focus-overlay img");
4150 // Unset "focused" class of just-removed image.
4151 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4153 // Create the focused version of the image.
4154 images[indexOfFocusedImage].addClass("focused");
4155 let imageFocusOverlay = query("#image-focus-overlay");
4156 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
4157 clonedImage.style = "";
4158 clonedImage.removeAttribute("width");
4159 clonedImage.removeAttribute("height");
4160 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
4161 imageFocusOverlay.appendChild(clonedImage);
4162 imageFocusOverlay.addClass("engaged");
4163 // Set image to default size and position.
4164 resetFocusedImagePosition();
4165 // Set state of next/previous buttons.
4166 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4167 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4168 // Set the image number display.
4169 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4171 setImageFocusCaption();
4172 // Replace the hash.
4173 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4176 function setImageFocusCaption() {
4177 GWLog("setImageFocusCaption");
4178 var T = { }; // Temporary storage.
4180 // Clear existing caption, if any.
4181 let captionContainer = query("#image-focus-overlay .caption");
4182 Array.from(captionContainer.children).forEach(child => { child.remove(); });
4184 // Determine caption.
4185 let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
4187 if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
4188 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
4189 captionHTML = (T.figcaption.query("p")) ?
4190 T.figcaption.innerHTML :
4191 "<p>" + T.figcaption.innerHTML + "</p>";
4192 } else if (currentlyFocusedImage.title != "") {
4193 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
4195 // Insert the caption, if any.
4196 if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
4199 function hideImageFocusUI() {
4200 GWLog("hideImageFocusUI");
4201 let imageFocusOverlay = query("#image-focus-overlay");
4202 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4203 element.addClass("hidden");
4207 function unhideImageFocusUI() {
4208 GWLog("unhideImageFocusUI");
4209 let imageFocusOverlay = query("#image-focus-overlay");
4210 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4211 element.removeClass("hidden");
4215 function cancelImageFocusHideUITimer() {
4216 clearTimeout(GW.imageFocus.hideUITimer);
4217 GW.imageFocus.hideUITimer = null;
4224 function keyboardHelpSetup() {
4225 let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
4226 <div class='keyboard-help-container'>
4227 <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'></button>
4228 <h1>Keyboard shortcuts</h1>
4229 <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>
4230 <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
4231 <div class='keyboard-shortcuts-lists'>` + [ [
4233 [ [ '?' ], "Show keyboard shortcuts" ],
4234 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
4237 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
4238 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
4239 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
4240 [ [ 'ak-m' ], "Go to Meta view" ],
4241 [ [ 'ak-v' ], "Go to Tags view"],
4242 [ [ 'ak-c' ], "Go to Recent Comments view" ],
4243 [ [ 'ak-r' ], "Go to Archive view" ],
4244 [ [ 'ak-q' ], "Go to Sequences view" ],
4245 [ [ 'ak-t' ], "Go to About page" ],
4246 [ [ 'ak-u' ], "Go to User or Login page" ],
4247 [ [ 'ak-o' ], "Go to Inbox page" ]
4250 [ [ 'ak-,' ], "Jump up to top of page" ],
4251 [ [ 'ak-.' ], "Jump down to bottom of page" ],
4252 [ [ 'ak-/' ], "Jump to top of comments section" ],
4253 [ [ 'ak-s' ], "Search" ],
4256 [ [ 'ak-n' ], "New post or comment" ],
4257 [ [ 'ak-e' ], "Edit current post" ]
4259 "Post/comment list views",
4260 [ [ '.' ], "Focus next entry in list" ],
4261 [ [ ',' ], "Focus previous entry in list" ],
4262 [ [ ';' ], "Cycle between links in focused entry" ],
4263 [ [ 'Enter' ], "Go to currently focused entry" ],
4264 [ [ 'Esc' ], "Unfocus currently focused entry" ],
4265 [ [ 'ak-]' ], "Go to next page" ],
4266 [ [ 'ak-[' ], "Go to previous page" ],
4267 [ [ 'ak-\\' ], "Go to first page" ],
4268 [ [ 'ak-e' ], "Edit currently focused post" ]
4271 [ [ 'ak-k' ], "Bold text" ],
4272 [ [ 'ak-i' ], "Italic text" ],
4273 [ [ 'ak-l' ], "Insert hyperlink" ],
4274 [ [ 'ak-q' ], "Blockquote text" ]
4277 [ [ 'ak-=' ], "Increase text size" ],
4278 [ [ 'ak--' ], "Decrease text size" ],
4279 [ [ 'ak-0' ], "Reset to default text size" ],
4280 [ [ 'ak-′' ], "Cycle through content width settings" ],
4281 [ [ 'ak-1' ], "Switch to default theme [A]" ],
4282 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
4283 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
4284 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
4285 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
4286 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
4287 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
4288 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
4289 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
4290 [ [ 'ak-;' ], "Open theme tweaker" ],
4291 [ [ 'Enter' ], "Save changes and close theme tweaker "],
4292 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
4295 [ [ 'ak-l' ], "Start/resume slideshow" ],
4296 [ [ 'Esc' ], "Exit slideshow" ],
4297 [ [ '→', '↓' ], "Next slide" ],
4298 [ [ '←', '↑' ], "Previous slide" ],
4299 [ [ 'Space' ], "Reset slide zoom" ]
4302 [ [ 'ak-x' ], "Switch to next view on user page" ],
4303 [ [ 'ak-z' ], "Switch to previous view on user page" ],
4304 [ [ 'ak-` ' ], "Toggle compact comment list view" ],
4305 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
4307 `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
4309 <span class='keys'>` +
4311 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
4314 <span class='action'>${entry[1]}</span>
4316 ).join("\n") + `</ul>`).join("\n") + `
4321 // Add listener to show the keyboard help overlay.
4322 document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
4323 GWLog("GW.keyboardHelpShowKeyPressed");
4324 if (event.key == '?')
4325 toggleKeyboardHelpOverlay(true);
4328 // Clicking the background overlay closes the keyboard help overlay.
4329 keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
4330 GWLog("GW.keyboardHelpOverlayClicked");
4331 if (event.type == "mousedown") {
4332 keyboardHelpOverlay.style.opacity = "0.01";
4334 toggleKeyboardHelpOverlay(false);
4335 keyboardHelpOverlay.style.opacity = "1.0";
4339 // Intercept clicks, so they don't "fall through" the background overlay.
4340 (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
4342 // Clicking the close button closes the keyboard help overlay.
4343 keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
4344 toggleKeyboardHelpOverlay(false);
4347 // Add button to open keyboard help.
4348 query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'></button>");
4349 query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
4350 GWLog("GW.openKeyboardHelpButtonClicked");
4351 toggleKeyboardHelpOverlay(true);
4352 event.target.blur();
4356 function toggleKeyboardHelpOverlay(show) {
4357 console.log("toggleKeyboardHelpOverlay");
4359 let keyboardHelpOverlay = query("#keyboard-help-overlay");
4360 show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
4361 keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
4363 // Prevent scrolling the document when the overlay is visible.
4364 togglePageScrolling(!show);
4366 // Focus the close button as soon as we open.
4367 keyboardHelpOverlay.query("button.close-keyboard-help").focus();
4370 // Add listener to show the keyboard help overlay.
4371 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
4372 GWLog("GW.keyboardHelpHideKeyPressed");
4373 if (event.key == 'Escape')
4374 toggleKeyboardHelpOverlay(false);
4377 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
4380 // Disable / enable tab-selection of the search box.
4381 setSearchBoxTabSelectable(!show);
4384 /**********************/
4385 /* PUSH NOTIFICATIONS */
4386 /**********************/
4388 function pushNotificationsSetup() {
4389 let pushNotificationsButton = query("#enable-push-notifications");
4390 if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
4391 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
4392 pushNotificationsButton.style.display = 'unset';
4396 function urlBase64ToUint8Array(base64String) {
4397 const padding = '='.repeat((4 - base64String.length % 4) % 4);
4398 const base64 = (base64String + padding)
4400 .replace(/_/g, '/');
4402 const rawData = window.atob(base64);
4403 const outputArray = new Uint8Array(rawData.length);
4405 for (let i = 0; i < rawData.length; ++i) {
4406 outputArray[i] = rawData.charCodeAt(i);
4411 function pushNotificationsButtonClicked(event) {
4412 event.target.style.opacity = 0.33;
4413 event.target.style.pointerEvents = "none";
4415 let reEnable = (message) => {
4416 if(message) alert(message);
4417 event.target.style.opacity = 1;
4418 event.target.style.pointerEvents = "unset";
4421 if(event.target.dataset.enabled) {
4422 fetch('/push/register', {
4424 headers: { 'Content-type': 'application/json' },
4425 body: JSON.stringify({
4429 event.target.innerHTML = "Enable push notifications";
4430 event.target.dataset.enabled = "";
4432 }).catch((err) => reEnable(err.message));
4434 Notification.requestPermission().then((permission) => {
4435 navigator.serviceWorker.ready
4436 .then((registration) => {
4437 return registration.pushManager.getSubscription()
4438 .then(async function(subscription) {
4440 return subscription;
4442 return registration.pushManager.subscribe({
4443 userVisibleOnly: true,
4444 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
4447 .catch((err) => reEnable(err.message));
4449 .then((subscription) => {
4450 fetch('/push/register', {
4453 'Content-type': 'application/json'
4455 body: JSON.stringify({
4456 subscription: subscription
4461 event.target.innerHTML = "Disable push notifications";
4462 event.target.dataset.enabled = "true";
4465 .catch(function(err){ reEnable(err.message) });
4471 /*******************************/
4472 /* HTML TO MARKDOWN CONVERSION */
4473 /*******************************/
4475 function MarkdownFromHTML(text, linePrefix) {
4476 GWLog("MarkdownFromHTML");
4478 let docFrag = document.createRange().createContextualFragment(text);
4481 let atLineBeginning = true;
4482 linePrefix = linePrefix || "";
4486 output += ("\n" + linePrefix).repeat(owedLines);
4490 atLineBeginning = false;
4492 let outText = text => {
4493 if(atLineBeginning) text = text.trimStart();
4494 text = text.replace(/\s+/gm, " ");
4498 let forceLine = n => {
4500 out(("\n" + linePrefix).repeat(n));
4501 atLineBeginning = true;
4503 let newLine = (n) => {
4505 if(owedLines >= 0 && owedLines < n) {
4508 atLineBeginning = true;
4510 let newParagraph = () => {
4513 let withPrefix = (prefix, fn) => {
4514 let oldPrefix = linePrefix;
4515 linePrefix += prefix;
4519 linePrefix = oldPrefix;
4522 let doConversion = (node) => {
4523 if(node.nodeType == Node.TEXT_NODE) {
4524 outText(node.nodeValue.replace(/[\][*\\#<>]/g, "\\$&"));
4526 else if(node.nodeType == Node.ELEMENT_NODE) {
4527 switch(node.tagName) {
4533 node.childNodes.forEach(doConversion);
4547 node.childNodes.forEach(doConversion);
4553 node.childNodes.forEach(doConversion);
4559 if(node.parentElement.tagName == "OL") {
4561 for(let e = node; e = e.previousElementSibling;) { i++ }
4562 listPrefix = "" + i + ". ";
4568 withPrefix(" ".repeat(listPrefix.length), () => node.childNodes.forEach(doConversion));
4578 out("#".repeat(node.tagName.charAt(1)) + " ");
4579 node.childNodes.forEach(doConversion);
4583 let href = node.getAttribute("href");
4585 node.childNodes.forEach(doConversion);
4589 let src = node.getAttribute("src");
4590 let alt = node.alt || "";
4591 out(`![${alt}](${src})`);
4596 withPrefix("> ", () => node.childNodes.forEach(doConversion));
4603 out(node.innerText);
4610 node.childNodes.forEach(doConversion);
4617 node.childNodes.forEach(doConversion);
4620 node.childNodes.forEach(doConversion);
4623 doConversion(docFrag);
4628 /************************************/
4629 /* ANCHOR LINK SCROLLING WORKAROUND */
4630 /************************************/
4632 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
4633 let hash = location.hash;
4634 if(hash && hash !== "#top" && !document.query(hash)) {
4635 let content = document.query("#content");
4636 content.style.display = "none";
4637 addTriggerListener("DOMReady", {priority: -1, fn: () => {
4638 content.style.visibility = "hidden";
4639 content.style.display = null;
4640 requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
4645 /******************/
4646 /* INITIALIZATION */
4647 /******************/
4649 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
4650 GWLog("INITIALIZER earlyInitialize");
4651 // Check to see whether we're on a mobile device (which we define as a narrow screen)
4652 GW.isMobile = (window.innerWidth <= 1160);
4653 GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
4655 // Backward compatibility
4656 let storedTheme = localStorage.getItem("selected-theme");
4658 Appearance.setTheme(storedTheme);
4659 localStorage.removeItem("selected-theme");
4662 // Animate width & theme adjustments?
4663 Appearance.adjustmentTransitions = false;
4664 // Add the content width selector.
4665 Appearance.injectContentWidthSelector();
4666 // Add the text size adjustment widget.
4667 Appearance.injectTextSizeAdjustmentUI();
4668 // Add the theme selector.
4669 Appearance.injectThemeSelector();
4670 // Add the theme tweaker.
4671 Appearance.injectThemeTweaker();
4673 // Add the dark mode selector (if desktop).
4674 if (GW.isMobile == false)
4675 DarkMode.injectModeSelector();
4677 // Add the quick-nav UI.
4680 // Finish initializing when ready.
4681 addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4684 function mainInitializer() {
4685 GWLog("INITIALIZER initialize");
4687 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4688 // "link without nav bars".
4689 if (getQueryVariable("hide-nav-bars") == "true") {
4690 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
4693 // If the page cannot have comments, remove the accesskey from the #comments
4694 // quick-nav button; and if the page can have comments, but does not, simply
4695 // disable the #comments quick nav button.
4696 let content = query("#content");
4697 if (content.query("#comments") == null) {
4698 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4699 } else if (content.query("#comments .comment-thread") == null) {
4700 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4703 // On edit post pages and conversation pages, add GUIEdit buttons to the
4704 // textarea and expand it.
4705 queryAll(".with-markdown-editor textarea").forEach(textarea => {
4706 textarea.addTextareaFeatures();
4707 expandTextarea(textarea);
4709 // Focus the textarea.
4710 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4712 // If we're on a comment thread page...
4713 if (query(".comments") != null) {
4714 // Add comment-minimize buttons to every comment.
4715 queryAll(".comment-meta").forEach(commentMeta => {
4716 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4717 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
4719 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4720 // Format and activate comment-minimize buttons.
4721 queryAll(".comment-minimize-button").forEach(button => {
4722 button.closest(".comment-item").setCommentThreadMaximized(false);
4723 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4724 event.target.closest(".comment-item").setCommentThreadMaximized(true);
4729 if (getQueryVariable("chrono") == "t") {
4730 insertHeadHTML(`<style> .comment-minimize-button::after { display: none; } </style>`);
4733 // On mobile, replace the labels for the checkboxes on the edit post form
4734 // with icons, to save space.
4735 if (GW.isMobile && query(".edit-post-page")) {
4736 query("label[for='link-post']").innerHTML = "";
4737 query("label[for='question']").innerHTML = "";
4740 // Add error message (as placeholder) if user tries to click Search with
4741 // an empty search field.
4743 let searchForm = query("#nav-item-search form");
4744 if(!searchForm) break searchForm;
4745 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4746 let searchField = event.target.query("input");
4747 if (searchField.value == "") {
4748 event.preventDefault();
4749 event.target.blur();
4750 searchField.placeholder = "Enter a search string!";
4751 searchField.focus();
4754 // Remove the placeholder / error on any input.
4755 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4756 event.target.placeholder = "";
4760 // Prevent conflict between various single-hotkey listeners and text fields
4761 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4762 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4763 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4766 if (content.hasClass("post-page")) {
4767 // Read and update last-visited-date.
4768 let lastVisitedDate = getLastVisitedDate();
4769 setLastVisitedDate(Date.now());
4771 // Save the number of comments this post has when it's visited.
4772 updateSavedCommentCount();
4774 if (content.query(".comments .comment-thread") != null) {
4775 // Add the new comments count & navigator.
4776 injectNewCommentNavUI();
4778 // Get the highlight-new-since date (as specified by URL parameter, if
4779 // present, or otherwise the date of the last visit).
4780 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4782 // Highlight new comments since the specified date.
4783 let newCommentsCount = highlightCommentsSince(hnsDate);
4785 // Update the comment count display.
4786 updateNewCommentNavUI(newCommentsCount, hnsDate);
4789 // On listing pages, make comment counts more informative.
4790 badgePostsWithNewComments();
4793 // Add the comments list mode selector widget (expanded vs. compact).
4794 injectCommentsListModeSelector();
4796 // Add the comments view selector widget (threaded vs. chrono).
4797 // injectCommentsViewModeSelector();
4799 // Add the comments sort mode selector (top, hot, new, old).
4800 if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4802 // Add the toggle for the post nav UI elements on mobile.
4803 if (GW.isMobile) injectPostNavUIToggle();
4805 // Add the toggle for the appearance adjustment UI elements on mobile.
4807 Appearance.injectAppearanceAdjustUIToggle();
4809 // Activate the antikibitzer.
4810 if (GW.useFancyFeatures)
4811 activateAntiKibitzer();
4813 // Add comment parent popups.
4814 injectPreviewPopupToggle();
4815 addCommentParentPopups();
4817 // Mark original poster's comments with a special class.
4818 markOriginalPosterComments();
4820 // On the All view, mark posts with non-positive karma with a special class.
4821 if (query("#content").hasClass("all-index-page")) {
4822 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4823 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4825 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4829 // Set the "submit" button on the edit post page to something more helpful.
4830 setEditPostPageSubmitButtonText();
4832 // Compute the text of the pagination UI tooltip text.
4833 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4834 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4837 // Add event listeners for Escape and Enter, for the theme tweaker.
4838 document.addEventListener("keyup", Appearance.themeTweakerUIKeyPressed);
4840 // Add event listener for . , ; (for navigating listings pages).
4841 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4842 if (!query(".comments") && listings.length > 0) {
4843 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
4844 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4846 if (event.key == "Escape") {
4847 if (document.activeElement.parentElement.hasClass("listing"))
4848 document.activeElement.blur();
4852 if (event.key == ';') {
4853 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4854 let links = document.activeElement.parentElement.queryAll("a");
4855 links[document.activeElement == links[0] ? 1 : 0].focus();
4856 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4857 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4858 links[document.activeElement == links[0] ? 1 : 0].focus();
4859 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4864 var indexOfActiveListing = -1;
4865 for (i = 0; i < listings.length; i++) {
4866 if (document.activeElement.parentElement.hasClass("listing") &&
4867 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4868 indexOfActiveListing = i;
4870 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
4871 listings[i] === document.activeElement.parentElement.query("a.date")) {
4872 indexOfActiveListing = i;
4876 // Remove edit accesskey from currently highlighted post by active user, if applicable.
4877 if (indexOfActiveListing > -1) {
4878 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4880 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4881 if (indexOfNextListing < listings.length) {
4882 listings[indexOfNextListing].focus();
4884 if (listings[indexOfNextListing].closest(".comment-item")) {
4885 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4886 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4889 document.activeElement.blur();
4891 // Add edit accesskey to newly highlighted post by active user, if applicable.
4892 (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4894 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4895 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4896 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4900 // Add event listener for ; (to focus the link on link posts).
4901 if (query("#content").hasClass("post-page") &&
4902 query(".post").hasClass("link-post")) {
4903 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4904 if (event.key == ';') query("a.link-post-link").focus();
4908 // Add accesskeys to user page view selector.
4909 let viewSelector = query("#content.user-page > .sublevel-nav");
4911 let currentView = viewSelector.query("span");
4912 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4913 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4916 // Add accesskey to index page sort selector.
4917 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4919 // Move MathJax style tags to <head>.
4920 var aggregatedStyles = "";
4921 queryAll("#content style").forEach(styleTag => {
4922 aggregatedStyles += styleTag.innerHTML;
4923 removeElement("style", styleTag.parentElement);
4925 if (aggregatedStyles != "") {
4926 insertHeadHTML(`<style id="mathjax-styles"> ${aggregatedStyles} </style>`);
4929 /* Makes double-clicking on a math element select the entire math element.
4930 (This actually makes no difference to the behavior of the copy listener
4931 which copies the entire LaTeX source of the full equation no matter how
4932 much of said equation is selected when the copy command is sent;
4933 however, it ensures that the UI communicates the actual behavior in a
4934 more accurate and understandable way.)
4936 query("#content").querySelectorAll(".mjpage").forEach(mathBlock => {
4937 mathBlock.addEventListener("dblclick", (event) => {
4938 document.getSelection().selectAllChildren(mathBlock.querySelector(".mjx-chtml"));
4940 mathBlock.title = mathBlock.classList.contains("mjpage__block")
4941 ? "Double-click to select equation, then copy, to get LaTeX source"
4942 : "Double-click to select equation; copy to get LaTeX source";
4945 // Add listeners to switch between word count and read time.
4946 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4947 queryAll(".post-meta .read-time").forEach(element => {
4948 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4949 let displayWordCount = localStorage.getItem("display-word-count");
4950 toggleReadTimeOrWordCount(!displayWordCount);
4951 if (displayWordCount) localStorage.removeItem("display-word-count");
4952 else localStorage.setItem("display-word-count", true);
4956 // Set up Image Focus feature.
4959 // Set up keyboard shortcuts guide overlay.
4960 keyboardHelpSetup();
4962 // Show push notifications button if supported
4963 pushNotificationsSetup();
4965 // Show elements now that javascript is ready.
4966 removeElement("#hide-until-init");
4968 activateTrigger("pageLayoutFinished");
4971 /*************************/
4972 /* POST-LOAD ADJUSTMENTS */
4973 /*************************/
4975 window.addEventListener("pageshow", badgePostsWithNewComments);
4977 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4978 GWLog("INITIALIZER pageLayoutFinished");
4980 Appearance.postSetThemeHousekeeping();
4982 focusImageSpecifiedByURL();
4984 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4985 // query("input[type='search']").value = GW.isMobile;
4986 // insertHeadHTML(`<style>
4987 // @media only screen and (hover:none) { #nav-item-search input { background-color: red; }}
4988 // @media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}
4992 function generateImagesOverlay() {
4993 GWLog("generateImagesOverlay");
4994 // Don’t do this on the about page.
4995 if (query(".about-page") != null) return;
4998 // Remove existing, if any.
4999 removeElement("#images-overlay");
5002 document.body.insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
5003 let imagesOverlay = query("#images-overlay");
5004 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
5005 queryAll(".post-body img").forEach(image => {
5006 let clonedImageContainer = newElement("DIV");
5008 let clonedImage = image.cloneNode(true);
5009 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
5010 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
5011 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
5012 clonedImageContainer.appendChild(clonedImage);
5014 let zoomLevel = Appearance.currentTextZoom;
5016 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
5017 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
5018 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
5019 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
5021 imagesOverlay.appendChild(clonedImageContainer);
5024 // Add the event listeners to focus each image.
5025 imageFocusSetup(true);
5028 function adjustUIForWindowSize() {
5029 GWLog("adjustUIForWindowSize");
5030 var bottomBarOffset;
5032 // Adjust bottom bar state.
5033 let bottomBar = query("#bottom-bar");
5034 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5035 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
5036 bottomBar.removeClass("decorative");
5038 bottomBar.query("#nav-item-top").style.display = "";
5039 } else if (bottomBar) {
5040 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
5041 else bottomBar.addClass("decorative");
5043 bottomBar.query("#nav-item-top").style.display = "none";
5046 // Show quick-nav UI up/down buttons if content is taller than window.
5047 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5048 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
5049 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
5052 // Move anti-kibitzer toggle if content is very short.
5053 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
5055 // Update the visibility of the post nav UI.
5056 updatePostNavUIVisibility();
5059 function recomputeUIElementsContainerHeight(force = false) {
5060 GWLog("recomputeUIElementsContainerHeight");
5062 (force || query("#ui-elements-container").style.height != "")) {
5063 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
5064 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
5065 query("#content").clientHeight + "px" :
5070 function focusImageSpecifiedByURL() {
5071 GWLog("focusImageSpecifiedByURL");
5072 if (location.hash.hasPrefix("#if_slide_")) {
5073 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
5074 let images = queryAll(GW.imageFocus.overlayImagesSelector);
5075 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
5076 if (imageToFocus > 0 && imageToFocus <= images.length) {
5077 focusImage(images[imageToFocus - 1]);
5079 // Set timer to hide the image focus UI.
5080 unhideImageFocusUI();
5081 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
5091 function insertMarkup(event) {
5092 var mopen = '', mclose = '', mtext = '', func = false;
5093 if (typeof arguments[1] == 'function') {
5094 func = arguments[1];
5096 mopen = arguments[1];
5097 mclose = arguments[2];
5098 mtext = arguments[3];
5101 var textarea = event.target.closest("form").query("textarea");
5103 var p0 = textarea.selectionStart;
5104 var p1 = textarea.selectionEnd;
5105 var cur0 = cur1 = p0;
5107 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
5108 str = func ? func(str, p0) : (mopen + str + mclose);
5110 // Determine selection.
5112 cur0 += (p0 == p1) ? mopen.length : str.length;
5113 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
5120 // Update textarea contents.
5121 document.execCommand("insertText", false, str);
5123 // Expand textarea, if needed.
5124 expandTextarea(textarea);
5127 textarea.selectionStart = cur0;
5128 textarea.selectionEnd = cur1;
5133 GW.guiEditButtons = [
5134 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
5135 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
5136 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
5137 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
5138 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
5139 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
5140 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
5141 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
5142 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
5143 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
5144 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
5145 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
5146 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
5147 [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '' ],
5148 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
5151 function blockquote(text, startpos) {
5153 text = "> Quoted text";
5154 return [ text, startpos + 2, startpos + text.length ];
5156 text = "> " + text.split("\n").join("\n> ") + "\n";
5157 return [ text, startpos + text.length, startpos + text.length ];
5161 function hyperlink(text, startpos) {
5162 var url = '', link_text = text, endpos = startpos;
5163 if (text.search(/^https?/) != -1) {
5165 link_text = "link text";
5166 startpos = startpos + 1;
5167 endpos = startpos + link_text.length;
5169 url = prompt("Link address (URL):");
5171 endpos = startpos + text.length;
5172 return [ text, startpos, endpos ];
5174 startpos = startpos + text.length + url.length + 4;
5178 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
5181 /******************/
5182 /* SERVICE WORKER */
5183 /******************/
5185 if(navigator.serviceWorker) {
5186 navigator.serviceWorker.register('/service-worker.js');
5187 setCookie("push", "t");
5190 /*********************/
5191 /* USER AUTOCOMPLETE */
5192 /*********************/
5194 function zLowerUIElements() {
5195 let uiElementsContainer = query("#ui-elements-container");
5196 if (uiElementsContainer)
5197 uiElementsContainer.style.zIndex = "1";
5200 function zRaiseUIElements() {
5201 let uiElementsContainer = query("#ui-elements-container");
5202 if (uiElementsContainer)
5203 uiElementsContainer.style.zIndex = "";
5206 var userAutocomplete = null;
5208 function abbreviatedInterval(date) {
5209 let seconds = Math.floor((new Date() - date) / 1000);
5210 let days = Math.floor(seconds / (60 * 60 * 24));
5211 let years = Math.floor(days / 365);
5220 function beginAutocompletion(control, startIndex, endIndex) {
5221 if(userAutocomplete) abortAutocompletion(userAutocomplete);
5223 let complete = { control: control,
5224 abortController: new AbortController(),
5225 fetchAbortController: new AbortController(),
5226 container: document.createElement("div") };
5228 endIndex = endIndex || control.selectionEnd;
5229 let valueLength = control.value.length;
5231 complete.container.className = "autocomplete-container "
5233 + (window.innerWidth > 1280
5236 control.insertAdjacentElement("afterend", complete.container);
5239 let makeReplacer = (userSlug, displayName) => {
5241 let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
5242 control.value = control.value.substring(0, startIndex - 1) +
5244 control.value.substring(endIndex);
5245 abortAutocompletion(complete);
5246 complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
5247 complete.control.focus();
5251 let switchHighlight = (newHighlight) => {
5255 complete.highlighted.removeClass("highlighted");
5256 newHighlight.addClass("highlighted");
5257 complete.highlighted = newHighlight;
5259 // Scroll newly highlighted item into view, if need be.
5260 if ( complete.highlighted.offsetTop + complete.highlighted.offsetHeight
5261 > complete.container.scrollTop + complete.container.clientHeight) {
5262 complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
5263 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
5264 complete.container.scrollTo(0, complete.highlighted.offsetTop);
5267 let highlightNext = () => {
5268 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
5270 let highlightPrev = () => {
5271 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
5274 let updateCompletions = () => {
5275 let fragment = control.value.substring(startIndex, endIndex);
5277 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
5278 {signal: complete.fetchAbortController.signal})
5279 .then((res) => res.json())
5281 if(res.error) return;
5282 if(res.length == 0) return abortAutocompletion(complete);
5284 complete.container.innerHTML = "";
5285 res.forEach(entry => {
5286 let entryContainer = document.createElement("div");
5287 [ [ entry.displayName, "name" ],
5288 [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
5289 [ (entry.karma || 0) + " karma", "karma" ]
5291 let e = document.createElement("span");
5294 entryContainer.append(e);
5296 entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
5297 complete.container.append(entryContainer);
5299 complete.highlighted = complete.container.children[0];
5300 complete.highlighted.classList.add("highlighted");
5301 complete.container.scrollTo(0, 0);
5306 document.body.addEventListener("click", (event) => {
5307 if (!complete.container.contains(event.target)) {
5308 abortAutocompletion(complete);
5309 event.preventDefault();
5310 event.stopPropagation();
5312 }, {signal: complete.abortController.signal,
5315 control.addEventListener("keydown", (event) => {
5316 switch (event.key) {
5318 abortAutocompletion(complete);
5319 event.preventDefault();
5323 event.preventDefault();
5327 event.preventDefault();
5334 event.preventDefault();
5337 complete.highlighted.onclick();
5338 event.preventDefault();
5341 }, {signal: complete.abortController.signal});
5343 control.addEventListener("selectionchange", (event) => {
5344 if (control.selectionStart < startIndex ||
5345 control.selectionEnd > endIndex) {
5346 abortAutocompletion(complete);
5348 }, {signal: complete.abortController.signal});
5350 control.addEventListener("input", (event) => {
5351 complete.fetchAbortController.abort();
5352 complete.fetchAbortController = new AbortController();
5354 endIndex += control.value.length - valueLength;
5355 valueLength = control.value.length;
5357 if (endIndex < startIndex) {
5358 abortAutocompletion(complete);
5362 updateCompletions();
5363 }, {signal: complete.abortController.signal});
5365 userAutocomplete = complete;
5367 if(startIndex != endIndex) updateCompletions();
5370 function abortAutocompletion(complete) {
5371 complete.fetchAbortController.abort();
5372 complete.abortController.abort();
5373 complete.container.remove();
5374 userAutocomplete = null;