Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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];
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/composer-rich-text-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/composer-rich-text-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1077,15 +1077,24 @@ 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();
openQuote ??= { prefix: entry.quote.prefix, content: [] };
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);
Expand Down
Loading