fix(muya): select the clicked line ourselves on a triple-click (#5389) by Jocs · Pull Request #5524 · marktext/marktext · GitHub
Skip to content

fix(muya): select the clicked line ourselves on a triple-click (#5389) - #5524

Merged
Jocs merged 5 commits into
developfrom
fix/5389-triple-click-line-selection
Sep 23, 2026
Merged

Jocs merged 5 commits into
developfrom
fix/5389-triple-click-line-selection

Conversation

@Jocs

@Jocs Jocs commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Summary

Triple-clicking a line highlights only that line, but typing a character also consumed the block below it: a thematic break disappeared, a list's first item or a quote's text was pulled into the line, front matter absorbed the body paragraph and lost its own. Above a table or a task list the screen changed but the document did not, and above a heading the triple-click highlighted nothing at all and the character landed at the start of the line.

All nine variants in #5389 now behave as that issue's "Expected result" column specifies, verified in the packaged app with the file re-read from disk.

Root cause

muya consumed the browser's select-paragraph range as if the user had made it. Captured from a real Chromium, that range reaches past what it highlights, and where it reaches depends on what follows the clicked line:

Next block Chromium's range end
thematic break, list, quote, paragraph offset 0 of that block's mu-content span
table, task list, math/html preview the <figure> / <li> / preview node — no mu-content ancestor
heading nowhere: the range is collapsed at the click
— (a later line of the same code block or front matter) one character past the line, over its own \n

TextSelection.getSelection() calls a selection cross-block on block identity alone, so the first row became a cross-block selection. Clipboard.keydownHandler then ran cutSelection, and resolveCutEnd with an end offset of 0 returns the end block's entire text as the tail to merge into the start block before removing it — so a block the user selected no character of was absorbed whole. In the second row findContentDOM (upward-only) resolved nothing, getSelection() returned null, and the keystroke neither cut nor called preventDefault(), leaving the browser to edit the DOM with the document behind it (#5414, one of the #5035 insertBefore triggers). The third row explains the heading case, and the fourth joined two lines of one code block.

The same selection fed Ctrl+X, paste (applyPaste delegates to cutHandler) and Enter, and because the line read as cross-block, Editor._dispatchEvents nulled activeContentBlock — which is why Cmd+B and the inline format toolbar did nothing over a triple-clicked line in a multi-block document. The existing triple-click specs use single-paragraph documents, where the range has no block to spill into, so none of this was covered.

Fix

Don't consume that range — cancel it. The third mousedown of a click streak is default-prevented, the clicked line is located from the pointer's own coordinates (caretPositionFromPoint, falling back to caretRangeFromPoint), and muya sets the selection itself:

if (!isMouseEvent(event) || event.button !== 0 || event.detail < 3)
    return

const line = lineAtPoint(this._doc, event.clientX, event.clientY)
if (!line)
    return

event.preventDefault()
this._selectInfo.isSelect = false
line.block.setCursor(line.start, line.end)

Both guards are measured, not defensive: a right-click streak really does arrive with detail = 3, button = 2, and excluding a shift-held streak would re-open the bug — Chromium's third shift-click discards the extension and select-paragraphs at the click, overshoot included. Eleven lines, and that is the whole behavioural change — handleMouseupOrLeave and handleMousemoveOrClick are untouched. Because no engine range is left to correct, the four shapes in the table above stop existing rather than being repaired: the collapsed cases, the unresolvable ones and the \n overshoot inside a multi-line block are all covered by one rule, a triple-click selects the line under the pointer. All three engines then agree, where before Chromium selected a paragraph-and-a-bit while Firefox and WebKit selected the clicked word.

Two supporting extractions, each its own commit: lineBounds (shared with codeBlockContent's getIndentSpace, which had the same off-by-one spelled out by hand) and resolveEndpoint (lifted out of getSelection(), which was the only place that knew how to turn one DOM position into a block and a character offset). lineAtPoint sits beside it in selection/dom.ts, and the selection is installed with Content#setCursor, the API that already existed for exactly this.

The cost: a triple-click no longer extends as the pointer drags on. Cancelling the mousedown leaves the browser's drag-select unarmed — measured: mid-drag the DOM selection stays exactly what we set and the browser never touches it again. The selection therefore stays the clicked line, which the e2e pins in all three drag directions rather than leaving it to chance. Re-arming that gesture would mean owning drag-extension and edge auto-scroll, which is not worth it for this one gesture; nothing is corrupted by its absence.

Two consequences worth calling out:

  • A triple-clicked line now behaves like any same-block selection, so an auto-pair character wraps it (* over alpha beta gives *alpha beta*) rather than replacing it — the same thing a drag-selected line has always done.
  • Copying a triple-clicked line yields the line, without the trailing \n\n getSelectedText() used to append.

Test plan

  • e2e e2e/tests/editing/triple-click-line-5389.spec.ts (Chromium, 22 cases) — the real gesture over twelve following block types, plus a front-matter line, one line of a multi-line code block, a list item, a quoted line, Backspace, a shift-held click streak, [Bug] Typing over a triple-clicked line above a task list or table detaches it, and Enter on a list then throws "Failed to execute 'insertBefore'" #5414's own reproduction through to the Enter that used to throw insertBefore, and the three drag directions. Asserts getMarkdown() and an empty pageerror log.
  • Unit src/selection/__tests__/lineAtPoint5389.spec.ts + src/utils/__tests__/lineBounds.spec.ts (19 cases) — lineBounds across single-line, multi-line and empty text; resolveEndpoint resolving a text node and returning null for a table, a task-list item and a detached node; lineAtPoint bounding the first, middle and last line of a code block, and declining when the caret lands outside any content block. happy-dom has no layout, so the caret position is handed in; the gesture itself is the e2e's job.
  • pnpm -C packages/muya test (2032 tests), lint, lint:types, check-circular.
  • Full Chromium e2e: 391 passed, 2 failed. Which two drifts run to run on this machine (diagrams/plantuml, which reaches plantuml.com; edges/empty-and-tiny; inline/format-toolbar; inline/shortcuts); run in isolation the failing set is byte-identical with and without this branch, and identical again on develop.
  • Packaged app (pnpm run build:unpack): issue [Bug] Typing over a triple-clicked line swallows the next block (thematic break, list item, quote, front matter) #5389's steps 1–5 for variants A–H plus a code-block line, driven through the real MarkText window, each asserting the file on disk after File > Save — x plus the untouched block, for all nine. A selection-heavy desktop e2e subset (editor input, inline format, selection word count, tab-switch cursor, search prefill, TOC active heading, paragraph blocks — 47 tests) passes against the refactored getSelection().

Two follow-ups this deliberately leaves alone, both pre-existing and both verified in a real Chromium while reviewing this:

Fixes #5389
Fixes #5414

🤖 Generated with Claude Code

@Jocs
Jocs force-pushed the fix/5389-triple-click-line-selection branch 2 times, most recently from 6bf7a6f to 198add9 Compare September 23, 2026 04:07
Jocs and others added 3 commits September 23, 2026 19:20
`getIndentSpace` in `codeBlockContent` spelled out `lastIndexOf('\n', offset - 1)
+ 1` / `indexOf('\n', start)` with its own `-1 → length` fallback, and the
triple-click work needs the same pair. Move it to `utils` as `lineBounds` so the
off-by-one lives in one tested place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`findContentDOM` → read the stamped block → `outMostBlock` gate →
`getOffsetOfParagraph(node, dom) + offset` is the answer to "which block and
character does this DOM position mean", and `getSelection()` was the only place
that knew it — inline, for both ends at once. Lift it into `selection/dom.ts` as
`resolveEndpoint` so a caller that has one DOM position rather than a whole
range can ask the same question. The cast the callsite used to make is now
`getBlock()`'s job.

Behaviour is unchanged: every bail-out the helper folds in already made
`getSelection()` return null for the whole selection, and the region it replaces
was side-effect free.

Also names the anchor/focus pair `ISelectionEndpoints`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A triple-click highlighted one line, but typing a character consumed the block
below it: a thematic break disappeared, a list's first item or a quote's text
was pulled into the line, front matter absorbed the body paragraph and lost its
own. Above a table or a task list the screen changed and the document did not;
above a heading nothing was highlighted at all and the character landed at the
start of the line.

Chromium's select-paragraph range reaches past what it highlights, and where it
reaches depends on what follows the clicked line: offset 0 of the next block; a
node that maps to no content block (a table, a task-list checkbox, a math or
html preview); the character after the line's own `\n` inside a code block or
front matter; or, above a heading and on a code block's last line, nowhere at
all. muya stored that range as the user's own, so `Clipboard.keydownHandler` ran
`cutSelection`, whose `resolveCutEnd` turns an end offset of 0 into "merge the
whole end block", and where the range resolved to no block the keystroke neither
cut nor called `preventDefault()` — leaving the browser to edit the DOM with the
document behind it (#5414, one of #5035's triggers).

Don't consume that range: cancel it. The third mousedown of a click streak is
default-prevented, the line under the pointer is found from the pointer's own
coordinates, and muya sets the selection itself. There is no engine range to
correct, so the collapsed cases, the unresolvable ones and the `\n` overshoot
inside a multi-line block all stop existing rather than being repaired, and all
three engines behave the same (Firefox and WebKit select only the clicked word
on their own).

The cost is that a triple-click no longer extends as the pointer drags on —
cancelling the mousedown leaves the browser's drag-select unarmed. The selection
stays the clicked line, which the e2e pins in all three drag directions.

Fixes #5389
Fixes #5414

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jocs
Jocs force-pushed the fix/5389-triple-click-line-selection branch from 08c0949 to 8813d26 Compare September 23, 2026 11:26
@Jocs Jocs changed the title fix(muya): read a triple-click as the line it highlights (#5389) fix(muya): select the clicked line ourselves on a triple-click (#5389) Sep 23, 2026
Jocs and others added 2 commits September 23, 2026 20:41
`resolveEndpoint` stopped one field short of what both its callers wanted:
each rebuilt an `IAnchorFocusInfo` by re-adding `block.path`, so `getSelection()`
carried two locals and two literals for it and `paragraphSelect` needed a
factory of its own. Return the endpoint itself.

With that, `paragraphSelect.ts` was one exported function whose only dependency
and only caller both lived in `selection/`, so fold it in as `lineAtPoint` beside
`resolveEndpoint` — the point-to-caret half is a general capability the next
point-based feature will want, not something about paragraphs. The caller then
says what it means with the API that already exists for it,
`Content#setCursor(start, end)`, instead of assembling both endpoints by hand,
and clears `isSelect`, which is where "this gesture will not drag" belongs.

`ISelection` now extends `ISelectionEndpoints` rather than redeclaring the pair
three lines below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A review raised that `detail >= 3` also matches a shift-held click streak, and
suggested excluding it so the shift-extend path keeps the selection. Measuring
it says otherwise: Chromium's third shift-click discards the extension and
select-paragraphs at the click, overshoot and all — so excluding it re-opens
#5389 for that gesture (`alpha beta` above `***` saves as `Z***`).

The predicate is right as it stands; what was missing is a test saying so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jocs
Jocs merged commit 69f93e4 into develop Sep 23, 2026
7 checks passed
@Jocs
Jocs deleted the fix/5389-triple-click-line-selection branch September 23, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant