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 let parentAnswer = commentControls.closest("#answers > .comment-thread > .comment-item");
298 let withParentAnswer = (!editMarkdownSource && parentAnswer && parentAnswer.getCommentId())
299 commentControls.innerHTML = "<button class='cancel-comment-button' tabindex='-1'>Cancel</button>" +
300 "<form method='post'>" +
301 "<div class='textarea-container'>" +
302 "<textarea name='text' oninput='enableBeforeUnload();'></textarea>" +
303 (withparent ? "<input type='hidden' name='parent-comment-id' value='" + commentControls.getCommentId() + "'>" : "") +
304 (withParentAnswer ? "<input type='hidden' name='parent-answer-id' value='" + withParentAnswer + "'>" : "") +
305 (editCommentId ? "<input type='hidden' name='edit-comment-id' value='" + editCommentId + "'>" : "") +
306 (answer ? "<input type='hidden' name='answer' value='t'>" : "") +
307 "<span class='markdown-reference-link'>You can use <a href='http://commonmark.org/help/' target='_blank'>Markdown</a> here.</span>" +
308 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-help-button">Help</button>` +
309 `<button type="button" class="guiedit-mobile-auxiliary-button guiedit-mobile-exit-button">Exit</button>` +
311 "<input type='hidden' name='csrf-token' value='" + GW.csrfToken + "'>" +
312 "<input type='submit' value='Submit'>" +
314 commentControls.onsubmit = disableBeforeUnload;
316 commentControls.query(".cancel-comment-button").addActivateEvent(GW.cancelCommentButtonClicked = (event) => {
317 hideReplyForm(event.target.closest(".comment-controls"));
319 commentControls.scrollIntoViewIfNeeded();
320 commentControls.query("form").onsubmit = (event) => {
321 if (!event.target.text.value) {
322 alert("Please enter a comment.");
326 let textarea = commentControls.query("textarea");
327 textarea.value = MarkdownFromHTML(editMarkdownSource || "");
328 textarea.addTextareaFeatures();
332 Element.prototype.updateCommentControlButton = function() {
333 let retractFn = () => {
334 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
335 return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
337 return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
340 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
341 "retract-button": retractFn,
342 "unretract-button": retractFn,
343 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
345 classMap.keys().forEach((testClass) => {
346 if(this.hasClass(testClass)) {
347 let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
349 this.addClasses([ buttonClass, "action-button" ]);
350 if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
351 this.dataset.label = buttonLabel;
352 this.title = buttonAltText;
353 this.tabIndex = '-1';
359 Element.prototype.constructCommentControls = function() {
360 GWLog("constructCommentControls");
361 let commentControls = this;
362 let commentType = (commentControls.parentElement.id == "answers" ? "answer" : "comment");
363 commentControls.innerHTML = "";
364 let replyButton = document.createElement("button");
365 if (commentControls.parentElement.hasClass("comments")) {
366 replyButton.className = "new-comment-button action-button";
367 replyButton.innerHTML = "Post new " + commentType;
368 replyButton.setAttribute("accesskey", (commentType == "comment" ? "n" : ""));
369 replyButton.setAttribute("title", "Post new " + commentType + (commentType == "comment" ? " [n]" : ""));
371 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
372 let buttonsList = [];
373 if(!commentControls.parentElement.query(".comment-thread"))
374 buttonsList.push("delete-button");
375 buttonsList.push("retract-button", "edit-button");
376 buttonsList.forEach(buttonClass => {
377 let button = commentControls.appendChild(document.createElement("button"));
378 button.addClass(buttonClass);
379 button.updateCommentControlButton();
382 replyButton.className = "reply-button action-button";
383 replyButton.innerHTML = "Reply";
384 replyButton.dataset.label = "Reply";
386 commentControls.appendChild(replyButton);
387 replyButton.tabIndex = '-1';
389 // On mobile, hide labels for all but the Reply button.
390 if (GW.isMobile && window.innerWidth <= 900) {
391 commentControls.queryAll(".delete-button, .retract-button, .unretract-button, .edit-button").forEach(button => {
392 button.innerHTML = "";
397 commentControls.queryAll(".action-button").forEach(button => {
398 button.addActivateEvent(GW.commentActionButtonClicked);
401 // Replicate karma controls at the bottom of comments.
402 if (commentControls.parentElement.hasClass("comments")) return;
403 let karmaControls = commentControls.parentElement.query(".comment-meta .karma");
404 let karmaControlsCloned = karmaControls.cloneNode(true);
405 commentControls.appendChild(karmaControlsCloned);
406 commentControls.queryAll("button.vote").forEach(voteButton => {
407 voteButton.addActivateEvent(voteButtonClicked);
411 GW.commentActionButtonClicked = (event) => {
412 GWLog("commentActionButtonClicked");
413 if (event.target.hasClass("edit-button") ||
414 event.target.hasClass("reply-button") ||
415 event.target.hasClass("new-comment-button")) {
416 queryAll("textarea").forEach(textarea => {
417 hideReplyForm(textarea.closest(".comment-controls"));
421 if (event.target.hasClass("delete-button")) {
422 let commentItem = event.target.closest(".comment-item");
423 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
424 "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" +
425 "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" +
426 "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
427 doCommentAction("delete", commentItem);
428 } else if (event.target.hasClass("retract-button")) {
429 doCommentAction("retract", event.target.closest(".comment-item"));
430 } else if (event.target.hasClass("unretract-button")) {
431 doCommentAction("unretract", event.target.closest(".comment-item"));
432 } else if (event.target.hasClass("edit-button")) {
433 showCommentEditForm(event.target.closest(".comment-item"));
434 } else if (event.target.hasClass("reply-button")) {
435 showReplyForm(event.target.closest(".comment-item"));
436 } else if (event.target.hasClass("new-comment-button")) {
437 showReplyForm(event.target.closest(".comments"));
443 function showCommentEditForm(commentItem) {
444 GWLog("showCommentEditForm");
446 let commentBody = commentItem.query(".comment-body");
447 commentBody.style.display = "none";
449 let commentControls = commentItem.query(".comment-controls");
450 commentControls.injectReplyForm(commentBody.dataset.markdownSource);
451 commentControls.query("form").addClass("edit-existing-comment");
452 expandTextarea(commentControls.query("textarea"));
455 function showReplyForm(commentItem) {
456 GWLog("showReplyForm");
458 let commentControls = commentItem.query(".comment-controls");
459 commentControls.injectReplyForm(commentControls.dataset.enteredText);
462 function hideReplyForm(commentControls) {
463 GWLog("hideReplyForm");
464 // Are we editing a comment? If so, un-hide the existing comment body.
465 let containingComment = commentControls.closest(".comment-item");
466 if (containingComment) containingComment.query(".comment-body").style.display = "";
468 let enteredText = commentControls.query("textarea").value;
469 if (enteredText) commentControls.dataset.enteredText = enteredText;
471 disableBeforeUnload();
472 commentControls.constructCommentControls();
475 function expandTextarea(textarea) {
476 GWLog("expandTextarea");
477 if (window.innerWidth <= 520) return;
479 let totalBorderHeight = 30;
480 if (textarea.clientHeight == textarea.scrollHeight + totalBorderHeight) return;
482 requestAnimationFrame(() => {
483 textarea.style.height = 'auto';
484 textarea.style.height = textarea.scrollHeight + totalBorderHeight + 'px';
485 if (textarea.clientHeight < window.innerHeight) {
486 textarea.parentElement.parentElement.scrollIntoViewIfNeeded();
491 function doCommentAction(action, commentItem) {
492 GWLog("doCommentAction");
494 params[(action + "-comment-id")] = commentItem.getCommentId();
498 onSuccess: (event) => {
500 retract: () => { commentItem.firstChild.addClass("retracted") },
501 unretract: () => { commentItem.firstChild.removeClass("retracted") },
503 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>";
504 commentItem.removeChild(commentItem.query(".comment-controls"));
508 if(action != "delete")
509 commentItem.query(".comment-controls").queryAll(".action-button").forEach(x => {x.updateCommentControlButton()});
518 function parseVoteType(voteType) {
519 GWLog("parseVoteType");
521 if (!voteType) return value;
522 value.up = /[Uu]pvote$/.test(voteType);
523 value.down = /[Dd]ownvote$/.test(voteType);
524 value.big = /^big/.test(voteType);
528 function makeVoteType(value) {
529 GWLog("makeVoteType");
530 return (value.big ? 'big' : 'small') + (value.up ? 'Up' : 'Down') + 'vote';
533 function makeVoteClass(vote) {
534 GWLog("makeVoteClass");
535 if (vote.up || vote.down) {
536 return (vote.big ? 'selected big-vote' : 'selected');
542 function addVoteButtons(element, voteType, targetType) {
543 GWLog("addVoteButtons");
544 let vote = parseVoteType(voteType);
545 let voteClass = makeVoteClass(vote);
546 element.insertAdjacentHTML('beforebegin', "<button type='button' class='vote upvote"+(vote.up ?' '+voteClass:'')+"' data-vote-type='upvote' data-target-type='"+targetType+"' tabindex='-1'></button>");
547 element.insertAdjacentHTML('afterend', "<button type='button' class='vote downvote"+(vote.down ?' '+voteClass:'')+"' data-vote-type='downvote' data-target-type='"+targetType+"' tabindex='-1'></button>");
550 function makeVoteCompleteEvent(target) {
551 GWLog("makeVoteCompleteEvent");
553 var buttonTargets, karmaTargets;
554 if (target === null) {
555 buttonTargets = queryAll(".post-meta .karma");
556 karmaTargets = queryAll(".post-meta .karma-value");
558 let commentItem = target.closest(".comment-item")
559 buttonTargets = [ commentItem.query(".comment-meta .karma"), commentItem.query(".comment-controls .karma") ];
560 karmaTargets = [ commentItem.query(".comment-meta .karma-value"), commentItem.query(".comment-controls .karma-value") ];
562 buttonTargets.forEach(buttonTarget => {
563 buttonTarget.removeClass("waiting");
565 if (event.target.status == 200) {
566 let response = JSON.parse(event.target.responseText);
567 let karmaText = response[0], voteType = response[1];
569 let vote = parseVoteType(voteType);
570 let voteUpDown = (vote.up ? 'upvote' : (vote.down ? 'downvote' : ''));
571 let voteClass = makeVoteClass(vote);
573 karmaTargets.forEach(karmaTarget => {
574 karmaTarget.innerHTML = karmaText;
575 if (karmaTarget.hasClass("redacted")) {
576 karmaTarget.dataset["trueValue"] = karmaTarget.firstChild.textContent;
577 karmaTarget.firstChild.textContent = "##";
580 buttonTargets.forEach(buttonTarget => {
581 buttonTarget.queryAll("button.vote").forEach(button => {
582 button.removeClasses([ "clicked-once", "clicked-twice", "selected", "big-vote" ]);
583 if (button.dataset.voteType == voteUpDown) button.addClass(voteClass);
590 function sendVoteRequest(targetId, targetType, voteType, onFinish) {
591 GWLog("sendVoteRequest");
592 let req = new XMLHttpRequest();
593 req.addEventListener("load", onFinish);
594 req.open("POST", "/karma-vote");
595 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
596 req.send("csrf-token="+encodeURIComponent(GW.csrfToken)+"&target="+encodeURIComponent(targetId)+"&target-type="+encodeURIComponent(targetType)+"&vote-type="+encodeURIComponent(voteType));
599 function voteButtonClicked(event) {
600 let voteButton = event.target;
602 // 500 ms (0.5 s) double-click timeout.
603 let doubleClickTimeout = 500;
605 if (!voteButton.clickedOnce) {
606 voteButton.clickedOnce = true;
607 voteButton.addClass("clicked-once");
609 setTimeout(GW.vbDoubleClickTimeoutCallback = (voteButton) => {
610 if (!voteButton.clickedOnce) return;
612 // Do single-click code.
613 voteButton.clickedOnce = false;
614 voteEvent(voteButton, 1);
615 }, doubleClickTimeout, voteButton);
617 voteButton.clickedOnce = false;
619 // Do double-click code.
620 voteEvent(voteButton, 2);
621 voteButton.removeClass("clicked-once");
622 voteButton.addClass("clicked-twice");
625 function voteEvent(voteButton, numClicks) {
627 voteButton.parentNode.addClass("waiting");
628 let targetType = voteButton.dataset.targetType;
629 let targetId = ((targetType == 'Comments') ? voteButton.getCommentId() : voteButton.parentNode.dataset.postId);
630 let voteUpDown = voteButton.dataset.voteType;
631 let vote = parseVoteType(voteUpDown);
632 vote.big = (numClicks == 2);
633 let voteType = makeVoteType(vote);
635 if (targetType == "Posts") {
636 oldVoteType = postVote;
637 postVote = ((voteType == oldVoteType) ? null : voteType);
639 oldVoteType = commentVotes[targetId];
640 commentVotes[targetId] = ((voteType == oldVoteType) ? null : voteType);
642 let f = () => { sendVoteRequest(targetId, targetType, voteType, makeVoteCompleteEvent((targetType == 'Comments' ? voteButton.parentNode : null))) };
643 if (oldVoteType && (oldVoteType != voteType)) {
644 sendVoteRequest(targetId, targetType, oldVoteType, f);
650 /***********************************/
651 /* COMMENT THREAD MINIMIZE BUTTONS */
652 /***********************************/
654 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
655 GWLog("setCommentThreadMaximized");
656 let commentItem = this;
657 let storageName = "thread-minimized-" + commentItem.getCommentId();
658 let minimize_button = commentItem.query(".comment-minimize-button");
659 let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !localStorage.getItem(storageName));
660 if (userOriginated) {
662 localStorage.removeItem(storageName);
664 localStorage.setItem(storageName, true);
668 commentItem.style.height = maximize ? 'auto' : '38px';
669 commentItem.style.overflow = maximize ? 'visible' : 'hidden';
671 minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
672 minimize_button.innerHTML = maximize ? "" : "";
673 minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
674 if (getQueryVariable("chrono") != "t") {
675 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
679 /*****************************************/
680 /* NEW COMMENT HIGHLIGHTING & NAVIGATION */
681 /*****************************************/
683 Element.prototype.getCommentDate = function() {
684 let item = (this.className == "comment-item") ? this : this.closest(".comment-item");
685 return (item ? parseInt(item.query(".date").dataset["jsDate"]) : false);
687 function getCurrentVisibleComment() {
688 let px = window.innerWidth/2, py = 5;
689 let commentItem = document.elementFromPoint(px, py).closest(".comment-item") || document.elementFromPoint(px, py+60).closest(".comment-item"); // Mind the gap between threads
690 let atbottom = query("#comments").getBoundingClientRect().bottom < window.innerHeight;
692 let hashci = location.hash && query(location.hash);
693 if (hashci && /comment-item/.test(hashci.className) && hashci.getBoundingClientRect().top > 0) {
694 commentItem = hashci;
700 function highlightCommentsSince(date) {
701 GWLog("highlightCommentsSince");
702 var newCommentsCount = 0;
703 GW.newComments = [ ];
704 let oldCommentsStack = [ ];
706 queryAll(".comment-item").forEach(commentItem => {
707 commentItem.prevNewComment = prevNewComment;
708 if (commentItem.getCommentDate() > date) {
709 commentItem.addClass("new-comment");
711 GW.newComments.push(commentItem.getCommentId());
712 oldCommentsStack.forEach(oldci => { oldci.nextNewComment = commentItem });
713 oldCommentsStack = [ commentItem ];
714 prevNewComment = commentItem;
716 commentItem.removeClass("new-comment");
717 oldCommentsStack.push(commentItem);
721 GW.newCommentScrollSet = (commentItem) => {
722 query("#new-comment-nav-ui .new-comment-previous").disabled = commentItem ? !commentItem.prevNewComment : true;
723 query("#new-comment-nav-ui .new-comment-next").disabled = commentItem ? !commentItem.nextNewComment : (GW.newComments.length == 0);
725 GW.newCommentScrollListener = () => {
726 let commentItem = getCurrentVisibleComment();
727 GW.newCommentScrollSet(commentItem);
730 addScrollListener(GW.newCommentScrollListener);
732 if (document.readyState=="complete") {
733 GW.newCommentScrollListener();
735 let commentItem = location.hash && /^#comment-/.test(location.hash) && query(location.hash);
736 GW.newCommentScrollSet(commentItem);
739 registerInitializer("initializeCommentScrollPosition", false, () => document.readyState == "complete", GW.newCommentScrollListener);
741 return newCommentsCount;
744 function scrollToNewComment(next) {
745 GWLog("scrollToNewComment");
746 let commentItem = getCurrentVisibleComment();
747 let targetComment = null;
748 let targetCommentID = null;
750 targetComment = (next ? commentItem.nextNewComment : commentItem.prevNewComment);
752 targetCommentID = targetComment.getCommentId();
755 if (GW.newComments[0]) {
756 targetCommentID = GW.newComments[0];
757 targetComment = query("#comment-" + targetCommentID);
761 expandAncestorsOf(targetCommentID);
762 history.replaceState(null, null, "#comment-" + targetCommentID);
763 targetComment.scrollIntoView();
766 GW.newCommentScrollListener();
769 function getPostHash() {
770 let postHash = /^\/posts\/([^\/]+)/.exec(location.pathname);
771 return (postHash ? postHash[1] : false);
773 function getLastVisitedDate() {
774 // Get the last visited date (or, if posting a comment, the previous last visited date).
775 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
776 let storageName = (aCommentHasJustBeenPosted ? "previous-last-visited-date_" : "last-visited-date_") + getPostHash();
777 return localStorage.getItem(storageName);
779 function setLastVisitedDate(date) {
780 // If NOT posting a comment, save the previous value for the last-visited-date
781 // (to recover it in case of posting a comment).
782 let aCommentHasJustBeenPosted = (query(".just-posted-comment") != null);
783 if (!aCommentHasJustBeenPosted) {
784 let previousLastVisitedDate = (localStorage.getItem("last-visited-date_" + getPostHash()) || 0);
785 localStorage.setItem("previous-last-visited-date_" + getPostHash(), previousLastVisitedDate);
788 // Set the new value.
789 localStorage.setItem("last-visited-date_" + getPostHash(), date);
792 function updateSavedCommentCount() {
793 let commentCount = queryAll(".comment").length;
794 localStorage.setItem("comment-count_" + getPostHash(), commentCount);
796 function badgePostsWithNewComments() {
797 if (getQueryVariable("show") == "conversations") return;
799 queryAll("h1.listing a[href^='/posts']").forEach(postLink => {
800 let postHash = /posts\/(.+?)\//.exec(postLink.href)[1];
802 let savedCommentCount = localStorage.getItem("comment-count_" + postHash);
803 let commentCountDisplay = postLink.parentElement.nextSibling.query(".comment-count");
804 let currentCommentCount = /([0-9]+)/.exec(commentCountDisplay.textContent)[1];
806 if (currentCommentCount > savedCommentCount)
807 commentCountDisplay.addClass("new-comments");
808 commentCountDisplay.title = `${currentCommentCount} comments (${currentCommentCount - savedCommentCount} new)`;
812 /***********************************/
813 /* CONTENT COLUMN WIDTH ADJUSTMENT */
814 /***********************************/
816 function injectContentWidthSelector() {
817 GWLog("injectContentWidthSelector");
818 // Get saved width setting (or default).
819 let currentWidth = localStorage.getItem("selected-width") || 'normal';
821 // Inject the content width selector widget and activate buttons.
822 let widthSelector = addUIElement(
823 "<div id='width-selector'>" +
824 String.prototype.concat.apply("", GW.widthOptions.map(widthOption => {
825 let [name, desc, abbr] = widthOption;
826 let selected = (name == currentWidth ? ' selected' : '');
827 let disabled = (name == currentWidth ? ' disabled' : '');
828 return `<button type='button' class='select-width-${name}${selected}'${disabled} title='${desc}' tabindex='-1' data-name='${name}'>${abbr}</button>`})) +
830 widthSelector.queryAll("button").forEach(button => {
831 button.addActivateEvent(GW.widthAdjustButtonClicked = (event) => {
832 // Determine which setting was chosen (i.e., which button was clicked).
833 let selectedWidth = event.target.dataset.name;
835 // Save the new setting.
836 if (selectedWidth == "normal") localStorage.removeItem("selected-width");
837 else localStorage.setItem("selected-width", selectedWidth);
839 // Actually change the content width.
840 setContentWidth(selectedWidth);
841 event.target.parentElement.childNodes.forEach(button => {
842 button.removeClass("selected");
843 button.disabled = false;
845 event.target.addClass("selected");
846 event.target.disabled = true;
848 // Make sure the accesskey (to cycle to the next width) is on the right button.
849 setWidthAdjustButtonsAccesskey();
851 // Regenerate images overlay.
852 generateImagesOverlay();
859 // Make sure the accesskey (to cycle to the next width) is on the right button.
860 setWidthAdjustButtonsAccesskey();
862 // Inject transitions CSS, if animating changes is enabled.
863 if (GW.adjustmentTransitions) {
864 query("head").insertAdjacentHTML("beforeend",
865 "<style id='width-transition'>" +
867 #ui-elements-container,
875 function setWidthAdjustButtonsAccesskey() {
876 GWLog("setWidthAdjustButtonsAccesskey");
877 let widthSelector = query("#width-selector");
878 widthSelector.queryAll("button").forEach(button => {
879 button.removeAttribute("accesskey");
880 button.title = /(.+?)( \['\])?$/.exec(button.title)[1];
882 let selectedButton = widthSelector.query("button.selected");
883 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
884 nextButtonInCycle.accessKey = "'";
885 nextButtonInCycle.title += ` [\']`;
888 /*******************/
889 /* THEME SELECTION */
890 /*******************/
892 function injectThemeSelector() {
893 GWLog("injectThemeSelector");
894 let currentTheme = readCookie("theme") || "default";
895 let themeSelector = addUIElement(
896 "<div id='theme-selector' class='theme-selector'>" +
897 String.prototype.concat.apply("", GW.themeOptions.map(themeOption => {
898 let [name, desc, letter] = themeOption;
899 let selected = (name == currentTheme ? ' selected' : '');
900 let disabled = (name == currentTheme ? ' disabled' : '');
901 let accesskey = letter.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
902 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>`;})) +
904 themeSelector.queryAll("button").forEach(button => {
905 button.addActivateEvent(GW.themeSelectButtonClicked = (event) => {
906 let themeName = /select-theme-([^\s]+)/.exec(event.target.className)[1];
907 setSelectedTheme(themeName);
908 if (GW.isMobile) toggleAppearanceAdjustUI();
912 // Inject transitions CSS, if animating changes is enabled.
913 if (GW.adjustmentTransitions) {
914 query("head").insertAdjacentHTML("beforeend",
915 "<style id='theme-fade-transition'>" +
918 opacity 0.5s ease-out,
919 background-color 0.3s ease-out;
922 background-color: #777;
925 opacity 0.5s ease-in,
926 background-color 0.3s ease-in;
931 function setSelectedTheme(themeName) {
932 GWLog("setSelectedTheme");
933 queryAll(".theme-selector button").forEach(button => {
934 button.removeClass("selected");
935 button.disabled = false;
937 queryAll(".theme-selector button.select-theme-" + themeName).forEach(button => {
938 button.addClass("selected");
939 button.disabled = true;
942 query("#theme-tweaker-ui .current-theme span").innerText = themeName;
944 function setTheme(newThemeName) {
945 var themeUnloadCallback = '';
946 var oldThemeName = '';
947 if (typeof(newThemeName) == 'undefined') {
948 newThemeName = readCookie('theme');
949 if (!newThemeName) return;
951 themeUnloadCallback = GW['themeUnloadCallback_' + (readCookie('theme') || 'default')];
952 oldThemeName = readCookie('theme') || 'default';
954 if (newThemeName == 'default') setCookie('theme', '');
955 else setCookie('theme', newThemeName);
957 if (themeUnloadCallback != null) themeUnloadCallback(newThemeName);
959 let styleSheetNameSuffix = (newThemeName == 'default') ? '' : ('-' + newThemeName);
960 let currentStyleSheetNameComponents = /style[^\.]*(\..+)$/.exec(query("head link[href*='.css']").href);
962 let newStyle = document.createElement('link');
963 newStyle.setAttribute('rel', 'stylesheet');
964 newStyle.setAttribute('href', '/style' + styleSheetNameSuffix + currentStyleSheetNameComponents[1]);
966 let oldStyle = query("head link[href*='.css']");
967 newStyle.addEventListener('load', () => { removeElement(oldStyle); });
968 newStyle.addEventListener('load', () => { postSetThemeHousekeeping(oldThemeName, newThemeName); });
970 if (GW.adjustmentTransitions) {
971 pageFadeTransition(false);
973 query('head').insertBefore(newStyle, oldStyle.nextSibling);
976 query('head').insertBefore(newStyle, oldStyle.nextSibling);
979 function postSetThemeHousekeeping(oldThemeName = "", newThemeName = (readCookie('theme') || 'default')) {
980 recomputeUIElementsContainerHeight(true);
982 let themeLoadCallback = GW['themeLoadCallback_' + newThemeName];
983 if (themeLoadCallback != null) themeLoadCallback(oldThemeName);
985 recomputeUIElementsContainerHeight();
986 adjustUIForWindowSize();
987 window.addEventListener('resize', GW.windowResized = (event) => {
988 adjustUIForWindowSize();
989 recomputeUIElementsContainerHeight();
992 generateImagesOverlay();
994 if (window.adjustmentTransitions) pageFadeTransition(true);
995 updateThemeTweakerSampleText();
997 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
998 window.matchMedia('(orientation: portrait)').addListener(generateImagesOverlay);
1001 setTimeout(realignHash, 0);
1004 function pageFadeTransition(fadeIn) {
1006 query("body").removeClass("transparent");
1008 query("body").addClass("transparent");
1012 GW.themeLoadCallback_less = (fromTheme = "") => {
1013 GWLog("themeLoadCallback_less");
1014 injectSiteNavUIToggle();
1016 injectPostNavUIToggle();
1017 injectAppearanceAdjustUIToggle();
1020 registerInitializer('shortenDate', true, () => query(".top-post-meta") != null, function () {
1021 let dtf = new Intl.DateTimeFormat([],
1022 (window.innerWidth < 1100) ?
1023 { month: 'short', day: 'numeric', year: 'numeric' } :
1024 { month: 'long', day: 'numeric', year: 'numeric' });
1025 let postDate = query(".top-post-meta .date");
1026 postDate.innerHTML = dtf.format(new Date(+ postDate.dataset.jsDate));
1030 query("#content").insertAdjacentHTML("beforeend", "<div id='theme-less-mobile-first-row-placeholder'></div>");
1034 registerInitializer('addSpans', true, () => query(".top-post-meta") != null, function () {
1035 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1036 element.innerHTML = "<span>" + element.innerHTML + "</span>";
1040 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == null) {
1041 // If state is not set (user has never clicked on the Less theme's appearance
1042 // adjustment UI toggle) then show it, but then hide it after a short time.
1043 registerInitializer('engageAppearanceAdjustUI', true, () => query("#ui-elements-container") != null, function () {
1044 toggleAppearanceAdjustUI();
1045 setTimeout(toggleAppearanceAdjustUI, 3000);
1049 if (fromTheme != "") {
1050 allUIToggles = queryAll("#ui-elements-container div[id$='-ui-toggle']");
1051 setTimeout(function () {
1052 allUIToggles.forEach(toggle => { toggle.addClass("highlighted"); });
1054 setTimeout(function () {
1055 allUIToggles.forEach(toggle => { toggle.removeClass("highlighted"); });
1059 // Unset the height of the #ui-elements-container.
1060 query("#ui-elements-container").style.height = "";
1062 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
1063 GW.themeTweaker.filtersExclusionPaths.themeLess = [
1064 "#content #secondary-bar",
1065 "#content .post .top-post-meta .date",
1066 "#content .post .top-post-meta .comment-count",
1068 applyFilters(GW.currentFilters);
1071 // We pre-query the relevant elements, so we don't have to run querySelectorAll
1072 // on every firing of the scroll listener.
1074 "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
1075 "unbrokenDownScrollDistance": 0,
1076 "unbrokenUpScrollDistance": 0,
1077 "siteNavUIToggleButton": query("#site-nav-ui-toggle button"),
1078 "siteNavUIElements": queryAll("#primary-bar, #secondary-bar, .page-toolbar"),
1079 "appearanceAdjustUIToggleButton": query("#appearance-adjust-ui-toggle button")
1081 addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
1084 // Hide the post-nav-ui toggle if none of the elements to be toggled are visible;
1085 // otherwise, show it.
1086 function updatePostNavUIVisibility() {
1087 GWLog("updatePostNavUIVisibility");
1088 var hidePostNavUIToggle = true;
1089 queryAll("#quick-nav-ui a, #new-comment-nav-ui").forEach(element => {
1090 if (getComputedStyle(element).visibility == "visible" ||
1091 element.style.visibility == "visible" ||
1092 element.style.visibility == "unset")
1093 hidePostNavUIToggle = false;
1095 queryAll("#quick-nav-ui, #post-nav-ui-toggle").forEach(element => {
1096 element.style.visibility = hidePostNavUIToggle ? "hidden" : "";
1100 // Hide the site nav and appearance adjust UIs on scroll down; show them on scroll up.
1101 // NOTE: The UIs are re-shown on scroll up ONLY if the user has them set to be
1102 // engaged; if they're manually disengaged, they are not re-engaged by scroll.
1103 function updateSiteNavUIState(event) {
1104 GWLog("updateSiteNavUIState");
1105 let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
1106 GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
1107 (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
1109 GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
1110 (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
1112 GW.scrollState.lastScrollTop = newScrollTop;
1114 // Hide site nav UI and appearance adjust UI when scrolling a full page down.
1115 if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
1116 if (GW.scrollState.siteNavUIToggleButton.hasClass("engaged")) toggleSiteNavUI();
1117 if (GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) toggleAppearanceAdjustUI();
1120 // On mobile, make site nav UI translucent on ANY scroll down.
1122 GW.scrollState.siteNavUIElements.forEach(element => {
1123 if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
1124 else element.removeClass("translucent-on-scroll");
1127 // Show site nav UI when scrolling a full page up, or to the top.
1128 if ((GW.scrollState.unbrokenUpScrollDistance > window.innerHeight ||
1129 GW.scrollState.lastScrollTop == 0) &&
1130 (!GW.scrollState.siteNavUIToggleButton.hasClass("engaged") &&
1131 localStorage.getItem("site-nav-ui-toggle-engaged") != "false")) toggleSiteNavUI();
1133 // On desktop, show appearance adjust UI when scrolling to the top.
1134 if ((!GW.isMobile) &&
1135 (GW.scrollState.lastScrollTop == 0) &&
1136 (!GW.scrollState.appearanceAdjustUIToggleButton.hasClass("engaged")) &&
1137 (localStorage.getItem("appearance-adjust-ui-toggle-engaged") != "false")) toggleAppearanceAdjustUI();
1140 GW.themeUnloadCallback_less = (toTheme = "") => {
1141 GWLog("themeUnloadCallback_less");
1142 removeSiteNavUIToggle();
1144 removePostNavUIToggle();
1145 removeAppearanceAdjustUIToggle();
1147 window.removeEventListener('resize', updatePostNavUIVisibility);
1149 document.removeEventListener("scroll", GW["updateSiteNavUIStateScrollListener"]);
1151 removeElement("#theme-less-mobile-first-row-placeholder");
1155 queryAll(".top-post-meta .date, .top-post-meta .comment-count").forEach(element => {
1156 element.innerHTML = element.firstChild.innerHTML;
1160 (query(".top-post-meta .date")||{}).innerHTML = (query(".bottom-post-meta .date")||{}).innerHTML;
1162 // Reset filtered elements selector to default.
1163 delete GW.themeTweaker.filtersExclusionPaths.themeLess;
1164 applyFilters(GW.currentFilters);
1167 GW.themeLoadCallback_dark = (fromTheme = "") => {
1168 GWLog("themeLoadCallback_dark");
1169 query("head").insertAdjacentHTML("beforeend",
1170 "<style id='dark-theme-adjustments'>" +
1171 `.markdown-reference-link a { color: #d200cf; filter: invert(100%); }` +
1172 `#bottom-bar.decorative::before { filter: invert(100%); }` +
1174 registerInitializer('makeImagesGlow', true, () => query("#images-overlay") != null, () => {
1175 queryAll("#images-overlay img").forEach(image => {
1176 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)";
1177 image.style.width = parseInt(image.style.width) + 12 + "px";
1178 image.style.height = parseInt(image.style.height) + 12 + "px";
1179 image.style.top = parseInt(image.style.top) - 6 + "px";
1180 image.style.left = parseInt(image.style.left) - 6 + "px";
1184 GW.themeUnloadCallback_dark = (toTheme = "") => {
1185 GWLog("themeUnloadCallback_dark");
1186 removeElement("#dark-theme-adjustments");
1189 GW.themeLoadCallback_brutalist = (fromTheme = "") => {
1190 GWLog("themeLoadCallback_brutalist");
1191 let bottomBarLinks = queryAll("#bottom-bar a");
1192 if (!GW.isMobile && bottomBarLinks.length == 5) {
1193 let newLinkTexts = [ "First", "Previous", "Top", "Next", "Last" ];
1194 bottomBarLinks.forEach((link, i) => {
1195 link.dataset.originalText = link.textContent;
1196 link.textContent = newLinkTexts[i];
1200 GW.themeUnloadCallback_brutalist = (toTheme = "") => {
1201 GWLog("themeUnloadCallback_brutalist");
1202 let bottomBarLinks = queryAll("#bottom-bar a");
1203 if (!GW.isMobile && bottomBarLinks.length == 5) {
1204 bottomBarLinks.forEach(link => {
1205 link.textContent = link.dataset.originalText;
1210 GW.themeLoadCallback_classic = (fromTheme = "") => {
1211 GWLog("themeLoadCallback_classic");
1212 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1213 button.innerHTML = "";
1216 GW.themeUnloadCallback_classic = (toTheme = "") => {
1217 GWLog("themeUnloadCallback_classic");
1218 if (GW.isMobile && window.innerWidth <= 900) return;
1219 queryAll(".comment-item .comment-controls .action-button").forEach(button => {
1220 button.innerHTML = button.dataset.label;
1224 /********************************************/
1225 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
1226 /********************************************/
1228 function injectThemeTweaker() {
1229 GWLog("injectThemeTweaker");
1230 let themeTweakerUI = addUIElement("<div id='theme-tweaker-ui' style='display: none;'>" +
1231 `<div class='main-theme-tweaker-window'>
1232 <h1>Customize appearance</h1>
1233 <button type='button' class='minimize-button minimize' tabindex='-1'></button>
1234 <button type='button' class='help-button' tabindex='-1'></button>
1235 <p class='current-theme'>Current theme: <span>` +
1236 (readCookie("theme") || "default") +
1238 <p class='theme-selector'></p>
1239 <div class='controls-container'>
1240 <div id='theme-tweak-section-sample-text' class='section' data-label='Sample text'>
1241 <div class='sample-text-container'><span class='sample-text'>
1242 <p>Less Wrong (text)</p>
1243 <p><a href="#">Less Wrong (link)</a></p>
1246 <div id='theme-tweak-section-text-size-adjust' class='section' data-label='Text size'>
1247 <button type='button' class='text-size-adjust-button decrease' title='Decrease text size'></button>
1248 <button type='button' class='text-size-adjust-button default' title='Reset to default text size'></button>
1249 <button type='button' class='text-size-adjust-button increase' title='Increase text size'></button>
1251 <div id='theme-tweak-section-invert' class='section' data-label='Invert (photo-negative)'>
1252 <input type='checkbox' id='theme-tweak-control-invert'></input>
1253 <label for='theme-tweak-control-invert'>Invert colors</label>
1255 <div id='theme-tweak-section-saturate' class='section' data-label='Saturation'>
1256 <input type="range" id="theme-tweak-control-saturate" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1257 <p class="theme-tweak-control-label" id="theme-tweak-label-saturate"></p>
1258 <div class='notch theme-tweak-slider-notch-saturate' title='Reset saturation to default value (100%)'></div>
1260 <div id='theme-tweak-section-brightness' class='section' data-label='Brightness'>
1261 <input type="range" id="theme-tweak-control-brightness" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1262 <p class="theme-tweak-control-label" id="theme-tweak-label-brightness"></p>
1263 <div class='notch theme-tweak-slider-notch-brightness' title='Reset brightness to default value (100%)'></div>
1265 <div id='theme-tweak-section-contrast' class='section' data-label='Contrast'>
1266 <input type="range" id="theme-tweak-control-contrast" min="0" max="300" data-default-value="100" data-value-suffix="%" data-label-suffix="%">
1267 <p class="theme-tweak-control-label" id="theme-tweak-label-contrast"></p>
1268 <div class='notch theme-tweak-slider-notch-contrast' title='Reset contrast to default value (100%)'></div>
1270 <div id='theme-tweak-section-hue-rotate' class='section' data-label='Hue rotation'>
1271 <input type="range" id="theme-tweak-control-hue-rotate" min="0" max="360" data-default-value="0" data-value-suffix="deg" data-label-suffix="°">
1272 <p class="theme-tweak-control-label" id="theme-tweak-label-hue-rotate"></p>
1273 <div class='notch theme-tweak-slider-notch-hue-rotate' title='Reset hue to default (0° away from standard colors for theme)'></div>
1276 <div class='buttons-container'>
1277 <button type="button" class="reset-defaults-button">Reset to defaults</button>
1278 <button type='button' class='ok-button default-button'>OK</button>
1279 <button type='button' class='cancel-button'>Cancel</button>
1282 <div class="clippy-container">
1283 <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>)
1284 <div class='clippy'></div>
1285 <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>
1287 <div class='help-window' style='display: none;'>
1288 <h1>Theme tweaker help</h1>
1289 <div id='theme-tweak-section-clippy' class='section' data-label='Theme Tweaker Assistant'>
1290 <input type='checkbox' id='theme-tweak-control-clippy' checked='checked'></input>
1291 <label for='theme-tweak-control-clippy'>Show Bobby the Basilisk</label>
1293 <div class='buttons-container'>
1294 <button type='button' class='ok-button default-button'>OK</button>
1295 <button type='button' class='cancel-button'>Cancel</button>
1300 // Clicking the background overlay closes the theme tweaker.
1301 themeTweakerUI.addActivateEvent(GW.themeTweaker.UIOverlayClicked = (event) => {
1302 if (event.type == 'mousedown') {
1303 themeTweakerUI.style.opacity = "0.01";
1305 toggleThemeTweakerUI();
1306 themeTweakerUI.style.opacity = "1.0";
1311 // Intercept clicks, so they don't "fall through" the background overlay.
1312 (query("#theme-tweaker-ui > div")||{}).addActivateEvent((event) => { event.stopPropagation(); }, true);
1314 let sampleTextContainer = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container");
1315 themeTweakerUI.queryAll("input").forEach(field => {
1316 // All input types in the theme tweaker receive a 'change' event when
1317 // their value is changed. (Range inputs, in particular, receive this
1318 // event when the user lets go of the handle.) This means we should
1319 // update the filters for the entire page, to match the new setting.
1320 field.addEventListener("change", GW.themeTweaker.fieldValueChanged = (event) => {
1321 if (event.target.id == 'theme-tweak-control-invert') {
1322 GW.currentFilters['invert'] = event.target.checked ? '100%' : '0%';
1323 } else if (event.target.type == 'range') {
1324 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1325 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1326 GW.currentFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1327 } else if (event.target.id == 'theme-tweak-control-clippy') {
1328 query(".clippy-container").style.display = event.target.checked ? "block" : "none";
1330 // Clear the sample text filters.
1331 sampleTextContainer.style.filter = "";
1332 // Apply the new filters globally.
1333 applyFilters(GW.currentFilters);
1336 // Range inputs receive an 'input' event while being scrubbed, updating
1337 // "live" as the handle is moved. We don't want to change the filters
1338 // for the actual page while this is happening, but we do want to change
1339 // the filters for the *sample text*, so the user can see what effects
1340 // his changes are having, live, without having to let go of the handle.
1341 if (field.type == "range") field.addEventListener("input", GW.themeTweaker.fieldInputReceived = (event) => {
1342 var sampleTextFilters = GW.currentFilters;
1344 let sliderName = /^theme-tweak-control-(.+)$/.exec(event.target.id)[1];
1345 query("#theme-tweak-label-" + sliderName).innerText = event.target.value + event.target.dataset["labelSuffix"];
1346 sampleTextFilters[sliderName] = event.target.value + event.target.dataset["valueSuffix"];
1348 sampleTextContainer.style.filter = filterStringFromFilters(sampleTextFilters);
1352 themeTweakerUI.query(".minimize-button").addActivateEvent(GW.themeTweaker.minimizeButtonClicked = (event) => {
1353 let themeTweakerStyle = query("#theme-tweaker-style");
1355 if (event.target.hasClass("minimize")) {
1356 event.target.removeClass("minimize");
1357 themeTweakerStyle.innerHTML =
1358 `#theme-tweaker-ui .main-theme-tweaker-window {
1362 padding: 30px 0 0 0;
1367 #theme-tweaker-ui::after {
1371 #theme-tweaker-ui::before {
1375 #theme-tweaker-ui .clippy-container {
1378 #theme-tweaker-ui .clippy-container .hint span {
1384 #content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1385 pointer-events: none;
1387 event.target.addClass("maximize");
1389 event.target.removeClass("maximize");
1390 themeTweakerStyle.innerHTML =
1391 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1392 pointer-events: none;
1394 event.target.addClass("minimize");
1397 themeTweakerUI.query(".help-button").addActivateEvent(GW.themeTweaker.helpButtonClicked = (event) => {
1398 themeTweakerUI.query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"];
1399 toggleThemeTweakerHelpWindow();
1401 themeTweakerUI.query(".reset-defaults-button").addActivateEvent(GW.themeTweaker.resetDefaultsButtonClicked = (event) => {
1402 themeTweakerUI.query("#theme-tweak-control-invert").checked = false;
1403 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1404 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1405 slider.value = slider.dataset['defaultValue'];
1406 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1408 GW.currentFilters = { };
1409 applyFilters(GW.currentFilters);
1411 GW.currentTextZoom = "1.0";
1412 setTextZoom(GW.currentTextZoom);
1414 setSelectedTheme("default");
1416 themeTweakerUI.query(".main-theme-tweaker-window .cancel-button").addActivateEvent(GW.themeTweaker.cancelButtonClicked = (event) => {
1417 toggleThemeTweakerUI();
1420 themeTweakerUI.query(".main-theme-tweaker-window .ok-button").addActivateEvent(GW.themeTweaker.OKButtonClicked = (event) => {
1421 toggleThemeTweakerUI();
1424 themeTweakerUI.query(".help-window .cancel-button").addActivateEvent(GW.themeTweaker.helpWindowCancelButtonClicked = (event) => {
1425 toggleThemeTweakerHelpWindow();
1426 themeTweakerResetSettings();
1428 themeTweakerUI.query(".help-window .ok-button").addActivateEvent(GW.themeTweaker.helpWindowOKButtonClicked = (event) => {
1429 toggleThemeTweakerHelpWindow();
1430 themeTweakerSaveSettings();
1433 themeTweakerUI.queryAll(".notch").forEach(notch => {
1434 notch.addActivateEvent(function (event) {
1435 let slider = event.target.parentElement.query("input[type='range']");
1436 slider.value = slider.dataset['defaultValue'];
1437 event.target.parentElement.query(".theme-tweak-control-label").innerText = slider.value + slider.dataset['labelSuffix'];
1438 GW.currentFilters[/^theme-tweak-control-(.+)$/.exec(slider.id)[1]] = slider.value + slider.dataset['valueSuffix'];
1439 applyFilters(GW.currentFilters);
1443 themeTweakerUI.query(".clippy-close-button").addActivateEvent(GW.themeTweaker.clippyCloseButtonClicked = (event) => {
1444 themeTweakerUI.query(".clippy-container").style.display = "none";
1445 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': false }));
1446 themeTweakerUI.query("#theme-tweak-control-clippy").checked = false;
1449 query("head").insertAdjacentHTML("beforeend","<style id='theme-tweaker-style'></style>");
1451 themeTweakerUI.query(".theme-selector").innerHTML = query("#theme-selector").innerHTML;
1452 themeTweakerUI.queryAll(".theme-selector button").forEach(button => {
1453 button.addActivateEvent(GW.themeSelectButtonClicked);
1456 themeTweakerUI.queryAll("#theme-tweak-section-text-size-adjust button").forEach(button => {
1457 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1460 let themeTweakerToggle = addUIElement(`<div id='theme-tweaker-toggle'><button type='button' tabindex='-1' title="Customize appearance [;]" accesskey=';'></button></div>`);
1461 themeTweakerToggle.query("button").addActivateEvent(GW.themeTweaker.toggleButtonClicked = (event) => {
1462 GW.themeTweakerStyleSheetAvailable = () => {
1463 themeTweakerUI.query(".current-theme span").innerText = (readCookie("theme") || "default");
1465 themeTweakerUI.query("#theme-tweak-control-invert").checked = (GW.currentFilters['invert'] == "100%");
1466 [ "saturate", "brightness", "contrast", "hue-rotate" ].forEach(sliderName => {
1467 let slider = themeTweakerUI.query("#theme-tweak-control-" + sliderName);
1468 slider.value = /^[0-9]+/.exec(GW.currentFilters[sliderName]) || slider.dataset['defaultValue'];
1469 themeTweakerUI.query("#theme-tweak-label-" + sliderName).innerText = slider.value + slider.dataset['labelSuffix'];
1472 toggleThemeTweakerUI();
1473 event.target.disabled = true;
1476 if (query("link[href^='/theme_tweaker.css']")) {
1477 GW.themeTweakerStyleSheetAvailable();
1479 // Load the theme tweaker CSS (if not loaded).
1480 let themeTweakerStyleSheet = document.createElement('link');
1481 themeTweakerStyleSheet.setAttribute('rel', 'stylesheet');
1482 themeTweakerStyleSheet.setAttribute('href', '/theme_tweaker.css');
1483 themeTweakerStyleSheet.addEventListener('load', GW.themeTweakerStyleSheetAvailable);
1484 query("head").appendChild(themeTweakerStyleSheet);
1488 function toggleThemeTweakerUI() {
1489 GWLog("toggleThemeTweakerUI");
1490 let themeTweakerUI = query("#theme-tweaker-ui");
1491 themeTweakerUI.style.display = (themeTweakerUI.style.display == "none") ? "block" : "none";
1492 query("#theme-tweaker-style").innerHTML = (themeTweakerUI.style.display == "none") ? "" :
1493 `#content, #ui-elements-container > div:not(#theme-tweaker-ui) {
1494 pointer-events: none;
1496 if (themeTweakerUI.style.display != "none") {
1497 // Save selected theme.
1498 GW.currentTheme = (readCookie("theme") || "default");
1499 // Focus invert checkbox.
1500 query("#theme-tweaker-ui #theme-tweak-control-invert").focus();
1501 // Show sample text in appropriate font.
1502 updateThemeTweakerSampleText();
1503 // Disable tab-selection of the search box.
1504 setSearchBoxTabSelectable(false);
1506 query("#theme-tweaker-toggle button").disabled = false;
1507 // Re-enable tab-selection of the search box.
1508 setSearchBoxTabSelectable(true);
1510 // Set theme tweaker assistant visibility.
1511 query(".clippy-container").style.display = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')["showClippy"] ? "block" : "none";
1513 function setSearchBoxTabSelectable(selectable) {
1514 GWLog("setSearchBoxTabSelectable");
1515 query("input[type='search']").tabIndex = selectable ? "" : "-1";
1516 query("input[type='search'] + button").tabIndex = selectable ? "" : "-1";
1518 function toggleThemeTweakerHelpWindow() {
1519 GWLog("toggleThemeTweakerHelpWindow");
1520 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
1521 themeTweakerHelpWindow.style.display = (themeTweakerHelpWindow.style.display == "none") ? "block" : "none";
1522 if (themeTweakerHelpWindow.style.display != "none") {
1523 // Focus theme tweaker assistant checkbox.
1524 query("#theme-tweaker-ui #theme-tweak-control-clippy").focus();
1525 // Disable interaction on main theme tweaker window.
1526 query("#theme-tweaker-ui").style.pointerEvents = "none";
1527 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "none";
1529 // Re-enable interaction on main theme tweaker window.
1530 query("#theme-tweaker-ui").style.pointerEvents = "auto";
1531 query("#theme-tweaker-ui .main-theme-tweaker-window").style.pointerEvents = "auto";
1534 function themeTweakReset() {
1535 GWLog("themeTweakReset");
1536 setSelectedTheme(GW.currentTheme);
1537 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
1538 applyFilters(GW.currentFilters);
1539 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1540 setTextZoom(GW.currentTextZoom);
1542 function themeTweakSave() {
1543 GWLog("themeTweakSave");
1544 GW.currentTheme = (readCookie("theme") || "default");
1545 localStorage.setItem("theme-tweaks", JSON.stringify(GW.currentFilters));
1546 localStorage.setItem("text-zoom", GW.currentTextZoom);
1549 function themeTweakerResetSettings() {
1550 GWLog("themeTweakerResetSettings");
1551 query("#theme-tweak-control-clippy").checked = JSON.parse(localStorage.getItem("theme-tweaker-settings") || '{ "showClippy": true }')['showClippy'];
1552 query(".clippy-container").style.display = query("#theme-tweak-control-clippy").checked ? "block" : "none";
1554 function themeTweakerSaveSettings() {
1555 GWLog("themeTweakerSaveSettings");
1556 localStorage.setItem("theme-tweaker-settings", JSON.stringify({ 'showClippy': query("#theme-tweak-control-clippy").checked }));
1558 function updateThemeTweakerSampleText() {
1559 GWLog("updateThemeTweakerSampleText");
1560 let sampleText = query("#theme-tweaker-ui #theme-tweak-section-sample-text .sample-text");
1562 // This causes the sample text to take on the properties of the body text of a post.
1563 sampleText.removeClass("post-body");
1564 let bodyTextElement = query(".post-body") || query(".comment-body");
1565 sampleText.addClass("post-body");
1566 sampleText.style.color = bodyTextElement ?
1567 getComputedStyle(bodyTextElement).color :
1568 getComputedStyle(query("#content")).color;
1570 // Here we find out what is the actual background color that will be visible behind
1571 // the body text of posts, and set the sample text’s background to that.
1572 var backgroundElement = query("#content");
1573 let searchField = query("#nav-item-search input");
1574 if (!(getComputedStyle(searchField).backgroundColor == "" ||
1575 getComputedStyle(searchField).backgroundColor == "rgba(0, 0, 0, 0)"))
1576 backgroundElement = searchField;
1577 else while (getComputedStyle(backgroundElement).backgroundColor == "" ||
1578 getComputedStyle(backgroundElement).backgroundColor == "rgba(0, 0, 0, 0)")
1579 backgroundElement = backgroundElement.parentElement;
1580 sampleText.parentElement.style.backgroundColor = getComputedStyle(backgroundElement).backgroundColor;
1583 /*********************/
1584 /* PAGE QUICK-NAV UI */
1585 /*********************/
1587 function injectQuickNavUI() {
1588 GWLog("injectQuickNavUI");
1589 let quickNavContainer = addUIElement("<div id='quick-nav-ui'>" +
1590 `<a href='#top' title="Up to top [,]" accesskey=','></a>
1591 <a href='#comments' title="Comments [/]" accesskey='/'></a>
1592 <a href='#bottom-bar' title="Down to bottom [.]" accesskey='.'></a>
1596 /**********************/
1597 /* NEW COMMENT NAV UI */
1598 /**********************/
1600 function injectNewCommentNavUI(newCommentsCount) {
1601 GWLog("injectNewCommentNavUI");
1602 let newCommentUIContainer = addUIElement("<div id='new-comment-nav-ui'>" +
1603 `<button type='button' class='new-comment-sequential-nav-button new-comment-previous' title='Previous new comment (,)' tabindex='-1'></button>
1604 <span class='new-comments-count'></span>
1605 <button type='button' class='new-comment-sequential-nav-button new-comment-next' title='Next new comment (.)' tabindex='-1'></button>`
1608 newCommentUIContainer.queryAll(".new-comment-sequential-nav-button").forEach(button => {
1609 button.addActivateEvent(GW.commentQuicknavButtonClicked = (event) => {
1610 scrollToNewComment(/next/.test(event.target.className));
1611 event.target.blur();
1615 document.addEventListener("keyup", (event) => {
1616 if (event.shiftKey || event.ctrlKey || event.altKey) return;
1617 if (event.key == ",") scrollToNewComment(false);
1618 if (event.key == ".") scrollToNewComment(true)
1621 let hnsDatePicker = addUIElement("<div id='hns-date-picker'>"
1622 + `<span>Since:</span>`
1623 + `<input type='text' class='hns-date'></input>`
1626 hnsDatePicker.query("input").addEventListener("input", GW.hnsDatePickerValueChanged = (event) => {
1627 let hnsDate = time_fromHuman(event.target.value);
1628 let newCommentsCount = highlightCommentsSince(hnsDate);
1629 updateNewCommentNavUI(newCommentsCount);
1632 newCommentUIContainer.query(".new-comments-count").addActivateEvent(GW.newCommentsCountClicked = (event) => {
1633 let hnsDatePickerVisible = (getComputedStyle(hnsDatePicker).display != "none");
1634 hnsDatePicker.style.display = hnsDatePickerVisible ? "none" : "block";
1638 // time_fromHuman() function copied from https://bakkot.github.io/SlateStarComments/ssc.js
1639 function time_fromHuman(string) {
1640 /* Convert a human-readable date into a JS timestamp */
1641 if (string.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
1642 string = string.replace(' ', 'T'); // revert nice spacing
1643 string += ':00.000Z'; // complete ISO 8601 date
1644 time = Date.parse(string); // milliseconds since epoch
1646 // browsers handle ISO 8601 without explicit timezone differently
1647 // thus, we have to fix that by hand
1648 time += (new Date()).getTimezoneOffset() * 60e3;
1650 string = string.replace(' at', '');
1651 time = Date.parse(string); // milliseconds since epoch
1656 function updateNewCommentNavUI(newCommentsCount, hnsDate = -1) {
1657 GWLog("updateNewCommentNavUI");
1658 // Update the new comments count.
1659 let newCommentsCountLabel = query("#new-comment-nav-ui .new-comments-count");
1660 newCommentsCountLabel.innerText = newCommentsCount;
1661 newCommentsCountLabel.title = `${newCommentsCount} new comments`;
1663 // Update the date picker field.
1664 if (hnsDate != -1) {
1665 query("#hns-date-picker input").value = (new Date(+ hnsDate - (new Date()).getTimezoneOffset() * 60e3)).toISOString().slice(0, 16).replace('T', ' ');
1669 /***************************/
1670 /* TEXT SIZE ADJUSTMENT UI */
1671 /***************************/
1673 GW.themeTweaker.textSizeAdjustButtonClicked = (event) => {
1674 GWLog("GW.themeTweaker.textSizeAdjustButtonClicked");
1675 var zoomFactor = parseFloat(GW.currentTextZoom) || 1.0;
1676 if (event.target.hasClass("decrease")) {
1677 zoomFactor = (zoomFactor - 0.05).toFixed(2);
1678 } else if (event.target.hasClass("increase")) {
1679 zoomFactor = (zoomFactor + 0.05).toFixed(2);
1683 setTextZoom(zoomFactor);
1684 GW.currentTextZoom = `${zoomFactor}`;
1686 if (event.target.parentElement.id == "text-size-adjustment-ui") {
1687 localStorage.setItem("text-zoom", GW.currentTextZoom);
1691 function injectTextSizeAdjustmentUIReal() {
1692 GWLog("injectTextSizeAdjustmentUIReal");
1693 let textSizeAdjustmentUIContainer = addUIElement("<div id='text-size-adjustment-ui'>"
1694 + `<button type='button' class='text-size-adjust-button decrease' title="Decrease text size [-]" tabindex='-1' accesskey='-'></button>`
1695 + `<button type='button' class='text-size-adjust-button default' title="Reset to default text size [0]" tabindex='-1' accesskey='0'>A</button>`
1696 + `<button type='button' class='text-size-adjust-button increase' title="Increase text size [=]" tabindex='-1' accesskey='='></button>`
1699 textSizeAdjustmentUIContainer.queryAll("button").forEach(button => {
1700 button.addActivateEvent(GW.themeTweaker.textSizeAdjustButtonClicked);
1703 GW.currentTextZoom = `${parseFloat(localStorage.getItem("text-zoom")) || 1.0}`;
1706 function injectTextSizeAdjustmentUI() {
1707 GWLog("injectTextSizeAdjustmentUI");
1708 if (query("#text-size-adjustment-ui") != null) return;
1709 if (query("#content.post-page") != null) injectTextSizeAdjustmentUIReal();
1710 else document.addEventListener("DOMContentLoaded", () => {
1711 if (!(query(".post-body") == null && query(".comment-body") == null)) injectTextSizeAdjustmentUIReal();
1715 /********************************/
1716 /* COMMENTS VIEW MODE SELECTION */
1717 /********************************/
1719 function injectCommentsViewModeSelector() {
1720 GWLog("injectCommentsViewModeSelector");
1721 let commentsContainer = query("#comments");
1722 if (commentsContainer == null) return;
1724 let currentModeThreaded = (location.href.search("chrono=t") == -1);
1725 let newHref = "href='" + location.pathname + location.search.replace("chrono=t","") + (currentModeThreaded ? ((location.search == "" ? "?" : "&") + "chrono=t") : "") + location.hash + "' ";
1727 let commentsViewModeSelector = addUIElement("<div id='comments-view-mode-selector'>"
1728 + `<a class="threaded ${currentModeThreaded ? 'selected' : ''}" ${currentModeThreaded ? "" : newHref} ${currentModeThreaded ? "" : "accesskey='x' "} title='Comments threaded view${currentModeThreaded ? "" : " [x]"}'></a>`
1729 + `<a class="chrono ${currentModeThreaded ? '' : 'selected'}" ${currentModeThreaded ? newHref : ""} ${currentModeThreaded ? "accesskey='x' " : ""} title='Comments chronological (flat) view${currentModeThreaded ? " [x]" : ""}'></a>`
1732 // commentsViewModeSelector.queryAll("a").forEach(button => {
1733 // button.addActivateEvent(commentsViewModeSelectorButtonClicked);
1736 if (!currentModeThreaded) {
1737 queryAll(".comment-meta > a.comment-parent-link").forEach(commentParentLink => {
1738 commentParentLink.textContent = query(commentParentLink.hash).query(".author").textContent;
1739 commentParentLink.addClass("inline-author");
1740 commentParentLink.outerHTML = "<div class='comment-parent-link'>in reply to: " + commentParentLink.outerHTML + "</div>";
1743 queryAll(".comment-child-links a").forEach(commentChildLink => {
1744 commentChildLink.textContent = commentChildLink.textContent.slice(1);
1745 commentChildLink.addClasses([ "inline-author", "comment-child-link" ]);
1748 rectifyChronoModeCommentChildLinks();
1750 commentsContainer.addClass("chrono");
1752 commentsContainer.addClass("threaded");
1755 // Remove extraneous top-level comment thread in chrono mode.
1756 let topLevelCommentThread = query("#comments > .comment-thread");
1757 if (topLevelCommentThread.children.length == 0) removeElement(topLevelCommentThread);
1760 // function commentsViewModeSelectorButtonClicked(event) {
1761 // event.preventDefault();
1764 // let request = new XMLHttpRequest();
1765 // request.open("GET", event.target.href);
1766 // request.onreadystatechange = () => {
1767 // if (request.readyState != 4) return;
1768 // newDocument = htmlToElement(request.response);
1770 // let classes = event.target.hasClass("threaded") ? { "old": "chrono", "new": "threaded" } : { "old": "threaded", "new": "chrono" };
1772 // // Update the buttons.
1773 // event.target.addClass("selected");
1774 // event.target.parentElement.query("." + classes.old).removeClass("selected");
1776 // // Update the #comments container.
1777 // let commentsContainer = query("#comments");
1778 // commentsContainer.removeClass(classes.old);
1779 // commentsContainer.addClass(classes.new);
1781 // // Update the content.
1782 // commentsContainer.outerHTML = newDocument.query("#comments").outerHTML;
1787 // function htmlToElement(html) {
1788 // var template = document.createElement('template');
1789 // template.innerHTML = html.trim();
1790 // return template.content;
1793 function rectifyChronoModeCommentChildLinks() {
1794 GWLog("rectifyChronoModeCommentChildLinks");
1795 queryAll(".comment-child-links").forEach(commentChildLinksContainer => {
1796 let children = childrenOfComment(commentChildLinksContainer.closest(".comment-item").id);
1797 let childLinks = commentChildLinksContainer.queryAll("a");
1798 childLinks.forEach((link, index) => {
1799 link.href = "#" + children.find(child => child.query(".author").textContent == link.textContent).id;
1803 let childLinksArray = Array.from(childLinks)
1804 childLinksArray.sort((a,b) => query(`${a.hash} .date`).dataset["jsDate"] - query(`${b.hash} .date`).dataset["jsDate"]);
1805 commentChildLinksContainer.innerHTML = "Replies: " + childLinksArray.map(childLink => childLink.outerHTML).join("");
1808 function childrenOfComment(commentID) {
1809 return Array.from(queryAll(`#${commentID} ~ .comment-item`)).filter(commentItem => {
1810 let commentParentLink = commentItem.query("a.comment-parent-link");
1811 return ((commentParentLink||{}).hash == "#" + commentID);
1815 /********************************/
1816 /* COMMENTS LIST MODE SELECTION */
1817 /********************************/
1819 function injectCommentsListModeSelector() {
1820 GWLog("injectCommentsListModeSelector");
1821 if (query("#content > .comment-thread") == null) return;
1823 let commentsListModeSelectorHTML = "<div id='comments-list-mode-selector'>"
1824 + `<button type='button' class='expanded' title='Expanded comments view' tabindex='-1'></button>`
1825 + `<button type='button' class='compact' title='Compact comments view' tabindex='-1'></button>`
1827 (query("#content.user-page .user-stats") || query(".page-toolbar") || query(".active-bar")).insertAdjacentHTML("afterend", commentsListModeSelectorHTML);
1828 let commentsListModeSelector = query("#comments-list-mode-selector");
1830 commentsListModeSelector.queryAll("button").forEach(button => {
1831 button.addActivateEvent(GW.commentsListModeSelectButtonClicked = (event) => {
1832 event.target.parentElement.queryAll("button").forEach(button => {
1833 button.removeClass("selected");
1834 button.disabled = false;
1835 button.accessKey = '`';
1837 localStorage.setItem("comments-list-mode", event.target.className);
1838 event.target.addClass("selected");
1839 event.target.disabled = true;
1840 event.target.removeAttribute("accesskey");
1842 if (event.target.hasClass("expanded")) {
1843 query("#content").removeClass("compact");
1845 query("#content").addClass("compact");
1850 let savedMode = (localStorage.getItem("comments-list-mode") == "compact") ? "compact" : "expanded";
1851 if (savedMode == "compact")
1852 query("#content").addClass("compact");
1853 commentsListModeSelector.query(`.${savedMode}`).addClass("selected");
1854 commentsListModeSelector.query(`.${savedMode}`).disabled = true;
1855 commentsListModeSelector.query(`.${(savedMode == "compact" ? "expanded" : "compact")}`).accessKey = '`';
1858 queryAll("#comments-list-mode-selector ~ .comment-thread").forEach(commentParentLink => {
1859 commentParentLink.addActivateEvent(function (event) {
1860 let parentCommentThread = event.target.closest("#content.compact .comment-thread");
1861 if (parentCommentThread) parentCommentThread.toggleClass("expanded");
1867 /**********************/
1868 /* SITE NAV UI TOGGLE */
1869 /**********************/
1871 function injectSiteNavUIToggle() {
1872 GWLog("injectSiteNavUIToggle");
1873 let siteNavUIToggle = addUIElement("<div id='site-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
1874 siteNavUIToggle.query("button").addActivateEvent(GW.siteNavUIToggleButtonClicked = (event) => {
1876 localStorage.setItem("site-nav-ui-toggle-engaged", event.target.hasClass("engaged"));
1879 if (!GW.isMobile && localStorage.getItem("site-nav-ui-toggle-engaged") == "true") toggleSiteNavUI();
1881 function removeSiteNavUIToggle() {
1882 GWLog("removeSiteNavUIToggle");
1883 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
1884 element.removeClass("engaged");
1886 removeElement("#site-nav-ui-toggle");
1888 function toggleSiteNavUI() {
1889 GWLog("toggleSiteNavUI");
1890 queryAll("#primary-bar, #secondary-bar, .page-toolbar, #site-nav-ui-toggle button").forEach(element => {
1891 element.toggleClass("engaged");
1892 element.removeClass("translucent-on-scroll");
1896 /**********************/
1897 /* POST NAV UI TOGGLE */
1898 /**********************/
1900 function injectPostNavUIToggle() {
1901 GWLog("injectPostNavUIToggle");
1902 let postNavUIToggle = addUIElement("<div id='post-nav-ui-toggle'><button type='button' tabindex='-1'></button></div>");
1903 postNavUIToggle.query("button").addActivateEvent(GW.postNavUIToggleButtonClicked = (event) => {
1905 localStorage.setItem("post-nav-ui-toggle-engaged", localStorage.getItem("post-nav-ui-toggle-engaged") != "true");
1908 if (localStorage.getItem("post-nav-ui-toggle-engaged") == "true") togglePostNavUI();
1910 function removePostNavUIToggle() {
1911 GWLog("removePostNavUIToggle");
1912 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
1913 element.removeClass("engaged");
1915 removeElement("#post-nav-ui-toggle");
1917 function togglePostNavUI() {
1918 GWLog("togglePostNavUI");
1919 queryAll("#quick-nav-ui, #new-comment-nav-ui, #hns-date-picker, #post-nav-ui-toggle button").forEach(element => {
1920 element.toggleClass("engaged");
1924 /*******************************/
1925 /* APPEARANCE ADJUST UI TOGGLE */
1926 /*******************************/
1928 function injectAppearanceAdjustUIToggle() {
1929 GWLog("injectAppearanceAdjustUIToggle");
1930 let appearanceAdjustUIToggle = addUIElement("<div id='appearance-adjust-ui-toggle'><button type='button' tabindex='-1'></button></div>");
1931 appearanceAdjustUIToggle.query("button").addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked = (event) => {
1932 toggleAppearanceAdjustUI();
1933 localStorage.setItem("appearance-adjust-ui-toggle-engaged", event.target.hasClass("engaged"));
1937 let themeSelectorCloseButton = appearanceAdjustUIToggle.query("button").cloneNode(true);
1938 themeSelectorCloseButton.addClass("theme-selector-close-button");
1939 themeSelectorCloseButton.innerHTML = "";
1940 query("#theme-selector").appendChild(themeSelectorCloseButton);
1941 themeSelectorCloseButton.addActivateEvent(GW.appearanceAdjustUIToggleButtonClicked);
1943 if (localStorage.getItem("appearance-adjust-ui-toggle-engaged") == "true") toggleAppearanceAdjustUI();
1946 function removeAppearanceAdjustUIToggle() {
1947 GWLog("removeAppearanceAdjustUIToggle");
1948 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
1949 element.removeClass("engaged");
1951 removeElement("#appearance-adjust-ui-toggle");
1953 function toggleAppearanceAdjustUI() {
1954 GWLog("toggleAppearanceAdjustUI");
1955 queryAll("#comments-view-mode-selector, #theme-selector, #width-selector, #text-size-adjustment-ui, #theme-tweaker-toggle, #appearance-adjust-ui-toggle button").forEach(element => {
1956 element.toggleClass("engaged");
1960 /*****************************/
1961 /* MINIMIZED THREAD HANDLING */
1962 /*****************************/
1964 function expandAncestorsOf(comment) {
1965 GWLog("expandAncestorsOf");
1966 if (typeof comment == "string") {
1967 comment = /(?:comment-)?(.+)/.exec(comment)[1];
1968 comment = query("#comment-" + comment);
1971 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
1975 // Expand collapsed comment threads.
1976 let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
1977 if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
1979 // Expand collapsed comments.
1980 let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
1981 if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
1984 /**************************/
1985 /* WORD COUNT & READ TIME */
1986 /**************************/
1988 function toggleReadTimeOrWordCount(addWordCountClass) {
1989 GWLog("toggleReadTimeOrWordCount");
1990 queryAll(".post-meta .read-time").forEach(element => {
1991 if (addWordCountClass) element.addClass("word-count");
1992 else element.removeClass("word-count");
1994 let titleParts = /(\S+)(.+)$/.exec(element.title);
1995 [ element.innerHTML, element.title ] = [ `${titleParts[1]}<span>${titleParts[2]}</span>`, element.textContent ];
1999 /**************************/
2000 /* PROMPT TO SAVE CHANGES */
2001 /**************************/
2003 function enableBeforeUnload() {
2004 window.onbeforeunload = function () { return true; };
2006 function disableBeforeUnload() {
2007 window.onbeforeunload = null;
2010 /***************************/
2011 /* ORIGINAL POSTER BADGING */
2012 /***************************/
2014 function markOriginalPosterComments() {
2015 GWLog("markOriginalPosterComments");
2016 let postAuthor = query(".post .author");
2017 if (postAuthor == null) return;
2019 queryAll(".comment-item .author, .comment-item .inline-author").forEach(author => {
2020 if (author.dataset.userid == postAuthor.dataset.userid ||
2021 (author.hash != "" && query(`${author.hash} .author`).dataset.userid == postAuthor.dataset.userid)) {
2022 author.addClass("original-poster");
2023 author.title += "Original poster";
2028 /********************************/
2029 /* EDIT POST PAGE SUBMIT BUTTON */
2030 /********************************/
2032 function setEditPostPageSubmitButtonText() {
2033 GWLog("setEditPostPageSubmitButtonText");
2034 if (!query("#content").hasClass("edit-post-page")) return;
2036 queryAll("input[type='radio'][name='section'], .question-checkbox").forEach(radio => {
2037 radio.addEventListener("change", GW.postSectionSelectorValueChanged = (event) => {
2038 updateEditPostPageSubmitButtonText();
2042 updateEditPostPageSubmitButtonText();
2044 function updateEditPostPageSubmitButtonText() {
2045 GWLog("updateEditPostPageSubmitButtonText");
2046 let submitButton = query("input[type='submit']");
2047 if (query("input#drafts").checked == true)
2048 submitButton.value = "Save Draft";
2049 else if (query(".posting-controls").hasClass("edit-existing-post"))
2050 submitButton.value = query(".question-checkbox").checked ? "Save Question" : "Save Post";
2052 submitButton.value = query(".question-checkbox").checked ? "Submit Question" : "Submit Post";
2059 function numToAlpha(n) {
2062 ret = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + ret;
2063 n = Math.floor((n / 26) - 1);
2068 function injectAntiKibitzer() {
2069 GWLog("injectAntiKibitzer");
2070 // Inject anti-kibitzer toggle controls.
2071 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>");
2072 antiKibitzerToggle.query("button").addActivateEvent(GW.antiKibitzerToggleButtonClicked = (event) => {
2073 if (query("#anti-kibitzer-toggle").hasClass("engaged") &&
2075 !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!)")) {
2076 event.target.blur();
2080 toggleAntiKibitzerMode();
2081 event.target.blur();
2084 // Activate anti-kibitzer mode (if needed).
2085 if (localStorage.getItem("antikibitzer") == "true")
2086 toggleAntiKibitzerMode();
2088 // Remove temporary CSS that hides the authors and karma values.
2089 removeElement("#antikibitzer-temp");
2092 function toggleAntiKibitzerMode() {
2093 GWLog("toggleAntiKibitzerMode");
2094 // This will be the URL of the user's own page, if logged in, or the URL of
2095 // the login page otherwise.
2096 let userTabTarget = query("#nav-item-login .nav-inner").href;
2097 let pageHeadingElement = query("h1.page-main-heading");
2100 let userFakeName = { };
2102 let appellation = (query(".comment-thread-page") ? "Commenter" : "User");
2104 let postAuthor = query(".post-page .post-meta .author");
2105 if (postAuthor) userFakeName[postAuthor.dataset["userid"]] = "Original Poster";
2107 let antiKibitzerToggle = query("#anti-kibitzer-toggle");
2108 if (antiKibitzerToggle.hasClass("engaged")) {
2109 localStorage.setItem("antikibitzer", "false");
2111 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["kibitzerRedirect"];
2112 if (redirectTarget) {
2113 window.location = redirectTarget;
2117 // Individual comment page title and header
2118 if (query(".individual-thread-page")) {
2119 let replacer = (node) => {
2121 node.firstChild.replaceWith(node.dataset["trueContent"]);
2123 replacer(query("title:not(.fake-title)"));
2124 replacer(query("#content > h1"));
2127 // Author names/links.
2128 queryAll(".author.redacted, .inline-author.redacted").forEach(author => {
2129 author.textContent = author.dataset["trueName"];
2130 if (/\/user/.test(author.href)) author.href = author.dataset["trueLink"];
2132 author.removeClass("redacted");
2134 // Post/comment karma values.
2135 queryAll(".karma-value.redacted").forEach(karmaValue => {
2136 karmaValue.innerHTML = karmaValue.dataset["trueValue"] + karmaValue.lastChild.outerHTML;
2137 karmaValue.lastChild.textContent = (parseInt(karmaValue.dataset["trueValue"]) == 1) ? " point" : " points";
2139 karmaValue.removeClass("redacted");
2141 // Link post domains.
2142 queryAll(".link-post-domain.redacted").forEach(linkPostDomain => {
2143 linkPostDomain.textContent = linkPostDomain.dataset["trueDomain"];
2145 linkPostDomain.removeClass("redacted");
2148 antiKibitzerToggle.removeClass("engaged");
2150 localStorage.setItem("antikibitzer", "true");
2152 let redirectTarget = pageHeadingElement && pageHeadingElement.dataset["antiKibitzerRedirect"];
2153 if (redirectTarget) {
2154 window.location = redirectTarget;
2158 // Individual comment page title and header
2159 if (query(".individual-thread-page")) {
2160 let replacer = (node) => {
2162 node.dataset["trueContent"] = node.firstChild.wholeText;
2163 let newText = node.firstChild.wholeText.replace(/^.* comments/, "REDACTED comments");
2164 node.firstChild.replaceWith(newText);
2166 replacer(query("title:not(.fake-title)"));
2167 replacer(query("#content > h1"));
2170 removeElement("title.fake-title");
2172 // Author names/links.
2173 queryAll(".author, .inline-author").forEach(author => {
2174 // Skip own posts/comments.
2175 if (author.hasClass("own-user-author"))
2178 let userid = author.dataset["userid"] || query(`${author.hash} .author`).dataset["userid"];
2180 author.dataset["trueName"] = author.textContent;
2181 author.textContent = userFakeName[userid] || (userFakeName[userid] = appellation + " " + numToAlpha(userCount++));
2183 if (/\/user/.test(author.href)) {
2184 author.dataset["trueLink"] = author.pathname;
2185 author.href = "/user?id=" + author.dataset["userid"];
2188 author.addClass("redacted");
2190 // Post/comment karma values.
2191 queryAll(".karma-value").forEach(karmaValue => {
2192 // Skip own posts/comments.
2193 if ((karmaValue.closest(".comment-item") || karmaValue.closest(".post-meta")).query(".author").hasClass("own-user-author"))
2196 karmaValue.dataset["trueValue"] = karmaValue.firstChild.textContent;
2197 karmaValue.innerHTML = "##" + karmaValue.lastChild.outerHTML;
2198 karmaValue.lastChild.textContent = " points";
2200 karmaValue.addClass("redacted");
2202 // Link post domains.
2203 queryAll(".link-post-domain").forEach(linkPostDomain => {
2204 // Skip own posts/comments.
2205 if (userTabTarget == linkPostDomain.closest(".post-meta").query(".author").href)
2208 linkPostDomain.dataset["trueDomain"] = linkPostDomain.textContent;
2209 linkPostDomain.textContent = "redacted.domain.tld";
2211 linkPostDomain.addClass("redacted");
2214 antiKibitzerToggle.addClass("engaged");
2218 /*******************************/
2219 /* COMMENT SORT MODE SELECTION */
2220 /*******************************/
2222 var CommentSortMode = Object.freeze({
2228 function sortComments(mode) {
2229 GWLog("sortComments");
2230 let commentsContainer = query("#comments");
2232 commentsContainer.removeClass(/(sorted-\S+)/.exec(commentsContainer.className)[1]);
2233 commentsContainer.addClass("sorting");
2235 GW.commentValues = { };
2236 let clonedCommentsContainer = commentsContainer.cloneNode(true);
2237 clonedCommentsContainer.queryAll(".comment-thread").forEach(commentThread => {
2240 case CommentSortMode.NEW:
2241 comparator = (a,b) => commentDate(b) - commentDate(a);
2243 case CommentSortMode.OLD:
2244 comparator = (a,b) => commentDate(a) - commentDate(b);
2246 case CommentSortMode.HOT:
2247 comparator = (a,b) => commentVoteCount(b) - commentVoteCount(a);
2249 case CommentSortMode.TOP:
2251 comparator = (a,b) => commentKarmaValue(b) - commentKarmaValue(a);
2254 Array.from(commentThread.childNodes).sort(comparator).forEach(commentItem => { commentThread.appendChild(commentItem); })
2256 removeElement(commentsContainer.lastChild);
2257 commentsContainer.appendChild(clonedCommentsContainer.lastChild);
2258 GW.commentValues = { };
2260 if (loggedInUserId) {
2261 // Re-activate vote buttons.
2262 commentsContainer.queryAll("button.vote").forEach(voteButton => {
2263 voteButton.addActivateEvent(voteButtonClicked);
2266 // Re-activate comment action buttons.
2267 commentsContainer.queryAll(".action-button").forEach(button => {
2268 button.addActivateEvent(GW.commentActionButtonClicked);
2272 // Re-activate comment-minimize buttons.
2273 queryAll(".comment-minimize-button").forEach(button => {
2274 button.addActivateEvent(GW.commentMinimizeButtonClicked);
2277 // Re-add comment parent popups.
2278 addCommentParentPopups();
2280 // Redo new-comments highlighting.
2281 highlightCommentsSince(time_fromHuman(query("#hns-date-picker input").value));
2283 requestAnimationFrame(() => {
2284 commentsContainer.removeClass("sorting");
2285 commentsContainer.addClass("sorted-" + mode);
2288 function commentKarmaValue(commentOrSelector) {
2289 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2290 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").firstChild.textContent));
2292 function commentDate(commentOrSelector) {
2293 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2294 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".date").dataset.jsDate));
2296 function commentVoteCount(commentOrSelector) {
2297 if (typeof commentOrSelector == "string") commentOrSelector = query(commentOrSelector);
2298 return GW.commentValues[commentOrSelector.id] || (GW.commentValues[commentOrSelector.id] = parseInt(commentOrSelector.query(".karma-value").title.split(" ")[0]));
2301 function injectCommentsSortModeSelector() {
2302 GWLog("injectCommentsSortModeSelector");
2303 let topCommentThread = query("#comments > .comment-thread");
2304 if (topCommentThread == null) return;
2306 // Do not show sort mode selector if there is no branching in comment tree.
2307 if (topCommentThread.query(".comment-item + .comment-item") == null) return;
2309 let commentsSortModeSelectorHTML = "<div id='comments-sort-mode-selector' class='sublevel-nav sort'>" +
2310 Object.values(CommentSortMode).map(sortMode => `<button type='button' class='sublevel-item sort-mode-${sortMode}' tabindex='-1' title='Sort by ${sortMode}'>${sortMode}</button>`).join("") +
2312 topCommentThread.insertAdjacentHTML("beforebegin", commentsSortModeSelectorHTML);
2313 let commentsSortModeSelector = query("#comments-sort-mode-selector");
2315 commentsSortModeSelector.queryAll("button").forEach(button => {
2316 button.addActivateEvent(GW.commentsSortModeSelectButtonClicked = (event) => {
2317 event.target.parentElement.queryAll("button").forEach(button => {
2318 button.removeClass("selected");
2319 button.disabled = false;
2321 event.target.addClass("selected");
2322 event.target.disabled = true;
2324 setTimeout(() => { sortComments(/sort-mode-(\S+)/.exec(event.target.className)[1]); });
2325 setCommentsSortModeSelectButtonsAccesskey();
2329 // TODO: Make this actually get the current sort mode (if that's saved).
2330 // TODO: Also change the condition here to properly get chrono/threaded mode,
2331 // when that is properly done with cookies.
2332 let currentSortMode = (location.href.search("chrono=t") == -1) ? CommentSortMode.TOP : CommentSortMode.OLD;
2333 topCommentThread.parentElement.addClass("sorted-" + currentSortMode);
2334 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).disabled = true;
2335 commentsSortModeSelector.query(".sort-mode-" + currentSortMode).addClass("selected");
2336 setCommentsSortModeSelectButtonsAccesskey();
2339 function setCommentsSortModeSelectButtonsAccesskey() {
2340 GWLog("setCommentsSortModeSelectButtonsAccesskey");
2341 queryAll("#comments-sort-mode-selector button").forEach(button => {
2342 button.removeAttribute("accesskey");
2343 button.title = /(.+?)( \[z\])?$/.exec(button.title)[1];
2345 let selectedButton = query("#comments-sort-mode-selector button.selected");
2346 let nextButtonInCycle = (selectedButton == selectedButton.parentElement.lastChild) ? selectedButton.parentElement.firstChild : selectedButton.nextSibling;
2347 nextButtonInCycle.accessKey = "z";
2348 nextButtonInCycle.title += " [z]";
2351 /*************************/
2352 /* COMMENT PARENT POPUPS */
2353 /*************************/
2355 function addCommentParentPopups() {
2356 GWLog("addCommentParentPopups");
2357 if (!query("#content").hasClass("comment-thread-page")) return;
2359 queryAll(".comment-meta a.comment-parent-link, .comment-meta a.comment-child-link").forEach(commentParentLink => {
2360 commentParentLink.addEventListener("mouseover", GW.commentParentLinkMouseOver = (event) => {
2361 let parentID = commentParentLink.getAttribute("href");
2363 if (!(parent = (query(parentID)||{}).firstChild)) return;
2364 var highlightClassName;
2365 if (parent.getBoundingClientRect().bottom < 10 || parent.getBoundingClientRect().top > window.innerHeight + 10) {
2366 parentHighlightClassName = "comment-item-highlight-faint";
2367 popup = parent.cloneNode(true);
2368 popup.addClasses([ "comment-popup", "comment-item-highlight" ]);
2369 commentParentLink.addEventListener("mouseout", (event) => {
2370 removeElement(popup);
2372 commentParentLink.closest(".comments > .comment-thread").appendChild(popup);
2374 parentHighlightClassName = "comment-item-highlight";
2376 parent.parentNode.addClass(parentHighlightClassName);
2377 commentParentLink.addEventListener("mouseout", (event) => {
2378 parent.parentNode.removeClass(parentHighlightClassName);
2383 // Due to filters vs. fixed elements, we need to be smarter about selecting which elements to filter...
2384 GW.themeTweaker.filtersExclusionPaths.commentParentPopups = [
2385 "#content .comments .comment-thread"
2387 applyFilters(GW.currentFilters);
2394 function imageFocusSetup(imagesOverlayOnly = false) {
2395 GWLog("imageFocusSetup");
2396 // Create event listener for clicking on images to focus them.
2397 GW.imageClickedToFocus = (event) => {
2398 focusImage(event.target);
2400 if (event.target.closest("#images-overlay")) {
2401 query("#image-focus-overlay .image-number").textContent = (getIndexOfFocusedImage() + 1);
2403 // Set timer to hide the image focus UI.
2404 resetImageFocusHideUITimer(true);
2407 // Add the listener to each image in the overlay (i.e., those in the post).
2408 queryAll("#images-overlay img").forEach(image => {
2409 image.addActivateEvent(GW.imageClickedToFocus);
2411 // Accesskey-L starts the slideshow.
2412 (query("#images-overlay img")||{}).accessKey = 'l';
2413 // Count how many images there are in the post, and set the "… of X" label to that.
2414 ((query("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = queryAll("#images-overlay img").length;
2415 if (imagesOverlayOnly) return;
2416 // Add the listener to all other content images (including those in comments).
2417 queryAll("#content img").forEach(image => {
2418 image.addActivateEvent(GW.imageClickedToFocus);
2421 // Create the image focus overlay.
2422 let imageFocusOverlay = addUIElement("<div id='image-focus-overlay'>" +
2423 `<div class='help-overlay'>
2424 <p><strong>Arrow keys:</strong> Next/previous image</p>
2425 <p><strong>Escape</strong> or <strong>click</strong>: Hide zoomed image</p>
2426 <p><strong>Space bar:</strong> Reset image size & position</p>
2427 <p><strong>Scroll</strong> to zoom in/out</p>
2428 <p>(When zoomed in, <strong>drag</strong> to pan; <br/><strong>double-click</strong> to close)</p>
2430 `<div class='image-number'></div>` +
2431 `<div class='slideshow-buttons'>
2432 <button type='button' class='slideshow-button previous' tabindex='-1' title='Previous image'></button>
2433 <button type='button' class='slideshow-button next' tabindex='-1' title='Next image'></button>
2436 imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
2438 imageFocusOverlay.queryAll(".slideshow-button").forEach(button => {
2439 button.addActivateEvent(GW.imageFocusSlideshowButtonClicked = (event) => {
2440 focusNextImage(event.target.hasClass("next"));
2441 event.target.blur();
2445 // On orientation change, reset the size & position.
2446 if (typeof(window.msMatchMedia || window.MozMatchMedia || window.WebkitMatchMedia || window.matchMedia) !== 'undefined') {
2447 window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
2450 // UI starts out hidden.
2454 function focusImage(imageToFocus) {
2455 GWLog("focusImage");
2456 // Clear 'last-focused' class of last focused image.
2457 let lastFocusedImage = query("img.last-focused");
2458 if (lastFocusedImage) {
2459 lastFocusedImage.removeClass("last-focused");
2460 lastFocusedImage.removeAttribute("accesskey");
2463 // Create the focused version of the image.
2464 imageToFocus.addClass("focused");
2465 let imageFocusOverlay = query("#image-focus-overlay");
2466 let clonedImage = imageToFocus.cloneNode(true);
2467 clonedImage.style = "";
2468 clonedImage.removeAttribute("width");
2469 clonedImage.removeAttribute("height");
2470 clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
2471 imageFocusOverlay.appendChild(clonedImage);
2472 imageFocusOverlay.addClass("engaged");
2474 // Set image to default size and position.
2475 resetFocusedImagePosition();
2477 // Blur everything else.
2478 queryAll("#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay").forEach(element => {
2479 element.addClass("blurred");
2482 // Add listener to zoom image with scroll wheel.
2483 window.addEventListener("wheel", GW.imageFocusScroll = (event) => {
2484 event.preventDefault();
2486 let image = query("#image-focus-overlay img");
2488 // Remove the filter.
2489 image.savedFilter = image.style.filter;
2490 image.style.filter = 'none';
2492 // Locate point under cursor.
2493 let imageBoundingBox = image.getBoundingClientRect();
2495 // Calculate resize factor.
2496 var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
2497 1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
2501 image.style.width = (event.deltaY < 0 ?
2502 (image.clientWidth * factor) :
2503 (image.clientWidth / factor))
2505 image.style.height = "";
2507 // Designate zoom origin.
2509 // Zoom from cursor if we're zoomed in to where image exceeds screen, AND
2510 // the cursor is over the image.
2511 let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
2512 let zoomingFromCursor = imageSizeExceedsWindowBounds &&
2513 (imageBoundingBox.left <= event.clientX &&
2514 event.clientX <= imageBoundingBox.right &&
2515 imageBoundingBox.top <= event.clientY &&
2516 event.clientY <= imageBoundingBox.bottom);
2517 // Otherwise, if we're zooming OUT, zoom from window center; if we're
2518 // zooming IN, zoom from image center.
2519 let zoomingFromWindowCenter = event.deltaY > 0;
2520 if (zoomingFromCursor)
2521 zoomOrigin = { x: event.clientX,
2523 else if (zoomingFromWindowCenter)
2524 zoomOrigin = { x: window.innerWidth / 2,
2525 y: window.innerHeight / 2 };
2527 zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
2528 y: imageBoundingBox.y + imageBoundingBox.height / 2 };
2530 // Calculate offset from zoom origin.
2531 let offsetOfImageFromZoomOrigin = {
2532 x: imageBoundingBox.x - zoomOrigin.x,
2533 y: imageBoundingBox.y - zoomOrigin.y
2535 // Calculate delta from centered zoom.
2536 let deltaFromCenteredZoom = {
2537 x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
2538 y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
2540 // Adjust image position appropriately.
2541 image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
2542 image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
2543 // Gradually re-center image, if it's smaller than the window.
2544 if (!imageSizeExceedsWindowBounds) {
2545 let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
2546 y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
2547 let windowCenter = { x: window.innerWidth / 2,
2548 y: window.innerHeight / 2 }
2549 let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
2550 y: windowCenter.y - imageCenter.y }
2551 // Divide the offset by 10 because we're nudging the image toward center,
2552 // not jumping it there.
2553 image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
2554 image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
2557 // Put the filter back.
2558 image.style.filter = image.savedFilter;
2560 // Set the cursor appropriately.
2561 setFocusedImageCursor();
2563 window.addEventListener("MozMousePixelScroll", GW.imageFocusOldFirefoxCompatibilityScrollEventFired = (event) => {
2564 event.preventDefault();
2567 // If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
2568 window.addEventListener("mouseup", GW.imageFocusMouseUp = (event) => {
2569 window.onmousemove = '';
2571 // We only want to do anything on left-clicks.
2572 if (event.button != 0) return;
2574 if (event.target.hasClass("slideshow-button")) {
2575 resetImageFocusHideUITimer(false);
2579 let focusedImage = query("#image-focus-overlay img");
2581 if (event.target != focusedImage) {
2582 unfocusImageOverlay();
2586 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
2587 // Put the filter back.
2588 focusedImage.style.filter = focusedImage.savedFilter;
2590 unfocusImageOverlay();
2593 window.addEventListener("mousedown", GW.imageFocusMouseDown = (event) => {
2594 event.preventDefault();
2596 let focusedImage = query("#image-focus-overlay img");
2597 if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
2598 let mouseCoordX = event.clientX;
2599 let mouseCoordY = event.clientY;
2601 let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
2602 let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
2605 focusedImage.savedFilter = focusedImage.style.filter;
2607 window.onmousemove = (event) => {
2608 // Remove the filter.
2609 focusedImage.style.filter = "none";
2610 focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
2611 focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
2617 // Double-click unfocuses, always.
2618 window.addEventListener('dblclick', GW.imageFocusDoubleClick = (event) => {
2619 if (event.target.hasClass("slideshow-button")) return;
2621 unfocusImageOverlay();
2624 // Escape key unfocuses, spacebar resets.
2625 document.addEventListener("keyup", GW.imageFocusKeyUp = (event) => {
2626 let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
2627 if (!allowedKeys.contains(event.key) ||
2628 getComputedStyle(query("#image-focus-overlay")).display == "none") return;
2630 event.preventDefault();
2632 switch (event.key) {
2635 unfocusImageOverlay();
2639 resetFocusedImagePosition();
2645 if (query("#images-overlay img.focused")) focusNextImage(true);
2651 if (query("#images-overlay img.focused")) focusNextImage(false);
2656 // Prevent spacebar or arrow keys from scrolling page when image focused.
2657 document.addEventListener("keydown", GW.imageFocusKeyDown = (event) => {
2658 let disabledKeys = [ " ", "Spacebar", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
2659 if (disabledKeys.contains(event.key))
2660 event.preventDefault();
2663 if (imageToFocus.closest("#images-overlay")) {
2664 // Set state of next/previous buttons.
2665 let images = queryAll("#images-overlay img");
2666 var indexOfFocusedImage = getIndexOfFocusedImage();
2667 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
2668 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
2670 // Moving mouse unhides image focus UI.
2671 window.addEventListener("mousemove", GW.imageFocusMouseMoved = (event) => {
2672 let restartTimer = (event.target.tagName == "IMG" || event.target.id == "image-focus-overlay");
2673 resetImageFocusHideUITimer(restartTimer);
2676 // Replace the hash.
2677 history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
2681 function resetFocusedImagePosition() {
2682 GWLog("resetFocusedImagePosition");
2683 let focusedImage = query("#image-focus-overlay img");
2684 if (!focusedImage) return;
2686 // Reset modifications to size.
2687 focusedImage.style.width = "";
2688 focusedImage.style.height = "";
2690 // Make sure that initially, the image fits into the viewport.
2691 let shrinkRatio = 0.975;
2692 focusedImage.style.width = Math.min(focusedImage.clientWidth, window.innerWidth * shrinkRatio) + 'px';
2693 let maxImageHeight = window.innerHeight * shrinkRatio;
2694 if (focusedImage.clientHeight > maxImageHeight) {
2695 focusedImage.style.height = maxImageHeight + 'px';
2696 focusedImage.style.width = "";
2699 // Remove modifications to position.
2700 focusedImage.style.left = "";
2701 focusedImage.style.top = "";
2703 // Set the cursor appropriately.
2704 setFocusedImageCursor();
2706 function setFocusedImageCursor() {
2707 let focusedImage = query("#image-focus-overlay img");
2708 if (!focusedImage) return;
2709 focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
2713 function unfocusImageOverlay() {
2714 GWLog("unfocusImageOverlay");
2715 // Set accesskey of currently focused image (if it's in the images overlay).
2716 let currentlyFocusedImage = query("#images-overlay img.focused");
2717 if (currentlyFocusedImage) {
2718 currentlyFocusedImage.addClass("last-focused");
2719 currentlyFocusedImage.accessKey = 'l';
2722 // Remove focused image and hide overlay.
2723 let imageFocusOverlay = query("#image-focus-overlay");
2724 imageFocusOverlay.removeClass("engaged");
2725 removeElement(imageFocusOverlay.query("img"));
2727 // Un-blur content/etc.
2728 queryAll("#content, #ui-elements-container > *:not(#image-focus-overlay), #images-overlay").forEach(element => {
2729 element.removeClass("blurred");
2732 // Unset "focused" class of focused image.
2733 queryAll("#content img.focused, #images-overlay img.focused").forEach(image => {
2734 image.removeClass("focused");
2737 // Remove event listeners.
2738 window.removeEventListener("wheel", GW.imageFocusScroll);
2739 window.removeEventListener("MozMousePixelScroll", GW.imageFocusOldFirefoxCompatibilityScrollEventFired);
2740 window.removeEventListener("dblclick", GW.imageFocusDoubleClick);
2741 document.removeEventListener("keyup", GW.imageFocusKeyUp);
2742 document.removeEventListener("keydown", GW.imageFocusKeyDown);
2743 window.removeEventListener("mousemove", GW.imageFocusMouseMoved);
2744 window.removeEventListener("mousedown", GW.imageFocusMouseDown);
2746 // Reset the hash, if needed.
2747 if (location.hash.hasPrefix("#if_slide_"))
2748 history.replaceState(null, null, "#");
2751 function getIndexOfFocusedImage() {
2752 let images = queryAll("#images-overlay img");
2753 var indexOfFocusedImage = -1;
2754 for (i = 0; i < images.length; i++) {
2755 if (images[i].hasClass("focused")) {
2756 indexOfFocusedImage = i;
2760 return indexOfFocusedImage;
2763 function focusNextImage(next = true) {
2764 GWLog("focusNextImage");
2765 let images = queryAll("#images-overlay img");
2766 var indexOfFocusedImage = getIndexOfFocusedImage();
2768 if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
2770 // Remove existing image.
2771 removeElement("#image-focus-overlay img");
2772 // Unset "focused" class of just-removed image.
2773 queryAll("#content img.focused, #images-overlay img.focused").forEach(image => {
2774 image.removeClass("focused");
2777 // Create the focused version of the image.
2778 images[indexOfFocusedImage].addClass("focused");
2779 let imageFocusOverlay = query("#image-focus-overlay");
2780 let clonedImage = images[indexOfFocusedImage].cloneNode(true);
2781 clonedImage.style = "";
2782 clonedImage.removeAttribute("width");
2783 clonedImage.removeAttribute("height");
2784 clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
2785 imageFocusOverlay.appendChild(clonedImage);
2786 imageFocusOverlay.addClass("engaged");
2787 // Set image to default size and position.
2788 resetFocusedImagePosition();
2789 // Set state of next/previous buttons.
2790 imageFocusOverlay.query(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
2791 imageFocusOverlay.query(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
2792 // Set the image number display.
2793 query("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
2794 // Replace the hash.
2795 history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
2798 function hideImageFocusUI() {
2799 let imageFocusOverlay = query("#image-focus-overlay");
2800 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number").forEach(element => {
2801 element.addClass("hidden");
2805 function unhideImageFocusUI() {
2806 let imageFocusOverlay = query("#image-focus-overlay");
2807 imageFocusOverlay.queryAll(".slideshow-button, .help-overlay, .image-number").forEach(element => {
2808 element.removeClass("hidden");
2812 function resetImageFocusHideUITimer(restart) {
2813 if (GW.isMobile) return;
2815 clearTimeout(GW.imageFocusHideUITimer);
2816 unhideImageFocusUI();
2817 if (restart) GW.imageFocusHideUITimer = setTimeout(hideImageFocusUI, 1500);
2820 /*********************/
2821 /* MORE MISC HELPERS */
2822 /*********************/
2824 function getQueryVariable(variable) {
2825 var query = window.location.search.substring(1);
2826 var vars = query.split("&");
2827 for (var i = 0; i < vars.length; i++) {
2828 var pair = vars[i].split("=");
2829 if (pair[0] == variable)
2836 function addUIElement(element_html) {
2837 var ui_elements_container = query("#ui-elements-container");
2838 if (!ui_elements_container) {
2839 ui_elements_container = document.createElement("div");
2840 ui_elements_container.id = "ui-elements-container";
2841 query("body").appendChild(ui_elements_container);
2844 ui_elements_container.insertAdjacentHTML("beforeend", element_html);
2845 return ui_elements_container.lastElementChild;
2848 function removeElement(elementOrSelector, ancestor = document) {
2849 if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
2850 if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
2853 String.prototype.hasPrefix = function (prefix) {
2854 return (this.lastIndexOf(prefix, 0) === 0);
2857 /*******************************/
2858 /* HTML TO MARKDOWN CONVERSION */
2859 /*******************************/
2861 function MarkdownFromHTML(text) {
2862 GWLog("MarkdownFromHTML");
2863 // Wrapper tags, paragraphs, bold, italic, code blocks.
2864 text = text.replace(/<(.+?)(?:\s(.+?))?>/g, (match, tag, attributes, offset, string) => {
2891 text = text.replace(/<ul>((?:.|\n)+?)<\/ul>/g, (match, listItems, offset, string) => {
2892 return listItems.replace(/<li>((?:.|\n)+?)<\/li>/g, (match, listItem, offset, string) => {
2893 return "* " + listItem + "\n";
2898 text = text.replace(/<h([1-9])>(.+?)<\/h[1-9]>/g, (match, level, headingText, offset, string) => {
2899 return { "1":"#", "2":"##", "3":"###" }[level] + " " + headingText + "\n";
2903 text = text.replace(/<blockquote>((?:.|\n)+?)<\/blockquote>/g, (match, quotedText, offset, string) => {
2904 return "> " + quotedText.trim().split("\n").join("\n> ") + "\n";
2908 text = text.replace(/<a href="(.+?)">(.+?)<\/a>/g, (match, href, text, offset, string) => {
2909 return `[${text}](${href})`;
2912 // Horizontal rules.
2913 text = text.replace(/<hr(.+?)\/?>/g, (match, offset, string) => {
2920 /******************/
2921 /* INITIALIZATION */
2922 /******************/
2924 registerInitializer('earlyInitialize', true, () => query("#content") != null, function () {
2925 GWLog("INITIALIZER earlyInitialize");
2926 // Check to see whether we're on a mobile device (which we define as a touchscreen)
2927 // GW.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
2928 GW.isMobile = ('ontouchstart' in document.documentElement);
2930 // Backward compatibility
2931 let storedTheme = localStorage.getItem('selected-theme');
2933 setTheme(storedTheme);
2934 localStorage.removeItem('selected-theme');
2937 // Animate width & theme adjustments?
2938 GW.adjustmentTransitions = false;
2940 // Add the content width selector.
2941 injectContentWidthSelector();
2942 // Add the text size adjustment widget.
2943 injectTextSizeAdjustmentUI();
2944 // Add the theme selector.
2945 injectThemeSelector();
2946 // Add the theme tweaker.
2947 injectThemeTweaker();
2948 // Add the quick-nav UI.
2951 setTimeout(() => { updateInbox(); }, 0);
2954 registerInitializer('initialize', false, () => document.readyState != 'loading', function () {
2955 GWLog("INITIALIZER initialize");
2956 forceInitializer('earlyInitialize');
2958 // This is for "qualified hyperlinking", i.e. "link without comments" and/or
2959 // "link without nav bars".
2960 if (getQueryVariable("comments") == "false")
2961 query("#content").addClass("no-comments");
2962 if (getQueryVariable("hide-nav-bars") == "true") {
2963 query("#content").addClass("no-nav-bars");
2964 let auxAboutLink = addUIElement("<div id='aux-about-link'><a href='/about' accesskey='t' target='_new'></a></div>");
2967 // If the page cannot have comments, remove the accesskey from the #comments
2968 // quick-nav button; and if the page can have comments, but does not, simply
2969 // disable the #comments quick nav button.
2970 let content = query("#content");
2971 if (content.query("#comments") == null) {
2972 query("#quick-nav-ui a[href='#comments']").accessKey = '';
2973 } else if (content.query("#comments .comment-thread") == null) {
2974 query("#quick-nav-ui a[href='#comments']").addClass("no-comments");
2977 // Links to comments generated by LW have a hash that consists of just the
2978 // comment ID, which can start with a number. Prefix it with "comment-".
2979 if (location.hash.length == 18) {
2980 location.hash = "#comment-" + location.hash.substring(1);
2983 // If the viewport is wide enough to fit the desktop-size content column,
2984 // use a long date format; otherwise, a short one.
2985 let useLongDate = window.innerWidth > 900;
2986 let dtf = new Intl.DateTimeFormat([],
2988 { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
2989 : { month: 'numeric', day: 'numeric', year: '2-digit', hour: 'numeric', minute: 'numeric' } ));
2990 queryAll(".date").forEach(date => {
2991 let d = date.dataset.jsDate;
2992 if (d) { date.innerHTML = dtf.format(new Date(+ d)); }
2995 GW.needHashRealignment = false;
2997 // On edit post pages and conversation pages, add GUIEdit buttons to the
2998 // textarea, expand it, and markdownify the existing text, if any (this is
2999 // needed if a post was last edited on LW).
3000 queryAll(".with-markdown-editor textarea").forEach(textarea => {
3001 textarea.addTextareaFeatures();
3002 expandTextarea(textarea);
3003 textarea.value = MarkdownFromHTML(textarea.value);
3005 // Focus the textarea.
3006 queryAll(((getQueryVariable("post-id")) ? "#edit-post-form textarea" : "#edit-post-form input[name='title']") + (GW.isMobile ? "" : ", .conversation-page textarea")).forEach(field => { field.focus(); });
3008 // If this is a post page...
3009 let postMeta = query(".post .post-meta");
3011 // Add "qualified hyperlinking" toolbar.
3012 let postPermalink = location.protocol + "//" + location.host + location.pathname;
3013 postMeta.insertAdjacentHTML("beforeend", "<div class='qualified-linking'>" +
3014 "<input type='checkbox' tabindex='-1' id='qualified-linking-toolbar-toggle-checkbox'><label for='qualified-linking-toolbar-toggle-checkbox'><span></span></label>" +
3015 "<div class='qualified-linking-toolbar'>" +
3016 `<a href='${postPermalink}'>Post permalink</a>` +
3017 `<a href='${postPermalink}?comments=false'>Link without comments</a>` +
3018 `<a href='${postPermalink}?hide-nav-bars=true'>Link without top nav bars</a>` +
3019 `<a href='${postPermalink}?comments=false&hide-nav-bars=true'>Link without comments or top nav bars</a>` +
3023 // Replicate .post-meta at bottom of post.
3024 let clonedPostMeta = postMeta.cloneNode(true);
3025 postMeta.addClass("top-post-meta");
3026 clonedPostMeta.addClass("bottom-post-meta");
3027 clonedPostMeta.query("input[type='checkbox']").id += "-bottom";
3028 clonedPostMeta.query("label").htmlFor += "-bottom";
3029 query(".post").appendChild(clonedPostMeta);
3032 // If client is logged in...
3033 if (loggedInUserId) {
3034 // Add upvote/downvote buttons.
3035 if (typeof postVote != 'undefined') {
3036 queryAll(".post-meta .karma-value").forEach(karmaValue => {
3037 addVoteButtons(karmaValue, postVote, 'Posts');
3038 karmaValue.parentElement.addClass("active-controls");
3041 if (typeof commentVotes != 'undefined') {
3042 queryAll(".comment-meta .karma-value").forEach(karmaValue => {
3043 let commentID = karmaValue.getCommentId();
3044 addVoteButtons(karmaValue, commentVotes[commentID], 'Comments');
3045 karmaValue.parentElement.addClass("active-controls");
3049 // Color the upvote/downvote buttons with an embedded style sheet.
3050 query("head").insertAdjacentHTML("beforeend","<style id='vote-buttons'>" +
3058 .downvote.selected {
3063 // Activate the vote buttons.
3064 queryAll("button.vote").forEach(voteButton => {
3065 voteButton.addActivateEvent(voteButtonClicked);
3068 // For all comment containers...
3069 queryAll(".comments").forEach((commentsContainer) => {
3070 // Add reply buttons.
3071 commentsContainer.queryAll(".comment").forEach(comment => {
3072 comment.insertAdjacentHTML("afterend", "<div class='comment-controls posting-controls'></div>");
3073 comment.parentElement.query(".comment-controls").constructCommentControls();
3076 // Add top-level new comment form.
3077 if (!query(".individual-thread-page")) {
3078 commentsContainer.insertAdjacentHTML("afterbegin", "<div class='comment-controls posting-controls'></div>");
3079 commentsContainer.query(".comment-controls").constructCommentControls();
3083 // Hash realignment is needed because adding the above elements almost
3084 // certainly caused the page to reflow, and now client is no longer
3085 // scrolled to the place indicated by the hash.
3086 GW.needHashRealignment = true;
3090 queryAll(".contents-list li a").forEach(tocLink => {
3091 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+\. /, '');
3092 tocLink.innerText = tocLink.innerText.replace(/^[0-9]+: /, '');
3093 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, '');
3094 tocLink.innerText = tocLink.innerText.replace(/^[A-Z]\. /, '');
3097 // If we're on a comment thread page...
3098 if (query(".comments") != null) {
3099 // Add comment-minimize buttons to every comment.
3100 queryAll(".comment-meta").forEach(commentMeta => {
3101 if (!commentMeta.lastChild.hasClass("comment-minimize-button"))
3102 commentMeta.insertAdjacentHTML("beforeend", "<div class='comment-minimize-button maximized'></div>");
3104 if (!query("#content").hasClass("individual-thread-page")) {
3105 // Format and activate comment-minimize buttons.
3106 queryAll(".comment-minimize-button").forEach(button => {
3107 button.closest(".comment-item").setCommentThreadMaximized(false);
3108 button.addActivateEvent(GW.commentMinimizeButtonClicked = (event) => {
3109 event.target.closest(".comment-item").setCommentThreadMaximized(true);
3114 if (getQueryVariable("chrono") == "t") {
3115 query("head").insertAdjacentHTML("beforeend", "<style>.comment-minimize-button::after { display: none; }</style>");
3117 let urlParts = document.URL.split('#comment-');
3118 if (urlParts.length > 1) {
3119 expandAncestorsOf(urlParts[1]);
3120 GW.needHashRealignment = true;
3123 // On mobile, replace the labels for the checkboxes on the edit post form
3124 // with icons, to save space.
3125 if (GW.isMobile && query(".edit-post-page")) {
3126 query("label[for='link-post']").innerHTML = "";
3127 query("label[for='question']").innerHTML = "";
3130 // Add error message (as placeholder) if user tries to click Search with
3131 // an empty search field.
3132 query("#nav-item-search form").addEventListener("submit", GW.siteSearchFormSubmitted = (event) => {
3133 let searchField = event.target.query("input");
3134 if (searchField.value == "") {
3135 event.preventDefault();
3136 event.target.blur();
3137 searchField.placeholder = "Enter a search string!";
3138 searchField.focus();
3141 // Remove the placeholder / error on any input.
3142 query("#nav-item-search input").addEventListener("input", GW.siteSearchFieldValueChanged = (event) => {
3143 event.target.placeholder = "";
3146 // Prevent conflict between various single-hotkey listeners and text fields
3147 queryAll("input[type='text'], input[type='search'], input[type='password']").forEach(inputField => {
3148 inputField.addEventListener("keyup", (event) => { event.stopPropagation(); });
3151 if (content.hasClass("post-page")) {
3152 // Read and update last-visited-date.
3153 let lastVisitedDate = getLastVisitedDate();
3154 setLastVisitedDate(Date.now());
3156 // Save the number of comments this post has when it's visited.
3157 updateSavedCommentCount();
3159 if (content.query(".comments .comment-thread") != null) {
3160 // Add the new comments count & navigator.
3161 injectNewCommentNavUI();
3163 // Get the highlight-new-since date (as specified by URL parameter, if
3164 // present, or otherwise the date of the last visit).
3165 let hnsDate = parseInt(getQueryVariable("hns")) || lastVisitedDate;
3167 // Highlight new comments since the specified date.
3168 let newCommentsCount = highlightCommentsSince(hnsDate);
3170 // Update the comment count display.
3171 updateNewCommentNavUI(newCommentsCount, hnsDate);
3174 // On listing pages, make comment counts more informative.
3175 badgePostsWithNewComments();
3178 // Add the comments list mode selector widget (expanded vs. compact).
3179 injectCommentsListModeSelector();
3181 // Add the comments view selector widget (threaded vs. chrono).
3182 // injectCommentsViewModeSelector();
3184 // Add the comments sort mode selector (top, hot, new, old).
3185 injectCommentsSortModeSelector();
3187 // Add the toggle for the post nav UI elements on mobile.
3188 if (GW.isMobile) injectPostNavUIToggle();
3190 // Add the toggle for the appearance adjustment UI elements on mobile.
3191 if (GW.isMobile) injectAppearanceAdjustUIToggle();
3193 // Add the antikibitzer.
3194 injectAntiKibitzer();
3196 // Add comment parent popups.
3197 addCommentParentPopups();
3199 // Mark original poster's comments with a special class.
3200 markOriginalPosterComments();
3202 // On the All view, mark posts with non-positive karma with a special class.
3203 if (query("#content").hasClass("all-index-page")) {
3204 queryAll("#content.index-page h1.listing + .post-meta .karma-value").forEach(karmaValue => {
3205 if (parseInt(karmaValue.textContent.replace("−", "-")) > 0) return;
3207 karmaValue.closest(".post-meta").previousSibling.addClass("spam");
3211 // Set the "submit" button on the edit post page to something more helpful.
3212 setEditPostPageSubmitButtonText();
3214 // Compute the text of the pagination UI tooltip text.
3215 queryAll("#top-nav-bar a:not(.disabled), #bottom-bar a").forEach(link => {
3216 link.dataset.targetPage = parseInt((/=([0-9]+)/.exec(link.href)||{})[1]||0)/20 + 1;
3219 // Add event listeners for Escape and Enter, for the theme tweaker.
3220 let themeTweakerHelpWindow = query("#theme-tweaker-ui .help-window");
3221 let themeTweakerUI = query("#theme-tweaker-ui");
3222 document.addEventListener("keyup", GW.themeTweaker.keyPressed = (event) => {
3223 if (event.keyCode == 27) {
3225 if (themeTweakerHelpWindow.style.display != "none") {
3226 toggleThemeTweakerHelpWindow();
3227 themeTweakerResetSettings();
3228 } else if (themeTweakerUI.style.display != "none") {
3229 toggleThemeTweakerUI();
3232 } else if (event.keyCode == 13) {
3234 if (themeTweakerHelpWindow.style.display != "none") {
3235 toggleThemeTweakerHelpWindow();
3236 themeTweakerSaveSettings();
3237 } else if (themeTweakerUI.style.display != "none") {
3238 toggleThemeTweakerUI();
3244 // Add event listener for . , ; (for navigating listings pages).
3245 let listings = queryAll("h1.listing a[href^='/posts'], #content > .comment-thread .comment-meta a.date");
3246 if (listings.length > 0) {
3247 document.addEventListener("keyup", GW.postListingsNavKeyPressed = (event) => {
3248 if (event.ctrlKey || event.shiftKey || event.altKey || !(event.key == "," || event.key == "." || event.key == ';' || event.keyCode == 27)) return;
3250 if (event.keyCode == 27) {
3251 if (document.activeElement.parentElement.hasClass("listing"))
3252 document.activeElement.blur();
3256 if (event.key == ';') {
3257 if (document.activeElement.parentElement.hasClass("link-post-listing")) {
3258 let links = document.activeElement.parentElement.queryAll("a");
3259 links[document.activeElement == links[0] ? 1 : 0].focus();
3260 } else if (document.activeElement.parentElement.hasClass("comment-meta")) {
3261 let links = document.activeElement.parentElement.queryAll("a.date, a.permalink");
3262 links[document.activeElement == links[0] ? 1 : 0].focus();
3263 document.activeElement.closest(".comment-item").addClass("comment-item-highlight");
3268 var indexOfActiveListing = -1;
3269 for (i = 0; i < listings.length; i++) {
3270 if (document.activeElement.parentElement.hasClass("listing") &&
3271 listings[i] === document.activeElement.parentElement.query("a[href^='/posts']")) {
3272 indexOfActiveListing = i;
3274 } else if (document.activeElement.parentElement.hasClass("comment-meta") &&
3275 listings[i] === document.activeElement.parentElement.query("a.date")) {
3276 indexOfActiveListing = i;
3280 let indexOfNextListing = (event.key == "." ? ++indexOfActiveListing : (--indexOfActiveListing + listings.length + 1)) % (listings.length + 1);
3281 if (indexOfNextListing < listings.length) {
3282 listings[indexOfNextListing].focus();
3284 if (listings[indexOfNextListing].closest(".comment-item")) {
3285 listings[indexOfNextListing].closest(".comment-item").addClasses([ "expanded", "comment-item-highlight" ]);
3286 listings[indexOfNextListing].closest(".comment-item").scrollIntoView();
3289 document.activeElement.blur();
3292 queryAll("#content > .comment-thread .comment-meta a.date, #content > .comment-thread .comment-meta a.permalink").forEach(link => {
3293 link.addEventListener("blur", GW.commentListingsHyperlinkUnfocused = (event) => {
3294 event.target.closest(".comment-item").removeClasses([ "expanded", "comment-item-highlight" ]);
3298 // Add event listener for ; (to focus the link on link posts).
3299 if (query("#content").hasClass("post-page") &&
3300 query(".post").hasClass("link-post")) {
3301 document.addEventListener("keyup", GW.linkPostLinkFocusKeyPressed = (event) => {
3302 if (event.key == ';') query("a.link-post-link").focus();
3306 // Add accesskeys to user page view selector.
3307 let viewSelector = query("#content.user-page > .sublevel-nav");
3309 let currentView = viewSelector.query("span");
3310 (currentView.nextSibling || viewSelector.firstChild).accessKey = 'x';
3311 (currentView.previousSibling || viewSelector.lastChild).accessKey = 'z';
3314 // Add accesskey to index page sort selector.
3315 (query("#content.index-page > .sublevel-nav.sort a")||{}).accessKey = 'z';
3317 // Move MathJax style tags to <head>.
3318 var aggregatedStyles = "";
3319 queryAll("#content style").forEach(styleTag => {
3320 aggregatedStyles += styleTag.innerHTML;
3321 removeElement("style", styleTag.parentElement);
3323 if (aggregatedStyles != "") {
3324 query("head").insertAdjacentHTML("beforeend", "<style id='mathjax-styles'>" + aggregatedStyles + "</style>");
3327 // Add listeners to switch between word count and read time.
3328 if (localStorage.getItem("display-word-count")) toggleReadTimeOrWordCount(true);
3329 queryAll(".post-meta .read-time").forEach(element => {
3330 element.addActivateEvent(GW.readTimeOrWordCountClicked = (event) => {
3331 let displayWordCount = localStorage.getItem("display-word-count");
3332 toggleReadTimeOrWordCount(!displayWordCount);
3333 if (displayWordCount) localStorage.removeItem("display-word-count");
3334 else localStorage.setItem("display-word-count", true);
3338 // Add copy listener to strip soft hyphens (inserted by server-side hyphenator).
3339 query("#content").addEventListener("copy", GW.textCopied = (event) => {
3340 event.preventDefault();
3341 const selectedHTML = getSelectionHTML();
3342 const selectedText = getSelection().toString();
3343 event.clipboardData.setData("text/plain", selectedText.replace(/\u00AD|\u200b/g, ""));
3344 event.clipboardData.setData("text/html", selectedHTML.replace(/\u00AD|\u200b/g, ""));
3347 // Set up Image Focus feature.
3351 /*************************/
3352 /* POST-LOAD ADJUSTMENTS */
3353 /*************************/
3355 registerInitializer('pageLayoutFinished', false, () => document.readyState == "complete", function () {
3356 GWLog("INITIALIZER pageLayoutFinished");
3357 forceInitializer('initialize');
3359 realignHashIfNeeded();
3361 postSetThemeHousekeeping();
3363 focusImageSpecifiedByURL();
3365 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
3366 // query("input[type='search']").value = GW.isMobile;
3367 // query("head").insertAdjacentHTML("beforeend", "<style>" +
3368 // `@media only screen and (hover:none) { #nav-item-search input { background-color: red; }}` +
3369 // `@media only screen and (hover:hover) { #nav-item-search input { background-color: LightGreen; }}` +
3373 function generateImagesOverlay() {
3374 GWLog("generateImagesOverlay");
3375 // Don't do this on the about page.
3376 if (query(".about-page") != null) return;
3378 // Remove existing, if any.
3379 removeElement("#images-overlay");
3382 query("body").insertAdjacentHTML("afterbegin", "<div id='images-overlay'></div>");
3383 let imagesOverlay = query("#images-overlay");
3384 let imagesOverlayLeftOffset = imagesOverlay.getBoundingClientRect().left;
3385 queryAll(".post-body img").forEach(image => {
3387 image.className = "";
3389 let clonedImageContainer = document.createElement("div");
3391 let clonedImage = image.cloneNode(true);
3392 clonedImage.style.borderStyle = getComputedStyle(image).borderStyle;
3393 clonedImage.style.borderColor = getComputedStyle(image).borderColor;
3394 clonedImage.style.borderWidth = Math.round(parseFloat(getComputedStyle(image).borderWidth)) + "px";
3395 clonedImageContainer.appendChild(clonedImage);
3397 let zoomLevel = parseFloat(GW.currentTextZoom);
3399 clonedImageContainer.style.top = image.getBoundingClientRect().top * zoomLevel - parseFloat(getComputedStyle(image).marginTop) + window.scrollY + "px";
3400 clonedImageContainer.style.left = image.getBoundingClientRect().left * zoomLevel - parseFloat(getComputedStyle(image).marginLeft) - imagesOverlayLeftOffset + "px";
3401 clonedImageContainer.style.width = image.getBoundingClientRect().width * zoomLevel + "px";
3402 clonedImageContainer.style.height = image.getBoundingClientRect().height * zoomLevel + "px";
3403 GWLog(clonedImageContainer);
3405 imagesOverlay.appendChild(clonedImageContainer);
3408 // Add the event listeners to focus each image.
3409 imageFocusSetup(true);
3412 function adjustUIForWindowSize() {
3413 GWLog("adjustUIForWindowSize");
3414 var bottomBarOffset;
3416 // Adjust bottom bar state.
3417 let bottomBar = query("#bottom-bar");
3418 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
3419 if (query("#content").clientHeight > window.innerHeight + bottomBarOffset) {
3420 bottomBar.removeClass("decorative");
3422 bottomBar.query("#nav-item-top").style.display = "";
3423 } else if (bottomBar) {
3424 if (bottomBar.childElementCount > 1) bottomBar.removeClass("decorative");
3425 else bottomBar.addClass("decorative");
3427 bottomBar.query("#nav-item-top").style.display = "none";
3430 // Show quick-nav UI up/down buttons if content is taller than window.
3431 bottomBarOffset = bottomBar.hasClass("decorative") ? 16 : 30;
3432 queryAll("#quick-nav-ui a[href='#top'], #quick-nav-ui a[href='#bottom-bar']").forEach(element => {
3433 element.style.visibility = (query("#content").clientHeight > window.innerHeight + bottomBarOffset) ? "unset" : "hidden";
3436 // Move anti-kibitzer toggle if content is very short.
3437 if (query("#content").clientHeight < 400) (query("#anti-kibitzer-toggle")||{}).style.bottom = "125px";
3439 // Update the visibility of the post nav UI.
3440 updatePostNavUIVisibility();
3443 function recomputeUIElementsContainerHeight(force = false) {
3444 GWLog("recomputeUIElementsContainerHeight");
3446 (force || query("#ui-elements-container").style.height != "")) {
3447 let bottomBarOffset = query("#bottom-bar").hasClass("decorative") ? 16 : 30;
3448 query("#ui-elements-container").style.height = (query("#content").clientHeight <= window.innerHeight + bottomBarOffset) ?
3449 query("#content").clientHeight + "px" :
3454 function realignHashIfNeeded() {
3455 if (GW.needHashRealignment)
3458 function realignHash() {
3459 GWLog("realignHash");
3460 if (!location.hash) return;
3462 let targetElement = query(location.hash);
3463 if (targetElement) targetElement.scrollIntoView(true);
3464 GW.needHashRealignment = false;
3467 function focusImageSpecifiedByURL() {
3468 GWLog("focusImageSpecifiedByURL");
3469 if (location.hash.hasPrefix("#if_slide_")) {
3470 registerInitializer('focusImageSpecifiedByURL', true, () => query("#images-overlay") != null, () => {
3471 let images = queryAll("#images-overlay img");
3472 let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
3473 if (imageToFocus > 0 && imageToFocus <= images.length) {
3474 focusImage(images[imageToFocus - 1]);
3475 query("#image-focus-overlay .image-number").textContent = imageToFocus;
3485 function insertMarkup(event) {
3486 var mopen = '', mclose = '', mtext = '', func = false;
3487 if (typeof arguments[1] == 'function') {
3488 func = arguments[1];
3490 mopen = arguments[1];
3491 mclose = arguments[2];
3492 mtext = arguments[3];
3495 var textarea = event.target.closest("form").query("textarea");
3497 var p0 = textarea.selectionStart;
3498 var p1 = textarea.selectionEnd;
3499 var cur0 = cur1 = p0;
3501 var str = (p0 == p1) ? mtext : textarea.value.substring(p0, p1);
3502 str = func ? func(str, p0) : (mopen + str + mclose);
3504 // Determine selection.
3506 cur0 += (p0 == p1) ? mopen.length : str.length;
3507 cur1 = (p0 == p1) ? (cur0 + mtext.length) : cur0;
3514 // Update textarea contents.
3515 textarea.value = textarea.value.substring(0, p0) + str + textarea.value.substring(p1);
3518 textarea.selectionStart = cur0;
3519 textarea.selectionEnd = cur1;
3524 GW.guiEditButtons = [
3525 [ 'strong', 'Strong (bold)', 'k', '**', '**', 'Bold text', '' ],
3526 [ 'em', 'Emphasized (italic)', 'i', '*', '*', 'Italicized text', '' ],
3527 [ 'link', 'Hyperlink', 'l', hyperlink, '', '', '' ],
3528 [ 'image', 'Image', '', '![', '](image url)', 'Image alt-text', '' ],
3529 [ 'heading1', 'Heading level 1', '', '\\n# ', '', 'Heading', '<sup>1</sup>' ],
3530 [ 'heading2', 'Heading level 2', '', '\\n## ', '', 'Heading', '<sup>2</sup>' ],
3531 [ 'heading3', 'Heading level 3', '', '\\n### ', '', 'Heading', '<sup>3</sup>' ],
3532 [ 'blockquote', 'Blockquote', 'q', blockquote, '', '', '' ],
3533 [ 'bulleted-list', 'Bulleted list', '', '\\n* ', '', 'List item', '' ],
3534 [ 'numbered-list', 'Numbered list', '', '\\n1. ', '', 'List item', '' ],
3535 [ 'horizontal-rule', 'Horizontal rule', '', '\\n\\n---\\n\\n', '', '', '' ],
3536 [ 'inline-code', 'Inline code', '', '`', '`', 'Code', '' ],
3537 [ 'code-block', 'Code block', '', '```\\n', '\\n```', 'Code', '' ],
3538 [ 'formula', 'LaTeX', '', '$', '$', 'LaTeX formula', '' ],
3539 [ 'spoiler', 'Spoiler block', '', '::: spoiler\\n', '\\n:::', 'Spoiler text', '' ]
3542 function blockquote(text, startpos) {
3544 text = "> Quoted text";
3545 return [ text, startpos + 2, startpos + text.length ];
3547 text = "> " + text.split("\n").join("\n> ") + "\n";
3548 return [ text, startpos + text.length, startpos + text.length ];
3552 function hyperlink(text, startpos) {
3553 var url = '', link_text = text, endpos = startpos;
3554 if (text.search(/^https?/) != -1) {
3556 link_text = "link text";
3557 startpos = startpos + 1;
3558 endpos = startpos + link_text.length;
3560 url = prompt("Link address (URL):");
3562 endpos = startpos + text.length;
3563 return [ text, startpos, endpos ];
3565 startpos = startpos + text.length + url.length + 4;
3569 return [ "[" + link_text + "](" + url + ")", startpos, endpos ];