fix(muya): cut a cross-block selection from beforeinput too (#5415) by Jocs · Pull Request #5536 · marktext/marktext · GitHub
Skip to content

fix(muya): cut a cross-block selection from beforeinput too (#5415) - #5536

Merged
Jocs merged 4 commits into
developfrom
fix/cross-block-modifier-edit-5415
Sep 23, 2026
Merged

Jocs merged 4 commits into
developfrom
fix/cross-block-modifier-edit-5415

Conversation

@Jocs

@Jocs Jocs commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Fixes #5415.

The bug

Deleting a selection that spans two blocks with Cmd+Backspace removed the second block from the screen but not from the document. The block stayed in muya's tree with its DOM node detached, so the next structural edit nearby — Enter, Enter at the end of a bullet list — threw NotFoundError: Failed to execute 'insertBefore' on 'Node'. One of the triggers behind #5035.

Root cause

muya cuts a cross-block selection only from its document keydown handler, and shouldCrossBlockCut() refuses every chord carrying Cmd or Ctrl — a guard added so Ctrl+C would stop silently deleting the selection (#3491).

A key alone cannot say whether a modifier combo edits. Several of the refused chords are editing commands, and some edits carry no keydown at all:

route what the browser does
Cmd+Backspace (deleteToBeginningOfLine:) native cross-block delete
Ctrl+K / Ctrl+D / Ctrl+H (macOS AppKit) native cross-block delete
Ctrl+Backspace (delete word, every platform) native cross-block delete
Ctrl+Y yank (macOS) native cross-block replace
emoji picker, dictation, handwriting no keydown to intercept

Chromium then applied the edit across the block elements and removed the ones in between, leaving muya's tree and json state holding a block whose element is gone.

Fix

Add a beforeinput handler alongside the existing keydown one. beforeinput fires for every editing intent whatever produced it, and its inputType says whether the event edits at all — so non-editing shortcuts need no blacklist (they fire no beforeinput), and the modifier guard stays exactly as it is for #3491.

The two handlers never act on the same edit: anything keydown took over arrives at beforeinput with the selection already cut, or does not arrive because keydown cancelled the event. So the new handler only ever sees what the old one missed.

crossBlockReplacement() is one rule — over a cross-block selection muya owns every editing beforeinput except the ones another handler already owns:

  • deleteByCut / deleteByDrag / insertFromPaste / insertFromPasteAsQuotation / insertFromDrop / insertParagraph / insertLineBreak / the two composition types are excluded — the cut, paste and Enter paths own them, and composition is not cancelable.
  • everything else classifies as insert or delete and replaces the selection accordingly.

Stating it as an exclusion set rather than an allowlist matters: an inputType nobody has enumerated (a future Chromium addition, a route no one reported yet) then degrades to "selection replaced" instead of to the corruption above, and the set does not grow a row per bug report. classifyInputKind() already splits insert from delete for undo grouping, so that stays one definition.

Why not just widen the keydown guard?

Measured, same spec, three implementations:

develop keydown: allow ⌫/⌦ with any modifier + allowlist ⌃K/⌃D/⌃H/⌃Y this PR
Ctrl+Backspace ✘ ✓ ✓ ✓
Cmd+Backspace ✘ ✓ ✓ ✓
Ctrl+K ✘ ✘ ✘ - tw ✓
Ctrl+D ✘ ✘ ✘ - twt ✓
Ctrl+H ✘ ✘ ✘ - txt ✓
Ctrl+Y ✘ ✘ ✓ ✓

Two reasons the key layer cannot get there:

  1. An allowlist only answers half the question. It decides whether to cut; it does not decide whether the browser's own action must then be suppressed. Insertions must be allowed to complete (that is how the existing printable-key branch works, and why ⌃Y passes above); deletions must be cancelled, or they delete a second time on top of the cut — hence - tw / - twt / - txt. That needs a second hand-maintained table.
  2. The same chord means different things per platform. On Linux/Windows Ctrl+K, Ctrl+D, Ctrl+H and Ctrl+Y are MarkText's own shortcuts (view.toggle-toc, format.strike, paragraph.bullet-list, format.inline-code — see keybindingsLinux.ts), so allowlisting them by key would reintroduce In Typewriter Mode, when I selected text, and somehow text would be disppeared #3491 on a different key. beforeinput needs no table: those chords fire no beforeinput on Linux, and arrive as deleteContentForward etc. on macOS.

The keydown path stays, and must: IME composition reaches beforeinput as insertCompositionText, which is cancelable: false. The keydown fallthrough (cut without preventDefault, before compositionstart) is the only hook that can collapse the selection in time. The two handlers are complementary, not redundant.

Verification

Measured in Chromium via the muya e2e host. Document - one / - two / blank / text, selection from tw|o into te|xt:

chord beforeinput before after
Cmd+Backspace deleteContentBackward - one/- twxt/``/text + detached paragraph - one/- twxt
Ctrl+K deleteHardLineForward same - one/- twxt
Ctrl+D deleteContentForward same - one/- twxt
Ctrl+H deleteContentBackward same - one/- twxt
Ctrl+Backspace deleteContentBackward same - one/- twxt
Ctrl+Y insertFromYank same - one/- twtextxt

Each case then runs Enter, Enter at the end of the list and asserts no pageerror, no detached block, and that the block tree still matches getState(). Without the fix that step throws the reported insertBefore error.

Tests

  • e2e/tests/editing/cross-block-modifier-edit-5415.spec.ts — the chords above, plus two guards that must not change: Ctrl+C still leaves the selection alone (In Typewriter Mode, when I selected text, and somehow text would be disppeared #3491) and a printable key still replaces it. Playwright only forwards macOS AppKit editing commands when the host is macOS, so the mac-only chords are gated on the platform; Ctrl+Backspace needs no such command and is what covers this on CI.
  • src/clipboard/__tests__/crossBlockReplacement.spec.ts — the inputType decision table, including that formatting and history commands never delete, and that an unrecognised type is still claimed.

Suite results: muya clipboard units 171 passed; muya e2e editing/ 111 passed. The one red spec there, cross-block-enter.spec.ts:36, fails identically on unmodified develop in this environment and is unrelated.

Also in this PR

Not covered

The emoji picker / dictation / handwriting routes named in the issue go through the same insert branch, but I could not verify them: document.execCommand('insertText') fires no beforeinput at all, so it is not a faithful stand-in, and those input sources cannot be driven from a test. Ctrl+Y is the one insertion route reachable from a keyboard, and it is what the spec exercises.

🤖 Generated with Claude Code

@Jocs
Jocs force-pushed the fix/cross-block-modifier-edit-5415 branch from 309c30f to abe25da Compare September 23, 2026 14:24
Jocs and others added 4 commits September 23, 2026 23:22
`cutSelection`'s same-block branch replaces the selected range with
nothing. Lift that splice into `replaceRange` and add `insertTextAtCursor`
on top of it, so replacing a selection with text and replacing it with
nothing stay one implementation.

No caller for the insert half yet; the cross-block `beforeinput` path
added next needs it to finish an edit whose native insertion it cancelled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
muya cut a selection spanning two blocks only from its document `keydown`
handler, and `shouldCrossBlockCut` refuses every chord carrying Cmd or
Ctrl so that Ctrl+C would stop deleting the selection (#3491). A key
alone cannot say whether a modifier combo edits, though: on macOS
Cmd+Backspace, Ctrl+K, Ctrl+D and Ctrl+H are editing commands,
Ctrl+Backspace is Chromium's delete-word binding everywhere, and input
committed without a key press at all (emoji picker, dictation) never
reaches a keydown handler. Those edits ran in the browser instead, which
deleted across the block elements: the second block left the screen but
stayed in muya's tree with a detached DOM node and in the saved markdown,
and the next structural edit near it threw "Failed to execute
'insertBefore' on 'Node'".

Add a `beforeinput` handler that catches what keydown missed. It asks
`inputType`, which says whether the event edits at all, so non-editing
shortcuts stay untouched — they fire no `beforeinput` — and every route
into an edit is covered however it was produced. Anything the keydown
handler did take over reaches it with the selection already cut, or not
at all because that handler cancelled the event, so the two never act on
the same edit.

The rule is one sentence: over a cross-block selection muya owns every
editing `beforeinput` except the ones another handler already owns. An
input type nobody has enumerated therefore degrades to "selection
replaced" rather than to the corruption above, and the exclusion set does
not have to grow a row per bug report. `classifyInputKind` already splits
insert from delete for undo grouping, so that stays one definition too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`detachedBlocks`, `expectTreeMatchesJson` and the Enter-twice-on-the-list
probe together are one assertion — "the browser did not leave a block
behind" — and #5035's spec is the only place that owns it. The next spec
to make that assertion (#5415) would copy all three verbatim, so move
them next to the other shared helpers first and switch #5035 over.

`.mu-list-item` joins `helpers/selectors.ts`, which is where specs are
meant to read class names from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#5415)

Drives the real chords over a selection running from a bullet into the
paragraph below it, and asserts all three symptoms are gone: the markdown
loses the second block, no block is left with a detached DOM node, and
Enter, Enter at the end of the list no longer throws.

Ctrl+Backspace is Chromium's own delete-word binding and needs no AppKit
command, so it covers this on CI; Playwright forwards the macOS editing
commands (Cmd+Backspace, Ctrl+K/D/H, Ctrl+Y yank) only when the host is
macOS, so those are gated on the platform. Ctrl+Y is the one insertion
route a keyboard can reach, standing in for the emoji picker and
dictation, which no test can drive.

Ctrl+C and a plain printable key are pinned alongside: both go through
the keydown handler, and neither behaviour may change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jocs
Jocs force-pushed the fix/cross-block-modifier-edit-5415 branch from abe25da to 5f50a84 Compare September 23, 2026 15:24
@Jocs
Jocs merged commit 978d71d into develop Sep 23, 2026
7 checks passed
@Jocs
Jocs deleted the fix/cross-block-modifier-edit-5415 branch September 23, 2026 15:34
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.

[Bug] Cmd+Backspace across two blocks detaches the second block, and Enter on a list then throws "Failed to execute 'insertBefore'"

1 participant