Add shortkey settings

This commit is contained in:
lionel-rowe 2023-04-12 21:28:46 +08:00
parent b5ffc734a2
commit 3f0ee6d925
5 changed files with 450 additions and 186 deletions

View File

@ -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 <kbd>Ctrl</kbd>+<kbd></kbd> and <kbd>Ctrl</kbd>+<kbd></kbd> (<kbd></kbd>+<kbd></kbd> and <kbd></kbd>+<kbd></kbd> 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 |
|------|-------------|
| <i class="fa fa-bars"></i> | Opens and closes the chapter listing sidebar. |
| <i class="fa fa-paint-brush"></i> | Opens a picker to choose a different color theme. |
| <i class="fa fa-cog"></i> | Opens a settings menu for setting a different color theme or shortcut keys. |
| <i class="fa fa-search"></i> | Opens a search bar for searching within the book. |
| <i class="fa fa-print"></i> | Instructs the web browser to print the entire book. |
| <i class="fa fa-github"></i> | Opens a link to the website that hosts the source code of the book. |

View File

@ -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) => `<kbd>${html(keyToPretty(x))}</kbd>`).join(
"<span>+</span>",
);
}
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 = `<label for="shortkey-${shortkey.id}">${shortkey.name}</label>
<div class="shortkey__control${
touched ? " shortkey__control--touched" : ""
}" data-shortkey-item="${shortkey.id}">
<span class="shortkey__input">
<input${
touched ? "" : " disabled"
} id="shortkey-${shortkey.id}" autocomplete="off" value="Control+ArrowLeft">
<span aria-hidden="true" class="shortkey__display">${
comboToPrettyHtml(combo)
}</span>
</span>
<button class="shortkey__change" type="button" ${buttonAttrs(changeLabel)}>
<i class="fa fa-pencil" aria-hidden="true"></i>
</button>
</div>`;
return div;
}
menu.innerHTML = `
<h2>Settings</h2>
<fieldset>
<legend>
<span>Appearance</span>
</legend>
<div>
<label for="theme">Theme</label>
<select id="theme">
${["light", "rust", "coal", "navy", "ayu"].map((theme) =>
`<option${(localStorage.getItem("mdbook-theme") ?? window.default_theme) ===
theme
? " selected"
: ""
} value="${theme}">${theme.charAt(0).toUpperCase() + theme.slice(1)}</option>`
).join("")
}
</select>
</div>
</fieldset>
${isTouchDevice ? "" : `<fieldset id="shortkeys" class="shortkeys">
<legend>
<span>Keyboard shortcuts</span>
</legend>
${defaultShortkeys.map((x) => renderShortkeyField(x).outerHTML).join("")}
<div>
<button class="shortkeys__reset-all" type="reset">
Reset all keyboard shortcuts
</button>
</div>
</fieldset>`}
`;
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);
}
});
})();

View File

@ -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;
}

View File

@ -120,18 +120,12 @@
<button id="sidebar-toggle" class="icon-button" type="button" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
<i class="fa fa-bars"></i>
</button>
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
<i class="fa fa-paint-brush"></i>
<button id="settings-toggle" class="icon-button" type="button" title="Settings" aria-label="Settings" aria-haspopup="true" aria-expanded="false" aria-controls="settings-menu">
<i class="fa fa-cog"></i>
</button>
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
<form hidden id="settings-menu" aria-label="Settings" role="menu"></form>
{{#if search_enabled}}
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
<button id="search-toggle" class="icon-button" type="button" title="Search" aria-label="Toggle Searchbar" aria-expanded="false" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
{{/if}}
@ -189,13 +183,13 @@
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
{{#previous}}
<a rel="prev" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<a rel="prev" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter">
<i class="fa fa-angle-left"></i>
</a>
{{/previous}}
{{#next}}
<a rel="next" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<a rel="next" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter">
<i class="fa fa-angle-right"></i>
</a>
{{/next}}
@ -207,13 +201,13 @@
<nav class="nav-wide-wrapper" aria-label="Page navigation">
{{#previous}}
<a rel="prev" href="{{ path_to_root }}{{link}}" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<a rel="prev" href="{{ path_to_root }}{{link}}" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter">
<i class="fa fa-angle-left"></i>
</a>
{{/previous}}
{{#next}}
<a rel="next" href="{{ path_to_root }}{{link}}" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<a rel="next" href="{{ path_to_root }}{{link}}" class="nav-chapters next" title="Next chapter" aria-label="Next chapter">
<i class="fa fa-angle-right"></i>
</a>
{{/next}}

View File

@ -48,7 +48,6 @@ window.search = window.search || {};
URL_MARK_PARAM = 'highlight',
teaser_count = 0,
SEARCH_HOTKEY_KEYCODE = 83,
ESCAPE_KEYCODE = 27,
DOWN_KEYCODE = 40,
UP_KEYCODE = 38,
@ -325,14 +324,10 @@ window.search = window.search || {};
(searchbar.value.trim() !== "") ? "push" : "replace");
if (hasFocus()) {
unfocusSearchbar();
searchicon.focus();
}
showSearch(false);
marker.unmark();
} else if (!hasFocus() && e.keyCode === SEARCH_HOTKEY_KEYCODE) {
e.preventDefault();
showSearch(true);
window.scrollTo(0, 0);
searchbar.select();
} else if (hasFocus() && e.keyCode === DOWN_KEYCODE) {
e.preventDefault();
unfocusSearchbar();