Make url-scanner accept urls with a query but no /, like http://​​garden.lesswrong...
[lw2-viewer.git] / www / head.js
blob403c6ba7e9cc439e595cb689a8428dc2da5df8a1
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/);
21         
22         classNames.forEach(className => {
23                 if (!this.hasClass(className))
24                         elementClassNames.push(className);
25         });
26         
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();
36         });
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);
45         else
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)) {
58             case '#':
59                 // Handle ID-based selectors
60                 let element = document.getElementById(selector.substr(1));
61                 return element ? [ element ] : [ ];
62             case '.':
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));
68             default:
69                 // Handle tag-based selectors
70                 return [].slice.call(context.getElementsByTagName(selector));
71         }
72     }
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 /***********************************/
91 GW.widthOptions = [
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;
111     return true;
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;
141                         }
143                         currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
144                 });
145         });
147         return tree;
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"
152         ];
153         
154         function selectorFromExclusionTreeNode(node, path = [ ]) {
155                 let [ value, children ] = node;
157                 let newPath = path.clone();
158                 newPath.push(value);
160                 if (!children) {
161                         return value;
162                 } else if (children.length == 0) {
163                         return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
164                 } else {
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(", ");
166                 }
167         }
168         
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})`;
176         }
177         return filterString;
179 function applyFilters(filters) {
180         var fullStyleString = "";
181         
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)}; }`;
185         }
186         
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;
204         
205         if (zoomFactor <= minZoomFactor) {
206                 zoomFactor = minZoomFactor;
207                 queryAll(".text-size-adjust-button.decrease").forEach(function (button) {
208                         button.disabled = true;
209                 });
210         } else if (zoomFactor >= maxZoomFactor) {
211                 zoomFactor = maxZoomFactor;
212                 queryAll(".text-size-adjust-button.increase").forEach(function (button) {
213                         button.disabled = true;
214                 });
215         } else {
216                 queryAll(".text-size-adjust-button").forEach(function (button) {
217                         button.disabled = false;
218                 });
219         }
221         let textZoomStyle = query("#text-zoom");
222         textZoomStyle.innerHTML = 
223                 `.post, .comment, .comment-controls {
224                         zoom: ${zoomFactor};
225                 }`;
227         if (window.generateImagesOverlay) setTimeout(generateImagesOverlay);
229 GW.currentTextZoom = localStorage.getItem('text-zoom');
230 setTextZoom(GW.currentTextZoom);
232 /**********/
233 /* THEMES */
234 /**********/
236 GW.themeOptions = [
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']
248 /*****************/
249 /* ANTI-KIBITZER */
250 /*****************/
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; }` + 
256         "</style>");
258         if(document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
259                 query("head").insertAdjacentHTML("beforeend", "<"+"title class='fake-title'></title>");
260         }
263 /****************/
264 /* DEBUG OUTPUT */
265 /****************/
267 function GWLog (string) {
268         if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
269                 console.log(string);
272 /****************/
273 /* MISC HELPERS */
274 /****************/
276 /*      Return the value of a GET (i.e., URL) parameter.
277         */
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)
284                         return pair[1];
285         }
287         return false;
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).
292         */
293 Element.prototype.getCommentId = function() {
294         let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
295         if (item) {
296                 return (/^comment-(.*)/.exec(item.id)||[])[1];
297         } else {
298                 return false;
299         }
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) {
313                 if (maximize) {
314                         localStorage.removeItem(storageName);
315                 } else {
316                         localStorage.setItem(storageName, true);
317                 }
318         }
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 ? "&#xf146;" : "&#xf0fe;";
325         minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
326         if (getQueryVariable("chrono") != "t") {
327                 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
328         }
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);
340         }
341         if (!comment) {
342                 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
343                 return;
344         }
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" ];
375                 else
376                         return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
377         };
378         let classMap = {
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" ] }
383         };
384         classMap.keys().forEach((testClass) => {
385                 if (this.hasClass(testClass)) {
386                         let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
387                         this.className = "";
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';
393                         return;
394                 }
395         });
398 Element.prototype.constructCommentControls = function() {
399         GWLog("constructCommentControls");
400         let commentControls = this;
402         if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
403                 return;
404         }
405         
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]" : ""));
414         } else {
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();
424                         });
425                 }
426                 replyButton.className = "reply-button action-button";
427                 replyButton.innerHTML = "Reply";
428                 replyButton.dataset.label = "Reply";
429         }
430         commentControls.appendChild(replyButton);
431         replyButton.tabIndex = '-1';
433         // Activate buttons.
434         commentControls.queryAll(".action-button").forEach(button => {
435                 button.addActivateEvent(GW.commentActionButtonClicked);
436         });
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);
447                 });
448         }
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);
459                 });
460         }
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"));
479         }
481         event.target.blur();
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);
495         }
498 /****************/
499 /* PRETTY DATES */
500 /****************/
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([], 
506                                   ( useLongDate ? 
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;
513         if (jsDate) {
514                 let pretty = dtf.format(new Date(+ jsDate));
515                 window.requestAnimationFrame(() => {
516                         dateElement.innerHTML = pretty;
517                         dateElement.removeClass('hide-until-init');
518                 });
519         }
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>");