Skip to content

Align the source editor with the clicked block in both previews - #577

Open
gordonwoodhull wants to merge 5 commits into
mainfrom
bugfix/preview-click-to-editor-scroll
Open

Align the source editor with the clicked block in both previews#577
gordonwoodhull wants to merge 5 commits into
mainfrom
bugfix/preview-click-to-editor-scroll

Conversation

@gordonwoodhull

@gordonwoodhull gordonwoodhull commented Aug 22, 2026

Copy link
Copy Markdown
Member

Clicking a block in the q2-preview never moved the Monaco source editor. Clicking in the HTML preview moved it, but centered the line vertically. Both now align: the clicked block's first source line lands at the same on-screen Y position as the block itself, so the panes read side by side.

17 files, ~3050 insertions. TypeScript only — no Rust or Cargo files changed, which is why cargo xtask verify isn't part of this.

Why q2-preview did nothing

Q2PreviewIframe drove preview→editor sync off a click listener on the iframe document. But q2-preview activates block editing on pointerup, and activation replaces the clicked element's subtree with a synthetic edit region — and Chromium dispatches no click at all when the pointerup target was detached during pointerup. So the handler never ran for the one gesture that mattered.

Fixed by listening for capture-phase pointerup, which runs before the app's bubble-phase activation handler while the target is still attached, and resolving the nearest [data-loc] ancestor to a source line.

The alignment

scrollTop = getTopForLineNumber(line) - (hostY - editorTop)

clamped to the editor's scroll range. Near a document's start or end the clamp wins and the panes can't line up exactly — geometry, not a bug.

hostY crosses two coordinate spaces: the clicked element's rect is relative to the iframe's viewport, so the iframe element's own offset in the host page is added. Dropping that term misaligns by exactly the height of the chrome above the preview. jsdom has no layout, so only the browser row can catch it; its 6px tolerance is set by measurement — reverting to the old centring call misses by ~13px.

Two deliberate asymmetries between the previews:

  • q2-preview takes no focus and moves no cursor. The same click opens an inline editor in the preview; pulling focus to Monaco would break the gesture it just started, and setPosition would feed editor→preview sync and bounce.
  • The HTML preview keeps its cursor move and focus steal. It has no inline editor to protect, and that's the behaviour users already have. Only the centring is replaced.

Five defects the tests as written could not have caught

Worth reviewing in their own right — each came from a step beyond "the suite is green".

  1. The plan's own pinned API specified instanceof Element, which is false for every real click: the listener runs in the parent frame's realm against a target from the sandboxed iframe's realm, and each realm has its own Element. Thirteen green jsdom rows passed against a guard that returned null in production. Only the real-browser row caught it. The shipped guard is duck-typed, and a new row reproduces the cross-realm case at the jsdom tier so a revert reddens a fast test.
  2. The reveal was silently overwritten ~50 ms after landing correctly. revealEditorLine never set isSyncingRef, and by design never takes focus — so both of syncPreviewToEditor's feedback guards were unarmed, and any post-click reflow replaced the reveal with a ratio-derived position. Measured: correct at t=1 ms, a 6px preview scroll at t=6 ms as a toolbar mounted, wrong by t=83 ms, with no second pointerup.
  3. Foreign-fileId clicks weren't inert as the plan claimed — they revealed the included file's line number as a line in the open file.
  4. The caret-move guard was unbound. Deleting it reddened neither of its two rows: the fixture nested the edit region inside a located <section>, so a separate guard returned null anyway and the row couldn't tell a working implementation from a broken one. Found by a six-hunk fail-on-revert pass over the finished seam.
  5. The HTML preview set the caret into included content — worse than (3), since you could then type into the wrong place. Fixed here because we were already editing that function.

What to look at

  • scrollSyncDom.tslineForClickTarget's four null cases and the duck-typed narrowing. Please don't "tidy" the latter back to instanceof.
  • useScrollSync.tsrevealEditorLine is threaded into useSelectionSync rather than extracted into a shared helper. That's load-bearing: it brackets useScrollSync's own isSyncingRef, the flag syncPreviewToEditor reads. A shared helper would set the other hook's unrelated flag and reopen (2) on the HTML path.
  • Q2PreviewIframe.tsx / MorphIframe.tsx — the two-coordinate-space hostY computation, in both previews.

