mirror of
https://github.com/docsifyjs/docsify.git
synced 2025-12-08 19:55:52 +00:00
parent
b8a5feaafd
commit
cf61192f9a
@ -288,6 +288,54 @@ window.$docsify = {
|
||||
};
|
||||
```
|
||||
|
||||
## keyBindings
|
||||
|
||||
- Type: `Boolean|Object`
|
||||
- Default: `Object`
|
||||
- <kbd>\\</kbd> Toggle the sidebar menu
|
||||
- <kbd>/</kbd> Focus on [search](plugins#full-text-search) field. Also supports <kbd>alt</kbd> / <kbd>ctrl</kbd> + <kbd>k</kbd>.
|
||||
|
||||
Binds key combination(s) to a custom callback function.
|
||||
|
||||
Key `bindings` are defined as case insensitive string values separated by `+`. Modifier key values include `alt`, `ctrl`, `meta`, and `shift`. Non-modifier key values should match the keyboard event's [key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) or [code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) value.
|
||||
|
||||
The `callback` function receive a [keydown event](https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event) as an argument.
|
||||
|
||||
!> Let site visitors know your custom key bindings are available! If a binding is associated with a DOM element, consider inserting a `<kbd>` element as a visual cue (e.g., <kbd>alt</kbd> + <kbd>a</kbd>) or adding [title](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title) and [aria-keyshortcuts](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts) attributes for hover/focus hints.
|
||||
|
||||
```js
|
||||
window.$docsify = {
|
||||
keyBindings: {
|
||||
// Custom key binding
|
||||
myCustomBinding: {
|
||||
bindings: ['alt+a', 'shift+a'],
|
||||
callback(event) {
|
||||
alert('Hello, World!');
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Key bindings can be disabled entirely or individually by setting the binding configuration to `false`.
|
||||
|
||||
```js
|
||||
window.$docsify = {
|
||||
// Disable all key bindings
|
||||
keyBindings: false,
|
||||
};
|
||||
```
|
||||
|
||||
```js
|
||||
window.$docsify = {
|
||||
keyBindings: {
|
||||
// Disable individual key bindings
|
||||
focusSearch: false,
|
||||
toggleSidebar: false,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## loadNavbar
|
||||
|
||||
- Type: `Boolean|String`
|
||||
|
||||
19425
package-lock.json
generated
19425
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -79,7 +79,7 @@
|
||||
"@babel/eslint-parser": "^7.16.5",
|
||||
"@babel/preset-env": "^7.11.5",
|
||||
"@eslint/js": "^8.43.0",
|
||||
"@playwright/test": "^1.18.1",
|
||||
"@playwright/test": "^1.39.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
"@rollup/plugin-node-resolve": "^15.1.0",
|
||||
"@rollup/plugin-replace": "^5.0.2",
|
||||
|
||||
@ -66,6 +66,28 @@ export default function (vm) {
|
||||
: window.$docsify
|
||||
);
|
||||
|
||||
// Merge default and user-specified key bindings
|
||||
if (config.keyBindings !== false) {
|
||||
config.keyBindings = Object.assign(
|
||||
// Default
|
||||
{
|
||||
toggleSidebar: {
|
||||
bindings: ['\\'],
|
||||
callback(e) {
|
||||
const toggleElm = document.querySelector('.sidebar-toggle');
|
||||
|
||||
if (toggleElm) {
|
||||
toggleElm.click();
|
||||
toggleElm.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
// User-specified
|
||||
config.keyBindings
|
||||
);
|
||||
}
|
||||
|
||||
const script =
|
||||
currentScript ||
|
||||
Array.from(document.getElementsByTagName('script')).filter(n =>
|
||||
|
||||
@ -40,6 +40,9 @@ export function Events(Base) {
|
||||
}
|
||||
|
||||
initEvent() {
|
||||
const { coverpage, keyBindings } = this.config;
|
||||
const modifierKeys = ['alt', 'ctrl', 'meta', 'shift'];
|
||||
|
||||
// Bind skip link
|
||||
this.#skipLink('#skip-to-content');
|
||||
|
||||
@ -48,11 +51,84 @@ export function Events(Base) {
|
||||
this.#collapse('.sidebar', this.router);
|
||||
|
||||
// Bind sticky effect
|
||||
if (this.config.coverpage) {
|
||||
if (coverpage) {
|
||||
!isMobile && on('scroll', this.__sticky);
|
||||
} else {
|
||||
body.classList.add('sticky');
|
||||
}
|
||||
// Bind keyboard shortcuts
|
||||
if (keyBindings && keyBindings.constructor === Object) {
|
||||
// Prepare key binding configurations
|
||||
Object.values(keyBindings || []).forEach(bindingConfig => {
|
||||
const { bindings } = bindingConfig;
|
||||
|
||||
if (!bindings) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert bindings to arrays
|
||||
// Ex: 'alt+t' => ['alt+t']
|
||||
bindingConfig.bindings = Array.isArray(bindings)
|
||||
? bindings
|
||||
: [bindings];
|
||||
|
||||
// Convert key sequences to sorted arrays (modifiers first)
|
||||
// Ex: ['alt+t', 't+ctrl'] => [['alt', 't'], ['ctrl', 't']]
|
||||
bindingConfig.bindings = bindingConfig.bindings.map(keys => {
|
||||
const sortedKeys = [[], []]; // Modifier keys, non-modifier keys
|
||||
|
||||
if (typeof keys === 'string') {
|
||||
keys = keys.split('+');
|
||||
}
|
||||
|
||||
keys.forEach(key => {
|
||||
const isModifierKey = modifierKeys.includes(key);
|
||||
const targetArray = sortedKeys[isModifierKey ? 0 : 1];
|
||||
const newKeyValue = key.trim().toLowerCase();
|
||||
|
||||
targetArray.push(newKeyValue);
|
||||
});
|
||||
|
||||
sortedKeys.forEach(arr => arr.sort());
|
||||
|
||||
return sortedKeys.flat();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle keyboard events
|
||||
on('keydown', e => {
|
||||
const isTextEntry = document.activeElement.matches(
|
||||
'input, select, textarea'
|
||||
);
|
||||
|
||||
if (isTextEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bindingConfigs = Object.values(keyBindings || []);
|
||||
const matchingConfigs = bindingConfigs.filter(
|
||||
({ bindings }) =>
|
||||
bindings &&
|
||||
// bindings: [['alt', 't'], ['ctrl', 't']]
|
||||
bindings.some(keys =>
|
||||
// keys: ['alt', 't']
|
||||
keys.every(
|
||||
// k: 'alt'
|
||||
k =>
|
||||
(modifierKeys.includes(k) && e[k + 'Key']) ||
|
||||
e.key === k || // Ex: " ", "a"
|
||||
e.code.toLowerCase() === k || // "space"
|
||||
e.code.toLowerCase() === `key${k}` // "keya"
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
matchingConfigs.forEach(({ callback }) => {
|
||||
e.preventDefault();
|
||||
callback(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @readonly */
|
||||
|
||||
@ -37,7 +37,7 @@ export function main(config) {
|
||||
const name = config.name ? config.name : '';
|
||||
|
||||
const aside = /* html */ `
|
||||
<button class="sidebar-toggle" aria-label="Toggle primary navigation" aria-controls="__sidebar">
|
||||
<button class="sidebar-toggle" title="Press \\ to toggle" aria-label="Toggle primary navigation" aria-keyshortcuts="\\" aria-controls="__sidebar">
|
||||
<div class="sidebar-toggle-button" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
@ -77,6 +77,17 @@ function style() {
|
||||
transform: scale(.5);
|
||||
}
|
||||
|
||||
.search kbd {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search input:focus ~ kbd,
|
||||
.search input:not(:empty) ~ kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search h2 {
|
||||
font-size: 17px;
|
||||
margin: 10px 0;
|
||||
@ -118,7 +129,7 @@ function style() {
|
||||
function tpl(defaultValue = '') {
|
||||
const html = /* html */ `
|
||||
<div class="input-wrap">
|
||||
<input type="search" value="${defaultValue}" />
|
||||
<input type="search" value="${defaultValue}" aria-keyshortcuts="/ control+k meta+k" />
|
||||
<div class="clear-button">
|
||||
<svg width="26" height="24">
|
||||
<circle cx="12" cy="12" r="11" fill="#ccc" />
|
||||
@ -126,6 +137,7 @@ function tpl(defaultValue = '') {
|
||||
<path stroke="white" stroke-width="2"d="M8.25,15.75,15.75,8.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<kbd title="Press / to search">/</kbd>
|
||||
</div>
|
||||
<div class="results-status" aria-live="polite"></div>
|
||||
<div class="results-panel"></div>
|
||||
|
||||
@ -14,6 +14,7 @@ const CONFIG = {
|
||||
hideOtherSidebarContent: false,
|
||||
namespace: undefined,
|
||||
pathNamespaces: undefined,
|
||||
keyBindings: ['/', 'meta+k', 'ctrl+k'],
|
||||
};
|
||||
|
||||
const install = function (hook, vm) {
|
||||
@ -32,10 +33,31 @@ const install = function (hook, vm) {
|
||||
opts.hideOtherSidebarContent || CONFIG.hideOtherSidebarContent;
|
||||
CONFIG.namespace = opts.namespace || CONFIG.namespace;
|
||||
CONFIG.pathNamespaces = opts.pathNamespaces || CONFIG.pathNamespaces;
|
||||
CONFIG.keyBindings = opts.keyBindings || CONFIG.keyBindings;
|
||||
}
|
||||
|
||||
const isAuto = CONFIG.paths === 'auto';
|
||||
|
||||
hook.init(() => {
|
||||
const { keyBindings } = vm.config;
|
||||
|
||||
// Add key bindings
|
||||
if (keyBindings.constructor === Object) {
|
||||
keyBindings.focusSearch = {
|
||||
bindings: CONFIG.keyBindings,
|
||||
callback(e) {
|
||||
const sidebarElm = document.querySelector('.sidebar');
|
||||
const sidebarToggleElm = document.querySelector('.sidebar-toggle');
|
||||
const searchElm = sidebarElm?.querySelector('input[type="search"]');
|
||||
const isSidebarHidden = sidebarElm?.getBoundingClientRect().x < 0;
|
||||
|
||||
isSidebarHidden && sidebarToggleElm?.click();
|
||||
|
||||
setTimeout(() => searchElm?.focus(), isSidebarHidden ? 250 : 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
hook.mounted(_ => {
|
||||
initComponent(CONFIG, vm);
|
||||
!isAuto && initSearch(CONFIG, vm);
|
||||
|
||||
@ -146,3 +146,108 @@ test.describe('Configuration options', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('keyBindings', () => {
|
||||
test('handles toggleSidebar binding (default)', async ({ page }) => {
|
||||
const docsifyInitConfig = {
|
||||
markdown: {
|
||||
homepage: `
|
||||
# Heading 1
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
await docsifyInit(docsifyInitConfig);
|
||||
|
||||
const bodyElm = page.locator('body');
|
||||
|
||||
await expect(bodyElm).not.toHaveClass(/close/);
|
||||
await page.keyboard.press('\\');
|
||||
await expect(bodyElm).toHaveClass(/close/);
|
||||
});
|
||||
|
||||
test('handles custom binding', async ({ page }) => {
|
||||
const docsifyInitConfig = {
|
||||
config: {
|
||||
keyBindings: {
|
||||
customBinding: {
|
||||
bindings: 'z',
|
||||
callback(e) {
|
||||
const elm = document.querySelector('main input[type="text"]');
|
||||
|
||||
elm.value = 'foo';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
homepage: `
|
||||
<input type="text">
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
const inputElm = page.locator('main input[type="text"]');
|
||||
|
||||
await docsifyInit(docsifyInitConfig);
|
||||
|
||||
await expect(inputElm).toHaveValue('');
|
||||
await page.keyboard.press('z');
|
||||
await expect(inputElm).toHaveValue('foo');
|
||||
});
|
||||
|
||||
test('ignores event when focused on text input elements', async ({
|
||||
page,
|
||||
}) => {
|
||||
const docsifyInitConfig = {
|
||||
config: {
|
||||
keyBindings: {
|
||||
customBinding: {
|
||||
bindings: 'z',
|
||||
callback(e) {
|
||||
document.body.setAttribute('data-foo', '');
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
homepage: `
|
||||
<input type="text">
|
||||
<select>
|
||||
<option value="a" selected>a</option>
|
||||
<option value="z">z</option>
|
||||
</select>
|
||||
<textarea></textarea>
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
const bodyElm = page.locator('body');
|
||||
const inputElm = page.locator('input[type="text"]');
|
||||
const selectElm = page.locator('select');
|
||||
const textareaElm = page.locator('textarea');
|
||||
|
||||
await docsifyInit(docsifyInitConfig);
|
||||
|
||||
await inputElm.focus();
|
||||
await expect(inputElm).toHaveValue('');
|
||||
await page.keyboard.press('z');
|
||||
await expect(inputElm).toHaveValue('z');
|
||||
await inputElm.blur();
|
||||
|
||||
await textareaElm.focus();
|
||||
await expect(textareaElm).toHaveValue('');
|
||||
await page.keyboard.press('z');
|
||||
await expect(textareaElm).toHaveValue('z');
|
||||
await textareaElm.blur();
|
||||
|
||||
await selectElm.focus();
|
||||
await page.keyboard.press('z');
|
||||
await expect(selectElm).toHaveValue('z');
|
||||
await selectElm.blur();
|
||||
|
||||
await expect(bodyElm).not.toHaveAttribute('data-foo');
|
||||
await page.keyboard.press('z');
|
||||
await expect(bodyElm).toHaveAttribute('data-foo');
|
||||
});
|
||||
});
|
||||
|
||||
@ -176,6 +176,7 @@ test.describe('Search Plugin Tests', () => {
|
||||
await searchFieldElm.fill('hello');
|
||||
await expect(resultsHeadingElm).toHaveText('Changelog Title');
|
||||
});
|
||||
|
||||
test('search when there is no body', async ({ page }) => {
|
||||
const docsifyInitConfig = {
|
||||
markdown: {
|
||||
@ -196,4 +197,39 @@ test.describe('Search Plugin Tests', () => {
|
||||
await searchFieldElm.fill('empty');
|
||||
await expect(resultsHeadingElm).toHaveText('EmptyContent');
|
||||
});
|
||||
|
||||
test('handles default focusSearch binding', async ({ page }) => {
|
||||
const docsifyInitConfig = {
|
||||
scriptURLs: ['/lib/plugins/search.min.js'],
|
||||
};
|
||||
|
||||
const searchFieldElm = page.locator('input[type="search"]');
|
||||
|
||||
await docsifyInit(docsifyInitConfig);
|
||||
|
||||
await expect(searchFieldElm).not.toBeFocused();
|
||||
await page.keyboard.press('/');
|
||||
await expect(searchFieldElm).toBeFocused();
|
||||
});
|
||||
|
||||
test('handles custom focusSearch binding', async ({ page }) => {
|
||||
const docsifyInitConfig = {
|
||||
config: {
|
||||
search: {
|
||||
keyBindings: ['z'],
|
||||
},
|
||||
},
|
||||
scriptURLs: ['/lib/plugins/search.min.js'],
|
||||
};
|
||||
|
||||
const searchFieldElm = page.locator('input[type="search"]');
|
||||
|
||||
await docsifyInit(docsifyInitConfig);
|
||||
|
||||
await expect(searchFieldElm).not.toBeFocused();
|
||||
await page.keyboard.press('/');
|
||||
await expect(searchFieldElm).not.toBeFocused();
|
||||
await page.keyboard.press('z');
|
||||
await expect(searchFieldElm).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user