Skip to content

feat(editor): shortcuts for the six unbound formatting commands, and one meaning for Ctrl+T - #480

Merged
PathGao merged 2 commits into
masterfrom
feat/format-shortcuts-and-ctrl-t
Aug 6, 2026
Merged

feat(editor): shortcuts for the six unbound formatting commands, and one meaning for Ctrl+T#480
PathGao merged 2 commits into
masterfrom
feat/format-shortcuts-and-ctrl-t

Conversation

@PathGao

@PathGao PathGao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #121. Closes #392.

Two keyboard items that needed the same thing first — the whole keymap — so
they share one PR and one test file. @alecdotdev approved the Ctrl+T direction
in #392
("proposed solutions sounds good to me"); #121 is from @kago76, on a German
QWERTZ keyboard where the backtick is a dead key.

The keymap

Standalone Monaco is not VS Code. Its defaults were dumped from the
installed monaco-editor@0.55.1 — load monaco-editor under a DOM shim and
print KeybindingsRegistry.getDefaultKeybindings() once per process.platform
— rather than read off VS Code's keybinding docs, because two of the four
chords proposed for this change survive the VS Code reading and die on the real
one. It matters that a clash is silent: editor.addAction registers dynamic
keybindings at weight 1000, above EditorContrib (100) and EditorCore
(0), so Markpad always wins the chord and the Monaco command just stops
answering its key.

Editor layer — every action Editor.svelte registers with a keybinding

New in this PR marked .

