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]). 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 25c727f04..a6f652f8f 100644 --- a/e2e/research-workspace.e2e.ts +++ b/e2e/research-workspace.e2e.ts @@ -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(".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 } }); + // 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", + }); +}); diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 035c672bc..e22b0f4a5 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -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; diff --git a/packages/editor/src/toolbar/research.test.tsx b/packages/editor/src/toolbar/research.test.tsx index 9ba27f7d0..1196161d6 100644 --- a/packages/editor/src/toolbar/research.test.tsx +++ b/packages/editor/src/toolbar/research.test.tsx @@ -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?: ( - resolved: Block, - offset: number, - previous: Block | undefined, - ) => Block; ResearchDraftShell?: ComponentType; retainDraftBlock?: (resolved: Block | undefined, previous: Block | undefined) => | Block @@ -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 () => { diff --git a/packages/editor/src/toolbar/research.tsx b/packages/editor/src/toolbar/research.tsx index 3b6ba7814..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,13 +31,14 @@ 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"; import type { DOMRectLike } from "./placement"; -type Attachment = { block: HTMLElement }; +type Placement = "before" | "after"; +type Attachment = { block: HTMLElement; placement: Placement }; export type OpenResearch = { anchor: DOMRectLike; @@ -87,7 +89,7 @@ export function insertResearchReference( ); $setSelection(selection); let reference = $createResearchNode(id); - $insertNodes([reference]); + $insertResearch(reference); key = reference.getKey(); }, { discrete: true }); } catch { @@ -120,9 +122,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 +142,54 @@ export function retainDraftBlock( return resolved ?? previous; } -export function attachmentBlock( - resolved: Block, +/** 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, + * 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, - previous: Block | undefined, -): Block { - return offset === 0 && previous ? previous : resolved; +): { key: string; placement: Placement } | undefined { + let node = $getNodeByKey(key); + let target = node ? $topLevel(node) : undefined; + if (!node || !target) return undefined; + let { block, nested } = target; + let first = $isElementNode(block) ? block.getFirstDescendant() : null; + 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" }; } export const UNRESOLVED_DRAFT = "This research draft cannot yet be placed at its saved position."; @@ -175,8 +224,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 +235,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 +272,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 +307,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(() => {