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
@@ -29,6 +30,22 @@ Open a markdown file. Find the markmap icon on the editor title-bar and click it

+### 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();
+}