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>
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|
import { ActionIcon, Group, Select, Tooltip } from "@mantine/core";
|
|
import { CopyButton } from "@/components/common/copy-button";
|
|
import { useEffect, useState } from "react";
|
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
|
import classes from "./code-block.module.css";
|
|
import React from "react";
|
|
import { Suspense } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
const MermaidView = React.lazy(
|
|
() => import("@/features/editor/components/code-block/mermaid-view.tsx"),
|
|
);
|
|
|
|
export default function CodeBlockView(props: NodeViewProps) {
|
|
const { t } = useTranslation();
|
|
const { node, updateAttributes, extension, editor, getPos } = props;
|
|
const { language } = node.attrs;
|
|
const [languageValue, setLanguageValue] = useState<string | null>(
|
|
language || null,
|
|
);
|
|
const [isSelected, setIsSelected] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const updateSelection = () => {
|
|
const { state } = editor;
|
|
const { from, to } = state.selection;
|
|
// Check if the selection intersects with the node's range
|
|
const isNodeSelected =
|
|
(from >= getPos() && from < getPos() + node.nodeSize) ||
|
|
(to > getPos() && to <= getPos() + node.nodeSize);
|
|
setIsSelected(isNodeSelected);
|
|
};
|
|
|
|
editor.on("selectionUpdate", updateSelection);
|
|
return () => {
|
|
editor.off("selectionUpdate", updateSelection);
|
|
};
|
|
}, [editor, getPos(), node.nodeSize]);
|
|
|
|
function changeLanguage(language: string) {
|
|
setLanguageValue(language);
|
|
updateAttributes({
|
|
language: language,
|
|
});
|
|
}
|
|
|
|
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}
|
|
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}
|
|
/>
|
|
|
|
<CopyButton value={node?.textContent} timeout={2000}>
|
|
{({ copied, copy }) => (
|
|
<Tooltip
|
|
label={copied ? t("Copied") : t("Copy")}
|
|
withArrow
|
|
position="right"
|
|
>
|
|
<ActionIcon
|
|
color={copied ? "teal" : "gray"}
|
|
variant="subtle"
|
|
onClick={copy}
|
|
>
|
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</CopyButton>
|
|
</Group>
|
|
|
|
{language === "mermaid" && (
|
|
<Suspense fallback={null}>
|
|
<MermaidView props={props} />
|
|
</Suspense>
|
|
)}
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|