fix(editor): render NodeViewContent first so click hit-testing isn't offset (#146)
Three editable NodeViews rendered a contentEditable=false "chrome" element IN FLOW BEFORE NodeViewContent. On macOS the browser's click hit-testing (posAtCoords → caretRangeFromPoint) then misses the contentDOM and snaps the caret to the previous node — the caret/selection lands a line (code block) or several lines (footnotes, into the body) above where the user clicked. Fix (the transclusion pattern / issue #146 plan): make the editable NodeViewContent the FIRST child in the DOM and move the non-editable chrome AFTER it, restoring its visual position with CSS: - code-block-view: <pre><NodeViewContent/></pre> first; the language/copy menu follows and is lifted above via flex `order` (.codeBlock is now a flex column). - footnotes-list-view: NodeViewContent first; the "Footnotes" heading follows and is lifted above via flex `order` (.list is a flex column; the separator border stays on the container). - footnote-definition-view: NodeViewContent first; the "N." marker follows with `order:-1` to stay on the left; the ↩ back-link stays on the right. Layout is visually unchanged. Verified in a real browser (Chromium): the contentDOM is now the first child of every editable NodeView wrapper (no contentEditable=false element precedes it), and the menu/heading/marker still render in their original positions. NOTE: the caret-offset itself is macOS-specific text hit-testing and does not reproduce in headless Chromium/WebKit on Linux (verified extensively), so the visible fix is best confirmed on macOS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,23 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="codeBlock">
|
||||
{/* #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 follows and is positioned as a top-right overlay via CSS (the
|
||||
transclusion pattern), so it no longer sits in-flow above the code. */}
|
||||
<pre
|
||||
spellCheck="false"
|
||||
hidden={
|
||||
((language === "mermaid" && !editor.isEditable) ||
|
||||
(language === "mermaid" && !isSelected)) &&
|
||||
node.textContent.length > 0
|
||||
}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
<Group
|
||||
justify="flex-end"
|
||||
contentEditable={false}
|
||||
@@ -83,18 +100,6 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
</CopyButton>
|
||||
</Group>
|
||||
|
||||
<pre
|
||||
spellCheck="false"
|
||||
hidden={
|
||||
((language === "mermaid" && !editor.isEditable) ||
|
||||
(language === "mermaid" && !isSelected)) &&
|
||||
node.textContent.length > 0
|
||||
}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
{language === "mermaid" && (
|
||||
<Suspense fallback={null}>
|
||||
<MermaidView props={props} />
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
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. */
|
||||
.menuGroup {
|
||||
order: -1;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -29,10 +29,14 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||
className={classes.definition}
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
>
|
||||
{/* #146: contentDOM MUST be the first child — a non-editable marker before
|
||||
it makes click hit-testing snap the caret above. Content first; the
|
||||
marker + back-link follow in DOM and are placed left/right via CSS
|
||||
flex `order`. */}
|
||||
<NodeViewContent className={classes.definitionContent} />
|
||||
<span className={classes.definitionMarker} contentEditable={false}>
|
||||
{number}.
|
||||
</span>
|
||||
<NodeViewContent className={classes.definitionContent} />
|
||||
<span
|
||||
className={classes.backLink}
|
||||
contentEditable={false}
|
||||
|
||||
@@ -57,14 +57,19 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Bottom footnotes container. */
|
||||
/* Bottom footnotes container. Flex column so the heading (rendered AFTER the
|
||||
editable NodeViewContent in the DOM for #146) is lifted back above the list
|
||||
visually via `order`, instead of sitting in-flow before the contentDOM. */
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.listHeading {
|
||||
order: -1; /* visually above the list, though it follows it in the DOM (#146) */
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
@@ -83,6 +88,7 @@
|
||||
}
|
||||
|
||||
.definitionMarker {
|
||||
order: -1; /* keep the "N." marker on the LEFT though it follows content in DOM (#146) */
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
/* Right-align within the narrow column so the period sits next to the text
|
||||
|
||||
@@ -3,18 +3,26 @@ import { useTranslation } from "react-i18next";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for the bottom footnotes container. Renders a visual separator and a
|
||||
* localized heading, then the editable list of definitions via NodeViewContent.
|
||||
* NodeView for the bottom footnotes container: the editable list of definitions
|
||||
* (NodeViewContent) plus a visual separator + localized heading.
|
||||
*
|
||||
* #146: the editable NodeViewContent MUST be the FIRST child in the DOM. A
|
||||
* non-editable block rendered before it (the old separator + heading) makes the
|
||||
* browser's click hit-testing (posAtCoords → caretRangeFromPoint) miss the
|
||||
* contentDOM and snap the caret to the previous node (several lines above, into
|
||||
* the body). So content goes first; the heading is rendered AFTER it and lifted
|
||||
* back above visually with CSS flex `order` (the separator border lives on the
|
||||
* flex container itself).
|
||||
*/
|
||||
export default function FootnotesListView(_props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.list} contentEditable={false}>
|
||||
<div className={classes.listHeading}>{t("Footnotes")}</div>
|
||||
</div>
|
||||
<NodeViewWrapper className={classes.list}>
|
||||
<NodeViewContent />
|
||||
<div className={classes.listHeading} contentEditable={false}>
|
||||
{t("Footnotes")}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
.ProseMirror {
|
||||
.codeBlock {
|
||||
/* #146: flex column so the menu (rendered AFTER <pre> in the DOM, so the
|
||||
editable contentDOM is first) is lifted back above the code via `order`. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
border-radius: var(--mantine-radius-default);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
|
||||
Reference in New Issue
Block a user