Tests

22 jsdom rows and 7 Playwright rows, each bound to a named production hunk. Every hunk was reverted and confirmed to redden its rows. Two rows are pinned against plausible-looking "fixes": one asserts click-align is not gated by the scroll-sync toggle (adding the gate looks tidy and would break it), and the browser race row asserts as a precondition that the reflow it depends on actually fired, so it can't quietly go vacuous.

hub-client 1001 unit / 112 integration / 131 wasm · preview-renderer 549 unit / 590 integration · typecheck:tests clean · build:all clean · Playwright 7/7.

One pre-existing unrelated failure: custom-components Equation > appends \tag{N} under the pinned katex 0.18.1 — filed as bd-s36g9dav, fails identically on main (invisible there only because a stale node_modules pins katex 0.17.0).

Deferred, deliberately

  • D5 — clicking included content should reveal the {{< include >}} shortcode's own line. Investigated; recommends a client-side scan over a producer-side Rust/wire change. Inert today, not wrong.
  • bd-5n66w8cslocal-prod-server.mjs dies on an unhandled ECONNRESET; hit it twice while demoing.
  • bd-mdcqnl84 (closed) — ratio scroll sync stays, since the scroll-sync toggle governs it. Its origin is worth knowing: loc-based preview→editor sync was specified in the 2025-12-29 matched-scrolling plan and never implemented; the ratio substitution shipped and that plan was marked Completed.

Plans: claude-notes/plans/2026-08-21-preview-click-to-editor-scroll.md, claude-notes/plans/2026-08-22-click-align-editor-y.md. Both record the false premises they contained, so the next reader doesn't re-derive them.

Clicking a block in the q2-preview did nothing to the Monaco source editor,
while the HTML preview scrolled. Root cause, confirmed in a real browser:
`Q2PreviewIframe` drove preview->editor sync off a `click` listener, but
q2-preview activates block editing on `pointerup`, and activation replaces the
clicked element's subtree with a synthetic edit region. Chromium dispatches no
`click` at all when the `pointerup` target was detached during `pointerup`, so
the listener never ran for the one gesture that mattered.

Fixed by listening for capture-phase `pointerup` and resolving the clicked
element's nearest `[data-loc]` ancestor to a source line. Capture phase runs
before the app's bubble-phase activation handler, while the target is still
attached.

The reveal is deliberately scroll-only -- no cursor move, no focus steal --
because the same click opens an inline editor *inside the preview*, and pulling
focus to Monaco would break the gesture that click just started. `setPosition`
would additionally feed editor->preview sync and bounce. The HTML preview's own
path is left untouched.

`lineForClickTarget` returns null rather than a line when the target is inside
the active edit region, when the nearest located ancestor is a `<section>`, and
when nothing resolves -- so a caret move inside an open editor does not yank
the editor away.

Worth recording: the plan's own pinned API specified narrowing the argument with
`instanceof Element`. That is false for every real click -- the listener runs in
the parent frame's realm against a target from the sandboxed iframe's realm, and
each realm has its own `Element` constructor. Thirteen green jsdom rows passed
against a guard that returned null in production; only the real-browser row
caught it. The shipped guard is duck-typed, and row U1f reproduces the
cross-realm case at the jsdom tier so a revert reddens a fast test.

Plan, decisions and the full test-seam spec:
claude-notes/plans/2026-08-21-preview-click-to-editor-scroll.md
None of these could have been caught by the test suite as written. Each came
from a step beyond "the tests pass".

The reveal was silently overwritten about 50ms after landing correctly.
revealEditorLine never set the isSyncingRef flag, and by design it never takes
focus, so both of syncPreviewToEditor's feedback-loop guards were unarmed: any
real preview scroll inside the debounce window replaced the correct reveal with
a scroll-ratio-derived position. Measured in a live browser: Monaco correctly at
lines 149-189 (containing the clicked line 171) at t=1ms; a genuine 6px preview
scroll at t=6ms as a rich-text toolbar mounted and reflowed the page; Monaco at
174-211 by t=83ms, with no second pointerup. Not specific to callouts -- any
post-click reflow does this to any block. Fixed with the same isSyncingRef/300ms
idiom the file already used elsewhere. The browser row guarding it asserts, as an
explicit precondition, that the reflow it depends on actually fired, so it cannot
quietly pass if the fixture ever stops reflowing.

