diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 480269d4326b3..692a135ad883b 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -532,6 +532,23 @@ async function focusAt(editor: Editor, position: number | "end") { vi.runOnlyPendingTimers(); } +it("reopens a long draft scrolled to its caret without taking focus", async () => { + // App CSS is not loaded here; give the editor its own scroll box. + const style = document.head.appendChild(document.createElement("style")); + // The bottom padding stands in for space reserved under overlaid controls. + style.textContent = + ".composer-tiptap { max-height: 120px; overflow-y: auto; padding-bottom: 48px; }"; + using _style = { [Symbol.dispose]: () => style.remove() }; + const value = Array.from({ length: 40 }, (_, line) => `line ${line + 1}`).join("\n"); + await using screen = await mountEditor(value); + const { editor } = screen; + const scroller = editor.view.dom; + await vi.waitFor(() => expect(scroller.scrollTop).toBeGreaterThan(0)); + expect(document.activeElement).not.toBe(scroller); + const caret = editor.view.coordsAtPos(editor.state.selection.head); + expect(caret.bottom).toBeLessThanOrEqual(scroller.getBoundingClientRect().bottom - 48 + 1); +}); + describe("Markdown markers around styled text", () => { it.each([ { keys: "{Backspace}", expected: "**** after" }, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 650a8be7b97c0..d89f57068e9cf 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1631,6 +1631,12 @@ function ComposerPromptEditorTiptapInner( selectMarkdownOffset(editor, normalizedExpandedCursor); const map = serializeEditorDoc(editor.state.doc); if (isFocused) scrollTiptapCaretIntoView(editor); + // A redraw later in this commit drops the editor's scroll offset, so reveal on the next frame. + else if (initialSelection) { + window.requestAnimationFrame(() => { + if (!editor.isDestroyed) revealCaretInEditor(editor); + }); + } if (pendingCitation) { citationRequestRef.current = null; const target = map.runs.find( @@ -1944,6 +1950,19 @@ function scrollTiptapCaretIntoView(editor: TiptapEditor): void { editor.view.dispatch(editor.state.tr.scrollIntoView()); } +/** Scrolls only the editor's own box: a mounting composer must not move the timeline or page. */ +function revealCaretInEditor(editor: TiptapEditor): void { + const scroller = editor.view.dom; + const caret = editor.view.coordsAtPos(editor.state.selection.head); + const box = scroller.getBoundingClientRect(); + // Padding can be reserved for controls drawn over the box, such as mobile pending-answer actions. + const style = getComputedStyle(scroller); + const bottom = box.bottom - parseFloat(style.paddingBottom); + const top = box.top + parseFloat(style.paddingTop); + if (caret.bottom > bottom) scroller.scrollTop += caret.bottom - bottom; + else if (caret.top < top) scroller.scrollTop -= top - caret.top; +} + function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 7e50f3773ff35..671815c587720 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -570,6 +570,15 @@ describe("composer rich text document model", () => { ); }); + it("reopens without an empty line between the quote and the comment", () => { + const { doc, map } = serialize(quote("quoted"), paragraph("Comment to it")); + const reopened = ProseMirrorNode.fromJSON( + schema, + buildDocJson(map.value, (name) => ({ label: name, description: null })), + ); + expect(reopened.toJSON()).toEqual(doc.toJSON()); + }); + it("settles on the blank line when a draft glued it to the quote", () => { const settled = "before\n> quoted\n\nafter"; expect(roundTrip("before\n> quoted\nafter").value).toBe(settled); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index dfd238e71819c..d8f0a7e7b6e5e 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1077,7 +1077,12 @@ export function buildTiptapContent( }); openQuote = null; }; - for (const entry of entries) { + const isProseLine = (entry: (typeof entries)[number] | undefined, empty: boolean) => + entry !== undefined && + "line" in entry && + !entry.line.list && + (entry.line.inline.length === 0) === empty; + for (const [entryIndex, entry] of entries.entries()) { if ("quote" in entry) { flushLists(); if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote(); @@ -1085,7 +1090,11 @@ export function buildTiptapContent( openQuote.content.push(entry.quote.inline); continue; } + // The serializer separates a quote from the prose after it with one blank line; it is not an empty paragraph. + const quoteSeparator = + openQuote !== null && isProseLine(entry, true) && isProseLine(entries[entryIndex + 1], false); flushQuote(); + if (quoteSeparator) continue; if ("code" in entry) { flushLists(); blocks.push(entry.code);