1 /***************************/
2 /* INITIALIZATION REGISTRY */
3 /***************************/
6 GW.initializersDone = { };
8 function registerInitializer(name, tryEarly, precondition, fn) {
9 GW.initializersDone[name] = false;
10 GW.initializers[name] = fn;
11 let wrapper = function () {
12 if (GW.initializersDone[name]) return;
13 if (!precondition()) {
15 setTimeout(() => requestIdleCallback(wrapper, {timeout: 1000}), 50);
17 document.addEventListener("readystatechange", wrapper, {once: true});
21 GW.initializersDone[name] = true;
25 requestIdleCallback(wrapper, {timeout: 1000});
27 document.addEventListener("readystatechange", wrapper, {once: true});
28 requestIdleCallback(wrapper);
31 function forceInitializer(name) {
32 if (GW.initializersDone[name]) return;
33 GW.initializersDone[name] = true;
34 GW.initializers[name]();
42 function setCookie(name, value, days) {
44 if (!days) days = 36500;
46 var date = new Date();
47 date.setTime(date.getTime() + (days*24*60*60*1000));
48 expires = "; expires=" + date.toUTCString();
50 document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax" + (GW.secureCookies ? "; Secure" : "");
53 /*******************************/
54 /* EVENT LISTENER MANIPULATION */
55 /*******************************/
57 /* Removes event listener from a clickable element, automatically detaching it
58 from all relevant event types. */
59 Element.prototype.removeActivateEvent = function() {
60 let ael = this.activateEventListener;
61 this.removeEventListener("mousedown", ael);
62 this.removeEventListener("click", ael);
63 this.removeEventListener("keyup", ael);
66 /* Adds a scroll event listener to the page. */
67 function addScrollListener(fn, name) {
68 let wrapper = (event) => {
69 requestAnimationFrame(() => {
71 document.addEventListener("scroll", wrapper, {once: true, passive: true});
74 document.addEventListener("scroll", wrapper, {once: true, passive: true});
76 // Retain a reference to the scroll listener, if a name is provided.
77 if (typeof name != "undefined")
85 // Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=325942
86 Element.prototype.scrollIntoView = function(realSIV) {
87 return function(bottom) {
88 realSIV.call(this, bottom);
89 if(fixTarget = this.closest("input[id^='expand'] ~ .comment-thread")) {
90 window.scrollBy(0, fixTarget.scrollTop);
91 fixTarget.scrollTop = 0;
94 }(Element.prototype.scrollIntoView);
96 /* If top of element is not at or above the top of the screen, scroll it into
98 Element.prototype.scrollIntoViewIfNeeded = function() {
99 GWLog("scrollIntoViewIfNeeded");
100 if (this.getBoundingClientRect().bottom > window.innerHeight &&
101 this.getBoundingClientRect().top > 0) {
102 this.scrollIntoView(false);
106 function urlEncodeQuery(params) {
107 return params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params[x])}).join("&");
110 function handleAjaxError(event) {
111 if(event.target.getResponseHeader("Content-Type") === "application/json") alert("Error: " + JSON.parse(event.target.responseText)["error"]);
112 else alert("Error: Something bad happened :(");
115 function doAjax(params) {
116 let req = new XMLHttpRequest();
117 let requestMethod = params["method"] || "GET";
118 req.addEventListener("load", (event) => {
119 if(event.target.status < 400) {
120 if(params["onSuccess"]) params.onSuccess(event);
122 if(params["onFailure"]) params.onFailure(event);
123 else handleAjaxError(event);
125 if(params["onFinish"]) params.onFinish(event);
127 req.open(requestMethod, (params.location || document.location) + ((requestMethod == "GET" && params.params) ? "?" + urlEncodeQuery(params.params) : ""));
128 if(requestMethod == "POST") {
129 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
130 params["params"]["csrf-token"] = GW.csrfToken;
131 req.send(urlEncodeQuery(params.params));
137 function activateReadyStateTriggers() {
138 if(document.readyState == 'interactive') {
139 activateTrigger('DOMReady');
140 } else if(document.readyState == 'complete') {
141 activateTrigger('DOMReady');
142 activateTrigger('DOMComplete');
146 document.addEventListener('readystatechange', activateReadyStateTriggers);
147 activateReadyStateTriggers();
149 function callWithServerData(fname, uri) {
152 onSuccess: (event) => {
153 let response = JSON.parse(event.target.responseText);
154 window[fname](response);
159 deferredCalls.forEach((x) => callWithServerData.apply(null, x));
160 deferredCalls = null;
162 /* Return the currently selected text, as HTML (rather than unstyled text).
164 function getSelectionHTML() {
165 var container = document.createElement("div");
166 container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
167 return container.innerHTML;
170 /* Given an HTML string, creates an element from that HTML, adds it to
171 #ui-elements-container (creating the latter if it does not exist), and
172 returns the created element.
174 function addUIElement(element_html) {
175 var ui_elements_container = query("#ui-elements-container");
176 if (!ui_elements_container) {
177 ui_elements_container = document.createElement("nav");
178 ui_elements_container.id = "ui-elements-container";
179 query("body").appendChild(ui_elements_container);
182 ui_elements_container.insertAdjacentHTML("beforeend", element_html);
183 return ui_elements_container.lastElementChild;
186 /* Given an element or a selector, removes that element (or the element
187 identified by the selector).
188 If multiple elements match the selector, only the first is removed.
190 function removeElement(elementOrSelector, ancestor = document) {
191 if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
192 if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
195 /* Returns true if the string begins with the given prefix.
197 String.prototype.hasPrefix = function (prefix) {
198 return (this.lastIndexOf(prefix, 0) === 0);
201 /* Toggles whether the page is scrollable.
203 function togglePageScrolling(enable) {
204 let body = query("body");
206 GW.scrollPositionBeforeScrollingDisabled = window.scrollY;
207 body.addClass("no-scroll");
208 body.style.top = `-${GW.scrollPositionBeforeScrollingDisabled}px`;
210 body.removeClass("no-scroll");
211 body.removeAttribute("style");
212 window.scrollTo(0, GW.scrollPositionBeforeScrollingDisabled);
216 DOMRectReadOnly.prototype.isInside = function (x, y) {
217 return (this.left <= x && this.right >= x && this.top <= y && this.bottom >= y);
220 /* Simple mutex mechanism.
222 function doIfAllowed(f, passHolder, passName, releaseImmediately = false) {
223 if (passHolder[passName] == false)
226 passHolder[passName] = false;
230 if (releaseImmediately) {
231 passHolder[passName] = true;
233 requestAnimationFrame(() => {
234 passHolder[passName] = true;
240 /********************/
241 /* DEBUGGING OUTPUT */
242 /********************/
244 GW.enableLogging = (permanently = false) => {
246 localStorage.setItem("logging-enabled", "true");
248 GW.loggingEnabled = true;
250 GW.disableLogging = (permanently = false) => {
252 localStorage.removeItem("logging-enabled");
254 GW.loggingEnabled = false;
257 /*******************/
258 /* INBOX INDICATOR */
259 /*******************/
261 function processUserStatus(userStatus) {
262 window.userStatus = userStatus;
264 if(userStatus.notifications) {
265 let element = query('#inbox-indicator');
266 element.className = 'new-messages';
267 element.title = 'New messages [o]';
278 function toggleMarkdownHintsBox() {
279 GWLog("toggleMarkdownHintsBox");
280 let markdownHintsBox = query("#markdown-hints");
281 markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
283 function hideMarkdownHintsBox() {
284 GWLog("hideMarkdownHintsBox");
285 let markdownHintsBox = query("#markdown-hints");
286 if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
289 Element.prototype.addTextareaFeatures = function() {
290 GWLog("addTextareaFeatures");
293 textarea.addEventListener("focus", GW.textareaFocused = (event) => {
294 GWLog("GW.textareaFocused");
295 event.target.closest("form").scrollIntoViewIfNeeded();
297 textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
298 GWLog("GW.textareaInputReceived");
299 if (window.innerWidth > 520) {
300 // Expand textarea if needed.
301 expandTextarea(textarea);
303 // Remove markdown hints.
304 hideMarkdownHintsBox();
305 query(".guiedit-mobile-help-button").removeClass("active");
308 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
309 textarea.addEventListener("keypress", (event) => { event.stopPropagation(); });
311 let form = textarea.closest("form");
312 if(form) form.addEventListener("submit", event => { textarea.value = MarkdownFromHTML(textarea.value)});
314 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
315 let textareaContainer = textarea.closest(".textarea-container");
316 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
317 for (var button of GW.guiEditButtons) {
318 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
319 buttons_container.insertAdjacentHTML("beforeend",
320 "<button type='button' class='guiedit guiedit-"
323 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
324 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
325 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
326 + " onclick='insertMarkup(event,"
327 + ((typeof m_before_or_func == 'function') ?
328 m_before_or_func.name :
329 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
337 `<input type='checkbox' id='markdown-hints-checkbox'>
338 <label for='markdown-hints-checkbox'></label>
339 <div id='markdown-hints'>` +
340 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
341 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
342 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
343 "<span>Heading 1</span><code># Heading 1</code>",
344 "<span>Heading 2</span><code>## Heading 1</code>",
345 "<span>Heading 3</span><code>### Heading 1</code>",
346 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
348 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
350 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
351 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
352 GWLog("GW.GUIEditMobileAuxiliaryButtonClicked");
353 if (button.hasClass("guiedit-mobile-help-button")) {
354 toggleMarkdownHintsBox();
355 event.target.toggleClass("active");
356 query(".posting-controls:focus-within textarea").focus();
357 } else if (button.hasClass("guiedit-mobile-exit-button")) {
359 hideMarkdownHintsBox();
360 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
365 // On smartphone (narrow mobile) screens, when a textarea is focused (and
366 // automatically fullscreened), remove all the filters from the page, and
367 // then apply them *just* to the fixed editor UI elements. This is in order
368 // to get around the “children of elements with a filter applied cannot be
370 if (GW.isMobile && window.innerWidth <= 520) {
371 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
372 textarea.addEventListener("focus", GW.textareaFocusedMobile = (event) => {
373 GWLog("GW.textareaFocusedMobile");
374 Appearance.savedFilters = Appearance.currentFilters;
375 Appearance.applyFilters(Appearance.noFilters);
376 fixedEditorElements.forEach(element => {
377 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
380 textarea.addEventListener("blur", GW.textareaBlurredMobile = (event) => {
381 GWLog("GW.textareaBlurredMobile");
382 requestAnimationFrame(() => {
383 Appearance.applyFilters(Appearance.savedFilters);
384 Appearance.savedFilters = null;
385 fixedEditorElements.forEach(element => {
386 element.style.filter = Appearance.filterStringFromFilters(Appearance.savedFilters);
393 Element.prototype.injectReplyForm = function(editMarkdownSource) {
394 GWLog("injectReplyForm");
395 let commentControls = this;
396 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
397 let postId = commentControls.parentElement.dataset["postId"];
398 let tagId = commentControls.parentElement.dataset["tagId"];
399 let withparent = (!editMarkdownSource && commentControls.getCommentId());
400 let answer = commentControls.parentElement.id == "answers";
401 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
402 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId());
403 let parentCommentItem = commentControls.closest(".comment-item");
404 let alignmentForum = userStatus.alignmentForumAllowed && alignmentForumPost &&
405 (!parentCommentItem || parentCommentItem.firstChild.querySelector(".comment-meta .alignment-forum"));
406 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
407 "<form method='post'>" +
408 "<div class='textarea-container'>" +
409 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
410 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
411 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
412 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
413 (postId ? "<input type='hidden' name='post-id' value='" + postId + "'>" : "") +
414 (tagId ? "<input type='hidden' name='tag-id' value='" + tagId + "'>" : "") +
415 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
416 (commentControls.parentElement.id == "nominations" ? "<input type='hidden' name='nomination' value='t'>" : "") +
417 (commentControls.parentElement.id == "reviews" ? "<input type='hidden' name='nomination-review' value='t'>" : "") +
418 (alignmentForum ? "<input type='hidden' name='af' value='t'>" : "") +
419 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
420 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
421 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
423 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
424 "<input type='submit' value='Submit'>" +
426 commentControls.onsubmit = disableBeforeUnload;
428 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
429 GWLog("GW.cancelCommentButtonClicked");
430 hideReplyForm(event.target.closest(".comment-controls"));
432 commentControls.scrollIntoViewIfNeeded();
433 commentControls.query("form").onsubmit = (event) => {
434 if (!event.target.text.value) {
435 alert("Please enter a comment.");
439 let textarea = commentControls.query("textarea");
440 textarea.value = MarkdownFromHTML(editMarkdownSource || "");
441 textarea.addTextareaFeatures();
445 function showCommentEditForm(commentItem) {
446 GWLog("showCommentEditForm");
448 let commentBody = commentItem.query(".comment-body");
449 commentBody.style.display = "none";
451 let commentControls = commentItem.query(".comment-controls");
452 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
453 commentControls.query("form").addClass("edit-existing-comment");
454 expandTextarea(commentControls.query("textarea"));
457 function showReplyForm(commentItem) {
458 GWLog("showReplyForm");
460 let commentControls = commentItem.query(".comment-controls");
461 commentControls.injectReplyForm(commentControls.dataset.enteredText);
464 function hideReplyForm(commentControls) {
465 GWLog("hideReplyForm");
466 // Are we editing a comment? If so, un-hide the existing comment body.
467 let containingComment = commentControls.closest(".comment-item");
468 if (containingComment) containingComment.query(".comment-body").style.display = "";
470 let enteredText = commentControls.query("textarea").value;
471 if (enteredText) commentControls.dataset.enteredText = enteredText;
473 disableBeforeUnload();
474 commentControls.constructCommentControls();
477 function expandTextarea(textarea) {
478 GWLog("expandTextarea");
479 if (window.innerWidth <= 520) return;
481 let totalBorderHeight = 30;
482 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
484 requestAnimationFrame(() => {
485 textarea.style.height = 'auto';
486 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
487 if (textarea.clientHeight < window.innerHeight) {
488 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
493 function doCommentAction(action, commentItem) {
494 GWLog("doCommentAction");
496 params[(action + "-comment-id")] = commentItem.getCommentId();
500 onSuccess: GW.commentActionPostSucceeded = (event) => {
501 GWLog("GW.commentActionPostSucceeded");
503 retract: () => { commentItem.firstChild.addClass("retracted") },
504 unretract: () => { commentItem.firstChild.removeClass("retracted") },
506 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>";
507 commentItem.removeChild(commentItem.query(".comment-controls"));
511 if(action != "delete")
512 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
521 function parseVoteType(voteType) {
522 GWLog("parseVoteType");
524 if (!voteType) return value;
525 value.up = /[Uu]pvote$/.test(voteType);
526 value.down = /[Dd]ownvote$/.test(voteType);
527 value.big = /^big/.test(voteType);
531 function makeVoteType(value) {
532 GWLog("makeVoteType");
533 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
536 function makeVoteClass(vote) {
537 GWLog("makeVoteClass");
538 if (vote.up || vote.down) {
539 return (vote.big ? 'selected big-vote' : 'selected');
545 function findVoteControls(targetType, targetId, voteAxis) {
546 var voteAxisQuery = (voteAxis ? "."+voteAxis : "");
548 if(targetType == "Post") {
549 return queryAll(".post-meta .voting-controls"+voteAxisQuery);
550 } else if(targetType == "Comment") {
551 return queryAll("#comment-"+targetId+" > .comment > .comment-meta .voting-controls"+voteAxisQuery+", #comment-"+targetId+" > .comment > .comment-controls .voting-controls"+voteAxisQuery);
555 function votesEqual(vote1, vote2) {
556 var allKeys = Object.assign({}, vote1);
557 Object.assign(allKeys, vote2);
559 for(k of allKeys.keys()) {
560 if((vote1[k] || "neutral") !== (vote2[k] || "neutral")) return false;
565 function addVoteButtons(element, vote, targetType) {
566 GWLog("addVoteButtons");
568 let voteAxis = element.parentElement.dataset.voteAxis || "karma";
569 let voteType = parseVoteType(vote[voteAxis]);
570 let voteClass = makeVoteClass(voteType);
572 element.parentElement.queryAll("button").forEach((button) => {
573 button.disabled = false;
575 if (button.dataset["voteType"] === (voteType.up ? "upvote" : "downvote"))
576 button.addClass(voteClass);
578 updateVoteButtonVisualState(button);
579 button.addActivateEvent(voteButtonClicked);
583 function updateVoteButtonVisualState(button) {
584 GWLog("updateVoteButtonVisualState");
586 button.removeClasses([ "none", "one", "two-temp", "two" ]);
589 button.addClass("none");
590 else if (button.hasClass("big-vote"))
591 button.addClass("two");
592 else if (button.hasClass("selected"))
593 button.addClass("one");
595 button.addClass("none");
598 function changeVoteButtonVisualState(button) {
599 GWLog("changeVoteButtonVisualState");
601 /* Interaction states are:
603 0 0· (neutral; +1 click)
604 1 1· (small vote; +1 click)
605 2 2· (big vote; +1 click)
607 Visual states are (with their state classes in [brackets]) are:
610 02 (small vote active) [one]
611 12 (small vote active, temporary indicator of big vote) [two-temp]
612 22 (big vote active) [two]
614 The following are the 9 possible interaction state transitions (and
615 the visual state transitions associated with them):
618 FROM TO FROM TO NOTES
619 ==== ==== ==== ==== =====
620 0 0· 01 12 first click
621 0· 1 12 02 one click without second
622 0· 2 12 22 second click
624 1 1· 02 12 first click
625 1· 0 12 01 one click without second
626 1· 2 12 22 second click
628 2 2· 22 12 first click
629 2· 1 12 02 one click without second
630 2· 0 12 01 second click
633 [ "big-vote two-temp clicked-twice", "none" ], // 2· => 0
634 [ "big-vote two-temp clicked-once", "one" ], // 2· => 1
635 [ "big-vote clicked-once", "two-temp" ], // 2 => 2·
637 [ "selected two-temp clicked-twice", "two" ], // 1· => 2
638 [ "selected two-temp clicked-once", "none" ], // 1· => 0
639 [ "selected clicked-once", "two-temp" ], // 1 => 1·
641 [ "two-temp clicked-twice", "two" ], // 0· => 2
642 [ "two-temp clicked-once", "one" ], // 0· => 1
643 [ "clicked-once", "two-temp" ], // 0 => 0·
645 for (let [ interactionClasses, visualStateClass ] of transitions) {
646 if (button.hasClasses(interactionClasses.split(" "))) {
647 button.removeClasses([ "none", "one", "two-temp", "two" ]);
648 button.addClass(visualStateClass);
654 function voteCompleteEvent(targetType, targetId, response) {
655 GWLog("voteCompleteEvent");
657 var currentVote = voteData[targetType][targetId] || {};
658 var desiredVote = voteDesired[targetType][targetId];
660 var controls = findVoteControls(targetType, targetId);
661 var controlsByAxis = new Object;
663 controls.forEach(control => {
664 const voteAxis = (control.dataset.voteAxis || "karma");
666 if (!desiredVote || (currentVote[voteAxis] || "neutral") === (desiredVote[voteAxis] || "neutral")) {
667 control.removeClass("waiting");
668 control.querySelectorAll("button").forEach(button => button.removeClass("waiting"));
671 if(!controlsByAxis[voteAxis]) controlsByAxis[voteAxis] = new Array;
672 controlsByAxis[voteAxis].push(control);
674 const voteType = currentVote[voteAxis];
675 const vote = parseVoteType(voteType);
676 const voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
677 const voteClass = makeVoteClass(vote);
679 if (response && response[voteAxis]) {
680 const [voteType, displayText, titleText] = response[voteAxis];
682 const displayTarget = control.query(".karma-value");
683 if (displayTarget.hasClass("redacted")) {
684 displayTarget.dataset["trueValue"] = displayText;
686 displayTarget.innerHTML = displayText;
688 displayTarget.setAttribute("title", titleText);
691 control.queryAll("button.vote").forEach(button => {
692 updateVoteButton(button, voteUpDown, voteClass);
697 function updateVoteButton(button, voteUpDown, voteClass) {
698 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
699 if (button.dataset.voteType == voteUpDown)
700 button.addClass(voteClass);
701 updateVoteButtonVisualState(button);
704 function makeVoteRequestCompleteEvent(targetType, targetId) {
706 var currentVote = {};
709 if (event.target.status == 200) {
710 response = JSON.parse(event.target.responseText);
711 for (const voteAxis of response.keys()) {
712 currentVote[voteAxis] = response[voteAxis][0];
714 voteData[targetType][targetId] = currentVote;
716 delete voteDesired[targetType][targetId];
717 currentVote = voteData[targetType][targetId];
720 var desiredVote = voteDesired[targetType][targetId];
722 if (desiredVote && !votesEqual(currentVote, desiredVote)) {
723 sendVoteRequest(targetType, targetId);
725 delete voteDesired[targetType][targetId];
726 voteCompleteEvent(targetType, targetId, response);
731 function sendVoteRequest(targetType, targetId) {
732 GWLog("sendVoteRequest");
736 location: "/karma-vote",
737 params: { "target": targetId,
738 "target-type": targetType,
739 "vote": JSON.stringify(voteDesired[targetType][targetId]) },
740 onFinish: makeVoteRequestCompleteEvent(targetType, targetId)
744 function voteButtonClicked(event) {
745 GWLog("voteButtonClicked");
746 let voteButton = event.target;
748 // 500 ms (0.5 s) double-click timeout.
749 let doubleClickTimeout = 500;
751 if (!voteButton.clickedOnce) {
752 voteButton.clickedOnce = true;
753 voteButton.addClass("clicked-once");
754 changeVoteButtonVisualState(voteButton);
756 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
757 if (!voteButton.clickedOnce) return;
759 // Do single-click code.
760 voteButton.clickedOnce = false;
761 voteEvent(voteButton, 1);
762 }, doubleClickTimeout, voteButton);
764 voteButton.clickedOnce = false;
766 // Do double-click code.
767 voteButton.removeClass("clicked-once");
768 voteButton.addClass("clicked-twice");
769 voteEvent(voteButton, 2);
773 function voteEvent(voteButton, numClicks) {
777 let voteControl = voteButton.parentNode;
779 let targetType = voteButton.dataset.targetType;
780 let targetId = ((targetType == 'Comment') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
781 let voteAxis = voteControl.dataset.voteAxis || "karma";
782 let voteUpDown = voteButton.dataset.voteType;
785 if ( (numClicks == 2 && voteButton.hasClass("big-vote"))
786 || (numClicks == 1 && voteButton.hasClass("selected") && !voteButton.hasClass("big-vote"))) {
787 voteType = "neutral";
789 let vote = parseVoteType(voteUpDown);
790 vote.big = (numClicks == 2);
791 voteType = makeVoteType(vote);
794 let voteControls = findVoteControls(targetType, targetId, voteAxis);
795 for (const voteControl of voteControls) {
796 voteControl.addClass("waiting");
797 voteControl.queryAll(".vote").forEach(button => {
798 button.addClass("waiting");
799 updateVoteButton(button, voteUpDown, makeVoteClass(parseVoteType(voteType)));
803 let voteRequestPending = voteDesired[targetType][targetId];
804 let voteObject = Object.assign({}, voteRequestPending || voteData[targetType][targetId] || {});
805 voteObject[voteAxis] = voteType;
806 voteDesired[targetType][targetId] = voteObject;
808 if (!voteRequestPending) sendVoteRequest(targetType, targetId);
811 function initializeVoteButtons() {
812 // Color the upvote/downvote buttons with an embedded style sheet.
813 insertHeadHTML("<style id='vote-buttons'>" + `
815 --GW-upvote-button-color: #00d800;
816 --GW-downvote-button-color: #eb4c2a;
820 function processVoteData(voteData) {
821 window.voteData = voteData;
823 window.voteDesired = new Object;
824 for(key of voteData.keys()) {
825 voteDesired[key] = new Object;
828 initializeVoteButtons();
830 addTriggerListener("postLoaded", {priority: 3000, fn: () => {
831 queryAll(".post .post-meta .karma-value").forEach(karmaValue => {
832 let postID = karmaValue.parentNode.dataset.postId;
833 addVoteButtons(karmaValue, voteData.Post[postId], 'Post');
834 karmaValue.parentElement.addClass("active-controls");
838 addTriggerListener("DOMReady", {priority: 3000, fn: () => {
839 queryAll(".comment-meta .karma-value, .comment-controls .karma-value").forEach(karmaValue => {
840 let commentID = karmaValue.getCommentId();
841 addVoteButtons(karmaValue, voteData.Comment[commentID], 'Comment');
842 karmaValue.parentElement.addClass("active-controls");
847 /*****************************************/
848 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
849 /*****************************************/
851 Element.prototype.getCommentDate = function() {
852 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
853 let dateElement = item && item.query(".date");
854 return (dateElement && parseInt(dateElement.dataset["jsDate"]));
856 function getCurrentVisibleComment() {
857 let px = window.innerWidth/2, py = 5;
858 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
859 let bottomBar = query("#bottom-bar");
860 let bottomOffset = (bottomBar ? bottomBar.getBoundingClientRect().top : query("body").getBoundingClientRect().bottom);
861 let atbottom = bottomOffset <= window.innerHeight;
863 let hashci = location.hash && query(location.hash);
864 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
865 commentItem = hashci;
871 function highlightCommentsSince(date) {
872 GWLog("highlightCommentsSince");
873 var newCommentsCount = 0;
874 GW.newComments = [ ];
875 let oldCommentsStack = [ ];
877 queryAll(".comment-item").forEach(commentItem => {
878 commentItem.prevNewComment = prevNewComment;
879 commentItem.nextNewComment = null;
880 if (commentItem.getCommentDate() > date) {
881 commentItem.addClass("new-comment");
883 GW.newComments.push(commentItem.getCommentId());
884 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
885 oldCommentsStack = [ commentItem ];
886 prevNewComment = commentItem;
888 commentItem.removeClass("new-comment");
889 oldCommentsStack.push(commentItem);
893 GW.newCommentScrollSet = (commentItem) => {
894 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
895 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
897 GW.newCommentScrollListener = () => {
898 let commentItem = getCurrentVisibleComment();
899 GW.newCommentScrollSet(commentItem);
902 addScrollListener(GW.newCommentScrollListener);
904 if (document.readyState=="complete") {
905 GW.newCommentScrollListener();
907 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
908 GW.newCommentScrollSet(commentItem);
911 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
913 return newCommentsCount;
916 function scrollToNewComment(next) {
917 GWLog("scrollToNewComment");
918 let commentItem = getCurrentVisibleComment();
919 let targetComment = null;
920 let targetCommentID = null;
922 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
924 targetCommentID = targetComment.getCommentId();
927 if (GW.newComments[0]) {
928 targetCommentID = GW.newComments[0];
929 targetComment = query("#comment-" + targetCommentID);
933 expandAncestorsOf(targetCommentID);
934 history.replaceState(window.history.state, null, "#comment-" + targetCommentID);
935 targetComment.scrollIntoView();
938 GW.newCommentScrollListener();
941 function getPostHash() {
942 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
943 return (postHash ? postHash[1] : false);
945 function setHistoryLastVisitedDate(date) {
946 window.history.replaceState({ lastVisited: date }, null);
948 function getLastVisitedDate() {
949 // Get the last visited date (or, if posting a comment, the previous last visited date).
950 if(window.history.state) return (window.history.state||{})['lastVisited'];
951 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
952 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
953 let currentVisited = localStorage.getItem(storageName);
954 setHistoryLastVisitedDate(currentVisited);
955 return currentVisited;
957 function setLastVisitedDate(date) {
958 GWLog("setLastVisitedDate");
959 // If NOT posting a comment, save the previous value for the last-visited-date
960 // (to recover it in case of posting a comment).
961 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
962 if (!aCommentHasJustBeenPosted) {
963 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
964 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
967 // Set the new value.
968 localStorage.setItem("last-visited-date_" + getPostHash(), date);
971 function updateSavedCommentCount() {
972 GWLog("updateSavedCommentCount");
973 let commentCount = queryAll(".comment").length;
974 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
976 function badgePostsWithNewComments() {
977 GWLog("badgePostsWithNewComments");
978 if (getQueryVariable("show") == "conversations") return;
980 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
981 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
983 let savedCommentCount = parseInt(localStorage.getItem("comment-count_" + postHash), 10) || 0;
984 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
985 let currentCommentCount = parseInt(/([0-9]+)/.exec(commentCountDisplay.textContent)[1], 10) || 0;
987 if (currentCommentCount > savedCommentCount)
988 commentCountDisplay.addClass("new-comments");
990 commentCountDisplay.removeClass("new-comments");
991 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
1001 systemDarkModeActive: matchMedia("(prefers-color-scheme: dark)")
1005 /************************/
1006 /* ACTIVE MEDIA QUERIES */
1007 /************************/
1009 /* This function provides two slightly different versions of its functionality,
1010 depending on how many arguments it gets.
1012 If one function is given (in addition to the media query and its name), it
1013 is called whenever the media query changes (in either direction).
1015 If two functions are given (in addition to the media query and its name),
1016 then the first function is called whenever the media query starts matching,
1017 and the second function is called whenever the media query stops matching.
1019 If you want to call a function for a change in one direction only, pass an
1020 empty closure (NOT null!) as one of the function arguments.
1022 There is also an optional fifth argument. This should be a function to be
1023 called when the active media query is canceled.
1025 function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
1026 if (typeof GW.mediaQueryResponders == "undefined")
1027 GW.mediaQueryResponders = { };
1029 let mediaQueryResponder = (event, canceling = false) => {
1031 GWLog(`Canceling media query “${name}”`, "media queries", 1);
1033 if (whenCanceledDo != null)
1034 whenCanceledDo(mediaQuery);
1036 let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
1038 GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`, "media queries", 1);
1040 if ((otherwiseDo == null) || matches)
1041 ifMatchesOrAlwaysDo(mediaQuery);
1043 otherwiseDo(mediaQuery);
1046 mediaQueryResponder();
1047 mediaQuery.addListener(mediaQueryResponder);
1049 GW.mediaQueryResponders[name] = mediaQueryResponder;
1052 /* Deactivates and discards an active media query, after calling the function
1053 that was passed as the whenCanceledDo parameter when the media query was
1056 function cancelDoWhenMatchMedia(name) {
1057 GW.mediaQueryResponders[name](null, true);
1059 for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
1060 mediaQuery.removeListener(GW.mediaQueryResponders[name]);
1062 GW.mediaQueryResponders[name] = null;
1066 /******************************/
1067 /* DARK/LIGHT MODE ADJUSTMENT */
1068 /******************************/
1075 [ "auto", "", "Set light or dark mode automatically, according to system-wide setting (Win: Start → Personalization → Colors; Mac: Apple → System-Preferences → General → Appearance; iOS: Settings → Display-and-Brightness; Android: Settings → Display)" ],
1076 [ "light", "", "Light mode at all times (black-on-white)" ],
1077 [ "dark", "", "Dark mode at all times (inverted: white-on-black)" ]
1080 selectedModeOptionNote: " [This option is currently selected.]",
1082 /******************/
1087 modeSelectorInteractable: true,
1089 /******************/
1093 /* Returns current (saved) mode (light, dark, or auto).
1095 getSavedMode: () => {
1096 return (localStorage.getItem("dark-mode-setting") || "auto");
1099 /* Saves specified mode (light, dark, or auto).
1101 saveMode: (mode) => {
1102 GWLog("DarkMode.setMode");
1105 localStorage.removeItem("dark-mode-setting");
1107 localStorage.setItem("dark-mode-setting", mode);
1110 /* Set specified color mode (light, dark, or auto).
1112 setMode: (selectedMode = DarkMode.getSavedMode()) => {
1113 GWLog("DarkMode.setMode");
1115 // The style block should be inlined (and already loaded).
1116 let darkModeStyles = document.querySelector("#inlined-dark-mode-styles");
1117 if (darkModeStyles) {
1118 // Set `media` attribute of style block to match requested mode.
1119 if (selectedMode == "auto") {
1120 darkModeStyles.media = "all and (prefers-color-scheme: dark)";
1121 } else if (selectedMode == "dark") {
1122 darkModeStyles.media = "all";
1124 darkModeStyles.media = "not all";
1129 DarkMode.updateModeSelectorState(DarkMode.modeSelector);
1132 modeSelectorHTML: (inline = false) => {
1133 let selectorTagName = (inline ? "span" : "div");
1134 let selectorId = (inline ? `` : ` id="dark-mode-selector"`);
1135 let selectorClass = (` class="dark-mode-selector mode-selector` + (inline ? ` mode-selector-inline` : ``) + `"`);
1137 // Get saved mode setting (or default).
1138 let currentMode = DarkMode.getSavedMode();
1140 return `<${selectorTagName}${selectorId}${selectorClass}>`
1141 + DarkMode.modeOptions.map(modeOption => {
1142 let [ name, label, desc ] = modeOption;
1143 let selected = (name == currentMode ? " selected" : "");
1144 let disabled = (name == currentMode ? " disabled" : "");
1145 let active = (( currentMode == "auto"
1146 && name == (GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light"))
1149 if (name == currentMode)
1150 desc += DarkMode.selectedModeOptionNote;
1153 class="select-mode-${name}${selected}${active}"
1158 >${label}</button>`;
1160 + `</${selectorTagName}>`;
1163 injectModeSelector: (replacedElement = null) => {
1164 GWLog("DarkMode.injectModeSelector", "dark-mode.js", 1);
1166 // Inject the mode selector widget.
1168 if (replacedElement) {
1169 replacedElement.innerHTML = DarkMode.modeSelectorHTML(true);
1170 modeSelector = replacedElement.firstElementChild;
1171 unwrap(replacedElement);
1173 modeSelector = DarkMode.modeSelector = addUIElement(DarkMode.modeSelectorHTML());
1176 // Add event listeners and update state.
1177 requestAnimationFrame(() => {
1178 // Activate mode selector widget buttons.
1179 modeSelector.querySelectorAll("button").forEach(button => {
1180 button.addActivateEvent(DarkMode.modeSelectButtonClicked);
1184 /* Add active media query to update mode selector state when system dark
1185 mode setting changes. (This is relevant only for the ‘auto’ setting.)
1187 doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "DarkMode.updateModeSelectorStateForSystemDarkMode", () => {
1188 DarkMode.updateModeSelectorState(modeSelector);
1192 modeSelectButtonClicked: (event) => {
1193 GWLog("DarkMode.modeSelectButtonClicked");
1195 /* We don’t want clicks to go through if the transition
1196 between modes has not completed yet, so we disable the
1197 button temporarily while we’re transitioning between
1201 // Determine which setting was chosen (ie. which button was clicked).
1202 let selectedMode = event.target.dataset.name;
1204 // Save the new setting.
1205 DarkMode.saveMode(selectedMode);
1207 // Actually change the mode.
1208 DarkMode.setMode(selectedMode);
1209 }, DarkMode, "modeSelectorInteractable");
1211 event.target.blur();
1214 updateModeSelectorState: (modeSelector = DarkMode.modeSelector) => {
1215 GWLog("DarkMode.updateModeSelectorState");
1217 /* If the mode selector has not yet been injected, then do nothing.
1219 if (modeSelector == null)
1222 // Get saved mode setting (or default).
1223 let currentMode = DarkMode.getSavedMode();
1225 // Clear current buttons state.
1226 modeSelector.querySelectorAll("button").forEach(button => {
1227 button.classList.remove("active", "selected");
1228 button.disabled = false;
1229 if (button.title.endsWith(DarkMode.selectedModeOptionNote))
1230 button.title = button.title.slice(0, (-1 * DarkMode.selectedModeOptionNote.length));
1233 // Set the correct button to be selected.
1234 modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
1235 button.classList.add("selected");
1236 button.disabled = true;
1237 button.title += DarkMode.selectedModeOptionNote;
1240 /* Ensure the right button (light or dark) has the “currently active”
1241 indicator, if the current mode is ‘auto’.
1243 if (currentMode == "auto")
1244 modeSelector.querySelector(`.select-mode-${(GW.mediaQueries.systemDarkModeActive.matches ? "dark" : "light")}`).classList.add("active");
1249 /****************************/
1250 /* APPEARANCE CUSTOMIZATION */
1251 /****************************/
1253 Appearance = { ...Appearance,
1254 /******************/
1260 themeSelector: null,
1262 themeTweakerToggle: null,
1264 themeTweakerUI: null,
1265 themeTweakerUIMainWindow: null,
1266 themeTweakerUIHelpWindow: null,
1267 themeTweakerUISampleTextContainer: null,
1268 themeTweakerUIClippyContainer: null,
1269 themeTweakerUIClippyControl: null,
1271 widthSelector: null,
1273 textSizeAdjustmentWidget: null,
1275 appearanceAdjustUIToggle: null,
1281 makeNewStyle: (newThemeName, colorSchemePreference) => {
1282 let styleSheetNameSuffix = (newThemeName == Appearance.defaultTheme) ? "" : ("-" + newThemeName);
1283 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
1285 let newStyle = document.createElement("link");
1286 newStyle.setAttribute("class", "theme");
1287 if (colorSchemePreference)
1288 newStyle.setAttribute("media", "(prefers-color-scheme: " + colorSchemePreference + ")");
1289 newStyle.setAttribute("rel", "stylesheet");
1290 newStyle.setAttribute("href", "/css/style" + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
1294 setTheme: (newThemeName, save = true) => {
1295 GWLog("Appearance.setTheme");
1297 let themeUnloadCallback = "";
1298 let oldThemeName = "";
1299 if (typeof(newThemeName) == "undefined") {
1300 newThemeName = Appearance.currentTheme;
1301 if (newThemeName == Appearance.defaultTheme)
1304 oldThemeName = Appearance.currentTheme;
1305 themeUnloadCallback = Appearance.themeUnloadCallbacks[oldThemeName];
1307 Appearance.currentTheme = newThemeName;
1309 Appearance.saveCurrentTheme();
1311 if (themeUnloadCallback != null)
1312 themeUnloadCallback(newThemeName);
1314 let newMainStyle, newStyles;
1315 if (newThemeName === Appearance.defaultTheme) {
1316 newStyles = [ Appearance.makeNewStyle("dark", "dark"), Appearance.makeNewStyle(Appearance.defaultTheme, "light") ];
1317 newMainStyle = (window.matchMedia("prefers-color-scheme: dark").matches ? newStyles[0] : newStyles[1]);
1319 newStyles = [ Appearance.makeNewStyle(newThemeName) ];
1320 newMainStyle = newStyles[0];
1323 let oldStyles = queryAll("head link.theme");
1324 newMainStyle.addEventListener("load", (event) => { oldStyles.forEach(x => removeElement(x)); });
1325 newMainStyle.addEventListener("load", (event) => { Appearance.postSetThemeHousekeeping(oldThemeName, newThemeName); });
1327 if (Appearance.adjustmentTransitions) {
1328 pageFadeTransition(false);
1330 newStyles.forEach(newStyle => document.head.insertBefore(newStyle, oldStyles[0].nextSibling));
1333 newStyles.forEach(newStyle => document.head.insertBefore(newStyle, oldStyles[0].nextSibling));
1336 Appearance.updateThemeSelectorsState();
1339 postSetThemeHousekeeping: (oldThemeName = "", newThemeName = null) => {
1340 GWLog("Appearance.postSetThemeHousekeeping");
1342 if (newThemeName == null)
1343 newThemeName = Appearance.getSavedTheme();
1345 document.body.className = document.body.className.replace(new RegExp("(^|\\s+)theme-\\w+(\\s+|$)"), "$1").trim();
1346 document.body.addClass("theme-" + newThemeName);
1348 recomputeUIElementsContainerHeight(true);
1350 let themeLoadCallback = Appearance.themeLoadCallbacks[newThemeName];
1351 if (themeLoadCallback != null)
1352 themeLoadCallback(oldThemeName);
1354 recomputeUIElementsContainerHeight();
1355 adjustUIForWindowSize();
1356 window.addEventListener("resize", GW.windowResized = (event) => {
1357 GWLog("GW.windowResized");
1358 adjustUIForWindowSize();
1359 recomputeUIElementsContainerHeight();
1362 generateImagesOverlay();
1364 if (Appearance.adjustmentTransitions)
1365 pageFadeTransition(true);
1366 Appearance.updateThemeTweakerSampleText();
1368 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== "undefined") {
1369 window.matchMedia("(orientation: portrait)").addListener(generateImagesOverlay);
1373 themeLoadCallbacks: {
1374 brutalist: (fromTheme = "") => {
1375 GWLog("Appearance.themeLoadCallbacks.brutalist");
1377 let bottomBarLinks = queryAll("#bottom-bar a");
1378 if (!GW.isMobile && bottomBarLinks.length == 5) {
1379 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1380 bottomBarLinks.forEach((link, i) => {
1381 link.dataset.originalText = link.textContent;
1382 link.textContent = newLinkTexts[i];
1387 classic: (fromTheme = "") => {
1388 GWLog("Appearance.themeLoadCallbacks.classic");
1390 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1391 button.innerHTML = "";
1395 dark: (fromTheme = "") => {
1396 GWLog("Appearance.themeLoadCallbacks.dark");
1398 insertHeadHTML(`<style id="dark-theme-adjustments">`
1399 + `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }`
1400 + `#bottom-bar.decorative::before { filter: invert(100%); }`
1402 registerInitializer("makeImagesGlow", true, () => query("#images-overlay") != null, () => {
1403 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
1404 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)";
1405 image.style.width = parseInt(image.style.width) + 12 + "px";
1406 image.style.height = parseInt(image.style.height) + 12 + "px";
1407 image.style.top = parseInt(image.style.top) - 6 + "px";
1408 image.style.left = parseInt(image.style.left) - 6 + "px";
1413 less: (fromTheme = "") => {
1414 GWLog("Appearance.themeLoadCallbacks.less");
1416 injectSiteNavUIToggle();
1418 injectPostNavUIToggle();
1419 Appearance.injectAppearanceAdjustUIToggle();
1422 registerInitializer("shortenDate", true, () => query(".top-post-meta") != null, function () {
1423 let dtf = new Intl.DateTimeFormat([],
1424 (window.innerWidth < 1100) ?
1425 { month: "short", day: "numeric", year: "numeric" } :
1426 { month: "long", day: "numeric", year: "numeric" });
1427 let postDate = query(".top-post-meta .date");
1428 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1432 query("#content").insertAdjacentHTML("beforeend", `<div id="theme-less-mobile-first-row-placeholder"></div>`);
1436 registerInitializer("addSpans", true, () => query(".top-post-meta") != null, function () {
1437 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1438 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1442 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1443 // If state is not set (user has never clicked on the Less theme’s appearance
1444 // adjustment UI toggle) then show it, but then hide it after a short time.
1445 registerInitializer("engageAppearanceAdjustUI", true, () => query("#ui-elements-container") != null, function () {
1446 Appearance.toggleAppearanceAdjustUI();
1447 setTimeout(Appearance.toggleAppearanceAdjustUI, 3000);
1451 if (fromTheme != "") {
1452 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1453 setTimeout(function () {
1454 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1456 setTimeout(function () {
1457 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1461 // Unset the height of the #ui-elements-container.
1462 query("#ui-elements-container").style.height = "";
1464 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1465 Appearance.filtersExclusionPaths.themeLess = [
1466 "#content #secondary-bar",
1467 "#content .post .top-post-meta .date",
1468 "#content .post .top-post-meta .comment-count",
1470 Appearance.applyFilters();
1473 // We pre-query the relevant elements, so we don’t have to run querySelectorAll
1474 // on every firing of the scroll listener.
1476 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1477 "unbrokenDownScrollDistance": 0,
1478 "unbrokenUpScrollDistance": 0,
1479 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1480 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1481 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1483 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1487 themeUnloadCallbacks: {
1488 brutalist: (toTheme = "") => {
1489 GWLog("Appearance.themeUnloadCallbacks.brutalist");
1491 let bottomBarLinks = queryAll("#bottom-bar a");
1492 if (!GW.isMobile && bottomBarLinks.length == 5) {
1493 bottomBarLinks.forEach(link => {
1494 link.textContent = link.dataset.originalText;
1499 classic: (toTheme = "") => {
1500 GWLog("Appearance.themeUnloadCallbacks.classic");
1502 if (GW.isMobile && window.innerWidth <= 900)
1505 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1506 button.innerHTML = button.dataset.label;
1510 dark: (toTheme = "") => {
1511 GWLog("Appearance.themeUnloadCallbacks.dark");
1513 removeElement("#dark-theme-adjustments");
1516 less: (toTheme = "") => {
1517 GWLog("Appearance.themeUnloadCallbacks.less");
1519 removeSiteNavUIToggle();
1521 removePostNavUIToggle();
1522 Appearance.removeAppearanceAdjustUIToggle();
1525 window.removeEventListener("resize", updatePostNavUIVisibility);
1527 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1529 removeElement("#theme-less-mobile-first-row-placeholder");
1533 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1534 element.innerHTML = element.firstChild.innerHTML;
1538 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1540 // Reset filtered elements selector to default.
1541 delete Appearance.filtersExclusionPaths.themeLess;
1542 Appearance.applyFilters();
1546 pageFadeTransition: (fadeIn) => {
1548 document.body.removeClass("transparent");
1550 document.body.addClass("transparent");
1554 saveCurrentTheme: () => {
1555 GWLog("Appearance.saveCurrentTheme");
1557 if (Appearance.currentTheme == Appearance.defaultTheme)
1558 setCookie("theme", "");
1560 setCookie("theme", Appearance.currentTheme);
1563 themeTweakReset: () => {
1564 GWLog("Appearance.themeTweakReset");
1566 Appearance.setTheme(Appearance.getSavedTheme());
1567 Appearance.applyFilters(Appearance.getSavedFilters());
1568 Appearance.setTextZoom(Appearance.getSavedTextZoom());
1571 themeTweakSave: () => {
1572 GWLog("Appearance.themeTweakSave");
1574 Appearance.saveCurrentTheme();
1575 Appearance.saveCurrentFilters();
1576 Appearance.saveCurrentTextZoom();
1579 themeTweakResetDefaults: () => {
1580 GWLog("Appearance.themeTweakResetDefaults");
1582 Appearance.setTheme(Appearance.defaultTheme);
1583 Appearance.applyFilters(Appearance.defaultFilters);
1584 Appearance.setTextZoom(Appearance.defaultTextZoom);
1587 themeTweakerResetSettings: () => {
1588 GWLog("Appearance.themeTweakerResetSettings");
1590 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
1591 Appearance.themeTweakerUIClippyContainer.style.display = Appearance.themeTweakerUIClippyControl.checked
1596 themeTweakerSaveSettings: () => {
1597 GWLog("Appearance.themeTweakerSaveSettings");
1599 Appearance.saveThemeTweakerClippyState();
1602 getSavedThemeTweakerClippyState: () => {
1603 return (JSON.parse(localStorage.getItem("theme-tweaker-settings") || `{ "showClippy": ${Appearance.defaultThemeTweakerClippyState} }` )["showClippy"]);
1606 saveThemeTweakerClippyState: () => {
1607 GWLog("Appearance.saveThemeTweakerClippyState");
1609 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ "showClippy": Appearance.themeTweakerUIClippyControl.checked }));
1612 getSavedAppearanceAdjustUIToggleState: () => {
1613 return ((localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") || Appearance.defaultAppearanceAdjustUIToggleState);
1616 saveAppearanceAdjustUIToggleState: () => {
1617 GWLog("Appearance.saveAppearanceAdjustUIToggleState");
1619 localStorage.setItem("appearance-adjust-ui-toggle-engaged", Appearance.appearanceAdjustUIToggle.query("button").hasClass("engaged"));
1626 contentWidthSelectorHTML: () => {
1627 return ("<div id='width-selector'>"
1628 + String.prototype.concat.apply("", Appearance.widthOptions.map(widthOption => {
1629 let [name, desc, abbr] = widthOption;
1630 let selected = (name == Appearance.currentWidth ? " selected" : "");
1631 let disabled = (name == Appearance.currentWidth ? " disabled" : "");
1632 return `<button type="button" class="select-width-${name}${selected}"${disabled} title="${desc}" tabindex="-1" data-name="${name}">${abbr}</button>`
1637 injectContentWidthSelector: () => {
1638 GWLog("Appearance.injectContentWidthSelector");
1640 // Inject the content width selector widget and activate buttons.
1641 Appearance.widthSelector = addUIElement(Appearance.contentWidthSelectorHTML());
1642 Appearance.widthSelector.queryAll("button").forEach(button => {
1643 button.addActivateEvent(Appearance.widthAdjustButtonClicked);
1646 // Make sure the accesskey (to cycle to the next width) is on the right button.
1647 Appearance.setWidthAdjustButtonsAccesskey();
1649 // Inject transitions CSS, if animating changes is enabled.
1650 if (Appearance.adjustmentTransitions) {
1652 "<style id='width-transition'>" +
1654 #ui-elements-container,
1657 max-width 0.3s ease;
1663 setWidthAdjustButtonsAccesskey: () => {
1664 GWLog("Appearance.setWidthAdjustButtonsAccesskey");
1666 Appearance.widthSelector.queryAll("button").forEach(button => {
1667 button.removeAttribute("accesskey");
1668 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
1670 let selectedButton = Appearance.widthSelector.query("button.selected");
1671 let nextButtonInCycle = selectedButton == selectedButton.parentElement.lastChild
1672 ? selectedButton.parentElement.firstChild
1673 : selectedButton.nextSibling;
1674 nextButtonInCycle.accessKey = "'";
1675 nextButtonInCycle.title += ` [\']`;
1678 injectTextSizeAdjustmentUI: () => {
1679 GWLog("Appearance.injectTextSizeAdjustmentUI");
1681 if (Appearance.textSizeAdjustmentWidget != null)
1684 let inject = () => {
1685 GWLog("Appearance.injectTextSizeAdjustmentUI [INJECTING]");
1687 Appearance.textSizeAdjustmentWidget = addUIElement("<div id='text-size-adjustment-ui'>"
1688 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1689 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1690 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1693 Appearance.textSizeAdjustmentWidget.queryAll("button").forEach(button => {
1694 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1698 if (query("#content.post-page") != null) {
1701 document.addEventListener("DOMContentLoaded", () => {
1702 if (!( query(".post-body") == null
1703 && query(".comment-body") == null))
1709 themeSelectorHTML: () => {
1710 return ("<div id='theme-selector' class='theme-selector'>"
1711 + String.prototype.concat.apply("", Appearance.themeOptions.map(themeOption => {
1712 let [name, desc, letter] = themeOption;
1713 let selected = (name == Appearance.currentTheme ? ' selected' : '');
1714 let disabled = (name == Appearance.currentTheme ? ' disabled' : '');
1715 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
1716 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>`;
1721 injectThemeSelector: () => {
1722 GWLog("Appearance.injectThemeSelector");
1724 Appearance.themeSelector = addUIElement(Appearance.themeSelectorHTML());
1725 Appearance.themeSelector.queryAll("button").forEach(button => {
1726 button.addActivateEvent(Appearance.themeSelectButtonClicked);
1729 // Inject transitions CSS, if animating changes is enabled.
1730 if (Appearance.adjustmentTransitions) {
1731 insertHeadHTML("<style id='theme-fade-transition'>" +
1734 opacity 0.5s ease-out,
1735 background-color 0.3s ease-out;
1738 background-color: #777;
1741 opacity 0.5s ease-in,
1742 background-color 0.3s ease-in;
1748 updateThemeSelectorsState: () => {
1749 GWLog("Appearance.updateThemeSelectorsState");
1751 queryAll(".theme-selector button").forEach(button => {
1752 button.removeClass("selected");
1753 button.disabled = false;
1755 queryAll(".theme-selector button.select-theme-" + Appearance.currentTheme).forEach(button => {
1756 button.addClass("selected");
1757 button.disabled = true;
1760 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.currentTheme;
1763 themeTweakerUIHTML: () => {
1764 return (`<div id="theme-tweaker-ui" style="display: none;">`
1765 + `<div class="main-theme-tweaker-window">
1766 <h1>Customize appearance</h1>
1767 <button type="button" class="minimize-button minimize" tabindex="-1"></button>
1768 <button type="button" class="help-button" tabindex="-1"></button>
1769 <p class="current-theme">Current theme: <span>` +
1770 Appearance.getSavedTheme() +
1772 <p class="theme-selector"></p>
1773 <div class="controls-container">
1774 <div id="theme-tweak-section-sample-text" class="section" data-label="Sample text">
1775 <div class="sample-text-container"><span class="sample-text">
1776 <p>Less Wrong (text)</p>
1777 <p><a href="#">Less Wrong (link)</a></p>
1780 <div id="theme-tweak-section-text-size-adjust" class="section" data-label="Text size">
1781 <button type="button" class="text-size-adjust-button decrease" title="Decrease text size"></button>
1782 <button type="button" class="text-size-adjust-button default" title="Reset to default text size"></button>
1783 <button type="button" class="text-size-adjust-button increase" title="Increase text size"></button>
1785 <div id="theme-tweak-section-invert" class="section" data-label="Invert (photo-negative)">
1786 <input type="checkbox" id="theme-tweak-control-invert"></input>
1787 <label for="theme-tweak-control-invert">Invert colors</label>
1789 <div id="theme-tweak-section-saturate" class="section" data-label="Saturation">
1790 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1791 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1792 <div class="notch theme-tweak-slider-notch-saturate" title="Reset saturation to default value (100%)"></div>
1794 <div id="theme-tweak-section-brightness" class="section" data-label="Brightness">
1795 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1796 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1797 <div class="notch theme-tweak-slider-notch-brightness" title="Reset brightness to default value (100%)"></div>
1799 <div id="theme-tweak-section-contrast" class="section" data-label="Contrast">
1800 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1801 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1802 <div class="notch theme-tweak-slider-notch-contrast" title="Reset contrast to default value (100%)"></div>
1804 <div id="theme-tweak-section-hue-rotate" class="section" data-label="Hue rotation">
1805 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1806 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1807 <div class="notch theme-tweak-slider-notch-hue-rotate" title="Reset hue to default (0° away from standard colors for theme)"></div>
1810 <div class="buttons-container">
1811 <button type="button" class="reset-defaults-button">Reset to defaults</button>
1812 <button type="button" class="ok-button default-button">OK</button>
1813 <button type="button" class="cancel-button">Cancel</button>
1816 <div class="clippy-container">
1817 <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>)
1818 <div class="clippy"></div>
1819 <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>
1821 <div class="help-window" style="display: none;">
1822 <h1>Theme tweaker help</h1>
1823 <div id="theme-tweak-section-clippy" class="section" data-label="Theme Tweaker Assistant">
1824 <input type="checkbox" id="theme-tweak-control-clippy" checked="checked"></input>
1825 <label for="theme-tweak-control-clippy">Show Bobby the Basilisk</label>
1827 <div class="buttons-container">
1828 <button type="button" class="ok-button default-button">OK</button>
1829 <button type="button" class="cancel-button">Cancel</button>
1835 injectThemeTweaker: () => {
1836 GWLog("Appearance.injectThemeTweaker");
1838 Appearance.themeTweakerUI = addUIElement(Appearance.themeTweakerUIHTML());
1839 Appearance.themeTweakerUIMainWindow = Appearance.themeTweakerUI.firstElementChild;
1840 Appearance.themeTweakerUIHelpWindow = Appearance.themeTweakerUI.query(".help-window");
1841 Appearance.themeTweakerUISampleTextContainer = Appearance.themeTweakerUI.query("#theme-tweak-section-sample-text .sample-text-container");
1842 Appearance.themeTweakerUIClippyContainer = Appearance.themeTweakerUI.query(".clippy-container");
1843 Appearance.themeTweakerUIClippyControl = Appearance.themeTweakerUI.query("#theme-tweak-control-clippy");
1845 // Clicking the background overlay closes the theme tweaker.
1846 Appearance.themeTweakerUI.addActivateEvent(Appearance.themeTweakerUIOverlayClicked, true);
1848 // Intercept clicks, so they don’t “fall through” the background overlay.
1849 Appearance.themeTweakerUIMainWindow.addActivateEvent((event) => {
1850 event.stopPropagation();
1853 Appearance.themeTweakerUI.queryAll("input").forEach(field => {
1854 /* All input types in the theme tweaker receive a ‘change’ event
1855 when their value is changed. (Range inputs, in particular,
1856 receive this event when the user lets go of the handle.) This
1857 means we should update the filters for the entire page, to match
1860 field.addEventListener("change", Appearance.themeTweakerUIFieldValueChanged);
1862 /* Range inputs receive an ‘input’ event while being scrubbed,
1863 updating “live” as the handle is moved. We don’t want to change
1864 the filters for the actual page while this is happening, but we
1865 do want to change the filters for the *sample text*, so the user
1866 can see what effects his changes are having, live, without
1867 having to let go of the handle.
1869 if (field.type == "range")
1870 field.addEventListener("input", Appearance.themeTweakerUIFieldInputReceived);
1873 Appearance.themeTweakerUI.query(".minimize-button").addActivateEvent(Appearance.themeTweakerUIMinimizeButtonClicked);
1874 Appearance.themeTweakerUI.query(".help-button").addActivateEvent(Appearance.themeTweakerUIHelpButtonClicked);
1875 Appearance.themeTweakerUI.query(".reset-defaults-button").addActivateEvent(Appearance.themeTweakerUIResetDefaultsButtonClicked);
1876 Appearance.themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(Appearance.themeTweakerUICancelButtonClicked);
1877 Appearance.themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(Appearance.themeTweakerUIOKButtonClicked);
1878 Appearance.themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowCancelButtonClicked);
1879 Appearance.themeTweakerUI.query(".help-window .ok-button").addActivateEvent(Appearance.themeTweakerUIHelpWindowOKButtonClicked);
1881 Appearance.themeTweakerUI.queryAll(".notch").forEach(notch => {
1882 notch.addActivateEvent(Appearance.themeTweakerUISliderNotchClicked);
1885 Appearance.themeTweakerUI.query(".clippy-close-button").addActivateEvent(Appearance.themeTweakerUIClippyCloseButtonClicked);
1887 insertHeadHTML(`<style id="theme-tweaker-style"></style>`);
1889 Appearance.themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1890 Appearance.themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1891 button.addActivateEvent(Appearance.themeSelectButtonClicked);
1894 Appearance.themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1895 button.addActivateEvent(Appearance.textSizeAdjustButtonClicked);
1898 Appearance.themeTweakerToggle = addUIElement(`<div id="theme-tweaker-toggle">`
1902 title="Customize appearance [;]"
1906 Appearance.themeTweakerToggle.query("button").addActivateEvent(Appearance.themeTweakerToggleClicked);
1909 showThemeTweakerUI: () => {
1910 GWLog("Appearance.showThemeTweakerUI");
1912 Appearance.themeTweakerUI.query(".current-theme span").innerText = Appearance.getSavedTheme();
1914 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = (Appearance.currentFilters["invert"] == "100%");
1915 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1916 let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1917 slider.value = /^[0-9]+/.exec(Appearance.currentFilters[sliderName]) || slider.dataset["defaultValue"];
1918 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
1921 Appearance.toggleThemeTweakerUI();
1922 event.target.disabled = true;
1925 toggleThemeTweakerUI: () => {
1926 GWLog("Appearance.toggleThemeTweakerUI");
1928 Appearance.themeTweakerUI.style.display = Appearance.themeTweakerUI.style.display == "none"
1931 query("#theme-tweaker-style").innerHTML = Appearance.themeTweakerUI.style.display == "none"
1933 : `#content, #ui-elements-container > div:not(#theme-tweaker-ui) { pointer-events: none; }`;
1935 if (Appearance.themeTweakerUI.style.display != "none") {
1936 // Focus invert checkbox.
1937 Appearance.themeTweakerUI.query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1938 // Show sample text in appropriate font.
1939 Appearance.updateThemeTweakerSampleText();
1940 // Disable tab-selection of the search box.
1941 setSearchBoxTabSelectable(false);
1942 // Disable scrolling of the page.
1943 togglePageScrolling(false);
1945 query("#theme-tweaker-toggle button").disabled = false;
1946 // Re-enable tab-selection of the search box.
1947 setSearchBoxTabSelectable(true);
1948 // Re-enable scrolling of the page.
1949 togglePageScrolling(true);
1952 // Set theme tweaker assistant visibility.
1953 Appearance.themeTweakerUIClippyContainer.style.display = (Appearance.getSavedThemeTweakerClippyState() == true) ? "block" : "none";
1956 toggleThemeTweakerHelpWindow: () => {
1957 GWLog("Appearance.toggleThemeTweakerHelpWindow");
1959 Appearance.themeTweakerUIHelpWindow.style.display = Appearance.themeTweakerUIHelpWindow.style.display == "none"
1962 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
1963 // Focus theme tweaker assistant checkbox.
1964 Appearance.themeTweakerUI.query("#theme-tweak-control-clippy").focus();
1965 // Disable interaction on main theme tweaker window.
1966 Appearance.themeTweakerUI.style.pointerEvents = "none";
1967 Appearance.themeTweakerUIMainWindow.style.pointerEvents = "none";
1969 // Re-enable interaction on main theme tweaker window.
1970 Appearance.themeTweakerUI.style.pointerEvents = "auto";
1971 Appearance.themeTweakerUIMainWindow.style.pointerEvents = "auto";
1975 resetThemeTweakerUIDefaultState: () => {
1976 GWLog("Appearance.resetThemeTweakerUIDefaultState");
1978 Appearance.themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1980 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1981 let slider = Appearance.themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1982 slider.value = slider.dataset["defaultValue"];
1983 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset["labelSuffix"];
1987 updateThemeTweakerSampleText: () => {
1988 GWLog("Appearance.updateThemeTweakerSampleText");
1990 let sampleText = Appearance.themeTweakerUISampleTextContainer.query("#theme-tweak-section-sample-text .sample-text");
1992 // This causes the sample text to take on the properties of the body text of a post.
1993 sampleText.removeClass("body-text");
1994 let bodyTextElement = query(".post-body") || query(".comment-body");
1995 sampleText.addClass("body-text");
1996 sampleText.style.color = bodyTextElement ?
1997 getComputedStyle(bodyTextElement).color :
1998 getComputedStyle(query("#content")).color;
2000 // Here we find out what is the actual background color that will be visible behind
2001 // the body text of posts, and set the sample text’s background to that.
2002 let findStyleBackground = (selector) => {
2004 Array.from(query("link[rel=stylesheet]").sheet.cssRules).forEach(rule => {
2005 if (rule.selectorText == selector)
2008 return x.style.backgroundColor;
2011 sampleText.parentElement.style.backgroundColor = findStyleBackground("#content::before") || findStyleBackground("body") || "#fff";
2014 injectAppearanceAdjustUIToggle: () => {
2015 GWLog("Appearance.injectAppearanceAdjustUIToggle");
2017 Appearance.appearanceAdjustUIToggle = addUIElement(`<div id="appearance-adjust-ui-toggle"><button type="button" tabindex="-1"></button></div>`);
2018 Appearance.appearanceAdjustUIToggle.query("button").addActivateEvent(Appearance.appearanceAdjustUIToggleClicked);
2021 let themeSelectorCloseButton = Appearance.appearanceAdjustUIToggle.query("button").cloneNode(true);
2022 themeSelectorCloseButton.addClass("theme-selector-close-button");
2023 themeSelectorCloseButton.innerHTML = "";
2024 themeSelectorCloseButton.addActivateEvent(Appearance.appearanceAdjustUIToggleButtonClicked);
2025 Appearance.themeSelector.appendChild(themeSelectorCloseButton);
2027 if (Appearance.getSavedAppearanceAdjustUIToggleState() == true)
2028 Appearance.toggleAppearanceAdjustUI();
2032 removeAppearanceAdjustUIToggle: () => {
2033 GWLog("Appearance.removeAppearanceAdjustUIToggle");
2035 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2036 element.removeClass("engaged");
2038 removeElement("#appearance-adjust-ui-toggle");
2041 toggleAppearanceAdjustUI: () => {
2042 GWLog("Appearance.toggleAppearanceAdjustUI");
2044 queryAll(Appearance.themeLessAppearanceAdjustUIElementsSelector).forEach(element => {
2045 element.toggleClass("engaged");
2053 appearanceAdjustUIToggleButtonClicked: (event) => {
2054 GWLog("Appearance.appearanceAdjustUIToggleButtonClicked");
2056 Appearance.toggleAppearanceAdjustUI();
2057 Appearance.saveAppearanceAdjustUIToggleState();
2060 widthAdjustButtonClicked: (event) => {
2061 GWLog("Appearance.widthAdjustButtonClicked");
2063 // Determine which setting was chosen (i.e., which button was clicked).
2064 let selectedWidth = event.target.dataset.name;
2067 Appearance.currentWidth = selectedWidth;
2069 // Save the new setting.
2070 Appearance.saveCurrentWidth();
2072 // Save current visible comment
2073 let visibleComment = getCurrentVisibleComment();
2075 // Actually change the content width.
2076 Appearance.setContentWidth(selectedWidth);
2077 event.target.parentElement.childNodes.forEach(button => {
2078 button.removeClass("selected");
2079 button.disabled = false;
2081 event.target.addClass("selected");
2082 event.target.disabled = true;
2084 // Make sure the accesskey (to cycle to the next width) is on the right button.
2085 Appearance.setWidthAdjustButtonsAccesskey();
2087 // Regenerate images overlay.
2088 generateImagesOverlay();
2091 visibleComment.scrollIntoView();
2094 themeSelectButtonClicked: (event) => {
2095 GWLog("Appearance.themeSelectButtonClicked");
2097 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
2098 let save = (Appearance.themeTweakerUI.contains(event.target) == false);
2099 Appearance.setTheme(themeName, save);
2101 Appearance.toggleAppearanceAdjustUI();
2104 textSizeAdjustButtonClicked: (event) => {
2105 GWLog("Appearance.textSizeAdjustButtonClicked");
2107 var zoomFactor = Appearance.currentTextZoom;
2108 if (event.target.hasClass("decrease")) {
2110 } else if (event.target.hasClass("increase")) {
2113 zoomFactor = Appearance.defaultTextZoom;
2116 let save = ( Appearance.textSizeAdjustmentWidget != null
2117 && Appearance.textSizeAdjustmentWidget.contains(event.target));
2118 Appearance.setTextZoom(zoomFactor, save);
2121 themeTweakerToggleClicked: (event) => {
2122 GWLog("Appearance.themeTweakerToggleClicked");
2124 if (query("link[href^='/css/theme_tweaker.css']")) {
2125 // Theme tweaker CSS is already loaded.
2126 Appearance.showThemeTweakerUI();
2128 // Load the theme tweaker CSS (if not loaded).
2129 let themeTweakerStyleSheet = document.createElement("link");
2130 themeTweakerStyleSheet.setAttribute("rel", "stylesheet");
2131 themeTweakerStyleSheet.setAttribute("href", "/css/theme_tweaker.css");
2132 themeTweakerStyleSheet.addEventListener("load", (event) => {
2133 requestAnimationFrame(() => {
2134 themeTweakerStyleSheet.disabled = false;
2136 Appearance.showThemeTweakerUI();
2138 document.head.appendChild(themeTweakerStyleSheet);
2142 themeTweakerUIKeyPressed: (event) => {
2143 GWLog("Appearance.themeTweakerUIKeyPressed");
2145 if (event.key == "Escape") {
2146 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2147 Appearance.toggleThemeTweakerHelpWindow();
2148 Appearance.themeTweakerResetSettings();
2149 } else if (Appearance.themeTweakerUI.style.display != "none") {
2150 Appearance.toggleThemeTweakerUI();
2151 Appearance.themeTweakReset();
2153 } else if (event.key == "Enter") {
2154 if (Appearance.themeTweakerUIHelpWindow.style.display != "none") {
2155 Appearance.toggleThemeTweakerHelpWindow();
2156 Appearance.themeTweakerSaveSettings();
2157 } else if (Appearance.themeTweakerUI.style.display != "none") {
2158 Appearance.toggleThemeTweakerUI();
2159 Appearance.themeTweakSave();
2164 themeTweakerUIOverlayClicked: (event) => {
2165 GWLog("Appearance.themeTweakerUIOverlayClicked");
2167 if (event.type == "mousedown") {
2168 Appearance.themeTweakerUI.style.opacity = "0.01";
2170 Appearance.toggleThemeTweakerUI();
2171 Appearance.themeTweakerUI.style.opacity = "1.0";
2172 Appearance.themeTweakReset();
2176 themeTweakerUIFieldValueChanged: (event) => {
2177 GWLog("Appearance.themeTweakerUIFieldValueChanged");
2179 if (event.target.id == "theme-tweak-control-invert") {
2180 Appearance.currentFilters["invert"] = event.target.checked ? "100%" : "0%";
2181 } else if (event.target.type == "range") {
2182 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2183 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2184 Appearance.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2185 } else if (event.target.id == "theme-tweak-control-clippy") {
2186 Appearance.themeTweakerUIClippyContainer.style.display = event.target.checked ? "block" : "none";
2189 // Clear the sample text filters.
2190 Appearance.themeTweakerUISampleTextContainer.style.filter = "";
2192 // Apply the new filters globally.
2193 Appearance.applyFilters();
2196 themeTweakerUIFieldInputReceived: (event) => {
2197 GWLog("Appearance.themeTweakerUIFieldInputReceived");
2199 let sampleTextFilters = Appearance.currentFilters;
2200 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
2201 Appearance.themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
2202 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
2204 Appearance.themeTweakerUISampleTextContainer.style.filter = Appearance.filterStringFromFilters(sampleTextFilters);
2207 themeTweakerUIMinimizeButtonClicked: (event) => {
2208 GWLog("Appearance.themeTweakerUIMinimizeButtonClicked");
2210 let themeTweakerStyleBlock = Appearance.themeTweakerUI.query("#theme-tweaker-style");
2212 if (event.target.hasClass("minimize")) {
2213 event.target.removeClass("minimize");
2214 themeTweakerStyleBlock.innerHTML =
2215 `#theme-tweaker-ui .main-theme-tweaker-window {
2219 padding: 30px 0 0 0;
2224 #theme-tweaker-ui::after {
2228 #theme-tweaker-ui::before {
2232 #theme-tweaker-ui .clippy-container {
2235 #theme-tweaker-ui .clippy-container .hint span {
2241 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
2242 pointer-events: none;
2244 event.target.addClass("maximize");
2246 event.target.removeClass("maximize");
2247 themeTweakerStyleBlock.innerHTML =
2248 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
2249 pointer-events: none;
2251 event.target.addClass("minimize");
2255 themeTweakerUIHelpButtonClicked: (event) => {
2256 GWLog("Appearance.themeTweakerUIHelpButtonClicked");
2258 Appearance.themeTweakerUIClippyControl.checked = Appearance.getSavedThemeTweakerClippyState();
2259 Appearance.toggleThemeTweakerHelpWindow();
2262 themeTweakerUIResetDefaultsButtonClicked: (event) => {
2263 GWLog("Appearance.themeTweakerUIResetDefaultsButtonClicked");
2265 Appearance.themeTweakResetDefaults();
2266 Appearance.resetThemeTweakerUIDefaultState();
2269 themeTweakerUICancelButtonClicked: (event) => {
2270 GWLog("Appearance.themeTweakerUICancelButtonClicked");
2272 Appearance.toggleThemeTweakerUI();
2273 Appearance.themeTweakReset();
2276 themeTweakerUIOKButtonClicked: (event) => {
2277 GWLog("Appearance.themeTweakerUIOKButtonClicked");
2279 Appearance.toggleThemeTweakerUI();
2280 Appearance.themeTweakSave();
2283 themeTweakerUIHelpWindowCancelButtonClicked: (event) => {
2284 GWLog("Appearance.themeTweakerUIHelpWindowCancelButtonClicked");
2286 Appearance.toggleThemeTweakerHelpWindow();
2287 Appearance.themeTweakerResetSettings();
2290 themeTweakerUIHelpWindowOKButtonClicked: (event) => {
2291 GWLog("Appearance.themeTweakerUIHelpWindowOKButtonClicked");
2293 Appearance.toggleThemeTweakerHelpWindow();
2294 Appearance.themeTweakerSaveSettings();
2297 themeTweakerUISliderNotchClicked: (event) => {
2298 GWLog("Appearance.themeTweakerUISliderNotchClicked");
2300 let slider = event.target.parentElement.query("input[type='range']");
2301 slider.value = slider.dataset["defaultValue"];
2302 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset["labelSuffix"];
2303 Appearance.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset["valueSuffix"];
2304 Appearance.applyFilters();
2307 themeTweakerUIClippyCloseButtonClicked: (event) => {
2308 GWLog("Appearance.themeTweakerUIClippyCloseButtonClicked");
2310 Appearance.themeTweakerUIClippyContainer.style.display = "none";
2311 Appearance.themeTweakerUIClippyControl.checked = false;
2312 Appearance.saveThemeTweakerClippyState();
2316 function setSearchBoxTabSelectable(selectable) {
2317 GWLog("setSearchBoxTabSelectable");
2318 query("input[type='search']").tabIndex = selectable ? "" : "-1";
2319 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
2322 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
2323 // otherwise, show it.
2324 function updatePostNavUIVisibility() {
2325 GWLog("updatePostNavUIVisibility");
2326 var hidePostNavUIToggle = true;
2327 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
2328 if (getComputedStyle(element).visibility == "visible" ||
2329 element.style.visibility == "visible" ||
2330 element.style.visibility == "unset")
2331 hidePostNavUIToggle = false;
2333 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
2334 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
2338 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
2339 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
2340 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
2341 function updateSiteNavUIState(event) {
2342 GWLog("updateSiteNavUIState");
2343 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
2344 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
2345 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
2347 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
2348 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
2350 GW.scrollState.lastScrollTop = newScrollTop;
2352 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
2353 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
2354 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
2355 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged"))
2356 Appearance.toggleAppearanceAdjustUI();
2359 // On mobile, make site nav UI translucent on ANY scroll down.
2361 GW.scrollState.siteNavUIElements.forEach(element => {
2362 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
2363 else element.removeClass("translucent-on-scroll");
2366 // Show site nav UI when scrolling a full page up, or to the top.
2367 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
2368 GW.scrollState.lastScrollTop == 0) &&
2369 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
2370 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
2372 // On desktop, show appearance adjust UI when scrolling to the top.
2373 if ((!GW.isMobile) &&
2374 (GW.scrollState.lastScrollTop == 0) &&
2375 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
2376 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false"))
2377 Appearance.toggleAppearanceAdjustUI();
2380 /*********************/
2381 /* PAGE QUICK-NAV UI */
2382 /*********************/
2384 function injectQuickNavUI() {
2385 GWLog("injectQuickNavUI");
2386 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
2387 `<a href='#top' title="Up to top [,]" accesskey=','></a>
2388 <a href='#comments' title="Comments [/]" accesskey='/'></a>
2389 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
2393 /**********************/
2394 /* NEW COMMENT NAV UI */
2395 /**********************/
2397 function injectNewCommentNavUI(newCommentsCount) {
2398 GWLog("injectNewCommentNavUI");
2399 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
2400 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
2401 <span class='new-comments-count'></span>
2402 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
2405 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
2406 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
2407 GWLog("GW.commentQuicknavButtonClicked");
2408 scrollToNewComment(/next/.test(event.target.className));
2409 event.target.blur();
2413 document.addEventListener("keyup", GW.commentQuicknavKeyPressed = (event) => {
2414 GWLog("GW.commentQuicknavKeyPressed");
2415 if (event.shiftKey || event.ctrlKey || event.altKey) return;
2416 if (event.key == ",") scrollToNewComment(false);
2417 if (event.key == ".") scrollToNewComment(true)
2420 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
2421 + `<span>Since:</span>`
2422 + `<input type='text' class='hns-date'></input>`
2425 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
2426 GWLog("GW.hnsDatePickerValueChanged");
2427 let hnsDate = time_fromHuman(event.target.value);
2429 setHistoryLastVisitedDate(hnsDate);
2430 let newCommentsCount = highlightCommentsSince(hnsDate);
2431 updateNewCommentNavUI(newCommentsCount);
2435 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
2436 GWLog("GW.newCommentsCountClicked");
2437 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
2438 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
2442 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
2443 function time_fromHuman(string) {
2444 /* Convert a human-readable date into a JS timestamp */
2445 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
2446 string = string.replace(' ', 'T'); // revert nice spacing
2447 string += ':00.000Z'; // complete ISO 8601 date
2448 time = Date.parse(string); // milliseconds since epoch
2450 // browsers handle ISO 8601 without explicit timezone differently
2451 // thus, we have to fix that by hand
2452 time += (new Date()).getTimezoneOffset() * 60e3;
2454 string = string.replace(' at', '');
2455 time = Date.parse(string); // milliseconds since epoch
2460 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
2461 GWLog("updateNewCommentNavUI");
2462 // Update the new comments count.
2463 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
2464 newCommentsCountLabel.innerText = newCommentsCount;
2465 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
2467 // Update the date picker field.
2468 if (hnsDate != -1) {
2469 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
2473 /********************************/
2474 /* COMMENTS VIEW MODE SELECTION */
2475 /********************************/
2477 function injectCommentsViewModeSelector() {
2478 GWLog("injectCommentsViewModeSelector");
2479 let commentsContainer = query("#comments");
2480 if (commentsContainer == null) return;
2482 let currentModeThreaded = (location.href.search("chrono=t") == -1);
2483 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
2485 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
2486 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
2487 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
2490 // commentsViewModeSelector.queryAll("a").forEach(button => {
2491 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
2494 if (!currentModeThreaded) {
2495 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
2496 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
2497 commentParentLink.addClass("inline-author");
2498 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
2501 queryAll(".comment-child-links a").forEach(commentChildLink => {
2502 commentChildLink.textContent = commentChildLink.textContent.slice(1);
2503 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
2506 rectifyChronoModeCommentChildLinks();
2508 commentsContainer.addClass("chrono");
2510 commentsContainer.addClass("threaded");
2513 // Remove extraneous top-level comment thread in chrono mode.
2514 let topLevelCommentThread = query("#comments > .comment-thread");
2515 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
2518 // function commentsViewModeSelectorButtonClicked(event) {
2519 // event.preventDefault();
2522 // let request = new XMLHttpRequest();
2523 // request.open("GET", event.target.href);
2524 // request.onreadystatechange = () => {
2525 // if (request.readyState != 4) return;
2526 // newDocument = htmlToElement(request.response);
2528 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
2530 // // Update the buttons.
2531 // event.target.addClass("selected");
2532 // event.target.parentElement.query("." + classes.old).removeClass("selected");
2534 // // Update the #comments container.
2535 // let commentsContainer = query("#comments");
2536 // commentsContainer.removeClass(classes.old);
2537 // commentsContainer.addClass(classes.new);
2539 // // Update the content.
2540 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
2545 // function htmlToElement(html) {
2546 // var template = document.createElement('template');
2547 // template.innerHTML = html.trim();
2548 // return template.content;
2551 function rectifyChronoModeCommentChildLinks() {
2552 GWLog("rectifyChronoModeCommentChildLinks");
2553 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
2554 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
2555 let childLinks = commentChildLinksContainer.queryAll("a");
2556 childLinks.forEach((link, index) => {
2557 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
2561 let childLinksArray = Array.from(childLinks)
2562 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
2563 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
2566 function childrenOfComment(commentID) {
2567 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
2568 let commentParentLink = commentItem.query("a.comment-parent-link");
2569 return ((commentParentLink||{}).hash == "#" + commentID);
2573 /********************************/
2574 /* COMMENTS LIST MODE SELECTION */
2575 /********************************/
2577 function injectCommentsListModeSelector() {
2578 GWLog("injectCommentsListModeSelector");
2579 if (query("#content > .comment-thread") == null) return;
2581 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
2582 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
2583 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
2586 if (query(".sublevel-nav") || query("#top-nav-bar")) {
2587 (query(".sublevel-nav") || query("#top-nav-bar")).insertAdjacentHTML("beforebegin", commentsListModeSelectorHTML);
2589 (query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
2591 let commentsListModeSelector = query("#comments-list-mode-selector");
2593 commentsListModeSelector.queryAll("button").forEach(button => {
2594 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
2595 GWLog("GW.commentsListModeSelectButtonClicked");
2596 event.target.parentElement.queryAll("button").forEach(button => {
2597 button.removeClass("selected");
2598 button.disabled = false;
2599 button.accessKey = '`';
2601 localStorage.setItem("comments-list-mode", event.target.className);
2602 event.target.addClass("selected");
2603 event.target.disabled = true;
2604 event.target.removeAttribute("accesskey");
2606 if (event.target.hasClass("expanded")) {
2607 query("#content").removeClass("compact");
2609 query("#content").addClass("compact");
2614 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
2615 if (savedMode == "compact")
2616 query("#content").addClass("compact");
2617 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
2618 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
2619 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
2622 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
2623 commentParentLink.addActivateEvent(function (event) {
2624 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
2625 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
2631 /**********************/
2632 /* SITE NAV UI TOGGLE */
2633 /**********************/
2635 function injectSiteNavUIToggle() {
2636 GWLog("injectSiteNavUIToggle");
2637 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2638 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
2639 GWLog("GW.siteNavUIToggleButtonClicked");
2641 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
2644 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
2646 function removeSiteNavUIToggle() {
2647 GWLog("removeSiteNavUIToggle");
2648 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2649 element.removeClass("engaged");
2651 removeElement("#site-nav-ui-toggle");
2653 function toggleSiteNavUI() {
2654 GWLog("toggleSiteNavUI");
2655 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
2656 element.toggleClass("engaged");
2657 element.removeClass("translucent-on-scroll");
2661 /**********************/
2662 /* POST NAV UI TOGGLE */
2663 /**********************/
2665 function injectPostNavUIToggle() {
2666 GWLog("injectPostNavUIToggle");
2667 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
2668 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
2669 GWLog("GW.postNavUIToggleButtonClicked");
2671 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
2674 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
2676 function removePostNavUIToggle() {
2677 GWLog("removePostNavUIToggle");
2678 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2679 element.removeClass("engaged");
2681 removeElement("#post-nav-ui-toggle");
2683 function togglePostNavUI() {
2684 GWLog("togglePostNavUI");
2685 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
2686 element.toggleClass("engaged");
2690 /**************************/
2691 /* WORD COUNT & READ TIME */
2692 /**************************/
2694 function toggleReadTimeOrWordCount(addWordCountClass) {
2695 GWLog("toggleReadTimeOrWordCount");
2696 queryAll(".post-meta .read-time").forEach(element => {
2697 if (addWordCountClass) element.addClass("word-count");
2698 else element.removeClass("word-count");
2700 let titleParts = /(\S+)(.+)$/.exec(element.title);
2701 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
2705 /**************************/
2706 /* PROMPT TO SAVE CHANGES */
2707 /**************************/
2709 function enableBeforeUnload() {
2710 window.onbeforeunload = function () { return true; };
2712 function disableBeforeUnload() {
2713 window.onbeforeunload = null;
2716 /***************************/
2717 /* ORIGINAL POSTER BADGING */
2718 /***************************/
2720 function markOriginalPosterComments() {
2721 GWLog("markOriginalPosterComments");
2722 let postAuthor = query(".post .author");
2723 if (postAuthor == null) return;
2725 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2726 if (author.dataset.userid == postAuthor.dataset.userid ||
2727 (author.tagName == "A" && author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2728 author.addClass("original-poster");
2729 author.title += "Original poster";
2734 /********************************/
2735 /* EDIT POST PAGE SUBMIT BUTTON */
2736 /********************************/
2738 function setEditPostPageSubmitButtonText() {
2739 GWLog("setEditPostPageSubmitButtonText");
2740 if (!query("#content").hasClass("edit-post-page")) return;
2742 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2743 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2744 GWLog("GW.postSectionSelectorValueChanged");
2745 updateEditPostPageSubmitButtonText();
2749 updateEditPostPageSubmitButtonText();
2751 function updateEditPostPageSubmitButtonText() {
2752 GWLog("updateEditPostPageSubmitButtonText");
2753 let submitButton = query("input[type='submit']");
2754 if (query("input#drafts").checked == true)
2755 submitButton.value = "Save Draft";
2756 else if (query(".posting-controls").hasClass("edit-existing-post"))
2757 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2759 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2766 function numToAlpha(n) {
2769 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2770 n = Math.floor((n / 26) - 1);
2775 function injectAntiKibitzer() {
2776 GWLog("injectAntiKibitzer");
2777 // Inject anti-kibitzer toggle controls.
2778 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>");
2779 antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2780 GWLog("GW.antiKibitzerToggleButtonClicked");
2781 if (query("#anti-kibitzer-toggle").hasClass("engaged") &&
2783 !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!)")) {
2784 event.target.blur();
2788 toggleAntiKibitzerMode();
2789 event.target.blur();
2792 // Activate anti-kibitzer mode (if needed).
2793 if (localStorage.getItem("antikibitzer") == "true")
2794 toggleAntiKibitzerMode();
2796 // Remove temporary CSS that hides the authors and karma values.
2797 removeElement("#antikibitzer-temp");
2800 function toggleAntiKibitzerMode() {
2801 GWLog("toggleAntiKibitzerMode");
2802 // This will be the URL of the user's own page, if logged in, or the URL of
2803 // the login page otherwise.
2804 let userTabTarget = query("#nav-item-login .nav-inner").href;
2805 let pageHeadingElement = query("h1.page-main-heading");
2808 let userFakeName = { };
2810 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2812 let postAuthor = query(".post-page .post-meta .author");
2813 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2815 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2816 if (antiKibitzerToggle.hasClass("engaged")) {
2817 localStorage.setItem("antikibitzer", "false");
2819 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2820 if (redirectTarget) {
2821 window.location = redirectTarget;
2825 // Individual comment page title and header
2826 if (query(".individual-thread-page")) {
2827 let replacer = (node) => {
2829 node.firstChild.replaceWith(node.dataset["trueContent"]);
2831 replacer(query("title:not(.fake-title)"));
2832 replacer(query("#content > h1"));
2835 // Author names/links.
2836 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2837 author.textContent = author.dataset["trueName"];
2838 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2840 author.removeClass("redacted");
2842 // Post/comment karma values.
2843 queryAll(".karma-value.redacted").forEach(karmaValue => {
2844 karmaValue.innerHTML = karmaValue.dataset["trueValue"];
2846 karmaValue.removeClass("redacted");
2848 // Link post domains.
2849 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2850 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2852 linkPostDomain.removeClass("redacted");
2855 antiKibitzerToggle.removeClass("engaged");
2857 localStorage.setItem("antikibitzer", "true");
2859 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2860 if (redirectTarget) {
2861 window.location = redirectTarget;
2865 // Individual comment page title and header
2866 if (query(".individual-thread-page")) {
2867 let replacer = (node) => {
2869 node.dataset["trueContent"] = node.firstChild.wholeText;
2870 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2871 node.firstChild.replaceWith(newText);
2873 replacer(query("title:not(.fake-title)"));
2874 replacer(query("#content > h1"));
2877 removeElement("title.fake-title");
2879 // Author names/links.
2880 queryAll(".author, .inline-author").forEach(author => {
2881 // Skip own posts/comments.
2882 if (author.hasClass("own-user-author"))
2885 let userid = author.dataset["userid"] || author.hash && query(`${author.hash} .author`).dataset["userid"];
2889 author.dataset["trueName"] = author.textContent;
2890 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2892 if (/\/user/.test(author.href)) {
2893 author.dataset["trueLink"] = author.pathname;
2894 author.href = "/user?id=" + author.dataset["userid"];
2897 author.addClass("redacted");
2899 // Post/comment karma values.
2900 queryAll(".karma-value").forEach(karmaValue => {
2901 // Skip own posts/comments.
2902 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2905 karmaValue.dataset["trueValue"] = karmaValue.innerHTML;
2906 karmaValue.innerHTML = "##<span> points</span>";
2908 karmaValue.addClass("redacted");
2910 // Link post domains.
2911 queryAll(".link-post-domain").forEach(linkPostDomain => {
2912 // Skip own posts/comments.
2913 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2916 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2917 linkPostDomain.textContent = "redacted.domain.tld";
2919 linkPostDomain.addClass("redacted");
2922 antiKibitzerToggle.addClass("engaged");
2926 /*******************************/
2927 /* COMMENT SORT MODE SELECTION */
2928 /*******************************/
2930 var CommentSortMode = Object.freeze({
2936 function sortComments(mode) {
2937 GWLog("sortComments");
2938 let commentsContainer = query("#comments");
2940 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2941 commentsContainer.addClass("sorting");
2943 GW.commentValues = { };
2944 let clonedCommentsContainer = commentsContainer.cloneNode(true);
2945 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2948 case CommentSortMode.NEW:
2949 comparator = (a,b) => commentDate(b) - commentDate(a);
2951 case CommentSortMode.OLD:
2952 comparator = (a,b) => commentDate(a) - commentDate(b);
2954 case CommentSortMode.HOT:
2955 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2957 case CommentSortMode.TOP:
2959 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2962 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2964 removeElement(commentsContainer.lastChild);
2965 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2966 GW.commentValues = { };
2968 if (loggedInUserId) {
2969 // Re-activate vote buttons.
2970 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2971 voteButton.addActivateEvent(voteButtonClicked);
2974 // Re-activate comment action buttons.
2975 commentsContainer.queryAll(".action-button").forEach(button => {
2976 button.addActivateEvent(GW.commentActionButtonClicked);
2980 // Re-activate comment-minimize buttons.
2981 queryAll(".comment-minimize-button").forEach(button => {
2982 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2985 // Re-add comment parent popups.
2986 addCommentParentPopups();
2988 // Redo new-comments highlighting.
2989 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2991 requestAnimationFrame(() => {
2992 commentsContainer.removeClass("sorting");
2993 commentsContainer.addClass("sorted-" + mode);
2996 function commentKarmaValue(commentOrSelector) {
2997 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2998 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
3000 function commentDate(commentOrSelector) {
3001 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3002 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
3004 function commentVoteCount(commentOrSelector) {
3005 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
3006 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
3009 function injectCommentsSortModeSelector() {
3010 GWLog("injectCommentsSortModeSelector");
3011 let topCommentThread = query("#comments > .comment-thread");
3012 if (topCommentThread == null) return;
3014 // Do not show sort mode selector if there is no branching in comment tree.
3015 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
3017 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
3018 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
3020 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
3021 let commentsSortModeSelector = query("#comments-sort-mode-selector");
3023 commentsSortModeSelector.queryAll("button").forEach(button => {
3024 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
3025 GWLog("GW.commentsSortModeSelectButtonClicked");
3026 event.target.parentElement.queryAll("button").forEach(button => {
3027 button.removeClass("selected");
3028 button.disabled = false;
3030 event.target.addClass("selected");
3031 event.target.disabled = true;
3033 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
3034 setCommentsSortModeSelectButtonsAccesskey();
3038 // TODO: Make this actually get the current sort mode (if that's saved).
3039 // TODO: Also change the condition here to properly get chrono/threaded mode,
3040 // when that is properly done with cookies.
3041 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
3042 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
3043 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
3044 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
3045 setCommentsSortModeSelectButtonsAccesskey();
3048 function setCommentsSortModeSelectButtonsAccesskey() {
3049 GWLog("setCommentsSortModeSelectButtonsAccesskey");
3050 queryAll("#comments-sort-mode-selector button").forEach(button => {
3051 button.removeAttribute("accesskey");
3052 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
3054 let selectedButton = query("#comments-sort-mode-selector button.selected");
3055 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
3056 nextButtonInCycle.accessKey = "z";
3057 nextButtonInCycle.title += " [z]";
3060 /*************************/
3061 /* COMMENT PARENT POPUPS */
3062 /*************************/
3064 function previewPopupsEnabled() {
3065 let isDisabled = localStorage.getItem("preview-popups-disabled");
3066 return (typeof(isDisabled) == "string" ? !JSON.parse(isDisabled) : !GW.isMobile);
3069 function setPreviewPopupsEnabled(state) {
3070 localStorage.setItem("preview-popups-disabled", !state);
3071 updatePreviewPopupToggle();
3074 function updatePreviewPopupToggle() {
3075 let style = (previewPopupsEnabled() ? "--display-slash: none" : "");
3076 query("#preview-popup-toggle").setAttribute("style", style);
3079 function injectPreviewPopupToggle() {
3080 GWLog("injectPreviewPopupToggle");
3082 let toggle = addUIElement("<div id='preview-popup-toggle' title='Toggle link preview popups'><svg width=40 height=50 id='popup-svg'></svg>");
3083 // This is required because Chrome can't use filters on an externally used SVG element.
3084 fetch(GW.assets["popup.svg"]).then(response => response.text().then(text => { query("#popup-svg").outerHTML = text }))
3085 updatePreviewPopupToggle();
3086 toggle.addActivateEvent(event => setPreviewPopupsEnabled(!previewPopupsEnabled()))
3089 var currentPreviewPopup = { };
3091 function removePreviewPopup(previewPopup) {
3092 if(previewPopup.element)
3093 removeElement(previewPopup.element);
3095 if(previewPopup.timeout)
3096 clearTimeout(previewPopup.timeout);
3098 if(currentPreviewPopup.pointerListener)
3099 window.removeEventListener("pointermove", previewPopup.pointerListener);
3101 if(currentPreviewPopup.mouseoutListener)
3102 document.body.removeEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3104 if(currentPreviewPopup.scrollListener)
3105 window.removeEventListener("scroll", previewPopup.scrollListener);
3107 currentPreviewPopup = { };
3110 function addCommentParentPopups() {
3111 GWLog("addCommentParentPopups");
3112 //if (!query("#content").hasClass("comment-thread-page")) return;
3114 queryAll("a[href]").forEach(linkTag => {
3115 let linkHref = linkTag.getAttribute("href");
3118 try { url = new URL(linkHref, window.location.href); }
3122 if(GW.sites[url.host]) {
3123 let linkCommentId = (/\/(?:comment|answer)\/([^\/#]+)$/.exec(url.pathname)||[])[1] || (/#comment-(.+)/.exec(url.hash)||[])[1];
3125 if(url.hash && linkTag.hasClass("comment-parent-link") || linkTag.hasClass("comment-child-link")) {
3126 linkTag.addEventListener("pointerover", GW.commentParentLinkMouseOver = (event) => {
3127 if(event.pointerType == "touch") return;
3128 GWLog("GW.commentParentLinkMouseOver");
3129 removePreviewPopup(currentPreviewPopup);
3130 let parentID = linkHref;
3132 if (!(parent = (query(parentID)||{}).firstChild)) return;
3133 var highlightClassName;
3134 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
3135 parentHighlightClassName = "comment-item-highlight-faint";
3136 popup = parent.cloneNode(true);
3137 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
3138 linkTag.addEventListener("mouseout", (event) => {
3139 removeElement(popup);
3141 linkTag.closest(".comments > .comment-thread").appendChild(popup);
3143 parentHighlightClassName = "comment-item-highlight";
3145 parent.parentNode.addClass(parentHighlightClassName);
3146 linkTag.addEventListener("mouseout", (event) => {
3147 parent.parentNode.removeClass(parentHighlightClassName);
3151 else if(url.pathname.match(/^\/(users|posts|events|tag|s|p|explore)\//)
3152 && !(url.pathname.match(/^\/(p|explore)\//) && url.hash.match(/^#comment-/)) // Arbital comment links not supported yet.
3153 && !(url.searchParams.get('format'))
3154 && !linkTag.closest("nav:not(.post-nav-links)")
3155 && (!url.hash || linkCommentId)
3156 && (!linkCommentId || linkTag.getCommentId() !== linkCommentId)) {
3157 linkTag.addEventListener("pointerover", event => {
3158 if(event.buttons != 0 || event.pointerType == "touch" || !previewPopupsEnabled()) return;
3159 if(currentPreviewPopup.linkTag) return;
3160 linkTag.createPreviewPopup();
3162 linkTag.createPreviewPopup = function() {
3163 removePreviewPopup(currentPreviewPopup);
3165 currentPreviewPopup = {linkTag: linkTag};
3167 let popup = document.createElement("iframe");
3168 currentPreviewPopup.element = popup;
3170 let popupTarget = linkHref;
3171 if(popupTarget.match(/#comment-/)) {
3172 popupTarget = popupTarget.replace(/#comment-/, "/comment/");
3174 // 'theme' attribute is required for proper caching
3175 popup.setAttribute("src", popupTarget + (popupTarget.match(/\?/) ? '&' : '?') + "format=preview&theme=" + (readCookie('theme') || 'default'));
3176 popup.addClass("preview-popup");
3178 let linkRect = linkTag.getBoundingClientRect();
3180 if(linkRect.right + 710 < window.innerWidth)
3181 popup.style.left = linkRect.right + 10 + "px";
3183 popup.style.right = "10px";
3185 popup.style.width = "700px";
3186 popup.style.height = "500px";
3187 popup.style.visibility = "hidden";
3188 popup.style.transition = "none";
3190 let recenter = function() {
3191 let popupHeight = 500;
3192 if(popup.contentDocument && popup.contentDocument.readyState !== "loading") {
3193 let popupContent = popup.contentDocument.querySelector("#content");
3195 popupHeight = popupContent.clientHeight + 2;
3196 if(popupHeight > (window.innerHeight * 0.875)) popupHeight = window.innerHeight * 0.875;
3197 popup.style.height = popupHeight + "px";
3200 popup.style.top = (window.innerHeight - popupHeight) * (linkRect.top / (window.innerHeight - linkRect.height)) + 'px';
3205 query('#content').insertAdjacentElement("beforeend", popup);
3207 let clickListener = event => {
3208 if(!event.target.closest("a, input, label")
3209 && !event.target.closest("popup-hide-button")) {
3210 window.location = linkHref;
3214 popup.addEventListener("load", () => {
3215 let hideButton = popup.contentDocument.createElement("div");
3216 hideButton.className = "popup-hide-button";
3217 hideButton.insertAdjacentText('beforeend', "\uF070");
3218 hideButton.onclick = (event) => {
3219 removePreviewPopup(currentPreviewPopup);
3220 setPreviewPopupsEnabled(false);
3221 event.stopPropagation();
3223 popup.contentDocument.body.appendChild(hideButton);
3225 let body = popup.contentDocument.body;
3226 body.addEventListener("click", clickListener);
3227 body.style.cursor = "pointer";
3232 popup.contentDocument.body.addEventListener("click", clickListener);
3234 currentPreviewPopup.timeout = setTimeout(() => {
3237 requestIdleCallback(() => {
3238 if(currentPreviewPopup.element === popup) {
3239 popup.scrolling = "";
3240 popup.style.visibility = "unset";
3241 popup.style.transition = null;
3244 { opacity: 0, transform: "translateY(10%)" },
3245 { opacity: 1, transform: "none" }
3246 ], { duration: 150, easing: "ease-out" });
3251 let pointerX, pointerY, mousePauseTimeout = null;
3253 currentPreviewPopup.pointerListener = (event) => {
3254 pointerX = event.clientX;
3255 pointerY = event.clientY;
3257 if(mousePauseTimeout) clearTimeout(mousePauseTimeout);
3258 mousePauseTimeout = null;
3260 let overElement = document.elementFromPoint(pointerX, pointerY);
3261 let mouseIsOverLink = linkRect.isInside(pointerX, pointerY);
3263 if(mouseIsOverLink || overElement === popup
3264 || (pointerX < popup.getBoundingClientRect().left
3265 && event.movementX >= 0)) {
3266 if(!mouseIsOverLink && overElement !== popup) {
3267 if(overElement['createPreviewPopup']) {
3268 mousePauseTimeout = setTimeout(overElement.createPreviewPopup, 150);
3270 mousePauseTimeout = setTimeout(() => removePreviewPopup(currentPreviewPopup), 500);
3274 removePreviewPopup(currentPreviewPopup);
3275 if(overElement['createPreviewPopup']) overElement.createPreviewPopup();
3278 window.addEventListener("pointermove", currentPreviewPopup.pointerListener);
3280 currentPreviewPopup.mouseoutListener = (event) => {
3281 clearTimeout(mousePauseTimeout);
3282 mousePauseTimeout = null;
3284 document.body.addEventListener("mouseout", currentPreviewPopup.mouseoutListener);
3286 currentPreviewPopup.scrollListener = (event) => {
3287 let overElement = document.elementFromPoint(pointerX, pointerY);
3288 linkRect = linkTag.getBoundingClientRect();
3289 if(linkRect.isInside(pointerX, pointerY) || overElement === popup) return;
3290 removePreviewPopup(currentPreviewPopup);
3292 window.addEventListener("scroll", currentPreviewPopup.scrollListener, {passive: true});
3297 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
3301 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
3302 Appearance.filtersExclusionPaths.commentParentPopups = [
3303 "#content .comments .comment-thread"
3305 Appearance.applyFilters();
3312 function imageFocusSetup(imagesOverlayOnly = false) {
3313 if (typeof GW.imageFocus == "undefined")
3315 contentImagesSelector: "#content img",
3316 overlayImagesSelector: "#images-overlay img",
3317 focusedImageSelector: "#content img.focused, #images-overlay img.focused",
3318 pageContentSelector: "#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay",
3320 hideUITimerDuration: 1500,
3321 hideUITimerExpired: () => {
3322 GWLog("GW.imageFocus.hideUITimerExpired");
3323 let currentTime = new Date();
3324 let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
3325 if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
3326 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
3329 cancelImageFocusHideUITimer();
3334 GWLog("imageFocusSetup");
3335 // Create event listener for clicking on images to focus them.
3336 GW.imageClickedToFocus = (event) => {
3337 GWLog("GW.imageClickedToFocus");
3338 focusImage(event.target);
3341 // Set timer to hide the image focus UI.
3342 unhideImageFocusUI();
3343 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3346 // Add the listener to each image in the overlay (i.e., those in the post).
3347 queryAll(GW.imageFocus.overlayImagesSelector).forEach(image => {
3348 image.addActivateEvent(GW.imageClickedToFocus);
3350 // Accesskey-L starts the slideshow.
3351 (query(GW.imageFocus.overlayImagesSelector)||{}).accessKey = 'l';
3352 // Count how many images there are in the post, and set the "… of X" label to that.
3353 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll(GW.imageFocus.overlayImagesSelector).length;
3354 if (imagesOverlayOnly) return;
3355 // Add the listener to all other content images (including those in comments).
3356 queryAll(GW.imageFocus.contentImagesSelector).forEach(image => {
3357 image.addActivateEvent(GW.imageClickedToFocus);
3360 // Create the image focus overlay.
3361 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
3362 `<div class='help-overlay'>
3363 <p><strong>Arrow keys:</strong> Next/previous image</p>
3364 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
3365 <p><strong>Space bar:</strong> Reset image size & position</p>
3366 <p><strong>Scroll</strong> to zoom in/out</p>
3367 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
3369 <div class='image-number'></div>
3370 <div class='slideshow-buttons'>
3371 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
3372 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
3374 <div class='caption'></div>` +
3376 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
3378 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
3379 button.addActivateEvent(GW.imageFocus.slideshowButtonClicked = (event) => {
3380 GWLog("GW.imageFocus.slideshowButtonClicked");
3381 focusNextImage(event.target.hasClass("next"));
3382 event.target.blur();
3386 // On orientation change, reset the size & position.
3387 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
3388 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
3391 // UI starts out hidden.
3395 function focusImage(imageToFocus) {
3396 GWLog("focusImage");
3397 // Clear 'last-focused' class of last focused image.
3398 let lastFocusedImage = query("img.last-focused");
3399 if (lastFocusedImage) {
3400 lastFocusedImage.removeClass("last-focused");
3401 lastFocusedImage.removeAttribute("accesskey");
3404 // Create the focused version of the image.
3405 imageToFocus.addClass("focused");
3406 let imageFocusOverlay = query("#image-focus-overlay");
3407 let clonedImage = imageToFocus.cloneNode(true);
3408 clonedImage.style = "";
3409 clonedImage.removeAttribute("width");
3410 clonedImage.removeAttribute("height");
3411 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
3412 imageFocusOverlay.appendChild(clonedImage);
3413 imageFocusOverlay.addClass("engaged");
3415 // Set image to default size and position.
3416 resetFocusedImagePosition();
3418 // Blur everything else.
3419 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3420 element.addClass("blurred");
3423 // Add listener to zoom image with scroll wheel.
3424 window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
3425 GWLog("GW.imageFocus.scrollEvent");
3426 event.preventDefault();
3428 let image = query("#image-focus-overlay img");
3430 // Remove the filter.
3431 image.savedFilter = image.style.filter;
3432 image.style.filter = 'none';
3434 // Locate point under cursor.
3435 let imageBoundingBox = image.getBoundingClientRect();
3437 // Calculate resize factor.
3438 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
3439 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
3443 image.style.width = (event.deltaY < 0 ?
3444 (image.clientWidth * factor) :
3445 (image.clientWidth / factor))
3447 image.style.height = "";
3449 // Designate zoom origin.
3451 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
3452 // the cursor is over the image.
3453 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
3454 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
3455 (imageBoundingBox.left <= event.clientX &&
3456 event.clientX <= imageBoundingBox.right &&
3457 imageBoundingBox.top <= event.clientY &&
3458 event.clientY <= imageBoundingBox.bottom);
3459 // Otherwise, if we're zooming OUT, zoom from window center; if we're
3460 // zooming IN, zoom from image center.
3461 let zoomingFromWindowCenter = event.deltaY > 0;
3462 if (zoomingFromCursor)
3463 zoomOrigin = { x: event.clientX,
3465 else if (zoomingFromWindowCenter)
3466 zoomOrigin = { x: window.innerWidth / 2,
3467 y: window.innerHeight / 2 };
3469 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
3470 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
3472 // Calculate offset from zoom origin.
3473 let offsetOfImageFromZoomOrigin = {
3474 x: imageBoundingBox.x - zoomOrigin.x,
3475 y: imageBoundingBox.y - zoomOrigin.y
3477 // Calculate delta from centered zoom.
3478 let deltaFromCenteredZoom = {
3479 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
3480 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
3482 // Adjust image position appropriately.
3483 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
3484 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
3485 // Gradually re-center image, if it's smaller than the window.
3486 if (!imageSizeExceedsWindowBounds) {
3487 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
3488 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
3489 let windowCenter = { x: window.innerWidth / 2,
3490 y: window.innerHeight / 2 }
3491 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
3492 y: windowCenter.y - imageCenter.y }
3493 // Divide the offset by 10 because we're nudging the image toward center,
3494 // not jumping it there.
3495 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
3496 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
3499 // Put the filter back.
3500 image.style.filter = image.savedFilter;
3502 // Set the cursor appropriately.
3503 setFocusedImageCursor();
3505 window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
3506 GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
3507 event.preventDefault();
3510 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
3511 window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
3512 GWLog("GW.imageFocus.mouseUp");
3513 window.onmousemove = '';
3515 // We only want to do anything on left-clicks.
3516 if (event.button != 0) return;
3518 // Don't unfocus if click was on a slideshow next/prev button!
3519 if (event.target.hasClass("slideshow-button")) return;
3521 // We also don't want to do anything if clicked on the help overlay.
3522 if (event.target.classList.contains("help-overlay") ||
3523 event.target.closest(".help-overlay"))
3526 let focusedImage = query("#image-focus-overlay img");
3527 if (event.target == focusedImage &&
3528 (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
3529 // If the mouseup event was the end of a pan of an overside image,
3530 // put the filter back; do not unfocus.
3531 focusedImage.style.filter = focusedImage.savedFilter;
3533 unfocusImageOverlay();
3537 window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
3538 GWLog("GW.imageFocus.mouseDown");
3539 event.preventDefault();
3541 let focusedImage = query("#image-focus-overlay img");
3542 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
3543 let mouseCoordX = event.clientX;
3544 let mouseCoordY = event.clientY;
3546 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
3547 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
3550 focusedImage.savedFilter = focusedImage.style.filter;
3552 window.onmousemove = (event) => {
3553 // Remove the filter.
3554 focusedImage.style.filter = "none";
3555 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
3556 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
3562 // Double-click on the image unfocuses.
3563 clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
3564 GWLog("GW.imageFocus.doubleClick");
3565 if (event.target.hasClass("slideshow-button")) return;
3567 unfocusImageOverlay();
3570 // Escape key unfocuses, spacebar resets.
3571 document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
3572 GWLog("GW.imageFocus.keyUp");
3573 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
3574 if (!allowedKeys.contains(event.key) ||
3575 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
3577 event.preventDefault();
3579 switch (event.key) {
3582 unfocusImageOverlay();
3586 resetFocusedImagePosition();
3592 if (query("#images-overlay img.focused")) focusNextImage(true);
3598 if (query("#images-overlay img.focused")) focusNextImage(false);
3603 // Prevent spacebar or arrow keys from scrolling page when image focused.
3604 togglePageScrolling(false);
3606 // If the image comes from the images overlay, for the main post...
3607 if (imageToFocus.closest("#images-overlay")) {
3608 // Mark the overlay as being in slide show mode (to show buttons/count).
3609 imageFocusOverlay.addClass("slideshow");
3611 // Set state of next/previous buttons.
3612 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3613 var indexOfFocusedImage = getIndexOfFocusedImage();
3614 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3615 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3617 // Set the image number.
3618 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3620 // Replace the hash.
3621 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3623 imageFocusOverlay.removeClass("slideshow");
3627 setImageFocusCaption();
3629 // Moving mouse unhides image focus UI.
3630 window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
3631 GWLog("GW.imageFocus.mouseMoved");
3632 let currentDateTime = new Date();
3633 if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
3634 cancelImageFocusHideUITimer();
3636 if (!GW.imageFocus.hideUITimer) {
3637 unhideImageFocusUI();
3638 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
3640 GW.imageFocus.mouseLastMovedAt = currentDateTime;
3645 function resetFocusedImagePosition() {
3646 GWLog("resetFocusedImagePosition");
3647 let focusedImage = query("#image-focus-overlay img");
3648 if (!focusedImage) return;
3650 let sourceImage = query(GW.imageFocus.focusedImageSelector);
3652 // Make sure that initially, the image fits into the viewport.
3653 let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
3654 let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
3655 var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
3656 let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
3657 let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
3658 focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
3659 focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
3661 // Remove modifications to position.
3662 focusedImage.style.left = "";
3663 focusedImage.style.top = "";
3665 // Set the cursor appropriately.
3666 setFocusedImageCursor();
3668 function setFocusedImageCursor() {
3669 let focusedImage = query("#image-focus-overlay img");
3670 if (!focusedImage) return;
3671 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
3675 function unfocusImageOverlay() {
3676 GWLog("unfocusImageOverlay");
3678 // Remove event listeners.
3679 window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
3680 window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
3681 // NOTE: The double-click listener does not need to be removed manually,
3682 // because the focused (cloned) image will be removed anyway.
3683 document.removeEventListener("keyup", GW.imageFocus.keyUp);
3684 document.removeEventListener("keydown", GW.imageFocus.keyDown);
3685 window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
3686 window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
3687 window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
3689 // Set accesskey of currently focused image (if it's in the images overlay).
3690 let currentlyFocusedImage = query("#images-overlay img.focused");
3691 if (currentlyFocusedImage) {
3692 currentlyFocusedImage.addClass("last-focused");
3693 currentlyFocusedImage.accessKey = 'l';
3696 // Remove focused image and hide overlay.
3697 let imageFocusOverlay = query("#image-focus-overlay");
3698 imageFocusOverlay.removeClass("engaged");
3699 removeElement(imageFocusOverlay.query("img"));
3701 // Un-blur content/etc.
3702 queryAll(GW.imageFocus.pageContentSelector).forEach(element => {
3703 element.removeClass("blurred");
3706 // Unset "focused" class of focused image.
3707 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3709 // Re-enable page scrolling.
3710 togglePageScrolling(true);
3712 // Reset the hash, if needed.
3713 if (location.hash.hasPrefix("#if_slide_"))
3714 history.replaceState(window.history.state, null, "#");
3717 function getIndexOfFocusedImage() {
3718 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3719 var indexOfFocusedImage = -1;
3720 for (i = 0; i < images.length; i++) {
3721 if (images[i].hasClass("focused")) {
3722 indexOfFocusedImage = i;
3726 return indexOfFocusedImage;
3729 function focusNextImage(next = true) {
3730 GWLog("focusNextImage");
3731 let images = queryAll(GW.imageFocus.overlayImagesSelector);
3732 var indexOfFocusedImage = getIndexOfFocusedImage();
3734 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
3736 // Remove existing image.
3737 removeElement("#image-focus-overlay img");
3738 // Unset "focused" class of just-removed image.
3739 query(GW.imageFocus.focusedImageSelector).removeClass("focused");
3741 // Create the focused version of the image.
3742 images[indexOfFocusedImage].addClass("focused");
3743 let imageFocusOverlay = query("#image-focus-overlay");
3744 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
3745 clonedImage.style = "";
3746 clonedImage.removeAttribute("width");
3747 clonedImage.removeAttribute("height");
3748 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
3749 imageFocusOverlay.appendChild(clonedImage);
3750 imageFocusOverlay.addClass("engaged");
3751 // Set image to default size and position.
3752 resetFocusedImagePosition();
3753 // Set state of next/previous buttons.
3754 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
3755 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
3756 // Set the image number display.
3757 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
3759 setImageFocusCaption();
3760 // Replace the hash.
3761 history.replaceState(window.history.state, null, "#if_slide_" + (indexOfFocusedImage + 1));
3764 function setImageFocusCaption() {
3765 GWLog("setImageFocusCaption");
3766 var T = { }; // Temporary storage.
3768 // Clear existing caption, if any.
3769 let captionContainer = query("#image-focus-overlay .caption");
3770 Array.from(captionContainer.children).forEach(child => { child.remove(); });
3772 // Determine caption.
3773 let currentlyFocusedImage = query(GW.imageFocus.focusedImageSelector);
3775 if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
3776 (T.figcaption = T.enclosingFigure.query("figcaption"))) {
3777 captionHTML = (T.figcaption.query("p")) ?
3778 T.figcaption.innerHTML :
3779 "<p>" + T.figcaption.innerHTML + "</p>";
3780 } else if (currentlyFocusedImage.title != "") {
3781 captionHTML = `<p>${currentlyFocusedImage.title}</p>`;
3783 // Insert the caption, if any.
3784 if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
3787 function hideImageFocusUI() {
3788 GWLog("hideImageFocusUI");
3789 let imageFocusOverlay = query("#image-focus-overlay");
3790 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3791 element.addClass("hidden");
3795 function unhideImageFocusUI() {
3796 GWLog("unhideImageFocusUI");
3797 let imageFocusOverlay = query("#image-focus-overlay");
3798 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
3799 element.removeClass("hidden");
3803 function cancelImageFocusHideUITimer() {
3804 clearTimeout(GW.imageFocus.hideUITimer);
3805 GW.imageFocus.hideUITimer = null;
3812 function keyboardHelpSetup() {
3813 let keyboardHelpOverlay = addUIElement("<nav id='keyboard-help-overlay'>" + `
3814 <div class='keyboard-help-container'>
3815 <button type='button' title='Close keyboard shortcuts' class='close-keyboard-help'></button>
3816 <h1>Keyboard shortcuts</h1>
3817 <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>
3818 <p class='note'>Keys shown in grey (e.g., <code>?</code>) do not require any modifier keys.</p>
3819 <div class='keyboard-shortcuts-lists'>` + [ [
3821 [ [ '?' ], "Show keyboard shortcuts" ],
3822 [ [ 'Esc' ], "Hide keyboard shortcuts" ]
3825 [ [ 'ak-h' ], "Go to Home (a.k.a. “Frontpage”) view" ],
3826 [ [ 'ak-f' ], "Go to Featured (a.k.a. “Curated”) view" ],
3827 [ [ 'ak-a' ], "Go to All (a.k.a. “Community”) view" ],
3828 [ [ 'ak-m' ], "Go to Meta view" ],
3829 [ [ 'ak-v' ], "Go to Tags view"],
3830 [ [ 'ak-c' ], "Go to Recent Comments view" ],
3831 [ [ 'ak-r' ], "Go to Archive view" ],
3832 [ [ 'ak-q' ], "Go to Sequences view" ],
3833 [ [ 'ak-t' ], "Go to About page" ],
3834 [ [ 'ak-u' ], "Go to User or Login page" ],
3835 [ [ 'ak-o' ], "Go to Inbox page" ]
3838 [ [ 'ak-,' ], "Jump up to top of page" ],
3839 [ [ 'ak-.' ], "Jump down to bottom of page" ],
3840 [ [ 'ak-/' ], "Jump to top of comments section" ],
3841 [ [ 'ak-s' ], "Search" ],
3844 [ [ 'ak-n' ], "New post or comment" ],
3845 [ [ 'ak-e' ], "Edit current post" ]
3847 "Post/comment list views",
3848 [ [ '.' ], "Focus next entry in list" ],
3849 [ [ ',' ], "Focus previous entry in list" ],
3850 [ [ ';' ], "Cycle between links in focused entry" ],
3851 [ [ 'Enter' ], "Go to currently focused entry" ],
3852 [ [ 'Esc' ], "Unfocus currently focused entry" ],
3853 [ [ 'ak-]' ], "Go to next page" ],
3854 [ [ 'ak-[' ], "Go to previous page" ],
3855 [ [ 'ak-\\' ], "Go to first page" ],
3856 [ [ 'ak-e' ], "Edit currently focused post" ]
3859 [ [ 'ak-k' ], "Bold text" ],
3860 [ [ 'ak-i' ], "Italic text" ],
3861 [ [ 'ak-l' ], "Insert hyperlink" ],
3862 [ [ 'ak-q' ], "Blockquote text" ]
3865 [ [ 'ak-=' ], "Increase text size" ],
3866 [ [ 'ak--' ], "Decrease text size" ],
3867 [ [ 'ak-0' ], "Reset to default text size" ],
3868 [ [ 'ak-′' ], "Cycle through content width settings" ],
3869 [ [ 'ak-1' ], "Switch to default theme [A]" ],
3870 [ [ 'ak-2' ], "Switch to dark theme [B]" ],
3871 [ [ 'ak-3' ], "Switch to grey theme [C]" ],
3872 [ [ 'ak-4' ], "Switch to ultramodern theme [D]" ],
3873 [ [ 'ak-5' ], "Switch to simple theme [E]" ],
3874 [ [ 'ak-6' ], "Switch to brutalist theme [F]" ],
3875 [ [ 'ak-7' ], "Switch to ReadTheSequences theme [G]" ],
3876 [ [ 'ak-8' ], "Switch to classic Less Wrong theme [H]" ],
3877 [ [ 'ak-9' ], "Switch to modern Less Wrong theme [I]" ],
3878 [ [ 'ak-;' ], "Open theme tweaker" ],
3879 [ [ 'Enter' ], "Save changes and close theme tweaker "],
3880 [ [ 'Esc' ], "Close theme tweaker (without saving)" ]
3883 [ [ 'ak-l' ], "Start/resume slideshow" ],
3884 [ [ 'Esc' ], "Exit slideshow" ],
3885 [ [ '→', '↓' ], "Next slide" ],
3886 [ [ '←', '↑' ], "Previous slide" ],
3887 [ [ 'Space' ], "Reset slide zoom" ]
3890 [ [ 'ak-x' ], "Switch to next view on user page" ],
3891 [ [ 'ak-z' ], "Switch to previous view on user page" ],
3892 [ [ 'ak-` ' ], "Toggle compact comment list view" ],
3893 [ [ 'ak-g' ], "Toggle anti-kibitzer" ]
3895 `<ul><li class='section'>${section[0]}</li>` + section.slice(1).map(entry =>
3897 <span class='keys'>` +
3899 (key.hasPrefix("ak-")) ? `<code class='ak'>${key.substring(3)}</code>` : `<code>${key}</code>`
3902 <span class='action'>${entry[1]}</span>
3904 ).join("\n") + `</ul>`).join("\n") + `
3909 // Add listener to show the keyboard help overlay.
3910 document.addEventListener("keypress", GW.keyboardHelpShowKeyPressed = (event) => {
3911 GWLog("GW.keyboardHelpShowKeyPressed");
3912 if (event.key == '?')
3913 toggleKeyboardHelpOverlay(true);
3916 // Clicking the background overlay closes the keyboard help overlay.
3917 keyboardHelpOverlay.addActivateEvent(GW.keyboardHelpOverlayClicked = (event) => {
3918 GWLog("GW.keyboardHelpOverlayClicked");
3919 if (event.type == 'mousedown') {
3920 keyboardHelpOverlay.style.opacity = "0.01";
3922 toggleKeyboardHelpOverlay(false);
3923 keyboardHelpOverlay.style.opacity = "1.0";
3927 // Intercept clicks, so they don't "fall through" the background overlay.
3928 (query("#keyboard-help-overlay .keyboard-help-container")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
3930 // Clicking the close button closes the keyboard help overlay.
3931 keyboardHelpOverlay.query("button.close-keyboard-help").addActivateEvent(GW.closeKeyboardHelpButtonClicked = (event) => {
3932 toggleKeyboardHelpOverlay(false);
3935 // Add button to open keyboard help.
3936 query("#nav-item-about").insertAdjacentHTML("beforeend", "<button type='button' tabindex='-1' class='open-keyboard-help' title='Keyboard shortcuts'></button>");
3937 query("#nav-item-about button.open-keyboard-help").addActivateEvent(GW.openKeyboardHelpButtonClicked = (event) => {
3938 GWLog("GW.openKeyboardHelpButtonClicked");
3939 toggleKeyboardHelpOverlay(true);
3940 event.target.blur();
3944 function toggleKeyboardHelpOverlay(show) {
3945 console.log("toggleKeyboardHelpOverlay");
3947 let keyboardHelpOverlay = query("#keyboard-help-overlay");
3948 show = (typeof show != "undefined") ? show : (getComputedStyle(keyboardHelpOverlay) == "hidden");
3949 keyboardHelpOverlay.style.visibility = show ? "visible" : "hidden";
3951 // Prevent scrolling the document when the overlay is visible.
3952 togglePageScrolling(!show);
3954 // Focus the close button as soon as we open.
3955 keyboardHelpOverlay.query("button.close-keyboard-help").focus();
3958 // Add listener to show the keyboard help overlay.
3959 document.addEventListener("keyup", GW.keyboardHelpHideKeyPressed = (event) => {
3960 GWLog("GW.keyboardHelpHideKeyPressed");
3961 if (event.key == 'Escape')
3962 toggleKeyboardHelpOverlay(false);
3965 document.removeEventListener("keyup", GW.keyboardHelpHideKeyPressed);
3968 // Disable / enable tab-selection of the search box.
3969 setSearchBoxTabSelectable(!show);
3972 /**********************/
3973 /* PUSH NOTIFICATIONS */
3974 /**********************/
3976 function pushNotificationsSetup() {
3977 let pushNotificationsButton = query("#enable-push-notifications");
3978 if(pushNotificationsButton && (pushNotificationsButton.dataset.enabled || (navigator.serviceWorker && window.Notification && window.PushManager))) {
3979 pushNotificationsButton.onclick = pushNotificationsButtonClicked;
3980 pushNotificationsButton.style.display = 'unset';
3984 function urlBase64ToUint8Array(base64String) {
3985 const padding = '='.repeat((4 - base64String.length % 4) % 4);
3986 const base64 = (base64String + padding)
3988 .replace(/_/g, '/');
3990 const rawData = window.atob(base64);
3991 const outputArray = new Uint8Array(rawData.length);
3993 for (let i = 0; i < rawData.length; ++i) {
3994 outputArray[i] = rawData.charCodeAt(i);
3999 function pushNotificationsButtonClicked(event) {
4000 event.target.style.opacity = 0.33;
4001 event.target.style.pointerEvents = "none";
4003 let reEnable = (message) => {
4004 if(message) alert(message);
4005 event.target.style.opacity = 1;
4006 event.target.style.pointerEvents = "unset";
4009 if(event.target.dataset.enabled) {
4010 fetch('/push/register', {
4012 headers: { 'Content-type': 'application/json' },
4013 body: JSON.stringify({
4017 event.target.innerHTML = "Enable push notifications";
4018 event.target.dataset.enabled = "";
4020 }).catch((err) => reEnable(err.message));
4022 Notification.requestPermission().then((permission) => {
4023 navigator.serviceWorker.ready
4024 .then((registration) => {
4025 return registration.pushManager.getSubscription()
4026 .then(async function(subscription) {
4028 return subscription;
4030 return registration.pushManager.subscribe({
4031 userVisibleOnly: true,
4032 applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
4035 .catch((err) => reEnable(err.message));
4037 .then((subscription) => {
4038 fetch('/push/register', {
4041 'Content-type': 'application/json'
4043 body: JSON.stringify({
4044 subscription: subscription
4049 event.target.innerHTML = "Disable push notifications";
4050 event.target.dataset.enabled = "true";
4053 .catch(function(err){ reEnable(err.message) });
4059 /*******************************/
4060 /* HTML TO MARKDOWN CONVERSION */
4061 /*******************************/
4063 function MarkdownFromHTML(text) {
4064 GWLog("MarkdownFromHTML");
4065 // Wrapper tags, paragraphs, bold, italic, code blocks.
4066 text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
4089 // <div> and <span>.
4090 text = text.replace(/<div.+?>(.+?)<\/div>/g, (match, text, offset, string) => {
4092 }).replace(/<span.+?>(.+?)<\/span>/g, (match, text, offset, string) => {
4097 text = text.replace(/<ul>\s+?((?:.|\n)+?)\s+?<\/ul>/g, (match, listItems, offset, string) => {
4098 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
4099 return `* ${listItem}\n`;
4104 text = text.replace(/<ol.+?(?:\sstart=["']([0-9]+)["'])?.+?>\s+?((?:.|\n)+?)\s+?<\/ol>/g, (match, start, listItems, offset, string) => {
4105 var countedItemValue = 0;
4106 return listItems.replace(/<li(?:\svalue=["']([0-9]+)["'])?>((?:.|\n)+?)<\/li>/g, (match, specifiedItemValue, listItem, offset, string) => {
4108 if (typeof specifiedItemValue != "undefined") {
4109 specifiedItemValue = parseInt(specifiedItemValue);
4110 countedItemValue = itemValue = specifiedItemValue;
4112 itemValue = (start ? parseInt(start) - 1 : 0) + ++countedItemValue;
4114 return `${itemValue}. ${listItem.trim()}\n`;
4119 text = text.replace(/<h([1-9]).+?>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
4120 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
4124 text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
4125 return `> ${quotedText.trim().split("\n").join("\n> ")}\n`;
4129 text = text.replace(/<a.+?href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
4130 return `[${text}](${href})`;
4134 text = text.replace(/<img.+?src="(.+?)".+?\/>/g, (match, src, offset, string) => {
4135 return `![](${src})`;
4138 // Horizontal rules.
4139 text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
4144 text = text.replace(/<br\s?\/?>/g, (match, offset, string) => {
4148 // Preformatted text (possibly with a code block inside).
4149 text = text.replace(/<pre>(?:\s*<code>)?((?:.|\n)+?)(?:<\/code>\s*)?<\/pre>/g, (match, text, offset, string) => {
4150 return "```\n" + text + "\n```";
4154 text = text.replace(/<code>(.+?)<\/code>/g, (match, text, offset, string) => {
4155 return "`" + text + "`";
4159 text = text.replace(/&(.+?);/g, (match, entity, offset, string) => {
4179 /************************************/
4180 /* ANCHOR LINK SCROLLING WORKAROUND */
4181 /************************************/
4183 addTriggerListener('navBarLoaded', {priority: -1, fn: () => {
4184 let hash = location.hash;
4185 if(hash && hash !== "#top" && !document.query(hash)) {
4186 let content = document.query("#content");
4187 content.style.display = "none";
4188 addTriggerListener("DOMReady", {priority: -1, fn: () => {
4189 content.style.visibility = "hidden";
4190 content.style.display = null;
4191 requestIdleCallback(() => {content.style.visibility = null}, {timeout: 500});
4196 /******************/
4197 /* INITIALIZATION */
4198 /******************/
4200 addTriggerListener('navBarLoaded', {priority: 3000, fn: function () {
4201 GWLog("INITIALIZER earlyInitialize");
4202 // Check to see whether we're on a mobile device (which we define as a narrow screen)
4203 GW.isMobile = (window.innerWidth <= 1160);
4204 GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
4206 // Backward compatibility
4207 let storedTheme = localStorage.getItem("selected-theme");
4209 Appearance.setTheme(storedTheme);
4210 localStorage.removeItem("selected-theme");
4213 // Animate width & theme adjustments?
4214 Appearance.adjustmentTransitions = false;
4215 // Add the content width selector.
4216 Appearance.injectContentWidthSelector();
4217 // Add the text size adjustment widget.
4218 Appearance.injectTextSizeAdjustmentUI();
4219 // Add the theme selector.
4220 Appearance.injectThemeSelector();
4221 // Add the theme tweaker.
4222 Appearance.injectThemeTweaker();
4224 // Add the dark mode selector.
4225 DarkMode.injectModeSelector();
4227 // Add the quick-nav UI.
4230 // Finish initializing when ready.
4231 addTriggerListener('DOMReady', {priority: 100, fn: mainInitializer});
4234 function mainInitializer() {
4235 GWLog("INITIALIZER initialize");
4237 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
4238 // "link without nav bars".
4239 if (getQueryVariable("hide-nav-bars") == "true") {
4240 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
4243 // If the page cannot have comments, remove the accesskey from the #comments
4244 // quick-nav button; and if the page can have comments, but does not, simply
4245 // disable the #comments quick nav button.
4246 let content = query("#content");
4247 if (content.query("#comments") == null) {
4248 query("#quick-nav-ui a[href='#comments']").accessKey = '';
4249 } else if (content.query("#comments .comment-thread") == null) {
4250 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
4253 // On edit post pages and conversation pages, add GUIEdit buttons to the
4254 // textarea, expand it, and markdownify the existing text, if any (this is
4255 // needed if a post was last edited on LW).
4256 queryAll(".with-markdown-editor textarea").forEach(textarea => {
4257 textarea.addTextareaFeatures();
4258 expandTextarea(textarea);
4259 textarea.value = MarkdownFromHTML(textarea.value);
4261 // Focus the textarea.
4262 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
4265 queryAll(".contents-list li a").forEach(tocLink => {
4266 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
4267 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
4268 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, '');
4269 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
4272 // If we're on a comment thread page...
4273 if (query(".comments") != null) {
4274 // Add comment-minimize buttons to every comment.
4275 queryAll(".comment-meta").forEach(commentMeta => {
4276 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
4277 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
4279 if (query("#content.comment-thread-page") && !query("#content").hasClass("individual-thread-page")) {
4280 // Format and activate comment-minimize buttons.
4281 queryAll(".comment-minimize-button").forEach(button => {
4282 button.closest(".comment-item").setCommentThreadMaximized(false);
4283 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
4284 event.target.closest(".comment-item").setCommentThreadMaximized(true);
4289 if (getQueryVariable("chrono") == "t") {
4290 insertHeadHTML("<style>.comment-minimize-button::after { display: none; }</style>");
4293 // On mobile, replace the labels for the checkboxes on the edit post form
4294 // with icons, to save space.
4295 if (GW.isMobile && query(".edit-post-page")) {
4296 query("label[for='link-post']").innerHTML = "";
4297 query("label[for='question']").innerHTML = "";
4300 // Add error message (as placeholder) if user tries to click Search with
4301 // an empty search field.
4303 let searchForm = query("#nav-item-search form");
4304 if(!searchForm) break searchForm;
4305 searchForm.addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
4306 let searchField = event.target.query("input");
4307 if (searchField.value == "") {
4308 event.preventDefault();
4309 event.target.blur();
4310 searchField.placeholder = "Enter a search string!";
4311 searchField.focus();
4314 // Remove the placeholder / error on any input.
4315 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
4316 event.target.placeholder = "";
4320 // Prevent conflict between various single-hotkey listeners and text fields
4321 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
4322 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
4323 inputField.addEventListener("keypress", (event) => { event.stopPropagation(); });
4326 if (content.hasClass("post-page")) {
4327 // Read and update last-visited-date.
4328 let lastVisitedDate = getLastVisitedDate();
4329 setLastVisitedDate(Date.now());
4331 // Save the number of comments this post has when it's visited.
4332 updateSavedCommentCount();
4334 if (content.query(".comments .comment-thread") != null) {
4335 // Add the new comments count & navigator.
4336 injectNewCommentNavUI();
4338 // Get the highlight-new-since date (as specified by URL parameter, if
4339 // present, or otherwise the date of the last visit).
4340 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
4342 // Highlight new comments since the specified date.
4343 let newCommentsCount = highlightCommentsSince(hnsDate);
4345 // Update the comment count display.
4346 updateNewCommentNavUI(newCommentsCount, hnsDate);
4349 // On listing pages, make comment counts more informative.
4350 badgePostsWithNewComments();
4353 // Add the comments list mode selector widget (expanded vs. compact).
4354 injectCommentsListModeSelector();
4356 // Add the comments view selector widget (threaded vs. chrono).
4357 // injectCommentsViewModeSelector();
4359 // Add the comments sort mode selector (top, hot, new, old).
4360 if (GW.useFancyFeatures) injectCommentsSortModeSelector();
4362 // Add the toggle for the post nav UI elements on mobile.
4363 if (GW.isMobile) injectPostNavUIToggle();
4365 // Add the toggle for the appearance adjustment UI elements on mobile.
4367 Appearance.injectAppearanceAdjustUIToggle();
4369 // Add the antikibitzer.
4370 if (GW.useFancyFeatures)
4371 injectAntiKibitzer();
4373 // Add comment parent popups.
4374 injectPreviewPopupToggle();
4375 addCommentParentPopups();
4377 // Mark original poster's comments with a special class.
4378 markOriginalPosterComments();
4380 // On the All view, mark posts with non-positive karma with a special class.
4381 if (query("#content").hasClass("all-index-page")) {
4382 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
4383 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
4385 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
4389 // Set the "submit" button on the edit post page to something more helpful.
4390 setEditPostPageSubmitButtonText();
4392 // Compute the text of the pagination UI tooltip text.
4393 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
4394 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
4397 // Add event listeners for Escape and Enter, for the theme tweaker.
4398 document.addEventListener("keyup", Appearance.themeTweakerUIKeyPressed);
4400 // Add event listener for . , ; (for navigating listings pages).
4401 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
4402 if (!query(".comments") && listings.length > 0) {
4403 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
4404 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.key == "Escape")) return;
4406 if (event.key == "Escape") {
4407 if (document.activeElement.parentElement.hasClass("listing"))
4408 document.activeElement.blur();
4412 if (event.key == ';') {
4413 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
4414 let links = document.activeElement.parentElement.queryAll("a");
4415 links[document.activeElement == links[0] ? 1 : 0].focus();
4416 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
4417 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
4418 links[document.activeElement == links[0] ? 1 : 0].focus();
4419 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
4424 var indexOfActiveListing = -1;
4425 for (i = 0; i < listings.length; i++) {
4426 if (document.activeElement.parentElement.hasClass("listing") &&
4427 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
4428 indexOfActiveListing = i;
4430 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
4431 listings[i] === document.activeElement.parentElement.query("a.date")) {
4432 indexOfActiveListing = i;
4436 // Remove edit accesskey from currently highlighted post by active user, if applicable.
4437 if (indexOfActiveListing > -1) {
4438 delete (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey;
4440 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
4441 if (indexOfNextListing < listings.length) {
4442 listings[indexOfNextListing].focus();
4444 if (listings[indexOfNextListing].closest(".comment-item")) {
4445 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
4446 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
4449 document.activeElement.blur();
4451 // Add edit accesskey to newly highlighted post by active user, if applicable.
4452 (listings[indexOfActiveListing].parentElement.query(".edit-post-link")||{}).accessKey = 'e';
4454 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
4455 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
4456 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
4460 // Add event listener for ; (to focus the link on link posts).
4461 if (query("#content").hasClass("post-page") &&
4462 query(".post").hasClass("link-post")) {
4463 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
4464 if (event.key == ';') query("a.link-post-link").focus();
4468 // Add accesskeys to user page view selector.
4469 let viewSelector = query("#content.user-page > .sublevel-nav");
4471 let currentView = viewSelector.query("span");
4472 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
4473 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
4476 // Add accesskey to index page sort selector.
4477 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
4479 // Move MathJax style tags to <head>.
4480 var aggregatedStyles = "";
4481 queryAll("#content style").forEach(styleTag => {
4482 aggregatedStyles += styleTag.innerHTML;
4483 removeElement("style", styleTag.parentElement);
4485 if (aggregatedStyles != "") {
4486 insertHeadHTML("<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
4489 // Add listeners to switch between word count and read time.
4490 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
4491 queryAll(".post-meta .read-time").forEach(element => {
4492 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
4493 let displayWordCount = localStorage.getItem("display-word-count");
4494 toggleReadTimeOrWordCount(!displayWordCount);
4495 if (displayWordCount) localStorage.removeItem("display-word-count");
4496 else localStorage.setItem("display-word-count", true);
4500 // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
4501 query("#content").addEventListener("copy", GW.textCopied = (event) => {
4502 if(event.target.matches("input, textarea")) return;
4503 event.preventDefault();
4504 const selectedHTML = getSelectionHTML();
4505 const selectedText = getSelection().toString();
4506 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
4507 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
4510 // Set up Image Focus feature.
4513 // Set up keyboard shortcuts guide overlay.
4514 keyboardHelpSetup();
4516 // Show push notifications button if supported
4517 pushNotificationsSetup();
4519 // Show elements now that javascript is ready.
4520 removeElement("#hide-until-init");
4522 activateTrigger("pageLayoutFinished");
4525 /*************************/
4526 /* POST-LOAD ADJUSTMENTS */
4527 /*************************/
4529 window.addEventListener("pageshow", badgePostsWithNewComments);
4531 addTriggerListener('pageLayoutFinished', {priority: 100, fn: function () {
4532 GWLog("INITIALIZER pageLayoutFinished");
4534 Appearance.postSetThemeHousekeeping();
4536 focusImageSpecifiedByURL();
4538 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
4539 // query("input[type='search']").value = GW.isMobile;
4540 // insertHeadHTML("<style>" +
4541 // `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` +
4542 // `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` +
4546 function generateImagesOverlay() {
4547 GWLog("generateImagesOverlay");
4548 // Don't do this on the about page.
4549 if (query(".about-page") != null) return;
4552 // Remove existing, if any.
4553 removeElement("#images-overlay");
4556 query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
4557 let imagesOverlay = query("#images-overlay");
4558 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
4559 queryAll(".post-body img").forEach(image => {
4560 let clonedImageContainer = document.createElement("div");
4562 let clonedImage = image.cloneNode(true);
4563 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
4564 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
4565 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
4566 clonedImageContainer.appendChild(clonedImage);
4568 let zoomLevel = Appearance.currentTextZoom;
4570 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
4571 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
4572 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
4573 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
4575 imagesOverlay.appendChild(clonedImageContainer);
4578 // Add the event listeners to focus each image.
4579 imageFocusSetup(true);
4582 function adjustUIForWindowSize() {
4583 GWLog("adjustUIForWindowSize");
4584 var bottomBarOffset;
4586 // Adjust bottom bar state.
4587 let bottomBar = query("#bottom-bar");
4588 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4589 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
4590 bottomBar.removeClass("decorative");
4592 bottomBar.query("#nav-item-top").style.display = "";
4593 } else if (bottomBar) {
4594 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
4595 else bottomBar.addClass("decorative");
4597 bottomBar.query("#nav-item-top").style.display = "none";
4600 // Show quick-nav UI up/down buttons if content is taller than window.
4601 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
4602 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
4603 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
4606 // Move anti-kibitzer toggle if content is very short.
4607 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
4609 // Update the visibility of the post nav UI.
4610 updatePostNavUIVisibility();
4613 function recomputeUIElementsContainerHeight(force = false) {
4614 GWLog("recomputeUIElementsContainerHeight");
4616 (force || query("#ui-elements-container").style.height != "")) {
4617 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
4618 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
4619 query("#content").clientHeight + "px" :
4624 function focusImageSpecifiedByURL() {
4625 GWLog("focusImageSpecifiedByURL");
4626 if (location.hash.hasPrefix("#if_slide_")) {
4627 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
4628 let images = queryAll(GW.imageFocus.overlayImagesSelector);
4629 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
4630 if (imageToFocus > 0 && imageToFocus <= images.length) {
4631 focusImage(images[imageToFocus - 1]);
4633 // Set timer to hide the image focus UI.
4634 unhideImageFocusUI();
4635 GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
4645 function insertMarkup(event) {
4646 var mopen = '', mclose = '', mtext = '', func = false;
4647 if (typeof arguments[1] == 'function') {
4648 func = arguments[1];
4650 mopen = arguments[1];
4651 mclose = arguments[2];
4652 mtext = arguments[3];
4655 var textarea = event.target.closest("form").query("textarea");
4657 var p0 = textarea.selectionStart;
4658 var p1 = textarea.selectionEnd;
4659 var cur0 = cur1 = p0;
4661 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
4662 str = func ? func(str, p0) : (mopen + str + mclose);
4664 // Determine selection.
4666 cur0 += (p0 == p1) ? mopen.length : str.length;
4667 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
4674 // Update textarea contents.
4675 // The document.execCommand API is broken in Firefox
4676 // ( https://bugzilla.mozilla.org/show_bug.cgi?id=1220696 ), but using it
4677 // allows native undo/redo to work; so we enable it in other browsers.
4679 textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
4681 document.execCommand("insertText", false, str);
4683 // Expand textarea, if needed.
4684 expandTextarea(textarea);
4687 textarea.selectionStart = cur0;
4688 textarea.selectionEnd = cur1;
4693 GW.guiEditButtons = [
4694 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
4695 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
4696 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
4697 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
4698 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
4699 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
4700 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
4701 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
4702 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
4703 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
4704 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
4705 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
4706 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
4707 [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '' ],
4708 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
4711 function blockquote(text, startpos) {
4713 text = "> Quoted text";
4714 return [ text, startpos + 2, startpos + text.length ];
4716 text = "> " + text.split("\n").join("\n> ") + "\n";
4717 return [ text, startpos + text.length, startpos + text.length ];
4721 function hyperlink(text, startpos) {
4722 var url = '', link_text = text, endpos = startpos;
4723 if (text.search(/^https?/) != -1) {
4725 link_text = "link text";
4726 startpos = startpos + 1;
4727 endpos = startpos + link_text.length;
4729 url = prompt("Link address (URL):");
4731 endpos = startpos + text.length;
4732 return [ text, startpos, endpos ];
4734 startpos = startpos + text.length + url.length + 4;
4738 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];
4741 if(navigator.serviceWorker) {
4742 navigator.serviceWorker.register('/service-worker.js');
4743 setCookie("push", "t");