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 = ` +
+ + + + + +
`; + + return div; + } + + menu.innerHTML = ` +

Settings

+
+ + Appearance + +
+ + +
+
+ ${isTouchDevice ? "" : `
+ + Keyboard shortcuts + + ${defaultShortkeys.map((x) => renderShortkeyField(x).outerHTML).join("")} +
+ +
+
`} + `; + + 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 @@ - - + {{#if search_enabled}} - {{/if}} @@ -189,13 +183,13 @@