Skip to content

Commit b655c10

Browse files
committed
fix(editor): focus the body editor and place the caret at its end when clicking below the last line
The Body field's outer grid cell is stretched to a fixed min-height by the dialog, but the contentEditable only grows to fit its own content, leaving empty space below the last line that silently ate clicks instead of focusing the editor. ClickBelowContentPlugin catches a click on the scroll wrapper itself and forces focus + caret to the document's end, matching what clicking below a plain <textarea>'s last line does. Also gives the wrapper a text cursor so it previews as clickable. Disabled whenever the body actually overflows the field, since a scrollbar then covers that same wrapper and isn't a distinct DOM node — clicking it would otherwise be mistaken for the same gesture and hijack the user's scroll.
1 parent 3c2aed5 commit b655c10

4 files changed

Lines changed: 192 additions & 1 deletion

File tree

src/components/molecules/MarkdownEditor/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ WYSIWYG Markdown editor (Lexical) for the task body. **Uncontrolled** — seeded
1515
One-line summaries — each plugin file's header comment owns the why-this-design rationale.
1616

1717
- **`LinkOpenPlugin`** — click a link → `onOpenLink(url)` to the system browser.
18+
- **`ClickBelowContentPlugin`** — a click on the scroll wrapper itself (the empty space below the last line, when the field's dialog-imposed `min-h-[...]` outgrows the actual content) focuses the editor with the caret forced to the document's end, instead of silently missing — the contentEditable only grows to fit its content, so that space belongs to the wrapper, not it. Disabled whenever the wrapper's content actually overflows (`scrollHeight > clientHeight`): a real scrollbar then covers that same wrapper, isn't a distinct DOM node, and clicking it is otherwise indistinguishable from a genuine dead-space click — but the dead-space gap this plugin exists for can only occur in the first place when nothing overflows, so the two cases never overlap.
1819
- **`ListTabIndentationPlugin`** — Tab / Shift+Tab indent inside list items.
1920
- **`ListExitPlugin`** — Backspace at list-item start exits the list instead of folding into the previous line; Ctrl+A across a leading list clears the doc (snapshot-gated so word-select doesn't trigger).
2021
- **`NoListInTablePlugin`** — safety net that unwraps any `ListNode` that slips into a `TableCellNode` via a non-transformer path (raw command, paste of pre-built nodes).
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, test } from "vitest";
2+
import { render } from "vitest-browser-react";
3+
4+
import "@/style.css";
5+
6+
import { MarkdownEditor } from "./MarkdownEditor";
7+
8+
// Regression test for a real bug: the dialog stretches the field's outer
9+
// grid cell to a fixed min-height (`min-h-[20rem]`), but the contentEditable
10+
// itself only grows to fit its own short content — clicking the empty space
11+
// between the last line and the field's visible bottom edge used to hit the
12+
// scroll wrapper div and silently do nothing (no focus, no caret). This
13+
// mounts the actual production `MarkdownEditor` (not the `renderTestEditor`
14+
// harness, which doesn't reproduce the wrapper DOM — see `MarkdownEditor.spec.tsx`)
15+
// with a short body inside the same fixed-height wrapper the real dialogs use.
16+
describe("MarkdownEditor (click below content)", () => {
17+
test("clicking the empty space below the last line focuses the editor with the caret at the document end", async () => {
18+
await render(
19+
<MarkdownEditor
20+
initialValue="hello"
21+
onChange={() => {}}
22+
onOpenLink={() => {}}
23+
className="h-[20rem]"
24+
/>,
25+
);
26+
27+
const editable = document.querySelector('[contenteditable="true"]');
28+
if (editable == null) throw new Error("contentEditable not found");
29+
const wrapper = editable.parentElement;
30+
if (wrapper == null) throw new Error("scroll wrapper not found");
31+
32+
// Sanity-check the bug's actual precondition: the wrapper is taller than
33+
// the content it holds, so there really is dead space below the text.
34+
expect(wrapper.getBoundingClientRect().height).toBeGreaterThan(
35+
editable.getBoundingClientRect().height,
36+
);
37+
38+
expect(document.activeElement).not.toBe(editable);
39+
40+
wrapper.dispatchEvent(new MouseEvent("click", { bubbles: true }));
41+
42+
expect(document.activeElement).toBe(editable);
43+
const selection = document.getSelection();
44+
expect(selection?.isCollapsed).toBe(true);
45+
expect(selection?.anchorNode?.textContent).toBe("hello");
46+
expect(selection?.anchorOffset).toBe("hello".length);
47+
});
48+
49+
test("a click that lands on the contentEditable itself (not the wrapper) is left alone", async () => {
50+
await render(
51+
<MarkdownEditor
52+
initialValue="hello"
53+
onChange={() => {}}
54+
onOpenLink={() => {}}
55+
className="h-[20rem]"
56+
/>,
57+
);
58+
59+
const editable = document.querySelector('[contenteditable="true"]');
60+
if (editable == null) throw new Error("contentEditable not found");
61+
62+
editable.dispatchEvent(new MouseEvent("click", { bubbles: true }));
63+
64+
// Nothing forces focus here — a real click on the contentEditable is
65+
// handled entirely by native browser behavior, so the plugin must not
66+
// double-handle it (its handler only reacts when `event.target` is the
67+
// wrapper itself).
68+
expect(document.activeElement).not.toBe(editable);
69+
});
70+
71+
test("a body long enough to overflow the field leaves click-to-focus disabled, so a scrollbar interaction can't be mistaken for one", async () => {
72+
await render(
73+
<MarkdownEditor
74+
initialValue={Array.from({ length: 60 }, (_, i) => `line ${i}`).join("\n\n")}
75+
onChange={() => {}}
76+
onOpenLink={() => {}}
77+
className="h-[10rem]"
78+
/>,
79+
);
80+
81+
const editable = document.querySelector('[contenteditable="true"]');
82+
if (editable == null) throw new Error("contentEditable not found");
83+
const wrapper = editable.parentElement;
84+
if (wrapper == null) throw new Error("scroll wrapper not found");
85+
86+
// Sanity-check the precondition this test cares about: the body actually
87+
// overflows, so a real scrollbar is showing (an overlay scrollbar isn't a
88+
// distinct DOM node and reserves no layout space of its own to click-test
89+
// against, so a click on it is indistinguishable, by target OR position,
90+
// from a click on the wrapper's dead space — see the plugin's comment).
91+
expect(wrapper.scrollHeight).toBeGreaterThan(wrapper.clientHeight);
92+
93+
wrapper.dispatchEvent(new MouseEvent("click", { bubbles: true }));
94+
95+
expect(document.activeElement).not.toBe(editable);
96+
});
97+
98+
test("the scroll wrapper previews as a text cursor, matching its click-to-type behavior", async () => {
99+
await render(
100+
<MarkdownEditor
101+
initialValue="hello"
102+
onChange={() => {}}
103+
onOpenLink={() => {}}
104+
className="h-[20rem]"
105+
/>,
106+
);
107+
108+
const editable = document.querySelector('[contenteditable="true"]');
109+
if (editable == null) throw new Error("contentEditable not found");
110+
const wrapper = editable.parentElement;
111+
if (wrapper == null) throw new Error("scroll wrapper not found");
112+
113+
expect(getComputedStyle(wrapper).cursor).toBe("text");
114+
});
115+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
2+
import { $getRoot } from "lexical";
3+
import { useEffect } from "react";
4+
5+
// The Body field's outer grid cell is stretched to a fixed min-height by the
6+
// dialog (`min-h-[20rem]` in TaskDetailDialog.tsx / CreateTaskDialog.tsx),
7+
// but the contentEditable itself only grows to fit its own content — a short
8+
// body leaves empty space between the last line and the field's visible
9+
// bottom edge that belongs to the scroll wrapper (the `min-h-0
10+
// overflow-y-auto` div in MarkdownEditor.tsx), not the contentEditable, and
11+
// silently swallows clicks there. This plugin catches a click that lands on
12+
// that wrapper itself — never on a descendant, since a click that lands
13+
// inside the contentEditable's own box (including the empty space below its
14+
// last line, as long as that space is still part of the box) is already
15+
// handled natively — and explicitly focuses the editor with the caret
16+
// forced to the very end of the document, matching what clicking below the
17+
// last line of a plain <textarea> does.
18+
export function ClickBelowContentPlugin(): null {
19+
const [editor] = useLexicalComposerContext();
20+
21+
useEffect(() => {
22+
return editor.registerRootListener((rootElement) => {
23+
if (rootElement === null) return;
24+
const wrapper = rootElement.parentElement;
25+
if (wrapper === null) return;
26+
27+
const handleClick = (event: MouseEvent) => {
28+
if (event.target !== wrapper) return;
29+
// The wrapper only has dead space below the content — this plugin's
30+
// whole reason to exist — when the content FITS without scrolling.
31+
// Once it overflows, the visible viewport is always fully covered by
32+
// content edge to edge (even scrolled to the very bottom), and a
33+
// real scrollbar appears. A scrollbar isn't a distinct DOM node, so
34+
// dragging/clicking it (to scroll and re-read an overflowing body —
35+
// the exact case this wrapper's `overflow-y-auto` exists for) also
36+
// fires with `event.target === wrapper`, indistinguishable from a
37+
// genuine dead-space click by target alone — and, for an
38+
// overlay-style scrollbar, not distinguishable by click position
39+
// either, since it reserves no layout space of its own to test
40+
// against. Bailing whenever the content overflows sidesteps that
41+
// ambiguity entirely instead of trying to geometrically guess where
42+
// the scrollbar sits.
43+
if (wrapper.scrollHeight > wrapper.clientHeight) return;
44+
rootElement.focus();
45+
// `discrete: true` commits synchronously, inside this same click
46+
// handler call — without it the update lands in the next microtask,
47+
// racing whatever selection the browser's own native `focus()`
48+
// placement (or Lexical's selectionchange sync of it) produces, and
49+
// `selectEnd()` can lose that race and never visibly apply.
50+
editor.update(
51+
() => {
52+
$getRoot().selectEnd();
53+
},
54+
{ discrete: true },
55+
);
56+
};
57+
58+
wrapper.addEventListener("click", handleClick);
59+
return () => wrapper.removeEventListener("click", handleClick);
60+
});
61+
}, [editor]);
62+
63+
return null;
64+
}

src/components/molecules/MarkdownEditor/MarkdownEditor.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { BoundaryStrictFormatPlugin } from "./BoundaryStrictFormatPlugin";
2525
import { CheckListIndentPlugin } from "./CheckListIndentPlugin";
2626
import { CheckListOutdentPlugin } from "./CheckListOutdentPlugin";
2727
import { CheckListShortcutPlugin } from "./CheckListShortcutPlugin";
28+
import { ClickBelowContentPlugin } from "./ClickBelowContentPlugin";
2829
import { CodeBlockEscapePlugin } from "./CodeBlockEscapePlugin";
2930
import { $highlightAllCodeBlocks, CodeBlockHighlightPlugin } from "./CodeBlockHighlightPlugin";
3031
import { CorkCodeNode } from "./CorkCodeNode";
@@ -362,7 +363,11 @@ export const MarkdownEditor = forwardRef<HTMLDivElement, MarkdownEditorProps>(
362363
<LexicalComposer initialConfig={buildInitialConfig(initialValue)}>
363364
<RichTextPlugin
364365
contentEditable={
365-
<div className="min-h-0 overflow-y-auto">
366+
// `cursor-text` so the whole scroll wrapper (not just the
367+
// contentEditable's own content-sized box) previews as
368+
// clickable-to-type — matching ClickBelowContentPlugin's actual
369+
// click behavior for the empty space below the last line.
370+
<div className="min-h-0 cursor-text overflow-y-auto">
366371
<ContentEditable
367372
ref={ref}
368373
ariaLabel={ariaLabel}
@@ -383,6 +388,12 @@ export const MarkdownEditor = forwardRef<HTMLDivElement, MarkdownEditorProps>(
383388
ErrorBoundary={LexicalErrorBoundary}
384389
/>
385390
<HistoryPlugin />
391+
{/* Clicking the empty space below the last line (the field's outer
392+
grid cell is stretched to a fixed min-height by the dialog, but
393+
the contentEditable itself only grows to fit its content) focuses
394+
the editor with the caret forced to the document's end, instead
395+
of silently missing. */}
396+
<ClickBelowContentPlugin />
386397
{/* Block / link shortcuts (headings, lists, tables, horizontal rules,
387398
links, etc.) — the upstream MarkdownShortcutPlugin handles these
388399
correctly. Text-format transformers (**bold**, *italic*, ==hl==,

0 commit comments

Comments
 (0)