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];
346 /***********************************/
347 /* COMMENT THREAD MINIMIZE BUTTONS */
348 /***********************************/
350 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
351 GWLog("setCommentThreadMaximized");
352 let commentItem = this;
353 let storageName = "thread-minimized-" + commentItem.getCommentId();
354 let minimize_button = commentItem.query(".comment-minimize-button");
355 let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !(localStorage.getItem(storageName) || commentItem.hasClass("ignored")));
356 if (userOriginated) {
358 localStorage.removeItem(storageName);
360 localStorage.setItem(storageName, true);
364 commentItem.style.height = maximize ? 'auto' : '38px';
365 commentItem.style.overflow = maximize ? 'visible' : 'hidden';
367 minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
368 minimize_button.innerHTML = maximize ? "" : "";
369 minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
370 if (getQueryVariable("chrono") != "t") {
371 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
375 /*****************************/
376 /* MINIMIZED THREAD HANDLING */
377 /*****************************/
379 function expandAncestorsOf(comment) {
380 GWLog("expandAncestorsOf");
381 if (typeof comment == "string") {
382 comment = /(?:comment-)?(.+)/.exec(comment)[1];
383 comment = query("#comment-" + comment);
386 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
390 // Expand collapsed comment threads.
391 let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
392 if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
394 // Expand collapsed comments.
395 let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
396 if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
399 /********************/
400 /* COMMENT CONTROLS */
401 /********************/
403 /* Adds an event listener to a button (or other clickable element), attaching
404 it to both "click" and "keyup" events (for use with keyboard navigation).
405 Optionally also attaches the listener to the 'mousedown' event, making the
406 element activate on mouse down instead of mouse up. */
407 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
408 let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
409 if (includeMouseDown) this.addEventListener("mousedown", ael);
410 this.addEventListener("click", ael);
411 this.addEventListener("keyup", ael);
414 Element.prototype.updateCommentControlButton = function() {
415 GWLog("updateCommentControlButton");
416 let retractFn = () => {
417 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
418 return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
420 return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
423 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
424 "retract-button": retractFn,
425 "unretract-button": retractFn,
426 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
428 classMap.keys().forEach((testClass) => {
429 if (this.hasClass(testClass)) {
430 let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
432 this.addClasses([ buttonClass, "action-button" ]);
433 if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
434 this.dataset.label = buttonLabel;
435 this.title = buttonAltText;
436 this.tabIndex = '-1';
442 Element.prototype.constructCommentControls = function() {
443 GWLog("constructCommentControls");
444 let commentControls = this;
446 if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
450 let commentType = commentControls.parentElement.id.replace(/s$/, "");
451 commentControls.innerHTML = "";
452 let replyButton = document.createElement("button");
453 if (commentControls.parentElement.hasClass("comments")) {
454 replyButton.className = "new-comment-button action-button";
455 replyButton.innerHTML = (commentType == "nomination" ? "Add nomination" : "Post new " + commentType);
456 replyButton.setAttribute("accesskey", (commentType == "comment" ? "n" : ""));
457 replyButton.setAttribute("title", "Post new " + commentType + (commentType == "comment" ? " [n]" : ""));
459 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
460 let buttonsList = [];
461 if(!commentControls.parentElement.query(".comment-thread"))
462 buttonsList.push("delete-button");
463 buttonsList.push("retract-button", "edit-button");
464 buttonsList.forEach(buttonClass => {
465 let button = commentControls.appendChild(document.createElement("button"));
466 button.addClass(buttonClass);
467 button.updateCommentControlButton();
470 replyButton.className = "reply-button action-button";
471 replyButton.innerHTML = "Reply";
472 replyButton.dataset.label = "Reply";
474 commentControls.appendChild(replyButton);
475 replyButton.tabIndex = '-1';
478 commentControls.queryAll(".action-button").forEach(button => {
479 button.addActivateEvent(GW.commentActionButtonClicked);
482 // Replicate voting controls at the bottom of comments.
483 if (commentControls.parentElement.hasClass("comments")) return;
484 let votingControls = commentControls.parentElement.queryAll(".comment-meta .voting-controls");
485 if (!votingControls) return;
486 votingControls.forEach(control => {
487 let controlCloned = control.cloneNode(true);
488 commentControls.appendChild(controlCloned);
491 if(commentControls.query(".active-controls")) {
492 commentControls.queryAll("button.vote").forEach(voteButton => {
493 voteButton.addActivateEvent(voteButtonClicked);
498 GW.commentActionButtonClicked = (event) => {
499 GWLog("commentActionButtonClicked");
500 if (event.target.hasClass("edit-button") ||
501 event.target.hasClass("reply-button") ||
502 event.target.hasClass("new-comment-button")) {
503 queryAll("textarea").forEach(textarea => {
504 let commentControls = textarea.closest(".comment-controls");
505 if(commentControls) hideReplyForm(commentControls);
509 if (event.target.hasClass("delete-button")) {
510 let commentItem = event.target.closest(".comment-item");
511 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
512 "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" +
513 "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" +
514 "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
515 doCommentAction("delete", commentItem);
516 } else if (event.target.hasClass("retract-button")) {
517 doCommentAction("retract", event.target.closest(".comment-item"));
518 } else if (event.target.hasClass("unretract-button")) {
519 doCommentAction("unretract", event.target.closest(".comment-item"));
520 } else if (event.target.hasClass("edit-button")) {
521 showCommentEditForm(event.target.closest(".comment-item"));
522 } else if (event.target.hasClass("reply-button")) {
523 showReplyForm(event.target.closest(".comment-item"));
524 } else if (event.target.hasClass("new-comment-button")) {
525 showReplyForm(event.target.closest(".comments"));
531 function initializeCommentControls() {
532 e = document.createElement("div");
533 e.className = "comment-controls posting-controls";
534 document.currentScript.insertAdjacentElement("afterend", e);
535 e.constructCommentControls();
537 if(window.location.hash) {
538 let comment = e.closest(".comment-item");
539 if(comment && window.location.hash == "#" + comment.id)
540 expandAncestorsOf(comment);
548 // If the viewport is wide enough to fit the desktop-size content column,
549 // use a long date format; otherwise, a short one.
550 let useLongDate = window.innerWidth > 900;
551 let dtf = new Intl.DateTimeFormat([],
553 { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
554 : { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' } ));
556 function prettyDate() {
557 let dateElement = document.currentScript.parentElement;
558 let jsDate = dateElement.dataset.jsDate;
560 let pretty = dtf.format(new Date(+ jsDate));
561 window.requestAnimationFrame(() => {
562 dateElement.innerHTML = pretty;
563 dateElement.removeClass('hide-until-init');
569 // Hide elements that require javascript until ready.
570 insertHeadHTML("<style>.only-without-js { display: none; }</style><style id='hide-until-init'>.hide-until-init { visibility: hidden; }</style>");
576 let deferredCalls = [];
578 function callWithServerData(fname, uri) {
579 deferredCalls.push([fname, uri]);
586 /* Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
587 if (!window.requestIdleCallback) {
588 window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
593 function invokeTrigger(args) {
594 if(args.priority < 0) {
596 } else if(args.priority > 0) {
597 requestIdleCallback(args.fn, {timeout: args.priority});
599 setTimeout(args.fn, 0);
603 function addTriggerListener(name, args) {
604 if(typeof(GW.triggers[name])=="string") return invokeTrigger(args);
605 if(!GW.triggers[name]) GW.triggers[name] = [];
606 GW.triggers[name].push(args);
609 function activateTrigger(name) {
610 if(Array.isArray(GW.triggers[name])) {
611 GW.triggers[name].forEach(invokeTrigger);
613 GW.triggers[name] = "done";
616 function addMultiTriggerListener(triggers, args) {
617 if(triggers.length == 1) {
618 addTriggerListener(triggers[0], args);
620 let trigger = triggers.pop();
621 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});