From 56bdb2ca5b571710b4a053cedba513e5d209e391 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:09:22 +0100 Subject: [PATCH 1/5] Keep the research draft in flow at its insertion point The draft now sits where its reference will be inserted: before a block whose start the caret is at, otherwise after the top-level block holding the caret. An empty line it was opened on collapses under it, since the reference replaces that line, and the empty-document prompt hides while a draft is open. Previously a zero-offset caret attached the draft after the preceding block, so from inside a list it jumped above the whole list and after a heading it left a blank line below. In an empty document the prompt overlapped the draft. Co-Authored-By: Claude Opus 5.5 --- e2e/research-workspace.e2e.ts | 95 +++++++++++++++++++ packages/editor/src/styles.css | 14 +++ packages/editor/src/toolbar/research.test.tsx | 79 ++++++++++++--- packages/editor/src/toolbar/research.tsx | 75 ++++++++------- 4 files changed, 215 insertions(+), 48 deletions(-) diff --git a/e2e/research-workspace.e2e.ts b/e2e/research-workspace.e2e.ts index 25c727f04..5931d5931 100644 --- a/e2e/research-workspace.e2e.ts +++ b/e2e/research-workspace.e2e.ts @@ -279,3 +279,98 @@ test("the inline research draft scrolls with its anchor and stays inside the pro ); await expect(editor).toBeVisible(); }); + +/** Draft geometry against the visible blocks around it and the empty-document prompt. */ +async function draftFlow(page: Page) { + return await page.evaluate(() => { + let draft = document.querySelector(".plan-research-draft")! + .getBoundingClientRect(); + let editor = document.querySelector( + '[role="textbox"][aria-label="editable markdown"]', + )!; + let prompt = editor.parentElement!.querySelector( + ":scope > .plan-content:not([contenteditable])", + ); + let overlaps = [...editor.children] + .filter(block => !block.classList.contains("plan-research-draft-host")) + .map(block => ({ text: block.textContent ?? "", box: block.getBoundingClientRect() })) + .filter(({ box }) => + box.height > 0 && box.top < draft.bottom - 1 && box.bottom > draft.top + 1 + ) + .map(({ text }) => text); + let host = document.querySelector(".plan-research-draft-host")!; + let previous = host.previousElementSibling; + while (previous && previous.getBoundingClientRect().height === 0) { + previous = previous.previousElementSibling; + } + let next = host.nextElementSibling; + while (next && next.getBoundingClientRect().height === 0) next = next.nextElementSibling; + return { + overlaps, + promptVisible: !!prompt && prompt.getClientRects().length > 0, + follows: previous?.textContent ?? null, + precedes: next?.textContent ?? null, + }; + }); +} + +test("the research draft takes its line in document flow without overlapping anything", async ({ join }) => { + let page = await join("ana"); + let editor = content(page); + let prompt = page.getByText("Start writing, or ask Chopin to plan", { exact: true }); + await expect(prompt).toBeVisible(); + let composer = page.getByRole("region", { name: "Research question", exact: true }); + + await editor.click(); + await page.keyboard.type("/research"); + await page.keyboard.press("Enter"); + await expect(composer.getByRole("textbox", { name: "Research question", exact: true })) + .toBeFocused(); + // The empty-document prompt sat on top of the draft's own placeholder. + await expect(prompt).toBeHidden(); + expect(await draftFlow(page)).toEqual({ + overlaps: [], + promptVisible: false, + follows: null, + precedes: null, + }); + await page.keyboard.press("Escape"); + await expect(composer).toHaveCount(0); + await expect(prompt).toBeVisible(); + + await page.keyboard.type("Title"); + await page.keyboard.press("Enter"); + await page.keyboard.type("Opening paragraph."); + await page.keyboard.press("Enter"); + await page.keyboard.type("- one"); + await page.keyboard.press("Enter"); + await page.keyboard.type("two"); + await page.keyboard.press("Enter"); + await page.keyboard.type("/research"); + await page.keyboard.press("Enter"); + await expect(composer).toBeVisible(); + // From an empty list item the draft follows the list, not the block before it. + await expect.poll(() => draftFlow(page)).toEqual({ + overlaps: [], + promptVisible: false, + follows: "onetwo", + precedes: null, + }); + await page.keyboard.press("Escape"); + await expect(composer).toHaveCount(0); + + let opening = page.getByText("Opening paragraph.", { exact: true }); + let box = (await opening.boundingBox())!; + await opening.click({ position: { x: box.width - 1, y: box.height / 2 } }); + await page.keyboard.press("Enter"); + await page.keyboard.type("/research"); + await page.keyboard.press("Enter"); + await expect(composer).toBeVisible(); + // The empty line it was opened on collapses under it rather than trailing as a blank line. + await expect.poll(() => draftFlow(page)).toEqual({ + overlaps: [], + promptVisible: false, + follows: "Opening paragraph.", + precedes: "onetwo", + }); +}); diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 035c672bc..3e3be99e9 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -2232,6 +2232,20 @@ body:has(.plan-comment-sheet-popup[data-swiping]) margin-block-start: 0; } +/* A research draft takes the empty line it was opened on; the reference replaces that line. */ +.plan .plan-content > p[data-research-draft-anchor]:has(> br:only-child) { + block-size: 0; + margin-block: 0; + overflow: visible; +} + +/* The draft is content in progress, so the empty-document prompt would only overlap it. */ +.plan + .mdxeditor-root-contenteditable:has(.plan-research-draft-host) + > .plan-content:not([contenteditable]) { + display: none; +} + /* Settled lines read as a tight list; the line's own paragraph margin is dropped. */ .plan .plan-content :is([data-plan-questionnaire], [data-plan-decision]):has([data-card-settled]) { margin-block: 0.5rem; diff --git a/packages/editor/src/toolbar/research.test.tsx b/packages/editor/src/toolbar/research.test.tsx index 9ba27f7d0..dc1429b3c 100644 --- a/packages/editor/src/toolbar/research.test.tsx +++ b/packages/editor/src/toolbar/research.test.tsx @@ -1,15 +1,25 @@ import { describe, expect, it } from "bun:test"; +import { createHeadlessEditor } from "@lexical/headless"; +import { $createListItemNode, $createListNode, ListItemNode, ListNode } from "@lexical/list"; +import { $createHeadingNode, HeadingNode } from "@lexical/rich-text"; +import { $createResearchNode, ResearchNode } from "@chopin/dialect"; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $insertNodes, + $isTextNode, +} from "lexical"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { $draftAttachment } from "./research"; + +import type { ElementNode, LexicalNode } from "lexical"; + import type { ComponentType, PropsWithChildren } from "react"; type DraftLayerModule = { - attachmentBlock?: ( - resolved: Block, - offset: number, - previous: Block | undefined, - ) => Block; ResearchDraftShell?: ComponentType; retainDraftBlock?: (resolved: Block | undefined, previous: Block | undefined) => | Block @@ -50,16 +60,55 @@ describe("research draft layer", () => { expect(retain(undefined, previous)).toBe(previous); }); - it("attaches a zero-offset boundary after the preceding block", async () => { - let attach = (await draftLayer()).attachmentBlock; - expect(typeof attach).toBe("function"); - if (!attach) return; - let previous = { key: "previous" }; - let resolved = { key: "resolved" }; - - expect(attach(resolved, 0, previous)).toBe(previous); - expect(attach(resolved, 1, previous)).toBe(resolved); - expect(attach(resolved, 0, undefined)).toBe(resolved); + it("shows the draft where the reference will be inserted", () => { + // Heading, empty paragraph, paragraph, list of two items and an empty one. + let cases: [string, (blocks: ElementNode[]) => LexicalNode, number][] = [ + ["empty paragraph", blocks => blocks[1]!, 0], + ["start of a paragraph", blocks => blocks[2]!.getFirstDescendant()!, 0], + ["middle of a paragraph", blocks => blocks[2]!.getFirstDescendant()!, 2], + ["start of the first list item", blocks => blocks[3]!.getFirstDescendant()!, 0], + ["start of a later list item", blocks => blocks[3]!.getLastDescendant()!, 0], + ]; + for (let [label, at, offset] of cases) { + let editor = createHeadlessEditor({ + nodes: [HeadingNode, ListNode, ListItemNode, ResearchNode], + onError: error => { + throw error; + }, + }); + let expected: string | undefined; + let actual: string | undefined; + editor.update(() => { + let blocks: ElementNode[] = [ + $createHeadingNode("h1").append($createTextNode("Heading")), + $createParagraphNode(), + $createParagraphNode().append($createTextNode("Para")), + $createListNode("bullet").append( + $createListItemNode().append($createTextNode("one")), + $createListItemNode().append($createTextNode("two")), + ), + ]; + $getRoot().append(...blocks); + let node = at(blocks); + let attachment = $draftAttachment(node.getKey(), offset)!; + let index = blocks.findIndex(block => block.getKey() === attachment.key); + // The block the draft visibly follows. An empty anchor line collapses under the draft. + let after = attachment.placement === "before" || !blocks[index]!.getTextContentSize() + ? index - 1 + : index; + actual = blocks[after]?.getTextContent(); + if ($isTextNode(node)) node.select(offset, offset); + else node.selectStart(); + $insertNodes([$createResearchNode("r")]); + }, { discrete: true }); + editor.read(() => { + let reference = $getRoot().getChildren().find(child => child.getType() === "plan-research"); + let before = reference?.getPreviousSibling(); + expected = before?.getTextContent(); + }); + // A split block stays whole in the DOM, so the draft follows all of it. + expect({ label, follows: actual?.startsWith(expected!) }).toEqual({ label, follows: true }); + } }); it("explains an unresolved saved position without blocking submission", async () => { diff --git a/packages/editor/src/toolbar/research.tsx b/packages/editor/src/toolbar/research.tsx index 3b6ba7814..59859af39 100644 --- a/packages/editor/src/toolbar/research.tsx +++ b/packages/editor/src/toolbar/research.tsx @@ -36,7 +36,8 @@ import type { ResearchDraftStore } from "../research-draft"; import type { ResearchStore } from "../widget-options"; import type { DOMRectLike } from "./placement"; -type Attachment = { block: HTMLElement }; +type Placement = "before" | "after"; +type Attachment = { block: HTMLElement; placement: Placement }; export type OpenResearch = { anchor: DOMRectLike; @@ -120,9 +121,14 @@ export function beginResearchDraft( } /** The private surface participates in flow without becoming collaborative content. */ -export function attachDraft(block: HTMLElement, host: HTMLElement): () => void { +export function attachDraft( + block: HTMLElement, + host: HTMLElement, + placement: Placement = "after", +): () => void { block.dataset.researchDraftAnchor = ""; - block.after(host); + if (placement === "before") block.before(host); + else block.after(host); return () => { delete block.dataset.researchDraftAnchor; }; @@ -135,12 +141,23 @@ export function retainDraftBlock( return resolved ?? previous; } -export function attachmentBlock( - resolved: Block, +/** + * Where the draft shows relative to the top-level block holding a saved point, + * mirroring where `$insertNodes` will put the reference: before a block whose + * start it is, otherwise after it. An empty paragraph is replaced by the + * reference, so the draft follows it and CSS lets the draft take its line. + */ +export function $draftAttachment( + key: string, offset: number, - previous: Block | undefined, -): Block { - return offset === 0 && previous ? previous : resolved; +): { key: string; placement: Placement } | undefined { + let node = $getNodeByKey(key); + let block = node?.getTopLevelElement(); + if (!node || !block) return undefined; + let first = $isElementNode(block) ? block.getFirstDescendant() : null; + let atStart = offset === 0 && (node === block || first === node); + let empty = $isElementNode(block) && block.getTextContentSize() === 0; + return { key: block.getKey(), placement: atStart && !empty ? "before" : "after" }; } export const UNRESOLVED_DRAFT = "This research draft cannot yet be placed at its saved position."; @@ -175,8 +192,7 @@ function resolveAttachment( binding: Binding, position: Y.RelativePosition, ): Attachment | undefined { - let key: string | undefined; - let offset = 0; + let target: ReturnType; try { editor.getEditorState().read(() => { let resolved = $getAnchorAndFocusForUserState(binding, { @@ -187,26 +203,14 @@ function resolveAttachment( name: "", awarenessData: {}, }); - key = resolved.anchorKey || undefined; - offset = resolved.anchorOffset; + if (resolved.anchorKey) target = $draftAttachment(resolved.anchorKey, resolved.anchorOffset); }); } catch { return undefined; } - if (!key) return undefined; - let resolved = blockElement(editor, key); - let previous = resolved?.previousElementSibling as HTMLElement | null; - if (previous?.classList.contains("plan-research-draft-host")) { - previous = previous.previousElementSibling as HTMLElement | null; - } - let block = resolved - ? attachmentBlock( - resolved, - offset, - previous ?? undefined, - ) - : undefined; - return block ? { block } : undefined; + if (!target) return undefined; + let block = blockElement(editor, target.key); + return block ? { block, placement: target.placement } : undefined; } function currentPosition( @@ -236,7 +240,7 @@ export function ResearchComposerSurface( let read = useCallback(() => drafts.get(), [drafts]); let draft = useSyncExternalStore(subscribe, read, read); let surface = useRef(null); - let attached = useRef<{ block: HTMLElement; detach: () => void } | undefined>(undefined); + let attached = useRef void } | undefined>(undefined); let slot = useRef(undefined); let [host, setHost] = useState(); let [unresolved, setUnresolved] = useState(false); @@ -271,17 +275,22 @@ export function ResearchComposerSurface( let resolved = binding && draft.position ? resolveAttachment(editor, binding, draft.position) : undefined; - let previous = attached.current?.block; - if (previous && !previous.isConnected) previous = undefined; - let block = retainDraftBlock(resolved?.block, previous); + let previous = attached.current; + if (previous && !previous.block.isConnected) previous = undefined; + let next = retainDraftBlock(resolved, previous); setUnresolved(!!binding && !!draft.position && !resolved); - if (!block) { + if (!next) { if (host.parentElement !== root) root.append(host); return; } - if (attached.current?.block === block && block.nextElementSibling === host) return; + let { block, placement } = next; + let sibling = placement === "before" ? block.previousElementSibling : block.nextElementSibling; + if ( + attached.current?.block === block && attached.current.placement === placement + && sibling === host + ) return; detach(); - attached.current = { block, detach: attachDraft(block, host) }; + attached.current = { block, placement, detach: attachDraft(block, host, placement) }; }, [binding, draft, editor, detach]); useLayoutEffect(() => { From 710a454a609123828274560f3c3b11ca2b6479e2 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:09:39 +0100 Subject: [PATCH 2/5] lane: record research draft placement memory Co-Authored-By: Claude Opus 5.5 --- ...QJHG78EBFAZ25KVAXT704K-the-research-draft-host-mirr.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .lane/memory/packages/editor/src/toolbar/research.tsx/01M4QJHG78EBFAZ25KVAXT704K-the-research-draft-host-mirr.md diff --git a/.lane/memory/packages/editor/src/toolbar/research.tsx/01M4QJHG78EBFAZ25KVAXT704K-the-research-draft-host-mirr.md b/.lane/memory/packages/editor/src/toolbar/research.tsx/01M4QJHG78EBFAZ25KVAXT704K-the-research-draft-host-mirr.md new file mode 100644 index 000000000..c83290760 --- /dev/null +++ b/.lane/memory/packages/editor/src/toolbar/research.tsx/01M4QJHG78EBFAZ25KVAXT704K-the-research-draft-host-mirr.md @@ -0,0 +1,8 @@ +--- +id: 01M4QJHG78EBFAZ25KVAXT704K +anchor: function $draftAttachment +created: 2026-10-12T08:09:22Z +norm: '1' +--- + +The research draft host mirrors $insertNodes: before a block when the caret is at its start, else after the top-level block. An empty paragraph is replaced by the reference, so CSS collapses the empty anchor line under the draft (styles.css p[data-research-draft-anchor]). From 666d80dfffdf4d38e449a053e0d6edd2b38e7ad2 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:50:16 +0100 Subject: [PATCH 3/5] Insert research after the whole table or list, where the draft shows Address review of the research draft placement: - A reference placed from a table cell or list item now goes after the top-level table or list, as the slash menu does, instead of inside the cell (cells allow inline text only). The walk to the root child skips past the table cell's shadow root, which `getTopLevelElement` stops at. - The empty anchor line collapses for empty headings and quotes too. - The prompt-hiding rule only matches the editor's own draft host. - The placement test asserts exact replacement for empty lines and covers a table cell, an empty heading, and an empty quote. Co-Authored-By: Claude Opus 5.5 --- packages/editor/src/styles.css | 6 +- packages/editor/src/toolbar/research.test.tsx | 79 +++++++++++++------ packages/editor/src/toolbar/research.tsx | 48 +++++++++-- 3 files changed, 97 insertions(+), 36 deletions(-) diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 3e3be99e9..ac95c5b64 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -2233,7 +2233,9 @@ body:has(.plan-comment-sheet-popup[data-swiping]) } /* A research draft takes the empty line it was opened on; the reference replaces that line. */ -.plan .plan-content > p[data-research-draft-anchor]:has(> br:only-child) { +.plan + .plan-content + > :is(p, h1, h2, h3, h4, h5, h6, blockquote)[data-research-draft-anchor]:has(> br:only-child) { block-size: 0; margin-block: 0; overflow: visible; @@ -2241,7 +2243,7 @@ body:has(.plan-comment-sheet-popup[data-swiping]) /* The draft is content in progress, so the empty-document prompt would only overlap it. */ .plan - .mdxeditor-root-contenteditable:has(.plan-research-draft-host) + .mdxeditor-root-contenteditable:has(> .plan-content > .plan-research-draft-host) > .plan-content:not([contenteditable]) { display: none; } diff --git a/packages/editor/src/toolbar/research.test.tsx b/packages/editor/src/toolbar/research.test.tsx index dc1429b3c..1196161d6 100644 --- a/packages/editor/src/toolbar/research.test.tsx +++ b/packages/editor/src/toolbar/research.test.tsx @@ -1,22 +1,23 @@ import { describe, expect, it } from "bun:test"; import { createHeadlessEditor } from "@lexical/headless"; import { $createListItemNode, $createListNode, ListItemNode, ListNode } from "@lexical/list"; -import { $createHeadingNode, HeadingNode } from "@lexical/rich-text"; -import { $createResearchNode, ResearchNode } from "@chopin/dialect"; +import { $createHeadingNode, $createQuoteNode, HeadingNode, QuoteNode } from "@lexical/rich-text"; import { - $createParagraphNode, - $createTextNode, - $getRoot, - $insertNodes, - $isTextNode, -} from "lexical"; + $createTableCellNode, + $createTableNode, + $createTableRowNode, + TableCellNode, + TableNode, + TableRowNode, +} from "@lexical/table"; +import { $createResearchNode, ResearchNode } from "@chopin/dialect"; +import { $createParagraphNode, $createTextNode, $getRoot, $isTextNode } from "lexical"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { $draftAttachment } from "./research"; +import { $draftAttachment, $insertResearch } from "./research"; import type { ElementNode, LexicalNode } from "lexical"; - import type { ComponentType, PropsWithChildren } from "react"; type DraftLayerModule = { @@ -61,24 +62,38 @@ describe("research draft layer", () => { }); it("shows the draft where the reference will be inserted", () => { - // Heading, empty paragraph, paragraph, list of two items and an empty one. - let cases: [string, (blocks: ElementNode[]) => LexicalNode, number][] = [ + // "split" marks a point inside a block: the reference splits it, the draft follows all of it. + let cases: [string, (blocks: ElementNode[]) => LexicalNode, number, "split"?][] = [ ["empty paragraph", blocks => blocks[1]!, 0], + ["empty heading", blocks => blocks[5]!, 0], + ["empty quote", blocks => blocks[6]!, 0], ["start of a paragraph", blocks => blocks[2]!.getFirstDescendant()!, 0], - ["middle of a paragraph", blocks => blocks[2]!.getFirstDescendant()!, 2], + ["middle of a paragraph", blocks => blocks[2]!.getFirstDescendant()!, 2, "split"], ["start of the first list item", blocks => blocks[3]!.getFirstDescendant()!, 0], ["start of a later list item", blocks => blocks[3]!.getLastDescendant()!, 0], + ["table cell", blocks => blocks[4]!.getLastDescendant()!, 1], ]; - for (let [label, at, offset] of cases) { + for (let [label, at, offset, split] of cases) { let editor = createHeadlessEditor({ - nodes: [HeadingNode, ListNode, ListItemNode, ResearchNode], + nodes: [ + HeadingNode, + QuoteNode, + ListNode, + ListItemNode, + TableNode, + TableRowNode, + TableCellNode, + ResearchNode, + ], onError: error => { throw error; }, }); - let expected: string | undefined; - let actual: string | undefined; + let follows: string | undefined; + let inserted: { previous?: string; parent?: string } = {}; editor.update(() => { + let cell = (text: string) => + $createTableCellNode().append($createParagraphNode().append($createTextNode(text))); let blocks: ElementNode[] = [ $createHeadingNode("h1").append($createTextNode("Heading")), $createParagraphNode(), @@ -87,27 +102,39 @@ describe("research draft layer", () => { $createListItemNode().append($createTextNode("one")), $createListItemNode().append($createTextNode("two")), ), + $createTableNode().append($createTableRowNode().append(cell("A"), cell("B"))), + $createHeadingNode("h2"), + $createQuoteNode(), ]; $getRoot().append(...blocks); let node = at(blocks); let attachment = $draftAttachment(node.getKey(), offset)!; - let index = blocks.findIndex(block => block.getKey() === attachment.key); + let children = $getRoot().getChildren(); + let index = children.findIndex(block => block.getKey() === attachment.key); // The block the draft visibly follows. An empty anchor line collapses under the draft. - let after = attachment.placement === "before" || !blocks[index]!.getTextContentSize() + let after = attachment.placement === "before" || !children[index]!.getTextContentSize() ? index - 1 : index; - actual = blocks[after]?.getTextContent(); + follows = children[after]?.getTextContent(); if ($isTextNode(node)) node.select(offset, offset); else node.selectStart(); - $insertNodes([$createResearchNode("r")]); + $insertResearch($createResearchNode("r")); }, { discrete: true }); editor.read(() => { - let reference = $getRoot().getChildren().find(child => child.getType() === "plan-research"); - let before = reference?.getPreviousSibling(); - expected = before?.getTextContent(); + let reference = [...editor.getEditorState()._nodeMap.values()] + .find(node => node.getType() === "plan-research"); + inserted = { + previous: reference?.getPreviousSibling()?.getTextContent(), + parent: reference?.getParent()?.getType(), + }; }); - // A split block stays whole in the DOM, so the draft follows all of it. - expect({ label, follows: actual?.startsWith(expected!) }).toEqual({ label, follows: true }); + expect({ label, parent: inserted.parent }).toEqual({ label, parent: "root" }); + if (split) { + expect({ label, follows: follows?.startsWith(inserted.previous!) }) + .toEqual({ label, follows: true }); + } else { + expect({ label, follows }).toEqual({ label, follows: inserted.previous }); + } } }); diff --git a/packages/editor/src/toolbar/research.tsx b/packages/editor/src/toolbar/research.tsx index 59859af39..fa309a98b 100644 --- a/packages/editor/src/toolbar/research.tsx +++ b/packages/editor/src/toolbar/research.tsx @@ -16,6 +16,7 @@ import { $insertNodes, $isElementNode, $isRangeSelection, + $isRootNode, $setSelection, COMMAND_PRIORITY_LOW, createCommand, @@ -30,7 +31,7 @@ import { ResearchComposer } from "../widgets/research"; import { $relativePosition } from "./position"; import type { Binding } from "@lexical/yjs"; -import type { LexicalEditor } from "lexical"; +import type { LexicalEditor, LexicalNode } from "lexical"; import type { ReactNode, Ref } from "react"; import type { ResearchDraftStore } from "../research-draft"; import type { ResearchStore } from "../widget-options"; @@ -88,7 +89,7 @@ export function insertResearchReference( ); $setSelection(selection); let reference = $createResearchNode(id); - $insertNodes([reference]); + $insertResearch(reference); key = reference.getKey(); }, { discrete: true }); } catch { @@ -141,21 +142,52 @@ export function retainDraftBlock( return resolved ?? previous; } +/** The top-level block holding a node, and whether the node sits deeper, in a list item or cell. */ +function $topLevel(node: LexicalNode): { block: LexicalNode; nested: boolean } | undefined { + // Not `getTopLevelElement`: a table cell is a shadow root, so it stops inside the table. + let block: LexicalNode = node; + for ( + let parent = block.getParent(); + parent && !$isRootNode(parent); + parent = parent.getParent() + ) { + block = parent; + } + if (!$isRootNode(block.getParent())) return undefined; + let host: LexicalNode | null = node; + while (host && !($isElementNode(host) && !host.isInline())) host = host.getParent(); + return { block, nested: host !== block }; +} + +/** + * Insert a reference at the selection. Like the slash menu, a point in a list + * item or table cell puts it after the whole list or table: a cell holds only + * inline text in the dialect, and a list item cannot hold the block. + */ +export function $insertResearch(reference: LexicalNode): void { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + let target = $topLevel(selection.anchor.getNode()); + if (target?.nested) target.block.insertAfter(reference); + else $insertNodes([reference]); +} + /** * Where the draft shows relative to the top-level block holding a saved point, - * mirroring where `$insertNodes` will put the reference: before a block whose - * start it is, otherwise after it. An empty paragraph is replaced by the - * reference, so the draft follows it and CSS lets the draft take its line. + * matching `$insertResearch`: before a block whose start it is, otherwise after + * it. An empty block is replaced by the reference, so the draft follows it and + * CSS lets the draft take its line. */ export function $draftAttachment( key: string, offset: number, ): { key: string; placement: Placement } | undefined { let node = $getNodeByKey(key); - let block = node?.getTopLevelElement(); - if (!node || !block) return undefined; + let target = node ? $topLevel(node) : undefined; + if (!node || !target) return undefined; + let { block, nested } = target; let first = $isElementNode(block) ? block.getFirstDescendant() : null; - let atStart = offset === 0 && (node === block || first === node); + let atStart = !nested && offset === 0 && (node === block || first === node); let empty = $isElementNode(block) && block.getTextContentSize() === 0; return { key: block.getKey(), placement: atStart && !empty ? "before" : "after" }; } From f125edb53abc6b95dd9b335b8bb21f85d2dc17a0 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:09:50 +0100 Subject: [PATCH 4/5] Match the editor's own draft host without assuming its wrapper depth The editable root is not a direct child of MDXEditor's wrapper, so the child-combinator form never matched and the empty-document prompt stayed visible. Co-Authored-By: Claude Opus 5.5 --- packages/editor/src/styles.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index ac95c5b64..e22b0f4a5 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -2243,7 +2243,8 @@ body:has(.plan-comment-sheet-popup[data-swiping]) /* The draft is content in progress, so the empty-document prompt would only overlap it. */ .plan - .mdxeditor-root-contenteditable:has(> .plan-content > .plan-research-draft-host) + .mdxeditor-root-contenteditable:has(.plan-content[contenteditable="true"] + > .plan-research-draft-host) > .plan-content:not([contenteditable]) { display: none; } From 6dc4cff388c6cf37340eb0aa73f52c93321cd52b Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:35:37 +0100 Subject: [PATCH 5/5] Keep the research draft geometry tests independent of caret timing and header height - The draft-flow test waits for the click's selection to reach the document before pressing Enter; otherwise Enter can act on the caret left in the list. - The prose-column test accepts a draft placed before its anchor. Under the taller two-bar header, its centre click can land at the start of a passage, where the draft now correctly sits before that passage. Co-Authored-By: Claude Opus 5.5 --- e2e/research-child-recovery.e2e.ts | 7 ++++--- e2e/research-workspace.e2e.ts | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/e2e/research-child-recovery.e2e.ts b/e2e/research-child-recovery.e2e.ts index c43e4049c..8fe509d67 100644 --- a/e2e/research-child-recovery.e2e.ts +++ b/e2e/research-child-recovery.e2e.ts @@ -319,21 +319,22 @@ test( let paragraph = document.querySelector("[data-research-draft-anchor]")!; let prose = paragraph.getBoundingClientRect(); let draft = element.getBoundingClientRect(); + // A caret at the start of a passage puts the draft before it, otherwise after. + let follows = paragraph.nextElementSibling?.contains(element) === true; return { position: getComputedStyle(element).position, proseWidth: prose.width, width: draft.width, left: draft.left, proseLeft: prose.left, - top: draft.top, - proseBottom: prose.bottom, + clear: follows ? draft.top - prose.bottom : prose.top - draft.bottom, }; }); expect(layout.position).toBe("static"); expect(layout.proseWidth).toBeCloseTo(650, 0); expect(layout.width).toBeCloseTo(layout.proseWidth, 0); expect(layout.left).toBeCloseTo(layout.proseLeft, 0); - expect(layout.top).toBeGreaterThanOrEqual(layout.proseBottom); + expect(layout.clear).toBeGreaterThanOrEqual(0); await opened.screenshot({ path: testInfo.outputPath("research-draft-layout.png") }); let before = await composer.boundingBox(); let travelled = await editor.evaluate(element => { diff --git a/e2e/research-workspace.e2e.ts b/e2e/research-workspace.e2e.ts index 5931d5931..a6f652f8f 100644 --- a/e2e/research-workspace.e2e.ts +++ b/e2e/research-workspace.e2e.ts @@ -362,6 +362,9 @@ test("the research draft takes its line in document flow without overlapping any let opening = page.getByText("Opening paragraph.", { exact: true }); let box = (await opening.boundingBox())!; await opening.click({ position: { x: box.width - 1, y: box.height / 2 } }); + // Enter straight after a click can reach Lexical before the click's selection does. + await expect.poll(() => page.evaluate(() => getSelection()?.anchorNode?.textContent)) + .toBe("Opening paragraph."); await page.keyboard.press("Enter"); await page.keyboard.type("/research"); await page.keyboard.press("Enter");