You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

452 lines
17KB

  1. // darkmode.js: Javascript library for controlling page appearance, toggling between regular white and 'dark mode'
  2. // Author: Said Achmiz
  3. // Date: 2020-03-20
  4. // When: Time-stamp: "2020-03-23 09:36:20 gwern"
  5. // license: PD
  6. /* Experimental 'dark mode': Mac OS (Safari) lets users specify via an OS widget 'dark'/'light' to make everything appear */
  7. /* bright-white or darker (eg for darker at evening to avoid straining eyes & disrupting circadian rhyhms); this then is */
  8. /* exposed by Safari as a CSS variable which can be selected on. This is also currently supported by Firefox weakly as an */
  9. /* about:config variable. Hypothetically, iOS in the future might use its camera or the clock to set 'dark mode' */
  10. /* automatically. https://drafts.csswg.org/mediaqueries-5/#prefers-color-scheme */
  11. /* https://webkit.org/blog/8718/new-webkit-features-in-safari-12-1/ */
  12. /* https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
  13. /* Because many users do not have access to a browser/OS which explicitly supports dark mode, cannot modify the browser/OS setting without undesired side-effects, wish to opt in only for specific websites, or simply forget that they turned on dark mode & dislike it, we make dark mode controllable by providing a widget at the top of the page. */
  14. /* For gwern.net, the default white-black */
  15. /* scheme is 'light', and it can be flipped to a 'dark' scheme fairly easily by inverting it; the main visual problem is */
  16. /* that blockquotes appear to become much harder to see & image-focus.js doesn't work well without additional tweaks. */
  17. /* Known bugs: images get inverted on zoom or hover; invert filters are slow, leading to 'janky' slow rendering on scrolling. */
  18. /****************/
  19. /* MISC HELPERS */
  20. /****************/
  21. /* Given an HTML string, creates an element from that HTML, adds it to
  22. #ui-elements-container (creating the latter if it does not exist), and
  23. returns the created element.
  24. */
  25. function addUIElement(element_html) {
  26. var ui_elements_container = document.querySelector("#ui-elements-container");
  27. if (!ui_elements_container) {
  28. ui_elements_container = document.createElement("div");
  29. ui_elements_container.id = "ui-elements-container";
  30. document.querySelector("body").appendChild(ui_elements_container);
  31. }
  32. ui_elements_container.insertAdjacentHTML("beforeend", element_html);
  33. return ui_elements_container.lastElementChild;
  34. }
  35. if (typeof window.GW == "undefined")
  36. window.GW = { };
  37. GW.temp = { };
  38. if (GW.mediaQueries == null)
  39. GW.mediaQueries = { };
  40. GW.mediaQueries.mobileNarrow = matchMedia("(max-width: 520px)");
  41. GW.mediaQueries.mobileWide = matchMedia("(max-width: 900px)");
  42. GW.mediaQueries.mobileMax = matchMedia("(max-width: 960px)");
  43. GW.mediaQueries.hover = matchMedia("only screen and (hover: hover) and (pointer: fine)");
  44. GW.mediaQueries.systemDarkModeActive = matchMedia("(prefers-color-scheme: dark)");
  45. GW.modeOptions = [
  46. [ 'auto', 'Auto', 'Set light or dark mode automatically, according to system-wide setting' ],
  47. [ 'light', 'Light', 'Light mode at all times' ],
  48. [ 'dark', 'Dark', 'Dark mode at all times' ]
  49. ];
  50. GW.modeStyles = `
  51. :root {
  52. --GW-blockquote-background-color: #ddd
  53. }
  54. body::before,
  55. body > * {
  56. filter: invert(90%)
  57. }
  58. body::before {
  59. content: '';
  60. width: 100vw;
  61. height: 100%;
  62. position: fixed;
  63. left: 0;
  64. top: 0;
  65. background-color: #fff;
  66. z-index: -1
  67. }
  68. img,
  69. video {
  70. filter: invert(100%);
  71. }
  72. #markdownBody, #mode-selector button {
  73. text-shadow: 0 0 0 #000
  74. }
  75. article > :not(#TOC) a:link {
  76. text-shadow:
  77. 0 0 #777,
  78. .03em 0 #fff,
  79. -.03em 0 #fff,
  80. 0 .03em #fff,
  81. 0 -.03em #fff,
  82. .06em 0 #fff,
  83. -.06em 0 #fff,
  84. .09em 0 #fff,
  85. -.09em 0 #fff,
  86. .12em 0 #fff,
  87. -.12em 0 #fff,
  88. .15em 0 #fff,
  89. -.15em 0 #fff
  90. }
  91. article > :not(#TOC) blockquote a:link {
  92. text-shadow:
  93. 0 0 #777,
  94. .03em 0 var(--GW-blockquote-background-color),
  95. -.03em 0 var(--GW-blockquote-background-color),
  96. 0 .03em var(--GW-blockquote-background-color),
  97. 0 -.03em var(--GW-blockquote-background-color),
  98. .06em 0 var(--GW-blockquote-background-color),
  99. -.06em 0 var(--GW-blockquote-background-color),
  100. .09em 0 var(--GW-blockquote-background-color),
  101. -.09em 0 var(--GW-blockquote-background-color),
  102. .12em 0 var(--GW-blockquote-background-color),
  103. -.12em 0 var(--GW-blockquote-background-color),
  104. .15em 0 var(--GW-blockquote-background-color),
  105. -.15em 0 var(--GW-blockquote-background-color)
  106. }
  107. #logo img {
  108. filter: none;
  109. }
  110. #mode-selector {
  111. opacity: 0.6;
  112. }
  113. #mode-selector:hover {
  114. background-color: #fff;
  115. }
  116. `;
  117. /****************/
  118. /* DEBUG OUTPUT */
  119. /****************/
  120. function GWLog (string) {
  121. if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
  122. console.log(string);
  123. }
  124. /***********/
  125. /* HELPERS */
  126. /***********/
  127. /* Run the given function immediately if the page is already loaded, or add
  128. a listener to run it as soon as the page loads.
  129. */
  130. function doWhenPageLoaded(f) {
  131. if (document.readyState == "complete")
  132. f();
  133. else
  134. window.addEventListener("load", f);
  135. }
  136. /* Adds an event listener to a button (or other clickable element), attaching
  137. it to both "click" and "keyup" events (for use with keyboard navigation).
  138. Optionally also attaches the listener to the 'mousedown' event, making the
  139. element activate on mouse down instead of mouse up.
  140. */
  141. Element.prototype.addActivateEvent = function(func, includeMouseDown) {
  142. let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
  143. if (includeMouseDown) this.addEventListener("mousedown", ael);
  144. this.addEventListener("click", ael);
  145. this.addEventListener("keyup", ael);
  146. }
  147. /* Adds a scroll event listener to the page.
  148. */
  149. function addScrollListener(fn, name) {
  150. let wrapper = (event) => {
  151. requestAnimationFrame(() => {
  152. fn(event);
  153. document.addEventListener("scroll", wrapper, { once: true, passive: true });
  154. });
  155. }
  156. document.addEventListener("scroll", wrapper, { once: true, passive: true });
  157. // Retain a reference to the scroll listener, if a name is provided.
  158. if (typeof name != "undefined")
  159. GW[name] = wrapper;
  160. }
  161. /************************/
  162. /* ACTIVE MEDIA QUERIES */
  163. /************************/
  164. /* This function provides two slightly different versions of its functionality,
  165. depending on how many arguments it gets.
  166. If one function is given (in addition to the media query and its name), it
  167. is called whenever the media query changes (in either direction).
  168. If two functions are given (in addition to the media query and its name),
  169. then the first function is called whenever the media query starts matching,
  170. and the second function is called whenever the media query stops matching.
  171. If you want to call a function for a change in one direction only, pass an
  172. empty closure (NOT null!) as one of the function arguments.
  173. There is also an optional fifth argument. This should be a function to be
  174. called when the active media query is canceled.
  175. */
  176. function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
  177. if (typeof GW.mediaQueryResponders == "undefined")
  178. GW.mediaQueryResponders = { };
  179. let mediaQueryResponder = (event, canceling = false) => {
  180. if (canceling) {
  181. GWLog(`Canceling media query “${name}”`);
  182. if (whenCanceledDo != null)
  183. whenCanceledDo(mediaQuery);
  184. } else {
  185. let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
  186. GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`);
  187. if (otherwiseDo == null || matches) ifMatchesOrAlwaysDo(mediaQuery);
  188. else otherwiseDo(mediaQuery);
  189. }
  190. };
  191. mediaQueryResponder();
  192. mediaQuery.addListener(mediaQueryResponder);
  193. GW.mediaQueryResponders[name] = mediaQueryResponder;
  194. }
  195. /* Deactivates and discards an active media query, after calling the function
  196. that was passed as the whenCanceledDo parameter when the media query was
  197. added.
  198. */
  199. function cancelDoWhenMatchMedia(name) {
  200. GW.mediaQueryResponders[name](null, true);
  201. for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
  202. mediaQuery.removeListener(GW.mediaQueryResponders[name]);
  203. GW.mediaQueryResponders[name] = null;
  204. }
  205. /******************/
  206. /* MODE SELECTION */
  207. /******************/
  208. function injectModeSelector() {
  209. GWLog("injectModeSelector");
  210. // Get saved mode setting (or default).
  211. let currentMode = localStorage.getItem("selected-mode") || 'auto';
  212. // Inject the mode selector widget and activate buttons.
  213. let modeSelector = addUIElement(
  214. "<div id='mode-selector'>" +
  215. String.prototype.concat.apply("", GW.modeOptions.map(modeOption => {
  216. let [ name, label, desc ] = modeOption;
  217. let selected = (name == currentMode ? ' selected' : '');
  218. let disabled = (name == currentMode ? ' disabled' : '');
  219. return `<button type='button' class='select-mode-${name}${selected}'${disabled} tabindex='-1' data-name='${name}' title='${desc}'>${label}</button>`})) +
  220. "</div>");
  221. modeSelector.querySelectorAll("button").forEach(button => {
  222. button.addActivateEvent(GW.modeSelectButtonClicked = (event) => {
  223. GWLog("GW.modeSelectButtonClicked");
  224. // Determine which setting was chosen (i.e., which button was clicked).
  225. let selectedMode = event.target.dataset.name;
  226. // Save the new setting.
  227. if (selectedMode == "auto") localStorage.removeItem("selected-mode");
  228. else localStorage.setItem("selected-mode", selectedMode);
  229. // Actually change the mode.
  230. setMode(selectedMode);
  231. });
  232. });
  233. document.querySelector("head").insertAdjacentHTML("beforeend", `<style id='mode-selector-styles'>
  234. #mode-selector {
  235. position: absolute;
  236. right: 3px;
  237. top: 4px;
  238. display: flex;
  239. background-color: #fff;
  240. padding: 0.125em 0.25em;
  241. border: 3px solid transparent;
  242. opacity: 0.3;
  243. transition:
  244. opacity 2s ease;
  245. }
  246. #mode-selector.hidden {
  247. opacity: 0;
  248. }
  249. #mode-selector:hover {
  250. transition: none;
  251. opacity: 1.0;
  252. border: 3px double #aaa;
  253. }
  254. #mode-selector button {
  255. -moz-appearance: none;
  256. appearance: none;
  257. border: none;
  258. background-color: transparent;
  259. padding: 0.5em;
  260. margin: 0;
  261. line-height: 1;
  262. font-family: Lucida Sans Unicode, Source Sans Pro, Helvetica, Trebuchet MS, sans-serif;
  263. font-size: 0.75rem;
  264. text-align: center;
  265. color: #777;
  266. position: relative;
  267. }
  268. #mode-selector button:hover,
  269. #mode-selector button.selected {
  270. box-shadow:
  271. 0 2px 0 6px #fff inset,
  272. 0 1px 0 6px currentColor inset;
  273. }
  274. #mode-selector button:not(:disabled):hover {
  275. color: #000;
  276. cursor: pointer;
  277. }
  278. #mode-selector button:not(:disabled):active {
  279. transform: translateY(2px);
  280. box-shadow:
  281. 0 0px 0 6px #fff inset,
  282. 0 -1px 0 6px currentColor inset;
  283. }
  284. #mode-selector button.active:not(:hover)::after {
  285. content: "";
  286. position: absolute;
  287. bottom: 0.25em;
  288. left: 0;
  289. right: 0;
  290. border-bottom: 1px dotted currentColor;
  291. width: calc(100% - 12px);
  292. margin: auto;
  293. }
  294. </style>`);
  295. document.querySelector("head").insertAdjacentHTML("beforeend", `<style id='mode-styles'></style>`);
  296. setMode(currentMode);
  297. // We pre-query the relevant elements, so we don’t have to run queryAll on
  298. // every firing of the scroll listener.
  299. GW.scrollState = {
  300. "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
  301. "unbrokenDownScrollDistance": 0,
  302. "unbrokenUpScrollDistance": 0,
  303. "modeSelector": document.querySelectorAll("#mode-selector"),
  304. };
  305. addScrollListener(updateModeSelectorVisibility, "updateModeSelectorVisibilityScrollListener");
  306. GW.scrollState.modeSelector[0].addEventListener("mouseover", () => { showModeSelector(); });
  307. doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "updateModeSelectorStateForSystemDarkMode", () => { updateModeSelectorState(); });
  308. }
  309. /* Show/hide the mode selector in response to scrolling.
  310. Called by the ‘updateModeSelectorVisibilityScrollListener’ scroll listener.
  311. */
  312. function updateModeSelectorVisibility(event) {
  313. GWLog("updateModeSelectorVisibility");
  314. let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
  315. GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
  316. (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
  317. 0;
  318. GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
  319. (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
  320. 0;
  321. GW.scrollState.lastScrollTop = newScrollTop;
  322. // Hide mode selector when scrolling a full page down.
  323. if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
  324. hideModeSelector();
  325. }
  326. // On desktop, show mode selector when scrolling to top of page,
  327. // or a full page up.
  328. // On mobile, show mode selector on ANY scroll up.
  329. if (GW.mediaQueries.mobileNarrow.matches) {
  330. if (GW.scrollState.unbrokenUpScrollDistance > 0 || GW.scrollState.lastScrollTop <= 0)
  331. showModeSelector();
  332. } else if ( GW.scrollState.unbrokenUpScrollDistance > window.innerHeight
  333. || GW.scrollState.lastScrollTop == 0) {
  334. showModeSelector();
  335. }
  336. }
  337. function hideModeSelector() {
  338. GWLog("hideModeSelector");
  339. GW.scrollState.modeSelector[0].classList.add("hidden");
  340. }
  341. function showModeSelector() {
  342. GWLog("showModeSelector");
  343. GW.scrollState.modeSelector[0].classList.remove("hidden");
  344. }
  345. /* Update the states of the mode selector buttons.
  346. */
  347. function updateModeSelectorState() {
  348. // Get saved mode setting (or default).
  349. let currentMode = localStorage.getItem("selected-mode") || 'auto';
  350. // Clear current buttons state.
  351. let modeSelector = document.querySelector("#mode-selector");
  352. modeSelector.childNodes.forEach(button => {
  353. button.classList.remove("active", "selected");
  354. button.disabled = false;
  355. });
  356. // Set the correct button to be selected.
  357. modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => {
  358. button.classList.add("selected");
  359. button.disabled = true;
  360. });
  361. // Ensure the right button (light or dark) has the “currently active”
  362. // indicator, if the current mode is ‘auto’.
  363. if (currentMode == "auto") {
  364. if (GW.mediaQueries.systemDarkModeActive.matches)
  365. modeSelector.querySelector(".select-mode-dark").classList.add("active");
  366. else
  367. modeSelector.querySelector(".select-mode-light").classList.add("active");
  368. }
  369. }
  370. /* Set specified color mode (auto, light, dark).
  371. */
  372. function setMode(modeOption) {
  373. GWLog("setMode");
  374. // Inject the appropriate styles.
  375. let modeStyles = document.querySelector("#mode-styles");
  376. if (modeOption == 'auto') {
  377. modeStyles.innerHTML = `@media (prefers-color-scheme:dark) {${GW.modeStyles}}`;
  378. } else if (modeOption == 'dark') {
  379. modeStyles.innerHTML = GW.modeStyles;
  380. } else {
  381. modeStyles.innerHTML = "";
  382. }
  383. // Update selector state.
  384. updateModeSelectorState();
  385. }
  386. /******************/
  387. /* INITIALIZATION */
  388. /******************/
  389. doWhenPageLoaded(() => {
  390. injectModeSelector();
  391. });