action Windows/Linux macOS shadows a Monaco default
custom-copy Ctrl+C ⌘C editor.action.clipboardCopyAction (deliberate — goes via the Rust clipboard)
(unnamed addCommand) Ctrl+V ⌘V editor.action.clipboardPasteAction (deliberate)
toggle-tabs Ctrl+Shift+B ⇧⌘B
toggle-zen-mode Ctrl+Shift+Z ⇧⌘Z redo
fmt-bold Ctrl+B ⌘B
fmt-italic Ctrl+I ⌘I editor.action.triggerSuggest (+2 suggest-widget commands)
fmt-underline Ctrl+U ⌘U cursorUndo
fmt-inline-code Ctrl+Shift+E ⇧⌘E
fmt-code-block Ctrl+Shift+F ⇧⌘F
fmt-quote Ctrl+Shift+. ⇧⌘. editor.action.inPlaceReplace.down
fmt-heading-1 Ctrl+1 ⌘1
fmt-heading-2 Ctrl+2 ⌘2
fmt-heading-3 Ctrl+3 ⌘3
insert-table-simple Ctrl+K T ⌘K T — (Monaco's Ctrl+K chords all take Ctrl on the second key)
file-new Ctrl+N, Ctrl+T ⌘N, ⌘T
file-open Ctrl+O ⌘O
file-save Ctrl+S ⌘S
file-close Ctrl+W ⌘W
file-reveal Ctrl+Shift+R ⇧⌘R editor.action.refactor (Windows/Linux only)
view-toggle-edit Ctrl+E ⌘E actions.findWithSelection (macOS only)
view-toggle-live Ctrl+L ⌘L expandLineSelection
view-toggle-split Ctrl+\, Ctrl+OEM_102 ⌘\, ⌘OEM_102
tab-next / tab-prev Ctrl+Tab / Ctrl+Shift+Tab Ctrl+Tab / Ctrl+Shift+Tab (real Ctrl — ⌘Tab is the system switcher)
tab-undo-close Ctrl+Shift+T ⇧⌘T
app-command-palette Ctrl+P ⌘P

Registered with no keybinding, unchanged: toggle-minimap,
toggle-word-wrap, toggle-line-numbers, toggle-vim-mode,
toggle-status-bar, toggle-word-count, toggle-line-highlight,
toggle-occurrences-highlight, toggle-whitespace, fmt-bullet-list,
fmt-numbered-list, fmt-checklist, fmt-link.

Document layer — MarkdownViewer.svelte's <svelte:document onkeydown>

This layer runs only when Monaco did not claim the key: the standalone
keybinding service listens on the editor's container node and calls
stopPropagation() on every chord it resolves, so a keystroke reaches the
document handler only from outside the editor.

Ctrl+W/F4 close · Ctrl+T and Ctrl+N new file · Ctrl+O open ·
Ctrl+S save · Ctrl+E toggle edit · Ctrl+\ split · Ctrl+Shift+T undo
close tab · Ctrl+Shift+M move tab to next window · Ctrl+Tab /
Ctrl+PageUp/Down / ⌘⌥←→ cycle tabs · Alt+←/→ file history · Ctrl+=/-/0
zoom · Ctrl+, settings · Ctrl+F find · Ctrl+Alt+[/] preview width ·
F5 reload · Ctrl+Q close window (returns early on macOS, where the native
menu owns it).

Native menu (macOS only)

⌘, Settings and ⌘Q Quit. That is the whole menu since #281 — the third
Ctrl+T path #392 described is already gone, and there is now a test that keeps
it gone.

Vim mode

monaco-vim hooks editor.onKeyDown, which fires at the textarea before the
keybinding service's container listener, and calls stopPropagation() when its
keymap matches — so a chord in vim's default keymap shadows the app's action
while vim is on. Its keymap contains <C-a> <C-b> <C-c> <C-d> <C-e> <C-f> <C-i> <C-n> <C-o> <C-p> <C-q> <C-r> <C-t> <C-u> <C-v> <C-w> <C-x> <C-y> <C-Space> <C-BS> <C-[> and no digits and no Ctrl+Shift combinations.
monacoToCmKey spells ⌘ as Meta-, which vim never matches, so macOS is
unaffected throughout. All six new chords are therefore vim-safe on every
platform; several existing ones (Ctrl+B, Ctrl+I, Ctrl+U, Ctrl+E,
Ctrl+N, Ctrl+O, Ctrl+P, Ctrl+T, Ctrl+W) are not, on Windows/Linux.
That is pre-existing and not touched here.

#121 — the six bindings, and what each of them displaced

Each proposal was checked against the table above; three of the four obvious
candidates failed.

action chose precedent, or the collision that forced a deviation
headings 1–3 Ctrl/⌘+1/2/3 Typora binds Ctrl/Cmd+1..6; Mark Text binds Cmd+1..6 on macOS. Free in standalone Monaco on both platforms, free in Markpad, absent from monaco-vim.
inline code Ctrl/⌘+Shift+E GitHub binds inline code to Ctrl/Cmd+E — but plain Ctrl/⌘E is already view-toggle-edit here, and that is the mainstream reading (Obsidian Ctrl+E edit↔read, Mark Text Ctrl+E source mode). Same letter, plus Shift. Typora's Ctrl+Shift+` and Mark Text's Ctrl+` were rejected on purpose: #121 is about the backtick dead key.
code block Ctrl/⌘+Shift+F No mainstream chord survives. Typora and Mark Text both use Ctrl+Shift+K on Windows/Linux — editor.action.deleteLines in standalone Monaco on both platforms — and both fall back to Cmd+Option+C on macOS, which is toggleFindCaseSensitive. F is for the fences ("Code Fences" is Typora's own name for the command). This one is a deviation, not a precedent.
blockquote Ctrl/⌘+Shift+. GitHub's documented blockquote chord, and the only blockquote binding that is identical on both platforms in anything surveyed; Shift+. is >. Typora and Mark Text use Ctrl+Shift+Q, which macOS cannot deliver — ⇧⌘Q is the system Log Out — which is exactly why both of them use Cmd+Option+Q there.

Corrections to what was proposed to me, since they are the interesting part:

  • Ctrl+E for inline code is wrong here. It is GitHub's chord, but it is
    Markpad's view-toggle-edit, and on macOS it is also Monaco's
    actions.findWithSelection.
  • Ctrl+Shift+Q for blockquote is wrong on macOS. Typora's blockquote is
    Ctrl+Shift+Q (Win/Linux) / Cmd+Option+Q (macOS) — not Ctrl+Q, and
    the macOS half exists because ⇧⌘Q is taken by the OS.
  • Ctrl+Shift+KdeleteLines was right, and it is worse than described:
    it collides on macOS too, and Typora's macOS alternative collides as well.
  • GitHub does not bind headings at all, and its Ctrl+E inserts inline
    code, not a fenced block ("Inserts Markdown formatting for a code
    one-liner"). Obsidian's official docs document no formatting hotkeys beyond
    macOS Cmd+B/Cmd+I.

Headings 1–6? No — 1–3 only. The app has three heading actions;
LINE_MARKERS, the toolbar and the context menu all stop at H3, and
editorContextMenuI18n.test.ts requires every formatting label to be
translated in all 26 locales, so H4–H6 is three actions, three toolbar entries
and 78 translations — a feature, not a keybinding. Ctrl+4/5/6 are left
unbound rather than given to something else, so completing Typora's range later
moves nothing.

editorToolbar.ts carries the second copy of each of these facts (the button
tooltip), so it is filled in too, and a new test holds the two together — which
nothing did for Bold, Italic and Underline either.

#392 — Ctrl+T

Verified before changing anything: it is now two paths, not three. Monaco's
file-new answers Ctrl+T inside the editor and opens an Untitled buffer; the
document handler answered it outside the editor and opened a Home tab. The
macOS native menu no longer takes it — #281 cut the menu to Settings and Quit —
so the issue's third path is already gone.

Both of the app's own labels had already picked "new file": the tab strip's
+ button is titled New Tab (Ctrl+T) and calls addNewTab, and the app
menu prints Ctrl/⌘+T beside New File. The branch was the odd one out.

file-new also binds Ctrl/⌘+N, and the document handler knew about only one
of the two — so Ctrl+N did nothing at all outside the editor. Both are
handled now, and the test asserts the two layers agree rather than asserting
either in isolation.

How does a user open the Home tab now? — the question the issue never asked

addHomeTab() had exactly one caller in the app, and it was this branch.
Unifying Ctrl+T therefore does remove the only way to open the home screen
as a tab. It does not remove the home screen:

entry point what it opens still there?
Ctrl/⌘+T outside the editor Home tab (tabManager.addHomeTab) no — becomes New File
App menu → Home Home screen (showHome, the same <HomePage>, same recent files / pinned tags / callbacks) yes
Tab strip + button new Untitled tab (never Home) yes
App menu → New File new Untitled tab yes

The two render the identical component through the same {#if} — the tab form
was already the weaker of the two. It is dropped on session restore
(hasRealFilePath is false for the HOME sentinel) and closed when windows are
merged, so a Home tab never survived a restart. I am calling that a
simplification rather than a regression, but it is a judgement call and it is
here rather than buried in the diff.

addHomeTab and the isHomePath special cases stay: the sentinel still has
to be recognised for snapshots written by older builds, and #429's render gate
is tested against it. Removing the now-callerless constructor is a separate
cleanup, deliberately not bundled here.

Scope

  • Not moving any existing binding. Ctrl+E stays view-toggle-edit.
  • Not binding fmt-bullet-list / fmt-numbered-list / fmt-checklist /
    fmt-link. GitHub has chords for the lists (Ctrl+Shift+7/8) and Ctrl+K
    for links; none of them were asked for and Ctrl+K is a chord prefix here.
  • Not fixing TabList.svelte's hard-coded (Ctrl+T) tooltip, which says
    Ctrl on macOS too. Now merely cosmetic, and it was wrong before this PR as
    well.
  • Not touching the pre-existing Monaco shadows in the table above
    (Ctrl+I → suggest, Ctrl+U → cursorUndo, Ctrl+Shift+Z → redo).
  • Not reconciling tab-next/tab-prev, whose two layers use different
    modifiers on macOS by design. The new test names that divergence in
    KNOWN_LAYER_DIVERGENCES so a new one fails instead of joining it.
  • Noticed and left alone: Editor.svelte declares an onhome prop that
    nothing in the component uses.
  • Overlap with an open PR: feat: search from selected text and copy document as Markdown #231 claims Ctrl/⌘+Shift+C for "copy document
    as Markdown". Nothing here takes it — that is part of why the code block is
    not on Ctrl+Shift+C.

Tests

scripts/formatShortcutKeymap.test.ts, 10 tests. It does not match either
component as text.

  • registerLocalizedActions and handleKeyDown are extracted by name
    (functionSource, not an anchor pair that widens as neighbours are added),
    transpiled, and run inside a with block over a Proxy scope that answers
    for every free identifier with a recording stub. Renaming a local or
    reordering the actions changes nothing; changing a key does.
  • Keybinding numbers come from Monaco's real KeyMod/KeyCode and are turned
    back into chords by Monaco's real decodeKeybinding, once per operating
    system
    , so CtrlCmd → Meta/Ctrl is resolved rather than assumed.
  • The document layer is discovered, not described: a synthetic keystroke is
    fired for every chord in a bounded space (a–z, 0–9, Tab, PageUp/Down, arrows,
    F4/F5, the brackets, \, ., ,, -, =, × Ctrl/Meta × Shift × Alt) and
    the app functions that ran are recorded. That is why "Ctrl+T does not open a
    Home tab" is an assertion about behaviour.
  • Each new action is run() and its call recorded, so fmt-heading-2 carrying
    heading 3's body fails.
  • Uniqueness is table-driven twice: no two editor actions may claim one chord,
    and any chord both layers answer must mean the same thing in both — the
    second one is the shape of the Ctrl+T means three different things, and the Home tab it opens renders blank #392 bug, and it catches the next person's
    addition.
  • Both harnesses assert they registered a plausible number of entries, so a
    refactor that makes the extraction silently return nothing fails rather than
    passing vacuously.

Weaker assertions, named: MONACO_DEFAULTS is a snapshot of the
standalone defaults the new chords were checked against, not a reading of them.
Enumerating them for real needs Monaco's browser-side contribution graph, which
needs a DOM; a shim big enough to load it would be a second, synthetic source of
truth for exactly the thing being checked. The regeneration recipe is in the
file. Layout-dependent key values are modelled, not reproduced: the synthetic
events carry the unshifted character, which is what the two punctuation branches
(Ctrl+,, Ctrl+=/-) actually compare against.

Falsification

Fix reverted, tests kept.

Item #121Editor.svelte and editorToolbar.ts back to master:

✖ each formatting action asked for in #121 now carries its shortcut
✖ the toolbar tooltip prints the shortcut the editor actually registered
ℹ tests 10 / pass 8 / fail 2

  AssertionError: fmt-heading-1 on macOS
    actual: undefined,
    expected: [ 'Meta+1' ],

  AssertionError: fmt-heading-1 toolbar hint
    actual: undefined,
    expected: 'Cmd+1',

Item #392MarkdownViewer.svelte back to master:

✖ Ctrl/Cmd+T means new file outside the editor too, on every platform
✖ no path anywhere still opens a Home tab from a keystroke
✖ a chord that both layers answer means the same thing in both
ℹ tests 10 / pass 7 / fail 3

  AssertionError: Meta+T on macOS opens a new file and nothing else
    actual: [ 'tabManager.addHomeTab' ],
    expected: [ 'handleNewFile' ],

  AssertionError: Ctrl+T on macOS still reaches addHomeTab
    actual: false, expected: true,

  AssertionError: macOS: Meta+N runs file-new in the editor but nothing outside it
    actual: undefined, expected: true,

The third one is the Ctrl+N gap, found by the test rather than by me.
each new shortcut runs the command it is labelled with stays green under both
reverts, correctly: the run bodies already existed, it is the keys that did
not.

Verification

npm audit        found 0 vulnerabilities
npm run check    649 files, 0 errors, 0 warnings
npm test         738 tests, 738 pass, 0 fail
cargo test       157 passed, 0 failed
npm run build    clean

Not verified: I did not run the app and press these keys. Everything above
is the keymap the app declares, plus Monaco's declared defaults; what a
running WebView does with ⇧⌘. on a German layout, whether ⇧⌘Q really never
reaches the app on macOS, and how the vim adapter behaves in practice are all
reasoned from source and vendor documentation, not observed. Windows and Linux
were not exercised at all — I am on macOS. If someone can try Ctrl+Shift+F
and Ctrl+Shift+. on a real Windows build before this lands, that is the gap
worth closing.

Rebased on e98da36, which already contains #471 and #474. Merge order may
still require a rebase — #231 touches the neighbouring lines of both files.

PathGao and others added 2 commits August 6, 2026 10:06
`fmt-inline-code`, `fmt-code-block`, `fmt-quote` and the three headings have
been in the toolbar and the command palette since v2.6.12 and answered no key.
The report (#121) is from a QWERTZ user, for whom the backtick is a dead key:
typing a code fence needs three dead-key escapes, which is why the request is
for a shortcut and not for the command.

Bold, Italic and Underline already had Ctrl/Cmd+B/I/U. These six now have:

| action             | Windows/Linux  | macOS        | why that chord                  |
|--------------------|----------------|--------------|---------------------------------|
| `fmt-heading-1..3` | Ctrl+1/2/3     | Cmd+1/2/3    | Typora, Mark Text               |
| `fmt-inline-code`  | Ctrl+Shift+E   | ⇧⌘E          | GitHub's Ctrl+E, plus Shift     |
| `fmt-code-block`   | Ctrl+Shift+F   | ⇧⌘F          | no precedent survives Monaco    |
| `fmt-quote`        | Ctrl+Shift+.   | ⇧⌘.          | GitHub                          |

Every one of them was checked against standalone Monaco's OWN defaults, dumped
from the installed monaco-editor 0.55.1 rather than read off VS Code's
documentation — the two keymaps are not the same, and `editor.addAction`
registers at weight 1000, above every Monaco default, so a clash does not
error: our action simply wins and Monaco's loses its key.

That check rejected the obvious candidates:

- Ctrl/Cmd+E for inline code (GitHub's chord) is `view-toggle-edit` here
  already — and Ctrl+E for edit/read is itself the mainstream reading, in
  Obsidian and Mark Text. So inline code takes the same letter with Shift.
- Ctrl/Cmd+Shift+K for a code block (Typora, Mark Text) is
  `editor.action.deleteLines` on BOTH platforms, not just on Windows. Their
  macOS alternative, Cmd+Option+C, is `toggleFindCaseSensitive`. No mainstream
  code-fence chord is free, so this one is a deviation and says so.
- Ctrl/Cmd+Shift+Q for a blockquote (Typora, Mark Text) is the macOS system
  Log Out on ⇧⌘Q, which is exactly why both of them use Cmd+Option+Q there.
  GitHub's Ctrl+Shift+. is the same on both platforms, and Shift+. is `>`.

Quote is the one binding that takes a chord away from Monaco:
`editor.action.inPlaceReplace.down`, which has no menu or toolbar presence
here, means nothing in Markdown, and stays in the command palette.

Headings stop at 3 because the app has three heading actions. 4, 5 and 6 are
left unbound rather than given to something else, so completing Typora's range
later moves nothing.

The toolbar hint is the second copy of each of these facts, so it is filled in
too, and the test holds the two together — which it did not for Bold, Italic
and Underline either.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Ctrl/Cmd+T meant two things (#392). Monaco resolves its keybindings on the
editor's container node and calls stopPropagation() when it consumes one, so
the document-level handler in MarkdownViewer.svelte only ever runs for
keystrokes the editor did not claim. Inside the editor, `file-new` answered
Ctrl+T and opened an Untitled buffer; outside it, the handler opened a Home
tab. Same key, different document, decided by where the caret happened to be.

The issue reported three paths. There are now two: the macOS native menu was
cut back to Settings and Quit by #281 and no longer takes Cmd+T at all, and
this asserts that, so a fourth meaning cannot be added above the other two
without failing.

The app's own labels had already picked a side. The tab strip's + button is
titled "New Tab (Ctrl+T)" and calls `addNewTab`; the app menu prints Ctrl/Cmd+T
beside "New File". The branch was the odd one out, not the labels, which is
what @alecdotdev's "new file seems the common reading" says in #392.

`file-new` also binds Ctrl/Cmd+N, and the document handler knew about only one
of the two — so Ctrl+N did nothing at all outside the editor. Both keys are
handled here now, and the test asserts the two layers agree rather than
asserting either one in isolation.

## The Home tab

`addHomeTab()` had exactly one caller in the app, and it was this branch, so
unifying Ctrl+T does remove the only way to open the home screen AS A TAB.
What it does not remove is the home screen: the app menu's Home item toggles
`showHome`, which renders the same `<HomePage>` with the same recent files,
pinned tags and callbacks. The tab form was already the weaker of the two — it
is dropped on session restore (`hasRealFilePath` is false for the sentinel) and
closed when windows are merged, so it never survived a restart anyway.

`addHomeTab` and the `isHomePath` special cases stay. The sentinel still has to
be recognised for snapshots written by older builds, and #429's render gate is
tested against it. Removing the now-callerless constructor is a separate
cleanup and is deliberately not bundled here.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit b04d547 into master Aug 6, 2026
4 checks passed
@PathGao
PathGao deleted the feat/format-shortcuts-and-ctrl-t branch August 6, 2026 02:42
PathGao added a commit that referenced this pull request Aug 6, 2026
…hind it (#488)

* feat(shortcuts): one registry behind the panel, the menu and the toolbar

"What is the shortcut for X" was answered in three places that nothing held
together: `editorToolbar.ts`, fourteen hard-coded literals in TitleBar's markup,
and the handlers themselves. Only the first was tested, and only since #480. A
shortcuts panel carrying its own list would have been a fourth copy, and the one
users would trust most -- so this adds `src/lib/utils/shortcuts.ts` and points
all three display sites at it. Net sources of truth: three down to one, plus the
handlers the test fires at.

The panel is a sixth Settings category, read-only. Remapping needs conflict
detection, persistence, reset-to-default and chord capture inside a webview, and
is a product-direction call; a reference gets most of the value with none of it.
Settings was chosen over a standalone modal because it is already reachable
(Ctrl+,) and needs no new global chord -- and a new chord would have to be
cleared against Monaco's defaults, monaco-vim and both layers, which is the
exact cost the read-only design avoids. Obsidian files Hotkeys under Settings
too; VS Code's dedicated editor is a remapping UI, which this is not.

Two literals were already wrong, and neither had a test:

  * The menu printed `Mod+Shift+S` beside Save As. Nothing binds that chord.
    The save branch is `cmdOrCtrl && key === 's'` with no Shift guard, so the
    advertised keystroke ran a plain Save -- writing the current file instead of
    asking where to put it. `saveContentAs` has no keyboard path at all. The
    label is removed rather than the binding invented: giving Save As a real
    chord is a behaviour change and the maintainer's call.
  * `showTooltip` took a suffix and composed `${modifier}+${suffix}`, so the
    Reload-from-Disk tooltip read "Ctrl+F5" for a binding that is plain F5 --
    while the menu row two lines below it correctly said F5. It now takes a
    complete chord from the registry.

Zero new command strings: every label reuses a dictionary key that already
exists for a menu or context-menu entry, and the four group headings are the
existing menu-bar categories. One new key total, `settings.shortcuts`, English
only, per the policy i18nCoverage.test.ts states.

The keymap harness from #480 moves to `scripts/keymapHarness.ts` so the new
contract test uses the same one rather than a parallel model. It also records
assignments now: `Mod+,` and the zoom chords work by writing a variable, and a
call-only recorder reported them as dead keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(shortcuts): drop an unused LanguageCode re-export

shortcuts.ts re-exported a type nothing imports from it. The panel, the menu and
the toolbar all take their language from the i18n module directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ctrl+T means three different things, and the Home tab it opens renders blank Feature request: commands for code blocks

1 participant