From 8be420b41ea460baff5de391e3b900d6e2cd23c5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:11:54 +0100 Subject: [PATCH] fix: keep a click in a reply above the folded quote --- .../__tests__/reply-quote-collapse.test.ts | 48 ++++++++++++++- components/email/reply-quote-collapse.ts | 58 ++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/components/email/__tests__/reply-quote-collapse.test.ts b/components/email/__tests__/reply-quote-collapse.test.ts index 86c22986c..eeb00d1a8 100644 --- a/components/email/__tests__/reply-quote-collapse.test.ts +++ b/components/email/__tests__/reply-quote-collapse.test.ts @@ -5,7 +5,7 @@ import Paragraph from '@tiptap/extension-paragraph'; import { trailingParagraphBreakRule } from '../paragraph-breaks'; import { QuotedHtml, buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html'; import { SignatureBlock, buildSignatureBlock } from '../signature-block'; -import { ReplyQuoteCollapse, markReplyQuoteHeader } from '../reply-quote-collapse'; +import { ReplyQuoteCollapse, markReplyQuoteHeader, userTextEnd } from '../reply-quote-collapse'; const HEADER = markReplyQuoteHeader('
On Monday, Bob wrote:
'); const ORIGINAL = '

Original message

Earlier reply
'; @@ -168,3 +168,49 @@ describe('reply quote visibility', () => { expect(toggle()).toHaveAttribute('aria-expanded', 'false'); }); }); + +// compose-p2-3: a click under the folded quote used to put the caret on the +// empty last line, below the hidden original, where the text went unseen. +describe('a click in a reply with a folded quote', () => { + const click = (e: Editor, pos: number) => { + let handled = false; + e.view.someProp('handleClick', (handler) => { handled = handler(e.view, pos, new MouseEvent('click')) || handled; return handled; }); + return handled; + }; + const lastPos = (e: Editor) => e.state.doc.content.size - 1; + + it('puts the caret at the end of the text above the quote', () => { + const e = mount(); + expect(e.state.doc.lastChild?.type.name).toBe('paragraph'); + expect(click(e, lastPos(e))).toBe(true); + expect(e.state.selection.from).toBe(1 + 'My response'.length); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + }); + + it('keeps the click where it was once the quote is shown', () => { + const e = mount(); + toggle().click(); + expect(click(e, lastPos(e))).toBe(false); + }); + + it('keeps a click on a line the user can see below the quote', () => { + const e = mount(`

My response

${HEADER}${QUOTE}

Postscript

`); + expect(click(e, lastPos(e) - 2)).toBe(false); + }); + + it('keeps a click in the text above the quote', () => { + const e = mount(); + expect(click(e, 3)).toBe(false); + }); + + it.each(['above', 'below'])('ends the text before a signature %s the quote', (position) => { + const signature = buildSignatureBlock('My signature'); + const e = mount(`

My response

${position === 'above' ? signature : ''}${HEADER}${QUOTE}${position === 'below' ? signature : ''}`); + expect(userTextEnd(e.state)).toBe(1 + 'My response'.length); + }); + + it('has no end of text in a body with neither quote nor signature', () => { + const e = mount('

Just text

'); + expect(userTextEnd(e.state)).toBeNull(); + }); +}); diff --git a/components/email/reply-quote-collapse.ts b/components/email/reply-quote-collapse.ts index 5dbd658f2..b97485888 100644 --- a/components/email/reply-quote-collapse.ts +++ b/components/email/reply-quote-collapse.ts @@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core'; import type { Node as PMNode } from '@tiptap/pm/model'; -import { Plugin, PluginKey, Selection } from '@tiptap/pm/state'; +import { Plugin, PluginKey, Selection, TextSelection, type EditorState } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; export const REPLY_QUOTE_START_ATTRIBUTE = 'data-reply-quote-start'; @@ -59,6 +59,55 @@ function quoteRanges(doc: PMNode): QuoteRange[] { return ranges; } +const isSignature = (node: PMNode) => node.type.name === 'signatureBlock' || Boolean(node.attrs['data-signature-block']); + +function collapsedRanges(state: EditorState): QuoteRange[] { + const expanded = quoteKey.getState(state)?.expanded ?? new Set(); + return quoteRanges(state.doc).filter((range) => !expanded.has(range.from)); +} + +/** + * Where the user's own text ends: the end of the last paragraph above the + * folded quote or the signature, whichever comes first. Null when the body + * has neither, so the end of the body is the end of the text. + */ +export function userTextEnd(state: EditorState): number | null { + const ranges = quoteRanges(state.doc); + let boundary: number | null = collapsedRanges(state)[0]?.from ?? null; + state.doc.forEach((node, pos) => { + if (isSignature(node) && (boundary === null || pos < boundary)) boundary = pos; + }); + if (boundary === null) return null; + let end: number | null = null; + state.doc.forEach((node, pos) => { + if (pos + node.nodeSize > boundary! || !node.isTextblock || isSignature(node)) return; + if (ranges.some((range) => pos >= range.from && pos < range.to)) return; + end = pos + node.nodeSize - 1; + }); + return end; +} + +/** + * A click that lands under a folded quote (on the empty line the editor keeps + * at the end) would write below the hidden original, where the user can't see + * it. It goes to the end of their own text above the quote instead + * (compose-p2-3). Lines the user can see below the quote keep their click. + */ +function clickBelowCollapsedQuote(state: EditorState, pos: number): boolean { + const ranges = collapsedRanges(state); + const first = ranges[0]; + if (!first || pos < first.from) return false; + const $pos = state.doc.resolve(pos); + const topPos = $pos.depth > 0 ? $pos.before(1) : pos; + let hiddenOrEmpty = true; + state.doc.forEach((node, nodePos) => { + if (nodePos < first.from || nodePos > topPos) return; + if (ranges.some((range) => nodePos >= range.from && nodePos < range.to)) return; + if (!node.isTextblock || isSignature(node) || node.content.size > 0) hiddenOrEmpty = false; + }); + return hiddenOrEmpty; +} + /** Visibility is a view decoration, never a document edit or an undo step. */ export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string }>({ name: 'replyQuoteCollapse', @@ -107,6 +156,13 @@ export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string }, }, props: { + handleClick(view, pos) { + if (!clickBelowCollapsedQuote(view.state, pos)) return false; + const end = userTextEnd(view.state); + if (end === null) return false; + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, end))); + return true; + }, decorations(state) { const expanded = quoteKey.getState(state)?.expanded ?? new Set(); const decorations: Decoration[] = [];