|
|
@@ -1,451 +0,0 @@ |
|
|
|
// darkmode.js: Javascript library for controlling page appearance, toggling between regular white and 'dark mode' |
|
|
|
// Author: Said Achmiz |
|
|
|
// Date: 2020-03-20 |
|
|
|
// When: Time-stamp: "2020-03-23 09:36:20 gwern" |
|
|
|
// license: PD |
|
|
|
|
|
|
|
/* Experimental 'dark mode': Mac OS (Safari) lets users specify via an OS widget 'dark'/'light' to make everything appear */ |
|
|
|
/* bright-white or darker (eg for darker at evening to avoid straining eyes & disrupting circadian rhyhms); this then is */ |
|
|
|
/* exposed by Safari as a CSS variable which can be selected on. This is also currently supported by Firefox weakly as an */ |
|
|
|
/* about:config variable. Hypothetically, iOS in the future might use its camera or the clock to set 'dark mode' */ |
|
|
|
/* automatically. https://drafts.csswg.org/mediaqueries-5/#prefers-color-scheme */ |
|
|
|
/* https://webkit.org/blog/8718/new-webkit-features-in-safari-12-1/ */ |
|
|
|
/* https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme |
|
|
|
|
|
|
|
/* 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. */ |
|
|
|
|
|
|
|
/* For gwern.net, the default white-black */ |
|
|
|
/* scheme is 'light', and it can be flipped to a 'dark' scheme fairly easily by inverting it; the main visual problem is */ |
|
|
|
/* that blockquotes appear to become much harder to see & image-focus.js doesn't work well without additional tweaks. */ |
|
|
|
/* Known bugs: images get inverted on zoom or hover; invert filters are slow, leading to 'janky' slow rendering on scrolling. */ |
|
|
|
|
|
|
|
/****************/ |
|
|
|
/* MISC HELPERS */ |
|
|
|
/****************/ |
|
|
|
|
|
|
|
/* Given an HTML string, creates an element from that HTML, adds it to |
|
|
|
#ui-elements-container (creating the latter if it does not exist), and |
|
|
|
returns the created element. |
|
|
|
*/ |
|
|
|
function addUIElement(element_html) { |
|
|
|
var ui_elements_container = document.querySelector("#ui-elements-container"); |
|
|
|
if (!ui_elements_container) { |
|
|
|
ui_elements_container = document.createElement("div"); |
|
|
|
ui_elements_container.id = "ui-elements-container"; |
|
|
|
document.querySelector("body").appendChild(ui_elements_container); |
|
|
|
} |
|
|
|
|
|
|
|
ui_elements_container.insertAdjacentHTML("beforeend", element_html); |
|
|
|
return ui_elements_container.lastElementChild; |
|
|
|
} |
|
|
|
|
|
|
|
if (typeof window.GW == "undefined") |
|
|
|
window.GW = { }; |
|
|
|
GW.temp = { }; |
|
|
|
|
|
|
|
if (GW.mediaQueries == null) |
|
|
|
GW.mediaQueries = { }; |
|
|
|
GW.mediaQueries.mobileNarrow = matchMedia("(max-width: 520px)"); |
|
|
|
GW.mediaQueries.mobileWide = matchMedia("(max-width: 900px)"); |
|
|
|
GW.mediaQueries.mobileMax = matchMedia("(max-width: 960px)"); |
|
|
|
GW.mediaQueries.hover = matchMedia("only screen and (hover: hover) and (pointer: fine)"); |
|
|
|
GW.mediaQueries.systemDarkModeActive = matchMedia("(prefers-color-scheme: dark)"); |
|
|
|
|
|
|
|
GW.modeOptions = [ |
|
|
|
[ 'auto', 'Auto', 'Set light or dark mode automatically, according to system-wide setting' ], |
|
|
|
[ 'light', 'Light', 'Light mode at all times' ], |
|
|
|
[ 'dark', 'Dark', 'Dark mode at all times' ] |
|
|
|
]; |
|
|
|
GW.modeStyles = ` |
|
|
|
:root { |
|
|
|
--GW-blockquote-background-color: #ddd |
|
|
|
} |
|
|
|
body::before, |
|
|
|
body > * { |
|
|
|
filter: invert(90%) |
|
|
|
} |
|
|
|
body::before { |
|
|
|
content: ''; |
|
|
|
width: 100vw; |
|
|
|
height: 100%; |
|
|
|
position: fixed; |
|
|
|
left: 0; |
|
|
|
top: 0; |
|
|
|
background-color: #fff; |
|
|
|
z-index: -1 |
|
|
|
} |
|
|
|
img, |
|
|
|
video { |
|
|
|
filter: invert(100%); |
|
|
|
} |
|
|
|
#markdownBody, #mode-selector button { |
|
|
|
text-shadow: 0 0 0 #000 |
|
|
|
} |
|
|
|
article > :not(#TOC) a:link { |
|
|
|
text-shadow: |
|
|
|
0 0 #777, |
|
|
|
.03em 0 #fff, |
|
|
|
-.03em 0 #fff, |
|
|
|
0 .03em #fff, |
|
|
|
0 -.03em #fff, |
|
|
|
.06em 0 #fff, |
|
|
|
-.06em 0 #fff, |
|
|
|
.09em 0 #fff, |
|
|
|
-.09em 0 #fff, |
|
|
|
.12em 0 #fff, |
|
|
|
-.12em 0 #fff, |
|
|
|
.15em 0 #fff, |
|
|
|
-.15em 0 #fff |
|
|
|
} |
|
|
|
article > :not(#TOC) blockquote a:link { |
|
|
|
text-shadow: |
|
|
|
0 0 #777, |
|
|
|
.03em 0 var(--GW-blockquote-background-color), |
|
|
|
-.03em 0 var(--GW-blockquote-background-color), |
|
|
|
0 .03em var(--GW-blockquote-background-color), |
|
|
|
0 -.03em var(--GW-blockquote-background-color), |
|
|
|
.06em 0 var(--GW-blockquote-background-color), |
|
|
|
-.06em 0 var(--GW-blockquote-background-color), |
|
|
|
.09em 0 var(--GW-blockquote-background-color), |
|
|
|
-.09em 0 var(--GW-blockquote-background-color), |
|
|
|
.12em 0 var(--GW-blockquote-background-color), |
|
|
|
-.12em 0 var(--GW-blockquote-background-color), |
|
|
|
.15em 0 var(--GW-blockquote-background-color), |
|
|
|
-.15em 0 var(--GW-blockquote-background-color) |
|
|
|
} |
|
|
|
#logo img { |
|
|
|
filter: none; |
|
|
|
} |
|
|
|
#mode-selector { |
|
|
|
opacity: 0.6; |
|
|
|
} |
|
|
|
#mode-selector:hover { |
|
|
|
background-color: #fff; |
|
|
|
} |
|
|
|
`; |
|
|
|
|
|
|
|
/****************/ |
|
|
|
/* DEBUG OUTPUT */ |
|
|
|
/****************/ |
|
|
|
|
|
|
|
function GWLog (string) { |
|
|
|
if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true") |
|
|
|
console.log(string); |
|
|
|
} |
|
|
|
|
|
|
|
/***********/ |
|
|
|
/* HELPERS */ |
|
|
|
/***********/ |
|
|
|
|
|
|
|
/* Run the given function immediately if the page is already loaded, or add |
|
|
|
a listener to run it as soon as the page loads. |
|
|
|
*/ |
|
|
|
function doWhenPageLoaded(f) { |
|
|
|
if (document.readyState == "complete") |
|
|
|
f(); |
|
|
|
else |
|
|
|
window.addEventListener("load", f); |
|
|
|
} |
|
|
|
|
|
|
|
/* Adds an event listener to a button (or other clickable element), attaching |
|
|
|
it to both "click" and "keyup" events (for use with keyboard navigation). |
|
|
|
Optionally also attaches the listener to the 'mousedown' event, making the |
|
|
|
element activate on mouse down instead of mouse up. |
|
|
|
*/ |
|
|
|
Element.prototype.addActivateEvent = function(func, includeMouseDown) { |
|
|
|
let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) }; |
|
|
|
if (includeMouseDown) this.addEventListener("mousedown", ael); |
|
|
|
this.addEventListener("click", ael); |
|
|
|
this.addEventListener("keyup", ael); |
|
|
|
} |
|
|
|
|
|
|
|
/* Adds a scroll event listener to the page. |
|
|
|
*/ |
|
|
|
function addScrollListener(fn, name) { |
|
|
|
let wrapper = (event) => { |
|
|
|
requestAnimationFrame(() => { |
|
|
|
fn(event); |
|
|
|
document.addEventListener("scroll", wrapper, { once: true, passive: true }); |
|
|
|
}); |
|
|
|
} |
|
|
|
document.addEventListener("scroll", wrapper, { once: true, passive: true }); |
|
|
|
|
|
|
|
// Retain a reference to the scroll listener, if a name is provided. |
|
|
|
if (typeof name != "undefined") |
|
|
|
GW[name] = wrapper; |
|
|
|
} |
|
|
|
|
|
|
|
/************************/ |
|
|
|
/* ACTIVE MEDIA QUERIES */ |
|
|
|
/************************/ |
|
|
|
|
|
|
|
/* This function provides two slightly different versions of its functionality, |
|
|
|
depending on how many arguments it gets. |
|
|
|
|
|
|
|
If one function is given (in addition to the media query and its name), it |
|
|
|
is called whenever the media query changes (in either direction). |
|
|
|
|
|
|
|
If two functions are given (in addition to the media query and its name), |
|
|
|
then the first function is called whenever the media query starts matching, |
|
|
|
and the second function is called whenever the media query stops matching. |
|
|
|
|
|
|
|
If you want to call a function for a change in one direction only, pass an |
|
|
|
empty closure (NOT null!) as one of the function arguments. |
|
|
|
|
|
|
|
There is also an optional fifth argument. This should be a function to be |
|
|
|
called when the active media query is canceled. |
|
|
|
*/ |
|
|
|
function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) { |
|
|
|
if (typeof GW.mediaQueryResponders == "undefined") |
|
|
|
GW.mediaQueryResponders = { }; |
|
|
|
|
|
|
|
let mediaQueryResponder = (event, canceling = false) => { |
|
|
|
if (canceling) { |
|
|
|
GWLog(`Canceling media query “${name}â€`); |
|
|
|
|
|
|
|
if (whenCanceledDo != null) |
|
|
|
whenCanceledDo(mediaQuery); |
|
|
|
} else { |
|
|
|
let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches; |
|
|
|
|
|
|
|
GWLog(`Media query “${name}†triggered (matches: ${matches ? "YES" : "NO"})`); |
|
|
|
|
|
|
|
if (otherwiseDo == null || matches) ifMatchesOrAlwaysDo(mediaQuery); |
|
|
|
else otherwiseDo(mediaQuery); |
|
|
|
} |
|
|
|
}; |
|
|
|
mediaQueryResponder(); |
|
|
|
mediaQuery.addListener(mediaQueryResponder); |
|
|
|
|
|
|
|
GW.mediaQueryResponders[name] = mediaQueryResponder; |
|
|
|
} |
|
|
|
|
|
|
|
/* Deactivates and discards an active media query, after calling the function |
|
|
|
that was passed as the whenCanceledDo parameter when the media query was |
|
|
|
added. |
|
|
|
*/ |
|
|
|
function cancelDoWhenMatchMedia(name) { |
|
|
|
GW.mediaQueryResponders[name](null, true); |
|
|
|
|
|
|
|
for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries)) |
|
|
|
mediaQuery.removeListener(GW.mediaQueryResponders[name]); |
|
|
|
|
|
|
|
GW.mediaQueryResponders[name] = null; |
|
|
|
} |
|
|
|
|
|
|
|
/******************/ |
|
|
|
/* MODE SELECTION */ |
|
|
|
/******************/ |
|
|
|
|
|
|
|
function injectModeSelector() { |
|
|
|
GWLog("injectModeSelector"); |
|
|
|
|
|
|
|
// Get saved mode setting (or default). |
|
|
|
let currentMode = localStorage.getItem("selected-mode") || 'auto'; |
|
|
|
|
|
|
|
// Inject the mode selector widget and activate buttons. |
|
|
|
let modeSelector = addUIElement( |
|
|
|
"<div id='mode-selector'>" + |
|
|
|
String.prototype.concat.apply("", GW.modeOptions.map(modeOption => { |
|
|
|
let [ name, label, desc ] = modeOption; |
|
|
|
let selected = (name == currentMode ? ' selected' : ''); |
|
|
|
let disabled = (name == currentMode ? ' disabled' : ''); |
|
|
|
return `<button type='button' class='select-mode-${name}${selected}'${disabled} tabindex='-1' data-name='${name}' title='${desc}'>${label}</button>`})) + |
|
|
|
"</div>"); |
|
|
|
|
|
|
|
modeSelector.querySelectorAll("button").forEach(button => { |
|
|
|
button.addActivateEvent(GW.modeSelectButtonClicked = (event) => { |
|
|
|
GWLog("GW.modeSelectButtonClicked"); |
|
|
|
|
|
|
|
// Determine which setting was chosen (i.e., which button was clicked). |
|
|
|
let selectedMode = event.target.dataset.name; |
|
|
|
|
|
|
|
// Save the new setting. |
|
|
|
if (selectedMode == "auto") localStorage.removeItem("selected-mode"); |
|
|
|
else localStorage.setItem("selected-mode", selectedMode); |
|
|
|
|
|
|
|
// Actually change the mode. |
|
|
|
setMode(selectedMode); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
document.querySelector("head").insertAdjacentHTML("beforeend", `<style id='mode-selector-styles'> |
|
|
|
#mode-selector { |
|
|
|
position: absolute; |
|
|
|
right: 3px; |
|
|
|
top: 4px; |
|
|
|
display: flex; |
|
|
|
background-color: #fff; |
|
|
|
padding: 0.125em 0.25em; |
|
|
|
border: 3px solid transparent; |
|
|
|
opacity: 0.3; |
|
|
|
transition: |
|
|
|
opacity 2s ease; |
|
|
|
} |
|
|
|
#mode-selector.hidden { |
|
|
|
opacity: 0; |
|
|
|
} |
|
|
|
#mode-selector:hover { |
|
|
|
transition: none; |
|
|
|
opacity: 1.0; |
|
|
|
border: 3px double #aaa; |
|
|
|
} |
|
|
|
#mode-selector button { |
|
|
|
-moz-appearance: none; |
|
|
|
appearance: none; |
|
|
|
border: none; |
|
|
|
background-color: transparent; |
|
|
|
padding: 0.5em; |
|
|
|
margin: 0; |
|
|
|
line-height: 1; |
|
|
|
font-family: Lucida Sans Unicode, Source Sans Pro, Helvetica, Trebuchet MS, sans-serif; |
|
|
|
font-size: 0.75rem; |
|
|
|
text-align: center; |
|
|
|
color: #777; |
|
|
|
position: relative; |
|
|
|
} |
|
|
|
#mode-selector button:hover, |
|
|
|
#mode-selector button.selected { |
|
|
|
box-shadow: |
|
|
|
0 2px 0 6px #fff inset, |
|
|
|
0 1px 0 6px currentColor inset; |
|
|
|
} |
|
|
|
#mode-selector button:not(:disabled):hover { |
|
|
|
color: #000; |
|
|
|
cursor: pointer; |
|
|
|
} |
|
|
|
#mode-selector button:not(:disabled):active { |
|
|
|
transform: translateY(2px); |
|
|
|
box-shadow: |
|
|
|
0 0px 0 6px #fff inset, |
|
|
|
0 -1px 0 6px currentColor inset; |
|
|
|
} |
|
|
|
#mode-selector button.active:not(:hover)::after { |
|
|
|
content: ""; |
|
|
|
position: absolute; |
|
|
|
bottom: 0.25em; |
|
|
|
left: 0; |
|
|
|
right: 0; |
|
|
|
border-bottom: 1px dotted currentColor; |
|
|
|
width: calc(100% - 12px); |
|
|
|
margin: auto; |
|
|
|
} |
|
|
|
</style>`); |
|
|
|
|
|
|
|
document.querySelector("head").insertAdjacentHTML("beforeend", `<style id='mode-styles'></style>`); |
|
|
|
|
|
|
|
setMode(currentMode); |
|
|
|
|
|
|
|
// We pre-query the relevant elements, so we don’t have to run queryAll on |
|
|
|
// every firing of the scroll listener. |
|
|
|
GW.scrollState = { |
|
|
|
"lastScrollTop": window.pageYOffset || document.documentElement.scrollTop, |
|
|
|
"unbrokenDownScrollDistance": 0, |
|
|
|
"unbrokenUpScrollDistance": 0, |
|
|
|
"modeSelector": document.querySelectorAll("#mode-selector"), |
|
|
|
}; |
|
|
|
addScrollListener(updateModeSelectorVisibility, "updateModeSelectorVisibilityScrollListener"); |
|
|
|
GW.scrollState.modeSelector[0].addEventListener("mouseover", () => { showModeSelector(); }); |
|
|
|
doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "updateModeSelectorStateForSystemDarkMode", () => { updateModeSelectorState(); }); |
|
|
|
} |
|
|
|
|
|
|
|
/* Show/hide the mode selector in response to scrolling. |
|
|
|
|
|
|
|
Called by the ‘updateModeSelectorVisibilityScrollListener’ scroll listener. |
|
|
|
*/ |
|
|
|
function updateModeSelectorVisibility(event) { |
|
|
|
GWLog("updateModeSelectorVisibility"); |
|
|
|
|
|
|
|
let newScrollTop = window.pageYOffset || document.documentElement.scrollTop; |
|
|
|
GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? |
|
|
|
(GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : |
|
|
|
0; |
|
|
|
GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ? |
|
|
|
(GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) : |
|
|
|
0; |
|
|
|
GW.scrollState.lastScrollTop = newScrollTop; |
|
|
|
|
|
|
|
// Hide mode selector when scrolling a full page down. |
|
|
|
if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) { |
|
|
|
hideModeSelector(); |
|
|
|
} |
|
|
|
|
|
|
|
// On desktop, show mode selector when scrolling to top of page, |
|
|
|
// or a full page up. |
|
|
|
// On mobile, show mode selector on ANY scroll up. |
|
|
|
if (GW.mediaQueries.mobileNarrow.matches) { |
|
|
|
if (GW.scrollState.unbrokenUpScrollDistance > 0 || GW.scrollState.lastScrollTop <= 0) |
|
|
|
showModeSelector(); |
|
|
|
} else if ( GW.scrollState.unbrokenUpScrollDistance > window.innerHeight |
|
|
|
|| GW.scrollState.lastScrollTop == 0) { |
|
|
|
showModeSelector(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function hideModeSelector() { |
|
|
|
GWLog("hideModeSelector"); |
|
|
|
|
|
|
|
GW.scrollState.modeSelector[0].classList.add("hidden"); |
|
|
|
} |
|
|
|
|
|
|
|
function showModeSelector() { |
|
|
|
GWLog("showModeSelector"); |
|
|
|
|
|
|
|
GW.scrollState.modeSelector[0].classList.remove("hidden"); |
|
|
|
} |
|
|
|
|
|
|
|
/* Update the states of the mode selector buttons. |
|
|
|
*/ |
|
|
|
function updateModeSelectorState() { |
|
|
|
// Get saved mode setting (or default). |
|
|
|
let currentMode = localStorage.getItem("selected-mode") || 'auto'; |
|
|
|
|
|
|
|
// Clear current buttons state. |
|
|
|
let modeSelector = document.querySelector("#mode-selector"); |
|
|
|
modeSelector.childNodes.forEach(button => { |
|
|
|
button.classList.remove("active", "selected"); |
|
|
|
button.disabled = false; |
|
|
|
}); |
|
|
|
|
|
|
|
// Set the correct button to be selected. |
|
|
|
modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => { |
|
|
|
button.classList.add("selected"); |
|
|
|
button.disabled = true; |
|
|
|
}); |
|
|
|
|
|
|
|
// Ensure the right button (light or dark) has the “currently active†|
|
|
|
// indicator, if the current mode is ‘auto’. |
|
|
|
if (currentMode == "auto") { |
|
|
|
if (GW.mediaQueries.systemDarkModeActive.matches) |
|
|
|
modeSelector.querySelector(".select-mode-dark").classList.add("active"); |
|
|
|
else |
|
|
|
modeSelector.querySelector(".select-mode-light").classList.add("active"); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/* Set specified color mode (auto, light, dark). |
|
|
|
*/ |
|
|
|
function setMode(modeOption) { |
|
|
|
GWLog("setMode"); |
|
|
|
|
|
|
|
// Inject the appropriate styles. |
|
|
|
let modeStyles = document.querySelector("#mode-styles"); |
|
|
|
if (modeOption == 'auto') { |
|
|
|
modeStyles.innerHTML = `@media (prefers-color-scheme:dark) {${GW.modeStyles}}`; |
|
|
|
} else if (modeOption == 'dark') { |
|
|
|
modeStyles.innerHTML = GW.modeStyles; |
|
|
|
} else { |
|
|
|
modeStyles.innerHTML = ""; |
|
|
|
} |
|
|
|
|
|
|
|
// Update selector state. |
|
|
|
updateModeSelectorState(); |
|
|
|
} |
|
|
|
|
|
|
|
/******************/ |
|
|
|
/* INITIALIZATION */ |
|
|
|
/******************/ |
|
|
|
|
|
|
|
doWhenPageLoaded(() => { |
|
|
|
injectModeSelector(); |
|
|
|
}); |