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 Element.prototype.toggleClass = function(className) {
43 if (this.hasClass(className))
44 this.removeClass(className);
46 this.addClass(className);
49 /********************/
50 /* QUERYING THE DOM */
51 /********************/
53 function queryAll(selector, context) {
54 context = context || document;
55 // Redirect simple selectors to the more performant function
56 if (/^(#?[\w-]+|\.[\w-.]+)$/.test(selector)) {
57 switch (selector.charAt(0)) {
59 // Handle ID-based selectors
60 let element = document.getElementById(selector.substr(1));
61 return element ? [ element ] : [ ];
63 // Handle class-based selectors
64 // Query by multiple classes by converting the selector
65 // string into single spaced class names
66 var classes = selector.substr(1).replace(/\./g, ' ');
67 return [].slice.call(context.getElementsByClassName(classes));
69 // Handle tag-based selectors
70 return [].slice.call(context.getElementsByTagName(selector));
73 // Default to `querySelectorAll`
74 return [].slice.call(context.querySelectorAll(selector));
76 function query(selector, context) {
77 let all = queryAll(selector, context);
78 return (all.length > 0) ? all[0] : null;
80 Object.prototype.queryAll = function (selector) {
81 return queryAll(selector, this);
83 Object.prototype.query = function (selector) {
84 return query(selector, this);
87 /***********************************/
88 /* CONTENT COLUMN WIDTH ADJUSTMENT */
89 /***********************************/
92 ['normal', 'Narrow (fixed-width) content column', 'N'],
93 ['wide', 'Wide (fixed-width) content column', 'W'],
94 ['fluid', 'Full-width (fluid) content column', 'F']
97 function setContentWidth(widthOption) {
98 let currentWidth = localStorage.getItem("selected-width") || 'normal';
99 let head = query('head');
100 head.removeClasses(GW.widthOptions.map(wo => 'content-width-' + wo[0]));
101 head.addClass('content-width-' + (widthOption || 'normal'));
103 setContentWidth(localStorage.getItem('selected-width'));
105 /********************************************/
106 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
107 /********************************************/
109 Object.prototype.isEmpty = function () {
110 for (var prop in this) if (this.hasOwnProperty(prop)) return false;
113 Object.prototype.keys = function () {
114 return Object.keys(this);
116 Array.prototype.contains = function (element) {
117 return (this.indexOf(element) !== -1);
119 Array.prototype.clone = function() {
120 return JSON.parse(JSON.stringify(this));
123 GW.themeTweaker = { };
124 GW.themeTweaker.filtersExclusionPaths = { };
125 GW.themeTweaker.defaultFiltersExclusionTree = [ "#content", [ ] ];
127 function exclusionTreeFromExclusionPaths(paths) {
128 if (!paths) return null;
130 let tree = GW.themeTweaker.defaultFiltersExclusionTree.clone();
131 paths.keys().flatMap(key => paths[key]).forEach(path => {
132 var currentNodeInTree = tree;
133 path.split(" ").slice(1).forEach(step => {
134 if (currentNodeInTree[1] == null)
135 currentNodeInTree[1] = [ ];
137 var indexOfMatchingChild = currentNodeInTree[1].findIndex(child => { return child[0] == step; });
138 if (indexOfMatchingChild == -1) {
139 currentNodeInTree[1].push([ step, [ ] ]);
140 indexOfMatchingChild = currentNodeInTree[1].length - 1;
143 currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
149 function selectorFromExclusionTree(tree) {
150 var selectorParts = [
151 "body::before, #ui-elements-container > div:not(#theme-tweaker-ui), #theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container"
154 function selectorFromExclusionTreeNode(node, path = [ ]) {
155 let [ value, children ] = node;
157 let newPath = path.clone();
162 } else if (children.length == 0) {
163 return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
165 return `${newPath.join(" > ")} > *:not(${children.map(child => child[0]).join("):not(")}), ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after, ` + children.map(child => selectorFromExclusionTreeNode(child, newPath)).join(", ");
169 return selectorParts + ", " + selectorFromExclusionTreeNode(tree);
171 function filterStringFromFilters(filters) {
172 var filterString = "";
173 for (key of Object.keys(filters)) {
174 let value = filters[key];
175 filterString += ` ${key}(${value})`;
179 function applyFilters(filters) {
180 var fullStyleString = "";
182 if (!filters.isEmpty()) {
183 let filtersExclusionTree = exclusionTreeFromExclusionPaths(GW.themeTweaker.filtersExclusionPaths) || GW.themeTweaker.defaultFiltersExclusionTree;
184 fullStyleString = `body::before { content: ""; } body > #content::before { z-index: 0; } ${selectorFromExclusionTree(filtersExclusionTree)} { filter: ${filterStringFromFilters(filters)}; }`;
187 // Update the style tag (if it’s already been loaded).
188 (query("#theme-tweak")||{}).innerHTML = fullStyleString;
190 query("head").insertAdjacentHTML("beforeend", "<style id='theme-tweak'></style>");
191 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
192 applyFilters(GW.currentFilters);
194 /************************/
195 /* TEXT SIZE ADJUSTMENT */
196 /************************/
198 query("head").insertAdjacentHTML("beforeend", "<style id='text-zoom'></style>");
199 function setTextZoom(zoomFactor) {
200 if (!zoomFactor) return;
202 let minZoomFactor = 0.5;
203 let maxZoomFactor = 1.5;
205 if (zoomFactor <= minZoomFactor) {
206 zoomFactor = minZoomFactor;
207 queryAll(".text-size-adjust-button.decrease").forEach(function (button) {
208 button.disabled = true;
210 } else if (zoomFactor >= maxZoomFactor) {
211 zoomFactor = maxZoomFactor;
212 queryAll(".text-size-adjust-button.increase").forEach(function (button) {
213 button.disabled = true;
216 queryAll(".text-size-adjust-button").forEach(function (button) {
217 button.disabled = false;
221 let textZoomStyle = query("#text-zoom");
222 textZoomStyle.innerHTML =
223 `.post, .comment, .comment-controls {
227 if (window.generateImagesOverlay) setTimeout(generateImagesOverlay);
229 GW.currentTextZoom = localStorage.getItem('text-zoom');
230 setTextZoom(GW.currentTextZoom);
237 ['default', 'Default theme (dark text on light background)', 'A'],
238 ['dark', 'Dark theme (light text on dark background)', 'B'],
239 ['grey', 'Grey theme (more subdued than default theme)', 'C'],
240 ['ultramodern', 'Ultramodern theme (very hip)', 'D'],
241 ['zero', 'Theme zero (plain and simple)', 'E'],
242 ['brutalist', 'Brutalist theme (the Motherland calls!)', 'F'],
243 ['rts', 'ReadTheSequences.com theme', 'G'],
244 ['classic', 'Classic Less Wrong theme', 'H'],
245 ['less', 'Less theme (serenity now)', 'I']
252 // While everything's being loaded, hide the authors and karma values.
253 if (localStorage.getItem("antikibitzer") == "true") {
254 query("head").insertAdjacentHTML("beforeend", "<style id='antikibitzer-temp'>" +
255 `.author, .inline-author, .karma-value, .individual-thread-page > h1 { visibility: hidden; }` +
258 if(document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
259 query("head").insertAdjacentHTML("beforeend", "<"+"title class='fake-title'></title>");
267 function GWLog (string) {
268 if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
276 /* Return the value of a GET (i.e., URL) parameter.
278 function getQueryVariable(variable) {
279 var query = window.location.search.substring(1);
280 var vars = query.split("&");
281 for (var i = 0; i < vars.length; i++) {
282 var pair = vars[i].split("=");
283 if (pair[0] == variable)
290 /* Get the comment ID of the item (if it's a comment) or of its containing
291 comment (if it's a child of a comment).
293 Element.prototype.getCommentId = function() {
294 let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
296 return (/^comment-(.*)/.exec(item.id)||[])[1];
302 /***********************************/
303 /* COMMENT THREAD MINIMIZE BUTTONS */
304 /***********************************/
306 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
307 GWLog("setCommentThreadMaximized");
308 let commentItem = this;
309 let storageName = "thread-minimized-" + commentItem.getCommentId();
310 let minimize_button = commentItem.query(".comment-minimize-button");
311 let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !(localStorage.getItem(storageName) || commentItem.hasClass("ignored")));
312 if (userOriginated) {
314 localStorage.removeItem(storageName);
316 localStorage.setItem(storageName, true);
320 commentItem.style.height = maximize ? 'auto' : '38px';
321 commentItem.style.overflow = maximize ? 'visible' : 'hidden';
323 minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
324 minimize_button.innerHTML = maximize ? "" : "";
325 minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
326 if (getQueryVariable("chrono") != "t") {
327 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
331 /*****************************/
332 /* MINIMIZED THREAD HANDLING */
333 /*****************************/
335 function expandAncestorsOf(comment) {
336 GWLog("expandAncestorsOf");
337 if (typeof comment == "string") {
338 comment = /(?:comment-)?(.+)/.exec(comment)[1];
339 comment = query("#comment-" + comment);
342 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
346 // Expand collapsed comment threads.
347 let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
348 if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
350 // Expand collapsed comments.
351 let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
352 if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
355 /********************/
356 /* COMMENT CONTROLS */
357 /********************/
359 /* Adds an event listener to a button (or other clickable element), attaching
360 it to both "click" and "keyup" events (for use with keyboard navigation).
361 Optionally also attaches the listener to the 'mousedown' event, making the
362 element activate on mouse down instead of mouse up. */
363 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
364 let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
365 if (includeMouseDown) this.addEventListener("mousedown", ael);
366 this.addEventListener("click", ael);
367 this.addEventListener("keyup", ael);
370 Element.prototype.updateCommentControlButton = function() {
371 GWLog("updateCommentControlButton");
372 let retractFn = () => {
373 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
374 return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
376 return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
379 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
380 "retract-button": retractFn,
381 "unretract-button": retractFn,
382 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
384 classMap.keys().forEach((testClass) => {
385 if (this.hasClass(testClass)) {
386 let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
388 this.addClasses([ buttonClass, "action-button" ]);
389 if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
390 this.dataset.label = buttonLabel;
391 this.title = buttonAltText;
392 this.tabIndex = '-1';
398 Element.prototype.constructCommentControls = function() {
399 GWLog("constructCommentControls");
400 let commentControls = this;
402 if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
406 let commentType = commentControls.parentElement.id.replace(/s$/, "");
407 commentControls.innerHTML = "";
408 let replyButton = document.createElement("button");
409 if (commentControls.parentElement.hasClass("comments")) {
410 replyButton.className = "new-comment-button action-button";
411 replyButton.innerHTML = (commentType == "nomination" ? "Add nomination" : "Post new " + commentType);
412 replyButton.setAttribute("accesskey", (commentType == "comment" ? "n" : ""));
413 replyButton.setAttribute("title", "Post new " + commentType + (commentType == "comment" ? " [n]" : ""));
415 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
416 let buttonsList = [];
417 if(!commentControls.parentElement.query(".comment-thread"))
418 buttonsList.push("delete-button");
419 buttonsList.push("retract-button", "edit-button");
420 buttonsList.forEach(buttonClass => {
421 let button = commentControls.appendChild(document.createElement("button"));
422 button.addClass(buttonClass);
423 button.updateCommentControlButton();
426 replyButton.className = "reply-button action-button";
427 replyButton.innerHTML = "Reply";
428 replyButton.dataset.label = "Reply";
430 commentControls.appendChild(replyButton);
431 replyButton.tabIndex = '-1';
434 commentControls.queryAll(".action-button").forEach(button => {
435 button.addActivateEvent(GW.commentActionButtonClicked);
438 // Replicate karma controls at the bottom of comments.
439 if (commentControls.parentElement.hasClass("comments")) return;
440 let karmaControls = commentControls.parentElement.query(".comment-meta .karma");
441 if (!karmaControls) return;
442 let karmaControlsCloned = karmaControls.cloneNode(true);
443 commentControls.appendChild(karmaControlsCloned);
444 if(commentControls.query(".active-controls")) {
445 commentControls.queryAll("button.vote").forEach(voteButton => {
446 voteButton.addActivateEvent(voteButtonClicked);
451 GW.commentActionButtonClicked = (event) => {
452 GWLog("commentActionButtonClicked");
453 if (event.target.hasClass("edit-button") ||
454 event.target.hasClass("reply-button") ||
455 event.target.hasClass("new-comment-button")) {
456 queryAll("textarea").forEach(textarea => {
457 let commentControls = textarea.closest(".comment-controls");
458 if(commentControls) hideReplyForm(commentControls);
462 if (event.target.hasClass("delete-button")) {
463 let commentItem = event.target.closest(".comment-item");
464 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
465 "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" +
466 "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" +
467 "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
468 doCommentAction("delete", commentItem);
469 } else if (event.target.hasClass("retract-button")) {
470 doCommentAction("retract", event.target.closest(".comment-item"));
471 } else if (event.target.hasClass("unretract-button")) {
472 doCommentAction("unretract", event.target.closest(".comment-item"));
473 } else if (event.target.hasClass("edit-button")) {
474 showCommentEditForm(event.target.closest(".comment-item"));
475 } else if (event.target.hasClass("reply-button")) {
476 showReplyForm(event.target.closest(".comment-item"));
477 } else if (event.target.hasClass("new-comment-button")) {
478 showReplyForm(event.target.closest(".comments"));
484 function initializeCommentControls() {
485 if(query(".tag-index-page")) return; // FIXME
486 e = document.createElement("div");
487 e.className = "comment-controls posting-controls";
488 document.currentScript.insertAdjacentElement("afterend", e);
489 e.constructCommentControls();
491 if(window.location.hash) {
492 let comment = e.closest(".comment-item");
493 if(comment && window.location.hash == "#" + comment.id)
494 expandAncestorsOf(comment);
502 // If the viewport is wide enough to fit the desktop-size content column,
503 // use a long date format; otherwise, a short one.
504 let useLongDate = window.innerWidth > 900;
505 let dtf = new Intl.DateTimeFormat([],
507 { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
508 : { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' } ));
510 function prettyDate() {
511 let dateElement = document.currentScript.parentElement;
512 let jsDate = dateElement.dataset.jsDate;
514 let pretty = dtf.format(new Date(+ jsDate));
515 window.requestAnimationFrame(() => {
516 dateElement.innerHTML = pretty;
517 dateElement.removeClass('hide-until-init');
523 // Hide elements that require javascript until ready.
524 query("head").insertAdjacentHTML("beforeend", "<style>.only-without-js { display: none; }</style><style id='hide-until-init'>.hide-until-init { visibility: hidden; }</style>");