Split contents-to-html into top-level function.
[lw2-viewer.git] / www / head.js
blob1ce0f3ac86447a45ca4596c06e1242edc7faf6c2
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 /*      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.
44  */
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))
51                         return false;
53         return true;
55 Element.prototype.toggleClass = function(className) {
56         if (this.hasClass(className))
57                 this.removeClass(className);
58         else
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:
70     
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.)
78  */
79 Element.prototype.swapClasses = function (classes, whichToAdd) {
80         let op1 = whichToAdd ? "removeClass" : "addClass";
81         let op2 = whichToAdd ? "addClass" : "removeClass";
82         
83         this[op1](classes[0]);
84         this[op2](classes[1]);
87 /* DOM helpers */
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)) {
102             case '#':
103                 // Handle ID-based selectors
104                 let element = document.getElementById(selector.substr(1));
105                 return element ? [ element ] : [ ];
106             case '.':
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));
112             default:
113                 // Handle tag-based selectors
114                 return [].slice.call(context.getElementsByTagName(selector));
115         }
116     }
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 /***********************************/
135 GW.widthOptions = [
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;
155     return true;
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;
185                         }
187                         currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
188                 });
189         });
191         return tree;
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"
196         ];
197         
198         function selectorFromExclusionTreeNode(node, path = [ ]) {
199                 let [ value, children ] = node;
201                 let newPath = path.clone();
202                 newPath.push(value);
204                 if (!children) {
205                         return value;
206                 } else if (children.length == 0) {
207                         return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
208                 } else {
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(", ");
210                 }
211         }
212         
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})`;
220         }
221         return filterString;
223 function applyFilters(filters) {
224         var fullStyleString = "";
225         
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)}; }`;
229         }
230         
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;
248         
249         if (zoomFactor <= minZoomFactor) {
250                 zoomFactor = minZoomFactor;
251                 queryAll(".text-size-adjust-button.decrease").forEach(function (button) {
252                         button.disabled = true;
253                 });
254         } else if (zoomFactor >= maxZoomFactor) {
255                 zoomFactor = maxZoomFactor;
256                 queryAll(".text-size-adjust-button.increase").forEach(function (button) {
257                         button.disabled = true;
258                 });
259         } else {
260                 queryAll(".text-size-adjust-button").forEach(function (button) {
261                         button.disabled = false;
262                 });
263         }
265         let textZoomStyle = query("#text-zoom");
266         textZoomStyle.innerHTML = 
267                 `.post, .comment, .comment-controls {
268                         zoom: ${zoomFactor};
269                 }`;
271         if (window.generateImagesOverlay) setTimeout(generateImagesOverlay);
273 GW.currentTextZoom = localStorage.getItem('text-zoom');
274 setTextZoom(GW.currentTextZoom);
276 /**********/
277 /* THEMES */
278 /**********/
280 GW.themeOptions = [
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']
292 /*****************/
293 /* ANTI-KIBITZER */
294 /*****************/
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; }` + 
300         "</style>");
302         if(document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
303                 insertHeadHTML("<"+"title class='fake-title'></title>");
304         }
307 /****************/
308 /* DEBUG OUTPUT */
309 /****************/
311 function GWLog (string) {
312         if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
313                 console.log(string);
316 /****************/
317 /* MISC HELPERS */
318 /****************/
320 /*      Return the value of a GET (i.e., URL) parameter.
321         */
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)
328                         return pair[1];
329         }
331         return false;
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).
336         */
337 Element.prototype.getCommentId = function() {
338         let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
339         if (item) {
340                 return (/^comment-(.*)/.exec(item.id)||[])[1];
341         } else {
342                 return false;
343         }
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) {
357                 if (maximize) {
358                         localStorage.removeItem(storageName);
359                 } else {
360                         localStorage.setItem(storageName, true);
361                 }
362         }
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 ? "&#xf146;" : "&#xf0fe;";
369         minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
370         if (getQueryVariable("chrono") != "t") {
371                 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
372         }
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);
384         }
385         if (!comment) {
386                 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
387                 return;
388         }
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" ];
419                 else
420                         return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
421         };
422         let classMap = {
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" ] }
427         };
428         classMap.keys().forEach((testClass) => {
429                 if (this.hasClass(testClass)) {
430                         let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
431                         this.className = "";
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';
437                         return;
438                 }
439         });
442 Element.prototype.constructCommentControls = function() {
443         GWLog("constructCommentControls");
444         let commentControls = this;
446         if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
447                 return;
448         }
449         
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]" : ""));
458         } else {
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();
468                         });
469                 }
470                 replyButton.className = "reply-button action-button";
471                 replyButton.innerHTML = "Reply";
472                 replyButton.dataset.label = "Reply";
473         }
474         commentControls.appendChild(replyButton);
475         replyButton.tabIndex = '-1';
477         // Activate buttons.
478         commentControls.queryAll(".action-button").forEach(button => {
479                 button.addActivateEvent(GW.commentActionButtonClicked);
480         });
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);
489         });
490         
491         if(commentControls.query(".active-controls")) {
492                 commentControls.queryAll("button.vote").forEach(voteButton => {
493                         voteButton.addActivateEvent(voteButtonClicked);
494                 });
495         }
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);
506                 });
507         }
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"));
526         }
528         event.target.blur();
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);
541         }
544 /****************/
545 /* PRETTY DATES */
546 /****************/
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([], 
552                                   ( useLongDate ? 
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;
559         if (jsDate) {
560                 let pretty = dtf.format(new Date(+ jsDate));
561                 window.requestAnimationFrame(() => {
562                         dateElement.innerHTML = pretty;
563                         dateElement.removeClass('hide-until-init');
564                 });
565         }
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>");
572 /****************/
573 /* SERVER CALLS */
574 /****************/
576 let deferredCalls = [];
578 function callWithServerData(fname, uri) {
579         deferredCalls.push([fname, uri]);
582 /************/
583 /* TRIGGERS */
584 /************/
586 /*      Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
587 if (!window.requestIdleCallback) {
588         window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
591 GW.triggers = {};
593 function invokeTrigger(args) {
594         if(args.priority < 0) {
595                 args.fn();
596         } else if(args.priority > 0) {
597                 requestIdleCallback(args.fn, {timeout: args.priority});
598         } else {
599                 setTimeout(args.fn, 0);
600         }
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);
612         }
613         GW.triggers[name] = "done";
616 function addMultiTriggerListener(triggers, args) {
617         if(triggers.length == 1) {
618                 addTriggerListener(triggers[0], args);
619         } else {
620                 let trigger = triggers.pop();
621                 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});
622         }