feat(editor): overlay code-block controls, hide language selector until hover (closes #275)

The code-block control panel (language selector + copy) took a full row above
the code. Move both to an absolute overlay in the top-right corner and hide the
language selector until the block is hovered/focused; the copy button stays
always visible. In read-only the language selector isn't rendered at all. The
<pre> (editable contentDOM) stays FIRST in the DOM so click hit-testing (#146)
is not regressed; the panel leaves the flow via position:absolute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent_coder
2026-07-02 01:20:43 +03:00
parent 2524f39a36
commit 5280392fc4
3 changed files with 53 additions and 23 deletions
@@ -50,10 +50,10 @@ export default function CodeBlockView(props: NodeViewProps) {
{/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM.
With the non-editable menu rendered before it, the browser's click
hit-testing snapped the caret up one line. Render content first; the
menu is rendered after it and lifted back above visually via flex
`order: -1` (the `.codeBlock` wrapper is a flex column — see
code-block.module.css). It stays fully in flow as a full-width row
above the code: no overlay/absolute positioning. The second #146
menu is rendered after it and floated into the top-right corner as an
absolute overlay (see `.menuGroup` in code-block.module.css, anchored
to the `position: relative` `.codeBlock` wrapper in code.css). It no
longer takes a full-width row above the code. The second #146
mitigation lives in editor-paste-handler.tsx (reflowAfterPaste). */}
<pre
spellCheck="false"
@@ -72,17 +72,22 @@ export default function CodeBlockView(props: NodeViewProps) {
contentEditable={false}
className={classes.menuGroup}
>
<Select
placeholder="auto"
checkIconPosition="right"
data={extension.options.lowlight.listLanguages().sort()}
value={languageValue}
onChange={changeLanguage}
searchable
style={{ maxWidth: "130px" }}
classNames={{ input: classes.selectInput }}
disabled={!editor.isEditable}
/>
{/* In read-only (published) there is no language selector at all —
only the copy button. When editable the selector is hidden until
the block is hovered/focused (or its dropdown is open) via the
`.languageSelect` class (see code-block.module.css). */}
{editor.isEditable && (
<Select
placeholder="auto"
checkIconPosition="right"
data={extension.options.lowlight.listLanguages().sort()}
value={languageValue}
onChange={changeLanguage}
searchable
style={{ maxWidth: "130px" }}
classNames={{ root: classes.languageSelect, input: classes.selectInput }}
/>
)}
<CopyButton value={node?.textContent} timeout={2000}>
{({ copied, copy }) => (
@@ -17,15 +17,37 @@
justify-content: center;
}
/* #146: the menu now follows the <pre> in the DOM (so the editable contentDOM is
FIRST and click hit-testing is correct). Lift it back ABOVE the code visually
with flex `order` — the .codeBlock wrapper is a flex column (see code.css) —
so the menu still reads as a row above the code, exactly as before, without
sitting in-flow before the contentDOM. */
/* #146: the menu follows the <pre> in the DOM (so the editable contentDOM is
FIRST and click hit-testing is correct). Instead of sitting in-flow, it is
floated into the top-right corner as an absolute overlay anchored to the
`position: relative` .codeBlock wrapper (see code.css), so it no longer
takes a full-width row above the code. The Mantine dropdown is portaled, so
it is never clipped by the overlay. */
.menuGroup {
order: -1;
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
gap: 4px;
@media print {
display: none;
}
}
/* The language selector is hidden until the block is hovered, or the selector
itself is focused / its dropdown is open. It keeps its width in the flex
Group (only opacity toggles) so the copy button never jumps, and
`pointer-events: none` while hidden lets clicks fall through to the code.
`.codeBlock` is the global NodeViewWrapper class → use :global(). */
.languageSelect {
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease;
}
:global(.codeBlock):hover .languageSelect,
.languageSelect:focus-within {
opacity: 1;
pointer-events: auto;
}