From 49a1293c1b24324991cc8cf417267e3c201f0309 Mon Sep 17 00:00:00 2001 From: Xiao-Chenguang <973990275@qq.com> Date: Fri, 21 Aug 2026 12:24:37 +0800 Subject: [PATCH] feat: add keyboard shortcuts and clickable nodes to the preview - Add single-key shortcuts when the preview is focused: F (fit window size), R (toggle recursively), +/- (zoom in/out), E/C (expand/collapse all), T (toggle the highlighted node), ? (shortcut help) - Sync the state of toolbar buttons with the shortcuts - Toggle a node by clicking anywhere on it, not only the circle; Ctrl/Cmd+click inverts the toggle-recursively mode - Fix the 'Toggle the active node' commands, which never worked because the active node was not tracked --- CHANGELOG.md | 7 ++ README.md | 17 +++++ assets/style.css | 55 +++++++++++++++ package.json | 2 +- src/app.ts | 172 ++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 249 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25941e8..5b8c960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## 0.3.0 + +- Added single-key shortcuts when the preview is focused: F fit window size, R toggle recursively, +/- zoom in/out, E/C expand/collapse all, T toggle the highlighted node, ? show shortcut help +- Click anywhere on a node to fold/unfold it, not only the circle (Ctrl/Cmd+click inverts the toggle-recursively mode) +- Toolbar buttons are highlighted in sync with the shortcuts +- Fixed the `Toggle the active node` commands, which previously did nothing + ## 0.1.1 - Use Cmd+click to toggle nodes recursively on macOS diff --git a/README.md b/README.md index c0ce8bc..507e3f1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This extension integrates [markmap](https://markmap.js.org/) into VSCode. - Preview markdown files as markmap - Edit markdown files in a text editor and the markmap will update on the fly +- Click anywhere on a node (not only the circle) to fold/unfold it, Ctrl/Cmd+click inverts the toggle-recursively mode - Works offline markmap @@ -29,6 +30,22 @@ Open a markdown file. Find the markmap icon on the editor title-bar and click it ![title button](https://user-images.githubusercontent.com/3139113/110966366-25f0cf00-8390-11eb-9a16-3c4d66712f47.png) +### Keyboard shortcuts + +When the markmap preview is focused, the following single-key shortcuts are available: + +| Key | Action | +| --- | --- | +| `F` | Fit window size | +| `R` | Toggle recursively (toggling a node affects the whole subtree) | +| `+` / `=` | Zoom in | +| `-` | Zoom out | +| `E` | Expand all nodes | +| `C` | Collapse all nodes (keep the first level) | +| `T` | Toggle the highlighted node | +| `?` | Show/hide the shortcut help | +| `Esc` | Close the shortcut help | + ## Configuration ### Custom CSS diff --git a/assets/style.css b/assets/style.css index 91f3b93..c389eb9 100644 --- a/assets/style.css +++ b/assets/style.css @@ -23,3 +23,58 @@ body { padding-left: 4px; padding-right: 4px; } + +.markmap-help { + position: absolute; + top: 20px; + right: 20px; + z-index: 10; + padding: 12px 16px; + border: 1px solid rgb(0 0 0 / 10%); + border-radius: 8px; + background: rgb(255 255 255 / 80%); + color: #333; + font-size: 14px; + line-height: 1.6; +} + +.markmap-help-title { + margin-bottom: 4px; + font-weight: 600; +} + +.markmap-help ul { + margin: 0; + padding: 0; + list-style: none; +} + +.markmap-help li { + display: flex; + justify-content: space-between; + gap: 32px; +} + +.markmap-help kbd { + display: inline-block; + min-width: 1em; + padding: 0 6px; + border: 1px solid rgb(0 0 0 / 30%); + border-bottom-width: 2px; + border-radius: 4px; + background: #fff; + font-family: monospace; + text-align: center; +} + +.markmap-dark .markmap-help { + border-color: rgb(255 255 255 / 15%); + background: rgb(0 0 0 / 70%); + color: #eee; +} + +.markmap-dark .markmap-help kbd { + border-color: rgb(255 255 255 / 30%); + background: #333; + color: #eee; +} diff --git a/package.json b/package.json index 17cee8d..b3ee633 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "markmap-vscode", - "version": "0.2.11", + "version": "0.3.0", "description": "Visualize your markdown in VSCode", "author": "Gerald ", "license": "MIT", diff --git a/src/app.ts b/src/app.ts index 3658fe9..f8e78b4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,10 @@ -import { IDeferred, defer, wrapFunction, type INode } from 'markmap-common'; +import { + IDeferred, + defer, + walkTree, + wrapFunction, + type INode, +} from 'markmap-common'; import { Toolbar } from 'markmap-toolbar'; import { defaultOptions, @@ -16,7 +22,6 @@ let style: HTMLStyleElement; let active: | { node: INode; - el: Element; } | undefined; const activeNodeOptions: { @@ -139,7 +144,19 @@ function initialize(mm: Markmap) { vscode.postMessage({ type: 'setFocus', data: line }); }, true, - ); + ) + .on('click.toggleNode', (e, d) => { + // markmap-view only handles clicks on the circle of a node; + // make the whole node (text included) clickable + if (!d?.children?.length) return; + // the circle is handled by markmap-view itself, and links by the + // document-level click handler + if ((e.target as Element).closest('circle, a')) return; + // ctrl/cmd inverts the recursive mode, same as clicking the circle + const recursive = + mm.options.toggleRecursively !== !!(e.metaKey || e.ctrlKey); + mm.toggleNode(d, recursive); + }); }); } @@ -214,6 +231,7 @@ function findActiveNode({ } async function highlightNode(node?: INode) { + active = node && { node }; await mm.setHighlight(node); if (!node) return; await mm[ @@ -222,3 +240,151 @@ async function highlightNode(node?: INode) { bottom: 80, }); } + +/** + * Keyboard shortcuts, available when the markmap preview is focused. + * Single keys only, since they have no other function in the preview. + */ +const keyHandlers: { + [key: string]: () => void; +} = { + f: () => + whenReady(() => { + mm.fit(); + pulseToolbar('fit'); + }), + r: () => toggleRecursively(), + '+': () => rescaleByKey(1.25), + '=': () => rescaleByKey(1.25), + '-': () => rescaleByKey(0.8), + e: () => setFoldAll(0), + c: () => setFoldAll(1), + t: () => { + if (active?.node) mm.toggleNode(active.node, isToggleRecursively()); + }, + '?': () => toggleHelp(), +}; + +document.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey || e.altKey) return; + const el = e.target as HTMLElement | null; + if (el?.closest?.('input, textarea, select, [contenteditable]')) return; + if (e.key === 'Escape') { + hideHelp(); + return; + } + const handler = keyHandlers[e.key.toLowerCase()]; + if (!handler) return; + e.preventDefault(); + handler(); +}); + +function whenReady(fn: () => void | Promise) { + return loading?.promise.then(fn); +} + +function isToggleRecursively() { + return mm.options.toggleRecursively; +} + +/** + * Toggle `toggleRecursively` mode, same as the `recurse` toolbar button, + * and sync the button's active state so the current mode is visible. + */ +function toggleRecursively() { + const enabled = !mm.options.toggleRecursively; + mm.setOptions({ toggleRecursively: enabled }); + getToolbarItemEl('recurse')?.classList.toggle('active', enabled); +} + +/** + * Get the rendered DOM element of a toolbar item. + * `registry[id].content` holds a virtual node instead of the rendered + * element, so look the button up by its index in the rendered items. + */ +function getToolbarItemEl(id: string) { + const index = toolbar.items.indexOf(id); + if (index < 0) return; + return toolbar.el.querySelectorAll('.mm-toolbar-item')[index]; +} + +/** + * Briefly highlight a toolbar item, as visual feedback for shortcuts + * mapped to one-shot actions like `fit` and zooming. + */ +function pulseToolbar(id: string) { + const el = getToolbarItemEl(id); + if (!el) return; + el.classList.add('active'); + setTimeout(() => { + el.classList.remove('active'); + }, 200); +} + +function rescaleByKey(ratio: number) { + whenReady(() => { + mm.rescale(ratio); + pulseToolbar(ratio > 1 ? 'zoomIn' : 'zoomOut'); + }); +} + +async function setFoldAll(fold: number) { + await whenReady(); + if (!root) return; + let isRoot = true; + walkTree(root, (node, next) => { + // never fold the root node, otherwise nothing is visible + if (!isRoot && node.children?.length) { + node.payload = { ...node.payload, fold }; + } + isRoot = false; + next(); + }); + await mm.renderData(); + await mm.fit(); +} + +let helpEl: HTMLDivElement | undefined; + +function toggleHelp() { + if (helpEl?.isConnected) { + hideHelp(); + return; + } + helpEl = document.createElement('div'); + helpEl.className = 'markmap-help'; + const title = document.createElement('div'); + title.className = 'markmap-help-title'; + title.textContent = 'Keyboard Shortcuts'; + const list = document.createElement('ul'); + const items: [string[], string][] = [ + [['F'], 'Fit window size'], + [['R'], 'Toggle recursively'], + [['+', '='], 'Zoom in'], + [['-'], 'Zoom out'], + [['E'], 'Expand all'], + [['C'], 'Collapse all'], + [['T'], 'Toggle the highlighted node'], + [['?'], 'Show/hide this help'], + ]; + items.forEach(([keys, label]) => { + const li = document.createElement('li'); + const kbdContainer = document.createElement('span'); + keys.forEach((key, i) => { + if (i) kbdContainer.append(' / '); + const kbd = document.createElement('kbd'); + kbd.textContent = key; + kbdContainer.append(kbd); + }); + const text = document.createElement('span'); + text.textContent = label; + li.append(kbdContainer, text); + list.append(li); + }); + helpEl.append(title, list); + document.body.append(helpEl); +} + +function hideHelp() { + helpEl?.remove(); +}