Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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]).
7 changes: 4 additions & 3 deletions e2e/research-child-recovery.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,21 +319,22 @@ test(
let paragraph = document.querySelector<HTMLElement>("[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 => {
Expand Down
98 changes: 98 additions & 0 deletions e2e/research-workspace.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,101 @@ 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<HTMLElement>(".plan-research-draft")!
.getBoundingClientRect();
let editor = document.querySelector<HTMLElement>(
'[role="textbox"][aria-label="editable markdown"]',
)!;
let prompt = editor.parentElement!.querySelector<HTMLElement>(
":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 } });
// 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");
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",
});
});
17 changes: 17 additions & 0 deletions packages/editor/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2232,6 +2232,23 @@ 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
> :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;
}

/* The draft is content in progress, so the empty-document prompt would only overlap it. */
.plan
.mdxeditor-root-contenteditable:has(.plan-content[contenteditable="true"]
> .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;
Expand Down
106 changes: 91 additions & 15 deletions packages/editor/src/toolbar/research.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import { describe, expect, it } from "bun:test";
import { createHeadlessEditor } from "@lexical/headless";
import { $createListItemNode, $createListNode, ListItemNode, ListNode } from "@lexical/list";
import { $createHeadingNode, $createQuoteNode, HeadingNode, QuoteNode } from "@lexical/rich-text";
import {
$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, $insertResearch } from "./research";

import type { ElementNode, LexicalNode } from "lexical";
import type { ComponentType, PropsWithChildren } from "react";

type DraftLayerModule = {
attachmentBlock?: <Block>(
resolved: Block,
offset: number,
previous: Block | undefined,
) => Block;
ResearchDraftShell?: ComponentType<PropsWithChildren>;
retainDraftBlock?: <Block>(resolved: Block | undefined, previous: Block | undefined) =>
| Block
Expand Down Expand Up @@ -50,16 +61,81 @@ 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", () => {
// "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, "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, split] of cases) {
let editor = createHeadlessEditor({
nodes: [
HeadingNode,
QuoteNode,
ListNode,
ListItemNode,
TableNode,
TableRowNode,
TableCellNode,
ResearchNode,
],
onError: error => {
throw error;
},
});
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(),
$createParagraphNode().append($createTextNode("Para")),
$createListNode("bullet").append(
$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 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" || !children[index]!.getTextContentSize()
? index - 1
: index;
follows = children[after]?.getTextContent();
if ($isTextNode(node)) node.select(offset, offset);
else node.selectStart();
$insertResearch($createResearchNode("r"));
}, { discrete: true });
editor.read(() => {
let reference = [...editor.getEditorState()._nodeMap.values()]
.find(node => node.getType() === "plan-research");
inserted = {
previous: reference?.getPreviousSibling()?.getTextContent(),
parent: reference?.getParent()?.getType(),
};
});
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 });
}
}
});

it("explains an unresolved saved position without blocking submission", async () => {
Expand Down
Loading
Loading