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.replace(/\s+/, " "));
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);
135 Object.prototype.isEmpty = function () {
136 for (var prop in this) if (this.hasOwnProperty(prop)) return false;
139 Object.prototype.keys = function () {
140 return Object.keys(this);
142 Array.prototype.contains = function (element) {
143 return (this.indexOf(element) !== -1);
145 Array.prototype.clone = function() {
146 return JSON.parse(JSON.stringify(this));
149 /********************************************************************/
150 /* Reads the value of named cookie.
151 Returns the cookie as a string, or null if no such cookie exists.
153 function readCookie(name) {
154 var nameEQ = name + "=";
155 var ca = document.cookie.split(';');
156 for(var i = 0; i < ca.length; i++) {
158 while (c.charAt(0)==' ') c = c.substring(1, c.length);
159 if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
164 /***************************************************************************/
165 /* Create and return a new element with the specified tag name, attributes,
166 and object properties.
168 function newElement(tagName, attributes = { }, properties = { }) {
169 let element = document.createElement(tagName);
170 for (const attrName in attributes)
171 if (attributes.hasOwnProperty(attrName))
172 element.setAttribute(attrName, attributes[attrName]);
173 for (const propName in properties)
174 if (properties.hasOwnProperty(propName))
175 element[propName] = properties[propName];
179 /****************************/
180 /* APPEARANCE CUSTOMIZATION */
181 /****************************/
188 defaultTheme: "default",
190 defaultWidth: "normal",
191 defaultTextZoom: 1.0,
197 [ "normal", "Narrow (fixed-width) content column", "N" ],
198 [ "wide", "Wide (fixed-width) content column", "W" ],
199 [ "fluid", "Full-width (fluid) content column", "F" ]
202 textSizeAdjustTargetElementsSelector: [
210 [ "default", "Default theme (dark text on light background)", "A" ],
211 [ "dark", "Dark theme (light text on dark background)", "B" ],
212 [ "grey", "Grey theme (more subdued than default theme)", "C" ],
213 [ "ultramodern", "Ultramodern theme (very hip)", "D" ],
214 [ "zero", "Theme zero (plain and simple)", "E" ],
215 [ "brutalist", "Brutalist theme (the Motherland calls!)", "F" ],
216 [ "rts", "ReadTheSequences.com theme", "G" ],
217 [ "classic", "Classic Less Wrong theme", "H" ],
218 [ "less", "Less theme (serenity now)", "I" ]
221 defaultFiltersExclusionTree: [ "#content", [ ] ],
223 defaultThemeTweakerClippyState: true,
225 defaultAppearanceAdjustUIToggleState: false,
227 themeLessAppearanceAdjustUIElementsSelector: [
228 "#comments-view-mode-selector",
230 "#dark-mode-selector",
232 "#text-size-adjustment-ui",
233 "#theme-tweaker-toggle",
234 "#appearance-adjust-ui-toggle button"
242 currentFilters: null,
244 currentTextZoom: null,
246 filtersExclusionPaths: { },
248 themeTweakStyleBlock: null,
249 textZoomStyleBlock: null,
258 getSavedTheme: () => {
259 return (readCookie("theme") || Appearance.defaultTheme);
262 /* Filters (theme tweaks).
265 getSavedFilters: () => {
266 return (JSON.parse(localStorage.getItem("theme-tweaks")) || Appearance.defaultFilters);
269 saveCurrentFilters: () => {
270 GWLog("Appearance.saveCurrentFilters");
272 if (Appearance.currentFilters == { })
273 localStorage.removeItem("theme-tweaks");
275 localStorage.setItem("theme-tweaks", JSON.stringify(Appearance.currentFilters));
278 applyFilters: (filters) => {
279 GWLog("Appearance.applyFilters");
281 if (typeof filters == "undefined")
282 filters = Appearance.currentFilters;
284 let fullStyleString = "";
285 if (!filters.isEmpty()) {
286 let filtersExclusionTree = ( Appearance.exclusionTreeFromExclusionPaths(Appearance.filtersExclusionPaths)
287 || Appearance.defaultFiltersExclusionTree);
288 fullStyleString = `body::before { content: ""; } body > #content::before { z-index: 0; }`
289 + ` ${Appearance.selectorFromExclusionTree(filtersExclusionTree)}`
290 + ` { filter: ${Appearance.filterStringFromFilters(filters)}; }`;
293 // Update the style tag.
294 Appearance.themeTweakStyleBlock.innerHTML = fullStyleString;
296 // Update the current filters.
297 Appearance.currentFilters = filters;
300 exclusionTreeFromExclusionPaths: (paths) => {
304 let tree = Appearance.defaultFiltersExclusionTree.clone();
305 paths.keys().flatMap(key => paths[key]).forEach(path => {
306 var currentNodeInTree = tree;
307 path.split(" ").slice(1).forEach(step => {
308 if (currentNodeInTree[1] == null)
309 currentNodeInTree[1] = [ ];
311 var indexOfMatchingChild = currentNodeInTree[1].findIndex(child => { return child[0] == step; });
312 if (indexOfMatchingChild == -1) {
313 currentNodeInTree[1].push([ step, [ ] ]);
314 indexOfMatchingChild = currentNodeInTree[1].length - 1;
317 currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
324 selectorFromExclusionTree: (tree) => {
325 let selectorParts = [
326 "body::before, #ui-elements-container > div:not(#theme-tweaker-ui), #theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container"
329 function selectorFromExclusionTreeNode(node, path = [ ]) {
330 let [ value, children ] = node;
332 let newPath = path.clone();
337 } else if (children.length == 0) {
338 return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
340 return `${newPath.join(" > ")} > *:not(${children.map(child => child[0]).join("):not(")}), ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after, ` + children.map(child => selectorFromExclusionTreeNode(child, newPath)).join(", ");
344 return selectorParts + ", " + selectorFromExclusionTreeNode(tree);
347 filterStringFromFilters: (filters) => {
348 let filterString = "";
349 for (key of Object.keys(filters)) {
350 let value = filters[key];
351 filterString += ` ${key}(${value})`;
356 /* Content column width.
359 getSavedWidth: () => {
360 return (localStorage.getItem("selected-width") || Appearance.defaultWidth);
363 saveCurrentWidth: () => {
364 GWLog("Appearance.saveCurrentWidth");
366 if (Appearance.currentWidth == "normal")
367 localStorage.removeItem("selected-width");
369 localStorage.setItem("selected-width", Appearance.currentWidth);
372 setContentWidth: (widthOption) => {
373 GWLog("Appearance.setContentWidth");
375 document.head.removeClasses(Appearance.widthOptions.map(wo => "content-width-" + wo[0]));
376 document.head.addClass("content-width-" + (widthOption || Appearance.currentWidth));
382 getSavedTextZoom: () => {
383 return (parseFloat(localStorage.getItem("text-zoom")) || Appearance.defaultTextZoom);
386 saveCurrentTextZoom: () => {
387 GWLog("Appearance.saveCurrentTextZoom");
389 if (Appearance.currentTextZoom == 1.0)
390 localStorage.removeItem("text-zoom");
392 localStorage.setItem("text-zoom", Appearance.currentTextZoom);
395 setTextZoom: (zoomFactor, save = true) => {
396 GWLog("Appearance.setTextZoom");
401 if (zoomFactor <= Appearance.minTextZoom) {
402 zoomFactor = Appearance.minTextZoom;
403 queryAll(".text-size-adjust-button.decrease").forEach(button => {
404 button.disabled = true;
406 } else if (zoomFactor >= Appearance.maxTextZoom) {
407 zoomFactor = Appearance.maxTextZoom;
408 queryAll(".text-size-adjust-button.increase").forEach(button => {
409 button.disabled = true;
412 queryAll(".text-size-adjust-button").forEach(button => {
413 button.disabled = false;
417 Appearance.currentTextZoom = zoomFactor;
419 Appearance.textZoomStyleBlock.innerHTML = `${Appearance.textSizeAdjustTargetElementsSelector} { zoom: ${zoomFactor}; }`;
421 if (window.generateImagesOverlay) {
422 requestAnimationFrame(() => {
423 generateImagesOverlay();
428 Appearance.saveCurrentTextZoom();
435 // Set up appearance system and apply saved settings.
437 GWLog("Appearance.setup");
439 Appearance.currentTheme = Appearance.getSavedTheme();
440 Appearance.currentFilters = Appearance.getSavedFilters();
441 Appearance.currentWidth = Appearance.getSavedWidth();
442 Appearance.currentTextZoom = Appearance.getSavedTextZoom();
444 insertHeadHTML("<style id='theme-tweak'></style>");
445 insertHeadHTML("<style id='text-zoom'></style>");
447 Appearance.themeTweakStyleBlock = document.head.query("#theme-tweak");
448 Appearance.textZoomStyleBlock = document.head.query("#text-zoom");
450 Appearance.applyFilters();
451 Appearance.setContentWidth();
452 Appearance.setTextZoom();
462 // While everything's being loaded, hide the authors and karma values.
463 if (localStorage.getItem("antikibitzer") == "true") {
464 insertHeadHTML("<style id='antikibitzer-temp'>" +
465 `.author, .inline-author, .karma-value, .individual-thread-page > h1 { visibility: hidden; }` +
468 if (document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
469 insertHeadHTML("<"+"title class='fake-title'></title>");
477 function GWLog (string) {
478 if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
486 /* Return the value of a GET (i.e., URL) parameter.
488 function getQueryVariable(variable) {
489 var query = window.location.search.substring(1);
490 var vars = query.split("&");
491 for (var i = 0; i < vars.length; i++) {
492 var pair = vars[i].split("=");
493 if (pair[0] == variable)
500 /* Get the comment ID of the item (if it's a comment) or of its containing
501 comment (if it's a child of a comment).
503 Element.prototype.getCommentId = function() {
504 let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
506 return (/^comment-(.*)/.exec(item.id)||[])[1];
512 /***********************************/
513 /* COMMENT THREAD MINIMIZE BUTTONS */
514 /***********************************/
516 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
517 GWLog("setCommentThreadMaximized");
518 let commentItem = this;
519 let storageName = "thread-minimized-" + commentItem.getCommentId();
520 let minimize_button = commentItem.query(".comment-minimize-button");
521 let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !(localStorage.getItem(storageName) || commentItem.hasClass("ignored")));
522 if (userOriginated) {
524 localStorage.removeItem(storageName);
526 localStorage.setItem(storageName, true);
530 commentItem.style.height = maximize ? 'auto' : '38px';
531 commentItem.style.overflow = maximize ? 'visible' : 'hidden';
533 minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
534 minimize_button.innerHTML = maximize ? "" : "";
535 minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
536 if (getQueryVariable("chrono") != "t") {
537 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
541 /*****************************/
542 /* MINIMIZED THREAD HANDLING */
543 /*****************************/
545 function expandAncestorsOf(comment) {
546 GWLog("expandAncestorsOf");
547 if (typeof comment == "string") {
548 comment = /(?:comment-)?(.+)/.exec(comment)[1];
549 comment = query("#comment-" + comment);
552 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
556 // Expand collapsed comment threads.
557 let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
558 if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
560 // Expand collapsed comments.
561 let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
562 if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
565 /********************/
566 /* COMMENT CONTROLS */
567 /********************/
569 /* Adds an event listener to a button (or other clickable element), attaching
570 it to both "click" and "keyup" events (for use with keyboard navigation).
571 Optionally also attaches the listener to the 'mousedown' event, making the
572 element activate on mouse down instead of mouse up. */
573 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
574 let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
575 if (includeMouseDown) this.addEventListener("mousedown", ael);
576 this.addEventListener("click", ael);
577 this.addEventListener("keyup", ael);
580 Element.prototype.updateCommentControlButton = function() {
581 GWLog("updateCommentControlButton");
582 let retractFn = () => {
583 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
584 return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
586 return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
589 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
590 "retract-button": retractFn,
591 "unretract-button": retractFn,
592 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
594 classMap.keys().forEach((testClass) => {
595 if (this.hasClass(testClass)) {
596 let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
598 this.addClasses([ buttonClass, "action-button" ]);
599 if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
600 this.dataset.label = buttonLabel;
601 this.title = buttonAltText;
602 this.tabIndex = '-1';
608 Element.prototype.constructCommentControls = function() {
609 GWLog("constructCommentControls");
610 let commentControls = this;
612 if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
616 let commentType = commentControls.parentElement.id.replace(/s$/, "");
617 commentControls.innerHTML = "";
619 if (commentControls.parentElement.hasClass("comments")) {
620 replyButton = newElement("BUTTON", {
621 "class": "new-comment-button action-button",
622 "accesskey": (commentType == "comment" ? "n" : ""),
623 "title": ("Post new " + commentType + (commentType == "comment" ? " [n]" : "")),
626 "innerHTML": (commentType == "nomination" ? "Add nomination" : "Post new " + commentType)
629 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
630 let buttonsList = [];
631 if (!commentControls.parentElement.query(".comment-thread"))
632 buttonsList.push("delete-button");
633 buttonsList.push("retract-button", "edit-button");
634 buttonsList.forEach(buttonClass => {
635 let button = commentControls.appendChild(newElement("BUTTON", { "class": buttonClass }));
636 button.updateCommentControlButton();
639 replyButton = newElement("BUTTON", {
640 "class": "reply-button action-button",
641 "data-label": "Reply",
647 commentControls.appendChild(replyButton);
650 commentControls.queryAll(".action-button").forEach(button => {
651 button.addActivateEvent(GW.commentActionButtonClicked);
654 // Replicate voting controls at the bottom of comments.
655 if (commentControls.parentElement.hasClass("comments")) return;
656 let votingControls = commentControls.parentElement.queryAll(".comment-meta .voting-controls");
657 if (!votingControls) return;
658 votingControls.forEach(control => {
659 let controlCloned = control.cloneNode(true);
660 commentControls.appendChild(controlCloned);
663 if(commentControls.query(".active-controls")) {
664 commentControls.queryAll("button.vote").forEach(voteButton => {
665 voteButton.addActivateEvent(voteButtonClicked);
670 GW.commentActionButtonClicked = (event) => {
671 GWLog("commentActionButtonClicked");
672 if (event.target.hasClass("edit-button") ||
673 event.target.hasClass("reply-button") ||
674 event.target.hasClass("new-comment-button")) {
675 queryAll("textarea").forEach(textarea => {
676 let commentControls = textarea.closest(".comment-controls");
677 if(commentControls) hideReplyForm(commentControls);
681 if (event.target.hasClass("delete-button")) {
682 let commentItem = event.target.closest(".comment-item");
683 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
684 "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" +
685 "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" +
686 "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
687 doCommentAction("delete", commentItem);
688 } else if (event.target.hasClass("retract-button")) {
689 doCommentAction("retract", event.target.closest(".comment-item"));
690 } else if (event.target.hasClass("unretract-button")) {
691 doCommentAction("unretract", event.target.closest(".comment-item"));
692 } else if (event.target.hasClass("edit-button")) {
693 showCommentEditForm(event.target.closest(".comment-item"));
694 } else if (event.target.hasClass("reply-button")) {
695 showReplyForm(event.target.closest(".comment-item"));
696 } else if (event.target.hasClass("new-comment-button")) {
697 showReplyForm(event.target.closest(".comments"));
703 function initializeCommentControls() {
704 e = newElement("DIV", { "class": "comment-controls posting-controls" });
705 document.currentScript.insertAdjacentElement("afterend", e);
706 e.constructCommentControls();
708 if (window.location.hash) {
709 let comment = e.closest(".comment-item");
710 if(comment && window.location.hash == "#" + comment.id)
711 expandAncestorsOf(comment);
719 // If the viewport is wide enough to fit the desktop-size content column,
720 // use a long date format; otherwise, a short one.
721 let useLongDate = window.innerWidth > 900;
722 let dtf = new Intl.DateTimeFormat([],
724 { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
725 : { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' } ));
727 function prettyDate() {
728 let dateElement = document.currentScript.parentElement;
729 let jsDate = dateElement.dataset.jsDate;
731 let pretty = dtf.format(new Date(+ jsDate));
732 window.requestAnimationFrame(() => {
733 dateElement.innerHTML = pretty;
734 dateElement.removeClass('hide-until-init');
740 // Hide elements that require javascript until ready.
741 insertHeadHTML("<style>.only-without-js { display: none; }</style><style id='hide-until-init'>.hide-until-init { visibility: hidden; }</style>");
747 let deferredCalls = [];
749 function callWithServerData(fname, uri) {
750 deferredCalls.push([fname, uri]);
757 /* Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
758 if (!window.requestIdleCallback) {
759 window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
764 function invokeTrigger(args) {
765 if(args.priority < 0) {
767 } else if(args.priority > 0) {
768 requestIdleCallback(args.fn, {timeout: args.priority});
770 setTimeout(args.fn, 0);
774 function addTriggerListener(name, args) {
775 if(typeof(GW.triggers[name])=="string") return invokeTrigger(args);
776 if(!GW.triggers[name]) GW.triggers[name] = [];
777 GW.triggers[name].push(args);
780 function activateTrigger(name) {
781 if(Array.isArray(GW.triggers[name])) {
782 GW.triggers[name].forEach(invokeTrigger);
784 GW.triggers[name] = "done";
787 function addMultiTriggerListener(triggers, args) {
788 if(triggers.length == 1) {
789 addTriggerListener(triggers[0], args);
791 let trigger = triggers.pop();
792 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});