2 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
3 // GW.loggingEnabled = true;
5 // Links to comments generated by LW have a hash that consists of just the
6 // comment ID, which can start with a number. Prefix it with "comment-".
7 if (location.hash.length == 18) {
8 location.hash = "#comment-" + location.hash.substring(1);
11 /****************************************************/
12 /* CSS CLASS MANIPULATION (polyfill for .classList) */
13 /****************************************************/
15 Element.prototype.addClass = function(className) {
16 if (!this.hasClass(className))
17 this.className = (this.className + " " + className).trim();
19 Element.prototype.addClasses = function(classNames) {
20 let elementClassNames = this.className.trim().split(/\s/);
22 classNames.forEach(className => {
23 if (!this.hasClass(className))
24 elementClassNames.push(className);
27 this.className = elementClassNames.join(" ");
29 Element.prototype.removeClass = function(className) {
30 this.className = this.className.replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), "$1").trim();
31 if (this.className == "") this.removeAttribute("class");
33 Element.prototype.removeClasses = function(classNames) {
34 classNames.forEach(className => {
35 this.className = this.className.replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), "$1").trim();
37 if (this.className == "") this.removeAttribute("class");
39 Element.prototype.hasClass = function(className) {
40 return (new RegExp("(^|\\s+)" + className + "(\\s+|$)")).test(this.className);
42 /* True if the element has _all_ of the classes in the argument (which may be
43 a space-separated string or an array); false otherwise.
45 Element.prototype.hasClasses = function (classes) {
46 if (typeof classes == "string")
47 classes = classes.split(" ");
49 for (let aClass of classes)
50 if (false == this.hasClass(aClass))
55 Element.prototype.toggleClass = function(className) {
56 if (this.hasClass(className))
57 this.removeClass(className);
59 this.addClass(className);
62 /* Swap classes on the given element.
64 First argument is an array with two string elements (the classes).
65 Second argument is 0 or 1 (index of class to add; the other is removed).
67 Note that the first class in the array is always removed/added first, and
68 then the second class in the array is added/removed; thus these two calls
69 have different effects:
71 anElement.swapClasses([ "foo", "bar" ], 1);
72 anElement.swapClasses([ "bar", "foo" ], 0);
74 The first call removes "foo" and then adds "bar"; the second call adds "bar"
75 and then removes "foo". (This can have different visual or other side
76 effects in many circumstances. It also results in a different end state in
77 the cases where the two classes are the same.)
79 Element.prototype.swapClasses = function (classes, whichToAdd) {
80 let op1 = whichToAdd ? "removeClass" : "addClass";
81 let op2 = whichToAdd ? "addClass" : "removeClass";
83 this[op1](classes[0]);
84 this[op2](classes[1]);
89 function insertHeadHTML(html) {
90 document.head.insertAdjacentHTML("beforeend", html);
93 /********************/
94 /* QUERYING THE DOM */
95 /********************/
97 function queryAll(selector, context) {
98 context = context || document;
99 // Redirect simple selectors to the more performant function
100 if (/^(#?[\w-]+|\.[\w-.]+)$/.test(selector)) {
101 switch (selector.charAt(0)) {
103 // Handle ID-based selectors
104 let element = document.getElementById(selector.substr(1));
105 return element ? [ element ] : [ ];
107 // Handle class-based selectors
108 // Query by multiple classes by converting the selector
109 // string into single spaced class names
110 var classes = selector.substr(1).replace(/\./g, ' ');
111 return [].slice.call(context.getElementsByClassName(classes));
113 // Handle tag-based selectors
114 return [].slice.call(context.getElementsByTagName(selector));
117 // Default to `querySelectorAll`
118 return [].slice.call(context.querySelectorAll(selector));
120 function query(selector, context) {
121 let all = queryAll(selector, context);
122 return (all.length > 0) ? all[0] : null;
124 Object.prototype.queryAll = function (selector) {
125 return queryAll(selector, this);
127 Object.prototype.query = function (selector) {
128 return query(selector, this);
131 /***********************************/
132 /* CONTENT COLUMN WIDTH ADJUSTMENT */
133 /***********************************/
136 ['normal', 'Narrow (fixed-width) content column', 'N'],
137 ['wide', 'Wide (fixed-width) content column', 'W'],
138 ['fluid', 'Full-width (fluid) content column', 'F']
141 function setContentWidth(widthOption) {
142 let currentWidth = localStorage.getItem("selected-width") || 'normal';
143 let head = query('head');
144 head.removeClasses(GW.widthOptions.map(wo => 'content-width-' + wo[0]));
145 head.addClass('content-width-' + (widthOption || 'normal'));
147 setContentWidth(localStorage.getItem('selected-width'));
149 /********************************************/
150 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
151 /********************************************/
153 Object.prototype.isEmpty = function () {
154 for (var prop in this) if (this.hasOwnProperty(prop)) return false;
157 Object.prototype.keys = function () {
158 return Object.keys(this);
160 Array.prototype.contains = function (element) {
161 return (this.indexOf(element) !== -1);
163 Array.prototype.clone = function() {
164 return JSON.parse(JSON.stringify(this));
167 GW.themeTweaker = { };
168 GW.themeTweaker.filtersExclusionPaths = { };
169 GW.themeTweaker.defaultFiltersExclusionTree = [ "#content", [ ] ];
171 function exclusionTreeFromExclusionPaths(paths) {
172 if (!paths) return null;
174 let tree = GW.themeTweaker.defaultFiltersExclusionTree.clone();
175 paths.keys().flatMap(key => paths[key]).forEach(path => {
176 var currentNodeInTree = tree;
177 path.split(" ").slice(1).forEach(step => {
178 if (currentNodeInTree[1] == null)
179 currentNodeInTree[1] = [ ];
181 var indexOfMatchingChild = currentNodeInTree[1].findIndex(child => { return child[0] == step; });
182 if (indexOfMatchingChild == -1) {
183 currentNodeInTree[1].push([ step, [ ] ]);
184 indexOfMatchingChild = currentNodeInTree[1].length - 1;
187 currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
193 function selectorFromExclusionTree(tree) {
194 var selectorParts = [
195 "body::before, #ui-elements-container > div:not(#theme-tweaker-ui), #theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container"
198 function selectorFromExclusionTreeNode(node, path = [ ]) {
199 let [ value, children ] = node;
201 let newPath = path.clone();
206 } else if (children.length == 0) {
207 return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
209 return `${newPath.join(" > ")} > *:not(${children.map(child => child[0]).join("):not(")}), ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after, ` + children.map(child => selectorFromExclusionTreeNode(child, newPath)).join(", ");
213 return selectorParts + ", " + selectorFromExclusionTreeNode(tree);
215 function filterStringFromFilters(filters) {
216 var filterString = "";
217 for (key of Object.keys(filters)) {
218 let value = filters[key];
219 filterString += ` ${key}(${value})`;
223 function applyFilters(filters) {
224 var fullStyleString = "";
226 if (!filters.isEmpty()) {
227 let filtersExclusionTree = exclusionTreeFromExclusionPaths(GW.themeTweaker.filtersExclusionPaths) || GW.themeTweaker.defaultFiltersExclusionTree;
228 fullStyleString = `body::before { content: ""; } body > #content::before { z-index: 0; } ${selectorFromExclusionTree(filtersExclusionTree)} { filter: ${filterStringFromFilters(filters)}; }`;
231 // Update the style tag (if it’s already been loaded).
232 (query("#theme-tweak")||{}).innerHTML = fullStyleString;
234 insertHeadHTML("<style id='theme-tweak'></style>");
235 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
236 applyFilters(GW.currentFilters);
238 /************************/
239 /* TEXT SIZE ADJUSTMENT */
240 /************************/
242 insertHeadHTML("<style id='text-zoom'></style>");
243 function setTextZoom(zoomFactor) {
244 if (!zoomFactor) return;
246 let minZoomFactor = 0.5;
247 let maxZoomFactor = 1.5;
249 if (zoomFactor <= minZoomFactor) {
250 zoomFactor = minZoomFactor;
251 queryAll(".text-size-adjust-button.decrease").forEach(function (button) {
252 button.disabled = true;
254 } else if (zoomFactor >= maxZoomFactor) {
255 zoomFactor = maxZoomFactor;
256 queryAll(".text-size-adjust-button.increase").forEach(function (button) {
257 button.disabled = true;
260 queryAll(".text-size-adjust-button").forEach(function (button) {
261 button.disabled = false;
265 let textZoomStyle = query("#text-zoom");
266 textZoomStyle.innerHTML =
267 `.post, .comment, .comment-controls {
271 if (window.generateImagesOverlay) setTimeout(generateImagesOverlay);
273 GW.currentTextZoom = localStorage.getItem('text-zoom');
274 setTextZoom(GW.currentTextZoom);
281 ['default', 'Default theme (dark text on light background)', 'A'],
282 ['dark', 'Dark theme (light text on dark background)', 'B'],
283 ['grey', 'Grey theme (more subdued than default theme)', 'C'],
284 ['ultramodern', 'Ultramodern theme (very hip)', 'D'],
285 ['zero', 'Theme zero (plain and simple)', 'E'],
286 ['brutalist', 'Brutalist theme (the Motherland calls!)', 'F'],
287 ['rts', 'ReadTheSequences.com theme', 'G'],
288 ['classic', 'Classic Less Wrong theme', 'H'],
289 ['less', 'Less theme (serenity now)', 'I']
296 // While everything's being loaded, hide the authors and karma values.
297 if (localStorage.getItem("antikibitzer") == "true") {
298 insertHeadHTML("<style id='antikibitzer-temp'>" +
299 `.author, .inline-author, .karma-value, .individual-thread-page > h1 { visibility: hidden; }` +
302 if(document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
303 insertHeadHTML("<"+"title class='fake-title'></title>");
311 function GWLog (string) {
312 if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
320 /* Return the value of a GET (i.e., URL) parameter.
322 function getQueryVariable(variable) {
323 var query = window.location.search.substring(1);
324 var vars = query.split("&");
325 for (var i = 0; i < vars.length; i++) {
326 var pair = vars[i].split("=");
327 if (pair[0] == variable)
334 /* Get the comment ID of the item (if it's a comment) or of its containing
335 comment (if it's a child of a comment).
337 Element.prototype.getCommentId = function() {
338 let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
340 return (/^comment-(.*)/.exec(item.id)||[])[1];
350 function setTOCCollapseState(collapsed = false) {
351 let TOC = query("nav.contents");
355 TOC.classList.toggle("collapsed", collapsed);
357 let button = TOC.query(".toc-collapse-toggle-button");
358 button.innerHTML = collapsed ? "" : "";
359 button.title = collapsed ? "Expand table of contents" : "Collapse table of contents";
362 function injectTOCCollapseToggleButton() {
363 let TOC = document.currentScript.parentElement;
367 TOC.insertAdjacentHTML("afterbegin", "<button type='button' class='toc-collapse-toggle-button'></button>");
369 let defaultTOCCollapseState = (window.innerWidth <= 520) ? "true" : "false";
370 setTOCCollapseState((localStorage.getItem("toc-collapsed") ?? defaultTOCCollapseState) == "true");
372 TOC.query(".toc-collapse-toggle-button").addActivateEvent(GW.tocCollapseToggleButtonClicked = (event) => {
373 setTOCCollapseState(TOC.classList.contains("collapsed") == false);
374 localStorage.setItem("toc-collapsed", TOC.classList.contains("collapsed"));
378 /***********************************/
379 /* COMMENT THREAD MINIMIZE BUTTONS */
380 /***********************************/
382 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
383 GWLog("setCommentThreadMaximized");
384 let commentItem = this;
385 let storageName = "thread-minimized-" + commentItem.getCommentId();
386 let minimize_button = commentItem.query(".comment-minimize-button");
387 let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !(localStorage.getItem(storageName) || commentItem.hasClass("ignored")));
388 if (userOriginated) {
390 localStorage.removeItem(storageName);
392 localStorage.setItem(storageName, true);
396 commentItem.style.height = maximize ? 'auto' : '38px';
397 commentItem.style.overflow = maximize ? 'visible' : 'hidden';
399 minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
400 minimize_button.innerHTML = maximize ? "" : "";
401 minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
402 if (getQueryVariable("chrono") != "t") {
403 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
407 /*****************************/
408 /* MINIMIZED THREAD HANDLING */
409 /*****************************/
411 function expandAncestorsOf(comment) {
412 GWLog("expandAncestorsOf");
413 if (typeof comment == "string") {
414 comment = /(?:comment-)?(.+)/.exec(comment)[1];
415 comment = query("#comment-" + comment);
418 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
422 // Expand collapsed comment threads.
423 let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
424 if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
426 // Expand collapsed comments.
427 let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
428 if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
431 /********************/
432 /* COMMENT CONTROLS */
433 /********************/
435 /* Adds an event listener to a button (or other clickable element), attaching
436 it to both "click" and "keyup" events (for use with keyboard navigation).
437 Optionally also attaches the listener to the 'mousedown' event, making the
438 element activate on mouse down instead of mouse up. */
439 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
440 let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
441 if (includeMouseDown) this.addEventListener("mousedown", ael);
442 this.addEventListener("click", ael);
443 this.addEventListener("keyup", ael);
446 Element.prototype.updateCommentControlButton = function() {
447 GWLog("updateCommentControlButton");
448 let retractFn = () => {
449 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
450 return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
452 return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
455 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
456 "retract-button": retractFn,
457 "unretract-button": retractFn,
458 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
460 classMap.keys().forEach((testClass) => {
461 if (this.hasClass(testClass)) {
462 let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
464 this.addClasses([ buttonClass, "action-button" ]);
465 if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
466 this.dataset.label = buttonLabel;
467 this.title = buttonAltText;
468 this.tabIndex = '-1';
474 Element.prototype.constructCommentControls = function() {
475 GWLog("constructCommentControls");
476 let commentControls = this;
478 if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
482 let commentType = commentControls.parentElement.id.replace(/s$/, "");
483 commentControls.innerHTML = "";
484 let replyButton = document.createElement("button");
485 if (commentControls.parentElement.hasClass("comments")) {
486 replyButton.className = "new-comment-button action-button";
487 replyButton.innerHTML = (commentType == "nomination" ? "Add nomination" : "Post new " + commentType);
488 replyButton.setAttribute("accesskey", (commentType == "comment" ? "n" : ""));
489 replyButton.setAttribute("title", "Post new " + commentType + (commentType == "comment" ? " [n]" : ""));
491 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
492 let buttonsList = [];
493 if(!commentControls.parentElement.query(".comment-thread"))
494 buttonsList.push("delete-button");
495 buttonsList.push("retract-button", "edit-button");
496 buttonsList.forEach(buttonClass => {
497 let button = commentControls.appendChild(document.createElement("button"));
498 button.addClass(buttonClass);
499 button.updateCommentControlButton();
502 replyButton.className = "reply-button action-button";
503 replyButton.innerHTML = "Reply";
504 replyButton.dataset.label = "Reply";
506 commentControls.appendChild(replyButton);
507 replyButton.tabIndex = '-1';
510 commentControls.queryAll(".action-button").forEach(button => {
511 button.addActivateEvent(GW.commentActionButtonClicked);
514 // Replicate voting controls at the bottom of comments.
515 if (commentControls.parentElement.hasClass("comments")) return;
516 let votingControls = commentControls.parentElement.queryAll(".comment-meta .voting-controls");
517 if (!votingControls) return;
518 votingControls.forEach(control => {
519 let controlCloned = control.cloneNode(true);
520 commentControls.appendChild(controlCloned);
523 if(commentControls.query(".active-controls")) {
524 commentControls.queryAll("button.vote").forEach(voteButton => {
525 voteButton.addActivateEvent(voteButtonClicked);
530 GW.commentActionButtonClicked = (event) => {
531 GWLog("commentActionButtonClicked");
532 if (event.target.hasClass("edit-button") ||
533 event.target.hasClass("reply-button") ||
534 event.target.hasClass("new-comment-button")) {
535 queryAll("textarea").forEach(textarea => {
536 let commentControls = textarea.closest(".comment-controls");
537 if(commentControls) hideReplyForm(commentControls);
541 if (event.target.hasClass("delete-button")) {
542 let commentItem = event.target.closest(".comment-item");
543 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
544 "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" +
545 "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" +
546 "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
547 doCommentAction("delete", commentItem);
548 } else if (event.target.hasClass("retract-button")) {
549 doCommentAction("retract", event.target.closest(".comment-item"));
550 } else if (event.target.hasClass("unretract-button")) {
551 doCommentAction("unretract", event.target.closest(".comment-item"));
552 } else if (event.target.hasClass("edit-button")) {
553 showCommentEditForm(event.target.closest(".comment-item"));
554 } else if (event.target.hasClass("reply-button")) {
555 showReplyForm(event.target.closest(".comment-item"));
556 } else if (event.target.hasClass("new-comment-button")) {
557 showReplyForm(event.target.closest(".comments"));
563 function initializeCommentControls() {
564 e = document.createElement("div");
565 e.className = "comment-controls posting-controls";
566 document.currentScript.insertAdjacentElement("afterend", e);
567 e.constructCommentControls();
569 if(window.location.hash) {
570 let comment = e.closest(".comment-item");
571 if(comment && window.location.hash == "#" + comment.id)
572 expandAncestorsOf(comment);
580 // If the viewport is wide enough to fit the desktop-size content column,
581 // use a long date format; otherwise, a short one.
582 let useLongDate = window.innerWidth > 900;
583 let dtf = new Intl.DateTimeFormat([],
585 { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
586 : { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' } ));
588 function prettyDate() {
589 let dateElement = document.currentScript.parentElement;
590 let jsDate = dateElement.dataset.jsDate;
592 let pretty = dtf.format(new Date(+ jsDate));
593 window.requestAnimationFrame(() => {
594 dateElement.innerHTML = pretty;
595 dateElement.removeClass('hide-until-init');
601 // Hide elements that require javascript until ready.
602 insertHeadHTML("<style>.only-without-js { display: none; }</style><style id='hide-until-init'>.hide-until-init { visibility: hidden; }</style>");
608 let deferredCalls = [];
610 function callWithServerData(fname, uri) {
611 deferredCalls.push([fname, uri]);
618 /* Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
619 if (!window.requestIdleCallback) {
620 window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
625 function invokeTrigger(args) {
626 if(args.priority < 0) {
628 } else if(args.priority > 0) {
629 requestIdleCallback(args.fn, {timeout: args.priority});
631 setTimeout(args.fn, 0);
635 function addTriggerListener(name, args) {
636 if(typeof(GW.triggers[name])=="string") return invokeTrigger(args);
637 if(!GW.triggers[name]) GW.triggers[name] = [];
638 GW.triggers[name].push(args);
641 function activateTrigger(name) {
642 if(Array.isArray(GW.triggers[name])) {
643 GW.triggers[name].forEach(invokeTrigger);
645 GW.triggers[name] = "done";
648 function addMultiTriggerListener(triggers, args) {
649 if(triggers.length == 1) {
650 addTriggerListener(triggers[0], args);
652 let trigger = triggers.pop();
653 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});