diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 1986cc888efba..daaad4c3b6892 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -89,6 +89,7 @@ import { markdownToFlat, pmToFlat, serializeEditorDoc, + serializedMarkdownOffset, serializeSelection, skillChipReplacements, splitOrLiftListItem, @@ -1593,7 +1594,7 @@ function ComposerPromptEditorTiptapInner( }, ); } - selectMarkdownOffset(editor, normalizedExpandedCursor); + selectMarkdownOffset(editor, value, normalizedExpandedCursor); const map = serializeEditorDoc(editor.state.doc); if (isFocused) scrollTiptapCaretIntoView(editor); if (pendingCitation) { @@ -1666,7 +1667,7 @@ function ComposerPromptEditorTiptapInner( const boundedCursor = clampEditorCursor(snapshotRef.current.value, nextCursor); const expandedCursor = exactExpandedCursor ?? expandEditorCursor(snapshotRef.current.value, boundedCursor); - selectMarkdownOffset(editor, expandedCursor); + selectMarkdownOffset(editor, snapshotRef.current.value, expandedCursor); scrollTiptapCaretIntoView(editor); if (boundedCursor === snapshotRef.current.cursor) return; snapshotRef.current = { @@ -1892,10 +1893,13 @@ function insertMarkdownParagraphs( } /** Placing the caret can show Markdown markers, so place it again in the shown source. */ -function selectMarkdownOffset(editor: TiptapEditor, offset: number): void { +function selectMarkdownOffset(editor: TiptapEditor, source: string, offset: number): void { for (let pass = 0; pass < 2; pass += 1) { const map = serializeEditorDoc(editor.state.doc); - const pos = flatToPm(map, markdownToFlat(map, offset)); + const pos = flatToPm( + map, + markdownToFlat(map, serializedMarkdownOffset(source, map.value, offset)), + ); const { selection } = editor.state; if (pass > 0 && selection.empty && selection.from === pos) return; editor.commands.setTextSelection(pos); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index fcdb8165662b5..df78eac50b152 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -30,6 +30,7 @@ import { markdownToFlat, pmToFlat, serializeEditorDoc, + serializedMarkdownOffset, serializeSelection, skillChipReplacements, stepCaretAcrossStyledEdge, @@ -597,6 +598,53 @@ describe("composer rich text document model", () => { }); }); + describe("a dash rule after a paragraph", () => { + const paragraph = (text: string) => ({ type: "paragraph", content: [{ type: "text", text }] }); + const rule = (source: string) => ({ type: "horizontalRule", attrs: { source } }); + const serialize = (...content: object[]) => { + const doc = ProseMirrorNode.fromJSON(schema, { type: "doc", content }); + doc.check(); + return { doc, map: serializeEditorDoc(doc) }; + }; + + it("is written after a blank line, or Markdown reads the paragraph as a heading", () => { + const settled = "text\n\n---\nmore"; + expect(roundTrip("text\n---\nmore").value).toBe(settled); + expect(roundTrip(settled).value).toBe(settled); + }); + + it("keeps every offset mapped through the blank line", () => { + const { doc, map } = serialize(paragraph("text"), rule("---"), paragraph("more")); + expect(map.value).toBe("text\n\n---\nmore"); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); + } + // The blank line is not a place to type: it clamps to the rule. + expect(flatToMarkdown(map, "text".length)).toBe("text".length); + expect(markdownToFlat(map, "text\n".length)).toBe("text".length + 1); + expect(flatToMarkdown(map, "text".length + 1)).toBe("text\n\n---".length); + expect(flatToPm(map, "text".length + 1)).toBe(doc.child(0).nodeSize + doc.child(1).nodeSize); + }); + }); + + it.each([ + ["text\n---\nmore", "text\n---\nm".length, "text\n\n---\nm".length], + ["before\n> quoted\nafter", "before\n> quoted\na".length, "before\n> quoted\n\na".length], + ["__x__\n---\nmore", "__x__\n---\nm".length, "**x**\n\n---\nm".length], + [ + "*a*_b_\n> q\nx\n> q\nabcde", + "*a*_b_\n> q\nx\n> q\na".length, + "*a*_b_\n> q\nx\n> q\na".length, + ], + ["a\n---\n", "a".length, "a".length], + ])("restores a caret in the glued draft %j on the same character", (value, caret, settled) => { + const map = roundTrip(value); + const restore = (offset: number) => + markdownToFlat(map, serializedMarkdownOffset(value, map.value, offset)); + expect(restore(value.length)).toBe(map.docLength); + expect(restore(caret)).toBe(markdownToFlat(map, settled)); + }); + it.each([ "---", "***", @@ -605,7 +653,8 @@ describe("composer rich text document model", () => { "* * *", "-----", "--- ", - "text\n---\nmore", + "text\n***\nmore", + "text\n- - -\nmore", "- item\n---\n- item two", "```\n---\n```", "--", @@ -651,7 +700,7 @@ describe("composer rich text document model", () => { it.each([ ["---", "---"], - ["a\n---", "a\n---"], + ["a\n---", "a\n\n---"], ["---\n", "---"], ])("gives the caret a line after the final rule of %j, written %j", (value, expected) => { const map = roundTrip(value); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 417397e88ccc5..94acc41bf0eea 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1051,14 +1051,26 @@ function pushBreakRun(acc: RichAccumulator, position?: number): void { acc.md += 1; } -// Separate prose from quotes to avoid lazy continuation; an empty paragraph already separates them. -function needsBlankLineAfterQuote(previous: ProseMirrorNode, block: ProseMirrorNode): boolean { +function horizontalRuleSource(block: ProseMirrorNode): string { + const source = (block.attrs as Record).source; + return typeof source === "string" && source ? source : "---"; +} + +// A blank line keeps prose out of a quote (lazy continuation) and a dash rule from +// underlining the line above into a setext heading; an empty paragraph already separates them. +function needsBlankLineBetween(previous: ProseMirrorNode, block: ProseMirrorNode): boolean { + if (previous.type.name === "blockquote") { + return block.type.name === "paragraph" && block.content.size > 0; + } return ( - previous.type.name === "blockquote" && block.type.name === "paragraph" && block.content.size > 0 + block.type.name === "horizontalRule" && + previous.type.name === "paragraph" && + previous.content.size > 0 && + /^-+[ \t]*\r?$/.test(horizontalRuleSource(block)) ); } -// Blank-line runs own no document characters; offsets clamp to the paragraph start. +// Blank-line runs own no document characters; offsets clamp to the start of the next block. function pushBlankLineRun(acc: RichAccumulator, pmPos: number): void { acc.runs.push({ kind: "prefix", @@ -1074,6 +1086,27 @@ function pushBlankLineRun(acc: RichAccumulator, pmPos: number): void { acc.md += 1; } +// Moves an offset into a parsed `value` past the blank lines its serialization inserted, +// treating other differences (`__` written as `**`) as same-length rewrites. When the two +// strings do not align that way end to end, the offset is returned unmapped. +export function serializedMarkdownOffset( + value: string, + serialized: string, + offset: number, +): number { + if (value === serialized) return offset; + let index = 0; + let mapped = 0; + let target = -1; + while (index < value.length && mapped < serialized.length) { + if (index === offset && target < 0) target = mapped; + if (value[index] === serialized[mapped] || serialized[mapped] !== "\n") index += 1; + mapped += 1; + } + if (index !== value.length || mapped !== serialized.length) return offset; + return target < 0 ? mapped + (offset - index) : target; +} + function appendInlineRuns( container: ProseMirrorNode, contentStart: number, @@ -1502,7 +1535,7 @@ function serializeDoc(doc: ProseMirrorNode): RichDocMap { let pmBlockStart = 0; blocks.forEach((block, blockIndex) => { if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc); - if (blockIndex > 0 && needsBlankLineAfterQuote(blocks[blockIndex - 1]!, block)) { + if (blockIndex > 0 && needsBlankLineBetween(blocks[blockIndex - 1]!, block)) { pushBlankLineRun(acc, pmBlockStart + 1); } if (LIST_NODE_NAMES.has(block.type.name)) { @@ -1530,8 +1563,7 @@ function serializeDoc(doc: ProseMirrorNode): RichDocMap { acc.md += prefix.length; appendInlineRuns(block, pmBlockStart + 1, acc); } else if (block.type.name === "horizontalRule") { - const attrs = block.attrs as Record; - const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---"; + const source = horizontalRuleSource(block); acc.runs.push({ kind: "prefix", flatStart: acc.flat,