diff --git a/guide/src/guide/reading.md b/guide/src/guide/reading.md
index cab3a865..a0378196 100644
--- a/guide/src/guide/reading.md
+++ b/guide/src/guide/reading.md
@@ -21,7 +21,7 @@ In that situation, the menu icon (three horizontal bars) at the top-left of the
The **arrow buttons** at the bottom of the page can be used to navigate to the previous or the next chapter.
-The **left and right arrow keys** on the keyboard can be used to navigate to the previous or the next chapter.
+Pressing Ctrl+← and Ctrl+→ (⌘+← and ⌘+→ on Mac) on the keyboard can be used to navigate to the previous or the next chapter.
## Top menu bar
@@ -31,7 +31,7 @@ The icons displayed will depend on the settings of how the book was generated.
| Icon | Description |
|------|-------------|
| | Opens and closes the chapter listing sidebar. |
-| | Opens a picker to choose a different color theme. |
+| | Opens a settings menu for setting a different color theme or shortcut keys. |
| | Opens a search bar for searching within the book. |
| | Instructs the web browser to print the entire book. |
| | Opens a link to the website that hosts the source code of the book. |
diff --git a/src/theme/book.js b/src/theme/book.js
index f2516be7..08b7014f 100644
--- a/src/theme/book.js
+++ b/src/theme/book.js
@@ -285,9 +285,7 @@ function playground_text(playground, hidden = true) {
})();
(function themes() {
- var html = document.querySelector('html');
- var themeToggleButton = document.getElementById('theme-toggle');
- var themePopup = document.getElementById('theme-list');
+ var html = document.documentElement;
var themeColorMetaTag = document.querySelector('meta[name="theme-color"]');
var stylesheets = {
ayuHighlight: document.querySelector("[href$='ayu-highlight.css']"),
@@ -295,30 +293,11 @@ function playground_text(playground, hidden = true) {
highlight: document.querySelector("[href$='highlight.css']"),
};
- function showThemes() {
- themePopup.style.display = 'block';
- themeToggleButton.setAttribute('aria-expanded', true);
- themePopup.querySelector("button#" + get_theme()).focus();
- }
-
- function updateThemeSelected() {
- themePopup.querySelectorAll('.theme-selected').forEach(function (el) {
- el.classList.remove('theme-selected');
- });
- themePopup.querySelector("button#" + get_theme()).classList.add('theme-selected');
- }
-
- function hideThemes() {
- themePopup.style.display = 'none';
- themeToggleButton.setAttribute('aria-expanded', false);
- themeToggleButton.focus();
- }
-
function get_theme() {
var theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch (e) { }
if (theme === null || theme === undefined) {
- return default_theme;
+ return window.default_theme;
} else {
return theme;
}
@@ -363,81 +342,11 @@ function playground_text(playground, hidden = true) {
html.classList.remove(previousTheme);
html.classList.add(theme);
- updateThemeSelected();
}
- // Set theme
- var theme = get_theme();
+ set_theme(get_theme(), false);
- set_theme(theme, false);
-
- themeToggleButton.addEventListener('click', function () {
- if (themePopup.style.display === 'block') {
- hideThemes();
- } else {
- showThemes();
- }
- });
-
- themePopup.addEventListener('click', function (e) {
- var theme;
- if (e.target.className === "theme") {
- theme = e.target.id;
- } else if (e.target.parentElement.className === "theme") {
- theme = e.target.parentElement.id;
- } else {
- return;
- }
- set_theme(theme);
- });
-
- themePopup.addEventListener('focusout', function(e) {
- // e.relatedTarget is null in Safari and Firefox on macOS (see workaround below)
- if (!!e.relatedTarget && !themeToggleButton.contains(e.relatedTarget) && !themePopup.contains(e.relatedTarget)) {
- hideThemes();
- }
- });
-
- // Should not be needed, but it works around an issue on macOS & iOS: https://github.com/rust-lang/mdBook/issues/628
- document.addEventListener('click', function(e) {
- if (themePopup.style.display === 'block' && !themeToggleButton.contains(e.target) && !themePopup.contains(e.target)) {
- hideThemes();
- }
- });
-
- document.addEventListener('keydown', function (e) {
- if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; }
- if (!themePopup.contains(e.target)) { return; }
-
- switch (e.key) {
- case 'Escape':
- e.preventDefault();
- hideThemes();
- break;
- case 'ArrowUp':
- e.preventDefault();
- var li = document.activeElement.parentElement;
- if (li && li.previousElementSibling) {
- li.previousElementSibling.querySelector('button').focus();
- }
- break;
- case 'ArrowDown':
- e.preventDefault();
- var li = document.activeElement.parentElement;
- if (li && li.nextElementSibling) {
- li.nextElementSibling.querySelector('button').focus();
- }
- break;
- case 'Home':
- e.preventDefault();
- themePopup.querySelector('li:first-child button').focus();
- break;
- case 'End':
- e.preventDefault();
- themePopup.querySelector('li:last-child button').focus();
- break;
- }
- });
+ window.set_theme = set_theme;
})();
(function sidebar() {
@@ -560,30 +469,6 @@ function playground_text(playground, hidden = true) {
}
})();
-(function chapterNavigation() {
- document.addEventListener('keydown', function (e) {
- if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; }
- if (window.search && window.search.hasFocus()) { return; }
-
- switch (e.key) {
- case 'ArrowRight':
- e.preventDefault();
- var nextButton = document.querySelector('.nav-chapters.next');
- if (nextButton) {
- window.location.href = nextButton.href;
- }
- break;
- case 'ArrowLeft':
- e.preventDefault();
- var previousButton = document.querySelector('.nav-chapters.previous');
- if (previousButton) {
- window.location.href = previousButton.href;
- }
- break;
- }
- });
-})();
-
(function clipboard() {
var clipButtons = document.querySelectorAll('.clip-button');
@@ -686,3 +571,311 @@ function playground_text(playground, hidden = true) {
}, { passive: true });
})();
})();
+
+(function settings() {
+ const toggle = document.querySelector("#settings-toggle");
+ const menu = document.querySelector("#settings-menu");
+
+ const isMac = /^Mac/i.test(navigator.userAgentData?.platform ?? navigator.platform);
+ const isTouchDevice = window.matchMedia("(pointer: coarse)").matches;
+
+ const eventModifiers = Object.fromEntries(["ctrl", "alt", "shift", "meta"]
+ .map((k) => [k, `${k}Key`]));
+
+ const defaultComboModifier = isMac ? "Meta" : "Control";
+
+ const defaultShortkeys = [
+ {
+ id: "toc",
+ name: "Toggle Table of Contents",
+ combo: "t",
+ selector: "#sidebar-toggle",
+ },
+ {
+ id: "settings",
+ name: "Open settings",
+ combo: "/",
+ selector: "#settings-toggle",
+ },
+ {
+ id: "search",
+ name: "Search",
+ combo: "s",
+ selector: "#search-toggle",
+ },
+ {
+ id: "previous",
+ name: "Previous chapter",
+ combo: `${defaultComboModifier}+ArrowLeft`,
+ selector: ".nav-chapters.previous",
+ altSelectors: [".mobile-nav-chapters.previous"],
+ },
+ {
+ id: "next",
+ name: "Next chapter",
+ combo: `${defaultComboModifier}+ArrowRight`,
+ selector: ".nav-chapters.next",
+ altSelectors: [".mobile-nav-chapters.next"],
+ },
+ ];
+
+ function getCombo(storageKey) {
+ const shortkey = localStorage.getItem(`mdbook-shortkeys::${storageKey}`);
+ return shortkey ?? defaultShortkeys.find((x) => x.id === storageKey).combo;
+ }
+
+ function setCombo(storageKey, combo) {
+ localStorage.setItem(`mdbook-shortkeys::${storageKey}`, combo);
+ }
+
+ function checkIsTextInputMode() {
+ return document.activeElement.isContentEditable ||
+ ["INPUT", "TEXTAREA"].includes(document.activeElement.nodeName);
+ }
+
+ function eventToCombo(e) {
+ const normalized = new Map([
+ [" ", "Space"],
+ ["+", "Plus"],
+ ["Ctrl", "Control"],
+ ]);
+
+ const modifierKeys = Object.keys(eventModifiers)
+ .filter((k) => e[eventModifiers[k]])
+ .map((x) => x.charAt(0).toUpperCase() + x.slice(1));
+
+ if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return null;
+
+ return [...modifierKeys, e.key]
+ .map((x) => normalized.has(x) ? normalized.get(x) : x).join("+");
+ }
+
+ function eventMatchesCombo(e, combo) {
+ const eventCombo = eventToCombo(e);
+ return eventCombo && (eventCombo === combo);
+ }
+
+ function keyToPretty(key) {
+ const fmtMap = new Map([
+ ["ArrowRight", "→"],
+ ["ArrowLeft", "←"],
+ ["ArrowUp", "↑"],
+ ["ArrowDown", "↓"],
+ ["Plus", "+"],
+ ["Control", isMac ? "Control" : "Ctrl"],
+ ["Alt", isMac ? "⌥" : "Alt"],
+ ["Meta", isMac ? "⌘" : "Meta"],
+ ]);
+
+ return fmtMap.has(key) ? fmtMap.get(key) : key;
+ }
+
+ function comboToPretty(combo) {
+ return combo.split("+").map(keyToPretty).join("+");
+ }
+
+ function comboToPrettyHtml(combo) {
+ const html = (text) =>
+ Object.assign(document.createElement("span"), { textContent: text })
+ .innerHTML;
+
+ return combo.split("+").map((x) => `${html(keyToPretty(x))}`).join(
+ "+",
+ );
+ }
+
+ function renderShortkeyField(shortkey) {
+ const div = document.createElement("div");
+
+ const combo = getCombo(shortkey.id);
+ const touched = combo !== shortkey.combo;
+ const changeLabel = `Change shortcut key for ${shortkey.name}`;
+ const buttonAttrs = (label) =>
+ `aria-label="${label}" title="${label}" aria-controls="shortkey-${shortkey.id}"`;
+
+ div.classList.add("shortkey");
+ div.innerHTML = `
+
+
+
+ ${
+ comboToPrettyHtml(combo)
+ }
+
+
+
`;
+
+ return div;
+ }
+
+ menu.innerHTML = `
+ Settings
+
+ ${isTouchDevice ? "" : ``}
+ `;
+
+ function updateButtons() {
+ for (const shortkey of defaultShortkeys) {
+ for (
+ const button of document.querySelectorAll(
+ [shortkey.selector, ...(shortkey.altSelectors ?? [])].join(", "),
+ )
+ ) {
+ const combo = getCombo(shortkey.id);
+ button.setAttribute("aria-keyshortcuts", combo);
+ button.title = button.title.replace(
+ /(?: \(.+\))?$/,
+ ` (${comboToPretty(combo)})`,
+ );
+ }
+ }
+ }
+
+ updateButtons();
+
+ function toggleSettingsPopup(open = toggle.getAttribute("aria-expanded") !== "true") {
+ toggle.setAttribute("aria-expanded", String(open));
+ menu.hidden = !open;
+
+ if (open) {
+ menu.querySelector("input, button, textarea, select").focus();
+ }
+ }
+
+ toggle.addEventListener("click", () => toggleSettingsPopup());
+ menu.addEventListener("submit", (e) => e.preventDefault());
+ menu.addEventListener("change", updateButtons);
+
+ menu.querySelector("#theme").addEventListener("change", (e) => {
+ window.set_theme(e.target.value);
+ });
+
+ menu.addEventListener("keydown", (e) => {
+ if (!e.target.matches(".shortkey__control input")) return;
+ if (["Escape", "Tab", "Enter", " "].includes(e.key)) return;
+
+ const parent = e.target.closest(".shortkey__control");
+
+ e.preventDefault();
+ const combo = eventToCombo(e);
+
+ if (!combo) return;
+
+ const html = comboToPrettyHtml(combo);
+
+ setCombo(parent.dataset.shortkeyItem, combo);
+ e.target.value = combo;
+ e.currentTarget.dispatchEvent(new Event("change"));
+
+ parent.querySelector(".shortkey__display").innerHTML = html;
+ });
+
+ menu.addEventListener("click", (e) => {
+ if (e.target.closest(".shortkey__control .shortkey__change")) {
+ e.preventDefault();
+
+ const input = e.target.closest(".shortkey__control").querySelector(
+ "input",
+ );
+ input.disabled = false;
+ e.target.closest(".shortkey__control").classList.add(
+ "shortkey__control--touched",
+ );
+ input.focus();
+ } else if (e.target.closest("#shortkeys .shortkeys__reset-all")) {
+ e.preventDefault();
+
+ for (const el of e.currentTarget.querySelectorAll(".shortkey__control")) {
+ const shortkey = defaultShortkeys.find((x) =>
+ x.id === el.dataset.shortkeyItem
+ );
+
+ setCombo(el.dataset.shortkeyItem, shortkey.combo);
+ el.closest(".shortkey").replaceWith(renderShortkeyField(shortkey));
+ }
+ e.currentTarget.dispatchEvent(new Event("change"));
+ }
+ });
+
+ menu.addEventListener("focusout", (e) => {
+ if (e.target.matches(".shortkey__control input")) {
+ e.target.closest(".shortkey").replaceWith(
+ renderShortkeyField(
+ defaultShortkeys.find((x) =>
+ x.id === e.target.closest(".shortkey__control").dataset.shortkeyItem
+ ),
+ ),
+ );
+ }
+ });
+
+ document.addEventListener("keydown", (e) => {
+ if (checkIsTextInputMode()) return;
+
+ for (const shortkey of defaultShortkeys) {
+ if (eventMatchesCombo(e, getCombo(shortkey.id))) {
+ e.preventDefault();
+ const button = document.querySelector(shortkey.selector);
+ if (button) {
+ button.focus();
+ button.click();
+ }
+
+ return;
+ }
+ }
+ });
+
+ window.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ if (e.target.closest("#settings-menu")) {
+ toggleSettingsPopup(false);
+ toggle.focus();
+ } else if (!checkIsTextInputMode()) {
+ toggleSettingsPopup(false);
+ }
+ }
+ });
+
+ window.addEventListener("click", (e) => {
+ if (
+ e.isTrusted && !e.target.closest("#settings-menu") &&
+ !e.target.closest("#settings-toggle")
+ ) {
+ toggleSettingsPopup(false);
+ }
+ });
+})();
diff --git a/src/theme/css/chrome.css b/src/theme/css/chrome.css
index 29992f7b..dc2d8635 100644
--- a/src/theme/css/chrome.css
+++ b/src/theme/css/chrome.css
@@ -87,6 +87,7 @@ a > .hljs {
}
.right-buttons a {
text-decoration: none;
+ display: inline-block;
}
.left-buttons {
@@ -139,8 +140,6 @@ a > .hljs {
text-decoration: none;
position: fixed;
- top: 0;
- bottom: 0;
margin: 0;
max-width: 150px;
min-width: 90px;
@@ -151,6 +150,10 @@ a > .hljs {
flex-direction: column;
transition: color 0.5s, background-color 0.5s;
+
+ height: 10rem;
+ top: 50%;
+ transform: translateY(-50%);
}
.nav-chapters:hover {
@@ -498,48 +501,127 @@ ul#searchresults span.teaser em {
line-height: 1.9em;
}
-/* Theme Menu Popup */
+/* Settings menu */
-.theme-popup {
+#settings-menu {
position: absolute;
- left: 10px;
- top: var(--menu-bar-height);
+ inset-inline-start: 10px;
+ inset-block-start: var(--menu-bar-height);
z-index: 1000;
border-radius: 4px;
font-size: 0.7em;
color: var(--fg);
background: var(--theme-popup-bg);
border: 1px solid var(--theme-popup-border);
- margin: 0;
- padding: 0;
- list-style: none;
- display: none;
- /* Don't let the children's background extend past the rounded corners. */
- overflow: hidden;
+ width: 32rem;
}
-.theme-popup .default {
- color: var(--icons);
+
+#settings-menu * {
+ box-sizing: border-box;
}
-.theme-popup .theme {
+
+#settings-menu h2 {
+ margin: 1rem;
+}
+
+#settings-menu fieldset {
+ margin: 1rem;
+ border: 1px solid var(--theme-popup-border);
+ border-radius: 4px;
+}
+
+#settings-menu select {
+ display: block;
+ padding: 0.6rem;
width: 100%;
- border: 0;
- margin: 0;
- padding: 2px 20px;
- line-height: 25px;
- white-space: nowrap;
- text-align: left;
- cursor: pointer;
- color: inherit;
- background: inherit;
- font-size: inherit;
}
-.theme-popup .theme:hover {
+
+#settings-menu :is(select, input) {
+ border: 1px solid var(--searchbar-border-color);
+ border-radius: 3px;
+ background-color: var(--searchbar-bg);
+ transition: box-shadow 300ms ease-in-out;
+ color: var(--searchbar-fg);
+}
+
+.shortkey__control {
+ position: relative;
+ display: flex;
+}
+
+#settings-menu .shortkey__input {
+ flex: 0 1 100%;
+}
+
+#settings-menu .shortkey__control input {
+ height: 3.2rem;
+ color: #00000001;
+ width: 100%;
+}
+
+#settings-menu .shortkey__control input:disabled {
+ background: transparent;
+ border: none;
+}
+
+#settings-menu .shortkey__control input::selection {
+ color: transparent;
+ background: transparent;
+}
+
+.shortkey__control.shortkey__control--touched .shortkey__change {
+ display: none;
+}
+
+.shortkey__control .shortkey__display {
+ position: absolute;
+ inset-inline-start: 0.8rem;
+ inset-block-start: 50%;
+ transform: translateY(-50%);
+ font-size: 1.2rem;
+}
+
+.shortkey__control.shortkey__control--touched .shortkey__display span {
+ color: var(--searchbar-fg);
+}
+
+.shortkey__control button {
+ height: 3rem;
+ color: var(--fg);
+ background: none;
+ border: none;
+}
+
+.shortkey__control :is(button:hover, button i:hover) {
+ cursor: pointer;
+ color: var(--links);
+}
+
+#menu-bar .shortkey__control button i {
+ line-height: initial;
+}
+
+#settings-menu .shortkeys__reset-all {
+ cursor: pointer;
+ padding: 0.5rem 0.8rem;
+ font-size: 14px;
+ border-style: solid;
+ border-width: 1px;
+ border-radius: 4px;
+ border-color: var(--icons);
+ background-color: var(--theme-popup-bg);
+ transition: 100ms;
+ transition-property: color,border-color,background-color;
+ color: var(--icons);
+ margin-block-start: 1rem;
+}
+
+#settings-menu .shortkeys__reset-all:hover {
+ color: var(--fg);
+ border-color: var(--icons-hover);
background-color: var(--theme-hover);
}
-.theme-selected::before {
- display: inline-block;
- content: "✓";
- margin-left: -14px;
- width: 14px;
+.shortkeys > * {
+ margin-block: 0.3rem;
}
diff --git a/src/theme/index.hbs b/src/theme/index.hbs
index 6f3948c6..6e4a972a 100644
--- a/src/theme/index.hbs
+++ b/src/theme/index.hbs
@@ -120,18 +120,12 @@
-