1 /***************************/
2 /* INITIALIZATION REGISTRY */
3 /***************************/
5 if (!window.requestIdleCallback) {
6 window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
9 GW.initializersDone = {};
11 function registerInitializer(name, tryEarly, precondition, fn) {
12 GW.initializersDone[name] = false;
13 GW.initializers[name] = fn;
14 let wrapper = function () {
15 if (GW.initializersDone[name]) return;
16 if (!precondition()) {
18 setTimeout(() => requestIdleCallback(wrapper, {timeout: 1000}), 50);
20 document.addEventListener("readystatechange", wrapper, {once: true});
24 GW.initializersDone[name] = true;
28 requestIdleCallback(wrapper, {timeout: 1000});
30 document.addEventListener("readystatechange", wrapper, {once: true});
31 requestIdleCallback(wrapper);
34 function forceInitializer(name) {
35 if (GW.initializersDone[name]) return;
36 GW.initializersDone[name] = true;
37 GW.initializers[name]();
44 function setCookie(name, value, days) {
46 if (!days) days = 36500;
48 var date = new Date();
49 date.setTime(date.getTime() + (days*24*60*60*1000));
50 expires = "; expires=" + date.toUTCString();
52 document.cookie = name + "=" + (value || "") + expires + "; path=/";
54 function readCookie(name) {
55 var nameEQ = name + "=";
56 var ca = document.cookie.split(';');
57 for(var i = 0; i < ca.length; i++) {
59 while (c.charAt(0)==' ') c = c.substring(1, c.length);
60 if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
65 /*******************************/
66 /* EVENT LISTENER MANIPULATION */
67 /*******************************/
69 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
70 let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
71 if (includeMouseDown) this.addEventListener("mousedown", ael);
72 this.addEventListener("click", ael);
73 this.addEventListener("keyup", ael);
76 Element.prototype.removeActivateEvent = function() {
77 let ael = this.activateEventListener;
78 this.removeEventListener("mousedown", ael);
79 this.removeEventListener("click", ael);
80 this.removeEventListener("keyup", ael);
83 function addScrollListener(fn, name) {
84 let wrapper = (event) => {
85 requestAnimationFrame(() => {
87 document.addEventListener("scroll", wrapper, {once: true, passive: true});
90 document.addEventListener("scroll", wrapper, {once: true, passive: true});
92 // Retain a reference to the scroll listener, if a name is provided.
93 if (typeof name != "undefined")
101 Element.prototype.scrollIntoViewIfNeeded = function() {
102 if (this.getBoundingClientRect().bottom > window.innerHeight) {
103 this.scrollIntoView(false);
107 Element.prototype.getCommentId = function() {
108 let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
110 return /^comment-(.*)/.exec(item.id)[1];
116 function GWLog (string) {
117 if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
120 GW.enableLogging = (permanently = false) => {
122 localStorage.setItem("logging-enabled", "true");
124 GW.loggingEnabled = true;
126 GW.disableLogging = (permanently = false) => {
128 localStorage.removeItem("logging-enabled");
130 GW.loggingEnabled = false;
133 function doAjax(params) {
134 let req = new XMLHttpRequest();
135 req.addEventListener("load", (event) => {
136 if(event.target.status < 400) {
137 if(params["onSuccess"]) params.onSuccess();
139 if(params["onFailure"]) params.onFailure();
142 req.open((params["method"] || "GET"), (params.location || document.location));
143 if(params["method"] == "POST") {
144 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
145 params["params"]["csrf-token"] = GW.csrfToken;
146 req.send(params.params.keys().map((x) => {return "" + x + "=" + encodeURIComponent(params.params[x])}).join("&"));
152 function getSelectionHTML() {
153 var container = document.createElement("div");
154 container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
155 return container.innerHTML;
158 /*******************/
159 /* INBOX INDICATOR */
160 /*******************/
162 function updateInbox() {
163 GWLog("updateInbox");
164 if (!loggedInUserId) return;
166 let request = new XMLHttpRequest();
167 request.addEventListener("load", (event) => {
168 if (event.target.status != 200) return;
170 let response = JSON.parse(event.target.responseText);
172 let element = query('#inbox-indicator');
173 element.className = 'new-messages';
174 element.title = 'New messages [o]';
177 request.open("GET", "/check-notifications");
185 function toggleMarkdownHintsBox() {
186 GWLog("toggleMarkdownHintsBox");
187 let markdownHintsBox = query("#markdown-hints");
188 markdownHintsBox.style.display = (getComputedStyle(markdownHintsBox).display == "none") ? "block" : "none";
190 function hideMarkdownHintsBox() {
191 GWLog("hideMarkdownHintsBox");
192 let markdownHintsBox = query("#markdown-hints");
193 if (getComputedStyle(markdownHintsBox).display != "none") markdownHintsBox.style.display = "none";
196 Element.prototype.addTextareaFeatures = function() {
197 GWLog("addTextareaFeatures");
200 textarea.addEventListener("focus", (event) => { event.target.closest("form").scrollIntoViewIfNeeded(); });
201 textarea.addEventListener("input", GW.textareaInputReceived = (event) => {
202 if (window.innerWidth > 520) {
203 // Expand textarea if needed.
204 expandTextarea(textarea);
206 // Remove markdown hints.
207 hideMarkdownHintsBox();
208 query(".guiedit-mobile-help-button").removeClass("active");
211 textarea.addEventListener("keyup", (event) => { event.stopPropagation(); });
213 textarea.insertAdjacentHTML("beforebegin", "<div class='guiedit-buttons-container'></div>");
214 let textareaContainer = textarea.closest(".textarea-container");
215 var buttons_container = textareaContainer.query(".guiedit-buttons-container");
216 for (var button of GW.guiEditButtons) {
217 let [ name, desc, accesskey, m_before_or_func, m_after, placeholder, icon ] = button;
218 buttons_container.insertAdjacentHTML("beforeend",
219 "<button type='button' class='guiedit guiedit-"
222 + ((accesskey != "") ? (" accesskey='" + accesskey + "'") : "")
223 + " title='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
224 + " data-tooltip='" + desc + ((accesskey != "") ? (" [" + accesskey + "]") : "") + "'"
225 + " onclick='insertMarkup(event,"
226 + ((typeof m_before_or_func == 'function') ?
227 m_before_or_func.name :
228 ("\"" + m_before_or_func + "\",\"" + m_after + "\",\"" + placeholder + "\""))
236 `<input type='checkbox' id='markdown-hints-checkbox'>
237 <label for='markdown-hints-checkbox'></label>
238 <div id='markdown-hints'>` +
239 [ "<span style='font-weight: bold;'>Bold</span><code>**Bold**</code>",
240 "<span style='font-style: italic;'>Italic</span><code>*Italic*</code>",
241 "<span><a href=#>Link</a></span><code>[Link](http://example.com)</code>",
242 "<span>Heading 1</span><code># Heading 1</code>",
243 "<span>Heading 2</span><code>## Heading 1</code>",
244 "<span>Heading 3</span><code>### Heading 1</code>",
245 "<span>Blockquote</span><code>> Blockquote</code>" ].map(row => "<div class='markdown-hints-row'>" + row + "</div>").join("") +
247 textareaContainer.query("span").insertAdjacentHTML("afterend", markdown_hints);
249 textareaContainer.queryAll(".guiedit-mobile-auxiliary-button").forEach(button => {
250 button.addActivateEvent(GW.GUIEditMobileAuxiliaryButtonClicked = (event) => {
251 if (button.hasClass("guiedit-mobile-help-button")) {
252 toggleMarkdownHintsBox();
253 event.target.toggleClass("active");
254 query(".posting-controls:focus-within textarea").focus();
255 } else if (button.hasClass("guiedit-mobile-exit-button")) {
257 hideMarkdownHintsBox();
258 textareaContainer.query(".guiedit-mobile-help-button").removeClass("active");
263 // On smartphone (narrow mobile) screens, when a textarea is focused (and
264 // automatically fullscreened), remove all the filters from the page, and
265 // then apply them *just* to the fixed editor UI elements. This is in order
266 // to get around the "children of elements with a filter applied cannot be
268 if (GW.isMobile && window.innerWidth <= 520) {
269 let fixedEditorElements = textareaContainer.queryAll("textarea, .guiedit-buttons-container, .guiedit-mobile-auxiliary-button, #markdown-hints");
270 textarea.addEventListener("focus", (event) => {
271 GW.savedFilters = GW.currentFilters;
272 GW.currentFilters = { };
273 applyFilters(GW.currentFilters);
274 fixedEditorElements.forEach(element => {
275 element.style.filter = filterStringFromFilters(GW.savedFilters);
278 textarea.addEventListener("blur", (event) => {
279 GW.currentFilters = GW.savedFilters;
280 GW.savedFilters = { };
281 requestAnimationFrame(() => {
282 applyFilters(GW.currentFilters);
283 fixedEditorElements.forEach(element => {
284 element.style.filter = filterStringFromFilters(GW.savedFilters);
291 Element.prototype.injectReplyForm = function(editMarkdownSource) {
292 GWLog("injectReplyForm");
293 let commentControls = this;
294 let editCommentId = (editMarkdownSource ? commentControls.getCommentId() : false);
295 let withparent = (!editMarkdownSource && commentControls.getCommentId());
296 let answer = commentControls.parentElement.id == "answers";
297 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
298 "<form method='post'>" +
299 "<div class='textarea-container'>" +
300 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
301 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
302 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
303 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
304 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
305 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
306 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
308 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
309 "<input type='submit' value='Submit'>" +
311 commentControls.onsubmit = disableBeforeUnload;
313 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
314 hideReplyForm(event.target.closest(".comment-controls"));
316 commentControls.scrollIntoViewIfNeeded();
317 commentControls.query("form").onsubmit = (event) => {
318 if (!event.target.text.value) {
319 alert("Please enter a comment.");
323 let textarea = commentControls.query("textarea");
324 textarea.value = MarkdownFromHTML(editMarkdownSource || "");
325 textarea.addTextareaFeatures();
329 Element.prototype.updateCommentControlButton = function() {
330 let retractFn = () => {
331 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
332 return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
334 return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
337 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
338 "retract-button": retractFn,
339 "unretract-button": retractFn,
340 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
342 classMap.keys().forEach((testClass) => {
343 if(this.hasClass(testClass)) {
344 let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
346 this.addClasses([ buttonClass, "action-button" ]);
347 if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
348 this.dataset.label = buttonLabel;
349 this.title = buttonAltText;
350 this.tabIndex = '-1';
356 Element.prototype.constructCommentControls = function() {
357 GWLog("constructCommentControls");
358 let commentControls = this;
359 let commentType = (commentControls.parentElement.id == "answers" ? "answer" : "comment");
360 commentControls.innerHTML = "";
361 let replyButton = document.createElement("button");
362 if (commentControls.parentElement.hasClass("comments")) {
363 replyButton.className = "new-comment-button action-button";
364 replyButton.innerHTML = "Post new " + commentType;
365 replyButton.setAttribute("accesskey", (commentType == "comment" ? "n" : ""));
366 replyButton.setAttribute("title", "Post new " + commentType + (commentType == "comment" ? " [n]" : ""));
368 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
369 let buttonsList = [];
370 if(!commentControls.parentElement.query(".comment-thread"))
371 buttonsList.push("delete-button");
372 buttonsList.push("retract-button", "edit-button");
373 buttonsList.forEach(buttonClass => {
374 let button = commentControls.appendChild(document.createElement("button"));
375 button.addClass(buttonClass);
376 button.updateCommentControlButton();
379 replyButton.className = "reply-button action-button";
380 replyButton.innerHTML = "Reply";
381 replyButton.dataset.label = "Reply";
383 commentControls.appendChild(replyButton);
384 replyButton.tabIndex = '-1';
386 // On mobile, hide labels for all but the Reply button.
387 if (GW.isMobile && window.innerWidth <= 900) {
388 commentControls.queryAll(".delete-button, .retract-button, .unretract-button, .edit-button").forEach(button => {
389 button.innerHTML = "";
394 commentControls.queryAll(".action-button").forEach(button => {
395 button.addActivateEvent(GW.commentActionButtonClicked);
398 // Replicate karma controls at the bottom of comments.
399 if (commentControls.parentElement.hasClass("comments")) return;
400 let karmaControls = commentControls.parentElement.query(".comment-meta .karma");
401 let karmaControlsCloned = karmaControls.cloneNode(true);
402 commentControls.appendChild(karmaControlsCloned);
403 commentControls.queryAll("button.vote").forEach(voteButton => {
404 voteButton.addActivateEvent(voteButtonClicked);
408 GW.commentActionButtonClicked = (event) => {
409 GWLog("commentActionButtonClicked");
410 if (event.target.hasClass("edit-button") ||
411 event.target.hasClass("reply-button") ||
412 event.target.hasClass("new-comment-button")) {
413 queryAll("textarea").forEach(textarea => {
414 hideReplyForm(textarea.closest(".comment-controls"));
418 if (event.target.hasClass("delete-button")) {
419 let commentItem = event.target.closest(".comment-item");
420 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
421 "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" +
422 "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" +
423 "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
424 doCommentAction("delete", commentItem);
425 } else if (event.target.hasClass("retract-button")) {
426 doCommentAction("retract", event.target.closest(".comment-item"));
427 } else if (event.target.hasClass("unretract-button")) {
428 doCommentAction("unretract", event.target.closest(".comment-item"));
429 } else if (event.target.hasClass("edit-button")) {
430 showCommentEditForm(event.target.closest(".comment-item"));
431 } else if (event.target.hasClass("reply-button")) {
432 showReplyForm(event.target.closest(".comment-item"));
433 } else if (event.target.hasClass("new-comment-button")) {
434 showReplyForm(event.target.closest(".comments"));
440 function showCommentEditForm(commentItem) {
441 GWLog("showCommentEditForm");
443 let commentBody = commentItem.query(".comment-body");
444 commentBody.style.display = "none";
446 let commentControls = commentItem.query(".comment-controls");
447 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
448 commentControls.query("form").addClass("edit-existing-comment");
449 expandTextarea(commentControls.query("textarea"));
452 function showReplyForm(commentItem) {
453 GWLog("showReplyForm");
455 let commentControls = commentItem.query(".comment-controls");
456 commentControls.injectReplyForm(commentControls.dataset.enteredText);
459 function hideReplyForm(commentControls) {
460 GWLog("hideReplyForm");
461 // Are we editing a comment? If so, un-hide the existing comment body.
462 let containingComment = commentControls.closest(".comment-item");
463 if (containingComment) containingComment.query(".comment-body").style.display = "";
465 let enteredText = commentControls.query("textarea").value;
466 if (enteredText) commentControls.dataset.enteredText = enteredText;
468 disableBeforeUnload();
469 commentControls.constructCommentControls();
472 function expandTextarea(textarea) {
473 GWLog("expandTextarea");
474 if (window.innerWidth <= 520) return;
476 let totalBorderHeight = 30;
477 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
479 requestAnimationFrame(() => {
480 textarea.style.height = 'auto';
481 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
482 if (textarea.clientHeight < window.innerHeight) {
483 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
488 function doCommentAction(action, commentItem) {
489 GWLog("doCommentAction");
491 params[(action + "-comment-id")] = commentItem.getCommentId();
495 onSuccess: (event) => {
497 retract: () => { commentItem.firstChild.addClass("retracted") },
498 unretract: () => { commentItem.firstChild.removeClass("retracted") },
500 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>";
501 commentItem.removeChild(commentItem.query(".comment-controls"));
505 if(action != "delete")
506 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
515 function parseVoteType(voteType) {
516 GWLog("parseVoteType");
518 if (!voteType) return value;
519 value.up = /[Uu]pvote$/.test(voteType);
520 value.down = /[Dd]ownvote$/.test(voteType);
521 value.big = /^big/.test(voteType);
525 function makeVoteType(value) {
526 GWLog("makeVoteType");
527 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
530 function makeVoteClass(vote) {
531 GWLog("makeVoteClass");
532 if (vote.up || vote.down) {
533 return (vote.big ? 'selected big-vote' : 'selected');
539 function addVoteButtons(element, voteType, targetType) {
540 GWLog("addVoteButtons");
541 let vote = parseVoteType(voteType);
542 let voteClass = makeVoteClass(vote);
543 element.insertAdjacentHTML('beforebegin', "<button type='button' class='vote upvote"+(vote.up ?' '+voteClass:'')+"' data-vote-type='upvote' data-target-type='"+targetType+"' tabindex='-1'></button>");
544 element.insertAdjacentHTML('afterend', "<button type='button' class='vote downvote"+(vote.down ?' '+voteClass:'')+"' data-vote-type='downvote' data-target-type='"+targetType+"' tabindex='-1'></button>");
547 function makeVoteCompleteEvent(target) {
548 GWLog("makeVoteCompleteEvent");
550 var buttonTargets, karmaTargets;
551 if (target === null) {
552 buttonTargets = queryAll(".post-meta .karma");
553 karmaTargets = queryAll(".post-meta .karma-value");
555 let commentItem = target.closest(".comment-item")
556 buttonTargets = [ commentItem.query(".comment-meta .karma"), commentItem.query(".comment-controls .karma") ];
557 karmaTargets = [ commentItem.query(".comment-meta .karma-value"), commentItem.query(".comment-controls .karma-value") ];
559 buttonTargets.forEach(buttonTarget => {
560 buttonTarget.removeClass("waiting");
562 if (event.target.status == 200) {
563 let response = JSON.parse(event.target.responseText);
564 let karmaText = response[0], voteType = response[1];
566 let vote = parseVoteType(voteType);
567 let voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
568 let voteClass = makeVoteClass(vote);
570 karmaTargets.forEach(karmaTarget => {
571 karmaTarget.innerHTML = karmaText;
572 if (karmaTarget.hasClass("redacted")) {
573 karmaTarget.dataset["trueValue"] = karmaTarget.firstChild.textContent;
574 karmaTarget.firstChild.textContent = "##";
577 buttonTargets.forEach(buttonTarget => {
578 buttonTarget.queryAll("button.vote").forEach(button => {
579 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
580 if (button.dataset.voteType == voteUpDown) button.addClass(voteClass);
587 function sendVoteRequest(targetId, targetType, voteType, onFinish) {
588 GWLog("sendVoteRequest");
589 let req = new XMLHttpRequest();
590 req.addEventListener("load", onFinish);
591 req.open("POST", "/karma-vote");
592 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
593 req.send("csrf-token="+encodeURIComponent(GW.csrfToken)+"&target="+encodeURIComponent(targetId)+"&target-type="+encodeURIComponent(targetType)+"&vote-type="+encodeURIComponent(voteType));
596 function voteButtonClicked(event) {
597 let voteButton = event.target;
599 // 500 ms (0.5 s) double-click timeout.
600 let doubleClickTimeout = 500;
602 if (!voteButton.clickedOnce) {
603 voteButton.clickedOnce = true;
604 voteButton.addClass("clicked-once");
606 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
607 if (!voteButton.clickedOnce) return;
609 // Do single-click code.
610 voteButton.clickedOnce = false;
611 voteEvent(voteButton, 1);
612 }, doubleClickTimeout, voteButton);
614 voteButton.clickedOnce = false;
616 // Do double-click code.
617 voteEvent(voteButton, 2);
618 voteButton.removeClass("clicked-once");
619 voteButton.addClass("clicked-twice");
622 function voteEvent(voteButton, numClicks) {
624 voteButton.parentNode.addClass("waiting");
625 let targetType = voteButton.dataset.targetType;
626 let targetId = ((targetType == 'Comments') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
627 let voteUpDown = voteButton.dataset.voteType;
628 let vote = parseVoteType(voteUpDown);
629 vote.big = (numClicks == 2);
630 let voteType = makeVoteType(vote);
632 if (targetType == "Posts") {
633 oldVoteType = postVote;
634 postVote = ((voteType == oldVoteType) ? null : voteType);
636 oldVoteType = commentVotes[targetId];
637 commentVotes[targetId] = ((voteType == oldVoteType) ? null : voteType);
639 let f = () => { sendVoteRequest(targetId, targetType, voteType, makeVoteCompleteEvent((targetType == 'Comments' ? voteButton.parentNode : null))) };
640 if (oldVoteType && (oldVoteType != voteType)) {
641 sendVoteRequest(targetId, targetType, oldVoteType, f);
647 /***********************************/
648 /* COMMENT THREAD MINIMIZE BUTTONS */
649 /***********************************/
651 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
652 GWLog("setCommentThreadMaximized");
653 let commentItem = this;
654 let storageName = "thread-minimized-" + commentItem.getCommentId();
655 let minimize_button = commentItem.query(".comment-minimize-button");
656 let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !localStorage.getItem(storageName));
657 if (userOriginated) {
659 localStorage.removeItem(storageName);
661 localStorage.setItem(storageName, true);
665 commentItem.style.height = maximize ? 'auto' : '38px';
666 commentItem.style.overflow = maximize ? 'visible' : 'hidden';
668 minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
669 minimize_button.innerHTML = maximize ? "" : "";
670 minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
671 if (getQueryVariable("chrono") != "t") {
672 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
676 /*****************************************/
677 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
678 /*****************************************/
680 Element.prototype.getCommentDate = function() {
681 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
682 return (item ? parseInt(item.query(".date").dataset["jsDate"]) : false);
684 function getCurrentVisibleComment() {
685 let px = window.innerWidth/2, py = 5;
686 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
687 let atbottom = query("#comments").getBoundingClientRect().bottom < window.innerHeight;
689 let hashci = location.hash && query(location.hash);
690 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
691 commentItem = hashci;
697 function highlightCommentsSince(date) {
698 GWLog("highlightCommentsSince");
699 var newCommentsCount = 0;
700 GW.newComments = [ ];
701 let oldCommentsStack = [ ];
703 queryAll(".comment-item").forEach(commentItem => {
704 commentItem.prevNewComment = prevNewComment;
705 if (commentItem.getCommentDate() > date) {
706 commentItem.addClass("new-comment");
708 GW.newComments.push(commentItem.getCommentId());
709 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
710 oldCommentsStack = [ commentItem ];
711 prevNewComment = commentItem;
713 commentItem.removeClass("new-comment");
714 oldCommentsStack.push(commentItem);
718 GW.newCommentScrollSet = (commentItem) => {
719 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
720 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
722 GW.newCommentScrollListener = () => {
723 let commentItem = getCurrentVisibleComment();
724 GW.newCommentScrollSet(commentItem);
727 addScrollListener(GW.newCommentScrollListener);
729 if (document.readyState=="complete") {
730 GW.newCommentScrollListener();
732 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
733 GW.newCommentScrollSet(commentItem);
736 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
738 return newCommentsCount;
741 function scrollToNewComment(next) {
742 GWLog("scrollToNewComment");
743 let commentItem = getCurrentVisibleComment();
744 let targetComment = null;
745 let targetCommentID = null;
747 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
749 targetCommentID = targetComment.getCommentId();
752 if (GW.newComments[0]) {
753 targetCommentID = GW.newComments[0];
754 targetComment = query("#comment-" + targetCommentID);
758 expandAncestorsOf(targetCommentID);
759 history.replaceState(null, null, "#comment-" + targetCommentID);
760 targetComment.scrollIntoView();
763 GW.newCommentScrollListener();
766 function getPostHash() {
767 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
768 return (postHash ? postHash[1] : false);
770 function getLastVisitedDate() {
771 // Get the last visited date (or, if posting a comment, the previous last visited date).
772 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
773 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
774 return localStorage.getItem(storageName);
776 function setLastVisitedDate(date) {
777 // If NOT posting a comment, save the previous value for the last-visited-date
778 // (to recover it in case of posting a comment).
779 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
780 if (!aCommentHasJustBeenPosted) {
781 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
782 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
785 // Set the new value.
786 localStorage.setItem("last-visited-date_" + getPostHash(), date);
789 function updateSavedCommentCount() {
790 let commentCount = queryAll(".comment").length;
791 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
793 function badgePostsWithNewComments() {
794 if (getQueryVariable("show") == "conversations") return;
796 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
797 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
799 let savedCommentCount = localStorage.getItem("comment-count_" + postHash);
800 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
801 let currentCommentCount = /([0-9]+)/.exec(commentCountDisplay.textContent)[1];
803 if (currentCommentCount > savedCommentCount)
804 commentCountDisplay.addClass("new-comments");
805 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
809 /***********************************/
810 /* CONTENT COLUMN WIDTH ADJUSTMENT */
811 /***********************************/
813 function injectContentWidthSelector() {
814 GWLog("injectContentWidthSelector");
815 // Get saved width setting (or default).
816 let currentWidth = localStorage.getItem("selected-width") || 'normal';
818 // Inject the content width selector widget and activate buttons.
819 let widthSelector = addUIElement(
820 "<div id='width-selector'>" +
821 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
822 let [name, desc, abbr] = widthOption;
823 let selected = (name == currentWidth ? ' selected' : '');
824 let disabled = (name == currentWidth ? ' disabled' : '');
825 return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
827 widthSelector.queryAll("button").forEach(button => {
828 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
829 // Determine which setting was chosen (i.e., which button was clicked).
830 let selectedWidth = event.target.dataset.name;
832 // Save the new setting.
833 if (selectedWidth == "normal") localStorage.removeItem("selected-width");
834 else localStorage.setItem("selected-width", selectedWidth);
836 // Actually change the content width.
837 setContentWidth(selectedWidth);
838 event.target.parentElement.childNodes.forEach(button => {
839 button.removeClass("selected");
840 button.disabled = false;
842 event.target.addClass("selected");
843 event.target.disabled = true;
845 // Make sure the accesskey (to cycle to the next width) is on the right button.
846 setWidthAdjustButtonsAccesskey();
848 // Regenerate images overlay.
849 generateImagesOverlay();
856 // Make sure the accesskey (to cycle to the next width) is on the right button.
857 setWidthAdjustButtonsAccesskey();
859 // Inject transitions CSS, if animating changes is enabled.
860 if (GW.adjustmentTransitions) {
861 query("head").insertAdjacentHTML("beforeend",
862 "<style id='width-transition'>" +
864 #ui-elements-container,
872 function setWidthAdjustButtonsAccesskey() {
873 GWLog("setWidthAdjustButtonsAccesskey");
874 let widthSelector = query("#width-selector");
875 widthSelector.queryAll("button").forEach(button => {
876 button.removeAttribute("accesskey");
877 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
879 let selectedButton = widthSelector.query("button.selected");
880 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
881 nextButtonInCycle.accessKey = "'";
882 nextButtonInCycle.title += ` [\']`;
885 /*******************/
886 /* THEME SELECTION */
887 /*******************/
889 function injectThemeSelector() {
890 GWLog("injectThemeSelector");
891 let currentTheme = readCookie("theme") || "default";
892 let themeSelector = addUIElement(
893 "<div id='theme-selector' class='theme-selector'>" +
894 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
895 let [name, desc, letter] = themeOption;
896 let selected = (name == currentTheme ? ' selected' : '');
897 let disabled = (name == currentTheme ? ' disabled' : '');
898 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
899 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>`;})) +
901 themeSelector.queryAll("button").forEach(button => {
902 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
903 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
904 setSelectedTheme(themeName);
908 // Inject transitions CSS, if animating changes is enabled.
909 if (GW.adjustmentTransitions) {
910 query("head").insertAdjacentHTML("beforeend",
911 "<style id='theme-fade-transition'>" +
914 opacity 0.5s ease-out,
915 background-color 0.3s ease-out;
918 background-color: #777;
921 opacity 0.5s ease-in,
922 background-color 0.3s ease-in;
927 function setSelectedTheme(themeName) {
928 GWLog("setSelectedTheme");
929 queryAll(".theme-selector button").forEach(button => {
930 button.removeClass("selected");
931 button.disabled = false;
933 queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
934 button.addClass("selected");
935 button.disabled = true;
938 query("#theme-tweaker-ui .current-theme span").innerText = themeName;
940 function setTheme(newThemeName) {
941 var themeUnloadCallback = '';
942 var oldThemeName = '';
943 if (typeof(newThemeName) == 'undefined') {
944 newThemeName = readCookie('theme');
945 if (!newThemeName) return;
947 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
948 oldThemeName = readCookie('theme') || 'default';
950 if (newThemeName == 'default') setCookie('theme', '');
951 else setCookie('theme', newThemeName);
953 if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
955 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
956 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
958 let newStyle = document.createElement('link');
959 newStyle.setAttribute('rel', 'stylesheet');
960 newStyle.setAttribute('href', '/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
962 let oldStyle = query("head link[href*='.css']");
963 newStyle.addEventListener('load', () => { removeElement(oldStyle); });
964 newStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
966 if (GW.adjustmentTransitions) {
967 pageFadeTransition(false);
969 query('head').insertBefore(newStyle, oldStyle.nextSibling);
972 query('head').insertBefore(newStyle, oldStyle.nextSibling);
975 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
976 recomputeUIElementsContainerHeight(true);
978 let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
979 if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
981 recomputeUIElementsContainerHeight();
982 adjustUIForWindowSize();
983 window.addEventListener('resize', GW.windowResized = (event) => {
984 adjustUIForWindowSize();
985 recomputeUIElementsContainerHeight();
988 generateImagesOverlay();
990 if (window.adjustmentTransitions) pageFadeTransition(true);
991 updateThemeTweakerSampleText();
993 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
994 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
997 setTimeout(realignHash, 0);
1000 function pageFadeTransition(fadeIn) {
1002 query("body").removeClass("transparent");
1004 query("body").addClass("transparent");
1008 GW.themeLoadCallback_less = (fromTheme = "") => {
1009 GWLog("themeLoadCallback_less");
1010 injectSiteNavUIToggle();
1012 injectPostNavUIToggle();
1013 injectAppearanceAdjustUIToggle();
1016 registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1017 let dtf = new Intl.DateTimeFormat([],
1018 (window.innerWidth < 1100) ?
1019 { month: 'short', day: 'numeric', year: 'numeric' } :
1020 { month: 'long', day: 'numeric', year: 'numeric' });
1021 let postDate = query(".top-post-meta .date");
1022 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1026 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1030 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1031 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1032 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1036 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1037 // If state is not set (user has never clicked on the Less theme's appearance
1038 // adjustment UI toggle) then show it, but then hide it after a short time.
1039 registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1040 toggleAppearanceAdjustUI();
1041 setTimeout(toggleAppearanceAdjustUI, 3000);
1045 if (fromTheme != "") {
1046 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1047 setTimeout(function () {
1048 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1050 setTimeout(function () {
1051 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1055 // Unset the height of the #ui-elements-container.
1056 query("#ui-elements-container").style.height = "";
1058 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1059 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1060 "#content #secondary-bar",
1061 "#content .post .top-post-meta .date",
1062 "#content .post .top-post-meta .comment-count",
1064 applyFilters(GW.currentFilters);
1067 // We pre-query the relevant elements, so we don't have to run querySelectorAll
1068 // on every firing of the scroll listener.
1070 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1071 "unbrokenDownScrollDistance": 0,
1072 "unbrokenUpScrollDistance": 0,
1073 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1074 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1075 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1077 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1080 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
1081 // otherwise, show it.
1082 function updatePostNavUIVisibility() {
1083 GWLog("updatePostNavUIVisibility");
1084 var hidePostNavUIToggle = true;
1085 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1086 if (getComputedStyle(element).visibility == "visible" ||
1087 element.style.visibility == "visible" ||
1088 element.style.visibility == "unset")
1089 hidePostNavUIToggle = false;
1091 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1092 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1096 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1097 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
1098 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1099 function updateSiteNavUIState(event) {
1100 GWLog("updateSiteNavUIState");
1101 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1102 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
1103 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
1105 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1106 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1108 GW.scrollState.lastScrollTop = newScrollTop;
1110 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1111 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1112 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1113 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1116 // On mobile, make site nav UI translucent on ANY scroll down.
1118 GW.scrollState.siteNavUIElements.forEach(element => {
1119 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1120 else element.removeClass("translucent-on-scroll");
1123 // Show site nav UI when scrolling a full page up, or to the top.
1124 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
1125 GW.scrollState.lastScrollTop == 0) &&
1126 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
1127 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1129 // On desktop, show appearance adjust UI when scrolling to the top.
1130 if ((!GW.isMobile) &&
1131 (GW.scrollState.lastScrollTop == 0) &&
1132 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
1133 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1136 GW.themeUnloadCallback_less = (toTheme = "") => {
1137 GWLog("themeUnloadCallback_less");
1138 removeSiteNavUIToggle();
1140 removePostNavUIToggle();
1141 removeAppearanceAdjustUIToggle();
1143 window.removeEventListener('resize', updatePostNavUIVisibility);
1145 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1147 removeElement("#theme-less-mobile-first-row-placeholder");
1151 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1152 element.innerHTML = element.firstChild.innerHTML;
1156 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1158 // Reset filtered elements selector to default.
1159 delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1160 applyFilters(GW.currentFilters);
1163 GW.themeLoadCallback_dark = (fromTheme = "") => {
1164 GWLog("themeLoadCallback_dark");
1165 query("head").insertAdjacentHTML("beforeend",
1166 "<style id='dark-theme-adjustments'>" +
1167 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` +
1168 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1170 registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1171 queryAll("#images-overlay img").forEach(image => {
1172 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)";
1173 image.style.width = parseInt(image.style.width) + 12 + "px";
1174 image.style.height = parseInt(image.style.height) + 12 + "px";
1175 image.style.top = parseInt(image.style.top) - 6 + "px";
1176 image.style.left = parseInt(image.style.left) - 6 + "px";
1180 GW.themeUnloadCallback_dark = (toTheme = "") => {
1181 GWLog("themeUnloadCallback_dark");
1182 removeElement("#dark-theme-adjustments");
1185 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1186 GWLog("themeLoadCallback_brutalist");
1187 let bottomBarLinks = queryAll("#bottom-bar a");
1188 if (!GW.isMobile && bottomBarLinks.length == 5) {
1189 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1190 bottomBarLinks.forEach((link, i) => {
1191 link.dataset.originalText = link.textContent;
1192 link.textContent = newLinkTexts[i];
1196 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1197 GWLog("themeUnloadCallback_brutalist");
1198 let bottomBarLinks = queryAll("#bottom-bar a");
1199 if (!GW.isMobile && bottomBarLinks.length == 5) {
1200 bottomBarLinks.forEach(link => {
1201 link.textContent = link.dataset.originalText;
1206 GW.themeLoadCallback_classic = (fromTheme = "") => {
1207 GWLog("themeLoadCallback_classic");
1208 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1209 button.innerHTML = "";
1212 GW.themeUnloadCallback_classic = (toTheme = "") => {
1213 GWLog("themeUnloadCallback_classic");
1214 if (GW.isMobile && window.innerWidth <= 900) return;
1215 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1216 button.innerHTML = button.dataset.label;
1220 /********************************************/
1221 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1222 /********************************************/
1224 function injectThemeTweaker() {
1225 GWLog("injectThemeTweaker");
1226 let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" +
1227 `<div class='main-theme-tweaker-window'>
1228 <h1>Customize appearance</h1>
1229 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1230 <button type='button' class='help-button' tabindex='-1'></button>
1231 <p class='current-theme'>Current theme: <span>` +
1232 (readCookie("theme") || "default") +
1234 <p class='theme-selector'></p>
1235 <div class='controls-container'>
1236 <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1237 <div class='sample-text-container'><span class='sample-text'>
1238 <p>Less Wrong (text)</p>
1239 <p><a href="#">Less Wrong (link)</a></p>
1242 <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1243 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1244 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1245 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1247 <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1248 <input type='checkbox' id='theme-tweak-control-invert'></input>
1249 <label for='theme-tweak-control-invert'>Invert colors</label>
1251 <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1252 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1253 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1254 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1256 <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1257 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1258 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1259 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1261 <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1262 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1263 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1264 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1266 <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1267 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1268 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1269 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1272 <div class='buttons-container'>
1273 <button type="button" class="reset-defaults-button">Reset to defaults</button>
1274 <button type='button' class='ok-button default-button'>OK</button>
1275 <button type='button' class='cancel-button'>Cancel</button>
1278 <div class="clippy-container">
1279 <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>)
1280 <div class='clippy'></div>
1281 <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>
1283 <div class='help-window' style='display: none;'>
1284 <h1>Theme tweaker help</h1>
1285 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1286 <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1287 <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1289 <div class='buttons-container'>
1290 <button type='button' class='ok-button default-button'>OK</button>
1291 <button type='button' class='cancel-button'>Cancel</button>
1296 // Clicking the background overlay closes the theme tweaker.
1297 themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1298 if (event.type == 'mousedown') {
1299 themeTweakerUI.style.opacity = "0.01";
1301 toggleThemeTweakerUI();
1302 themeTweakerUI.style.opacity = "1.0";
1307 // Intercept clicks, so they don't "fall through" the background overlay.
1308 (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1310 let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1311 themeTweakerUI.queryAll("input").forEach(field => {
1312 // All input types in the theme tweaker receive a 'change' event when
1313 // their value is changed. (Range inputs, in particular, receive this
1314 // event when the user lets go of the handle.) This means we should
1315 // update the filters for the entire page, to match the new setting.
1316 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1317 if (event.target.id == 'theme-tweak-control-invert') {
1318 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1319 } else if (event.target.type == 'range') {
1320 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1321 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1322 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1323 } else if (event.target.id == 'theme-tweak-control-clippy') {
1324 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1326 // Clear the sample text filters.
1327 sampleTextContainer.style.filter = "";
1328 // Apply the new filters globally.
1329 applyFilters(GW.currentFilters);
1332 // Range inputs receive an 'input' event while being scrubbed, updating
1333 // "live" as the handle is moved. We don't want to change the filters
1334 // for the actual page while this is happening, but we do want to change
1335 // the filters for the *sample text*, so the user can see what effects
1336 // his changes are having, live, without having to let go of the handle.
1337 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1338 var sampleTextFilters = GW.currentFilters;
1340 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1341 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1342 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1344 sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1348 themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1349 let themeTweakerStyle = query("#theme-tweaker-style");
1351 if (event.target.hasClass("minimize")) {
1352 event.target.removeClass("minimize");
1353 themeTweakerStyle.innerHTML =
1354 `#theme-tweaker-ui .main-theme-tweaker-window {
1358 padding: 30px 0 0 0;
1363 #theme-tweaker-ui::after {
1367 #theme-tweaker-ui::before {
1371 #theme-tweaker-ui .clippy-container {
1374 #theme-tweaker-ui .clippy-container .hint span {
1380 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1381 pointer-events: none;
1383 event.target.addClass("maximize");
1385 event.target.removeClass("maximize");
1386 themeTweakerStyle.innerHTML =
1387 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1388 pointer-events: none;
1390 event.target.addClass("minimize");
1393 themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1394 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1395 toggleThemeTweakerHelpWindow();
1397 themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1398 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1399 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1400 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1401 slider.value = slider.dataset['defaultValue'];
1402 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1404 GW.currentFilters = { };
1405 applyFilters(GW.currentFilters);
1407 GW.currentTextZoom = "1.0";
1408 setTextZoom(GW.currentTextZoom);
1410 setSelectedTheme("default");
1412 themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1413 toggleThemeTweakerUI();
1416 themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1417 toggleThemeTweakerUI();
1420 themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1421 toggleThemeTweakerHelpWindow();
1422 themeTweakerResetSettings();
1424 themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1425 toggleThemeTweakerHelpWindow();
1426 themeTweakerSaveSettings();
1429 themeTweakerUI.queryAll(".notch").forEach(notch => {
1430 notch.addActivateEvent(function (event) {
1431 let slider = event.target.parentElement.query("input[type='range']");
1432 slider.value = slider.dataset['defaultValue'];
1433 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1434 GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1435 applyFilters(GW.currentFilters);
1439 themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1440 themeTweakerUI.query(".clippy-container").style.display = "none";
1441 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1442 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1445 query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1447 themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1448 themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1449 button.addActivateEvent(GW.themeSelectButtonClicked);
1452 themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1453 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1456 let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'></button></div>`);
1457 themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1458 GW.themeTweakerStyleSheetAvailable = () => {
1459 themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1461 themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1462 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1463 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1464 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1465 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1468 toggleThemeTweakerUI();
1469 event.target.disabled = true;
1472 if (query("link[href^='/theme_tweaker.css']")) {
1473 GW.themeTweakerStyleSheetAvailable();
1475 // Load the theme tweaker CSS (if not loaded).
1476 let themeTweakerStyleSheet = document.createElement('link');
1477 themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1478 themeTweakerStyleSheet.setAttribute('href', '/theme_tweaker.css');
1479 themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1480 query("head").appendChild(themeTweakerStyleSheet);
1484 function toggleThemeTweakerUI() {
1485 GWLog("toggleThemeTweakerUI");
1486 let themeTweakerUI = query("#theme-tweaker-ui");
1487 themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1488 query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" :
1489 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1490 pointer-events: none;
1492 if (themeTweakerUI.style.display != "none") {
1493 // Save selected theme.
1494 GW.currentTheme = (readCookie("theme") || "default");
1495 // Focus invert checkbox.
1496 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1497 // Show sample text in appropriate font.
1498 updateThemeTweakerSampleText();
1499 // Disable tab-selection of the search box.
1500 setSearchBoxTabSelectable(false);
1502 query("#theme-tweaker-toggle button").disabled = false;
1503 // Re-enable tab-selection of the search box.
1504 setSearchBoxTabSelectable(true);
1506 // Set theme tweaker assistant visibility.
1507 query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1509 function setSearchBoxTabSelectable(selectable) {
1510 GWLog("setSearchBoxTabSelectable");
1511 query("input[type='search']").tabIndex = selectable ? "" : "-1";
1512 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1514 function toggleThemeTweakerHelpWindow() {
1515 GWLog("toggleThemeTweakerHelpWindow");
1516 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1517 themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1518 if (themeTweakerHelpWindow.style.display != "none") {
1519 // Focus theme tweaker assistant checkbox.
1520 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1521 // Disable interaction on main theme tweaker window.
1522 query("#theme-tweaker-ui").style.pointerEvents = "none";
1523 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1525 // Re-enable interaction on main theme tweaker window.
1526 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1527 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1530 function themeTweakReset() {
1531 GWLog("themeTweakReset");
1532 setSelectedTheme(GW.currentTheme);
1533 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1534 applyFilters(GW.currentFilters);
1535 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1536 setTextZoom(GW.currentTextZoom);
1538 function themeTweakSave() {
1539 GWLog("themeTweakSave");
1540 GW.currentTheme = (readCookie("theme") || "default");
1541 localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1542 localStorage.setItem("text-zoom", GW.currentTextZoom);
1545 function themeTweakerResetSettings() {
1546 GWLog("themeTweakerResetSettings");
1547 query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1548 query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1550 function themeTweakerSaveSettings() {
1551 GWLog("themeTweakerSaveSettings");
1552 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1554 function updateThemeTweakerSampleText() {
1555 GWLog("updateThemeTweakerSampleText");
1556 let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1558 // This causes the sample text to take on the properties of the body text of a post.
1559 sampleText.removeClass("post-body");
1560 let bodyTextElement = query(".post-body") || query(".comment-body");
1561 sampleText.addClass("post-body");
1562 sampleText.style.color = bodyTextElement ?
1563 getComputedStyle(bodyTextElement).color :
1564 getComputedStyle(query("#content")).color;
1566 // Here we find out what is the actual background color that will be visible behind
1567 // the body text of posts, and set the sample text’s background to that.
1568 var backgroundElement = query("#content");
1569 let searchField = query("#nav-item-search input");
1570 if (!(getComputedStyle(searchField).backgroundColor == "" ||
1571 getComputedStyle(searchField).backgroundColor == "rgba(0, 0, 0, 0)"))
1572 backgroundElement = searchField;
1573 else while (getComputedStyle(backgroundElement).backgroundColor == "" ||
1574 getComputedStyle(backgroundElement).backgroundColor == "rgba(0, 0, 0, 0)")
1575 backgroundElement = backgroundElement.parentElement;
1576 sampleText.parentElement.style.backgroundColor = getComputedStyle(backgroundElement).backgroundColor;
1579 /*********************/
1580 /* PAGE QUICK-NAV UI */
1581 /*********************/
1583 function injectQuickNavUI() {
1584 GWLog("injectQuickNavUI");
1585 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1586 `<a href='#top' title="Up to top [,]" accesskey=','></a>
1587 <a href='#comments' title="Comments [/]" accesskey='/'></a>
1588 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
1592 /**********************/
1593 /* NEW COMMENT NAV UI */
1594 /**********************/
1596 function injectNewCommentNavUI(newCommentsCount) {
1597 GWLog("injectNewCommentNavUI");
1598 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
1599 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
1600 <span class='new-comments-count'></span>
1601 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
1604 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1605 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1606 scrollToNewComment(/next/.test(event.target.className));
1607 event.target.blur();
1611 document.addEventListener("keyup", (event) => {
1612 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1613 if (event.key == ",") scrollToNewComment(false);
1614 if (event.key == ".") scrollToNewComment(true)
1617 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1618 + `<span>Since:</span>`
1619 + `<input type='text' class='hns-date'></input>`
1622 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1623 let hnsDate = time_fromHuman(event.target.value);
1624 let newCommentsCount = highlightCommentsSince(hnsDate);
1625 updateNewCommentNavUI(newCommentsCount);
1628 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1629 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1630 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1634 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1635 function time_fromHuman(string) {
1636 /* Convert a human-readable date into a JS timestamp */
1637 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1638 string = string.replace(' ', 'T'); // revert nice spacing
1639 string += ':00.000Z'; // complete ISO 8601 date
1640 time = Date.parse(string); // milliseconds since epoch
1642 // browsers handle ISO 8601 without explicit timezone differently
1643 // thus, we have to fix that by hand
1644 time += (new Date()).getTimezoneOffset() * 60e3;
1646 string = string.replace(' at', '');
1647 time = Date.parse(string); // milliseconds since epoch
1652 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1653 GWLog("updateNewCommentNavUI");
1654 // Update the new comments count.
1655 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1656 newCommentsCountLabel.innerText = newCommentsCount;
1657 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1659 // Update the date picker field.
1660 if (hnsDate != -1) {
1661 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1665 /***************************/
1666 /* TEXT SIZE ADJUSTMENT UI */
1667 /***************************/
1669 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1670 GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1671 var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1672 if (event.target.hasClass("decrease")) {
1673 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1674 } else if (event.target.hasClass("increase")) {
1675 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1679 setTextZoom(zoomFactor);
1680 GW.currentTextZoom = `${zoomFactor}`;
1682 if (event.target.parentElement.id == "text-size-adjustment-ui") {
1683 localStorage.setItem("text-zoom", GW.currentTextZoom);
1687 function injectTextSizeAdjustmentUIReal() {
1688 GWLog("injectTextSizeAdjustmentUIReal");
1689 let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1690 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1691 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1692 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1695 textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1696 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1699 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1702 function injectTextSizeAdjustmentUI() {
1703 GWLog("injectTextSizeAdjustmentUI");
1704 if (query("#text-size-adjustment-ui") != null) return;
1705 if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1706 else document.addEventListener("DOMContentLoaded", () => {
1707 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1711 /********************************/
1712 /* COMMENTS VIEW MODE SELECTION */
1713 /********************************/
1715 function injectCommentsViewModeSelector() {
1716 GWLog("injectCommentsViewModeSelector");
1717 let commentsContainer = query("#comments");
1718 if (commentsContainer == null) return;
1720 let currentModeThreaded = (location.href.search("chrono=t") == -1);
1721 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1723 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1724 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
1725 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
1728 // commentsViewModeSelector.queryAll("a").forEach(button => {
1729 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1732 if (!currentModeThreaded) {
1733 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1734 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1735 commentParentLink.addClass("inline-author");
1736 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1739 queryAll(".comment-child-links a").forEach(commentChildLink => {
1740 commentChildLink.textContent = commentChildLink.textContent.slice(1);
1741 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
1744 rectifyChronoModeCommentChildLinks();
1746 commentsContainer.addClass("chrono");
1748 commentsContainer.addClass("threaded");
1751 // Remove extraneous top-level comment thread in chrono mode.
1752 let topLevelCommentThread = query("#comments > .comment-thread");
1753 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
1756 // function commentsViewModeSelectorButtonClicked(event) {
1757 // event.preventDefault();
1760 // let request = new XMLHttpRequest();
1761 // request.open("GET", event.target.href);
1762 // request.onreadystatechange = () => {
1763 // if (request.readyState != 4) return;
1764 // newDocument = htmlToElement(request.response);
1766 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
1768 // // Update the buttons.
1769 // event.target.addClass("selected");
1770 // event.target.parentElement.query("." + classes.old).removeClass("selected");
1772 // // Update the #comments container.
1773 // let commentsContainer = query("#comments");
1774 // commentsContainer.removeClass(classes.old);
1775 // commentsContainer.addClass(classes.new);
1777 // // Update the content.
1778 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
1783 // function htmlToElement(html) {
1784 // var template = document.createElement('template');
1785 // template.innerHTML = html.trim();
1786 // return template.content;
1789 function rectifyChronoModeCommentChildLinks() {
1790 GWLog("rectifyChronoModeCommentChildLinks");
1791 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
1792 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
1793 let childLinks = commentChildLinksContainer.queryAll("a");
1794 childLinks.forEach((link, index) => {
1795 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
1799 let childLinksArray = Array.from(childLinks)
1800 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
1801 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
1804 function childrenOfComment(commentID) {
1805 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
1806 let commentParentLink = commentItem.query("a.comment-parent-link");
1807 return ((commentParentLink||{}).hash == "#" + commentID);
1811 /********************************/
1812 /* COMMENTS LIST MODE SELECTION */
1813 /********************************/
1815 function injectCommentsListModeSelector() {
1816 GWLog("injectCommentsListModeSelector");
1817 if (query("#content > .comment-thread") == null) return;
1819 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
1820 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
1821 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
1823 (query("#content.user-page .user-stats") || query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
1824 let commentsListModeSelector = query("#comments-list-mode-selector");
1826 commentsListModeSelector.queryAll("button").forEach(button => {
1827 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
1828 event.target.parentElement.queryAll("button").forEach(button => {
1829 button.removeClass("selected");
1830 button.disabled = false;
1831 button.accessKey = '`';
1833 localStorage.setItem("comments-list-mode", event.target.className);
1834 event.target.addClass("selected");
1835 event.target.disabled = true;
1836 event.target.removeAttribute("accesskey");
1838 if (event.target.hasClass("expanded")) {
1839 query("#content").removeClass("compact");
1841 query("#content").addClass("compact");
1846 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
1847 if (savedMode == "compact")
1848 query("#content").addClass("compact");
1849 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
1850 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
1851 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
1854 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
1855 commentParentLink.addActivateEvent(function (event) {
1856 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
1857 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
1863 /**********************/
1864 /* SITE NAV UI TOGGLE */
1865 /**********************/
1867 function injectSiteNavUIToggle() {
1868 GWLog("injectSiteNavUIToggle");
1869 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
1870 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
1872 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
1875 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
1877 function removeSiteNavUIToggle() {
1878 GWLog("removeSiteNavUIToggle");
1879 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
1880 element.removeClass("engaged");
1882 removeElement("#site-nav-ui-toggle");
1884 function toggleSiteNavUI() {
1885 GWLog("toggleSiteNavUI");
1886 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
1887 element.toggleClass("engaged");
1888 element.removeClass("translucent-on-scroll");
1892 /**********************/
1893 /* POST NAV UI TOGGLE */
1894 /**********************/
1896 function injectPostNavUIToggle() {
1897 GWLog("injectPostNavUIToggle");
1898 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
1899 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
1901 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
1904 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
1906 function removePostNavUIToggle() {
1907 GWLog("removePostNavUIToggle");
1908 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
1909 element.removeClass("engaged");
1911 removeElement("#post-nav-ui-toggle");
1913 function togglePostNavUI() {
1914 GWLog("togglePostNavUI");
1915 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
1916 element.toggleClass("engaged");
1920 /*******************************/
1921 /* APPEARANCE ADJUST UI TOGGLE */
1922 /*******************************/
1924 function injectAppearanceAdjustUIToggle() {
1925 GWLog("injectAppearanceAdjustUIToggle");
1926 let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'></button></div>");
1927 appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
1928 toggleAppearanceAdjustUI();
1929 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
1933 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
1934 themeSelectorCloseButton.addClass("theme-selector-close-button");
1935 themeSelectorCloseButton.innerHTML = "";
1936 query("#theme-selector").appendChild(themeSelectorCloseButton);
1937 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
1939 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
1942 function removeAppearanceAdjustUIToggle() {
1943 GWLog("removeAppearanceAdjustUIToggle");
1944 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
1945 element.removeClass("engaged");
1947 removeElement("#appearance-adjust-ui-toggle");
1949 function toggleAppearanceAdjustUI() {
1950 GWLog("toggleAppearanceAdjustUI");
1951 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
1952 element.toggleClass("engaged");
1956 /*****************************/
1957 /* MINIMIZED THREAD HANDLING */
1958 /*****************************/
1960 function expandAncestorsOf(comment) {
1961 GWLog("expandAncestorsOf");
1962 if (typeof comment == "string") {
1963 comment = /(?:comment-)?(.+)/.exec(comment)[1];
1964 comment = query("#comment-" + comment);
1967 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
1971 // Expand collapsed comment threads.
1972 let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
1973 if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
1975 // Expand collapsed comments.
1976 let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
1977 if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
1980 /**************************/
1981 /* WORD COUNT & READ TIME */
1982 /**************************/
1984 function toggleReadTimeOrWordCount(addWordCountClass) {
1985 GWLog("toggleReadTimeOrWordCount");
1986 queryAll(".post-meta .read-time").forEach(element => {
1987 if (addWordCountClass) element.addClass("word-count");
1988 else element.removeClass("word-count");
1990 let titleParts = /(\S+)(.+)$/.exec(element.title);
1991 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
1995 /**************************/
1996 /* PROMPT TO SAVE CHANGES */
1997 /**************************/
1999 function enableBeforeUnload() {
2000 window.onbeforeunload = function () { return true; };
2002 function disableBeforeUnload() {
2003 window.onbeforeunload = null;
2006 /***************************/
2007 /* ORIGINAL POSTER BADGING */
2008 /***************************/
2010 function markOriginalPosterComments() {
2011 GWLog("markOriginalPosterComments");
2012 let postAuthor = query(".post .author");
2013 if (postAuthor == null) return;
2015 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2016 if (author.dataset.userid == postAuthor.dataset.userid ||
2017 (author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2018 author.addClass("original-poster");
2019 author.title += "Original poster";
2024 /********************************/
2025 /* EDIT POST PAGE SUBMIT BUTTON */
2026 /********************************/
2028 function setEditPostPageSubmitButtonText() {
2029 GWLog("setEditPostPageSubmitButtonText");
2030 if (!query("#content").hasClass("edit-post-page")) return;
2032 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2033 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2034 updateEditPostPageSubmitButtonText();
2038 updateEditPostPageSubmitButtonText();
2040 function updateEditPostPageSubmitButtonText() {
2041 GWLog("updateEditPostPageSubmitButtonText");
2042 let submitButton = query("input[type='submit']");
2043 if (query("input#drafts").checked == true)
2044 submitButton.value = "Save Draft";
2045 else if (query(".posting-controls").hasClass("edit-existing-post"))
2046 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2048 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2055 function numToAlpha(n) {
2058 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2059 n = Math.floor((n / 26) - 1);
2064 function injectAntiKibitzer() {
2065 GWLog("injectAntiKibitzer");
2066 // Inject anti-kibitzer toggle controls.
2067 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>");
2068 antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2069 if (query("#anti-kibitzer-toggle").hasClass("engaged") &&
2071 !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!)")) {
2072 event.target.blur();
2076 toggleAntiKibitzerMode();
2077 event.target.blur();
2080 // Activate anti-kibitzer mode (if needed).
2081 if (localStorage.getItem("antikibitzer") == "true")
2082 toggleAntiKibitzerMode();
2084 // Remove temporary CSS that hides the authors and karma values.
2085 removeElement("#antikibitzer-temp");
2088 function toggleAntiKibitzerMode() {
2089 GWLog("toggleAntiKibitzerMode");
2090 // This will be the URL of the user's own page, if logged in, or the URL of
2091 // the login page otherwise.
2092 let userTabTarget = query("#nav-item-login .nav-inner").href;
2093 let pageHeadingElement = query("h1.page-main-heading");
2096 let userFakeName = { };
2098 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2100 let postAuthor = query(".post-page .post-meta .author");
2101 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2103 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2104 if (antiKibitzerToggle.hasClass("engaged")) {
2105 localStorage.setItem("antikibitzer", "false");
2107 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2108 if (redirectTarget) {
2109 window.location = redirectTarget;
2113 // Individual comment page title and header
2114 if (query(".individual-thread-page")) {
2115 let replacer = (node) => {
2117 node.firstChild.replaceWith(node.dataset["trueContent"]);
2119 replacer(query("title:not(.fake-title)"));
2120 replacer(query("#content > h1"));
2123 // Author names/links.
2124 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2125 author.textContent = author.dataset["trueName"];
2126 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2128 author.removeClass("redacted");
2130 // Post/comment karma values.
2131 queryAll(".karma-value.redacted").forEach(karmaValue => {
2132 karmaValue.innerHTML = karmaValue.dataset["trueValue"] + karmaValue.lastChild.outerHTML;
2133 karmaValue.lastChild.textContent = (parseInt(karmaValue.dataset["trueValue"]) == 1) ? " point" : " points";
2135 karmaValue.removeClass("redacted");
2137 // Link post domains.
2138 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2139 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2141 linkPostDomain.removeClass("redacted");
2144 antiKibitzerToggle.removeClass("engaged");
2146 localStorage.setItem("antikibitzer", "true");
2148 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2149 if (redirectTarget) {
2150 window.location = redirectTarget;
2154 // Individual comment page title and header
2155 if (query(".individual-thread-page")) {
2156 let replacer = (node) => {
2158 node.dataset["trueContent"] = node.firstChild.wholeText;
2159 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2160 node.firstChild.replaceWith(newText);
2162 replacer(query("title:not(.fake-title)"));
2163 replacer(query("#content > h1"));
2166 removeElement("title.fake-title");
2168 // Author names/links.
2169 queryAll(".author, .inline-author").forEach(author => {
2170 // Skip own posts/comments.
2171 if (author.hasClass("own-user-author"))
2174 let userid = author.dataset["userid"] || query(`${author.hash} .author`).dataset["userid"];
2176 author.dataset["trueName"] = author.textContent;
2177 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2179 if (/\/user/.test(author.href)) {
2180 author.dataset["trueLink"] = author.pathname;
2181 author.href = "/user?id=" + author.dataset["userid"];
2184 author.addClass("redacted");
2186 // Post/comment karma values.
2187 queryAll(".karma-value").forEach(karmaValue => {
2188 // Skip own posts/comments.
2189 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2192 karmaValue.dataset["trueValue"] = karmaValue.firstChild.textContent;
2193 karmaValue.innerHTML = "##" + karmaValue.lastChild.outerHTML;
2194 karmaValue.lastChild.textContent = " points";
2196 karmaValue.addClass("redacted");
2198 // Link post domains.
2199 queryAll(".link-post-domain").forEach(linkPostDomain => {
2200 // Skip own posts/comments.
2201 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2204 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2205 linkPostDomain.textContent = "redacted.domain.tld";
2207 linkPostDomain.addClass("redacted");
2210 antiKibitzerToggle.addClass("engaged");
2214 /*******************************/
2215 /* COMMENT SORT MODE SELECTION */
2216 /*******************************/
2218 var CommentSortMode = Object.freeze({
2224 function sortComments(mode) {
2225 GWLog("sortComments");
2226 let commentsContainer = query("#comments");
2228 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2229 commentsContainer.addClass("sorting");
2231 GW.commentValues = { };
2232 let clonedCommentsContainer = commentsContainer.cloneNode(true);
2233 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2236 case CommentSortMode.NEW:
2237 comparator = (a,b) => commentDate(b) - commentDate(a);
2239 case CommentSortMode.OLD:
2240 comparator = (a,b) => commentDate(a) - commentDate(b);
2242 case CommentSortMode.HOT:
2243 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2245 case CommentSortMode.TOP:
2247 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2250 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2252 removeElement(commentsContainer.lastChild);
2253 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2254 GW.commentValues = { };
2256 if (loggedInUserId) {
2257 // Re-activate vote buttons.
2258 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2259 voteButton.addActivateEvent(voteButtonClicked);
2262 // Re-activate comment action buttons.
2263 commentsContainer.queryAll(".action-button").forEach(button => {
2264 button.addActivateEvent(GW.commentActionButtonClicked);
2268 // Re-activate comment-minimize buttons.
2269 queryAll(".comment-minimize-button").forEach(button => {
2270 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2273 // Re-add comment parent popups.
2274 addCommentParentPopups();
2276 // Redo new-comments highlighting.
2277 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2279 requestAnimationFrame(() => {
2280 commentsContainer.removeClass("sorting");
2281 commentsContainer.addClass("sorted-" + mode);
2284 function commentKarmaValue(commentOrSelector) {
2285 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2286 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2288 function commentDate(commentOrSelector) {
2289 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2290 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2292 function commentVoteCount(commentOrSelector) {
2293 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2294 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2297 function injectCommentsSortModeSelector() {
2298 GWLog("injectCommentsSortModeSelector");
2299 let topCommentThread = query("#comments > .comment-thread");
2300 if (topCommentThread == null) return;
2302 // Do not show sort mode selector if there is no branching in comment tree.
2303 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2305 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
2306 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
2308 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2309 let commentsSortModeSelector = query("#comments-sort-mode-selector");
2311 commentsSortModeSelector.queryAll("button").forEach(button => {
2312 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2313 event.target.parentElement.queryAll("button").forEach(button => {
2314 button.removeClass("selected");
2315 button.disabled = false;
2317 event.target.addClass("selected");
2318 event.target.disabled = true;
2320 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2321 setCommentsSortModeSelectButtonsAccesskey();
2325 // TODO: Make this actually get the current sort mode (if that's saved).
2326 // TODO: Also change the condition here to properly get chrono/threaded mode,
2327 // when that is properly done with cookies.
2328 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2329 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2330 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2331 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2332 setCommentsSortModeSelectButtonsAccesskey();
2335 function setCommentsSortModeSelectButtonsAccesskey() {
2336 GWLog("setCommentsSortModeSelectButtonsAccesskey");
2337 queryAll("#comments-sort-mode-selector button").forEach(button => {
2338 button.removeAttribute("accesskey");
2339 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2341 let selectedButton = query("#comments-sort-mode-selector button.selected");
2342 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2343 nextButtonInCycle.accessKey = "z";
2344 nextButtonInCycle.title += " [z]";
2347 /*************************/
2348 /* COMMENT PARENT POPUPS */
2349 /*************************/
2351 function addCommentParentPopups() {
2352 GWLog("addCommentParentPopups");
2353 if (!query("#content").hasClass("comment-thread-page")) return;
2355 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2356 commentParentLink.addEventListener("mouseover", GW.commentParentLinkMouseOver = (event) => {
2357 let parentID = commentParentLink.getAttribute("href");
2359 if (!(parent = (query(parentID)||{}).firstChild)) return;
2360 var highlightClassName;
2361 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2362 parentHighlightClassName = "comment-item-highlight-faint";
2363 popup = parent.cloneNode(true);
2364 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2365 commentParentLink.addEventListener("mouseout", (event) => {
2366 removeElement(popup);
2368 commentParentLink.closest(".comments > .comment-thread").appendChild(popup);
2370 parentHighlightClassName = "comment-item-highlight";
2372 parent.parentNode.addClass(parentHighlightClassName);
2373 commentParentLink.addEventListener("mouseout", (event) => {
2374 parent.parentNode.removeClass(parentHighlightClassName);
2379 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2380 GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2381 "#content .comments .comment-thread"
2383 applyFilters(GW.currentFilters);
2390 function imageFocusSetup(imagesOverlayOnly = false) {
2391 GWLog("imageFocusSetup");
2392 // Create event listener for clicking on images to focus them.
2393 GW.imageClickedToFocus = (event) => {
2394 focusImage(event.target);
2396 if (event.target.closest("#images-overlay")) {
2397 query("#image-focus-overlay .image-number").textContent = (getIndexOfFocusedImage() + 1);
2399 // Set timer to hide the image focus UI.
2400 resetImageFocusHideUITimer(true);
2403 // Add the listener to each image in the overlay (i.e., those in the post).
2404 queryAll("#images-overlay img").forEach(image => {
2405 image.addActivateEvent(GW.imageClickedToFocus);
2407 // Accesskey-L starts the slideshow.
2408 (query("#images-overlay img")||{}).accessKey = 'l';
2409 // Count how many images there are in the post, and set the "… of X" label to that.
2410 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll("#images-overlay img").length;
2411 if (imagesOverlayOnly) return;
2412 // Add the listener to all other content images (including those in comments).
2413 queryAll("#content img").forEach(image => {
2414 image.addActivateEvent(GW.imageClickedToFocus);
2417 // Create the image focus overlay.
2418 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
2419 `<div class='help-overlay'>
2420 <p><strong>Arrow keys:</strong> Next/previous image</p>
2421 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2422 <p><strong>Space bar:</strong> Reset image size & position</p>
2423 <p><strong>Scroll</strong> to zoom in/out</p>
2424 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2426 `<div class='image-number'></div>` +
2427 `<div class='slideshow-buttons'>
2428 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
2429 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
2432 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2434 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2435 button.addActivateEvent(GW.imageFocusSlideshowButtonClicked = (event) => {
2436 focusNextImage(event.target.hasClass("next"));
2437 event.target.blur();
2441 // On orientation change, reset the size & position.
2442 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2443 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2446 // UI starts out hidden.
2450 function focusImage(imageToFocus) {
2451 GWLog("focusImage");
2452 // Clear 'last-focused' class of last focused image.
2453 let lastFocusedImage = query("img.last-focused");
2454 if (lastFocusedImage) {
2455 lastFocusedImage.removeClass("last-focused");
2456 lastFocusedImage.removeAttribute("accesskey");
2459 // Create the focused version of the image.
2460 imageToFocus.addClass("focused");
2461 let imageFocusOverlay = query("#image-focus-overlay");
2462 let clonedImage = imageToFocus.cloneNode(true);
2463 clonedImage.style = "";
2464 clonedImage.removeAttribute("width");
2465 clonedImage.removeAttribute("height");
2466 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2467 imageFocusOverlay.appendChild(clonedImage);
2468 imageFocusOverlay.addClass("engaged");
2470 // Set image to default size and position.
2471 resetFocusedImagePosition();
2473 // Blur everything else.
2474 queryAll("#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay").forEach(element => {
2475 element.addClass("blurred");
2478 // Add listener to zoom image with scroll wheel.
2479 window.addEventListener("wheel", GW.imageFocusScroll = (event) => {
2480 event.preventDefault();
2482 let image = query("#image-focus-overlay img");
2484 // Remove the filter.
2485 image.savedFilter = image.style.filter;
2486 image.style.filter = 'none';
2488 // Locate point under cursor.
2489 let imageBoundingBox = image.getBoundingClientRect();
2491 // Calculate resize factor.
2492 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2493 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2497 image.style.width = (event.deltaY < 0 ?
2498 (image.clientWidth * factor) :
2499 (image.clientWidth / factor))
2501 image.style.height = "";
2503 // Designate zoom origin.
2505 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2506 // the cursor is over the image.
2507 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2508 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2509 (imageBoundingBox.left <= event.clientX &&
2510 event.clientX <= imageBoundingBox.right &&
2511 imageBoundingBox.top <= event.clientY &&
2512 event.clientY <= imageBoundingBox.bottom);
2513 // Otherwise, if we're zooming OUT, zoom from window center; if we're
2514 // zooming IN, zoom from image center.
2515 let zoomingFromWindowCenter = event.deltaY > 0;
2516 if (zoomingFromCursor)
2517 zoomOrigin = { x: event.clientX,
2519 else if (zoomingFromWindowCenter)
2520 zoomOrigin = { x: window.innerWidth / 2,
2521 y: window.innerHeight / 2 };
2523 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
2524 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
2526 // Calculate offset from zoom origin.
2527 let offsetOfImageFromZoomOrigin = {
2528 x: imageBoundingBox.x - zoomOrigin.x,
2529 y: imageBoundingBox.y - zoomOrigin.y
2531 // Calculate delta from centered zoom.
2532 let deltaFromCenteredZoom = {
2533 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
2534 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
2536 // Adjust image position appropriately.
2537 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
2538 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
2539 // Gradually re-center image, if it's smaller than the window.
2540 if (!imageSizeExceedsWindowBounds) {
2541 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
2542 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
2543 let windowCenter = { x: window.innerWidth / 2,
2544 y: window.innerHeight / 2 }
2545 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
2546 y: windowCenter.y - imageCenter.y }
2547 // Divide the offset by 10 because we're nudging the image toward center,
2548 // not jumping it there.
2549 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
2550 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
2553 // Put the filter back.
2554 image.style.filter = image.savedFilter;
2556 // Set the cursor appropriately.
2557 setFocusedImageCursor();
2559 window.addEventListener("MozMousePixelScroll", GW.imageFocusOldFirefoxCompatibilityScrollEventFired = (event) => {
2560 event.preventDefault();
2563 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
2564 window.addEventListener("mouseup", GW.imageFocusMouseUp = (event) => {
2565 window.onmousemove = '';
2567 // We only want to do anything on left-clicks.
2568 if (event.button != 0) return;
2570 if (event.target.hasClass("slideshow-button")) {
2571 resetImageFocusHideUITimer(false);
2575 let focusedImage = query("#image-focus-overlay img");
2577 if (event.target != focusedImage) {
2578 unfocusImageOverlay();
2582 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
2583 // Put the filter back.
2584 focusedImage.style.filter = focusedImage.savedFilter;
2586 unfocusImageOverlay();
2589 window.addEventListener("mousedown", GW.imageFocusMouseDown = (event) => {
2590 event.preventDefault();
2592 let focusedImage = query("#image-focus-overlay img");
2593 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
2594 let mouseCoordX = event.clientX;
2595 let mouseCoordY = event.clientY;
2597 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
2598 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
2601 focusedImage.savedFilter = focusedImage.style.filter;
2603 window.onmousemove = (event) => {
2604 // Remove the filter.
2605 focusedImage.style.filter = "none";
2606 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
2607 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
2613 // Double-click unfocuses, always.
2614 window.addEventListener('dblclick', GW.imageFocusDoubleClick = (event) => {
2615 if (event.target.hasClass("slideshow-button")) return;
2617 unfocusImageOverlay();
2620 // Escape key unfocuses, spacebar resets.
2621 document.addEventListener("keyup", GW.imageFocusKeyUp = (event) => {
2622 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
2623 if (!allowedKeys.contains(event.key) ||
2624 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
2626 event.preventDefault();
2628 switch (event.key) {
2631 unfocusImageOverlay();
2635 resetFocusedImagePosition();
2641 if (query("#images-overlay img.focused")) focusNextImage(true);
2647 if (query("#images-overlay img.focused")) focusNextImage(false);
2652 // Prevent spacebar or arrow keys from scrolling page when image focused.
2653 document.addEventListener("keydown", GW.imageFocusKeyDown = (event) => {
2654 let disabledKeys = [ " ", "Spacebar", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
2655 if (disabledKeys.contains(event.key))
2656 event.preventDefault();
2659 if (imageToFocus.closest("#images-overlay")) {
2660 // Set state of next/previous buttons.
2661 let images = queryAll("#images-overlay img");
2662 var indexOfFocusedImage = getIndexOfFocusedImage();
2663 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
2664 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
2666 // Moving mouse unhides image focus UI.
2667 window.addEventListener("mousemove", GW.imageFocusMouseMoved = (event) => {
2668 let restartTimer = (event.target.tagName == "IMG" || event.target.id == "image-focus-overlay");
2669 resetImageFocusHideUITimer(restartTimer);
2672 // Replace the hash.
2673 history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
2677 function resetFocusedImagePosition() {
2678 GWLog("resetFocusedImagePosition");
2679 let focusedImage = query("#image-focus-overlay img");
2680 if (!focusedImage) return;
2682 // Reset modifications to size.
2683 focusedImage.style.width = "";
2684 focusedImage.style.height = "";
2686 // Make sure that initially, the image fits into the viewport.
2687 let shrinkRatio = 0.975;
2688 focusedImage.style.width = Math.min(focusedImage.clientWidth, window.innerWidth * shrinkRatio) + 'px';
2689 let maxImageHeight = window.innerHeight * shrinkRatio;
2690 if (focusedImage.clientHeight > maxImageHeight) {
2691 focusedImage.style.height = maxImageHeight + 'px';
2692 focusedImage.style.width = "";
2695 // Remove modifications to position.
2696 focusedImage.style.left = "";
2697 focusedImage.style.top = "";
2699 // Set the cursor appropriately.
2700 setFocusedImageCursor();
2702 function setFocusedImageCursor() {
2703 let focusedImage = query("#image-focus-overlay img");
2704 if (!focusedImage) return;
2705 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
2709 function unfocusImageOverlay() {
2710 GWLog("unfocusImageOverlay");
2711 // Set accesskey of currently focused image (if it's in the images overlay).
2712 let currentlyFocusedImage = query("#images-overlay img.focused");
2713 if (currentlyFocusedImage) {
2714 currentlyFocusedImage.addClass("last-focused");
2715 currentlyFocusedImage.accessKey = 'l';
2718 // Remove focused image and hide overlay.
2719 let imageFocusOverlay = query("#image-focus-overlay");
2720 imageFocusOverlay.removeClass("engaged");
2721 removeElement(imageFocusOverlay.query("img"));
2723 // Un-blur content/etc.
2724 queryAll("#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay").forEach(element => {
2725 element.removeClass("blurred");
2728 // Unset "focused" class of focused image.
2729 queryAll("#content img.focused, #images-overlay img.focused").forEach(image => {
2730 image.removeClass("focused");
2733 // Remove event listeners.
2734 window.removeEventListener("wheel", GW.imageFocusScroll);
2735 window.removeEventListener("MozMousePixelScroll", GW.imageFocusOldFirefoxCompatibilityScrollEventFired);
2736 window.removeEventListener("dblclick", GW.imageFocusDoubleClick);
2737 document.removeEventListener("keyup", GW.imageFocusKeyUp);
2738 document.removeEventListener("keydown", GW.imageFocusKeyDown);
2739 window.removeEventListener("mousemove", GW.imageFocusMouseMoved);
2740 window.removeEventListener("mousedown", GW.imageFocusMouseDown);
2742 // Reset the hash, if needed.
2743 if (location.hash.hasPrefix("#if_slide_"))
2744 history.replaceState(null, null, "#");
2747 function getIndexOfFocusedImage() {
2748 let images = queryAll("#images-overlay img");
2749 var indexOfFocusedImage = -1;
2750 for (i = 0; i < images.length; i++) {
2751 if (images[i].hasClass("focused")) {
2752 indexOfFocusedImage = i;
2756 return indexOfFocusedImage;
2759 function focusNextImage(next = true) {
2760 GWLog("focusNextImage");
2761 let images = queryAll("#images-overlay img");
2762 var indexOfFocusedImage = getIndexOfFocusedImage();
2764 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
2766 // Remove existing image.
2767 removeElement("#image-focus-overlay img");
2768 // Unset "focused" class of just-removed image.
2769 queryAll("#content img.focused, #images-overlay img.focused").forEach(image => {
2770 image.removeClass("focused");
2773 // Create the focused version of the image.
2774 images[indexOfFocusedImage].addClass("focused");
2775 let imageFocusOverlay = query("#image-focus-overlay");
2776 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
2777 clonedImage.style = "";
2778 clonedImage.removeAttribute("width");
2779 clonedImage.removeAttribute("height");
2780 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
2781 imageFocusOverlay.appendChild(clonedImage);
2782 imageFocusOverlay.addClass("engaged");
2783 // Set image to default size and position.
2784 resetFocusedImagePosition();
2785 // Set state of next/previous buttons.
2786 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
2787 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
2788 // Set the image number display.
2789 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
2790 // Replace the hash.
2791 history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
2794 function hideImageFocusUI() {
2795 let imageFocusOverlay = query("#image-focus-overlay");
2796 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number").forEach(element => {
2797 element.addClass("hidden");
2801 function unhideImageFocusUI() {
2802 let imageFocusOverlay = query("#image-focus-overlay");
2803 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number").forEach(element => {
2804 element.removeClass("hidden");
2808 function resetImageFocusHideUITimer(restart) {
2809 if (GW.isMobile) return;
2811 clearTimeout(GW.imageFocusHideUITimer);
2812 unhideImageFocusUI();
2813 if (restart) GW.imageFocusHideUITimer = setTimeout(hideImageFocusUI, 1500);
2816 /*********************/
2817 /* MORE MISC HELPERS */
2818 /*********************/
2820 function getQueryVariable(variable) {
2821 var query = window.location.search.substring(1);
2822 var vars = query.split("&");
2823 for (var i = 0; i < vars.length; i++) {
2824 var pair = vars[i].split("=");
2825 if (pair[0] == variable)
2832 function addUIElement(element_html) {
2833 var ui_elements_container = query("#ui-elements-container");
2834 if (!ui_elements_container) {
2835 ui_elements_container = document.createElement("div");
2836 ui_elements_container.id = "ui-elements-container";
2837 query("body").appendChild(ui_elements_container);
2840 ui_elements_container.insertAdjacentHTML("beforeend", element_html);
2841 return ui_elements_container.lastElementChild;
2844 function removeElement(elementOrSelector, ancestor = document) {
2845 if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
2846 if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
2849 String.prototype.hasPrefix = function (prefix) {
2850 return (this.lastIndexOf(prefix, 0) === 0);
2853 /*******************************/
2854 /* HTML TO MARKDOWN CONVERSION */
2855 /*******************************/
2857 function MarkdownFromHTML(text) {
2858 GWLog("MarkdownFromHTML");
2859 // Wrapper tags, paragraphs, bold, italic, code blocks.
2860 text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
2887 text = text.replace(/<ul>((?:.|\n)+?)<\/ul>/g, (match, listItems, offset, string) => {
2888 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
2889 return "* " + listItem + "\n";
2894 text = text.replace(/<h([1-9])>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
2895 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
2899 text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
2900 return "> " + quotedText.trim().split("\n").join("\n> ") + "\n";
2904 text = text.replace(/<a href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
2905 return `[${text}](${href})`;
2908 // Horizontal rules.
2909 text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
2916 /******************/
2917 /* INITIALIZATION */
2918 /******************/
2920 registerInitializer('earlyInitialize', true, () => query("#content") != null, function () {
2921 GWLog("INITIALIZER earlyInitialize");
2922 // Check to see whether we're on a mobile device (which we define as a touchscreen)
2923 // GW.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
2924 GW.isMobile = ('ontouchstart' in document.documentElement);
2926 // Backward compatibility
2927 let storedTheme = localStorage.getItem('selected-theme');
2929 setTheme(storedTheme);
2930 localStorage.removeItem('selected-theme');
2933 // Animate width & theme adjustments?
2934 GW.adjustmentTransitions = false;
2936 // Add the content width selector.
2937 injectContentWidthSelector();
2938 // Add the text size adjustment widget.
2939 injectTextSizeAdjustmentUI();
2940 // Add the theme selector.
2941 injectThemeSelector();
2942 // Add the theme tweaker.
2943 injectThemeTweaker();
2944 // Add the quick-nav UI.
2947 setTimeout(() => { updateInbox(); }, 0);
2950 registerInitializer('initialize', false, () => document.readyState != 'loading', function () {
2951 GWLog("INITIALIZER initialize");
2952 forceInitializer('earlyInitialize');
2954 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
2955 // "link without nav bars".
2956 if (getQueryVariable("comments") == "false")
2957 query("#content").addClass("no-comments");
2958 if (getQueryVariable("hide-nav-bars") == "true") {
2959 query("#content").addClass("no-nav-bars");
2960 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
2963 // If the page cannot have comments, remove the accesskey from the #comments
2964 // quick-nav button; and if the page can have comments, but does not, simply
2965 // disable the #comments quick nav button.
2966 let content = query("#content");
2967 if (content.query("#comments") == null) {
2968 query("#quick-nav-ui a[href='#comments']").accessKey = '';
2969 } else if (content.query("#comments .comment-thread") == null) {
2970 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
2973 // Links to comments generated by LW have a hash that consists of just the
2974 // comment ID, which can start with a number. Prefix it with "comment-".
2975 if (location.hash.length == 18) {
2976 location.hash = "#comment-" + location.hash.substring(1);
2979 // If the viewport is wide enough to fit the desktop-size content column,
2980 // use a long date format; otherwise, a short one.
2981 let useLongDate = window.innerWidth > 900;
2982 let dtf = new Intl.DateTimeFormat([],
2984 { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
2985 : { month: 'numeric', day: 'numeric', year: '2-digit', hour: 'numeric', minute: 'numeric' } ));
2986 queryAll(".date").forEach(date => {
2987 let d = date.dataset.jsDate;
2988 if (d) { date.innerHTML = dtf.format(new Date(+ d)); }
2991 GW.needHashRealignment = false;
2993 // On edit post pages and conversation pages, add GUIEdit buttons to the
2994 // textarea, expand it, and markdownify the existing text, if any (this is
2995 // needed if a post was last edited on LW).
2996 queryAll(".with-markdown-editor textarea").forEach(textarea => {
2997 textarea.addTextareaFeatures();
2998 expandTextarea(textarea);
2999 textarea.value = MarkdownFromHTML(textarea.value);
3001 // Focus the textarea.
3002 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3004 // If this is a post page...
3005 let postMeta = query(".post .post-meta");
3007 // Add "qualified hyperlinking" toolbar.
3008 let postPermalink = location.protocol + "//" + location.host + location.pathname;
3009 postMeta.insertAdjacentHTML("beforeend", "<div class='qualified-linking'>" +
3010 "<input type='checkbox' tabindex='-1' id='qualified-linking-toolbar-toggle-checkbox'><label for='qualified-linking-toolbar-toggle-checkbox'><span></span></label>" +
3011 "<div class='qualified-linking-toolbar'>" +
3012 `<a href='${postPermalink}'>Post permalink</a>` +
3013 `<a href='${postPermalink}?comments=false'>Link without comments</a>` +
3014 `<a href='${postPermalink}?hide-nav-bars=true'>Link without top nav bars</a>` +
3015 `<a href='${postPermalink}?comments=false&hide-nav-bars=true'>Link without comments or top nav bars</a>` +
3019 // Replicate .post-meta at bottom of post.
3020 let clonedPostMeta = postMeta.cloneNode(true);
3021 postMeta.addClass("top-post-meta");
3022 clonedPostMeta.addClass("bottom-post-meta");
3023 clonedPostMeta.query("input[type='checkbox']").id += "-bottom";
3024 clonedPostMeta.query("label").htmlFor += "-bottom";
3025 query(".post").appendChild(clonedPostMeta);
3028 // If client is logged in...
3029 if (loggedInUserId) {
3030 // Add upvote/downvote buttons.
3031 if (typeof postVote != 'undefined') {
3032 queryAll(".post-meta .karma-value").forEach(karmaValue => {
3033 addVoteButtons(karmaValue, postVote, 'Posts');
3034 karmaValue.parentElement.addClass("active-controls");
3037 if (typeof commentVotes != 'undefined') {
3038 queryAll(".comment-meta .karma-value").forEach(karmaValue => {
3039 let commentID = karmaValue.getCommentId();
3040 addVoteButtons(karmaValue, commentVotes[commentID], 'Comments');
3041 karmaValue.parentElement.addClass("active-controls");
3045 // Color the upvote/downvote buttons with an embedded style sheet.
3046 query("head").insertAdjacentHTML("beforeend","<style id='vote-buttons'>" +
3054 .downvote.selected {
3059 // Activate the vote buttons.
3060 queryAll("button.vote").forEach(voteButton => {
3061 voteButton.addActivateEvent(voteButtonClicked);
3064 // For all comment containers...
3065 queryAll(".comments").forEach((commentsContainer) => {
3066 // Add reply buttons.
3067 commentsContainer.queryAll(".comment").forEach(comment => {
3068 comment.insertAdjacentHTML("afterend", "<div class='comment-controls posting-controls'></div>");
3069 comment.parentElement.query(".comment-controls").constructCommentControls();
3072 // Add top-level new comment form.
3073 if (!query(".individual-thread-page")) {
3074 commentsContainer.insertAdjacentHTML("afterbegin", "<div class='comment-controls posting-controls'></div>");
3075 commentsContainer.query(".comment-controls").constructCommentControls();
3079 // Hash realignment is needed because adding the above elements almost
3080 // certainly caused the page to reflow, and now client is no longer
3081 // scrolled to the place indicated by the hash.
3082 GW.needHashRealignment = true;
3086 queryAll(".contents-list li a").forEach(tocLink => {
3087 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
3088 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
3089 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, '');
3090 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
3093 // If we're on a comment thread page...
3094 if (query(".comments") != null) {
3095 // Add comment-minimize buttons to every comment.
3096 queryAll(".comment-meta").forEach(commentMeta => {
3097 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3098 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
3100 if (!query("#content").hasClass("individual-thread-page")) {
3101 // Format and activate comment-minimize buttons.
3102 queryAll(".comment-minimize-button").forEach(button => {
3103 button.closest(".comment-item").setCommentThreadMaximized(false);
3104 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3105 event.target.closest(".comment-item").setCommentThreadMaximized(true);
3110 if (getQueryVariable("chrono") == "t") {
3111 query("head").insertAdjacentHTML("beforeend", "<style>.comment-minimize-button::after { display: none; }</style>");
3113 let urlParts = document.URL.split('#comment-');
3114 if (urlParts.length > 1) {
3115 expandAncestorsOf(urlParts[1]);
3116 GW.needHashRealignment = true;
3119 // On mobile, replace the labels for the checkboxes on the edit post form
3120 // with icons, to save space.
3121 if (GW.isMobile && query(".edit-post-page")) {
3122 query("label[for='link-post']").innerHTML = "";
3123 query("label[for='question']").innerHTML = "";
3126 // Add error message (as placeholder) if user tries to click Search with
3127 // an empty search field.
3128 query("#nav-item-search form").addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3129 let searchField = event.target.query("input");
3130 if (searchField.value == "") {
3131 event.preventDefault();
3132 event.target.blur();
3133 searchField.placeholder = "Enter a search string!";
3134 searchField.focus();
3137 // Remove the placeholder / error on any input.
3138 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3139 event.target.placeholder = "";
3142 // Prevent conflict between various single-hotkey listeners and text fields
3143 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3144 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3147 if (content.hasClass("post-page")) {
3148 // Read and update last-visited-date.
3149 let lastVisitedDate = getLastVisitedDate();
3150 setLastVisitedDate(Date.now());
3152 // Save the number of comments this post has when it's visited.
3153 updateSavedCommentCount();
3155 if (content.query(".comments .comment-thread") != null) {
3156 // Add the new comments count & navigator.
3157 injectNewCommentNavUI();
3159 // Get the highlight-new-since date (as specified by URL parameter, if
3160 // present, or otherwise the date of the last visit).
3161 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3163 // Highlight new comments since the specified date.
3164 let newCommentsCount = highlightCommentsSince(hnsDate);
3166 // Update the comment count display.
3167 updateNewCommentNavUI(newCommentsCount, hnsDate);
3170 // On listing pages, make comment counts more informative.
3171 badgePostsWithNewComments();
3174 // Add the comments list mode selector widget (expanded vs. compact).
3175 injectCommentsListModeSelector();
3177 // Add the comments view selector widget (threaded vs. chrono).
3178 // injectCommentsViewModeSelector();
3180 // Add the comments sort mode selector (top, hot, new, old).
3181 injectCommentsSortModeSelector();
3183 // Add the toggle for the post nav UI elements on mobile.
3184 if (GW.isMobile) injectPostNavUIToggle();
3186 // Add the toggle for the appearance adjustment UI elements on mobile.
3187 if (GW.isMobile) injectAppearanceAdjustUIToggle();
3189 // Add the antikibitzer.
3190 injectAntiKibitzer();
3192 // Add comment parent popups.
3193 addCommentParentPopups();
3195 // Mark original poster's comments with a special class.
3196 markOriginalPosterComments();
3198 // On the All view, mark posts with non-positive karma with a special class.
3199 if (query("#content").hasClass("all-index-page")) {
3200 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3201 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3203 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3207 // Set the "submit" button on the edit post page to something more helpful.
3208 setEditPostPageSubmitButtonText();
3210 // Compute the text of the pagination UI tooltip text.
3211 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3212 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3215 // Add event listeners for Escape and Enter, for the theme tweaker.
3216 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3217 let themeTweakerUI = query("#theme-tweaker-ui");
3218 document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3219 if (event.keyCode == 27) {
3221 if (themeTweakerHelpWindow.style.display != "none") {
3222 toggleThemeTweakerHelpWindow();
3223 themeTweakerResetSettings();
3224 } else if (themeTweakerUI.style.display != "none") {
3225 toggleThemeTweakerUI();
3228 } else if (event.keyCode == 13) {
3230 if (themeTweakerHelpWindow.style.display != "none") {
3231 toggleThemeTweakerHelpWindow();
3232 themeTweakerSaveSettings();
3233 } else if (themeTweakerUI.style.display != "none") {
3234 toggleThemeTweakerUI();
3240 // Add event listener for . , ; (for navigating listings pages).
3241 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3242 if (listings.length > 0) {
3243 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
3244 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.keyCode == 27)) return;
3246 if (event.keyCode == 27) {
3247 if (document.activeElement.parentElement.hasClass("listing"))
3248 document.activeElement.blur();
3252 if (event.key == ';') {
3253 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3254 let links = document.activeElement.parentElement.queryAll("a");
3255 links[document.activeElement == links[0] ? 1 : 0].focus();
3256 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3257 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3258 links[document.activeElement == links[0] ? 1 : 0].focus();
3259 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3264 var indexOfActiveListing = -1;
3265 for (i = 0; i < listings.length; i++) {
3266 if (document.activeElement.parentElement.hasClass("listing") &&
3267 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3268 indexOfActiveListing = i;
3270 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
3271 listings[i] === document.activeElement.parentElement.query("a.date")) {
3272 indexOfActiveListing = i;
3276 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3277 if (indexOfNextListing < listings.length) {
3278 listings[indexOfNextListing].focus();
3280 if (listings[indexOfNextListing].closest(".comment-item")) {
3281 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3282 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3285 document.activeElement.blur();
3288 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
3289 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
3290 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
3294 // Add event listener for ; (to focus the link on link posts).
3295 if (query("#content").hasClass("post-page") &&
3296 query(".post").hasClass("link-post")) {
3297 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
3298 if (event.key == ';') query("a.link-post-link").focus();
3302 // Add accesskeys to user page view selector.
3303 let viewSelector = query("#content.user-page > .sublevel-nav");
3305 let currentView = viewSelector.query("span");
3306 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
3307 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
3310 // Add accesskey to index page sort selector.
3311 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
3313 // Move MathJax style tags to <head>.
3314 var aggregatedStyles = "";
3315 queryAll("#content style").forEach(styleTag => {
3316 aggregatedStyles += styleTag.innerHTML;
3317 removeElement("style", styleTag.parentElement);
3319 if (aggregatedStyles != "") {
3320 query("head").insertAdjacentHTML("beforeend", "<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
3323 // Add listeners to switch between word count and read time.
3324 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
3325 queryAll(".post-meta .read-time").forEach(element => {
3326 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
3327 let displayWordCount = localStorage.getItem("display-word-count");
3328 toggleReadTimeOrWordCount(!displayWordCount);
3329 if (displayWordCount) localStorage.removeItem("display-word-count");
3330 else localStorage.setItem("display-word-count", true);
3334 // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
3335 query("#content").addEventListener("copy", GW.textCopied = (event) => {
3336 event.preventDefault();
3337 const selectedHTML = getSelectionHTML();
3338 const selectedText = getSelection().toString();
3339 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
3340 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
3343 // Set up Image Focus feature.
3347 /*************************/
3348 /* POST-LOAD ADJUSTMENTS */
3349 /*************************/
3351 registerInitializer('pageLayoutFinished', false, () => document.readyState == "complete", function () {
3352 GWLog("INITIALIZER pageLayoutFinished");
3353 forceInitializer('initialize');
3355 realignHashIfNeeded();
3357 postSetThemeHousekeeping();
3359 focusImageSpecifiedByURL();
3361 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
3362 // query("input[type='search']").value = GW.isMobile;
3363 // query("head").insertAdjacentHTML("beforeend", "<style>" +
3364 // `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` +
3365 // `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` +
3369 function generateImagesOverlay() {
3370 GWLog("generateImagesOverlay");
3371 // Don't do this on the about page.
3372 if (query(".about-page") != null) return;
3374 // Remove existing, if any.
3375 removeElement("#images-overlay");
3378 query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
3379 let imagesOverlay = query("#images-overlay");
3380 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
3381 queryAll(".post-body img").forEach(image => {
3383 delete image.className;
3385 let clonedImageContainer = document.createElement("div");
3387 let clonedImage = image.cloneNode(true);
3388 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
3389 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
3390 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
3391 clonedImageContainer.appendChild(clonedImage);
3393 let zoomLevel = parseFloat(GW.currentTextZoom);
3395 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
3396 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
3397 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
3398 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
3399 GWLog(clonedImageContainer);
3401 imagesOverlay.appendChild(clonedImageContainer);
3404 // Add the event listeners to focus each image.
3405 imageFocusSetup(true);
3408 function adjustUIForWindowSize() {
3409 GWLog("adjustUIForWindowSize");
3410 var bottomBarOffset;
3412 // Adjust bottom bar state.
3413 let bottomBar = query("#bottom-bar");
3414 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
3415 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
3416 bottomBar.removeClass("decorative");
3418 bottomBar.query("#nav-item-top").style.display = "";
3419 } else if (bottomBar) {
3420 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
3421 else bottomBar.addClass("decorative");
3423 bottomBar.query("#nav-item-top").style.display = "none";
3426 // Show quick-nav UI up/down buttons if content is taller than window.
3427 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
3428 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
3429 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
3432 // Move anti-kibitzer toggle if content is very short.
3433 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
3435 // Update the visibility of the post nav UI.
3436 updatePostNavUIVisibility();
3439 function recomputeUIElementsContainerHeight(force = false) {
3440 GWLog("recomputeUIElementsContainerHeight");
3442 (force || query("#ui-elements-container").style.height != "")) {
3443 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
3444 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
3445 query("#content").clientHeight + "px" :
3450 function realignHashIfNeeded() {
3451 if (GW.needHashRealignment)
3454 function realignHash() {
3455 GWLog("realignHash");
3456 if (!location.hash) return;
3458 let targetElement = query(location.hash);
3459 if (targetElement) targetElement.scrollIntoView(true);
3460 GW.needHashRealignment = false;
3463 function focusImageSpecifiedByURL() {
3464 GWLog("focusImageSpecifiedByURL");
3465 if (location.hash.hasPrefix("#if_slide_")) {
3466 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
3467 let images = queryAll("#images-overlay img");
3468 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
3469 if (imageToFocus > 0 && imageToFocus <= images.length) {
3470 focusImage(images[imageToFocus - 1]);
3471 query("#image-focus-overlay .image-number").textContent = imageToFocus;
3481 function insertMarkup(event) {
3482 var mopen = '', mclose = '', mtext = '', func = false;
3483 if (typeof arguments[1] == 'function') {
3484 func = arguments[1];
3486 mopen = arguments[1];
3487 mclose = arguments[2];
3488 mtext = arguments[3];
3491 var textarea = event.target.closest("form").query("textarea");
3493 var p0 = textarea.selectionStart;
3494 var p1 = textarea.selectionEnd;
3495 var cur0 = cur1 = p0;
3497 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
3498 str = func ? func(str, p0) : (mopen + str + mclose);
3500 // Determine selection.
3502 cur0 += (p0 == p1) ? mopen.length : str.length;
3503 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
3510 // Update textarea contents.
3511 textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
3514 textarea.selectionStart = cur0;
3515 textarea.selectionEnd = cur1;
3520 GW.guiEditButtons = [
3521 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
3522 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
3523 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
3524 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
3525 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
3526 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
3527 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
3528 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
3529 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
3530 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
3531 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
3532 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
3533 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
3534 [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '' ],
3535 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
3538 function blockquote(text, startpos) {
3540 text = "> Quoted text";
3541 return [ text, startpos + 2, startpos + text.length ];
3543 text = "> " + text.split("\n").join("\n> ") + "\n";
3544 return [ text, startpos + text.length, startpos + text.length ];
3548 function hyperlink(text, startpos) {
3549 var url = '', link_text = text, endpos = startpos;
3550 if (text.search(/^https?/) != -1) {
3552 link_text = "link text";
3553 startpos = startpos + 1;
3554 endpos = startpos + link_text.length;
3556 url = prompt("Link address (URL):");
3558 endpos = startpos + text.length;
3559 return [ text, startpos, endpos ];
3561 startpos = startpos + text.length + url.length + 4;
3565 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];