1 /***************************/
2 /* INITIALIZATION REGISTRY */
3 /***************************/
6 GW.initializersDone = { };
8 function registerInitializer(name, tryEarly, precondition, fn) {
9 GW.initializersDone[name] = false;
10 GW.initializers[name] = fn;
11 let wrapper = function () {
12 if (GW.initializersDone[name]) return;
13 if (!precondition()) {
15 setTimeout(() => requestIdleCallback(wrapper, {timeout: 1000}), 50);
17 document.addEventListener("readystatechange", wrapper, {once: true});
21 GW.initializersDone[name] = true;
25 requestIdleCallback(wrapper, {timeout: 1000});
27 document.addEventListener("readystatechange", wrapper, {once: true});
28 requestIdleCallback(wrapper);
31 function forceInitializer(name) {
32 if (GW.initializersDone[name]) return;
33 GW.initializersDone[name] = true;
34 GW.initializers[name]();
42 function setCookie(name, value, days) {
44 if (!days) days = 36500;
46 var date = new Date();
47 date.setTime(date.getTime() + (days*24*60*60*1000));
48 expires = "; expires=" + date.toUTCString();
50 document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax" + (GW.secureCookies ? "; Secure" : "");
53 /* Reads the value of named cookie.
54 Returns the cookie as a string, or null if no such cookie exists. */
55 function readCookie(name) {
56 var nameEQ = name + "=";
57 var ca = document.cookie.split(';');
58 for(var i = 0; i < ca.length; i++) {
60 while (c.charAt(0)==' ') c = c.substring(1, c.length);
61 if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
66 /*******************************/
67 /* EVENT LISTENER MANIPULATION */
68 /*******************************/
70 /* Removes event listener from a clickable element, automatically detaching it
71 from all relevant event types. */
72 Element.prototype.removeActivateEvent = function() {
73 let ael = this.activateEventListener;
74 this.removeEventListener("mousedown", ael);
75 this.removeEventListener("click", ael);
76 this.removeEventListener("keyup", ael);
79 /* Adds a scroll event listener to the page. */
80 function addScrollListener(fn, name) {
81 let wrapper = (event) => {
82 requestAnimationFrame(() => {
84 document.addEventListener("scroll", wrapper, {once: true, passive: true});
87 document.addEventListener("scroll", wrapper, {once: true, passive: true});
89 // Retain a reference to the scroll listener, if a name is provided.
90 if (typeof name != "undefined")
98 // Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=325942
99 Element.prototype.scrollIntoView = function(realSIV) {
100 return function(bottom) {
101 realSIV.call(this, bottom);
102 if(fixTarget = this.closest("input[id^='expand'] ~ .comment-thread")) {
103 window.scrollBy(0, fixTarget.scrollTop);
104 fixTarget.scrollTop = 0;
107 }(Element.prototype.scrollIntoView);
109 /* If top of element is not at or above the top of the screen, scroll it into
111 Element.prototype.scrollIntoViewIfNeeded = function() {
112 GWLog("scrollIntoViewIfNeeded");
113 if (this.getBoundingClientRect().bottom > window.innerHeight &&
114 this.getBoundingClientRect().top > 0) {
115 this.scrollIntoView(false);
119 function urlEncodeQuery(params) {
120 return params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params[x])}).join("&");
123 function handleAjaxError(event) {
124 if(event.target.getResponseHeader("Content-Type") === "application/json") alert("Error: " + JSON.parse(event.target.responseText)["error"]);
125 else alert("Error: Something bad happened :(");
128 function doAjax(params) {
129 let req = new XMLHttpRequest();
130 let requestMethod = params["method"] || "GET";
131 req.addEventListener("load", (event) => {
132 if(event.target.status < 400) {
133 if(params["onSuccess"]) params.onSuccess(event);
135 if(params["onFailure"]) params.onFailure(event);
136 else handleAjaxError(event);
138 if(params["onFinish"]) params.onFinish(event);
140 req.open(requestMethod, (params.location || document.location) + ((requestMethod == "GET" && params.params) ? "?" + urlEncodeQuery(params.params) : ""));
141 if(requestMethod == "POST") {
142 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
143 params["params"]["csrf-token"] = GW.csrfToken;
144 req.send(urlEncodeQuery(params.params));
150 function activateReadyStateTriggers() {
151 if(document.readyState == 'interactive') {
152 activateTrigger('DOMReady');
153 } else if(document.readyState == 'complete') {
154 activateTrigger('DOMReady');
155 activateTrigger('DOMComplete');
159 document.addEventListener('readystatechange', activateReadyStateTriggers);
160 activateReadyStateTriggers();
162 function callWithServerData(fname, uri) {
165 onSuccess: (event) => {
166 let response = JSON.parse(event.target.responseText);
167 window[fname](response);
172 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
173 deferredCalls = null;
175 /* Return the currently selected text, as HTML (rather than unstyled text).
177 function getSelectionHTML() {
178 var container = document.createElement("div");
179 container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
180 return container.innerHTML;
183 /* Given an HTML string, creates an element from that HTML, adds it to
184 #ui-elements-container (creating the latter if it does not exist), and
185 returns the created element.
187 function addUIElement(element_html) {
188 var ui_elements_container = query("#ui-elements-container");
189 if (!ui_elements_container) {
190 ui_elements_container = document.createElement("nav");
191 ui_elements_container.id = "ui-elements-container";
192 query("body").appendChild(ui_elements_container);
195 ui_elements_container.insertAdjacentHTML("beforeend", element_html);
196 return ui_elements_container.lastElementChild;
199 /* Given an element or a selector, removes that element (or the element
200 identified by the selector).
201 If multiple elements match the selector, only the first is removed.
203 function removeElement(elementOrSelector, ancestor = document) {
204 if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
205 if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
208 /* Returns true if the string begins with the given prefix.
210 String.prototype.hasPrefix = function (prefix) {
211 return (this.lastIndexOf(prefix, 0) === 0);
214 /* Toggles whether the page is scrollable.
216 function togglePageScrolling(enable) {
217 let body = query("body");
219 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
220 body.addClass("no-scroll");
221 body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
223 body.removeClass("no-scroll");
224 body.removeAttribute("style");
225 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
229 DOMRectReadOnly.prototype.isInside = function (x, y) {
230 return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
233 /********************/
234 /* DEBUGGING OUTPUT */
235 /********************/
237 GW.enableLogging = (permanently = false) => {
239 localStorage.setItem("logging-enabled", "true");
241 GW.loggingEnabled = true;
243 GW.disableLogging = (permanently = false) => {
245 localStorage.removeItem("logging-enabled");
247 GW.loggingEnabled = false;
250 /*******************/
251 /* INBOX INDICATOR */
252 /*******************/
254 function processUserStatus(userStatus) {
255 window.userStatus = userStatus;
257 if(userStatus.notifications) {
258 let element = query('#inbox-indicator');
259 element.className = 'new-messages';
260 element.title = 'New messages [o]';
271 function toggleMarkdownHintsBox() {
272 GWLog("toggleMarkdownHintsBox");
273 let markdownHintsBox = query("#markdown-hints");
274 markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
276 function hideMarkdownHintsBox() {
277 GWLog("hideMarkdownHintsBox");
278 let markdownHintsBox = query("#markdown-hints");
279 if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
282 Element.prototype.addTextareaFeatures = function() {
283 GWLog("addTextareaFeatures");
286 textarea.addEventListener("focus", GW.textareaFocused = (event) => {
287 GWLog("GW.textareaFocused");
288 event.target.closest("form").scrollIntoViewIfNeeded();
290 textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
291 GWLog("GW.textareaInputReceived");
292 if (window.innerWidth > 520) {
293 // Expand textarea if needed.
294 expandTextarea(textarea);
296 // Remove markdown hints.
297 hideMarkdownHintsBox();
298 query(".guiedit-mobile-help-button").removeClass("active");
301 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
302 textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
304 let form = textarea.closest("form");
305 if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
307 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
308 let textareaContainer = textarea.closest(".textarea-container");
309 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
310 for (var button of GW.guiEditButtons) {
311 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
312 buttons_container.insertAdjacentHTML("beforeend",
313 "<button type='button' class='guiedit guiedit-"
316 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
317 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
318 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
319 + " onclick='insertMarkup(event,"
320 + ((typeof m_before_or_func == 'function') ?
321 m_before_or_func.name :
322 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
330 `<input type='checkbox' id='markdown-hints-checkbox'>
331 <label for='markdown-hints-checkbox'></label>
332 <div id='markdown-hints'>` +
333 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
334 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
335 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
336 "<span>Heading 1</span><code># Heading 1</code>",
337 "<span>Heading 2</span><code>## Heading 1</code>",
338 "<span>Heading 3</span><code>### Heading 1</code>",
339 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
341 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
343 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
344 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
345 GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
346 if (button.hasClass("guiedit-mobile-help-button")) {
347 toggleMarkdownHintsBox();
348 event.target.toggleClass("active");
349 query(".posting-controls:focus-within textarea").focus();
350 } else if (button.hasClass("guiedit-mobile-exit-button")) {
352 hideMarkdownHintsBox();
353 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
358 // On smartphone (narrow mobile) screens, when a textarea is focused (and
359 // automatically fullscreened), remove all the filters from the page, and
360 // then apply them *just* to the fixed editor UI elements. This is in order
361 // to get around the "children of elements with a filter applied cannot be
363 if (GW.isMobile && window.innerWidth <= 520) {
364 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
365 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
366 GWLog("GW.textareaFocusedMobile");
367 GW.savedFilters = GW.currentFilters;
368 GW.currentFilters = { };
369 applyFilters(GW.currentFilters);
370 fixedEditorElements.forEach(element => {
371 element.style.filter = filterStringFromFilters(GW.savedFilters);
374 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
375 GWLog("GW.textareaBlurredMobile");
376 GW.currentFilters = GW.savedFilters;
377 GW.savedFilters = { };
378 requestAnimationFrame(() => {
379 applyFilters(GW.currentFilters);
380 fixedEditorElements.forEach(element => {
381 element.style.filter = filterStringFromFilters(GW.savedFilters);
388 Element.prototype.injectReplyForm = function(editMarkdownSource) {
389 GWLog("injectReplyForm");
390 let commentControls = this;
391 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
392 let postId = commentControls.parentElement.dataset["postId"];
393 let tagId = commentControls.parentElement.dataset["tagId"];
394 let withparent = (!editMarkdownSource && commentControls.getCommentId());
395 let answer = commentControls.parentElement.id == "answers";
396 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
397 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
398 let parentCommentItem = commentControls.closest(".comment-item");
399 let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
400 (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
401 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
402 "<form method='post'>" +
403 "<div class='textarea-container'>" +
404 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
405 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
406 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
407 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
408 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
409 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
410 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
411 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
412 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
413 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
414 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
415 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
416 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
418 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
419 "<input type='submit' value='Submit'>" +
421 commentControls.onsubmit = disableBeforeUnload;
423 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
424 GWLog("GW.cancelCommentButtonClicked");
425 hideReplyForm(event.target.closest(".comment-controls"));
427 commentControls.scrollIntoViewIfNeeded();
428 commentControls.query("form").onsubmit = (event) => {
429 if (!event.target.text.value) {
430 alert("Please enter a comment.");
434 let textarea = commentControls.query("textarea");
435 textarea.value = MarkdownFromHTML(editMarkdownSource || "");
436 textarea.addTextareaFeatures();
440 function showCommentEditForm(commentItem) {
441 GWLog("showCommentEditForm");
443 let commentBody = commentItem.query(".comment-body");
444 commentBody.style.display = "none";
446 let commentControls = commentItem.query(".comment-controls");
447 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
448 commentControls.query("form").addClass("edit-existing-comment");
449 expandTextarea(commentControls.query("textarea"));
452 function showReplyForm(commentItem) {
453 GWLog("showReplyForm");
455 let commentControls = commentItem.query(".comment-controls");
456 commentControls.injectReplyForm(commentControls.dataset.enteredText);
459 function hideReplyForm(commentControls) {
460 GWLog("hideReplyForm");
461 // Are we editing a comment? If so, un-hide the existing comment body.
462 let containingComment = commentControls.closest(".comment-item");
463 if (containingComment) containingComment.query(".comment-body").style.display = "";
465 let enteredText = commentControls.query("textarea").value;
466 if (enteredText) commentControls.dataset.enteredText = enteredText;
468 disableBeforeUnload();
469 commentControls.constructCommentControls();
472 function expandTextarea(textarea) {
473 GWLog("expandTextarea");
474 if (window.innerWidth <= 520) return;
476 let totalBorderHeight = 30;
477 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
479 requestAnimationFrame(() => {
480 textarea.style.height = 'auto';
481 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
482 if (textarea.clientHeight < window.innerHeight) {
483 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
488 function doCommentAction(action, commentItem) {
489 GWLog("doCommentAction");
491 params[(action + "-comment-id")] = commentItem.getCommentId();
495 onSuccess: GW.commentActionPostSucceeded = (event) => {
496 GWLog("GW.commentActionPostSucceeded");
498 retract: () => { commentItem.firstChild.addClass("retracted") },
499 unretract: () => { commentItem.firstChild.removeClass("retracted") },
501 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>";
502 commentItem.removeChild(commentItem.query(".comment-controls"));
506 if(action != "delete")
507 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
516 function parseVoteType(voteType) {
517 GWLog("parseVoteType");
519 if (!voteType) return value;
520 value.up = /[Uu]pvote$/.test(voteType);
521 value.down = /[Dd]ownvote$/.test(voteType);
522 value.big = /^big/.test(voteType);
526 function makeVoteType(value) {
527 GWLog("makeVoteType");
528 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
531 function makeVoteClass(vote) {
532 GWLog("makeVoteClass");
533 if (vote.up || vote.down) {
534 return (vote.big ? 'selected big-vote' : 'selected');
540 function findVoteControls(targetType, targetId, voteAxis) {
541 var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
543 if(targetType == "Post") {
544 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
545 } else if(targetType == "Comment") {
546 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
550 function votesEqual(vote1, vote2) {
551 var allKeys = Object.assign({}, vote1);
552 Object.assign(allKeys, vote2);
554 for(k of allKeys.keys()) {
555 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
560 function addVoteButtons(element, vote, targetType) {
561 GWLog("addVoteButtons");
563 let voteAxis = element.parentElement.dataset.voteAxis || "karma";
564 let voteType = parseVoteType(vote[voteAxis]);
565 let voteClass = makeVoteClass(voteType);
567 element.parentElement.queryAll("button").forEach((button) => {
568 button.disabled = false;
570 if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
571 button.addClass(voteClass);
573 updateVoteButtonVisualState(button);
574 button.addActivateEvent(voteButtonClicked);
578 function updateVoteButtonVisualState(button) {
579 GWLog("updateVoteButtonVisualState");
581 button.removeClasses([ "none", "one", "two-temp", "two" ]);
584 button.addClass("none");
585 else if (button.hasClass("big-vote"))
586 button.addClass("two");
587 else if (button.hasClass("selected"))
588 button.addClass("one");
590 button.addClass("none");
593 function changeVoteButtonVisualState(button) {
594 GWLog("changeVoteButtonVisualState");
596 /* Interaction states are:
598 0 0· (neutral; +1 click)
599 1 1· (small vote; +1 click)
600 2 2· (big vote; +1 click)
602 Visual states are (with their state classes in [brackets]) are:
605 02 (small vote active) [one]
606 12 (small vote active, temporary indicator of big vote) [two-temp]
607 22 (big vote active) [two]
609 The following are the 9 possible interaction state transitions (and
610 the visual state transitions associated with them):
613 FROM TO FROM TO NOTES
614 ==== ==== ==== ==== =====
615 0 0· 01 12 first click
616 0· 1 12 02 one click without second
617 0· 2 12 22 second click
619 1 1· 02 12 first click
620 1· 0 12 01 one click without second
621 1· 2 12 22 second click
623 2 2· 22 12 first click
624 2· 1 12 02 one click without second
625 2· 0 12 01 second click
628 [ "big-vote two-temp clicked-twice", "none" ], // 2· => 0
629 [ "big-vote two-temp clicked-once", "one" ], // 2· => 1
630 [ "big-vote clicked-once", "two-temp" ], // 2 => 2·
632 [ "selected two-temp clicked-twice", "two" ], // 1· => 2
633 [ "selected two-temp clicked-once", "none" ], // 1· => 0
634 [ "selected clicked-once", "two-temp" ], // 1 => 1·
636 [ "two-temp clicked-twice", "two" ], // 0· => 2
637 [ "two-temp clicked-once", "one" ], // 0· => 1
638 [ "clicked-once", "two-temp" ], // 0 => 0·
640 for (let [ interactionClasses, visualStateClass ] of transitions) {
641 if (button.hasClasses(interactionClasses.split(" "))) {
642 button.removeClasses([ "none", "one", "two-temp", "two" ]);
643 button.addClass(visualStateClass);
649 function voteCompleteEvent(targetType, targetId, response) {
650 GWLog("voteCompleteEvent");
652 var currentVote = voteData[targetType][targetId] || {};
653 var desiredVote = voteDesired[targetType][targetId];
655 var controls = findVoteControls(targetType, targetId);
656 var controlsByAxis = new Object;
658 controls.forEach(control => {
659 const voteAxis = (control.dataset.voteAxis || "karma");
661 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
662 control.removeClass("waiting");
663 control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
666 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
667 controlsByAxis[voteAxis].push(control);
669 const voteType = currentVote[voteAxis];
670 const vote = parseVoteType(voteType);
671 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
672 const voteClass = makeVoteClass(vote);
674 if (response && response[voteAxis]) {
675 const [voteType, displayText, titleText] = response[voteAxis];
677 const displayTarget = control.query(".karma-value");
678 if (displayTarget.hasClass("redacted")) {
679 displayTarget.dataset["trueValue"] = displayText;
681 displayTarget.innerHTML = displayText;
683 displayTarget.setAttribute("title", titleText);
686 control.queryAll("button.vote").forEach(button => {
687 updateVoteButton(button, voteUpDown, voteClass);
692 function updateVoteButton(button, voteUpDown, voteClass) {
693 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
694 if (button.dataset.voteType == voteUpDown)
695 button.addClass(voteClass);
696 updateVoteButtonVisualState(button);
699 function makeVoteRequestCompleteEvent(targetType, targetId) {
701 var currentVote = {};
704 if (event.target.status == 200) {
705 response = JSON.parse(event.target.responseText);
706 for (const voteAxis of response.keys()) {
707 currentVote[voteAxis] = response[voteAxis][0];
709 voteData[targetType][targetId] = currentVote;
711 delete voteDesired[targetType][targetId];
712 currentVote = voteData[targetType][targetId];
715 var desiredVote = voteDesired[targetType][targetId];
717 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
718 sendVoteRequest(targetType, targetId);
720 delete voteDesired[targetType][targetId];
721 voteCompleteEvent(targetType, targetId, response);
726 function sendVoteRequest(targetType, targetId) {
727 GWLog("sendVoteRequest");
731 location: "/karma-vote",
732 params: { "target": targetId,
733 "target-type": targetType,
734 "vote": JSON.stringify(voteDesired[targetType][targetId]) },
735 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
739 function voteButtonClicked(event) {
740 GWLog("voteButtonClicked");
741 let voteButton = event.target;
743 // 500 ms (0.5 s) double-click timeout.
744 let doubleClickTimeout = 500;
746 if (!voteButton.clickedOnce) {
747 voteButton.clickedOnce = true;
748 voteButton.addClass("clicked-once");
749 changeVoteButtonVisualState(voteButton);
751 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
752 if (!voteButton.clickedOnce) return;
754 // Do single-click code.
755 voteButton.clickedOnce = false;
756 voteEvent(voteButton, 1);
757 }, doubleClickTimeout, voteButton);
759 voteButton.clickedOnce = false;
761 // Do double-click code.
762 voteButton.removeClass("clicked-once");
763 voteButton.addClass("clicked-twice");
764 voteEvent(voteButton, 2);
768 function voteEvent(voteButton, numClicks) {
772 let voteControl = voteButton.parentNode;
774 let targetType = voteButton.dataset.targetType;
775 let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
776 let voteAxis = voteControl.dataset.voteAxis || "karma";
777 let voteUpDown = voteButton.dataset.voteType;
780 if ( (numClicks == 2 && voteButton.hasClass("big-vote"))
781 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
782 voteType = "neutral";
784 let vote = parseVoteType(voteUpDown);
785 vote.big = (numClicks == 2);
786 voteType = makeVoteType(vote);
789 let voteControls = findVoteControls(targetType, targetId, voteAxis);
790 for (const voteControl of voteControls) {
791 voteControl.addClass("waiting");
792 voteControl.queryAll(".vote").forEach(button => {
793 button.addClass("waiting");
794 updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
798 let voteRequestPending = voteDesired[targetType][targetId];
799 let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
800 voteObject[voteAxis] = voteType;
801 voteDesired[targetType][targetId] = voteObject;
803 if (!voteRequestPending) sendVoteRequest(targetType, targetId);
806 function initializeVoteButtons() {
807 // Color the upvote/downvote buttons with an embedded style sheet.
808 query("head").insertAdjacentHTML("beforeend", "<style id='vote-buttons'>" + `
810 --GW-upvote-button-color: #00d800;
811 --GW-downvote-button-color: #eb4c2a;
815 function processVoteData(voteData) {
816 window.voteData = voteData;
818 window.voteDesired = new Object;
819 for(key of voteData.keys()) {
820 voteDesired[key] = new Object;
823 initializeVoteButtons();
825 addTriggerListener("postLoaded", {priority: 3000, fn: () => {
826 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
827 let postID = karmaValue.parentNode.dataset.postId;
828 addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
829 karmaValue.parentElement.addClass("active-controls");
833 addTriggerListener("DOMReady", {priority: 3000, fn: () => {
834 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
835 let commentID = karmaValue.getCommentId();
836 addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
837 karmaValue.parentElement.addClass("active-controls");
842 /*****************************************/
843 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
844 /*****************************************/
846 Element.prototype.getCommentDate = function() {
847 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
848 let dateElement = item && item.query(".date");
849 return (dateElement && parseInt(dateElement.dataset["jsDate"]));
851 function getCurrentVisibleComment() {
852 let px = window.innerWidth/2, py = 5;
853 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
854 let bottomBar = query("#bottom-bar");
855 let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
856 let atbottom = bottomOffset <= window.innerHeight;
858 let hashci = location.hash && query(location.hash);
859 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
860 commentItem = hashci;
866 function highlightCommentsSince(date) {
867 GWLog("highlightCommentsSince");
868 var newCommentsCount = 0;
869 GW.newComments = [ ];
870 let oldCommentsStack = [ ];
872 queryAll(".comment-item").forEach(commentItem => {
873 commentItem.prevNewComment = prevNewComment;
874 commentItem.nextNewComment = null;
875 if (commentItem.getCommentDate() > date) {
876 commentItem.addClass("new-comment");
878 GW.newComments.push(commentItem.getCommentId());
879 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
880 oldCommentsStack = [ commentItem ];
881 prevNewComment = commentItem;
883 commentItem.removeClass("new-comment");
884 oldCommentsStack.push(commentItem);
888 GW.newCommentScrollSet = (commentItem) => {
889 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
890 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
892 GW.newCommentScrollListener = () => {
893 let commentItem = getCurrentVisibleComment();
894 GW.newCommentScrollSet(commentItem);
897 addScrollListener(GW.newCommentScrollListener);
899 if (document.readyState=="complete") {
900 GW.newCommentScrollListener();
902 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
903 GW.newCommentScrollSet(commentItem);
906 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
908 return newCommentsCount;
911 function scrollToNewComment(next) {
912 GWLog("scrollToNewComment");
913 let commentItem = getCurrentVisibleComment();
914 let targetComment = null;
915 let targetCommentID = null;
917 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
919 targetCommentID = targetComment.getCommentId();
922 if (GW.newComments[0]) {
923 targetCommentID = GW.newComments[0];
924 targetComment = query("#comment-" + targetCommentID);
928 expandAncestorsOf(targetCommentID);
929 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
930 targetComment.scrollIntoView();
933 GW.newCommentScrollListener();
936 function getPostHash() {
937 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
938 return (postHash ? postHash[1] : false);
940 function setHistoryLastVisitedDate(date) {
941 window.history.replaceState({ lastVisited: date }, null);
943 function getLastVisitedDate() {
944 // Get the last visited date (or, if posting a comment, the previous last visited date).
945 if(window.history.state) return (window.history.state||{})['lastVisited'];
946 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
947 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
948 let currentVisited = localStorage.getItem(storageName);
949 setHistoryLastVisitedDate(currentVisited);
950 return currentVisited;
952 function setLastVisitedDate(date) {
953 GWLog("setLastVisitedDate");
954 // If NOT posting a comment, save the previous value for the last-visited-date
955 // (to recover it in case of posting a comment).
956 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
957 if (!aCommentHasJustBeenPosted) {
958 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
959 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
962 // Set the new value.
963 localStorage.setItem("last-visited-date_" + getPostHash(), date);
966 function updateSavedCommentCount() {
967 GWLog("updateSavedCommentCount");
968 let commentCount = queryAll(".comment").length;
969 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
971 function badgePostsWithNewComments() {
972 GWLog("badgePostsWithNewComments");
973 if (getQueryVariable("show") == "conversations") return;
975 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
976 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
978 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
979 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
980 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
982 if (currentCommentCount > savedCommentCount)
983 commentCountDisplay.addClass("new-comments");
985 commentCountDisplay.removeClass("new-comments");
986 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
990 /***********************************/
991 /* CONTENT COLUMN WIDTH ADJUSTMENT */
992 /***********************************/
994 function injectContentWidthSelector() {
995 GWLog("injectContentWidthSelector");
996 // Get saved width setting (or default).
997 let currentWidth = localStorage.getItem("selected-width") || 'normal';
999 // Inject the content width selector widget and activate buttons.
1000 let widthSelector = addUIElement(
1001 "<div id='width-selector'>" +
1002 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
1003 let [name, desc, abbr] = widthOption;
1004 let selected = (name == currentWidth ? ' selected' : '');
1005 let disabled = (name == currentWidth ? ' disabled' : '');
1006 return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
1008 widthSelector.queryAll("button").forEach(button => {
1009 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
1010 GWLog("GW.widthAdjustButtonClicked");
1012 // Determine which setting was chosen (i.e., which button was clicked).
1013 let selectedWidth = event.target.dataset.name;
1015 // Save the new setting.
1016 if (selectedWidth == "normal") localStorage.removeItem("selected-width");
1017 else localStorage.setItem("selected-width", selectedWidth);
1019 // Save current visible comment
1020 let visibleComment = getCurrentVisibleComment();
1022 // Actually change the content width.
1023 setContentWidth(selectedWidth);
1024 event.target.parentElement.childNodes.forEach(button => {
1025 button.removeClass("selected");
1026 button.disabled = false;
1028 event.target.addClass("selected");
1029 event.target.disabled = true;
1031 // Make sure the accesskey (to cycle to the next width) is on the right button.
1032 setWidthAdjustButtonsAccesskey();
1034 // Regenerate images overlay.
1035 generateImagesOverlay();
1037 if(visibleComment) visibleComment.scrollIntoView();
1041 // Make sure the accesskey (to cycle to the next width) is on the right button.
1042 setWidthAdjustButtonsAccesskey();
1044 // Inject transitions CSS, if animating changes is enabled.
1045 if (GW.adjustmentTransitions) {
1047 "<style id='width-transition'>" +
1049 #ui-elements-container,
1052 max-width 0.3s ease;
1057 function setWidthAdjustButtonsAccesskey() {
1058 GWLog("setWidthAdjustButtonsAccesskey");
1059 let widthSelector = query("#width-selector");
1060 widthSelector.queryAll("button").forEach(button => {
1061 button.removeAttribute("accesskey");
1062 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1064 let selectedButton = widthSelector.query("button.selected");
1065 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
1066 nextButtonInCycle.accessKey = "'";
1067 nextButtonInCycle.title += ` [\']`;
1070 /*******************/
1071 /* THEME SELECTION */
1072 /*******************/
1074 function injectThemeSelector() {
1075 GWLog("injectThemeSelector");
1076 let currentTheme = readCookie("theme") || "default";
1077 let themeSelector = addUIElement(
1078 "<div id='theme-selector' class='theme-selector'>" +
1079 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
1080 let [name, desc, letter] = themeOption;
1081 let selected = (name == currentTheme ? ' selected' : '');
1082 let disabled = (name == currentTheme ? ' disabled' : '');
1083 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1084 return `<button type='button' class='select-theme-${name}${selected}'${disabled} title="${desc} [${accesskey}]" data-theme-name="${name}" data-theme-description="${desc}" accesskey='${accesskey}' tabindex='-1'>${letter}</button>`;})) +
1086 themeSelector.queryAll("button").forEach(button => {
1087 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
1088 GWLog("GW.themeSelectButtonClicked");
1089 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
1090 setSelectedTheme(themeName);
1091 if (GW.isMobile) toggleAppearanceAdjustUI();
1095 // Inject transitions CSS, if animating changes is enabled.
1096 if (GW.adjustmentTransitions) {
1098 "<style id='theme-fade-transition'>" +
1101 opacity 0.5s ease-out,
1102 background-color 0.3s ease-out;
1105 background-color: #777;
1108 opacity 0.5s ease-in,
1109 background-color 0.3s ease-in;
1114 function setSelectedTheme(themeName) {
1115 GWLog("setSelectedTheme");
1116 queryAll(".theme-selector button").forEach(button => {
1117 button.removeClass("selected");
1118 button.disabled = false;
1120 queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
1121 button.addClass("selected");
1122 button.disabled = true;
1124 setTheme(themeName);
1125 query("#theme-tweaker-ui .current-theme span").innerText = themeName;
1127 function setTheme(newThemeName) {
1128 var themeUnloadCallback = '';
1129 var oldThemeName = '';
1130 if (typeof(newThemeName) == 'undefined') {
1131 newThemeName = readCookie('theme');
1132 if (!newThemeName) return;
1134 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
1135 oldThemeName = readCookie('theme') || 'default';
1137 if (newThemeName == 'default') setCookie('theme', '');
1138 else setCookie('theme', newThemeName);
1140 if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
1142 let makeNewStyle = function(newThemeName, colorSchemePreference) {
1143 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
1144 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1146 let newStyle = document.createElement('link');
1147 newStyle.setAttribute('class', 'theme');
1148 if(colorSchemePreference)
1149 newStyle.setAttribute('media', '(prefers-color-scheme: ' + colorSchemePreference + ')');
1150 newStyle.setAttribute('rel', 'stylesheet');
1151 newStyle.setAttribute('href', '/css/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1155 let newMainStyle, newStyles;
1156 if(newThemeName === 'default') {
1157 newStyles = [makeNewStyle('dark', 'dark'), makeNewStyle('default', 'light')];
1158 newMainStyle = (window.matchMedia('prefers-color-scheme: dark').matches ? newStyles[0] : newStyles[1]);
1160 newStyles = [makeNewStyle(newThemeName)];
1161 newMainStyle = newStyles[0];
1164 let oldStyles = queryAll("head link.theme");
1165 newMainStyle.addEventListener('load', () => { oldStyles.forEach(x => removeElement(x)); });
1166 newMainStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
1168 if (GW.adjustmentTransitions) {
1169 pageFadeTransition(false);
1171 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1174 newStyles.forEach(newStyle => query('head').insertBefore(newStyle, oldStyles[0].nextSibling));
1177 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
1178 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1179 document.body.addClass("theme-" + newThemeName);
1181 recomputeUIElementsContainerHeight(true);
1183 let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
1184 if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
1186 recomputeUIElementsContainerHeight();
1187 adjustUIForWindowSize();
1188 window.addEventListener('resize', GW.windowResized = (event) => {
1189 GWLog("GW.windowResized");
1190 adjustUIForWindowSize();
1191 recomputeUIElementsContainerHeight();
1194 generateImagesOverlay();
1196 if (window.adjustmentTransitions) pageFadeTransition(true);
1197 updateThemeTweakerSampleText();
1199 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
1200 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1204 function pageFadeTransition(fadeIn) {
1206 query("body").removeClass("transparent");
1208 query("body").addClass("transparent");
1212 GW.themeLoadCallback_less = (fromTheme = "") => {
1213 GWLog("themeLoadCallback_less");
1214 injectSiteNavUIToggle();
1216 injectPostNavUIToggle();
1217 injectAppearanceAdjustUIToggle();
1220 registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1221 let dtf = new Intl.DateTimeFormat([],
1222 (window.innerWidth < 1100) ?
1223 { month: 'short', day: 'numeric', year: 'numeric' } :
1224 { month: 'long', day: 'numeric', year: 'numeric' });
1225 let postDate = query(".top-post-meta .date");
1226 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1230 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1234 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1235 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1236 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1240 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1241 // If state is not set (user has never clicked on the Less theme's appearance
1242 // adjustment UI toggle) then show it, but then hide it after a short time.
1243 registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1244 toggleAppearanceAdjustUI();
1245 setTimeout(toggleAppearanceAdjustUI, 3000);
1249 if (fromTheme != "") {
1250 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1251 setTimeout(function () {
1252 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1254 setTimeout(function () {
1255 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1259 // Unset the height of the #ui-elements-container.
1260 query("#ui-elements-container").style.height = "";
1262 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1263 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1264 "#content #secondary-bar",
1265 "#content .post .top-post-meta .date",
1266 "#content .post .top-post-meta .comment-count",
1268 applyFilters(GW.currentFilters);
1271 // We pre-query the relevant elements, so we don't have to run querySelectorAll
1272 // on every firing of the scroll listener.
1274 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1275 "unbrokenDownScrollDistance": 0,
1276 "unbrokenUpScrollDistance": 0,
1277 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1278 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1279 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1281 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1284 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
1285 // otherwise, show it.
1286 function updatePostNavUIVisibility() {
1287 GWLog("updatePostNavUIVisibility");
1288 var hidePostNavUIToggle = true;
1289 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1290 if (getComputedStyle(element).visibility == "visible" ||
1291 element.style.visibility == "visible" ||
1292 element.style.visibility == "unset")
1293 hidePostNavUIToggle = false;
1295 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1296 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1300 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1301 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
1302 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1303 function updateSiteNavUIState(event) {
1304 GWLog("updateSiteNavUIState");
1305 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1306 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
1307 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
1309 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1310 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1312 GW.scrollState.lastScrollTop = newScrollTop;
1314 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1315 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1316 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1317 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1320 // On mobile, make site nav UI translucent on ANY scroll down.
1322 GW.scrollState.siteNavUIElements.forEach(element => {
1323 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1324 else element.removeClass("translucent-on-scroll");
1327 // Show site nav UI when scrolling a full page up, or to the top.
1328 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
1329 GW.scrollState.lastScrollTop == 0) &&
1330 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
1331 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1333 // On desktop, show appearance adjust UI when scrolling to the top.
1334 if ((!GW.isMobile) &&
1335 (GW.scrollState.lastScrollTop == 0) &&
1336 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
1337 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1340 GW.themeUnloadCallback_less = (toTheme = "") => {
1341 GWLog("themeUnloadCallback_less");
1342 removeSiteNavUIToggle();
1344 removePostNavUIToggle();
1345 removeAppearanceAdjustUIToggle();
1347 window.removeEventListener('resize', updatePostNavUIVisibility);
1349 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1351 removeElement("#theme-less-mobile-first-row-placeholder");
1355 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1356 element.innerHTML = element.firstChild.innerHTML;
1360 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1362 // Reset filtered elements selector to default.
1363 delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1364 applyFilters(GW.currentFilters);
1367 GW.themeLoadCallback_dark = (fromTheme = "") => {
1368 GWLog("themeLoadCallback_dark");
1370 "<style id='dark-theme-adjustments'>" +
1371 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` +
1372 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1374 registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1375 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1376 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)";
1377 image.style.width = parseInt(image.style.width) + 12 + "px";
1378 image.style.height = parseInt(image.style.height) + 12 + "px";
1379 image.style.top = parseInt(image.style.top) - 6 + "px";
1380 image.style.left = parseInt(image.style.left) - 6 + "px";
1384 GW.themeUnloadCallback_dark = (toTheme = "") => {
1385 GWLog("themeUnloadCallback_dark");
1386 removeElement("#dark-theme-adjustments");
1389 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1390 GWLog("themeLoadCallback_brutalist");
1391 let bottomBarLinks = queryAll("#bottom-bar a");
1392 if (!GW.isMobile && bottomBarLinks.length == 5) {
1393 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1394 bottomBarLinks.forEach((link, i) => {
1395 link.dataset.originalText = link.textContent;
1396 link.textContent = newLinkTexts[i];
1400 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1401 GWLog("themeUnloadCallback_brutalist");
1402 let bottomBarLinks = queryAll("#bottom-bar a");
1403 if (!GW.isMobile && bottomBarLinks.length == 5) {
1404 bottomBarLinks.forEach(link => {
1405 link.textContent = link.dataset.originalText;
1410 GW.themeLoadCallback_classic = (fromTheme = "") => {
1411 GWLog("themeLoadCallback_classic");
1412 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1413 button.innerHTML = "";
1416 GW.themeUnloadCallback_classic = (toTheme = "") => {
1417 GWLog("themeUnloadCallback_classic");
1418 if (GW.isMobile && window.innerWidth <= 900) return;
1419 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1420 button.innerHTML = button.dataset.label;
1424 /********************************************/
1425 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1426 /********************************************/
1428 function injectThemeTweaker() {
1429 GWLog("injectThemeTweaker");
1430 let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" +
1431 `<div class='main-theme-tweaker-window'>
1432 <h1>Customize appearance</h1>
1433 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1434 <button type='button' class='help-button' tabindex='-1'></button>
1435 <p class='current-theme'>Current theme: <span>` +
1436 (readCookie("theme") || "default") +
1438 <p class='theme-selector'></p>
1439 <div class='controls-container'>
1440 <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1441 <div class='sample-text-container'><span class='sample-text'>
1442 <p>Less Wrong (text)</p>
1443 <p><a href="#">Less Wrong (link)</a></p>
1446 <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1447 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1448 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1449 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1451 <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1452 <input type='checkbox' id='theme-tweak-control-invert'></input>
1453 <label for='theme-tweak-control-invert'>Invert colors</label>
1455 <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1456 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1457 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1458 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1460 <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1461 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1462 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1463 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1465 <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1466 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1467 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1468 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1470 <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1471 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1472 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1473 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1476 <div class='buttons-container'>
1477 <button type="button" class="reset-defaults-button">Reset to defaults</button>
1478 <button type='button' class='ok-button default-button'>OK</button>
1479 <button type='button' class='cancel-button'>Cancel</button>
1482 <div class="clippy-container">
1483 <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>)
1484 <div class='clippy'></div>
1485 <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>
1487 <div class='help-window' style='display: none;'>
1488 <h1>Theme tweaker help</h1>
1489 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1490 <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1491 <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1493 <div class='buttons-container'>
1494 <button type='button' class='ok-button default-button'>OK</button>
1495 <button type='button' class='cancel-button'>Cancel</button>
1500 // Clicking the background overlay closes the theme tweaker.
1501 themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1502 GWLog("GW.themeTweaker.UIOverlayClicked");
1503 if (event.type == 'mousedown') {
1504 themeTweakerUI.style.opacity = "0.01";
1506 toggleThemeTweakerUI();
1507 themeTweakerUI.style.opacity = "1.0";
1512 // Intercept clicks, so they don't "fall through" the background overlay.
1513 (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1515 let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1516 themeTweakerUI.queryAll("input").forEach(field => {
1517 // All input types in the theme tweaker receive a 'change' event when
1518 // their value is changed. (Range inputs, in particular, receive this
1519 // event when the user lets go of the handle.) This means we should
1520 // update the filters for the entire page, to match the new setting.
1521 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1522 GWLog("GW.themeTweaker.fieldValueChanged");
1523 if (event.target.id == 'theme-tweak-control-invert') {
1524 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1525 } else if (event.target.type == 'range') {
1526 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1527 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1528 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1529 } else if (event.target.id == 'theme-tweak-control-clippy') {
1530 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1532 // Clear the sample text filters.
1533 sampleTextContainer.style.filter = "";
1534 // Apply the new filters globally.
1535 applyFilters(GW.currentFilters);
1538 // Range inputs receive an 'input' event while being scrubbed, updating
1539 // "live" as the handle is moved. We don't want to change the filters
1540 // for the actual page while this is happening, but we do want to change
1541 // the filters for the *sample text*, so the user can see what effects
1542 // his changes are having, live, without having to let go of the handle.
1543 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1544 GWLog("GW.themeTweaker.fieldInputReceived");
1545 var sampleTextFilters = GW.currentFilters;
1547 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1548 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1549 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1551 sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1555 themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1556 GWLog("GW.themeTweaker.minimizeButtonClicked");
1557 let themeTweakerStyle = query("#theme-tweaker-style");
1559 if (event.target.hasClass("minimize")) {
1560 event.target.removeClass("minimize");
1561 themeTweakerStyle.innerHTML =
1562 `#theme-tweaker-ui .main-theme-tweaker-window {
1566 padding: 30px 0 0 0;
1571 #theme-tweaker-ui::after {
1575 #theme-tweaker-ui::before {
1579 #theme-tweaker-ui .clippy-container {
1582 #theme-tweaker-ui .clippy-container .hint span {
1588 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1589 pointer-events: none;
1591 event.target.addClass("maximize");
1593 event.target.removeClass("maximize");
1594 themeTweakerStyle.innerHTML =
1595 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1596 pointer-events: none;
1598 event.target.addClass("minimize");
1601 themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1602 GWLog("GW.themeTweaker.helpButtonClicked");
1603 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1604 toggleThemeTweakerHelpWindow();
1606 themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1607 GWLog("GW.themeTweaker.resetDefaultsButtonClicked");
1608 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1609 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1610 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1611 slider.value = slider.dataset['defaultValue'];
1612 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1614 GW.currentFilters = { };
1615 applyFilters(GW.currentFilters);
1617 GW.currentTextZoom = "1.0";
1618 setTextZoom(GW.currentTextZoom);
1620 setSelectedTheme("default");
1622 themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1623 GWLog("GW.themeTweaker.cancelButtonClicked");
1624 toggleThemeTweakerUI();
1627 themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1628 GWLog("GW.themeTweaker.OKButtonClicked");
1629 toggleThemeTweakerUI();
1632 themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1633 GWLog("GW.themeTweaker.helpWindowCancelButtonClicked");
1634 toggleThemeTweakerHelpWindow();
1635 themeTweakerResetSettings();
1637 themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1638 GWLog("GW.themeTweaker.helpWindowOKButtonClicked");
1639 toggleThemeTweakerHelpWindow();
1640 themeTweakerSaveSettings();
1643 themeTweakerUI.queryAll(".notch").forEach(notch => {
1644 notch.addActivateEvent(GW.themeTweaker.sliderNotchClicked = (event) => {
1645 GWLog("GW.themeTweaker.sliderNotchClicked");
1646 let slider = event.target.parentElement.query("input[type='range']");
1647 slider.value = slider.dataset['defaultValue'];
1648 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1649 GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1650 applyFilters(GW.currentFilters);
1654 themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1655 GWLog("GW.themeTweaker.clippyCloseButtonClicked");
1656 themeTweakerUI.query(".clippy-container").style.display = "none";
1657 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1658 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1661 query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1663 themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1664 themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1665 button.addActivateEvent(GW.themeSelectButtonClicked);
1668 themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1669 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1672 let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'></button></div>`);
1673 themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1674 GWLog("GW.themeTweaker.toggleButtonClicked");
1675 GW.themeTweakerStyleSheetAvailable = () => {
1676 GWLog("GW.themeTweakerStyleSheetAvailable");
1677 themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1679 themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1680 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1681 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1682 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1683 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1686 toggleThemeTweakerUI();
1687 event.target.disabled = true;
1690 if (query("link[href^='/css/theme_tweaker.css']")) {
1691 // Theme tweaker CSS is already loaded.
1692 GW.themeTweakerStyleSheetAvailable();
1694 // Load the theme tweaker CSS (if not loaded).
1695 let themeTweakerStyleSheet = document.createElement('link');
1696 themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1697 themeTweakerStyleSheet.setAttribute('href', '/css/theme_tweaker.css');
1698 themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1699 query("head").appendChild(themeTweakerStyleSheet);
1703 function toggleThemeTweakerUI() {
1704 GWLog("toggleThemeTweakerUI");
1705 let themeTweakerUI = query("#theme-tweaker-ui");
1706 themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1707 query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" :
1708 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1709 pointer-events: none;
1711 if (themeTweakerUI.style.display != "none") {
1712 // Save selected theme.
1713 GW.currentTheme = (readCookie("theme") || "default");
1714 // Focus invert checkbox.
1715 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1716 // Show sample text in appropriate font.
1717 updateThemeTweakerSampleText();
1718 // Disable tab-selection of the search box.
1719 setSearchBoxTabSelectable(false);
1720 // Disable scrolling of the page.
1721 togglePageScrolling(false);
1723 query("#theme-tweaker-toggle button").disabled = false;
1724 // Re-enable tab-selection of the search box.
1725 setSearchBoxTabSelectable(true);
1726 // Re-enable scrolling of the page.
1727 togglePageScrolling(true);
1729 // Set theme tweaker assistant visibility.
1730 query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1732 function setSearchBoxTabSelectable(selectable) {
1733 GWLog("setSearchBoxTabSelectable");
1734 query("input[type='search']").tabIndex = selectable ? "" : "-1";
1735 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1737 function toggleThemeTweakerHelpWindow() {
1738 GWLog("toggleThemeTweakerHelpWindow");
1739 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1740 themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1741 if (themeTweakerHelpWindow.style.display != "none") {
1742 // Focus theme tweaker assistant checkbox.
1743 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1744 // Disable interaction on main theme tweaker window.
1745 query("#theme-tweaker-ui").style.pointerEvents = "none";
1746 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1748 // Re-enable interaction on main theme tweaker window.
1749 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1750 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1753 function themeTweakReset() {
1754 GWLog("themeTweakReset");
1755 setSelectedTheme(GW.currentTheme);
1756 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1757 applyFilters(GW.currentFilters);
1758 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1759 setTextZoom(GW.currentTextZoom);
1761 function themeTweakSave() {
1762 GWLog("themeTweakSave");
1763 GW.currentTheme = (readCookie("theme") || "default");
1764 localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1765 localStorage.setItem("text-zoom", GW.currentTextZoom);
1768 function themeTweakerResetSettings() {
1769 GWLog("themeTweakerResetSettings");
1770 query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1771 query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1773 function themeTweakerSaveSettings() {
1774 GWLog("themeTweakerSaveSettings");
1775 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1777 function updateThemeTweakerSampleText() {
1778 GWLog("updateThemeTweakerSampleText");
1779 let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1781 // This causes the sample text to take on the properties of the body text of a post.
1782 sampleText.removeClass("body-text");
1783 let bodyTextElement = query(".post-body") || query(".comment-body");
1784 sampleText.addClass("body-text");
1785 sampleText.style.color = bodyTextElement ?
1786 getComputedStyle(bodyTextElement).color :
1787 getComputedStyle(query("#content")).color;
1789 // Here we find out what is the actual background color that will be visible behind
1790 // the body text of posts, and set the sample text’s background to that.
1791 let findStyleBackground = (selector) => {
1793 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
1794 if(rule.selectorText == selector)
1797 return x.style.backgroundColor;
1800 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
1803 /*********************/
1804 /* PAGE QUICK-NAV UI */
1805 /*********************/
1807 function injectQuickNavUI() {
1808 GWLog("injectQuickNavUI");
1809 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1810 `<a href='#top' title="Up to top [,]" accesskey=','></a>
1811 <a href='#comments' title="Comments [/]" accesskey='/'></a>
1812 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
1816 /**********************/
1817 /* NEW COMMENT NAV UI */
1818 /**********************/
1820 function injectNewCommentNavUI(newCommentsCount) {
1821 GWLog("injectNewCommentNavUI");
1822 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
1823 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
1824 <span class='new-comments-count'></span>
1825 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
1828 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1829 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1830 GWLog("GW.commentQuicknavButtonClicked");
1831 scrollToNewComment(/next/.test(event.target.className));
1832 event.target.blur();
1836 document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => {
1837 GWLog("GW.commentQuicknavKeyPressed");
1838 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1839 if (event.key == ",") scrollToNewComment(false);
1840 if (event.key == ".") scrollToNewComment(true)
1843 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1844 + `<span>Since:</span>`
1845 + `<input type='text' class='hns-date'></input>`
1848 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1849 GWLog("GW.hnsDatePickerValueChanged");
1850 let hnsDate = time_fromHuman(event.target.value);
1852 setHistoryLastVisitedDate(hnsDate);
1853 let newCommentsCount = highlightCommentsSince(hnsDate);
1854 updateNewCommentNavUI(newCommentsCount);
1858 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1859 GWLog("GW.newCommentsCountClicked");
1860 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1861 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1865 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1866 function time_fromHuman(string) {
1867 /* Convert a human-readable date into a JS timestamp */
1868 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1869 string = string.replace(' ', 'T'); // revert nice spacing
1870 string += ':00.000Z'; // complete ISO 8601 date
1871 time = Date.parse(string); // milliseconds since epoch
1873 // browsers handle ISO 8601 without explicit timezone differently
1874 // thus, we have to fix that by hand
1875 time += (new Date()).getTimezoneOffset() * 60e3;
1877 string = string.replace(' at', '');
1878 time = Date.parse(string); // milliseconds since epoch
1883 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1884 GWLog("updateNewCommentNavUI");
1885 // Update the new comments count.
1886 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1887 newCommentsCountLabel.innerText = newCommentsCount;
1888 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1890 // Update the date picker field.
1891 if (hnsDate != -1) {
1892 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1896 /***************************/
1897 /* TEXT SIZE ADJUSTMENT UI */
1898 /***************************/
1900 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1901 GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1902 var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1903 if (event.target.hasClass("decrease")) {
1904 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1905 } else if (event.target.hasClass("increase")) {
1906 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1910 setTextZoom(zoomFactor);
1911 GW.currentTextZoom = `${zoomFactor}`;
1913 if (event.target.parentElement.id == "text-size-adjustment-ui") {
1914 localStorage.setItem("text-zoom", GW.currentTextZoom);
1918 function injectTextSizeAdjustmentUIReal() {
1919 GWLog("injectTextSizeAdjustmentUIReal");
1920 let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1921 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1922 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1923 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1926 textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1927 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1930 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1933 function injectTextSizeAdjustmentUI() {
1934 GWLog("injectTextSizeAdjustmentUI");
1935 if (query("#text-size-adjustment-ui") != null) return;
1936 if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1937 else document.addEventListener("DOMContentLoaded", () => {
1938 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1942 /********************************/
1943 /* COMMENTS VIEW MODE SELECTION */
1944 /********************************/
1946 function injectCommentsViewModeSelector() {
1947 GWLog("injectCommentsViewModeSelector");
1948 let commentsContainer = query("#comments");
1949 if (commentsContainer == null) return;
1951 let currentModeThreaded = (location.href.search("chrono=t") == -1);
1952 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1954 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1955 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
1956 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
1959 // commentsViewModeSelector.queryAll("a").forEach(button => {
1960 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1963 if (!currentModeThreaded) {
1964 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1965 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1966 commentParentLink.addClass("inline-author");
1967 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1970 queryAll(".comment-child-links a").forEach(commentChildLink => {
1971 commentChildLink.textContent = commentChildLink.textContent.slice(1);
1972 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
1975 rectifyChronoModeCommentChildLinks();
1977 commentsContainer.addClass("chrono");
1979 commentsContainer.addClass("threaded");
1982 // Remove extraneous top-level comment thread in chrono mode.
1983 let topLevelCommentThread = query("#comments > .comment-thread");
1984 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
1987 // function commentsViewModeSelectorButtonClicked(event) {
1988 // event.preventDefault();
1991 // let request = new XMLHttpRequest();
1992 // request.open("GET", event.target.href);
1993 // request.onreadystatechange = () => {
1994 // if (request.readyState != 4) return;
1995 // newDocument = htmlToElement(request.response);
1997 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
1999 // // Update the buttons.
2000 // event.target.addClass("selected");
2001 // event.target.parentElement.query("." + classes.old).removeClass("selected");
2003 // // Update the #comments container.
2004 // let commentsContainer = query("#comments");
2005 // commentsContainer.removeClass(classes.old);
2006 // commentsContainer.addClass(classes.new);
2008 // // Update the content.
2009 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2014 // function htmlToElement(html) {
2015 // var template = document.createElement('template');
2016 // template.innerHTML = html.trim();
2017 // return template.content;
2020 function rectifyChronoModeCommentChildLinks() {
2021 GWLog("rectifyChronoModeCommentChildLinks");
2022 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2023 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2024 let childLinks = commentChildLinksContainer.queryAll("a");
2025 childLinks.forEach((link, index) => {
2026 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2030 let childLinksArray = Array.from(childLinks)
2031 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2032 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2035 function childrenOfComment(commentID) {
2036 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2037 let commentParentLink = commentItem.query("a.comment-parent-link");
2038 return ((commentParentLink||{}).hash == "#" + commentID);
2042 /********************************/
2043 /* COMMENTS LIST MODE SELECTION */
2044 /********************************/
2046 function injectCommentsListModeSelector() {
2047 GWLog("injectCommentsListModeSelector");
2048 if (query("#content > .comment-thread") == null) return;
2050 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2051 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2052 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2055 if (query(".sublevel-nav") || query("#top-nav-bar")) {
2056 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2058 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2060 let commentsListModeSelector = query("#comments-list-mode-selector");
2062 commentsListModeSelector.queryAll("button").forEach(button => {
2063 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2064 GWLog("GW.commentsListModeSelectButtonClicked");
2065 event.target.parentElement.queryAll("button").forEach(button => {
2066 button.removeClass("selected");
2067 button.disabled = false;
2068 button.accessKey = '`';
2070 localStorage.setItem("comments-list-mode", event.target.className);
2071 event.target.addClass("selected");
2072 event.target.disabled = true;
2073 event.target.removeAttribute("accesskey");
2075 if (event.target.hasClass("expanded")) {
2076 query("#content").removeClass("compact");
2078 query("#content").addClass("compact");
2083 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2084 if (savedMode == "compact")
2085 query("#content").addClass("compact");
2086 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2087 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2088 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2091 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2092 commentParentLink.addActivateEvent(function (event) {
2093 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2094 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2100 /**********************/
2101 /* SITE NAV UI TOGGLE */
2102 /**********************/
2104 function injectSiteNavUIToggle() {
2105 GWLog("injectSiteNavUIToggle");
2106 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2107 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2108 GWLog("GW.siteNavUIToggleButtonClicked");
2110 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2113 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2115 function removeSiteNavUIToggle() {
2116 GWLog("removeSiteNavUIToggle");
2117 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2118 element.removeClass("engaged");
2120 removeElement("#site-nav-ui-toggle");
2122 function toggleSiteNavUI() {
2123 GWLog("toggleSiteNavUI");
2124 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2125 element.toggleClass("engaged");
2126 element.removeClass("translucent-on-scroll");
2130 /**********************/
2131 /* POST NAV UI TOGGLE */
2132 /**********************/
2134 function injectPostNavUIToggle() {
2135 GWLog("injectPostNavUIToggle");
2136 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2137 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2138 GWLog("GW.postNavUIToggleButtonClicked");
2140 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2143 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2145 function removePostNavUIToggle() {
2146 GWLog("removePostNavUIToggle");
2147 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2148 element.removeClass("engaged");
2150 removeElement("#post-nav-ui-toggle");
2152 function togglePostNavUI() {
2153 GWLog("togglePostNavUI");
2154 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2155 element.toggleClass("engaged");
2159 /*******************************/
2160 /* APPEARANCE ADJUST UI TOGGLE */
2161 /*******************************/
2163 function injectAppearanceAdjustUIToggle() {
2164 GWLog("injectAppearanceAdjustUIToggle");
2165 let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2166 appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
2167 GWLog("GW.appearanceAdjustUIToggleButtonClicked");
2168 toggleAppearanceAdjustUI();
2169 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
2173 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
2174 themeSelectorCloseButton.addClass("theme-selector-close-button");
2175 themeSelectorCloseButton.innerHTML = "";
2176 query("#theme-selector").appendChild(themeSelectorCloseButton);
2177 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
2179 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
2182 function removeAppearanceAdjustUIToggle() {
2183 GWLog("removeAppearanceAdjustUIToggle");
2184 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2185 element.removeClass("engaged");
2187 removeElement("#appearance-adjust-ui-toggle");
2189 function toggleAppearanceAdjustUI() {
2190 GWLog("toggleAppearanceAdjustUI");
2191 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
2192 element.toggleClass("engaged");
2196 /**************************/
2197 /* WORD COUNT & READ TIME */
2198 /**************************/
2200 function toggleReadTimeOrWordCount(addWordCountClass) {
2201 GWLog("toggleReadTimeOrWordCount");
2202 queryAll(".post-meta .read-time").forEach(element => {
2203 if (addWordCountClass) element.addClass("word-count");
2204 else element.removeClass("word-count");
2206 let titleParts = /(\S+)(.+)$/.exec(element.title);
2207 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2211 /**************************/
2212 /* PROMPT TO SAVE CHANGES */
2213 /**************************/
2215 function enableBeforeUnload() {
2216 window.onbeforeunload = function () { return true; };
2218 function disableBeforeUnload() {
2219 window.onbeforeunload = null;
2222 /***************************/
2223 /* ORIGINAL POSTER BADGING */
2224 /***************************/
2226 function markOriginalPosterComments() {
2227 GWLog("markOriginalPosterComments");
2228 let postAuthor = query(".post .author");
2229 if (postAuthor == null) return;
2231 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2232 if (author.dataset.userid == postAuthor.dataset.userid ||
2233 (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2234 author.addClass("original-poster");
2235 author.title += "Original poster";
2240 /********************************/
2241 /* EDIT POST PAGE SUBMIT BUTTON */
2242 /********************************/
2244 function setEditPostPageSubmitButtonText() {
2245 GWLog("setEditPostPageSubmitButtonText");
2246 if (!query("#content").hasClass("edit-post-page")) return;
2248 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2249 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2250 GWLog("GW.postSectionSelectorValueChanged");
2251 updateEditPostPageSubmitButtonText();
2255 updateEditPostPageSubmitButtonText();
2257 function updateEditPostPageSubmitButtonText() {
2258 GWLog("updateEditPostPageSubmitButtonText");
2259 let submitButton = query("input[type='submit']");
2260 if (query("input#drafts").checked == true)
2261 submitButton.value = "Save Draft";
2262 else if (query(".posting-controls").hasClass("edit-existing-post"))
2263 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2265 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2272 function numToAlpha(n) {
2275 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2276 n = Math.floor((n / 26) - 1);
2281 function injectAntiKibitzer() {
2282 GWLog("injectAntiKibitzer");
2283 // Inject anti-kibitzer toggle controls.
2284 let antiKibitzerToggle = addUIElement("<div id='anti-kibitzer-toggle'><button type='button' tabindex='-1' accesskey='g' title='Toggle anti-kibitzer (show/hide authors & karma values) [g]'></button>");
2285 antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2286 GWLog("GW.antiKibitzerToggleButtonClicked");
2287 if (query("#anti-kibitzer-toggle").hasClass("engaged") &&
2289 !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!)")) {
2290 event.target.blur();
2294 toggleAntiKibitzerMode();
2295 event.target.blur();
2298 // Activate anti-kibitzer mode (if needed).
2299 if (localStorage.getItem("antikibitzer") == "true")
2300 toggleAntiKibitzerMode();
2302 // Remove temporary CSS that hides the authors and karma values.
2303 removeElement("#antikibitzer-temp");
2306 function toggleAntiKibitzerMode() {
2307 GWLog("toggleAntiKibitzerMode");
2308 // This will be the URL of the user's own page, if logged in, or the URL of
2309 // the login page otherwise.
2310 let userTabTarget = query("#nav-item-login .nav-inner").href;
2311 let pageHeadingElement = query("h1.page-main-heading");
2314 let userFakeName = { };
2316 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2318 let postAuthor = query(".post-page .post-meta .author");
2319 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2321 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2322 if (antiKibitzerToggle.hasClass("engaged")) {
2323 localStorage.setItem("antikibitzer", "false");
2325 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2326 if (redirectTarget) {
2327 window.location = redirectTarget;
2331 // Individual comment page title and header
2332 if (query(".individual-thread-page")) {
2333 let replacer = (node) => {
2335 node.firstChild.replaceWith(node.dataset["trueContent"]);
2337 replacer(query("title:not(.fake-title)"));
2338 replacer(query("#content > h1"));
2341 // Author names/links.
2342 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2343 author.textContent = author.dataset["trueName"];
2344 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2346 author.removeClass("redacted");
2348 // Post/comment karma values.
2349 queryAll(".karma-value.redacted").forEach(karmaValue => {
2350 karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2352 karmaValue.removeClass("redacted");
2354 // Link post domains.
2355 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2356 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2358 linkPostDomain.removeClass("redacted");
2361 antiKibitzerToggle.removeClass("engaged");
2363 localStorage.setItem("antikibitzer", "true");
2365 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2366 if (redirectTarget) {
2367 window.location = redirectTarget;
2371 // Individual comment page title and header
2372 if (query(".individual-thread-page")) {
2373 let replacer = (node) => {
2375 node.dataset["trueContent"] = node.firstChild.wholeText;
2376 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2377 node.firstChild.replaceWith(newText);
2379 replacer(query("title:not(.fake-title)"));
2380 replacer(query("#content > h1"));
2383 removeElement("title.fake-title");
2385 // Author names/links.
2386 queryAll(".author, .inline-author").forEach(author => {
2387 // Skip own posts/comments.
2388 if (author.hasClass("own-user-author"))
2391 let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2395 author.dataset["trueName"] = author.textContent;
2396 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2398 if (/\/user/.test(author.href)) {
2399 author.dataset["trueLink"] = author.pathname;
2400 author.href = "/user?id=" + author.dataset["userid"];
2403 author.addClass("redacted");
2405 // Post/comment karma values.
2406 queryAll(".karma-value").forEach(karmaValue => {
2407 // Skip own posts/comments.
2408 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2411 karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2412 karmaValue.innerHTML = "##<span> points</span>";
2414 karmaValue.addClass("redacted");
2416 // Link post domains.
2417 queryAll(".link-post-domain").forEach(linkPostDomain => {
2418 // Skip own posts/comments.
2419 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2422 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2423 linkPostDomain.textContent = "redacted.domain.tld";
2425 linkPostDomain.addClass("redacted");
2428 antiKibitzerToggle.addClass("engaged");
2432 /*******************************/
2433 /* COMMENT SORT MODE SELECTION */
2434 /*******************************/
2436 var CommentSortMode = Object.freeze({
2442 function sortComments(mode) {
2443 GWLog("sortComments");
2444 let commentsContainer = query("#comments");
2446 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2447 commentsContainer.addClass("sorting");
2449 GW.commentValues = { };
2450 let clonedCommentsContainer = commentsContainer.cloneNode(true);
2451 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2454 case CommentSortMode.NEW:
2455 comparator = (a,b) => commentDate(b) - commentDate(a);
2457 case CommentSortMode.OLD:
2458 comparator = (a,b) => commentDate(a) - commentDate(b);
2460 case CommentSortMode.HOT:
2461 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2463 case CommentSortMode.TOP:
2465 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2468 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2470 removeElement(commentsContainer.lastChild);
2471 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2472 GW.commentValues = { };
2474 if (loggedInUserId) {
2475 // Re-activate vote buttons.
2476 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2477 voteButton.addActivateEvent(voteButtonClicked);
2480 // Re-activate comment action buttons.
2481 commentsContainer.queryAll(".action-button").forEach(button => {
2482 button.addActivateEvent(GW.commentActionButtonClicked);
2486 // Re-activate comment-minimize buttons.
2487 queryAll(".comment-minimize-button").forEach(button => {
2488 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2491 // Re-add comment parent popups.
2492 addCommentParentPopups();
2494 // Redo new-comments highlighting.
2495 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2497 requestAnimationFrame(() => {
2498 commentsContainer.removeClass("sorting");
2499 commentsContainer.addClass("sorted-" + mode);
2502 function commentKarmaValue(commentOrSelector) {
2503 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2504 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2506 function commentDate(commentOrSelector) {
2507 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2508 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2510 function commentVoteCount(commentOrSelector) {
2511 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2512 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2515 function injectCommentsSortModeSelector() {
2516 GWLog("injectCommentsSortModeSelector");
2517 let topCommentThread = query("#comments > .comment-thread");
2518 if (topCommentThread == null) return;
2520 // Do not show sort mode selector if there is no branching in comment tree.
2521 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2523 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
2524 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
2526 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2527 let commentsSortModeSelector = query("#comments-sort-mode-selector");
2529 commentsSortModeSelector.queryAll("button").forEach(button => {
2530 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2531 GWLog("GW.commentsSortModeSelectButtonClicked");
2532 event.target.parentElement.queryAll("button").forEach(button => {
2533 button.removeClass("selected");
2534 button.disabled = false;
2536 event.target.addClass("selected");
2537 event.target.disabled = true;
2539 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2540 setCommentsSortModeSelectButtonsAccesskey();
2544 // TODO: Make this actually get the current sort mode (if that's saved).
2545 // TODO: Also change the condition here to properly get chrono/threaded mode,
2546 // when that is properly done with cookies.
2547 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2548 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2549 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2550 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2551 setCommentsSortModeSelectButtonsAccesskey();
2554 function setCommentsSortModeSelectButtonsAccesskey() {
2555 GWLog("setCommentsSortModeSelectButtonsAccesskey");
2556 queryAll("#comments-sort-mode-selector button").forEach(button => {
2557 button.removeAttribute("accesskey");
2558 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2560 let selectedButton = query("#comments-sort-mode-selector button.selected");
2561 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2562 nextButtonInCycle.accessKey = "z";
2563 nextButtonInCycle.title += " [z]";
2566 /*************************/
2567 /* COMMENT PARENT POPUPS */
2568 /*************************/
2570 function previewPopupsEnabled() {
2571 let isDisabled = localStorage.getItem("preview-popups-disabled");
2572 return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
2575 function setPreviewPopupsEnabled(state) {
2576 localStorage.setItem("preview-popups-disabled", !state);
2577 updatePreviewPopupToggle();
2580 function updatePreviewPopupToggle() {
2581 let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
2582 query("#preview-popup-toggle").setAttribute("style", style);
2585 function injectPreviewPopupToggle() {
2586 GWLog("injectPreviewPopupToggle");
2588 let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
2589 // This is required because Chrome can't use filters on an externally used SVG element.
2590 fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
2591 updatePreviewPopupToggle();
2592 toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
2595 var currentPreviewPopup = { };
2597 function removePreviewPopup(previewPopup) {
2598 if(previewPopup.element)
2599 removeElement(previewPopup.element);
2601 if(previewPopup.timeout)
2602 clearTimeout(previewPopup.timeout);
2604 if(currentPreviewPopup.pointerListener)
2605 window.removeEventListener("pointermove", previewPopup.pointerListener);
2607 if(currentPreviewPopup.mouseoutListener)
2608 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2610 if(currentPreviewPopup.scrollListener)
2611 window.removeEventListener("scroll", previewPopup.scrollListener);
2613 currentPreviewPopup = { };
2616 function addCommentParentPopups() {
2617 GWLog("addCommentParentPopups");
2618 //if (!query("#content").hasClass("comment-thread-page")) return;
2620 queryAll("a[href]").forEach(linkTag => {
2621 let linkHref = linkTag.getAttribute("href");
2624 try { url = new URL(linkHref, window.location.href); }
2628 if(GW.sites[url.host]) {
2629 let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
2631 if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
2632 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
2633 if(event.pointerType == "touch") return;
2634 GWLog("GW.commentParentLinkMouseOver");
2635 removePreviewPopup(currentPreviewPopup);
2636 let parentID = linkHref;
2638 if (!(parent = (query(parentID)||{}).firstChild)) return;
2639 var highlightClassName;
2640 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2641 parentHighlightClassName = "comment-item-highlight-faint";
2642 popup = parent.cloneNode(true);
2643 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2644 linkTag.addEventListener("mouseout", (event) => {
2645 removeElement(popup);
2647 linkTag.closest(".comments > .comment-thread").appendChild(popup);
2649 parentHighlightClassName = "comment-item-highlight";
2651 parent.parentNode.addClass(parentHighlightClassName);
2652 linkTag.addEventListener("mouseout", (event) => {
2653 parent.parentNode.removeClass(parentHighlightClassName);
2657 else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
2658 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
2659 && !(url.searchParams.get('format'))
2660 && !linkTag.closest("nav:not(.post-nav-links)")
2661 && (!url.hash || linkCommentId)
2662 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
2663 linkTag.addEventListener("pointerover", event => {
2664 if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
2665 if(currentPreviewPopup.linkTag) return;
2666 linkTag.createPreviewPopup();
2668 linkTag.createPreviewPopup = function() {
2669 removePreviewPopup(currentPreviewPopup);
2671 currentPreviewPopup = {linkTag: linkTag};
2673 let popup = document.createElement("iframe");
2674 currentPreviewPopup.element = popup;
2676 let popupTarget = linkHref;
2677 if(popupTarget.match(/#comment-/)) {
2678 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
2680 // 'theme' attribute is required for proper caching
2681 popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
2682 popup.addClass("preview-popup");
2684 let linkRect = linkTag.getBoundingClientRect();
2686 if(linkRect.right + 710 < window.innerWidth)
2687 popup.style.left = linkRect.right + 10 + "px";
2689 popup.style.right = "10px";
2691 popup.style.width = "700px";
2692 popup.style.height = "500px";
2693 popup.style.visibility = "hidden";
2694 popup.style.transition = "none";
2696 let recenter = function() {
2697 let popupHeight = 500;
2698 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
2699 let popupContent = popup.contentDocument.querySelector("#content");
2701 popupHeight = popupContent.clientHeight + 2;
2702 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
2703 popup.style.height = popupHeight + "px";
2706 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
2711 query('#content').insertAdjacentElement("beforeend", popup);
2713 let clickListener = event => {
2714 if(!event.target.closest("a, input, label")
2715 && !event.target.closest("popup-hide-button")) {
2716 window.location = linkHref;
2720 popup.addEventListener("load", () => {
2721 let hideButton = popup.contentDocument.createElement("div");
2722 hideButton.className = "popup-hide-button";
2723 hideButton.insertAdjacentText('beforeend', "\uF070");
2724 hideButton.onclick = (event) => {
2725 removePreviewPopup(currentPreviewPopup);
2726 setPreviewPopupsEnabled(false);
2727 event.stopPropagation();
2729 popup.contentDocument.body.appendChild(hideButton);
2731 let body = popup.contentDocument.body;
2732 body.addEventListener("click", clickListener);
2733 body.style.cursor = "pointer";
2738 popup.contentDocument.body.addEventListener("click", clickListener);
2740 currentPreviewPopup.timeout = setTimeout(() => {
2743 requestIdleCallback(() => {
2744 if(currentPreviewPopup.element === popup) {
2745 popup.scrolling = "";
2746 popup.style.visibility = "unset";
2747 popup.style.transition = null;
2750 { opacity: 0, transform: "translateY(10%)" },
2751 { opacity: 1, transform: "none" }
2752 ], { duration: 150, easing: "ease-out" });
2757 let pointerX, pointerY, mousePauseTimeout = null;
2759 currentPreviewPopup.pointerListener = (event) => {
2760 pointerX = event.clientX;
2761 pointerY = event.clientY;
2763 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
2764 mousePauseTimeout = null;
2766 let overElement = document.elementFromPoint(pointerX, pointerY);
2767 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
2769 if(mouseIsOverLink || overElement === popup
2770 || (pointerX < popup.getBoundingClientRect().left
2771 && event.movementX >= 0)) {
2772 if(!mouseIsOverLink && overElement !== popup) {
2773 if(overElement['createPreviewPopup']) {
2774 mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
2776 mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
2780 removePreviewPopup(currentPreviewPopup);
2781 if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
2784 window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
2786 currentPreviewPopup.mouseoutListener = (event) => {
2787 clearTimeout(mousePauseTimeout);
2788 mousePauseTimeout = null;
2790 document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
2792 currentPreviewPopup.scrollListener = (event) => {
2793 let overElement = document.elementFromPoint(pointerX, pointerY);
2794 linkRect = linkTag.getBoundingClientRect();
2795 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
2796 removePreviewPopup(currentPreviewPopup);
2798 window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
2803 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2807 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2808 GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2809 "#content .comments .comment-thread"
2811 applyFilters(GW.currentFilters);
2818 function imageFocusSetup(imagesOverlayOnly = false) {
2819 if (typeof GW.imageFocus == "undefined")
2821 contentImagesSelector: "#content img",
2822 overlayImagesSelector: "#images-overlay img",
2823 focusedImageSelector: "#content img.focused, #images-overlay img.focused",
2824 pageContentSelector: "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
2826 hideUITimerDuration: 1500,
2827 hideUITimerExpired: () => {
2828 GWLog("GW.imageFocus.hideUITimerExpired");
2829 let currentTime = new Date();
2830 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
2831 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
2832 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
2835 cancelImageFocusHideUITimer();
2840 GWLog("imageFocusSetup");
2841 // Create event listener for clicking on images to focus them.
2842 GW.imageClickedToFocus = (event) => {
2843 GWLog("GW.imageClickedToFocus");
2844 focusImage(event.target);
2847 // Set timer to hide the image focus UI.
2848 unhideImageFocusUI();
2849 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
2852 // Add the listener to each image in the overlay (i.e., those in the post).
2853 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
2854 image.addActivateEvent(GW.imageClickedToFocus);
2856 // Accesskey-L starts the slideshow.
2857 (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
2858 // Count how many images there are in the post, and set the "… of X" label to that.
2859 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
2860 if (imagesOverlayOnly) return;
2861 // Add the listener to all other content images (including those in comments).
2862 queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
2863 image.addActivateEvent(GW.imageClickedToFocus);
2866 // Create the image focus overlay.
2867 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
2868 `<div class='help-overlay'>
2869 <p><strong>Arrow keys:</strong> Next/previous image</p>
2870 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2871 <p><strong>Space bar:</strong> Reset image size & position</p>
2872 <p><strong>Scroll</strong> to zoom in/out</p>
2873 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2875 <div class='image-number'></div>
2876 <div class='slideshow-buttons'>
2877 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
2878 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
2880 <div class='caption'></div>` +
2882 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2884 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2885 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
2886 GWLog("GW.imageFocus.slideshowButtonClicked");
2887 focusNextImage(event.target.hasClass("next"));
2888 event.target.blur();
2892 // On orientation change, reset the size & position.
2893 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2894 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2897 // UI starts out hidden.
2901 function focusImage(imageToFocus) {
2902 GWLog("focusImage");
2903 // Clear 'last-focused' class of last focused image.
2904 let lastFocusedImage = query("img.last-focused");
2905 if (lastFocusedImage) {
2906 lastFocusedImage.removeClass("last-focused");
2907 lastFocusedImage.removeAttribute("accesskey");
2910 // Create the focused version of the image.
2911 imageToFocus.addClass("focused");
2912 let imageFocusOverlay = query("#image-focus-overlay");
2913 let clonedImage = imageToFocus.cloneNode(true);
2914 clonedImage.style = "";
2915 clonedImage.removeAttribute("width");
2916 clonedImage.removeAttribute("height");
2917 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2918 imageFocusOverlay.appendChild(clonedImage);
2919 imageFocusOverlay.addClass("engaged");
2921 // Set image to default size and position.
2922 resetFocusedImagePosition();
2924 // Blur everything else.
2925 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
2926 element.addClass("blurred");
2929 // Add listener to zoom image with scroll wheel.
2930 window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
2931 GWLog("GW.imageFocus.scrollEvent");
2932 event.preventDefault();
2934 let image = query("#image-focus-overlay img");
2936 // Remove the filter.
2937 image.savedFilter = image.style.filter;
2938 image.style.filter = 'none';
2940 // Locate point under cursor.
2941 let imageBoundingBox = image.getBoundingClientRect();
2943 // Calculate resize factor.
2944 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2945 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2949 image.style.width = (event.deltaY < 0 ?
2950 (image.clientWidth * factor) :
2951 (image.clientWidth / factor))
2953 image.style.height = "";
2955 // Designate zoom origin.
2957 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2958 // the cursor is over the image.
2959 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2960 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2961 (imageBoundingBox.left <= event.clientX &&
2962 event.clientX <= imageBoundingBox.right &&
2963 imageBoundingBox.top <= event.clientY &&
2964 event.clientY <= imageBoundingBox.bottom);
2965 // Otherwise, if we're zooming OUT, zoom from window center; if we're
2966 // zooming IN, zoom from image center.
2967 let zoomingFromWindowCenter = event.deltaY > 0;
2968 if (zoomingFromCursor)
2969 zoomOrigin = { x: event.clientX,
2971 else if (zoomingFromWindowCenter)
2972 zoomOrigin = { x: window.innerWidth / 2,
2973 y: window.innerHeight / 2 };
2975 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
2976 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
2978 // Calculate offset from zoom origin.
2979 let offsetOfImageFromZoomOrigin = {
2980 x: imageBoundingBox.x - zoomOrigin.x,
2981 y: imageBoundingBox.y - zoomOrigin.y
2983 // Calculate delta from centered zoom.
2984 let deltaFromCenteredZoom = {
2985 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
2986 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
2988 // Adjust image position appropriately.
2989 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
2990 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
2991 // Gradually re-center image, if it's smaller than the window.
2992 if (!imageSizeExceedsWindowBounds) {
2993 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
2994 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
2995 let windowCenter = { x: window.innerWidth / 2,
2996 y: window.innerHeight / 2 }
2997 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
2998 y: windowCenter.y - imageCenter.y }
2999 // Divide the offset by 10 because we're nudging the image toward center,
3000 // not jumping it there.
3001 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3002 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3005 // Put the filter back.
3006 image.style.filter = image.savedFilter;
3008 // Set the cursor appropriately.
3009 setFocusedImageCursor();
3011 window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3012 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3013 event.preventDefault();
3016 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3017 window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3018 GWLog("GW.imageFocus.mouseUp");
3019 window.onmousemove = '';
3021 // We only want to do anything on left-clicks.
3022 if (event.button != 0) return;
3024 // Don't unfocus if click was on a slideshow next/prev button!
3025 if (event.target.hasClass("slideshow-button")) return;
3027 // We also don't want to do anything if clicked on the help overlay.
3028 if (event.target.classList.contains("help-overlay") ||
3029 event.target.closest(".help-overlay"))
3032 let focusedImage = query("#image-focus-overlay img");
3033 if (event.target == focusedImage &&
3034 (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3035 // If the mouseup event was the end of a pan of an overside image,
3036 // put the filter back; do not unfocus.
3037 focusedImage.style.filter = focusedImage.savedFilter;
3039 unfocusImageOverlay();
3043 window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3044 GWLog("GW.imageFocus.mouseDown");
3045 event.preventDefault();
3047 let focusedImage = query("#image-focus-overlay img");
3048 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3049 let mouseCoordX = event.clientX;
3050 let mouseCoordY = event.clientY;
3052 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3053 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3056 focusedImage.savedFilter = focusedImage.style.filter;
3058 window.onmousemove = (event) => {
3059 // Remove the filter.
3060 focusedImage.style.filter = "none";
3061 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3062 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3068 // Double-click on the image unfocuses.
3069 clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3070 GWLog("GW.imageFocus.doubleClick");
3071 if (event.target.hasClass("slideshow-button")) return;
3073 unfocusImageOverlay();
3076 // Escape key unfocuses, spacebar resets.
3077 document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3078 GWLog("GW.imageFocus.keyUp");
3079 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3080 if (!allowedKeys.contains(event.key) ||
3081 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3083 event.preventDefault();
3085 switch (event.key) {
3088 unfocusImageOverlay();
3092 resetFocusedImagePosition();
3098 if (query("#images-overlay img.focused")) focusNextImage(true);
3104 if (query("#images-overlay img.focused")) focusNextImage(false);
3109 // Prevent spacebar or arrow keys from scrolling page when image focused.
3110 togglePageScrolling(false);
3112 // If the image comes from the images overlay, for the main post...
3113 if (imageToFocus.closest("#images-overlay")) {
3114 // Mark the overlay as being in slide show mode (to show buttons/count).
3115 imageFocusOverlay.addClass("slideshow");
3117 // Set state of next/previous buttons.
3118 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3119 var indexOfFocusedImage = getIndexOfFocusedImage();
3120 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3121 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3123 // Set the image number.
3124 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3126 // Replace the hash.
3127 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3129 imageFocusOverlay.removeClass("slideshow");
3133 setImageFocusCaption();
3135 // Moving mouse unhides image focus UI.
3136 window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3137 GWLog("GW.imageFocus.mouseMoved");
3138 let currentDateTime = new Date();
3139 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3140 cancelImageFocusHideUITimer();
3142 if (!GW.imageFocus.hideUITimer) {
3143 unhideImageFocusUI();
3144 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3146 GW.imageFocus.mouseLastMovedAt = currentDateTime;
3151 function resetFocusedImagePosition() {
3152 GWLog("resetFocusedImagePosition");
3153 let focusedImage = query("#image-focus-overlay img");
3154 if (!focusedImage) return;
3156 let sourceImage = query(GW.imageFocus.focusedImageSelector);
3158 // Make sure that initially, the image fits into the viewport.
3159 let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3160 let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3161 var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3162 let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3163 let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3164 focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3165 focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3167 // Remove modifications to position.
3168 focusedImage.style.left = "";
3169 focusedImage.style.top = "";
3171 // Set the cursor appropriately.
3172 setFocusedImageCursor();
3174 function setFocusedImageCursor() {
3175 let focusedImage = query("#image-focus-overlay img");
3176 if (!focusedImage) return;
3177 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
3181 function unfocusImageOverlay() {
3182 GWLog("unfocusImageOverlay");
3184 // Remove event listeners.
3185 window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3186 window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3187 // NOTE: The double-click listener does not need to be removed manually,
3188 // because the focused (cloned) image will be removed anyway.
3189 document.removeEventListener("keyup", GW.imageFocus.keyUp);
3190 document.removeEventListener("keydown", GW.imageFocus.keyDown);
3191 window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3192 window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3193 window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3195 // Set accesskey of currently focused image (if it's in the images overlay).
3196 let currentlyFocusedImage = query("#images-overlay img.focused");
3197 if (currentlyFocusedImage) {
3198 currentlyFocusedImage.addClass("last-focused");
3199 currentlyFocusedImage.accessKey = 'l';
3202 // Remove focused image and hide overlay.
3203 let imageFocusOverlay = query("#image-focus-overlay");
3204 imageFocusOverlay.removeClass("engaged");
3205 removeElement(imageFocusOverlay.query("img"));
3207 // Un-blur content/etc.
3208 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3209 element.removeClass("blurred");
3212 // Unset "focused" class of focused image.
3213 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3215 // Re-enable page scrolling.
3216 togglePageScrolling(true);
3218 // Reset the hash, if needed.
3219 if (location.hash.hasPrefix("#if_slide_"))
3220 history.replaceState(window.history.state, null, "#");
3223 function getIndexOfFocusedImage() {
3224 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3225 var indexOfFocusedImage = -1;
3226 for (i = 0; i < images.length; i++) {
3227 if (images[i].hasClass("focused")) {
3228 indexOfFocusedImage = i;
3232 return indexOfFocusedImage;
3235 function focusNextImage(next = true) {
3236 GWLog("focusNextImage");
3237 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3238 var indexOfFocusedImage = getIndexOfFocusedImage();
3240 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3242 // Remove existing image.
3243 removeElement("#image-focus-overlay img");
3244 // Unset "focused" class of just-removed image.
3245 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3247 // Create the focused version of the image.
3248 images[indexOfFocusedImage].addClass("focused");
3249 let imageFocusOverlay = query("#image-focus-overlay");
3250 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3251 clonedImage.style = "";
3252 clonedImage.removeAttribute("width");
3253 clonedImage.removeAttribute("height");
3254 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3255 imageFocusOverlay.appendChild(clonedImage);
3256 imageFocusOverlay.addClass("engaged");
3257 // Set image to default size and position.
3258 resetFocusedImagePosition();
3259 // Set state of next/previous buttons.
3260 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3261 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3262 // Set the image number display.
3263 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3265 setImageFocusCaption();
3266 // Replace the hash.
3267 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3270 function setImageFocusCaption() {
3271 GWLog("setImageFocusCaption");
3272 var T = { }; // Temporary storage.
3274 // Clear existing caption, if any.
3275 let captionContainer = query("#image-focus-overlay .caption");
3276 Array.from(captionContainer.children).forEach(child => { child.remove(); });
3278 // Determine caption.
3279 let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3281 if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
3282 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3283 captionHTML = (T.figcaption.query("p")) ?
3284 T.figcaption.innerHTML :
3285 "<p>" + T.figcaption.innerHTML + "</p>";
3286 } else if (currentlyFocusedImage.title != "") {
3287 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3289 // Insert the caption, if any.
3290 if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3293 function hideImageFocusUI() {
3294 GWLog("hideImageFocusUI");
3295 let imageFocusOverlay = query("#image-focus-overlay");
3296 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3297 element.addClass("hidden");
3301 function unhideImageFocusUI() {
3302 GWLog("unhideImageFocusUI");
3303 let imageFocusOverlay = query("#image-focus-overlay");
3304 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3305 element.removeClass("hidden");
3309 function cancelImageFocusHideUITimer() {
3310 clearTimeout(GW.imageFocus.hideUITimer);
3311 GW.imageFocus.hideUITimer = null;
3318 function keyboardHelpSetup() {
3319 let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3320 <div class='keyboard-help-container'>
3321 <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'></button>
3322 <h1>Keyboard shortcuts</h1>
3323 <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>
3324 <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3325 <div class='keyboard-shortcuts-lists'>` + [ [
3327 [ [ '?' ], "Show keyboard shortcuts" ],
3328 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3331 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3332 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3333 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3334 [ [ 'ak-m' ], "Go to Meta view" ],
3335 [ [ 'ak-v' ], "Go to Tags view"],
3336 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3337 [ [ 'ak-r' ], "Go to Archive view" ],
3338 [ [ 'ak-q' ], "Go to Sequences view" ],
3339 [ [ 'ak-t' ], "Go to About page" ],
3340 [ [ 'ak-u' ], "Go to User or Login page" ],
3341 [ [ 'ak-o' ], "Go to Inbox page" ]
3344 [ [ 'ak-,' ], "Jump up to top of page" ],
3345 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3346 [ [ 'ak-/' ], "Jump to top of comments section" ],
3347 [ [ 'ak-s' ], "Search" ],
3350 [ [ 'ak-n' ], "New post or comment" ],
3351 [ [ 'ak-e' ], "Edit current post" ]
3353 "Post/comment list views",
3354 [ [ '.' ], "Focus next entry in list" ],
3355 [ [ ',' ], "Focus previous entry in list" ],
3356 [ [ ';' ], "Cycle between links in focused entry" ],
3357 [ [ 'Enter' ], "Go to currently focused entry" ],
3358 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3359 [ [ 'ak-]' ], "Go to next page" ],
3360 [ [ 'ak-[' ], "Go to previous page" ],
3361 [ [ 'ak-\\' ], "Go to first page" ],
3362 [ [ 'ak-e' ], "Edit currently focused post" ]
3365 [ [ 'ak-k' ], "Bold text" ],
3366 [ [ 'ak-i' ], "Italic text" ],
3367 [ [ 'ak-l' ], "Insert hyperlink" ],
3368 [ [ 'ak-q' ], "Blockquote text" ]
3371 [ [ 'ak-=' ], "Increase text size" ],
3372 [ [ 'ak--' ], "Decrease text size" ],
3373 [ [ 'ak-0' ], "Reset to default text size" ],
3374 [ [ 'ak-′' ], "Cycle through content width settings" ],
3375 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3376 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3377 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3378 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3379 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3380 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3381 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3382 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3383 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3384 [ [ 'ak-;' ], "Open theme tweaker" ],
3385 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3386 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3389 [ [ 'ak-l' ], "Start/resume slideshow" ],
3390 [ [ 'Esc' ], "Exit slideshow" ],
3391 [ [ '→', '↓' ], "Next slide" ],
3392 [ [ '←', '↑' ], "Previous slide" ],
3393 [ [ 'Space' ], "Reset slide zoom" ]
3396 [ [ 'ak-x' ], "Switch to next view on user page" ],
3397 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3398 [ [ 'ak-` ' ], "Toggle compact comment list view" ],
3399 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3401 `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3403 <span class='keys'>` +
3405 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3408 <span class='action'>${entry[1]}</span>
3410 ).join("\n") + `</ul>`).join("\n") + `
3415 // Add listener to show the keyboard help overlay.
3416 document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3417 GWLog("GW.keyboardHelpShowKeyPressed");
3418 if (event.key == '?')
3419 toggleKeyboardHelpOverlay(true);
3422 // Clicking the background overlay closes the keyboard help overlay.
3423 keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3424 GWLog("GW.keyboardHelpOverlayClicked");
3425 if (event.type == 'mousedown') {
3426 keyboardHelpOverlay.style.opacity = "0.01";
3428 toggleKeyboardHelpOverlay(false);
3429 keyboardHelpOverlay.style.opacity = "1.0";
3433 // Intercept clicks, so they don't "fall through" the background overlay.
3434 (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3436 // Clicking the close button closes the keyboard help overlay.
3437 keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3438 toggleKeyboardHelpOverlay(false);
3441 // Add button to open keyboard help.
3442 query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'></button>");
3443 query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3444 GWLog("GW.openKeyboardHelpButtonClicked");
3445 toggleKeyboardHelpOverlay(true);
3446 event.target.blur();
3450 function toggleKeyboardHelpOverlay(show) {
3451 console.log("toggleKeyboardHelpOverlay");
3453 let keyboardHelpOverlay = query("#keyboard-help-overlay");
3454 show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3455 keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3457 // Prevent scrolling the document when the overlay is visible.
3458 togglePageScrolling(!show);
3460 // Focus the close button as soon as we open.
3461 keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3464 // Add listener to show the keyboard help overlay.
3465 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3466 GWLog("GW.keyboardHelpHideKeyPressed");
3467 if (event.key == 'Escape')
3468 toggleKeyboardHelpOverlay(false);
3471 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3474 // Disable / enable tab-selection of the search box.
3475 setSearchBoxTabSelectable(!show);
3478 /**********************/
3479 /* PUSH NOTIFICATIONS */
3480 /**********************/
3482 function pushNotificationsSetup() {
3483 let pushNotificationsButton = query("#enable-push-notifications");
3484 if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3485 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3486 pushNotificationsButton.style.display = 'unset';
3490 function urlBase64ToUint8Array(base64String) {
3491 const padding = '='.repeat((4 - base64String.length % 4) % 4);
3492 const base64 = (base64String + padding)
3494 .replace(/_/g, '/');
3496 const rawData = window.atob(base64);
3497 const outputArray = new Uint8Array(rawData.length);
3499 for (let i = 0; i < rawData.length; ++i) {
3500 outputArray[i] = rawData.charCodeAt(i);
3505 function pushNotificationsButtonClicked(event) {
3506 event.target.style.opacity = 0.33;
3507 event.target.style.pointerEvents = "none";
3509 let reEnable = (message) => {
3510 if(message) alert(message);
3511 event.target.style.opacity = 1;
3512 event.target.style.pointerEvents = "unset";
3515 if(event.target.dataset.enabled) {
3516 fetch('/push/register', {
3518 headers: { 'Content-type': 'application/json' },
3519 body: JSON.stringify({
3523 event.target.innerHTML = "Enable push notifications";
3524 event.target.dataset.enabled = "";
3526 }).catch((err) => reEnable(err.message));
3528 Notification.requestPermission().then((permission) => {
3529 navigator.serviceWorker.ready
3530 .then((registration) => {
3531 return registration.pushManager.getSubscription()
3532 .then(async function(subscription) {
3534 return subscription;
3536 return registration.pushManager.subscribe({
3537 userVisibleOnly: true,
3538 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
3541 .catch((err) => reEnable(err.message));
3543 .then((subscription) => {
3544 fetch('/push/register', {
3547 'Content-type': 'application/json'
3549 body: JSON.stringify({
3550 subscription: subscription
3555 event.target.innerHTML = "Disable push notifications";
3556 event.target.dataset.enabled = "true";
3559 .catch(function(err){ reEnable(err.message) });
3565 /*******************************/
3566 /* HTML TO MARKDOWN CONVERSION */
3567 /*******************************/
3569 function MarkdownFromHTML(text) {
3570 GWLog("MarkdownFromHTML");
3571 // Wrapper tags, paragraphs, bold, italic, code blocks.
3572 text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
3595 // <div> and <span>.
3596 text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
3598 }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
3603 text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
3604 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
3605 return `* ${listItem}\n`;
3610 text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
3611 var countedItemValue = 0;
3612 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
3614 if (typeof specifiedItemValue != "undefined") {
3615 specifiedItemValue = parseInt(specifiedItemValue);
3616 countedItemValue = itemValue = specifiedItemValue;
3618 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
3620 return `${itemValue}. ${listItem.trim()}\n`;
3625 text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
3626 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
3630 text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
3631 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
3635 text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
3636 return `[${text}](${href})`;
3640 text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
3641 return `![](${src})`;
3644 // Horizontal rules.
3645 text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
3650 text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
3654 // Preformatted text (possibly with a code block inside).
3655 text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
3656 return "```\n" + text + "\n```";
3660 text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
3661 return "`" + text + "`";
3665 text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
3685 /************************************/
3686 /* ANCHOR LINK SCROLLING WORKAROUND */
3687 /************************************/
3689 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
3690 let hash = location.hash;
3691 if(hash && hash !== "#top" && !document.query(hash)) {
3692 let content = document.query("#content");
3693 content.style.display = "none";
3694 addTriggerListener("DOMReady", {priority: -1, fn: () => {
3695 content.style.visibility = "hidden";
3696 content.style.display = null;
3697 requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
3702 /******************/
3703 /* INITIALIZATION */
3704 /******************/
3706 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
3707 GWLog("INITIALIZER earlyInitialize");
3708 // Check to see whether we're on a mobile device (which we define as a narrow screen)
3709 GW.isMobile = (window.innerWidth <= 1160);
3710 GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3712 // Backward compatibility
3713 let storedTheme = localStorage.getItem('selected-theme');
3715 setTheme(storedTheme);
3716 localStorage.removeItem('selected-theme');
3719 // Animate width & theme adjustments?
3720 GW.adjustmentTransitions = false;
3722 // Add the content width selector.
3723 injectContentWidthSelector();
3724 // Add the text size adjustment widget.
3725 injectTextSizeAdjustmentUI();
3726 // Add the theme selector.
3727 injectThemeSelector();
3728 // Add the theme tweaker.
3729 injectThemeTweaker();
3730 // Add the quick-nav UI.
3733 // Finish initializing when ready.
3734 addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
3737 function mainInitializer() {
3738 GWLog("INITIALIZER initialize");
3740 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
3741 // "link without nav bars".
3742 if (getQueryVariable("hide-nav-bars") == "true") {
3743 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
3746 // If the page cannot have comments, remove the accesskey from the #comments
3747 // quick-nav button; and if the page can have comments, but does not, simply
3748 // disable the #comments quick nav button.
3749 let content = query("#content");
3750 if (content.query("#comments") == null) {
3751 query("#quick-nav-ui a[href='#comments']").accessKey = '';
3752 } else if (content.query("#comments .comment-thread") == null) {
3753 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
3756 // On edit post pages and conversation pages, add GUIEdit buttons to the
3757 // textarea, expand it, and markdownify the existing text, if any (this is
3758 // needed if a post was last edited on LW).
3759 queryAll(".with-markdown-editor textarea").forEach(textarea => {
3760 textarea.addTextareaFeatures();
3761 expandTextarea(textarea);
3762 textarea.value = MarkdownFromHTML(textarea.value);
3764 // Focus the textarea.
3765 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3768 queryAll(".contents-list li a").forEach(tocLink => {
3769 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
3770 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
3771 tocLink.innerText = tocLink.innerText.replace(/^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})\. /i, '');
3772 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
3775 // If we're on a comment thread page...
3776 if (query(".comments") != null) {
3777 // Add comment-minimize buttons to every comment.
3778 queryAll(".comment-meta").forEach(commentMeta => {
3779 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3780 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
3782 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
3783 // Format and activate comment-minimize buttons.
3784 queryAll(".comment-minimize-button").forEach(button => {
3785 button.closest(".comment-item").setCommentThreadMaximized(false);
3786 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3787 event.target.closest(".comment-item").setCommentThreadMaximized(true);
3792 if (getQueryVariable("chrono") == "t") {
3793 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
3796 // On mobile, replace the labels for the checkboxes on the edit post form
3797 // with icons, to save space.
3798 if (GW.isMobile && query(".edit-post-page")) {
3799 query("label[for='link-post']").innerHTML = "";
3800 query("label[for='question']").innerHTML = "";
3803 // Add error message (as placeholder) if user tries to click Search with
3804 // an empty search field.
3806 let searchForm = query("#nav-item-search form");
3807 if(!searchForm) break searchForm;
3808 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3809 let searchField = event.target.query("input");
3810 if (searchField.value == "") {
3811 event.preventDefault();
3812 event.target.blur();
3813 searchField.placeholder = "Enter a search string!";
3814 searchField.focus();
3817 // Remove the placeholder / error on any input.
3818 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3819 event.target.placeholder = "";
3823 // Prevent conflict between various single-hotkey listeners and text fields
3824 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3825 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3826 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
3829 if (content.hasClass("post-page")) {
3830 // Read and update last-visited-date.
3831 let lastVisitedDate = getLastVisitedDate();
3832 setLastVisitedDate(Date.now());
3834 // Save the number of comments this post has when it's visited.
3835 updateSavedCommentCount();
3837 if (content.query(".comments .comment-thread") != null) {
3838 // Add the new comments count & navigator.
3839 injectNewCommentNavUI();
3841 // Get the highlight-new-since date (as specified by URL parameter, if
3842 // present, or otherwise the date of the last visit).
3843 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3845 // Highlight new comments since the specified date.
3846 let newCommentsCount = highlightCommentsSince(hnsDate);
3848 // Update the comment count display.
3849 updateNewCommentNavUI(newCommentsCount, hnsDate);
3852 // On listing pages, make comment counts more informative.
3853 badgePostsWithNewComments();
3856 // Add the comments list mode selector widget (expanded vs. compact).
3857 injectCommentsListModeSelector();
3859 // Add the comments view selector widget (threaded vs. chrono).
3860 // injectCommentsViewModeSelector();
3862 // Add the comments sort mode selector (top, hot, new, old).
3863 if (GW.useFancyFeatures) injectCommentsSortModeSelector();
3865 // Add the toggle for the post nav UI elements on mobile.
3866 if (GW.isMobile) injectPostNavUIToggle();
3868 // Add the toggle for the appearance adjustment UI elements on mobile.
3869 if (GW.isMobile) injectAppearanceAdjustUIToggle();
3871 // Add the antikibitzer.
3872 if (GW.useFancyFeatures) injectAntiKibitzer();
3874 // Add comment parent popups.
3875 injectPreviewPopupToggle();
3876 addCommentParentPopups();
3878 // Mark original poster's comments with a special class.
3879 markOriginalPosterComments();
3881 // On the All view, mark posts with non-positive karma with a special class.
3882 if (query("#content").hasClass("all-index-page")) {
3883 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3884 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3886 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3890 // Set the "submit" button on the edit post page to something more helpful.
3891 setEditPostPageSubmitButtonText();
3893 // Compute the text of the pagination UI tooltip text.
3894 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3895 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3898 // Add event listeners for Escape and Enter, for the theme tweaker.
3899 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3900 let themeTweakerUI = query("#theme-tweaker-ui");
3901 document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3902 if (event.key == "Escape") {
3903 if (themeTweakerHelpWindow.style.display != "none") {
3904 toggleThemeTweakerHelpWindow();
3905 themeTweakerResetSettings();
3906 } else if (themeTweakerUI.style.display != "none") {
3907 toggleThemeTweakerUI();
3910 } else if (event.key == "Enter") {
3911 if (themeTweakerHelpWindow.style.display != "none") {
3912 toggleThemeTweakerHelpWindow();
3913 themeTweakerSaveSettings();
3914 } else if (themeTweakerUI.style.display != "none") {
3915 toggleThemeTweakerUI();
3921 // Add event listener for . , ; (for navigating listings pages).
3922 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3923 if (!query(".comments") && listings.length > 0) {
3924 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
3925 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
3927 if (event.key == "Escape") {
3928 if (document.activeElement.parentElement.hasClass("listing"))
3929 document.activeElement.blur();
3933 if (event.key == ';') {
3934 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3935 let links = document.activeElement.parentElement.queryAll("a");
3936 links[document.activeElement == links[0] ? 1 : 0].focus();
3937 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3938 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3939 links[document.activeElement == links[0] ? 1 : 0].focus();
3940 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3945 var indexOfActiveListing = -1;
3946 for (i = 0; i < listings.length; i++) {
3947 if (document.activeElement.parentElement.hasClass("listing") &&
3948 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3949 indexOfActiveListing = i;
3951 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
3952 listings[i] === document.activeElement.parentElement.query("a.date")) {
3953 indexOfActiveListing = i;
3957 // Remove edit accesskey from currently highlighted post by active user, if applicable.
3958 if (indexOfActiveListing > -1) {
3959 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
3961 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3962 if (indexOfNextListing < listings.length) {
3963 listings[indexOfNextListing].focus();
3965 if (listings[indexOfNextListing].closest(".comment-item")) {
3966 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3967 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3970 document.activeElement.blur();
3972 // Add edit accesskey to newly highlighted post by active user, if applicable.
3973 (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
3975 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
3976 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
3977 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
3981 // Add event listener for ; (to focus the link on link posts).
3982 if (query("#content").hasClass("post-page") &&
3983 query(".post").hasClass("link-post")) {
3984 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
3985 if (event.key == ';') query("a.link-post-link").focus();
3989 // Add accesskeys to user page view selector.
3990 let viewSelector = query("#content.user-page > .sublevel-nav");
3992 let currentView = viewSelector.query("span");
3993 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
3994 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
3997 // Add accesskey to index page sort selector.
3998 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4000 // Move MathJax style tags to <head>.
4001 var aggregatedStyles = "";
4002 queryAll("#content style").forEach(styleTag => {
4003 aggregatedStyles += styleTag.innerHTML;
4004 removeElement("style", styleTag.parentElement);
4006 if (aggregatedStyles != "") {
4007 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4010 // Add listeners to switch between word count and read time.
4011 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4012 queryAll(".post-meta .read-time").forEach(element => {
4013 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4014 let displayWordCount = localStorage.getItem("display-word-count");
4015 toggleReadTimeOrWordCount(!displayWordCount);
4016 if (displayWordCount) localStorage.removeItem("display-word-count");
4017 else localStorage.setItem("display-word-count", true);
4021 // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4022 query("#content").addEventListener("copy", GW.textCopied = (event) => {
4023 if(event.target.matches("input, textarea")) return;
4024 event.preventDefault();
4025 const selectedHTML = getSelectionHTML();
4026 const selectedText = getSelection().toString();
4027 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4028 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4031 // Set up Image Focus feature.
4034 // Set up keyboard shortcuts guide overlay.
4035 keyboardHelpSetup();
4037 // Show push notifications button if supported
4038 pushNotificationsSetup();
4040 // Show elements now that javascript is ready.
4041 removeElement("#hide-until-init");
4043 activateTrigger("pageLayoutFinished");
4046 /*************************/
4047 /* POST-LOAD ADJUSTMENTS */
4048 /*************************/
4050 window.addEventListener("pageshow", badgePostsWithNewComments);
4052 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4053 GWLog("INITIALIZER pageLayoutFinished");
4055 postSetThemeHousekeeping();
4057 focusImageSpecifiedByURL();
4059 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4060 // query("input[type='search']").value = GW.isMobile;
4061 // insertHeadHTML("<style>" +
4062 // `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` +
4063 // `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` +
4067 function generateImagesOverlay() {
4068 GWLog("generateImagesOverlay");
4069 // Don't do this on the about page.
4070 if (query(".about-page") != null) return;
4073 // Remove existing, if any.
4074 removeElement("#images-overlay");
4077 query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4078 let imagesOverlay = query("#images-overlay");
4079 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4080 queryAll(".post-body img").forEach(image => {
4081 let clonedImageContainer = document.createElement("div");
4083 let clonedImage = image.cloneNode(true);
4084 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4085 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4086 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4087 clonedImageContainer.appendChild(clonedImage);
4089 let zoomLevel = parseFloat(GW.currentTextZoom);
4091 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4092 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4093 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4094 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4096 imagesOverlay.appendChild(clonedImageContainer);
4099 // Add the event listeners to focus each image.
4100 imageFocusSetup(true);
4103 function adjustUIForWindowSize() {
4104 GWLog("adjustUIForWindowSize");
4105 var bottomBarOffset;
4107 // Adjust bottom bar state.
4108 let bottomBar = query("#bottom-bar");
4109 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4110 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4111 bottomBar.removeClass("decorative");
4113 bottomBar.query("#nav-item-top").style.display = "";
4114 } else if (bottomBar) {
4115 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4116 else bottomBar.addClass("decorative");
4118 bottomBar.query("#nav-item-top").style.display = "none";
4121 // Show quick-nav UI up/down buttons if content is taller than window.
4122 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4123 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4124 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4127 // Move anti-kibitzer toggle if content is very short.
4128 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4130 // Update the visibility of the post nav UI.
4131 updatePostNavUIVisibility();
4134 function recomputeUIElementsContainerHeight(force = false) {
4135 GWLog("recomputeUIElementsContainerHeight");
4137 (force || query("#ui-elements-container").style.height != "")) {
4138 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4139 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
4140 query("#content").clientHeight + "px" :
4145 function focusImageSpecifiedByURL() {
4146 GWLog("focusImageSpecifiedByURL");
4147 if (location.hash.hasPrefix("#if_slide_")) {
4148 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4149 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4150 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4151 if (imageToFocus > 0 && imageToFocus <= images.length) {
4152 focusImage(images[imageToFocus - 1]);
4154 // Set timer to hide the image focus UI.
4155 unhideImageFocusUI();
4156 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4166 function insertMarkup(event) {
4167 var mopen = '', mclose = '', mtext = '', func = false;
4168 if (typeof arguments[1] == 'function') {
4169 func = arguments[1];
4171 mopen = arguments[1];
4172 mclose = arguments[2];
4173 mtext = arguments[3];
4176 var textarea = event.target.closest("form").query("textarea");
4178 var p0 = textarea.selectionStart;
4179 var p1 = textarea.selectionEnd;
4180 var cur0 = cur1 = p0;
4182 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4183 str = func ? func(str, p0) : (mopen + str + mclose);
4185 // Determine selection.
4187 cur0 += (p0 == p1) ? mopen.length : str.length;
4188 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4195 // Update textarea contents.
4196 // The document.execCommand API is broken in Firefox
4197 // ( https://bugzilla.mozilla.org/show_bug.cgi?id=1220696 ), but using it
4198 // allows native undo/redo to work; so we enable it in other browsers.
4200 textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
4202 document.execCommand("insertText", false, str);
4204 // Expand textarea, if needed.
4205 expandTextarea(textarea);
4208 textarea.selectionStart = cur0;
4209 textarea.selectionEnd = cur1;
4214 GW.guiEditButtons = [
4215 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
4216 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
4217 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
4218 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
4219 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
4220 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
4221 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
4222 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
4223 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
4224 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
4225 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
4226 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
4227 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
4228 [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '' ],
4229 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
4232 function blockquote(text, startpos) {
4234 text = "> Quoted text";
4235 return [ text, startpos + 2, startpos + text.length ];
4237 text = "> " + text.split("\n").join("\n> ") + "\n";
4238 return [ text, startpos + text.length, startpos + text.length ];
4242 function hyperlink(text, startpos) {
4243 var url = '', link_text = text, endpos = startpos;
4244 if (text.search(/^https?/) != -1) {
4246 link_text = "link text";
4247 startpos = startpos + 1;
4248 endpos = startpos + link_text.length;
4250 url = prompt("Link address (URL):");
4252 endpos = startpos + text.length;
4253 return [ text, startpos, endpos ];
4255 startpos = startpos + text.length + url.length + 4;
4259 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4262 if(navigator.serviceWorker) {
4263 navigator.serviceWorker.register('/service-worker.js');
4264 setCookie("push", "t");