Clicking included content revealed a wrong line. lineForClickTarget parsed the
fileId out of data-loc and never checked it, so clicking content from an included
file revealed that file's line number as though it were a line in the file you
had open -- a real, editable, wrong line. The plan claimed such clicks were left
inert; they were not. Now a fourth null case, with a same-file control so the
guard cannot be satisfied by rejecting everything.

The guard against caret-move clicks was unbound. Deleting it reddened neither of
its two tests: the unit fixture nested the edit region directly inside a located
section, so a separate section guard returned null anyway and the test could not
tell a working implementation from a broken one. Both now discriminate, proven in
both directions.

That last one surfaced in a six-hunk fail-on-revert pass over the finished test
seam, which also established that dropping the capture-phase argument does not
redden the browser test -- a detached paragraph still carries its own data-loc,
and closest() called on it finds itself. The unit test's listener-registration
assertion is therefore the only thing binding capture phase; don't delete it on
the theory that the browser tier covers it.
Clicking a block revealed its source line centred in the editor. Now it aligns:
the block's first line of code lands at the same on-screen height as the block
you clicked, so the two panes line up across the split.

    scrollTop = getTopForLineNumber(line) - (hostY - editorTop)

clamped to the editor's scroll range. Near the start or end of a document the
clamp wins and the panes cannot line up exactly; that is geometry, not a defect.

hostY is the clicked block's top edge in host-page coordinates. Two coordinate
spaces are involved: the block's rect is relative to the iframe's viewport, so
the iframe element's own offset in the host page has to be added. Omitting that
term misaligns by exactly the height of whatever chrome sits above the preview,
which is the symptom to look for. Only the browser test can catch it -- jsdom has
no layout -- and its 6px tolerance is set by measurement: reverting to the old
centring call misses by about 13px.

Alignment is unconditional, where the previous call did nothing when the line was
already visible. Alignment is a claim about where a line sits, so "on screen
somewhere" isn't good enough.

Click-align is deliberately not gated by the scroll-sync toggle, unlike the three
ambient-sync paths, so turning that toggle off leaves click-align as the only
coupling between the panes. A test pins this, because adding the missing gate
looks like a tidy consistency fix.

Plan and decisions: claude-notes/plans/2026-08-22-click-align-editor-y.md
…ncludes

The HTML preview reaches the editor by a different route than q2-preview, and it
already did more: its selection handler runs on selectionchange with no
collapsed-selection guard, so a plain click already moved the cursor, centred the
view, and took focus. Only the centring is replaced here. The cursor move and the
focus steal stay -- this preview has no inline editor of its own to protect,
which is the only reason q2-preview avoids them.

The alignment anchor is the clicked span, not its containing block. The source
location already reported is span- and column-precise, so anchoring to a coarser
block's top would desync from the very line being reported -- on a paragraph that
wraps, by a lot. The browser test therefore measures the span under the click,
and uses a deliberately wrapping fixture, since a single-line paragraph could not
tell span-anchored from block-anchored.

revealEditorLine is threaded from useScrollSync into useSelectionSync rather than
reimplemented or extracted into a shared helper. That is load-bearing, not
stylistic: it brackets useScrollSync's own isSyncingRef, which is the flag
syncPreviewToEditor reads, so the reveal-then-overwrite race is closed on this
path too. A shared helper called from useSelectionSync would set that hook's
unrelated flag and leave the race wide open. Don't decouple the hooks.

Also fixes a pre-existing bug in the same function: it built a Monaco range
without checking fileId, so selecting text inside included content moved the
caret and focus to an unrelated line of the file you had open. That is worse than
the q2-side equivalent, which only produced a wrong scroll -- here you could type
into the wrong place.
Replaces the five per-commit entries the two-commit workflow produced while
this was built. Their hashes did not survive squashing, and an entry cannot
carry the hash of the commit it lives in, so the consolidated entry lands here
in its own commit -- the same reason the two-commit workflow exists.

Written for someone using the preview rather than reviewing the diff, since
this renders in the About tab.
@posit-snyk-bot

posit-snyk-bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

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.

2 participants