1 /***************************/
2 /* INITIALIZATION REGISTRY */
3 /***************************/
6 GW.initializersDone = { };
8 function registerInitializer(name, tryEarly, precondition, fn) {
9 GW.initializersDone[name] = false;
10 GW.initializers[name] = fn;
11 let wrapper = function () {
12 if (GW.initializersDone[name]) return;
13 if (!precondition()) {
15 setTimeout(() => requestIdleCallback(wrapper, {timeout: 1000}), 50);
17 document.addEventListener("readystatechange", wrapper, {once: true});
21 GW.initializersDone[name] = true;
25 requestIdleCallback(wrapper, {timeout: 1000});
27 document.addEventListener("readystatechange", wrapper, {once: true});
28 requestIdleCallback(wrapper);
31 function forceInitializer(name) {
32 if (GW.initializersDone[name]) return;
33 GW.initializersDone[name] = true;
34 GW.initializers[name]();
42 function setCookie(name, value, days) {
44 if (!days) days = 36500;
46 var date = new Date();
47 date.setTime(date.getTime() + (days*24*60*60*1000));
48 expires = "; expires=" + date.toUTCString();
50 document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax" + (GW.secureCookies ? "; Secure" : "");
53 /*******************************/
54 /* EVENT LISTENER MANIPULATION */
55 /*******************************/
57 /* Removes event listener from a clickable element, automatically detaching it
58 from all relevant event types. */
59 Element.prototype.removeActivateEvent = function() {
60 let ael = this.activateEventListener;
61 this.removeEventListener("mousedown", ael);
62 this.removeEventListener("click", ael);
63 this.removeEventListener("keyup", ael);
66 /* Adds a scroll event listener to the page. */
67 function addScrollListener(fn, name) {
68 let wrapper = (event) => {
69 requestAnimationFrame(() => {
71 document.addEventListener("scroll", wrapper, {once: true, passive: true});
74 document.addEventListener("scroll", wrapper, {once: true, passive: true});
76 // Retain a reference to the scroll listener, if a name is provided.
77 if (typeof name != "undefined")
85 // Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=325942
86 Element.prototype.scrollIntoView = function(realSIV) {
87 return function(bottom) {
88 realSIV.call(this, bottom);
89 if(fixTarget = this.closest("input[id^='expand'] ~ .comment-thread")) {
90 window.scrollBy(0, fixTarget.scrollTop);
91 fixTarget.scrollTop = 0;
94 }(Element.prototype.scrollIntoView);
96 /* If top of element is not at or above the top of the screen, scroll it into
98 Element.prototype.scrollIntoViewIfNeeded = function() {
99 GWLog("scrollIntoViewIfNeeded");
100 if (this.getBoundingClientRect().bottom > window.innerHeight &&
101 this.getBoundingClientRect().top > 0) {
102 this.scrollIntoView(false);
106 function urlEncodeQuery(params) {
107 return params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params[x])}).join("&");
110 function handleAjaxError(event) {
111 if(event.target.getResponseHeader("Content-Type") === "application/json") console.log("doAjax error: " + JSON.parse(event.target.responseText)["error"]);
112 else console.log("doAjax error: Something bad happened :(");
115 function doAjax(params) {
116 let req = new XMLHttpRequest();
117 let requestMethod = params["method"] || "GET";
118 req.addEventListener("load", (event) => {
119 if(event.target.status < 400) {
120 if(params["onSuccess"]) params.onSuccess(event);
122 if(params["onFailure"]) params.onFailure(event);
123 else handleAjaxError(event);
125 if(params["onFinish"]) params.onFinish(event);
127 req.open(requestMethod, (params.location || document.location) + ((requestMethod == "GET" && params.params) ? "?" + urlEncodeQuery(params.params) : ""));
128 if(requestMethod == "POST") {
129 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
130 params["params"]["csrf-token"] = GW.csrfToken;
131 req.send(urlEncodeQuery(params.params));
137 function activateReadyStateTriggers() {
138 if(document.readyState == 'interactive') {
139 activateTrigger('DOMReady');
140 } else if(document.readyState == 'complete') {
141 activateTrigger('DOMReady');
142 activateTrigger('DOMComplete');
146 document.addEventListener('readystatechange', activateReadyStateTriggers);
147 activateReadyStateTriggers();
149 function callWithServerData(fname, uri) {
152 onSuccess: (event) => {
153 let response = JSON.parse(event.target.responseText);
154 window[fname](response);
159 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
160 deferredCalls = null;
162 /* Return the currently selected text, as HTML (rather than unstyled text).
164 function getSelectionHTML() {
165 let container = newElement("DIV");
166 container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
167 return container.innerHTML;
170 /* Given an HTML string, creates an element from that HTML, adds it to
171 #ui-elements-container (creating the latter if it does not exist), and
172 returns the created element.
174 function addUIElement(element_html) {
175 let ui_elements_container = query("#ui-elements-container");
176 if (ui_elements_container == null)
177 ui_elements_container = document.body.appendChild(newElement("NAV", { "id": "ui-elements-container" }));
179 ui_elements_container.insertAdjacentHTML("beforeend", element_html);
180 return ui_elements_container.lastElementChild;
183 /* Given an element or a selector, removes that element (or the element
184 identified by the selector).
185 If multiple elements match the selector, only the first is removed.
187 function removeElement(elementOrSelector, ancestor = document) {
188 if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
189 if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
192 /* Returns true if the string begins with the given prefix.
194 String.prototype.hasPrefix = function (prefix) {
195 return (this.lastIndexOf(prefix, 0) === 0);
198 /* Toggles whether the page is scrollable.
200 function togglePageScrolling(enable) {
202 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
203 document.body.addClass("no-scroll");
204 document.body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
206 document.body.removeClass("no-scroll");
207 document.body.removeAttribute("style");
208 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
212 DOMRectReadOnly.prototype.isInside = function (x, y) {
213 return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
216 /* Simple mutex mechanism.
218 function doIfAllowed(f, passHolder, passName, releaseImmediately = false) {
219 if (passHolder[passName] == false)
222 passHolder[passName] = false;
226 if (releaseImmediately) {
227 passHolder[passName] = true;
229 requestAnimationFrame(() => {
230 passHolder[passName] = true;
235 /*******************/
236 /* COPY PROCESSORS */
237 /*******************/
239 /*********************************************************************/
240 /* Workaround for Firefox weirdness, based on more Firefox weirdness.
242 DocumentFragment.prototype.getSelection = function () {
243 return document.getSelection();
246 /******************************************************************************/
247 /* Returns true if the node contains only whitespace and/or other empty nodes.
249 function isNodeEmpty(node) {
250 if (node.nodeType == Node.TEXT_NODE)
251 return (node.textContent.match(/\S/) == null);
253 if ( node.nodeType == Node.ELEMENT_NODE
254 && [ "IMG", "VIDEO", "AUDIO", "IFRAME", "OBJECT" ].includes(node.tagName))
257 if (node.childNodes.length == 0)
260 for (childNode of node.childNodes)
261 if (isNodeEmpty(childNode) == false)
267 /***************************************************************/
268 /* Returns a DocumentFragment containing the current selection.
270 function getSelectionAsDocument(doc = document) {
271 let docFrag = doc.getSelection().getRangeAt(0).cloneContents();
273 // Strip whitespace (remove top-level empty nodes).
274 let nodesToRemove = [ ];
275 docFrag.childNodes.forEach(node => {
276 if (isNodeEmpty(node))
277 nodesToRemove.push(node);
279 nodesToRemove.forEach(node => {
280 docFrag.removeChild(node);
286 /*****************************************************************************/
287 /* Adds the given copy processor, appending it to the existing array thereof.
289 Each copy processor should take two arguments: the copy event, and the
290 DocumentFragment which holds the selection as it is being processed by each
291 successive copy processor.
293 A copy processor should return true if processing should continue after it’s
294 done, false otherwise (e.g. if it has entirely replaced the contents of the
295 selection object with what the final clipboard contents should be).
297 function addCopyProcessor(processor) {
298 if (GW.copyProcessors == null)
299 GW.copyProcessors = [ ];
301 GW.copyProcessors.push(processor);
304 /******************************************************************************/
305 /* Set up the copy processor system by registering a ‘copy’ event handler to
306 call copy processors. (Must be set up for the main document, and separately
307 for any shadow roots.)
309 function registerCopyProcessorsForDocument(doc) {
310 GWLog("registerCopyProcessorsForDocument", "rewrite.js", 1);
312 doc.addEventListener("copy", (event) => {
313 if ( GW.copyProcessors == null
314 || GW.copyProcessors.length == 0)
317 event.preventDefault();
318 event.stopPropagation();
320 let selection = getSelectionAsDocument(doc);
323 while ( i < GW.copyProcessors.length
324 && GW.copyProcessors[i++](event, selection));
326 // This is necessary for .innerText to work properly.
327 let wrapper = newElement("DIV");
328 wrapper.appendChild(selection);
329 document.body.appendChild(wrapper);
331 let makeLinksAbsolute = (node) => {
332 if(node['attributes']) {
333 for(attr of ['src', 'href']) {
335 node[attr] = node[attr];
338 node.childNodes.forEach(makeLinksAbsolute);
340 makeLinksAbsolute(wrapper);
342 event.clipboardData.setData("text/plain", wrapper.innerText);
343 event.clipboardData.setData("text/html", wrapper.innerHTML);
345 document.body.removeChild(wrapper);
349 /*******************************************/
350 /* Set up copy processors in main document.
352 registerCopyProcessorsForDocument(document);
354 /*****************************************************************************/
355 /* Makes it so that copying a rendered equation or other math element copies
356 the LaTeX source, instead of the useless gibberish that is the contents of
357 the text nodes of the HTML representation of the equation.
359 addCopyProcessor((event, selection) => {
360 if (event.target.closest(".mjx-math")) {
361 selection.replaceChildren(event.target.closest(".mjx-math").getAttribute("aria-label"));
366 selection.querySelectorAll(".mjx-chtml").forEach(mathBlock => {
367 mathBlock.innerHTML = " " + mathBlock.querySelector(".mjx-math").getAttribute("aria-label") + " ";
373 /************************************************************************/
374 /* Remove soft hyphens and other extraneous characters from copied text.
376 addCopyProcessor((event, selection) => {
377 let replaceText = (node) => {
378 if(node.nodeType == Node.TEXT_NODE) {
379 node.nodeValue = node.nodeValue.replace(/\u00AD|\u200b/g, "");
382 node.childNodes.forEach(replaceText);
384 replaceText(selection);
390 /********************/
391 /* DEBUGGING OUTPUT */
392 /********************/
394 GW.enableLogging = (permanently = false) => {
396 localStorage.setItem("logging-enabled", "true");
398 GW.loggingEnabled = true;
400 GW.disableLogging = (permanently = false) => {
402 localStorage.removeItem("logging-enabled");
404 GW.loggingEnabled = false;
407 /*******************/
408 /* INBOX INDICATOR */
409 /*******************/
411 function processUserStatus(userStatus) {
412 window.userStatus = userStatus;
414 if(userStatus.notifications) {
415 let element = query('#inbox-indicator');
416 element.className = 'new-messages';
417 element.title = 'New messages [o]';
428 function toggleMarkdownHintsBox() {
429 GWLog("toggleMarkdownHintsBox");
430 let markdownHintsBox = query("#markdown-hints");
431 markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
433 function hideMarkdownHintsBox() {
434 GWLog("hideMarkdownHintsBox");
435 let markdownHintsBox = query("#markdown-hints");
436 if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
439 Element.prototype.addTextareaFeatures = function() {
440 GWLog("addTextareaFeatures");
443 textarea.addEventListener("focus", GW.textareaFocused = (event) => {
444 GWLog("GW.textareaFocused");
445 event.target.closest("form").scrollIntoViewIfNeeded();
447 textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
448 GWLog("GW.textareaInputReceived");
449 if (window.innerWidth > 520) {
450 // Expand textarea if needed.
451 expandTextarea(textarea);
453 // Remove markdown hints.
454 hideMarkdownHintsBox();
455 query(".guiedit-mobile-help-button").removeClass("active");
457 // User mentions autocomplete
458 if(!userAutocomplete &&
459 textarea.value.charAt(textarea.selectionStart - 1) === "@" &&
460 (textarea.selectionStart === 1 ||
461 !textarea.value.charAt(textarea.selectionStart - 2).match(/[a-zA-Z0-9]/))) {
462 beginAutocompletion(textarea, textarea.selectionStart);
465 textarea.addEventListener("click", (event) => {
466 if(!userAutocomplete) {
467 let start = textarea.selectionStart, end = textarea.selectionEnd;
468 let value = textarea.value;
469 if (start <= 1) return;
470 for (; value.charAt(start - 1) != "@"; start--) {
471 if (start <= 1) return;
472 if (value.charAt(start - 1) == " ") return;
474 for(; end < value.length && value.charAt(end) != " "; end++) { true }
475 beginAutocompletion(textarea, start, end);
479 textarea.addEventListener("paste", (event) => {
480 let html = event.clipboardData.getData("text/html");
482 html = html.replace(/\n|\r/gm, "");
483 let isQuoted = textarea.selectionStart >= 2 &&
484 textarea.value.substring(textarea.selectionStart - 2, textarea.selectionStart) == "> ";
485 document.execCommand("insertText", false, MarkdownFromHTML(html, (isQuoted ? "> " : null)));
486 event.preventDefault();
490 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
491 textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
492 textarea.addEventListener("keydown", (event) => {
493 // Special case for alt+4
494 // Generalize this before adding more.
495 if(event.altKey && event.key === '4') {
496 insertMarkup(event, "$", "$", "LaTeX formula");
497 event.stopPropagation();
498 event.preventDefault();
502 let form = textarea.closest("form");
504 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
505 let textareaContainer = textarea.closest(".textarea-container");
506 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
507 for (var button of GW.guiEditButtons) {
508 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
509 buttons_container.insertAdjacentHTML("beforeend",
510 "<button type='button' class='guiedit guiedit-"
513 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
514 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
515 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
516 + " onclick='insertMarkup(event,"
517 + ((typeof m_before_or_func == 'function') ?
518 m_before_or_func.name :
519 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
527 `<input type='checkbox' id='markdown-hints-checkbox'>
528 <label for='markdown-hints-checkbox'></label>
529 <div id='markdown-hints'>` +
530 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
531 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
532 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
533 "<span>Heading 1</span><code># Heading 1</code>",
534 "<span>Heading 2</span><code>## Heading 1</code>",
535 "<span>Heading 3</span><code>### Heading 1</code>",
536 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
538 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
540 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
541 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
542 GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
543 if (button.hasClass("guiedit-mobile-help-button")) {
544 toggleMarkdownHintsBox();
545 event.target.toggleClass("active");
546 query(".posting-controls:focus-within textarea").focus();
547 } else if (button.hasClass("guiedit-mobile-exit-button")) {
549 hideMarkdownHintsBox();
550 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
555 // On smartphone (narrow mobile) screens, when a textarea is focused (and
556 // automatically fullscreened), remove all the filters from the page, and
557 // then apply them *just* to the fixed editor UI elements. This is in order
558 // to get around the “children of elements with a filter applied cannot be
560 if (GW.isMobile && window.innerWidth <= 520) {
561 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
562 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
563 GWLog("GW.textareaFocusedMobile");
564 Appearance.savedFilters = Appearance.currentFilters;
565 Appearance.applyFilters(Appearance.noFilters);
566 fixedEditorElements.forEach(element => {
567 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
570 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
571 GWLog("GW.textareaBlurredMobile");
572 requestAnimationFrame(() => {
573 Appearance.applyFilters(Appearance.savedFilters);
574 Appearance.savedFilters = null;
575 fixedEditorElements.forEach(element => {
576 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
583 Element.prototype.injectReplyForm = function(editMarkdownSource) {
584 GWLog("injectReplyForm");
585 let commentControls = this;
586 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
587 let postId = commentControls.parentElement.dataset["postId"];
588 let tagId = commentControls.parentElement.dataset["tagId"];
589 let withparent = (!editMarkdownSource && commentControls.getCommentId());
590 let answer = commentControls.parentElement.id == "answers";
591 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
592 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
593 let parentCommentItem = commentControls.closest(".comment-item");
594 let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
595 (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
596 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
597 "<form method='post'>" +
598 "<div class='textarea-container'>" +
599 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
600 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
601 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
602 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
603 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
604 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
605 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
606 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
607 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
608 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
609 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
610 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
611 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
613 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
614 "<input type='submit' value='Submit'>" +
616 commentControls.onsubmit = disableBeforeUnload;
618 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
619 GWLog("GW.cancelCommentButtonClicked");
620 hideReplyForm(event.target.closest(".comment-controls"));
622 commentControls.scrollIntoViewIfNeeded();
623 commentControls.query("form").onsubmit = (event) => {
624 if (!event.target.text.value) {
625 alert("Please enter a comment.");
629 let textarea = commentControls.query("textarea");
630 textarea.addTextareaFeatures();
634 function showCommentEditForm(commentItem) {
635 GWLog("showCommentEditForm");
637 let commentBody = commentItem.query(".comment-body");
638 commentBody.style.display = "none";
640 let commentControls = commentItem.query(".comment-controls");
641 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
642 commentControls.query("form").addClass("edit-existing-comment");
643 expandTextarea(commentControls.query("textarea"));
646 function showReplyForm(commentItem) {
647 GWLog("showReplyForm");
649 let commentControls = commentItem.query(".comment-controls");
650 commentControls.injectReplyForm(commentControls.dataset.enteredText);
653 function hideReplyForm(commentControls) {
654 GWLog("hideReplyForm");
655 // Are we editing a comment? If so, un-hide the existing comment body.
656 let containingComment = commentControls.closest(".comment-item");
657 if (containingComment) containingComment.query(".comment-body").style.display = "";
659 let enteredText = commentControls.query("textarea").value;
660 if (enteredText) commentControls.dataset.enteredText = enteredText;
662 disableBeforeUnload();
663 commentControls.constructCommentControls();
666 function expandTextarea(textarea) {
667 GWLog("expandTextarea");
668 if (window.innerWidth <= 520) return;
670 let totalBorderHeight = 30;
671 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
673 requestAnimationFrame(() => {
674 textarea.style.height = 'auto';
675 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
676 if (textarea.clientHeight < window.innerHeight) {
677 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
682 function doCommentAction(action, commentItem) {
683 GWLog("doCommentAction");
685 params[(action + "-comment-id")] = commentItem.getCommentId();
689 onSuccess: GW.commentActionPostSucceeded = (event) => {
690 GWLog("GW.commentActionPostSucceeded");
692 retract: () => { commentItem.firstChild.addClass("retracted") },
693 unretract: () => { commentItem.firstChild.removeClass("retracted") },
695 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>";
696 commentItem.removeChild(commentItem.query(".comment-controls"));
700 if(action != "delete")
701 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
710 function parseVoteType(voteType) {
711 GWLog("parseVoteType");
713 if (!voteType) return value;
714 value.up = /[Uu]pvote$/.test(voteType);
715 value.down = /[Dd]ownvote$/.test(voteType);
716 value.big = /^big/.test(voteType);
720 function makeVoteType(value) {
721 GWLog("makeVoteType");
722 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
725 function makeVoteClass(vote) {
726 GWLog("makeVoteClass");
727 if (vote.up || vote.down) {
728 return (vote.big ? 'selected big-vote' : 'selected');
734 function findVoteControls(targetType, targetId, voteAxis) {
735 var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
737 if(targetType == "Post") {
738 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
739 } else if(targetType == "Comment") {
740 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
744 function votesEqual(vote1, vote2) {
745 var allKeys = Object.assign({}, vote1);
746 Object.assign(allKeys, vote2);
748 for(k of allKeys.keys()) {
749 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
754 function addVoteButtons(element, vote, targetType) {
755 GWLog("addVoteButtons");
757 let voteAxis = element.parentElement.dataset.voteAxis || "karma";
758 let voteType = parseVoteType(vote[voteAxis]);
759 let voteClass = makeVoteClass(voteType);
761 element.parentElement.queryAll("button").forEach((button) => {
762 button.disabled = false;
764 if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
765 button.addClass(voteClass);
767 updateVoteButtonVisualState(button);
768 button.addActivateEvent(voteButtonClicked);
772 function updateVoteButtonVisualState(button) {
773 GWLog("updateVoteButtonVisualState");
775 button.removeClasses([ "none", "one", "two-temp", "two" ]);
778 button.addClass("none");
779 else if (button.hasClass("big-vote"))
780 button.addClass("two");
781 else if (button.hasClass("selected"))
782 button.addClass("one");
784 button.addClass("none");
787 function changeVoteButtonVisualState(button) {
788 GWLog("changeVoteButtonVisualState");
790 /* Interaction states are:
792 0 0· (neutral; +1 click)
793 1 1· (small vote; +1 click)
794 2 2· (big vote; +1 click)
796 Visual states are (with their state classes in [brackets]) are:
799 02 (small vote active) [one]
800 12 (small vote active, temporary indicator of big vote) [two-temp]
801 22 (big vote active) [two]
803 The following are the 9 possible interaction state transitions (and
804 the visual state transitions associated with them):
807 FROM TO FROM TO NOTES
808 ==== ==== ==== ==== =====
809 0 0· 01 12 first click
810 0· 1 12 02 one click without second
811 0· 2 12 22 second click
813 1 1· 02 12 first click
814 1· 0 12 01 one click without second
815 1· 2 12 22 second click
817 2 2· 22 12 first click
818 2· 1 12 02 one click without second
819 2· 0 12 01 second click
822 [ "big-vote two-temp clicked-twice", "none" ], // 2· => 0
823 [ "big-vote two-temp clicked-once", "one" ], // 2· => 1
824 [ "big-vote clicked-once", "two-temp" ], // 2 => 2·
826 [ "selected two-temp clicked-twice", "two" ], // 1· => 2
827 [ "selected two-temp clicked-once", "none" ], // 1· => 0
828 [ "selected clicked-once", "two-temp" ], // 1 => 1·
830 [ "two-temp clicked-twice", "two" ], // 0· => 2
831 [ "two-temp clicked-once", "one" ], // 0· => 1
832 [ "clicked-once", "two-temp" ], // 0 => 0·
834 for (let [ interactionClasses, visualStateClass ] of transitions) {
835 if (button.hasClasses(interactionClasses.split(" "))) {
836 button.removeClasses([ "none", "one", "two-temp", "two" ]);
837 button.addClass(visualStateClass);
843 function voteCompleteEvent(targetType, targetId, response) {
844 GWLog("voteCompleteEvent");
846 var currentVote = voteData[targetType][targetId] || {};
847 var desiredVote = voteDesired[targetType][targetId];
849 var controls = findVoteControls(targetType, targetId);
850 var controlsByAxis = new Object;
852 controls.forEach(control => {
853 const voteAxis = (control.dataset.voteAxis || "karma");
855 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
856 control.removeClass("waiting");
857 control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
860 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
861 controlsByAxis[voteAxis].push(control);
863 const voteType = currentVote[voteAxis];
864 const vote = parseVoteType(voteType);
865 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
866 const voteClass = makeVoteClass(vote);
868 if (response && response[voteAxis]) {
869 const [voteType, displayText, titleText] = response[voteAxis];
871 const displayTarget = control.query(".karma-value");
872 if (displayTarget.hasClass("redacted")) {
873 displayTarget.dataset["trueValue"] = displayText;
875 displayTarget.innerHTML = displayText;
877 displayTarget.setAttribute("title", titleText);
880 control.queryAll("button.vote").forEach(button => {
881 updateVoteButton(button, voteUpDown, voteClass);
886 function updateVoteButton(button, voteUpDown, voteClass) {
887 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
888 if (button.dataset.voteType == voteUpDown)
889 button.addClass(voteClass);
890 updateVoteButtonVisualState(button);
893 function makeVoteRequestCompleteEvent(targetType, targetId) {
895 var currentVote = {};
898 if (event.target.status == 200) {
899 response = JSON.parse(event.target.responseText);
900 for (const voteAxis of response.keys()) {
901 currentVote[voteAxis] = response[voteAxis][0];
903 voteData[targetType][targetId] = currentVote;
905 delete voteDesired[targetType][targetId];
906 currentVote = voteData[targetType][targetId];
909 var desiredVote = voteDesired[targetType][targetId];
911 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
912 sendVoteRequest(targetType, targetId);
914 delete voteDesired[targetType][targetId];
915 voteCompleteEvent(targetType, targetId, response);
920 function sendVoteRequest(targetType, targetId) {
921 GWLog("sendVoteRequest");
925 location: "/karma-vote",
926 params: { "target": targetId,
927 "target-type": targetType,
928 "vote": JSON.stringify(voteDesired[targetType][targetId]) },
929 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
933 function voteButtonClicked(event) {
934 GWLog("voteButtonClicked");
935 let voteButton = event.target;
937 // 500 ms (0.5 s) double-click timeout.
938 let doubleClickTimeout = 500;
940 if (!voteButton.clickedOnce) {
941 voteButton.clickedOnce = true;
942 voteButton.addClass("clicked-once");
943 changeVoteButtonVisualState(voteButton);
945 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
946 if (!voteButton.clickedOnce) return;
948 // Do single-click code.
949 voteButton.clickedOnce = false;
950 voteEvent(voteButton, 1);
951 }, doubleClickTimeout, voteButton);
953 voteButton.clickedOnce = false;
955 // Do double-click code.
956 voteButton.removeClass("clicked-once");
957 voteButton.addClass("clicked-twice");
958 voteEvent(voteButton, 2);
962 function voteEvent(voteButton, numClicks) {
966 let voteControl = voteButton.parentNode;
968 let targetType = voteButton.dataset.targetType;
969 let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
970 let voteAxis = voteControl.dataset.voteAxis || "karma";
971 let voteUpDown = voteButton.dataset.voteType;
974 if ( (numClicks == 2 && voteButton.hasClass("big-vote"))
975 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
976 voteType = "neutral";
978 let vote = parseVoteType(voteUpDown);
979 vote.big = (numClicks == 2);
980 voteType = makeVoteType(vote);
983 let voteControls = findVoteControls(targetType, targetId, voteAxis);
984 for (const voteControl of voteControls) {
985 voteControl.addClass("waiting");
986 voteControl.queryAll(".vote").forEach(button => {
987 button.addClass("waiting");
988 updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
992 let voteRequestPending = voteDesired[targetType][targetId];
993 let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
994 voteObject[voteAxis] = voteType;
995 voteDesired[targetType][targetId] = voteObject;
997 if (!voteRequestPending) sendVoteRequest(targetType, targetId);
1000 function initializeVoteButtons() {
1001 // Color the upvote/downvote buttons with an embedded style sheet.
1002 insertHeadHTML(`<style id="vote-buttons">
1004 --GW-upvote-button-color: #00d800;
1005 --GW-downvote-button-color: #eb4c2a;
1010 function processVoteData(voteData) {
1011 window.voteData = voteData;
1013 window.voteDesired = new Object;
1014 for(key of voteData.keys()) {
1015 voteDesired[key] = new Object;
1018 initializeVoteButtons();
1020 addTriggerListener("postLoaded", {priority: 3000, fn: () => {
1021 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
1022 let postID = karmaValue.parentNode.dataset.postId;
1023 addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
1024 karmaValue.parentElement.addClass("active-controls");
1028 addTriggerListener("DOMReady", {priority: 3000, fn: () => {
1029 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
1030 let commentID = karmaValue.getCommentId();
1031 addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
1032 karmaValue.parentElement.addClass("active-controls");
1037 /*****************************************/
1038 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
1039 /*****************************************/
1041 Element.prototype.getCommentDate = function() {
1042 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
1043 let dateElement = item && item.query(".date");
1044 return (dateElement && parseInt(dateElement.dataset["jsDate"]));
1046 function getCurrentVisibleComment() {
1047 let px = window.innerWidth/2, py = 5;
1048 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
1049 let bottomBar = query("#bottom-bar");
1050 let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : document.body.getBoundingClientRect().bottom);
1051 let atbottom = bottomOffset <= window.innerHeight;
1053 let hashci = location.hash && query(location.hash);
1054 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
1055 commentItem = hashci;
1061 function highlightCommentsSince(date) {
1062 GWLog("highlightCommentsSince");
1063 var newCommentsCount = 0;
1064 GW.newComments = [ ];
1065 let oldCommentsStack = [ ];
1067 queryAll(".comment-item").forEach(commentItem => {
1068 commentItem.prevNewComment = prevNewComment;
1069 commentItem.nextNewComment = null;
1070 if (commentItem.getCommentDate() > date) {
1071 commentItem.addClass("new-comment");
1073 GW.newComments.push(commentItem.getCommentId());
1074 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
1075 oldCommentsStack = [ commentItem ];
1076 prevNewComment = commentItem;
1078 commentItem.removeClass("new-comment");
1079 oldCommentsStack.push(commentItem);
1083 GW.newCommentScrollSet = (commentItem) => {
1084 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
1085 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
1087 GW.newCommentScrollListener = () => {
1088 let commentItem = getCurrentVisibleComment();
1089 GW.newCommentScrollSet(commentItem);
1092 addScrollListener(GW.newCommentScrollListener);
1094 if (document.readyState=="complete") {
1095 GW.newCommentScrollListener();
1097 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
1098 GW.newCommentScrollSet(commentItem);
1101 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
1103 return newCommentsCount;
1106 function scrollToNewComment(next) {
1107 GWLog("scrollToNewComment");
1108 let commentItem = getCurrentVisibleComment();
1109 let targetComment = null;
1110 let targetCommentID = null;
1112 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
1113 if (targetComment) {
1114 targetCommentID = targetComment.getCommentId();
1117 if (GW.newComments[0]) {
1118 targetCommentID = GW.newComments[0];
1119 targetComment = query("#comment-" + targetCommentID);
1122 if (targetComment) {
1123 expandAncestorsOf(targetCommentID);
1124 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
1125 targetComment.scrollIntoView();
1128 GW.newCommentScrollListener();
1131 function getPostHash() {
1132 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
1133 return (postHash ? postHash[1] : false);
1135 function setHistoryLastVisitedDate(date) {
1136 window.history.replaceState({ lastVisited: date }, null);
1138 function getLastVisitedDate() {
1139 // Get the last visited date (or, if posting a comment, the previous last visited date).
1140 if(window.history.state) return (window.history.state||{})['lastVisited'];
1141 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1142 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
1143 let currentVisited = localStorage.getItem(storageName);
1144 setHistoryLastVisitedDate(currentVisited);
1145 return currentVisited;
1147 function setLastVisitedDate(date) {
1148 GWLog("setLastVisitedDate");
1149 // If NOT posting a comment, save the previous value for the last-visited-date
1150 // (to recover it in case of posting a comment).
1151 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
1152 if (!aCommentHasJustBeenPosted) {
1153 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
1154 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
1157 // Set the new value.
1158 localStorage.setItem("last-visited-date_" + getPostHash(), date);
1161 function updateSavedCommentCount() {
1162 GWLog("updateSavedCommentCount");
1163 let commentCount = queryAll(".comment").length;
1164 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
1166 function badgePostsWithNewComments() {
1167 GWLog("badgePostsWithNewComments");
1168 if (getQueryVariable("show") == "conversations") return;
1170 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
1171 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
1173 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
1174 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
1175 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
1177 if (currentCommentCount > savedCommentCount)
1178 commentCountDisplay.addClass("new-comments");
1180 commentCountDisplay.removeClass("new-comments");
1181 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1191 systemDarkModeActive: matchMedia("(prefers-color-scheme: dark)")
1195 /************************/
1196 /* ACTIVE MEDIA QUERIES */
1197 /************************/
1199 /* This function provides two slightly different versions of its functionality,
1200 depending on how many arguments it gets.
1202 If one function is given (in addition to the media query and its name), it
1203 is called whenever the media query changes (in either direction).
1205 If two functions are given (in addition to the media query and its name),
1206 then the first function is called whenever the media query starts matching,
1207 and the second function is called whenever the media query stops matching.
1209 If you want to call a function for a change in one direction only, pass an
1210 empty closure (NOT null!) as one of the function arguments.
1212 There is also an optional fifth argument. This should be a function to be
1213 called when the active media query is canceled.
1215 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1216 if (typeof GW.mediaQueryResponders == "undefined")
1217 GW.mediaQueryResponders = { };
1219 let mediaQueryResponder = (event, canceling = false) => {
1221 GWLog(`Canceling media query “${name}”`, "media queries", 1);
1223 if (whenCanceledDo != null)
1224 whenCanceledDo(mediaQuery);
1226 let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1228 GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1230 if ((otherwiseDo == null) || matches)
1231 ifMatchesOrAlwaysDo(mediaQuery);
1233 otherwiseDo(mediaQuery);
1236 mediaQueryResponder();
1237 mediaQuery.addListener(mediaQueryResponder);
1239 GW.mediaQueryResponders[name] = mediaQueryResponder;
1242 /* Deactivates and discards an active media query, after calling the function
1243 that was passed as the whenCanceledDo parameter when the media query was
1246 function cancelDoWhenMatchMedia(name) {
1247 GW.mediaQueryResponders[name](null, true);
1249 for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1250 mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1252 GW.mediaQueryResponders[name] = null;
1256 /******************************/
1257 /* DARK/LIGHT MODE ADJUSTMENT */
1258 /******************************/
1265 [ "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)" ],
1266 [ "light", "", "Light mode at all times (black-on-white)" ],
1267 [ "dark", "", "Dark mode at all times (inverted: white-on-black)" ]
1270 selectedModeOptionNote: " [This option is currently selected.]",
1272 /******************/
1277 modeSelectorInteractable: true,
1279 /******************/
1283 /* Returns current (saved) mode (light, dark, or auto).
1285 getSavedMode: () => {
1286 return (readCookie("dark-mode") || "auto");
1289 /* Saves specified mode (light, dark, or auto).
1291 saveMode: (mode) => {
1292 GWLog("DarkMode.setMode");
1295 setCookie("dark-mode", "");
1297 setCookie("dark-mode", mode);
1300 getMediaQuery: (selectedMode = DarkMode.getSavedMode()) => {
1301 if (selectedMode == "auto") {
1302 return "all and (prefers-color-scheme: dark)";
1303 } else if (selectedMode == "dark") {
1310 /* Set specified color mode (light, dark, or auto).
1312 setMode: (selectedMode = DarkMode.getSavedMode()) => {
1313 GWLog("DarkMode.setMode");
1315 document.body.removeClasses(["force-dark-mode", "force-light-mode"]);
1316 if(selectedMode === "dark" || selectedMode === "light")
1317 document.body.addClass("force-" + selectedMode + "-mode");
1319 let media = DarkMode.getMediaQuery(selectedMode);
1320 let darkModeStyles = document.querySelector("link.dark-mode");
1321 if (darkModeStyles) {
1322 // Set `media` attribute of style block to match requested mode.
1323 darkModeStyles.media = media;
1326 for(elem of document.querySelectorAll("picture.invertible source")) {
1327 // Update invertible images.
1332 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1335 modeSelectorHTML: (inline = false) => {
1336 let selectorTagName = (inline ? "span" : "div");
1337 let selectorId = (inline ? `` : ` id="dark-mode-selector"`);
1338 let selectorClass = (` class="dark-mode-selector mode-selector` + (inline ? ` mode-selector-inline` : ``) + `"`);
1340 // Get saved mode setting (or default).
1341 let currentMode = DarkMode.getSavedMode();
1343 return `<${selectorTagName}${selectorId}${selectorClass}>`
1344 + DarkMode.modeOptions.map(modeOption => {
1345 let [ name, label, desc ] = modeOption;
1346 let selected = (name == currentMode ? " selected" : "");
1347 let disabled = (name == currentMode ? " disabled" : "");
1348 let active = (( currentMode == "auto"
1349 && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1352 if (name == currentMode)
1353 desc += DarkMode.selectedModeOptionNote;
1356 class="select-mode-${name}${selected}${active}"
1361 >${label}</button>`;
1363 + `</${selectorTagName}>`;
1366 injectModeSelector: (replacedElement = null) => {
1367 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1369 // Inject the mode selector widget.
1371 if (replacedElement) {
1372 replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1373 modeSelector = replacedElement.firstElementChild;
1374 unwrap(replacedElement);
1377 if (Appearance.themeSelector == null)
1380 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", DarkMode.modeSelectorHTML());
1382 addUIElement(DarkMode.modeSelectorHTML());
1385 modeSelector = DarkMode.modeSelector = query("#dark-mode-selector");
1388 // Add event listeners and update state.
1389 requestAnimationFrame(() => {
1390 // Activate mode selector widget buttons.
1391 modeSelector.querySelectorAll("button").forEach(button => {
1392 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1396 /* Add active media query to update mode selector state when system dark
1397 mode setting changes. (This is relevant only for the ‘auto’ setting.)
1399 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => {
1400 DarkMode.updateModeSelectorState(modeSelector);
1404 modeSelectButtonClicked: (event) => {
1405 GWLog("DarkMode.modeSelectButtonClicked");
1407 /* We don’t want clicks to go through if the transition
1408 between modes has not completed yet, so we disable the
1409 button temporarily while we’re transitioning between
1413 // Determine which setting was chosen (ie. which button was clicked).
1414 let selectedMode = event.target.dataset.name;
1416 // Save the new setting.
1417 DarkMode.saveMode(selectedMode);
1419 // Actually change the mode.
1420 DarkMode.setMode(selectedMode);
1421 }, DarkMode, "modeSelectorInteractable");
1423 event.target.blur();
1426 updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1427 GWLog("DarkMode.updateModeSelectorState");
1429 /* If the mode selector has not yet been injected, then do nothing.
1431 if (modeSelector == null)
1434 // Get saved mode setting (or default).
1435 let currentMode = DarkMode.getSavedMode();
1437 // Clear current buttons state.
1438 modeSelector.querySelectorAll("button").forEach(button => {
1439 button.classList.remove("active", "selected");
1440 button.disabled = false;
1441 if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1442 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1445 // Set the correct button to be selected.
1446 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1447 button.classList.add("selected");
1448 button.disabled = true;
1449 button.title += DarkMode.selectedModeOptionNote;
1452 /* Ensure the right button (light or dark) has the “currently active”
1453 indicator, if the current mode is ‘auto’.
1455 if (currentMode == "auto")
1456 modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1461 /****************************/
1462 /* APPEARANCE CUSTOMIZATION */
1463 /****************************/
1465 Appearance = { ...Appearance,
1466 /**************************************************************************/
1472 themeSelector: null,
1473 themeSelectorAuxiliaryControlsContainer: null,
1474 themeSelectorInteractionBlockerOverlay: null,
1475 themeSelectorInteractableTimer: null,
1477 themeTweakerToggle: null,
1479 themeTweakerStyleBlock: null,
1481 themeTweakerUI: null,
1482 themeTweakerUIMainWindow: null,
1483 themeTweakerUIHelpWindow: null,
1484 themeTweakerUISampleTextContainer: null,
1485 themeTweakerUIClippyContainer: null,
1486 themeTweakerUIClippyControl: null,
1488 widthSelector: null,
1490 textSizeAdjustmentWidget: null,
1492 appearanceAdjustUIToggle: null,
1494 /**************************************************************************/
1498 /* Return a new <link> element linking a style sheet (.css file) for the
1499 given theme name and color scheme preference (i.e., value for the
1500 ‘media’ attribute; may be “light”, “dark”, or “” [empty string]).
1502 makeNewStyle: (newThemeName) => {
1503 let styleSheetNameSuffix = newThemeName == Appearance.defaultTheme
1505 : ("-" + newThemeName);
1506 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1508 return [["style", "theme"], ["colors", "theme light-mode"], ["inverted", "theme dark-mode", DarkMode.getMediaQuery()]].map(args => {
1509 let [baseName, className, mediaQuery] = args;
1510 return newElement("LINK", {
1512 "rel": "stylesheet",
1513 "href": ("/generated-css/" + baseName + styleSheetNameSuffix + currentStyleSheetNameComponents[1]),
1514 "media": mediaQuery || null,
1515 "blocking": "render"
1520 setTheme: (newThemeName, save = true) => {
1521 GWLog("Appearance.setTheme");
1523 let oldThemeName = "";
1524 if (typeof(newThemeName) == "undefined") {
1525 /* If no theme name to set is given, that means we’re setting the
1526 theme initially, on page load. The .currentTheme value will have
1527 been set by .setup().
1529 newThemeName = Appearance.currentTheme;
1531 /* If the selected (saved) theme is the default theme, then there’s
1534 if (newThemeName == Appearance.defaultTheme)
1537 oldThemeName = Appearance.currentTheme;
1539 /* When the unload callback runs, the .currentTheme value is still
1540 that of the old theme.
1542 let themeUnloadCallback = Appearance.themeUnloadCallbacks[oldThemeName];
1543 if (themeUnloadCallback != null)
1544 themeUnloadCallback(newThemeName);
1546 /* The old .currentTheme value is saved in oldThemeName.
1548 Appearance.currentTheme = newThemeName;
1550 /* The ‘save’ parameter might be false if this function is called
1551 from the theme tweaker, in which case we want to switch only
1552 temporarily, and preserve the saved setting until the user
1556 Appearance.saveCurrentTheme();
1559 let newStyles = Appearance.makeNewStyle(newThemeName);
1560 let loadingStyleCount = newStyles.length;
1562 let oldStyles = queryAll("head link.theme");
1564 let onNewStylesLoaded = (event) => {
1565 loadingStyleCount--;
1566 if(loadingStyleCount === 0) {
1567 for(oldStyle of oldStyles) removeElement(oldStyle);
1568 Appearance.postSetThemeHousekeeping(oldThemeName, newThemeName);
1572 for(newStyle of newStyles) newStyle.addEventListener("load", onNewStylesLoaded);
1574 if (Appearance.adjustmentTransitions) {
1575 pageFadeTransition(false);
1577 document.head.prepend(...newStyles);
1580 document.head.prepend(...newStyles);
1583 // Update UI state of all theme selectors.
1584 Appearance.updateThemeSelectorsState();
1587 postSetThemeHousekeeping: (oldThemeName = "", newThemeName = null) => {
1588 GWLog("Appearance.postSetThemeHousekeeping");
1590 if (newThemeName == null)
1591 newThemeName = Appearance.getSavedTheme();
1593 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1594 document.body.addClass("theme-" + newThemeName);
1596 recomputeUIElementsContainerHeight(true);
1598 let themeLoadCallback = Appearance.themeLoadCallbacks[newThemeName];
1599 if (themeLoadCallback != null)
1600 themeLoadCallback(oldThemeName);
1602 recomputeUIElementsContainerHeight();
1603 adjustUIForWindowSize();
1604 window.addEventListener("resize", GW.windowResized = (event) => {
1605 GWLog("GW.windowResized");
1606 adjustUIForWindowSize();
1607 recomputeUIElementsContainerHeight();
1610 generateImagesOverlay();
1612 if (Appearance.adjustmentTransitions)
1613 pageFadeTransition(true);
1614 Appearance.updateThemeTweakerSampleText();
1616 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== "undefined") {
1617 window.matchMedia("(orientation: portrait)").addListener(generateImagesOverlay);
1621 themeLoadCallbacks: {
1622 brutalist: (fromTheme = "") => {
1623 GWLog("Appearance.themeLoadCallbacks.brutalist");
1625 let bottomBarLinks = queryAll("#bottom-bar a");
1626 if (!GW.isMobile && bottomBarLinks.length == 5) {
1627 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1628 bottomBarLinks.forEach((link, i) => {
1629 link.dataset.originalText = link.textContent;
1630 link.textContent = newLinkTexts[i];
1635 classic: (fromTheme = "") => {
1636 GWLog("Appearance.themeLoadCallbacks.classic");
1638 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1639 button.innerHTML = "";
1643 dark: (fromTheme = "") => {
1644 GWLog("Appearance.themeLoadCallbacks.dark");
1646 insertHeadHTML(`<style id="dark-theme-adjustments">
1647 .markdown-reference-link a { color: #d200cf; filter: invert(100%); }
1648 #bottom-bar.decorative::before { filter: invert(100%); }
1650 registerInitializer("makeImagesGlow", true, () => query("#images-overlay") != null, () => {
1651 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1652 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)";
1653 image.style.width = parseInt(image.style.width) + 12 + "px";
1654 image.style.height = parseInt(image.style.height) + 12 + "px";
1655 image.style.top = parseInt(image.style.top) - 6 + "px";
1656 image.style.left = parseInt(image.style.left) - 6 + "px";
1661 less: (fromTheme = "") => {
1662 GWLog("Appearance.themeLoadCallbacks.less");
1664 injectSiteNavUIToggle();
1666 injectPostNavUIToggle();
1667 Appearance.injectAppearanceAdjustUIToggle();
1670 registerInitializer("shortenDate", true, () => query(".top-post-meta") != null, function () {
1671 let dtf = new Intl.DateTimeFormat([],
1672 (window.innerWidth < 1100) ?
1673 { month: "short", day: "numeric", year: "numeric" } :
1674 { month: "long", day: "numeric", year: "numeric" });
1675 let postDate = query(".top-post-meta .date");
1676 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1680 query("#content").insertAdjacentHTML("beforeend", `<div id="theme-less-mobile-first-row-placeholder"></div>`);
1684 registerInitializer("addSpans", true, () => query(".top-post-meta") != null, function () {
1685 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1686 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1690 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1691 // If state is not set (user has never clicked on the Less theme’s appearance
1692 // adjustment UI toggle) then show it, but then hide it after a short time.
1693 registerInitializer("engageAppearanceAdjustUI", true, () => query("#ui-elements-container") != null, function () {
1694 Appearance.toggleAppearanceAdjustUI();
1695 setTimeout(Appearance.toggleAppearanceAdjustUI, 3000);
1699 if (fromTheme != "") {
1700 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1701 setTimeout(function () {
1702 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1704 setTimeout(function () {
1705 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1709 // Unset the height of the #ui-elements-container.
1710 query("#ui-elements-container").style.height = "";
1712 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1713 Appearance.filtersExclusionPaths.themeLess = [
1714 "#content #secondary-bar",
1715 "#content .post .top-post-meta .date",
1716 "#content .post .top-post-meta .comment-count",
1718 Appearance.applyFilters();
1721 // We pre-query the relevant elements, so we don’t have to run querySelectorAll
1722 // on every firing of the scroll listener.
1724 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1725 "unbrokenDownScrollDistance": 0,
1726 "unbrokenUpScrollDistance": 0,
1727 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1728 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1729 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1731 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1735 themeUnloadCallbacks: {
1736 brutalist: (toTheme = "") => {
1737 GWLog("Appearance.themeUnloadCallbacks.brutalist");
1739 let bottomBarLinks = queryAll("#bottom-bar a");
1740 if (!GW.isMobile && bottomBarLinks.length == 5) {
1741 bottomBarLinks.forEach(link => {
1742 link.textContent = link.dataset.originalText;
1747 classic: (toTheme = "") => {
1748 GWLog("Appearance.themeUnloadCallbacks.classic");
1750 if (GW.isMobile && window.innerWidth <= 900)
1753 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1754 button.innerHTML = button.dataset.label;
1758 dark: (toTheme = "") => {
1759 GWLog("Appearance.themeUnloadCallbacks.dark");
1761 removeElement("#dark-theme-adjustments");
1764 less: (toTheme = "") => {
1765 GWLog("Appearance.themeUnloadCallbacks.less");
1767 removeSiteNavUIToggle();
1769 removePostNavUIToggle();
1770 Appearance.removeAppearanceAdjustUIToggle();
1773 window.removeEventListener("resize", updatePostNavUIVisibility);
1775 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1777 removeElement("#theme-less-mobile-first-row-placeholder");
1781 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1782 element.innerHTML = element.firstChild.innerHTML;
1786 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1788 // Reset filtered elements selector to default.
1789 delete Appearance.filtersExclusionPaths.themeLess;
1790 Appearance.applyFilters();
1794 pageFadeTransition: (fadeIn) => {
1796 document.body.removeClass("transparent");
1798 document.body.addClass("transparent");
1802 /* Set the saved theme setting to the currently active theme.
1804 saveCurrentTheme: () => {
1805 GWLog("Appearance.saveCurrentTheme");
1807 if (Appearance.currentTheme == Appearance.defaultTheme)
1808 setCookie("theme", "");
1810 setCookie("theme", Appearance.currentTheme);
1813 /* Reset theme, theme tweak filters, and text zoom to their saved settings.
1815 themeTweakReset: () => {
1816 GWLog("Appearance.themeTweakReset");
1818 Appearance.setTheme(Appearance.getSavedTheme());
1819 Appearance.applyFilters(Appearance.getSavedFilters());
1820 Appearance.setTextZoom(Appearance.getSavedTextZoom());
1823 /* Set the saved theme, theme tweak filter, and text zoom settings to their
1824 currently active values.
1826 themeTweakSave: () => {
1827 GWLog("Appearance.themeTweakSave");
1829 Appearance.saveCurrentTheme();
1830 Appearance.saveCurrentFilters();
1831 Appearance.saveCurrentTextZoom();
1834 /* Reset theme, theme tweak filters, and text zoom to their default levels.
1835 (Do not save the new settings, however.)
1837 themeTweakResetDefaults: () => {
1838 GWLog("Appearance.themeTweakResetDefaults");
1840 Appearance.setTheme(Appearance.defaultTheme, false);
1841 Appearance.applyFilters(Appearance.defaultFilters);
1842 Appearance.setTextZoom(Appearance.defaultTextZoom, false);
1845 themeTweakerResetSettings: () => {
1846 GWLog("Appearance.themeTweakerResetSettings");
1848 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
1849 Appearance.themeTweakerUIClippyContainer.style.display = Appearance.themeTweakerUIClippyControl.checked
1854 themeTweakerSaveSettings: () => {
1855 GWLog("Appearance.themeTweakerSaveSettings");
1857 Appearance.saveThemeTweakerClippyState();
1860 getSavedThemeTweakerClippyState: () => {
1861 return (JSON.parse(localStorage.getItem("theme-tweaker-settings") || `{ "showClippy": ${Appearance.defaultThemeTweakerClippyState} }` )["showClippy"]);
1864 saveThemeTweakerClippyState: () => {
1865 GWLog("Appearance.saveThemeTweakerClippyState");
1867 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ "showClippy": Appearance.themeTweakerUIClippyControl.checked }));
1870 getSavedAppearanceAdjustUIToggleState: () => {
1871 return ((localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") || Appearance.defaultAppearanceAdjustUIToggleState);
1874 saveAppearanceAdjustUIToggleState: () => {
1875 GWLog("Appearance.saveAppearanceAdjustUIToggleState");
1877 localStorage.setItem("appearance-adjust-ui-toggle-engaged", Appearance.appearanceAdjustUIToggle.query("button").hasClass("engaged"));
1880 /**************************************************************************/
1881 /* UI CONSTRUCTION & MANIPULATION
1884 contentWidthSelectorHTML: () => {
1885 return ("<div id='width-selector'>"
1886 + String.prototype.concat.apply("", Appearance.widthOptions.map(widthOption => {
1887 let [name, desc, abbr] = widthOption;
1888 let selected = (name == Appearance.currentWidth ? " selected" : "");
1889 let disabled = (name == Appearance.currentWidth ? " disabled" : "");
1890 return `<button type="button" class="select-width-${name}${selected}"${disabled} title="${desc}" tabindex="-1" data-name="${name}">${abbr}</button>`
1895 injectContentWidthSelector: () => {
1896 GWLog("Appearance.injectContentWidthSelector");
1898 // Inject the content width selector widget and activate buttons.
1899 Appearance.widthSelector = addUIElement(Appearance.contentWidthSelectorHTML());
1900 Appearance.widthSelector.queryAll("button").forEach(button => {
1901 button.addActivateEvent(Appearance.widthAdjustButtonClicked);
1904 // Make sure the accesskey (to cycle to the next width) is on the right button.
1905 Appearance.setWidthAdjustButtonsAccesskey();
1907 // Inject transitions CSS, if animating changes is enabled.
1908 if (Appearance.adjustmentTransitions) {
1910 `<style id="width-transition">
1912 #ui-elements-container,
1915 max-width 0.3s ease;
1921 setWidthAdjustButtonsAccesskey: () => {
1922 GWLog("Appearance.setWidthAdjustButtonsAccesskey");
1924 Appearance.widthSelector.queryAll("button").forEach(button => {
1925 button.removeAttribute("accesskey");
1926 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1928 let selectedButton = Appearance.widthSelector.query("button.selected");
1929 let nextButtonInCycle = selectedButton == selectedButton.parentElement.lastChild
1930 ? selectedButton.parentElement.firstChild
1931 : selectedButton.nextSibling;
1932 nextButtonInCycle.accessKey = "'";
1933 nextButtonInCycle.title += ` [\']`;
1936 injectTextSizeAdjustmentUI: () => {
1937 GWLog("Appearance.injectTextSizeAdjustmentUI");
1939 if (Appearance.textSizeAdjustmentWidget != null)
1942 let inject = () => {
1943 GWLog("Appearance.injectTextSizeAdjustmentUI [INJECTING]");
1945 Appearance.textSizeAdjustmentWidget = addUIElement("<div id='text-size-adjustment-ui'>"
1946 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1947 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1948 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1951 Appearance.textSizeAdjustmentWidget.queryAll("button").forEach(button => {
1952 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1956 if (query("#content.post-page") != null) {
1959 document.addEventListener("DOMContentLoaded", () => {
1960 if (!( query(".post-body") == null
1961 && query(".comment-body") == null))
1967 themeSelectorHTML: () => {
1968 return ("<div id='theme-selector' class='theme-selector'>"
1969 + String.prototype.concat.apply("", Appearance.themeOptions.map(themeOption => {
1970 let [name, desc, letter] = themeOption;
1971 let selected = (name == Appearance.currentTheme ? ' selected' : '');
1972 let disabled = (name == Appearance.currentTheme ? ' disabled' : '');
1973 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1974 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>`;
1979 injectThemeSelector: () => {
1980 GWLog("Appearance.injectThemeSelector");
1982 Appearance.themeSelector = addUIElement(Appearance.themeSelectorHTML());
1983 Appearance.themeSelector.queryAll("button").forEach(button => {
1984 button.addActivateEvent(Appearance.themeSelectButtonClicked);
1988 // Add close button.
1989 let themeSelectorCloseButton = newElement("BUTTON", { "class": "theme-selector-close-button" }, { "innerHTML": "" });
1990 themeSelectorCloseButton.addActivateEvent(Appearance.themeSelectorCloseButtonClicked);
1991 Appearance.themeSelector.appendChild(themeSelectorCloseButton);
1993 // Inject auxiliary controls container.
1994 Appearance.themeSelectorAuxiliaryControlsContainer = newElement("DIV", { "class": "auxiliary-controls-container" });
1995 Appearance.themeSelector.appendChild(Appearance.themeSelectorAuxiliaryControlsContainer);
1997 // Inject mobile versions of various UI elements.
1998 Appearance.injectThemeTweakerToggle();
1999 injectAntiKibitzerToggle();
2000 DarkMode.injectModeSelector();
2002 // Inject interaction blocker overlay.
2003 Appearance.themeSelectorInteractionBlockerOverlay = Appearance.themeSelector.appendChild(newElement("DIV", { "class": "interaction-blocker-overlay" }));
2004 Appearance.themeSelectorInteractionBlockerOverlay.addActivateEvent(event => { event.stopPropagation(); });
2007 // Inject transitions CSS, if animating changes is enabled.
2008 if (Appearance.adjustmentTransitions) {
2009 insertHeadHTML(`<style id="theme-fade-transition">
2012 opacity 0.5s ease-out,
2013 background-color 0.3s ease-out;
2016 background-color: #777;
2019 opacity 0.5s ease-in,
2020 background-color 0.3s ease-in;
2026 updateThemeSelectorsState: () => {
2027 GWLog("Appearance.updateThemeSelectorsState");
2029 queryAll(".theme-selector button.select-theme").forEach(button => {
2030 button.removeClass("selected");
2031 button.disabled = false;
2033 queryAll(".theme-selector button.select-theme-" + Appearance.currentTheme).forEach(button => {
2034 button.addClass("selected");
2035 button.disabled = true;
2038 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.currentTheme;
2041 setThemeSelectorInteractable: (interactable) => {
2042 GWLog("Appearance.setThemeSelectorInteractable");
2044 Appearance.themeSelectorInteractionBlockerOverlay.classList.toggle("enabled", (interactable == false));
2047 themeTweakerUIHTML: () => {
2048 return (`<div id="theme-tweaker-ui" style="display: none;">\n`
2049 + `<div class="theme-tweaker-window main-window">
2050 <div class="theme-tweaker-window-title-bar">
2051 <div class="theme-tweaker-window-title">
2052 <h1>Customize appearance</h1>
2054 <div class="theme-tweaker-window-title-bar-buttons-container">
2055 <button type="button" class="help-button" tabindex="-1"></button>
2056 <button type="button" class="minimize-button minimize" tabindex="-1"></button>
2057 <button type="button" class="close-button" tabindex="-1"></button>
2060 <div class="theme-tweaker-window-content-view">
2061 <div class="theme-select">
2062 <p class="current-theme">Current theme:
2063 <span>${Appearance.getSavedTheme()}</span>
2065 <div class="theme-selector"></div>
2067 <div class="controls-container">
2068 <div id="theme-tweak-section-sample-text" class="section" data-label="Sample text">
2069 <div class="sample-text-container"><span class="sample-text">
2070 <p>Less Wrong (text)</p>
2071 <p><a href="#">Less Wrong (link)</a></p>
2074 <div id="theme-tweak-section-text-size-adjust" class="section" data-label="Text size">
2075 <button type="button" class="text-size-adjust-button decrease" title="Decrease text size"></button>
2076 <button type="button" class="text-size-adjust-button default" title="Reset to default text size"></button>
2077 <button type="button" class="text-size-adjust-button increase" title="Increase text size"></button>
2079 <div id="theme-tweak-section-invert" class="section" data-label="Invert (photo-negative)">
2080 <input type="checkbox" id="theme-tweak-control-invert"></input>
2081 <label for="theme-tweak-control-invert">Invert colors</label>
2083 <div id="theme-tweak-section-saturate" class="section" data-label="Saturation">
2084 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2085 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
2086 <div class="notch theme-tweak-slider-notch-saturate" title="Reset saturation to default value (100%)"></div>
2088 <div id="theme-tweak-section-brightness" class="section" data-label="Brightness">
2089 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2090 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
2091 <div class="notch theme-tweak-slider-notch-brightness" title="Reset brightness to default value (100%)"></div>
2093 <div id="theme-tweak-section-contrast" class="section" data-label="Contrast">
2094 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
2095 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
2096 <div class="notch theme-tweak-slider-notch-contrast" title="Reset contrast to default value (100%)"></div>
2098 <div id="theme-tweak-section-hue-rotate" class="section" data-label="Hue rotation">
2099 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
2100 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
2101 <div class="notch theme-tweak-slider-notch-hue-rotate" title="Reset hue to default (0° away from standard colors for theme)"></div>
2104 <div class="buttons-container">
2105 <button type="button" class="reset-defaults-button">Reset to defaults</button>
2106 <button type="button" class="ok-button default-button">OK</button>
2107 <button type="button" class="cancel-button">Cancel</button>
2111 <div class="clippy-container">
2112 <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>)
2113 <div class="clippy"></div>
2114 <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>
2116 <div class="theme-tweaker-window help-window" style="display: none;">
2117 <div class="theme-tweaker-window-title-bar">
2118 <div class="theme-tweaker-window-title">
2119 <h1>Theme tweaker help</h1>
2122 <div class="theme-tweaker-window-content-view">
2123 <div id="theme-tweak-section-clippy" class="section" data-label="Theme Tweaker Assistant">
2124 <input type="checkbox" id="theme-tweak-control-clippy" checked="checked"></input>
2125 <label for="theme-tweak-control-clippy">Show Bobby the Basilisk</label>
2127 <div class="buttons-container">
2128 <button type="button" class="ok-button default-button">OK</button>
2129 <button type="button" class="cancel-button">Cancel</button>
2136 injectThemeTweaker: () => {
2137 GWLog("Appearance.injectThemeTweaker");
2139 Appearance.themeTweakerUI = addUIElement(Appearance.themeTweakerUIHTML());
2140 Appearance.themeTweakerUIMainWindow = Appearance.themeTweakerUI.firstElementChild;
2141 Appearance.themeTweakerUIHelpWindow = Appearance.themeTweakerUI.query(".help-window");
2142 Appearance.themeTweakerUISampleTextContainer = Appearance.themeTweakerUI.query("#theme-tweak-section-sample-text .sample-text-container");
2143 Appearance.themeTweakerUIClippyContainer = Appearance.themeTweakerUI.query(".clippy-container");
2144 Appearance.themeTweakerUIClippyControl = Appearance.themeTweakerUI.query("#theme-tweak-control-clippy");
2146 // Clicking the background overlay closes the theme tweaker.
2147 Appearance.themeTweakerUI.addActivateEvent(Appearance.themeTweakerUIOverlayClicked, true);
2149 // Intercept clicks, so they don’t “fall through” the background overlay.
2150 Array.from(Appearance.themeTweakerUI.children).forEach(themeTweakerUIWindow => {
2151 themeTweakerUIWindow.addActivateEvent((event) => {
2152 event.stopPropagation();
2156 Appearance.themeTweakerUI.queryAll("input").forEach(field => {
2157 /* All input types in the theme tweaker receive a ‘change’ event
2158 when their value is changed. (Range inputs, in particular,
2159 receive this event when the user lets go of the handle.) This
2160 means we should update the filters for the entire page, to match
2163 field.addEventListener("change", Appearance.themeTweakerUIFieldValueChanged);
2165 /* Range inputs receive an ‘input’ event while being scrubbed,
2166 updating “live” as the handle is moved. We don’t want to change
2167 the filters for the actual page while this is happening, but we
2168 do want to change the filters for the *sample text*, so the user
2169 can see what effects his changes are having, live, without
2170 having to let go of the handle.
2172 if (field.type == "range")
2173 field.addEventListener("input", Appearance.themeTweakerUIFieldInputReceived);
2176 Appearance.themeTweakerUI.query(".help-button").addActivateEvent(Appearance.themeTweakerUIHelpButtonClicked);
2177 Appearance.themeTweakerUI.query(".minimize-button").addActivateEvent(Appearance.themeTweakerUIMinimizeButtonClicked);
2178 Appearance.themeTweakerUI.query(".close-button").addActivateEvent(Appearance.themeTweakerUICloseButtonClicked);
2179 Appearance.themeTweakerUI.query(".reset-defaults-button").addActivateEvent(Appearance.themeTweakerUIResetDefaultsButtonClicked);
2180 Appearance.themeTweakerUI.query(".main-window .cancel-button").addActivateEvent(Appearance.themeTweakerUICancelButtonClicked);
2181 Appearance.themeTweakerUI.query(".main-window .ok-button").addActivateEvent(Appearance.themeTweakerUIOKButtonClicked);
2182 Appearance.themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowCancelButtonClicked);
2183 Appearance.themeTweakerUI.query(".help-window .ok-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowOKButtonClicked);
2185 Appearance.themeTweakerUI.queryAll(".notch").forEach(notch => {
2186 notch.addActivateEvent(Appearance.themeTweakerUISliderNotchClicked);
2189 Appearance.themeTweakerUI.query(".clippy-close-button").addActivateEvent(Appearance.themeTweakerUIClippyCloseButtonClicked);
2191 insertHeadHTML(`<style id="theme-tweaker-style"></style>`);
2192 Appearance.themeTweakerStyleBlock = document.head.query("#theme-tweaker-style");
2194 Appearance.themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
2195 Appearance.themeTweakerUI.queryAll(".theme-selector > *:not(.select-theme)").forEach(element => {
2198 Appearance.themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
2199 button.addActivateEvent(Appearance.themeSelectButtonClicked);
2202 Appearance.themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
2203 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
2206 if (GW.isMobile == false)
2207 Appearance.injectThemeTweakerToggle();
2210 themeTweakerToggleHTML: () => {
2211 return (`<div id="theme-tweaker-toggle">`
2215 title="Customize appearance [;]"
2221 injectThemeTweakerToggle: () => {
2222 GWLog("Appearance.injectThemeTweakerToggle");
2225 if (Appearance.themeSelector == null)
2228 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", Appearance.themeTweakerToggleHTML());
2229 Appearance.themeTweakerToggle = Appearance.themeSelector.query("#theme-tweaker-toggle");
2231 Appearance.themeTweakerToggle = addUIElement(Appearance.themeTweakerToggleHTML());
2234 Appearance.themeTweakerToggle.query("button").addActivateEvent(Appearance.themeTweakerToggleClicked);
2237 showThemeTweakerUI: () => {
2238 GWLog("Appearance.showThemeTweakerUI");
2240 if (query("link[href^='/css/theme_tweaker.css']") == null) {
2241 // Theme tweaker CSS needs to be loaded.
2243 let themeTweakerStyleSheet = newElement("LINK", {
2244 "rel": "stylesheet",
2245 "href": "/css/theme_tweaker.css"
2248 themeTweakerStyleSheet.addEventListener("load", (event) => {
2249 requestAnimationFrame(() => {
2250 themeTweakerStyleSheet.disabled = false;
2252 Appearance.showThemeTweakerUI();
2255 document.head.appendChild(themeTweakerStyleSheet);
2260 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.getSavedTheme();
2262 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = (Appearance.currentFilters["invert"] == "100%");
2263 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2264 let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2265 slider.value = /^[0-9]+/.exec(Appearance.currentFilters[sliderName]) || slider.dataset["defaultValue"];
2266 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2269 Appearance.toggleThemeTweakerUI();
2272 toggleThemeTweakerUI: () => {
2273 GWLog("Appearance.toggleThemeTweakerUI");
2275 let show = (Appearance.themeTweakerUI.style.display == "none");
2277 Appearance.themeTweakerUI.style.display = show ? "block" : "none";
2278 Appearance.setThemeTweakerWindowMinimized(false);
2279 Appearance.themeTweakerStyleBlock.innerHTML = show ? `#content, #ui-elements-container > div:not(#theme-tweaker-ui) { pointer-events: none; user-select: none; }` : "";
2283 Appearance.themeTweakerToggle.query("button").disabled = true;
2284 // Focus invert checkbox.
2285 Appearance.themeTweakerUI.query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
2286 // Show sample text in appropriate font.
2287 Appearance.updateThemeTweakerSampleText();
2288 // Disable tab-selection of the search box.
2289 setSearchBoxTabSelectable(false);
2290 // Disable scrolling of the page.
2291 togglePageScrolling(false);
2293 // Re-enable button.
2294 Appearance.themeTweakerToggle.query("button").disabled = false;
2295 // Re-enable tab-selection of the search box.
2296 setSearchBoxTabSelectable(true);
2297 // Re-enable scrolling of the page.
2298 togglePageScrolling(true);
2301 // Set theme tweaker assistant visibility.
2302 Appearance.themeTweakerUIClippyContainer.style.display = (Appearance.getSavedThemeTweakerClippyState() == true) ? "block" : "none";
2305 setThemeTweakerWindowMinimized: (minimize) => {
2306 GWLog("Appearance.setThemeTweakerWindowMinimized");
2308 Appearance.themeTweakerUIMainWindow.query(".minimize-button").swapClasses([ "minimize", "maximize" ], (minimize ? 1 : 0));
2309 Appearance.themeTweakerUIMainWindow.classList.toggle("minimized", minimize);
2310 Appearance.themeTweakerUI.classList.toggle("main-window-minimized", minimize);
2313 toggleThemeTweakerHelpWindow: () => {
2314 GWLog("Appearance.toggleThemeTweakerHelpWindow");
2316 Appearance.themeTweakerUIHelpWindow.style.display = Appearance.themeTweakerUIHelpWindow.style.display == "none"
2319 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2320 // Focus theme tweaker assistant checkbox.
2321 Appearance.themeTweakerUI.query("#theme-tweak-control-clippy").focus();
2322 // Disable interaction on main theme tweaker window.
2323 Appearance.themeTweakerUI.style.pointerEvents = "none";
2324 Appearance.themeTweakerUIMainWindow.style.pointerEvents = "none";
2326 // Re-enable interaction on main theme tweaker window.
2327 Appearance.themeTweakerUI.style.pointerEvents = "auto";
2328 Appearance.themeTweakerUIMainWindow.style.pointerEvents = "auto";
2332 resetThemeTweakerUIDefaultState: () => {
2333 GWLog("Appearance.resetThemeTweakerUIDefaultState");
2335 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
2337 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
2338 let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
2339 slider.value = slider.dataset["defaultValue"];
2340 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
2344 updateThemeTweakerSampleText: () => {
2345 GWLog("Appearance.updateThemeTweakerSampleText");
2347 let sampleText = Appearance.themeTweakerUISampleTextContainer.query("#theme-tweak-section-sample-text .sample-text");
2349 // This causes the sample text to take on the properties of the body text of a post.
2350 sampleText.removeClass("body-text");
2351 let bodyTextElement = query(".post-body") || query(".comment-body");
2352 sampleText.addClass("body-text");
2353 sampleText.style.color = bodyTextElement ?
2354 getComputedStyle(bodyTextElement).color :
2355 getComputedStyle(query("#content")).color;
2357 // Here we find out what is the actual background color that will be visible behind
2358 // the body text of posts, and set the sample text’s background to that.
2359 let findStyleBackground = (selector) => {
2360 return "#fff"; // FIXME
2362 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2363 if (rule.selectorText == selector)
2366 return x.style.backgroundColor;
2369 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2372 injectAppearanceAdjustUIToggle: () => {
2373 GWLog("Appearance.injectAppearanceAdjustUIToggle");
2375 Appearance.appearanceAdjustUIToggle = addUIElement(`<div id="appearance-adjust-ui-toggle"><button type="button" tabindex="-1"></button></div>`);
2376 Appearance.appearanceAdjustUIToggle.query("button").addActivateEvent(Appearance.appearanceAdjustUIToggleButtonClicked);
2379 && Appearance.getSavedAppearanceAdjustUIToggleState() == true) {
2380 Appearance.toggleAppearanceAdjustUI();
2384 removeAppearanceAdjustUIToggle: () => {
2385 GWLog("Appearance.removeAppearanceAdjustUIToggle");
2387 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2388 element.removeClass("engaged");
2390 removeElement("#appearance-adjust-ui-toggle");
2393 toggleAppearanceAdjustUI: () => {
2394 GWLog("Appearance.toggleAppearanceAdjustUI");
2396 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2397 element.toggleClass("engaged");
2401 clearTimeout(Appearance.themeSelectorInteractableTimer);
2402 Appearance.setThemeSelectorInteractable(false);
2403 Appearance.themeSelectorInteractableTimer = setTimeout(() => {
2404 Appearance.setThemeSelectorInteractable(true);
2409 /**************************************************************************/
2413 /* Theme selector close button (on mobile version of theme selector).
2415 themeSelectorCloseButtonClicked: (event) => {
2416 GWLog("Appearance.themeSelectorCloseButtonClicked");
2418 Appearance.toggleAppearanceAdjustUI();
2419 Appearance.saveAppearanceAdjustUIToggleState();
2422 /* “Cog” button (to toggle the appearance adjust UI widgets in “less”
2423 theme, or theme selector UI on mobile).
2425 appearanceAdjustUIToggleButtonClicked: (event) => {
2426 GWLog("Appearance.appearanceAdjustUIToggleButtonClicked");
2428 Appearance.toggleAppearanceAdjustUI();
2429 Appearance.saveAppearanceAdjustUIToggleState();
2432 /* Width adjust buttons (“normal”, “wide”, “fluid”).
2434 widthAdjustButtonClicked: (event) => {
2435 GWLog("Appearance.widthAdjustButtonClicked");
2437 // Determine which setting was chosen (i.e., which button was clicked).
2438 let selectedWidth = event.target.dataset.name;
2441 Appearance.currentWidth = selectedWidth;
2443 // Save the new setting.
2444 Appearance.saveCurrentWidth();
2446 // Save current visible comment
2447 let visibleComment = getCurrentVisibleComment();
2449 // Actually change the content width.
2450 Appearance.setContentWidth(selectedWidth);
2451 event.target.parentElement.childNodes.forEach(button => {
2452 button.removeClass("selected");
2453 button.disabled = false;
2455 event.target.addClass("selected");
2456 event.target.disabled = true;
2458 // Make sure the accesskey (to cycle to the next width) is on the right button.
2459 Appearance.setWidthAdjustButtonsAccesskey();
2461 // Regenerate images overlay.
2462 generateImagesOverlay();
2465 visibleComment.scrollIntoView();
2468 /* Theme selector buttons (“A” through “I”).
2470 themeSelectButtonClicked: (event) => {
2471 GWLog("Appearance.themeSelectButtonClicked");
2473 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
2474 let save = (Appearance.themeTweakerUI.contains(event.target) == false);
2475 Appearance.setTheme(themeName, save);
2477 Appearance.toggleAppearanceAdjustUI();
2480 /* The text size adjust (“-”, “A”, “+”) buttons.
2482 textSizeAdjustButtonClicked: (event) => {
2483 GWLog("Appearance.textSizeAdjustButtonClicked");
2485 var zoomFactor = Appearance.currentTextZoom;
2486 if (event.target.hasClass("decrease")) {
2488 } else if (event.target.hasClass("increase")) {
2491 zoomFactor = Appearance.defaultTextZoom;
2494 let save = ( Appearance.textSizeAdjustmentWidget != null
2495 && Appearance.textSizeAdjustmentWidget.contains(event.target));
2496 Appearance.setTextZoom(zoomFactor, save);
2499 /* Theme tweaker toggle button.
2501 themeTweakerToggleClicked: (event) => {
2502 GWLog("Appearance.themeTweakerToggleClicked");
2504 Appearance.showThemeTweakerUI();
2507 /***************************/
2508 /* Theme tweaker UI events.
2511 /* Key pressed while theme tweaker is open.
2513 themeTweakerUIKeyPressed: (event) => {
2514 GWLog("Appearance.themeTweakerUIKeyPressed");
2516 if (event.key == "Escape") {
2517 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2518 Appearance.toggleThemeTweakerHelpWindow();
2519 Appearance.themeTweakerResetSettings();
2520 } else if (Appearance.themeTweakerUI.style.display != "none") {
2521 Appearance.toggleThemeTweakerUI();
2522 Appearance.themeTweakReset();
2524 } else if (event.key == "Enter") {
2525 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2526 Appearance.toggleThemeTweakerHelpWindow();
2527 Appearance.themeTweakerSaveSettings();
2528 } else if (Appearance.themeTweakerUI.style.display != "none") {
2529 Appearance.toggleThemeTweakerUI();
2530 Appearance.themeTweakSave();
2535 /* Theme tweaker overlay clicked.
2537 themeTweakerUIOverlayClicked: (event) => {
2538 GWLog("Appearance.themeTweakerUIOverlayClicked");
2540 if (event.type == "mousedown") {
2541 Appearance.themeTweakerUI.style.opacity = "0.01";
2543 Appearance.toggleThemeTweakerUI();
2544 Appearance.themeTweakerUI.style.opacity = "1.0";
2545 Appearance.themeTweakReset();
2549 /* In the theme tweaker, a slider clicked, or released after drag; or a
2550 checkbox clicked (either in the main theme tweaker UI, or in the help
2553 themeTweakerUIFieldValueChanged: (event) => {
2554 GWLog("Appearance.themeTweakerUIFieldValueChanged");
2556 if (event.target.id == "theme-tweak-control-invert") {
2557 Appearance.currentFilters["invert"] = event.target.checked ? "100%" : "0%";
2558 } else if (event.target.type == "range") {
2559 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2560 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2561 Appearance.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2562 } else if (event.target.id == "theme-tweak-control-clippy") {
2563 Appearance.themeTweakerUIClippyContainer.style.display = event.target.checked ? "block" : "none";
2566 // Clear the sample text filters.
2567 Appearance.themeTweakerUISampleTextContainer.style.filter = "";
2569 // Apply the new filters globally.
2570 Appearance.applyFilters();
2573 /* Theme tweaker slider dragged (live-update event).
2575 themeTweakerUIFieldInputReceived: (event) => {
2576 GWLog("Appearance.themeTweakerUIFieldInputReceived");
2578 let sampleTextFilters = Appearance.currentFilters;
2579 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2580 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2581 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2583 Appearance.themeTweakerUISampleTextContainer.style.filter = Appearance.filterStringFromFilters(sampleTextFilters);
2586 /* Close button in main theme tweaker UI (title bar).
2588 themeTweakerUICloseButtonClicked: (event) => {
2589 GWLog("Appearance.themeTweakerUICloseButtonClicked");
2591 Appearance.toggleThemeTweakerUI();
2592 Appearance.themeTweakReset();
2595 /* Minimize button in main theme tweaker UI (title bar).
2597 themeTweakerUIMinimizeButtonClicked: (event) => {
2598 GWLog("Appearance.themeTweakerUIMinimizeButtonClicked");
2600 Appearance.setThemeTweakerWindowMinimized(event.target.hasClass("minimize"));
2603 /* Help (“?”) button in main theme tweaker UI (title bar).
2605 themeTweakerUIHelpButtonClicked: (event) => {
2606 GWLog("Appearance.themeTweakerUIHelpButtonClicked");
2608 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
2609 Appearance.toggleThemeTweakerHelpWindow();
2612 /* “Reset Defaults” button in main theme tweaker UI.
2614 themeTweakerUIResetDefaultsButtonClicked: (event) => {
2615 GWLog("Appearance.themeTweakerUIResetDefaultsButtonClicked");
2617 Appearance.themeTweakResetDefaults();
2618 Appearance.resetThemeTweakerUIDefaultState();
2621 /* “Cancel” button in main theme tweaker UI.
2623 themeTweakerUICancelButtonClicked: (event) => {
2624 GWLog("Appearance.themeTweakerUICancelButtonClicked");
2626 Appearance.toggleThemeTweakerUI();
2627 Appearance.themeTweakReset();
2630 /* “OK” button in main theme tweaker UI.
2632 themeTweakerUIOKButtonClicked: (event) => {
2633 GWLog("Appearance.themeTweakerUIOKButtonClicked");
2635 Appearance.toggleThemeTweakerUI();
2636 Appearance.themeTweakSave();
2639 /* “Cancel” button in theme tweaker help window.
2641 themeTweakerUIHelpWindowCancelButtonClicked: (event) => {
2642 GWLog("Appearance.themeTweakerUIHelpWindowCancelButtonClicked");
2644 Appearance.toggleThemeTweakerHelpWindow();
2645 Appearance.themeTweakerResetSettings();
2648 /* “OK” button in theme tweaker help window.
2650 themeTweakerUIHelpWindowOKButtonClicked: (event) => {
2651 GWLog("Appearance.themeTweakerUIHelpWindowOKButtonClicked");
2653 Appearance.toggleThemeTweakerHelpWindow();
2654 Appearance.themeTweakerSaveSettings();
2657 /* The notch in the theme tweaker sliders (to reset the slider to its
2660 themeTweakerUISliderNotchClicked: (event) => {
2661 GWLog("Appearance.themeTweakerUISliderNotchClicked");
2663 let slider = event.target.parentElement.query("input[type='range']");
2664 slider.value = slider.dataset["defaultValue"];
2665 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset["labelSuffix"];
2666 Appearance.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset["valueSuffix"];
2667 Appearance.applyFilters();
2670 /* The close button in the “Bobby the Basilisk” help message.
2672 themeTweakerUIClippyCloseButtonClicked: (event) => {
2673 GWLog("Appearance.themeTweakerUIClippyCloseButtonClicked");
2675 Appearance.themeTweakerUIClippyContainer.style.display = "none";
2676 Appearance.themeTweakerUIClippyControl.checked = false;
2677 Appearance.saveThemeTweakerClippyState();
2681 function setSearchBoxTabSelectable(selectable) {
2682 GWLog("setSearchBoxTabSelectable");
2683 query("input[type='search']").tabIndex = selectable ? "" : "-1";
2684 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2687 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
2688 // otherwise, show it.
2689 function updatePostNavUIVisibility() {
2690 GWLog("updatePostNavUIVisibility");
2691 var hidePostNavUIToggle = true;
2692 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
2693 if (getComputedStyle(element).visibility == "visible" ||
2694 element.style.visibility == "visible" ||
2695 element.style.visibility == "unset")
2696 hidePostNavUIToggle = false;
2698 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
2699 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
2703 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
2704 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
2705 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
2706 function updateSiteNavUIState(event) {
2707 GWLog("updateSiteNavUIState");
2708 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
2709 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
2710 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
2712 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
2713 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
2715 GW.scrollState.lastScrollTop = newScrollTop;
2717 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
2718 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
2719 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
2720 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged"))
2721 Appearance.toggleAppearanceAdjustUI();
2724 // On mobile, make site nav UI translucent on ANY scroll down.
2726 GW.scrollState.siteNavUIElements.forEach(element => {
2727 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
2728 else element.removeClass("translucent-on-scroll");
2731 // Show site nav UI when scrolling a full page up, or to the top.
2732 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
2733 GW.scrollState.lastScrollTop == 0) &&
2734 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
2735 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
2737 // On desktop, show appearance adjust UI when scrolling to the top.
2738 if ((!GW.isMobile) &&
2739 (GW.scrollState.lastScrollTop == 0) &&
2740 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
2741 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false"))
2742 Appearance.toggleAppearanceAdjustUI();
2745 /*********************/
2746 /* PAGE QUICK-NAV UI */
2747 /*********************/
2749 function injectQuickNavUI() {
2750 GWLog("injectQuickNavUI");
2751 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2752 `<a href='#top' title="Up to top [,]" accesskey=','></a>
2753 <a href='#comments' title="Comments [/]" accesskey='/'></a>
2754 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
2758 /**********************/
2759 /* NEW COMMENT NAV UI */
2760 /**********************/
2762 function injectNewCommentNavUI(newCommentsCount) {
2763 GWLog("injectNewCommentNavUI");
2764 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
2765 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
2766 <span class='new-comments-count'></span>
2767 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
2770 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2771 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2772 GWLog("GW.commentQuicknavButtonClicked");
2773 scrollToNewComment(/next/.test(event.target.className));
2774 event.target.blur();
2778 document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => {
2779 GWLog("GW.commentQuicknavKeyPressed");
2780 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2781 if (event.key == ",") scrollToNewComment(false);
2782 if (event.key == ".") scrollToNewComment(true)
2785 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2786 + `<span>Since:</span>`
2787 + `<input type='text' class='hns-date'></input>`
2790 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2791 GWLog("GW.hnsDatePickerValueChanged");
2792 let hnsDate = time_fromHuman(event.target.value);
2794 setHistoryLastVisitedDate(hnsDate);
2795 let newCommentsCount = highlightCommentsSince(hnsDate);
2796 updateNewCommentNavUI(newCommentsCount);
2800 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2801 GWLog("GW.newCommentsCountClicked");
2802 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2803 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2807 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2808 function time_fromHuman(string) {
2809 /* Convert a human-readable date into a JS timestamp */
2810 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2811 string = string.replace(' ', 'T'); // revert nice spacing
2812 string += ':00.000Z'; // complete ISO 8601 date
2813 time = Date.parse(string); // milliseconds since epoch
2815 // browsers handle ISO 8601 without explicit timezone differently
2816 // thus, we have to fix that by hand
2817 time += (new Date()).getTimezoneOffset() * 60e3;
2819 string = string.replace(' at', '');
2820 time = Date.parse(string); // milliseconds since epoch
2825 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2826 GWLog("updateNewCommentNavUI");
2827 // Update the new comments count.
2828 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2829 newCommentsCountLabel.innerText = newCommentsCount;
2830 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2832 // Update the date picker field.
2833 if (hnsDate != -1) {
2834 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2838 /********************************/
2839 /* COMMENTS VIEW MODE SELECTION */
2840 /********************************/
2842 function injectCommentsViewModeSelector() {
2843 GWLog("injectCommentsViewModeSelector");
2844 let commentsContainer = query("#comments");
2845 if (commentsContainer == null) return;
2847 let currentModeThreaded = (location.href.search("chrono=t") == -1);
2848 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2850 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2851 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
2852 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
2855 // commentsViewModeSelector.queryAll("a").forEach(button => {
2856 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2859 if (!currentModeThreaded) {
2860 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2861 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2862 commentParentLink.addClass("inline-author");
2863 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2866 queryAll(".comment-child-links a").forEach(commentChildLink => {
2867 commentChildLink.textContent = commentChildLink.textContent.slice(1);
2868 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2871 rectifyChronoModeCommentChildLinks();
2873 commentsContainer.addClass("chrono");
2875 commentsContainer.addClass("threaded");
2878 // Remove extraneous top-level comment thread in chrono mode.
2879 let topLevelCommentThread = query("#comments > .comment-thread");
2880 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2883 // function commentsViewModeSelectorButtonClicked(event) {
2884 // event.preventDefault();
2887 // let request = new XMLHttpRequest();
2888 // request.open("GET", event.target.href);
2889 // request.onreadystatechange = () => {
2890 // if (request.readyState != 4) return;
2891 // newDocument = htmlToElement(request.response);
2893 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2895 // // Update the buttons.
2896 // event.target.addClass("selected");
2897 // event.target.parentElement.query("." + classes.old).removeClass("selected");
2899 // // Update the #comments container.
2900 // let commentsContainer = query("#comments");
2901 // commentsContainer.removeClass(classes.old);
2902 // commentsContainer.addClass(classes.new);
2904 // // Update the content.
2905 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2910 // function htmlToElement(html) {
2911 // let template = newElement("TEMPLATE", { }, { "innerHTML": html.trim() });
2912 // return template.content;
2915 function rectifyChronoModeCommentChildLinks() {
2916 GWLog("rectifyChronoModeCommentChildLinks");
2917 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2918 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2919 let childLinks = commentChildLinksContainer.queryAll("a");
2920 childLinks.forEach((link, index) => {
2921 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2925 let childLinksArray = Array.from(childLinks)
2926 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2927 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2930 function childrenOfComment(commentID) {
2931 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2932 let commentParentLink = commentItem.query("a.comment-parent-link");
2933 return ((commentParentLink||{}).hash == "#" + commentID);
2937 /********************************/
2938 /* COMMENTS LIST MODE SELECTION */
2939 /********************************/
2941 function injectCommentsListModeSelector() {
2942 GWLog("injectCommentsListModeSelector");
2943 if (query("#content > .comment-thread") == null) return;
2945 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2946 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2947 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2950 if (query(".sublevel-nav") || query("#top-nav-bar")) {
2951 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2953 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2955 let commentsListModeSelector = query("#comments-list-mode-selector");
2957 commentsListModeSelector.queryAll("button").forEach(button => {
2958 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2959 GWLog("GW.commentsListModeSelectButtonClicked");
2960 event.target.parentElement.queryAll("button").forEach(button => {
2961 button.removeClass("selected");
2962 button.disabled = false;
2963 button.accessKey = '`';
2965 localStorage.setItem("comments-list-mode", event.target.className);
2966 event.target.addClass("selected");
2967 event.target.disabled = true;
2968 event.target.removeAttribute("accesskey");
2970 if (event.target.hasClass("expanded")) {
2971 query("#content").removeClass("compact");
2973 query("#content").addClass("compact");
2978 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2979 if (savedMode == "compact")
2980 query("#content").addClass("compact");
2981 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2982 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2983 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2986 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2987 commentParentLink.addActivateEvent(function (event) {
2988 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2989 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2995 /**********************/
2996 /* SITE NAV UI TOGGLE */
2997 /**********************/
2999 function injectSiteNavUIToggle() {
3000 GWLog("injectSiteNavUIToggle");
3001 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
3002 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
3003 GWLog("GW.siteNavUIToggleButtonClicked");
3005 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
3008 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
3010 function removeSiteNavUIToggle() {
3011 GWLog("removeSiteNavUIToggle");
3012 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3013 element.removeClass("engaged");
3015 removeElement("#site-nav-ui-toggle");
3017 function toggleSiteNavUI() {
3018 GWLog("toggleSiteNavUI");
3019 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
3020 element.toggleClass("engaged");
3021 element.removeClass("translucent-on-scroll");
3025 /**********************/
3026 /* POST NAV UI TOGGLE */
3027 /**********************/
3029 function injectPostNavUIToggle() {
3030 GWLog("injectPostNavUIToggle");
3031 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
3032 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
3033 GWLog("GW.postNavUIToggleButtonClicked");
3035 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
3038 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
3040 function removePostNavUIToggle() {
3041 GWLog("removePostNavUIToggle");
3042 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3043 element.removeClass("engaged");
3045 removeElement("#post-nav-ui-toggle");
3047 function togglePostNavUI() {
3048 GWLog("togglePostNavUI");
3049 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
3050 element.toggleClass("engaged");
3054 /**************************/
3055 /* WORD COUNT & READ TIME */
3056 /**************************/
3058 function toggleReadTimeOrWordCount(addWordCountClass) {
3059 GWLog("toggleReadTimeOrWordCount");
3060 queryAll(".post-meta .read-time").forEach(element => {
3061 if (addWordCountClass) element.addClass("word-count");
3062 else element.removeClass("word-count");
3064 let titleParts = /(\S+)(.+)$/.exec(element.title);
3065 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
3069 /**************************/
3070 /* PROMPT TO SAVE CHANGES */
3071 /**************************/
3073 function enableBeforeUnload() {
3074 window.onbeforeunload = function () { return true; };
3076 function disableBeforeUnload() {
3077 window.onbeforeunload = null;
3080 /***************************/
3081 /* ORIGINAL POSTER BADGING */
3082 /***************************/
3084 function markOriginalPosterComments() {
3085 GWLog("markOriginalPosterComments");
3086 let postAuthor = query(".post .author");
3087 if (postAuthor == null) return;
3089 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
3090 if (author.dataset.userid == postAuthor.dataset.userid ||
3091 (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
3092 author.addClass("original-poster");
3093 author.title += "Original poster";
3098 /********************************/
3099 /* EDIT POST PAGE SUBMIT BUTTON */
3100 /********************************/
3102 function setEditPostPageSubmitButtonText() {
3103 GWLog("setEditPostPageSubmitButtonText");
3104 if (!query("#content").hasClass("edit-post-page")) return;
3106 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
3107 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
3108 GWLog("GW.postSectionSelectorValueChanged");
3109 updateEditPostPageSubmitButtonText();
3113 updateEditPostPageSubmitButtonText();
3115 function updateEditPostPageSubmitButtonText() {
3116 GWLog("updateEditPostPageSubmitButtonText");
3117 let submitButton = query("input[type='submit']");
3118 if (query("input#drafts").checked == true)
3119 submitButton.value = "Save Draft";
3120 else if (query(".posting-controls").hasClass("edit-existing-post"))
3121 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
3123 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
3130 function numToAlpha(n) {
3133 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
3134 n = Math.floor((n / 26) - 1);
3139 function activateAntiKibitzer() {
3140 GWLog("activateAntiKibitzer");
3142 // Activate anti-kibitzer mode (if needed).
3143 if (localStorage.getItem("antikibitzer") == "true")
3144 toggleAntiKibitzerMode();
3146 // Remove temporary CSS that hides the authors and karma values.
3147 removeElement("#antikibitzer-temp");
3149 // Inject controls (if desktop).
3150 if (GW.isMobile == false)
3151 injectAntiKibitzerToggle();
3154 function injectAntiKibitzerToggle() {
3155 GWLog("injectAntiKibitzerToggle");
3157 let antiKibitzerHTML = `<div id="anti-kibitzer-toggle">
3158 <button type="button" tabindex="-1" accesskey="g" title="Toggle anti-kibitzer (show/hide authors & karma values) [g]"></button>
3162 if (Appearance.themeSelector == null)
3165 Appearance.themeSelectorAuxiliaryControlsContainer.insertAdjacentHTML("beforeend", antiKibitzerHTML);
3167 addUIElement(antiKibitzerHTML);
3170 // Activate anti-kibitzer toggle button.
3171 query("#anti-kibitzer-toggle button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
3172 GWLog("GW.antiKibitzerToggleButtonClicked");
3173 if ( query("#anti-kibitzer-toggle").hasClass("engaged")
3175 && !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!)")) {
3176 event.target.blur();
3180 toggleAntiKibitzerMode();
3181 event.target.blur();
3185 function toggleAntiKibitzerMode() {
3186 GWLog("toggleAntiKibitzerMode");
3187 // This will be the URL of the user's own page, if logged in, or the URL of
3188 // the login page otherwise.
3189 let userTabTarget = query("#nav-item-login .nav-inner").href;
3190 let pageHeadingElement = query("h1.page-main-heading");
3193 let userFakeName = { };
3195 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
3197 let postAuthor = query(".post-page .post-meta .author");
3198 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
3200 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
3201 if (antiKibitzerToggle.hasClass("engaged")) {
3202 localStorage.setItem("antikibitzer", "false");
3204 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
3205 if (redirectTarget) {
3206 window.location = redirectTarget;
3210 // Individual comment page title and header
3211 if (query(".individual-thread-page")) {
3212 let replacer = (node) => {
3214 node.firstChild.replaceWith(node.dataset["trueContent"]);
3216 replacer(query("title:not(.fake-title)"));
3217 replacer(query("#content > h1"));
3220 // Author names/links.
3221 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
3222 author.textContent = author.dataset["trueName"];
3223 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
3225 author.removeClass("redacted");
3227 // Post/comment karma values.
3228 queryAll(".karma-value.redacted").forEach(karmaValue => {
3229 karmaValue.innerHTML = karmaValue.dataset["trueValue"];
3231 karmaValue.removeClass("redacted");
3233 // Link post domains.
3234 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
3235 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
3237 linkPostDomain.removeClass("redacted");
3240 antiKibitzerToggle.removeClass("engaged");
3242 localStorage.setItem("antikibitzer", "true");
3244 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
3245 if (redirectTarget) {
3246 window.location = redirectTarget;
3250 // Individual comment page title and header
3251 if (query(".individual-thread-page")) {
3252 let replacer = (node) => {
3254 node.dataset["trueContent"] = node.firstChild.wholeText;
3255 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
3256 node.firstChild.replaceWith(newText);
3258 replacer(query("title:not(.fake-title)"));
3259 replacer(query("#content > h1"));
3262 removeElement("title.fake-title");
3264 // Author names/links.
3265 queryAll(".author, .inline-author").forEach(author => {
3266 // Skip own posts/comments.
3267 if (author.hasClass("own-user-author"))
3270 let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
3274 author.dataset["trueName"] = author.textContent;
3275 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
3277 if (/\/user/.test(author.href)) {
3278 author.dataset["trueLink"] = author.pathname;
3279 author.href = "/user?id=" + author.dataset["userid"];
3282 author.addClass("redacted");
3284 // Post/comment karma values.
3285 queryAll(".karma-value").forEach(karmaValue => {
3286 // Skip own posts/comments.
3287 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
3290 karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
3291 karmaValue.innerHTML = "##<span> points</span>";
3293 karmaValue.addClass("redacted");
3295 // Link post domains.
3296 queryAll(".link-post-domain").forEach(linkPostDomain => {
3297 // Skip own posts/comments.
3298 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
3301 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
3302 linkPostDomain.textContent = "redacted.domain.tld";
3304 linkPostDomain.addClass("redacted");
3307 antiKibitzerToggle.addClass("engaged");
3311 /*******************************/
3312 /* COMMENT SORT MODE SELECTION */
3313 /*******************************/
3315 var CommentSortMode = Object.freeze({
3321 function sortComments(mode) {
3322 GWLog("sortComments");
3323 let commentsContainer = query("#comments");
3325 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
3326 commentsContainer.addClass("sorting");
3328 GW.commentValues = { };
3329 let clonedCommentsContainer = commentsContainer.cloneNode(true);
3330 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
3333 case CommentSortMode.NEW:
3334 comparator = (a,b) => commentDate(b) - commentDate(a);
3336 case CommentSortMode.OLD:
3337 comparator = (a,b) => commentDate(a) - commentDate(b);
3339 case CommentSortMode.HOT:
3340 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
3342 case CommentSortMode.TOP:
3344 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
3347 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
3349 removeElement(commentsContainer.lastChild);
3350 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
3351 GW.commentValues = { };
3353 if (loggedInUserId) {
3354 // Re-activate vote buttons.
3355 commentsContainer.queryAll("button.vote").forEach(voteButton => {
3356 voteButton.addActivateEvent(voteButtonClicked);
3359 // Re-activate comment action buttons.
3360 commentsContainer.queryAll(".action-button").forEach(button => {
3361 button.addActivateEvent(GW.commentActionButtonClicked);
3365 // Re-activate comment-minimize buttons.
3366 queryAll(".comment-minimize-button").forEach(button => {
3367 button.addActivateEvent(GW.commentMinimizeButtonClicked);
3370 // Re-add comment parent popups.
3371 addCommentParentPopups();
3373 // Redo new-comments highlighting.
3374 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
3376 requestAnimationFrame(() => {
3377 commentsContainer.removeClass("sorting");
3378 commentsContainer.addClass("sorted-" + mode);
3381 function commentKarmaValue(commentOrSelector) {
3382 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3384 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
3385 } catch(e) {return null};
3387 function commentDate(commentOrSelector) {
3388 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3390 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
3391 } catch(e) {return null};
3393 function commentVoteCount(commentOrSelector) {
3394 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3396 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
3397 } catch(e) {return null};
3400 function injectCommentsSortModeSelector() {
3401 GWLog("injectCommentsSortModeSelector");
3402 let topCommentThread = query("#comments > .comment-thread");
3403 if (topCommentThread == null) return;
3405 // Do not show sort mode selector if there is no branching in comment tree.
3406 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
3408 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
3409 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
3411 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
3412 let commentsSortModeSelector = query("#comments-sort-mode-selector");
3414 commentsSortModeSelector.queryAll("button").forEach(button => {
3415 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
3416 GWLog("GW.commentsSortModeSelectButtonClicked");
3417 event.target.parentElement.queryAll("button").forEach(button => {
3418 button.removeClass("selected");
3419 button.disabled = false;
3421 event.target.addClass("selected");
3422 event.target.disabled = true;
3424 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
3425 setCommentsSortModeSelectButtonsAccesskey();
3429 // TODO: Make this actually get the current sort mode (if that's saved).
3430 // TODO: Also change the condition here to properly get chrono/threaded mode,
3431 // when that is properly done with cookies.
3432 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
3433 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
3434 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
3435 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
3436 setCommentsSortModeSelectButtonsAccesskey();
3439 function setCommentsSortModeSelectButtonsAccesskey() {
3440 GWLog("setCommentsSortModeSelectButtonsAccesskey");
3441 queryAll("#comments-sort-mode-selector button").forEach(button => {
3442 button.removeAttribute("accesskey");
3443 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
3445 let selectedButton = query("#comments-sort-mode-selector button.selected");
3446 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
3447 nextButtonInCycle.accessKey = "z";
3448 nextButtonInCycle.title += " [z]";
3451 /*************************/
3452 /* COMMENT PARENT POPUPS */
3453 /*************************/
3455 function previewPopupsEnabled() {
3456 let isDisabled = localStorage.getItem("preview-popups-disabled");
3457 return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
3460 function setPreviewPopupsEnabled(state) {
3461 localStorage.setItem("preview-popups-disabled", !state);
3462 updatePreviewPopupToggle();
3465 function updatePreviewPopupToggle() {
3466 let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
3467 query("#preview-popup-toggle").setAttribute("style", style);
3470 function injectPreviewPopupToggle() {
3471 GWLog("injectPreviewPopupToggle");
3473 let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
3474 // This is required because Chrome can't use filters on an externally used SVG element.
3475 fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
3476 updatePreviewPopupToggle();
3477 toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
3480 var currentPreviewPopup = { };
3482 function removePreviewPopup(previewPopup) {
3483 if(previewPopup.element)
3484 removeElement(previewPopup.element);
3486 if(previewPopup.timeout)
3487 clearTimeout(previewPopup.timeout);
3489 if(currentPreviewPopup.pointerListener)
3490 window.removeEventListener("pointermove", previewPopup.pointerListener);
3492 if(currentPreviewPopup.mouseoutListener)
3493 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3495 if(currentPreviewPopup.scrollListener)
3496 window.removeEventListener("scroll", previewPopup.scrollListener);
3498 currentPreviewPopup = { };
3501 document.addEventListener("visibilitychange", () => {
3502 if(document.visibilityState != "visible") {
3503 removePreviewPopup(currentPreviewPopup);
3507 function addCommentParentPopups() {
3508 GWLog("addCommentParentPopups");
3509 //if (!query("#content").hasClass("comment-thread-page")) return;
3511 queryAll("a[href]").forEach(linkTag => {
3512 let linkHref = linkTag.getAttribute("href");
3515 try { url = new URL(linkHref, window.location.href); }
3519 if(GW.sites[url.host]) {
3520 let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
3522 if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
3523 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
3524 if(event.pointerType == "touch") return;
3525 GWLog("GW.commentParentLinkMouseOver");
3526 removePreviewPopup(currentPreviewPopup);
3527 let parentID = linkHref;
3529 if (!(parent = (query(parentID)||{}).firstChild)) return;
3530 var highlightClassName;
3531 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
3532 parentHighlightClassName = "comment-item-highlight-faint";
3533 popup = parent.cloneNode(true);
3534 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
3535 linkTag.addEventListener("mouseout", (event) => {
3536 removeElement(popup);
3538 linkTag.closest(".comments > .comment-thread").appendChild(popup);
3540 parentHighlightClassName = "comment-item-highlight";
3542 parent.parentNode.addClass(parentHighlightClassName);
3543 linkTag.addEventListener("mouseout", (event) => {
3544 parent.parentNode.removeClass(parentHighlightClassName);
3548 else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
3549 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
3550 && !(url.searchParams.get('format'))
3551 && !linkTag.closest("nav:not(.post-nav-links)")
3552 && (!url.hash || linkCommentId)
3553 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
3554 linkTag.addEventListener("pointerover", event => {
3555 if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
3556 if(currentPreviewPopup.linkTag) return;
3557 linkTag.createPreviewPopup();
3559 linkTag.createPreviewPopup = function() {
3560 removePreviewPopup(currentPreviewPopup);
3562 currentPreviewPopup = {linkTag: linkTag};
3564 let popup = newElement("IFRAME");
3565 currentPreviewPopup.element = popup;
3567 let popupTarget = linkHref;
3568 if(popupTarget.match(/#comment-/)) {
3569 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
3571 // 'theme' attribute is required for proper caching
3572 popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview");
3573 popup.addClass("preview-popup");
3575 let linkRect = linkTag.getBoundingClientRect();
3577 if(linkRect.right + 710 < window.innerWidth)
3578 popup.style.left = linkRect.right + 10 + "px";
3580 popup.style.right = "10px";
3582 popup.style.width = "700px";
3583 popup.style.height = "500px";
3584 popup.style.visibility = "hidden";
3585 popup.style.transition = "none";
3587 let recenter = function() {
3588 let popupHeight = 500;
3589 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
3590 let popupContent = popup.contentDocument.querySelector("#content");
3592 popupHeight = popupContent.clientHeight + 2;
3593 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
3594 popup.style.height = popupHeight + "px";
3597 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
3602 query('#content').insertAdjacentElement("beforeend", popup);
3604 let clickListener = event => {
3605 if(!event.target.closest("a, input, label")
3606 && !event.target.closest("popup-hide-button")) {
3607 window.location = linkHref;
3611 popup.addEventListener("load", () => {
3612 let hideButton = newElement("DIV", {
3613 "class": "popup-hide-button"
3615 "innerHTML": ""
3617 hideButton.onclick = (event) => {
3618 removePreviewPopup(currentPreviewPopup);
3619 setPreviewPopupsEnabled(false);
3620 event.stopPropagation();
3622 popup.contentDocument.body.appendChild(hideButton);
3624 let popupBody = popup.contentDocument.body;
3625 popupBody.addEventListener("click", clickListener);
3626 popupBody.style.cursor = "pointer";
3631 popup.contentDocument.body.addEventListener("click", clickListener);
3633 currentPreviewPopup.timeout = setTimeout(() => {
3636 requestIdleCallback(() => {
3637 if(currentPreviewPopup.element === popup) {
3638 popup.scrolling = "";
3639 popup.style.visibility = "unset";
3640 popup.style.transition = null;
3643 { opacity: 0, transform: "translateY(10%)" },
3644 { opacity: 1, transform: "none" }
3645 ], { duration: 150, easing: "ease-out" });
3650 let pointerX, pointerY, mousePauseTimeout = null;
3652 currentPreviewPopup.pointerListener = (event) => {
3653 pointerX = event.clientX;
3654 pointerY = event.clientY;
3656 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3657 mousePauseTimeout = null;
3659 let overElement = document.elementFromPoint(pointerX, pointerY);
3660 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3662 if(mouseIsOverLink || overElement === popup
3663 || (pointerX < popup.getBoundingClientRect().left
3664 && event.movementX >= 0)) {
3665 if(!mouseIsOverLink && overElement !== popup) {
3666 if(overElement['createPreviewPopup']) {
3667 mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3669 mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3673 removePreviewPopup(currentPreviewPopup);
3674 if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3677 window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3679 currentPreviewPopup.mouseoutListener = (event) => {
3680 clearTimeout(mousePauseTimeout);
3681 mousePauseTimeout = null;
3683 document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3685 currentPreviewPopup.scrollListener = (event) => {
3686 let overElement = document.elementFromPoint(pointerX, pointerY);
3687 linkRect = linkTag.getBoundingClientRect();
3688 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3689 removePreviewPopup(currentPreviewPopup);
3691 window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3696 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3700 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3701 Appearance.filtersExclusionPaths.commentParentPopups = [
3702 "#content .comments .comment-thread"
3704 Appearance.applyFilters();
3711 function imageFocusSetup(imagesOverlayOnly = false) {
3712 if (typeof GW.imageFocus == "undefined")
3714 contentImagesSelector: "#content img",
3715 overlayImagesSelector: "#images-overlay img",
3716 focusedImageSelector: "#content img.focused, #images-overlay img.focused",
3717 pageContentSelector: "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3719 hideUITimerDuration: 1500,
3720 hideUITimerExpired: () => {
3721 GWLog("GW.imageFocus.hideUITimerExpired");
3722 let currentTime = new Date();
3723 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3724 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3725 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3728 cancelImageFocusHideUITimer();
3733 GWLog("imageFocusSetup");
3734 // Create event listener for clicking on images to focus them.
3735 GW.imageClickedToFocus = (event) => {
3736 GWLog("GW.imageClickedToFocus");
3737 focusImage(event.target);
3740 // Set timer to hide the image focus UI.
3741 unhideImageFocusUI();
3742 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3745 // Add the listener to each image in the overlay (i.e., those in the post).
3746 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3747 image.addActivateEvent(GW.imageClickedToFocus);
3749 // Accesskey-L starts the slideshow.
3750 (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3751 // Count how many images there are in the post, and set the "… of X" label to that.
3752 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3753 if (imagesOverlayOnly) return;
3754 // Add the listener to all other content images (including those in comments).
3755 queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3756 image.addActivateEvent(GW.imageClickedToFocus);
3759 // Create the image focus overlay.
3760 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
3761 `<div class='help-overlay'>
3762 <p><strong>Arrow keys:</strong> Next/previous image</p>
3763 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3764 <p><strong>Space bar:</strong> Reset image size & position</p>
3765 <p><strong>Scroll</strong> to zoom in/out</p>
3766 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3768 <div class='image-number'></div>
3769 <div class='slideshow-buttons'>
3770 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
3771 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
3773 <div class='caption'></div>` +
3775 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3777 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3778 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3779 GWLog("GW.imageFocus.slideshowButtonClicked");
3780 focusNextImage(event.target.hasClass("next"));
3781 event.target.blur();
3785 // On orientation change, reset the size & position.
3786 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3787 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3790 // UI starts out hidden.
3794 function focusImage(imageToFocus) {
3795 GWLog("focusImage");
3796 // Clear 'last-focused' class of last focused image.
3797 let lastFocusedImage = query("img.last-focused");
3798 if (lastFocusedImage) {
3799 lastFocusedImage.removeClass("last-focused");
3800 lastFocusedImage.removeAttribute("accesskey");
3803 // Create the focused version of the image.
3804 imageToFocus.addClass("focused");
3805 let imageFocusOverlay = query("#image-focus-overlay");
3806 let clonedImage = imageToFocus.cloneNode(true);
3807 clonedImage.style = "";
3808 clonedImage.removeAttribute("width");
3809 clonedImage.removeAttribute("height");
3810 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3811 imageFocusOverlay.appendChild(clonedImage);
3812 imageFocusOverlay.addClass("engaged");
3814 // Set image to default size and position.
3815 resetFocusedImagePosition();
3817 // Blur everything else.
3818 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3819 element.addClass("blurred");
3822 // Add listener to zoom image with scroll wheel.
3823 window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3824 GWLog("GW.imageFocus.scrollEvent");
3825 event.preventDefault();
3827 let image = query("#image-focus-overlay img");
3829 // Remove the filter.
3830 image.savedFilter = image.style.filter;
3831 image.style.filter = 'none';
3833 // Locate point under cursor.
3834 let imageBoundingBox = image.getBoundingClientRect();
3836 // Calculate resize factor.
3837 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3838 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3842 image.style.width = (event.deltaY < 0 ?
3843 (image.clientWidth * factor) :
3844 (image.clientWidth / factor))
3846 image.style.height = "";
3848 // Designate zoom origin.
3850 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3851 // the cursor is over the image.
3852 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3853 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3854 (imageBoundingBox.left <= event.clientX &&
3855 event.clientX <= imageBoundingBox.right &&
3856 imageBoundingBox.top <= event.clientY &&
3857 event.clientY <= imageBoundingBox.bottom);
3858 // Otherwise, if we're zooming OUT, zoom from window center; if we're
3859 // zooming IN, zoom from image center.
3860 let zoomingFromWindowCenter = event.deltaY > 0;
3861 if (zoomingFromCursor)
3862 zoomOrigin = { x: event.clientX,
3864 else if (zoomingFromWindowCenter)
3865 zoomOrigin = { x: window.innerWidth / 2,
3866 y: window.innerHeight / 2 };
3868 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
3869 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3871 // Calculate offset from zoom origin.
3872 let offsetOfImageFromZoomOrigin = {
3873 x: imageBoundingBox.x - zoomOrigin.x,
3874 y: imageBoundingBox.y - zoomOrigin.y
3876 // Calculate delta from centered zoom.
3877 let deltaFromCenteredZoom = {
3878 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3879 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3881 // Adjust image position appropriately.
3882 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3883 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3884 // Gradually re-center image, if it's smaller than the window.
3885 if (!imageSizeExceedsWindowBounds) {
3886 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
3887 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3888 let windowCenter = { x: window.innerWidth / 2,
3889 y: window.innerHeight / 2 }
3890 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3891 y: windowCenter.y - imageCenter.y }
3892 // Divide the offset by 10 because we're nudging the image toward center,
3893 // not jumping it there.
3894 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3895 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3898 // Put the filter back.
3899 image.style.filter = image.savedFilter;
3901 // Set the cursor appropriately.
3902 setFocusedImageCursor();
3904 window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3905 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3906 event.preventDefault();
3909 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3910 window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3911 GWLog("GW.imageFocus.mouseUp");
3912 window.onmousemove = '';
3914 // We only want to do anything on left-clicks.
3915 if (event.button != 0) return;
3917 // Don't unfocus if click was on a slideshow next/prev button!
3918 if (event.target.hasClass("slideshow-button")) return;
3920 // We also don't want to do anything if clicked on the help overlay.
3921 if (event.target.classList.contains("help-overlay") ||
3922 event.target.closest(".help-overlay"))
3925 let focusedImage = query("#image-focus-overlay img");
3926 if (event.target == focusedImage &&
3927 (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3928 // If the mouseup event was the end of a pan of an overside image,
3929 // put the filter back; do not unfocus.
3930 focusedImage.style.filter = focusedImage.savedFilter;
3932 unfocusImageOverlay();
3936 window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3937 GWLog("GW.imageFocus.mouseDown");
3938 event.preventDefault();
3940 let focusedImage = query("#image-focus-overlay img");
3941 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3942 let mouseCoordX = event.clientX;
3943 let mouseCoordY = event.clientY;
3945 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3946 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3949 focusedImage.savedFilter = focusedImage.style.filter;
3951 window.onmousemove = (event) => {
3952 // Remove the filter.
3953 focusedImage.style.filter = "none";
3954 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3955 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3961 // Double-click on the image unfocuses.
3962 clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3963 GWLog("GW.imageFocus.doubleClick");
3964 if (event.target.hasClass("slideshow-button")) return;
3966 unfocusImageOverlay();
3969 // Escape key unfocuses, spacebar resets.
3970 document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3971 GWLog("GW.imageFocus.keyUp");
3972 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3973 if (!allowedKeys.contains(event.key) ||
3974 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3976 event.preventDefault();
3978 switch (event.key) {
3981 unfocusImageOverlay();
3985 resetFocusedImagePosition();
3991 if (query("#images-overlay img.focused")) focusNextImage(true);
3997 if (query("#images-overlay img.focused")) focusNextImage(false);
4002 // Prevent spacebar or arrow keys from scrolling page when image focused.
4003 togglePageScrolling(false);
4005 // If the image comes from the images overlay, for the main post...
4006 if (imageToFocus.closest("#images-overlay")) {
4007 // Mark the overlay as being in slide show mode (to show buttons/count).
4008 imageFocusOverlay.addClass("slideshow");
4010 // Set state of next/previous buttons.
4011 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4012 var indexOfFocusedImage = getIndexOfFocusedImage();
4013 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4014 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4016 // Set the image number.
4017 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4019 // Replace the hash.
4020 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4022 imageFocusOverlay.removeClass("slideshow");
4026 setImageFocusCaption();
4028 // Moving mouse unhides image focus UI.
4029 window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
4030 GWLog("GW.imageFocus.mouseMoved");
4031 let currentDateTime = new Date();
4032 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
4033 cancelImageFocusHideUITimer();
4035 if (!GW.imageFocus.hideUITimer) {
4036 unhideImageFocusUI();
4037 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4039 GW.imageFocus.mouseLastMovedAt = currentDateTime;
4044 function resetFocusedImagePosition() {
4045 GWLog("resetFocusedImagePosition");
4046 let focusedImage = query("#image-focus-overlay img");
4047 if (!focusedImage) return;
4049 let sourceImage = query(GW.imageFocus.focusedImageSelector);
4051 // Make sure that initially, the image fits into the viewport.
4052 let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
4053 let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
4054 var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
4055 let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
4056 let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
4057 focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
4058 focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
4060 // Remove modifications to position.
4061 focusedImage.style.left = "";
4062 focusedImage.style.top = "";
4064 // Set the cursor appropriately.
4065 setFocusedImageCursor();
4067 function setFocusedImageCursor() {
4068 let focusedImage = query("#image-focus-overlay img");
4069 if (!focusedImage) return;
4070 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
4074 function unfocusImageOverlay() {
4075 GWLog("unfocusImageOverlay");
4077 // Remove event listeners.
4078 window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
4079 window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
4080 // NOTE: The double-click listener does not need to be removed manually,
4081 // because the focused (cloned) image will be removed anyway.
4082 document.removeEventListener("keyup", GW.imageFocus.keyUp);
4083 document.removeEventListener("keydown", GW.imageFocus.keyDown);
4084 window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
4085 window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
4086 window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
4088 // Set accesskey of currently focused image (if it's in the images overlay).
4089 let currentlyFocusedImage = query("#images-overlay img.focused");
4090 if (currentlyFocusedImage) {
4091 currentlyFocusedImage.addClass("last-focused");
4092 currentlyFocusedImage.accessKey = 'l';
4095 // Remove focused image and hide overlay.
4096 let imageFocusOverlay = query("#image-focus-overlay");
4097 imageFocusOverlay.removeClass("engaged");
4098 removeElement(imageFocusOverlay.query("img"));
4100 // Un-blur content/etc.
4101 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
4102 element.removeClass("blurred");
4105 // Unset "focused" class of focused image.
4106 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4108 // Re-enable page scrolling.
4109 togglePageScrolling(true);
4111 // Reset the hash, if needed.
4112 if (location.hash.hasPrefix("#if_slide_"))
4113 history.replaceState(window.history.state, null, "#");
4116 function getIndexOfFocusedImage() {
4117 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4118 var indexOfFocusedImage = -1;
4119 for (i = 0; i < images.length; i++) {
4120 if (images[i].hasClass("focused")) {
4121 indexOfFocusedImage = i;
4125 return indexOfFocusedImage;
4128 function focusNextImage(next = true) {
4129 GWLog("focusNextImage");
4130 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4131 var indexOfFocusedImage = getIndexOfFocusedImage();
4133 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
4135 // Remove existing image.
4136 removeElement("#image-focus-overlay img");
4137 // Unset "focused" class of just-removed image.
4138 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
4140 // Create the focused version of the image.
4141 images[indexOfFocusedImage].addClass("focused");
4142 let imageFocusOverlay = query("#image-focus-overlay");
4143 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
4144 clonedImage.style = "";
4145 clonedImage.removeAttribute("width");
4146 clonedImage.removeAttribute("height");
4147 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
4148 imageFocusOverlay.appendChild(clonedImage);
4149 imageFocusOverlay.addClass("engaged");
4150 // Set image to default size and position.
4151 resetFocusedImagePosition();
4152 // Set state of next/previous buttons.
4153 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
4154 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
4155 // Set the image number display.
4156 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
4158 setImageFocusCaption();
4159 // Replace the hash.
4160 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
4163 function setImageFocusCaption() {
4164 GWLog("setImageFocusCaption");
4165 var T = { }; // Temporary storage.
4167 // Clear existing caption, if any.
4168 let captionContainer = query("#image-focus-overlay .caption");
4169 Array.from(captionContainer.children).forEach(child => { child.remove(); });
4171 // Determine caption.
4172 let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
4174 if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
4175 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
4176 captionHTML = (T.figcaption.query("p")) ?
4177 T.figcaption.innerHTML :
4178 "<p>" + T.figcaption.innerHTML + "</p>";
4179 } else if (currentlyFocusedImage.title != "") {
4180 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
4182 // Insert the caption, if any.
4183 if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
4186 function hideImageFocusUI() {
4187 GWLog("hideImageFocusUI");
4188 let imageFocusOverlay = query("#image-focus-overlay");
4189 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4190 element.addClass("hidden");
4194 function unhideImageFocusUI() {
4195 GWLog("unhideImageFocusUI");
4196 let imageFocusOverlay = query("#image-focus-overlay");
4197 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
4198 element.removeClass("hidden");
4202 function cancelImageFocusHideUITimer() {
4203 clearTimeout(GW.imageFocus.hideUITimer);
4204 GW.imageFocus.hideUITimer = null;
4211 function keyboardHelpSetup() {
4212 let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
4213 <div class='keyboard-help-container'>
4214 <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'></button>
4215 <h1>Keyboard shortcuts</h1>
4216 <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>
4217 <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
4218 <div class='keyboard-shortcuts-lists'>` + [ [
4220 [ [ '?' ], "Show keyboard shortcuts" ],
4221 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
4224 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
4225 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
4226 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
4227 [ [ 'ak-m' ], "Go to Meta view" ],
4228 [ [ 'ak-v' ], "Go to Tags view"],
4229 [ [ 'ak-c' ], "Go to Recent Comments view" ],
4230 [ [ 'ak-r' ], "Go to Archive view" ],
4231 [ [ 'ak-q' ], "Go to Sequences view" ],
4232 [ [ 'ak-t' ], "Go to About page" ],
4233 [ [ 'ak-u' ], "Go to User or Login page" ],
4234 [ [ 'ak-o' ], "Go to Inbox page" ]
4237 [ [ 'ak-,' ], "Jump up to top of page" ],
4238 [ [ 'ak-.' ], "Jump down to bottom of page" ],
4239 [ [ 'ak-/' ], "Jump to top of comments section" ],
4240 [ [ 'ak-s' ], "Search" ],
4243 [ [ 'ak-n' ], "New post or comment" ],
4244 [ [ 'ak-e' ], "Edit current post" ]
4246 "Post/comment list views",
4247 [ [ '.' ], "Focus next entry in list" ],
4248 [ [ ',' ], "Focus previous entry in list" ],
4249 [ [ ';' ], "Cycle between links in focused entry" ],
4250 [ [ 'Enter' ], "Go to currently focused entry" ],
4251 [ [ 'Esc' ], "Unfocus currently focused entry" ],
4252 [ [ 'ak-]' ], "Go to next page" ],
4253 [ [ 'ak-[' ], "Go to previous page" ],
4254 [ [ 'ak-\\' ], "Go to first page" ],
4255 [ [ 'ak-e' ], "Edit currently focused post" ]
4258 [ [ 'ak-k' ], "Bold text" ],
4259 [ [ 'ak-i' ], "Italic text" ],
4260 [ [ 'ak-l' ], "Insert hyperlink" ],
4261 [ [ 'ak-q' ], "Blockquote text" ]
4264 [ [ 'ak-=' ], "Increase text size" ],
4265 [ [ 'ak--' ], "Decrease text size" ],
4266 [ [ 'ak-0' ], "Reset to default text size" ],
4267 [ [ 'ak-′' ], "Cycle through content width settings" ],
4268 [ [ 'ak-1' ], "Switch to default theme [A]" ],
4269 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
4270 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
4271 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
4272 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
4273 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
4274 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
4275 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
4276 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
4277 [ [ 'ak-;' ], "Open theme tweaker" ],
4278 [ [ 'Enter' ], "Save changes and close theme tweaker "],
4279 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
4282 [ [ 'ak-l' ], "Start/resume slideshow" ],
4283 [ [ 'Esc' ], "Exit slideshow" ],
4284 [ [ '→', '↓' ], "Next slide" ],
4285 [ [ '←', '↑' ], "Previous slide" ],
4286 [ [ 'Space' ], "Reset slide zoom" ]
4289 [ [ 'ak-x' ], "Switch to next view on user page" ],
4290 [ [ 'ak-z' ], "Switch to previous view on user page" ],
4291 [ [ 'ak-` ' ], "Toggle compact comment list view" ],
4292 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
4294 `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
4296 <span class='keys'>` +
4298 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
4301 <span class='action'>${entry[1]}</span>
4303 ).join("\n") + `</ul>`).join("\n") + `
4308 // Add listener to show the keyboard help overlay.
4309 document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
4310 GWLog("GW.keyboardHelpShowKeyPressed");
4311 if (event.key == '?')
4312 toggleKeyboardHelpOverlay(true);
4315 // Clicking the background overlay closes the keyboard help overlay.
4316 keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
4317 GWLog("GW.keyboardHelpOverlayClicked");
4318 if (event.type == "mousedown") {
4319 keyboardHelpOverlay.style.opacity = "0.01";
4321 toggleKeyboardHelpOverlay(false);
4322 keyboardHelpOverlay.style.opacity = "1.0";
4326 // Intercept clicks, so they don't "fall through" the background overlay.
4327 (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
4329 // Clicking the close button closes the keyboard help overlay.
4330 keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
4331 toggleKeyboardHelpOverlay(false);
4334 // Add button to open keyboard help.
4335 query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'></button>");
4336 query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
4337 GWLog("GW.openKeyboardHelpButtonClicked");
4338 toggleKeyboardHelpOverlay(true);
4339 event.target.blur();
4343 function toggleKeyboardHelpOverlay(show) {
4344 console.log("toggleKeyboardHelpOverlay");
4346 let keyboardHelpOverlay = query("#keyboard-help-overlay");
4347 show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
4348 keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
4350 // Prevent scrolling the document when the overlay is visible.
4351 togglePageScrolling(!show);
4353 // Focus the close button as soon as we open.
4354 keyboardHelpOverlay.query("button.close-keyboard-help").focus();
4357 // Add listener to show the keyboard help overlay.
4358 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
4359 GWLog("GW.keyboardHelpHideKeyPressed");
4360 if (event.key == 'Escape')
4361 toggleKeyboardHelpOverlay(false);
4364 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
4367 // Disable / enable tab-selection of the search box.
4368 setSearchBoxTabSelectable(!show);
4371 /**********************/
4372 /* PUSH NOTIFICATIONS */
4373 /**********************/
4375 function pushNotificationsSetup() {
4376 let pushNotificationsButton = query("#enable-push-notifications");
4377 if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
4378 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
4379 pushNotificationsButton.style.display = 'unset';
4383 function urlBase64ToUint8Array(base64String) {
4384 const padding = '='.repeat((4 - base64String.length % 4) % 4);
4385 const base64 = (base64String + padding)
4387 .replace(/_/g, '/');
4389 const rawData = window.atob(base64);
4390 const outputArray = new Uint8Array(rawData.length);
4392 for (let i = 0; i < rawData.length; ++i) {
4393 outputArray[i] = rawData.charCodeAt(i);
4398 function pushNotificationsButtonClicked(event) {
4399 event.target.style.opacity = 0.33;
4400 event.target.style.pointerEvents = "none";
4402 let reEnable = (message) => {
4403 if(message) alert(message);
4404 event.target.style.opacity = 1;
4405 event.target.style.pointerEvents = "unset";
4408 if(event.target.dataset.enabled) {
4409 fetch('/push/register', {
4411 headers: { 'Content-type': 'application/json' },
4412 body: JSON.stringify({
4416 event.target.innerHTML = "Enable push notifications";
4417 event.target.dataset.enabled = "";
4419 }).catch((err) => reEnable(err.message));
4421 Notification.requestPermission().then((permission) => {
4422 navigator.serviceWorker.ready
4423 .then((registration) => {
4424 return registration.pushManager.getSubscription()
4425 .then(async function(subscription) {
4427 return subscription;
4429 return registration.pushManager.subscribe({
4430 userVisibleOnly: true,
4431 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
4434 .catch((err) => reEnable(err.message));
4436 .then((subscription) => {
4437 fetch('/push/register', {
4440 'Content-type': 'application/json'
4442 body: JSON.stringify({
4443 subscription: subscription
4448 event.target.innerHTML = "Disable push notifications";
4449 event.target.dataset.enabled = "true";
4452 .catch(function(err){ reEnable(err.message) });
4458 /*******************************/
4459 /* HTML TO MARKDOWN CONVERSION */
4460 /*******************************/
4462 function MarkdownFromHTML(text, linePrefix) {
4463 GWLog("MarkdownFromHTML");
4466 let docFrag = document.createRange().createContextualFragment(text);
4469 linePrefix = linePrefix || "";
4473 output += ("\n" + linePrefix).repeat(owedLines);
4478 let forceLine = n => {
4480 out(("\n" + linePrefix).repeat(n));
4482 let newLine = (n) => {
4484 if(owedLines >= 0 && owedLines < n) {
4488 let newParagraph = () => {
4491 let withPrefix = (prefix, fn) => {
4492 let oldPrefix = linePrefix;
4493 linePrefix += prefix;
4497 linePrefix = oldPrefix;
4500 let doConversion = (node) => {
4501 if(node.nodeType == Node.TEXT_NODE) {
4502 let lines = node.nodeValue.split(/\r|\n/m);
4503 for(text of lines.slice(0, -1)) {
4508 } else if(node.nodeType == Node.ELEMENT_NODE) {
4509 switch(node.tagName) {
4515 node.childNodes.forEach(doConversion);
4529 node.childNodes.forEach(doConversion);
4535 node.childNodes.forEach(doConversion);
4541 if(node.parentElement.tagName == "OL") {
4543 for(let e = node; e = e.previousElementSibling;) { i++ }
4544 listPrefix = "" + i + ". ";
4550 withPrefix(" ".repeat(listPrefix.length), () => node.childNodes.forEach(doConversion));
4560 out("#".repeat(node.tagName.charAt(1)) + " ");
4561 node.childNodes.forEach(doConversion);
4565 let href = node.href;
4567 node.childNodes.forEach(doConversion);
4572 let alt = node.alt || "";
4573 out(`![${alt}](${src})`);
4578 withPrefix("> ", () => node.childNodes.forEach(doConversion));
4585 out(node.innerText);
4592 node.childNodes.forEach(doConversion);
4596 node.childNodes.forEach(doConversion);
4599 node.childNodes.forEach(doConversion);
4602 doConversion(docFrag);
4607 /************************************/
4608 /* ANCHOR LINK SCROLLING WORKAROUND */
4609 /************************************/
4611 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
4612 let hash = location.hash;
4613 if(hash && hash !== "#top" && !document.query(hash)) {
4614 let content = document.query("#content");
4615 content.style.display = "none";
4616 addTriggerListener("DOMReady", {priority: -1, fn: () => {
4617 content.style.visibility = "hidden";
4618 content.style.display = null;
4619 requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
4624 /******************/
4625 /* INITIALIZATION */
4626 /******************/
4628 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
4629 GWLog("INITIALIZER earlyInitialize");
4630 // Check to see whether we're on a mobile device (which we define as a narrow screen)
4631 GW.isMobile = (window.innerWidth <= 1160);
4632 GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
4634 // Backward compatibility
4635 let storedTheme = localStorage.getItem("selected-theme");
4637 Appearance.setTheme(storedTheme);
4638 localStorage.removeItem("selected-theme");
4641 // Animate width & theme adjustments?
4642 Appearance.adjustmentTransitions = false;
4643 // Add the content width selector.
4644 Appearance.injectContentWidthSelector();
4645 // Add the text size adjustment widget.
4646 Appearance.injectTextSizeAdjustmentUI();
4647 // Add the theme selector.
4648 Appearance.injectThemeSelector();
4649 // Add the theme tweaker.
4650 Appearance.injectThemeTweaker();
4652 // Add the dark mode selector (if desktop).
4653 if (GW.isMobile == false)
4654 DarkMode.injectModeSelector();
4656 // Add the quick-nav UI.
4659 // Finish initializing when ready.
4660 addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4663 function mainInitializer() {
4664 GWLog("INITIALIZER initialize");
4666 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4667 // "link without nav bars".
4668 if (getQueryVariable("hide-nav-bars") == "true") {
4669 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
4672 // If the page cannot have comments, remove the accesskey from the #comments
4673 // quick-nav button; and if the page can have comments, but does not, simply
4674 // disable the #comments quick nav button.
4675 let content = query("#content");
4676 if (content.query("#comments") == null) {
4677 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4678 } else if (content.query("#comments .comment-thread") == null) {
4679 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4682 // On edit post pages and conversation pages, add GUIEdit buttons to the
4683 // textarea and expand it.
4684 queryAll(".with-markdown-editor textarea").forEach(textarea => {
4685 textarea.addTextareaFeatures();
4686 expandTextarea(textarea);
4688 // Focus the textarea.
4689 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4691 // If we're on a comment thread page...
4692 if (query(".comments") != null) {
4693 // Add comment-minimize buttons to every comment.
4694 queryAll(".comment-meta").forEach(commentMeta => {
4695 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4696 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
4698 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4699 // Format and activate comment-minimize buttons.
4700 queryAll(".comment-minimize-button").forEach(button => {
4701 button.closest(".comment-item").setCommentThreadMaximized(false);
4702 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4703 event.target.closest(".comment-item").setCommentThreadMaximized(true);
4708 if (getQueryVariable("chrono") == "t") {
4709 insertHeadHTML(`<style> .comment-minimize-button::after { display: none; } </style>`);
4712 // On mobile, replace the labels for the checkboxes on the edit post form
4713 // with icons, to save space.
4714 if (GW.isMobile && query(".edit-post-page")) {
4715 query("label[for='link-post']").innerHTML = "";
4716 query("label[for='question']").innerHTML = "";
4719 // Add error message (as placeholder) if user tries to click Search with
4720 // an empty search field.
4722 let searchForm = query("#nav-item-search form");
4723 if(!searchForm) break searchForm;
4724 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4725 let searchField = event.target.query("input");
4726 if (searchField.value == "") {
4727 event.preventDefault();
4728 event.target.blur();
4729 searchField.placeholder = "Enter a search string!";
4730 searchField.focus();
4733 // Remove the placeholder / error on any input.
4734 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4735 event.target.placeholder = "";
4739 // Prevent conflict between various single-hotkey listeners and text fields
4740 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4741 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4742 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4745 if (content.hasClass("post-page")) {
4746 // Read and update last-visited-date.
4747 let lastVisitedDate = getLastVisitedDate();
4748 setLastVisitedDate(Date.now());
4750 // Save the number of comments this post has when it's visited.
4751 updateSavedCommentCount();
4753 if (content.query(".comments .comment-thread") != null) {
4754 // Add the new comments count & navigator.
4755 injectNewCommentNavUI();
4757 // Get the highlight-new-since date (as specified by URL parameter, if
4758 // present, or otherwise the date of the last visit).
4759 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4761 // Highlight new comments since the specified date.
4762 let newCommentsCount = highlightCommentsSince(hnsDate);
4764 // Update the comment count display.
4765 updateNewCommentNavUI(newCommentsCount, hnsDate);
4768 // On listing pages, make comment counts more informative.
4769 badgePostsWithNewComments();
4772 // Add the comments list mode selector widget (expanded vs. compact).
4773 injectCommentsListModeSelector();
4775 // Add the comments view selector widget (threaded vs. chrono).
4776 // injectCommentsViewModeSelector();
4778 // Add the comments sort mode selector (top, hot, new, old).
4779 if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4781 // Add the toggle for the post nav UI elements on mobile.
4782 if (GW.isMobile) injectPostNavUIToggle();
4784 // Add the toggle for the appearance adjustment UI elements on mobile.
4786 Appearance.injectAppearanceAdjustUIToggle();
4788 // Activate the antikibitzer.
4789 if (GW.useFancyFeatures)
4790 activateAntiKibitzer();
4792 // Add comment parent popups.
4793 injectPreviewPopupToggle();
4794 addCommentParentPopups();
4796 // Mark original poster's comments with a special class.
4797 markOriginalPosterComments();
4799 // On the All view, mark posts with non-positive karma with a special class.
4800 if (query("#content").hasClass("all-index-page")) {
4801 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4802 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4804 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4808 // Set the "submit" button on the edit post page to something more helpful.
4809 setEditPostPageSubmitButtonText();
4811 // Compute the text of the pagination UI tooltip text.
4812 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4813 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4816 // Add event listeners for Escape and Enter, for the theme tweaker.
4817 document.addEventListener("keyup", Appearance.themeTweakerUIKeyPressed);
4819 // Add event listener for . , ; (for navigating listings pages).
4820 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4821 if (!query(".comments") && listings.length > 0) {
4822 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
4823 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4825 if (event.key == "Escape") {
4826 if (document.activeElement.parentElement.hasClass("listing"))
4827 document.activeElement.blur();
4831 if (event.key == ';') {
4832 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4833 let links = document.activeElement.parentElement.queryAll("a");
4834 links[document.activeElement == links[0] ? 1 : 0].focus();
4835 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4836 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4837 links[document.activeElement == links[0] ? 1 : 0].focus();
4838 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4843 var indexOfActiveListing = -1;
4844 for (i = 0; i < listings.length; i++) {
4845 if (document.activeElement.parentElement.hasClass("listing") &&
4846 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4847 indexOfActiveListing = i;
4849 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
4850 listings[i] === document.activeElement.parentElement.query("a.date")) {
4851 indexOfActiveListing = i;
4855 // Remove edit accesskey from currently highlighted post by active user, if applicable.
4856 if (indexOfActiveListing > -1) {
4857 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4859 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4860 if (indexOfNextListing < listings.length) {
4861 listings[indexOfNextListing].focus();
4863 if (listings[indexOfNextListing].closest(".comment-item")) {
4864 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4865 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4868 document.activeElement.blur();
4870 // Add edit accesskey to newly highlighted post by active user, if applicable.
4871 (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4873 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4874 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4875 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4879 // Add event listener for ; (to focus the link on link posts).
4880 if (query("#content").hasClass("post-page") &&
4881 query(".post").hasClass("link-post")) {
4882 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4883 if (event.key == ';') query("a.link-post-link").focus();
4887 // Add accesskeys to user page view selector.
4888 let viewSelector = query("#content.user-page > .sublevel-nav");
4890 let currentView = viewSelector.query("span");
4891 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4892 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4895 // Add accesskey to index page sort selector.
4896 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4898 // Move MathJax style tags to <head>.
4899 var aggregatedStyles = "";
4900 queryAll("#content style").forEach(styleTag => {
4901 aggregatedStyles += styleTag.innerHTML;
4902 removeElement("style", styleTag.parentElement);
4904 if (aggregatedStyles != "") {
4905 insertHeadHTML(`<style id="mathjax-styles"> ${aggregatedStyles} </style>`);
4908 /* Makes double-clicking on a math element select the entire math element.
4909 (This actually makes no difference to the behavior of the copy listener
4910 which copies the entire LaTeX source of the full equation no matter how
4911 much of said equation is selected when the copy command is sent;
4912 however, it ensures that the UI communicates the actual behavior in a
4913 more accurate and understandable way.)
4915 query("#content").querySelectorAll(".mjpage").forEach(mathBlock => {
4916 mathBlock.addEventListener("dblclick", (event) => {
4917 document.getSelection().selectAllChildren(mathBlock.querySelector(".mjx-chtml"));
4919 mathBlock.title = mathBlock.classList.contains("mjpage__block")
4920 ? "Double-click to select equation, then copy, to get LaTeX source"
4921 : "Double-click to select equation; copy to get LaTeX source";
4924 // Add listeners to switch between word count and read time.
4925 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4926 queryAll(".post-meta .read-time").forEach(element => {
4927 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4928 let displayWordCount = localStorage.getItem("display-word-count");
4929 toggleReadTimeOrWordCount(!displayWordCount);
4930 if (displayWordCount) localStorage.removeItem("display-word-count");
4931 else localStorage.setItem("display-word-count", true);
4935 // Set up Image Focus feature.
4938 // Set up keyboard shortcuts guide overlay.
4939 keyboardHelpSetup();
4941 // Show push notifications button if supported
4942 pushNotificationsSetup();
4944 // Show elements now that javascript is ready.
4945 removeElement("#hide-until-init");
4947 activateTrigger("pageLayoutFinished");
4950 /*************************/
4951 /* POST-LOAD ADJUSTMENTS */
4952 /*************************/
4954 window.addEventListener("pageshow", badgePostsWithNewComments);
4956 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4957 GWLog("INITIALIZER pageLayoutFinished");
4959 Appearance.postSetThemeHousekeeping();
4961 focusImageSpecifiedByURL();
4963 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4964 // query("input[type='search']").value = GW.isMobile;
4965 // insertHeadHTML(`<style>
4966 // @media only screen and (hover:none) { #nav-item-search input { background-color: red; }}
4967 // @media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}
4971 function generateImagesOverlay() {
4972 GWLog("generateImagesOverlay");
4973 // Don’t do this on the about page.
4974 if (query(".about-page") != null) return;
4977 // Remove existing, if any.
4978 removeElement("#images-overlay");
4981 document.body.insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4982 let imagesOverlay = query("#images-overlay");
4983 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4984 queryAll(".post-body img").forEach(image => {
4985 let clonedImageContainer = newElement("DIV");
4987 let clonedImage = image.cloneNode(true);
4988 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4989 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4990 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4991 clonedImageContainer.appendChild(clonedImage);
4993 let zoomLevel = Appearance.currentTextZoom;
4995 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4996 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4997 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4998 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
5000 imagesOverlay.appendChild(clonedImageContainer);
5003 // Add the event listeners to focus each image.
5004 imageFocusSetup(true);
5007 function adjustUIForWindowSize() {
5008 GWLog("adjustUIForWindowSize");
5009 var bottomBarOffset;
5011 // Adjust bottom bar state.
5012 let bottomBar = query("#bottom-bar");
5013 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5014 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
5015 bottomBar.removeClass("decorative");
5017 bottomBar.query("#nav-item-top").style.display = "";
5018 } else if (bottomBar) {
5019 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
5020 else bottomBar.addClass("decorative");
5022 bottomBar.query("#nav-item-top").style.display = "none";
5025 // Show quick-nav UI up/down buttons if content is taller than window.
5026 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
5027 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
5028 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
5031 // Move anti-kibitzer toggle if content is very short.
5032 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
5034 // Update the visibility of the post nav UI.
5035 updatePostNavUIVisibility();
5038 function recomputeUIElementsContainerHeight(force = false) {
5039 GWLog("recomputeUIElementsContainerHeight");
5041 (force || query("#ui-elements-container").style.height != "")) {
5042 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
5043 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
5044 query("#content").clientHeight + "px" :
5049 function focusImageSpecifiedByURL() {
5050 GWLog("focusImageSpecifiedByURL");
5051 if (location.hash.hasPrefix("#if_slide_")) {
5052 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
5053 let images = queryAll(GW.imageFocus.overlayImagesSelector);
5054 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
5055 if (imageToFocus > 0 && imageToFocus <= images.length) {
5056 focusImage(images[imageToFocus - 1]);
5058 // Set timer to hide the image focus UI.
5059 unhideImageFocusUI();
5060 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
5070 function insertMarkup(event) {
5071 var mopen = '', mclose = '', mtext = '', func = false;
5072 if (typeof arguments[1] == 'function') {
5073 func = arguments[1];
5075 mopen = arguments[1];
5076 mclose = arguments[2];
5077 mtext = arguments[3];
5080 var textarea = event.target.closest("form").query("textarea");
5082 var p0 = textarea.selectionStart;
5083 var p1 = textarea.selectionEnd;
5084 var cur0 = cur1 = p0;
5086 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
5087 str = func ? func(str, p0) : (mopen + str + mclose);
5089 // Determine selection.
5091 cur0 += (p0 == p1) ? mopen.length : str.length;
5092 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
5099 // Update textarea contents.
5100 document.execCommand("insertText", false, str);
5102 // Expand textarea, if needed.
5103 expandTextarea(textarea);
5106 textarea.selectionStart = cur0;
5107 textarea.selectionEnd = cur1;
5112 GW.guiEditButtons = [
5113 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
5114 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
5115 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
5116 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
5117 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
5118 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
5119 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
5120 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
5121 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
5122 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
5123 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
5124 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
5125 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
5126 [ 'formula', 'LaTeX [alt+4]', '', '$', '$', 'LaTeX formula', '' ],
5127 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
5130 function blockquote(text, startpos) {
5132 text = "> Quoted text";
5133 return [ text, startpos + 2, startpos + text.length ];
5135 text = "> " + text.split("\n").join("\n> ") + "\n";
5136 return [ text, startpos + text.length, startpos + text.length ];
5140 function hyperlink(text, startpos) {
5141 var url = '', link_text = text, endpos = startpos;
5142 if (text.search(/^https?/) != -1) {
5144 link_text = "link text";
5145 startpos = startpos + 1;
5146 endpos = startpos + link_text.length;
5148 url = prompt("Link address (URL):");
5150 endpos = startpos + text.length;
5151 return [ text, startpos, endpos ];
5153 startpos = startpos + text.length + url.length + 4;
5157 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
5160 /******************/
5161 /* SERVICE WORKER */
5162 /******************/
5164 if(navigator.serviceWorker) {
5165 navigator.serviceWorker.register('/service-worker.js');
5166 setCookie("push", "t");
5169 /*********************/
5170 /* USER AUTOCOMPLETE */
5171 /*********************/
5173 function zLowerUIElements() {
5174 let uiElementsContainer = query("#ui-elements-container");
5175 if (uiElementsContainer)
5176 uiElementsContainer.style.zIndex = "1";
5179 function zRaiseUIElements() {
5180 let uiElementsContainer = query("#ui-elements-container");
5181 if (uiElementsContainer)
5182 uiElementsContainer.style.zIndex = "";
5185 var userAutocomplete = null;
5187 function abbreviatedInterval(date) {
5188 let seconds = Math.floor((new Date() - date) / 1000);
5189 let days = Math.floor(seconds / (60 * 60 * 24));
5190 let years = Math.floor(days / 365);
5199 function beginAutocompletion(control, startIndex, endIndex) {
5200 if(userAutocomplete) abortAutocompletion(userAutocomplete);
5202 let complete = { control: control,
5203 abortController: new AbortController(),
5204 fetchAbortController: new AbortController(),
5205 container: document.createElement("div") };
5207 endIndex = endIndex || control.selectionEnd;
5208 let valueLength = control.value.length;
5210 complete.container.className = "autocomplete-container "
5212 + (window.innerWidth > 1280
5215 control.insertAdjacentElement("afterend", complete.container);
5218 let makeReplacer = (userSlug, displayName) => {
5220 let replacement = '[@' + displayName + '](/users/' + userSlug + '?mention=user)';
5221 control.value = control.value.substring(0, startIndex - 1) +
5223 control.value.substring(endIndex);
5224 abortAutocompletion(complete);
5225 complete.control.selectionStart = complete.control.selectionEnd = startIndex + -1 + replacement.length;
5226 complete.control.focus();
5230 let switchHighlight = (newHighlight) => {
5234 complete.highlighted.removeClass("highlighted");
5235 newHighlight.addClass("highlighted");
5236 complete.highlighted = newHighlight;
5238 // Scroll newly highlighted item into view, if need be.
5239 if ( complete.highlighted.offsetTop + complete.highlighted.offsetHeight
5240 > complete.container.scrollTop + complete.container.clientHeight) {
5241 complete.container.scrollTo(0, complete.highlighted.offsetTop + complete.highlighted.offsetHeight - complete.container.clientHeight);
5242 } else if (complete.highlighted.offsetTop < complete.container.scrollTop) {
5243 complete.container.scrollTo(0, complete.highlighted.offsetTop);
5246 let highlightNext = () => {
5247 switchHighlight(complete.highlighted.nextElementSibling ?? complete.container.firstElementChild);
5249 let highlightPrev = () => {
5250 switchHighlight(complete.highlighted.previousElementSibling ?? complete.container.lastElementChild);
5253 let updateCompletions = () => {
5254 let fragment = control.value.substring(startIndex, endIndex);
5256 fetch("/-user-autocomplete?" + urlEncodeQuery({q: fragment}),
5257 {signal: complete.fetchAbortController.signal})
5258 .then((res) => res.json())
5260 if(res.error) return;
5261 if(res.length == 0) return abortAutocompletion(complete);
5263 complete.container.innerHTML = "";
5264 res.forEach(entry => {
5265 let entryContainer = document.createElement("div");
5266 [ [ entry.displayName, "name" ],
5267 [ abbreviatedInterval(Date.parse(entry.createdAt)), "age" ],
5268 [ (entry.karma || 0) + " karma", "karma" ]
5270 let e = document.createElement("span");
5273 entryContainer.append(e);
5275 entryContainer.onclick = makeReplacer(entry.slug, entry.displayName);
5276 complete.container.append(entryContainer);
5278 complete.highlighted = complete.container.children[0];
5279 complete.highlighted.classList.add("highlighted");
5280 complete.container.scrollTo(0, 0);
5285 document.body.addEventListener("click", (event) => {
5286 if (!complete.container.contains(event.target)) {
5287 abortAutocompletion(complete);
5288 event.preventDefault();
5289 event.stopPropagation();
5291 }, {signal: complete.abortController.signal,
5294 control.addEventListener("keydown", (event) => {
5295 switch (event.key) {
5297 abortAutocompletion(complete);
5298 event.preventDefault();
5302 event.preventDefault();
5306 event.preventDefault();
5313 event.preventDefault();
5316 complete.highlighted.onclick();
5317 event.preventDefault();
5320 }, {signal: complete.abortController.signal});
5322 control.addEventListener("selectionchange", (event) => {
5323 if (control.selectionStart < startIndex ||
5324 control.selectionEnd > endIndex) {
5325 abortAutocompletion(complete);
5327 }, {signal: complete.abortController.signal});
5329 control.addEventListener("input", (event) => {
5330 complete.fetchAbortController.abort();
5331 complete.fetchAbortController = new AbortController();
5333 endIndex += control.value.length - valueLength;
5334 valueLength = control.value.length;
5336 if (endIndex < startIndex) {
5337 abortAutocompletion(complete);
5341 updateCompletions();
5342 }, {signal: complete.abortController.signal});
5344 userAutocomplete = complete;
5346 if(startIndex != endIndex) updateCompletions();
5349 function abortAutocompletion(complete) {
5350 complete.fetchAbortController.abort();
5351 complete.abortController.abort();
5352 complete.container.remove();
5353 userAutocomplete = null;