Skip to content
12 changes: 8 additions & 4 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
markdownToFlat,
pmToFlat,
serializeEditorDoc,
serializedMarkdownOffset,
serializeSelection,
skillChipReplacements,
splitOrLiftListItem,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
53 changes: 51 additions & 2 deletions apps/web/src/composer-rich-text-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
markdownToFlat,
pmToFlat,
serializeEditorDoc,
serializedMarkdownOffset,
serializeSelection,
skillChipReplacements,
stepCaretAcrossStyledEdge,
Expand Down Expand Up @@ -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([
"---",
"***",
Expand All @@ -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```",
"--",
Expand Down Expand Up @@ -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);
Expand Down
46 changes: 39 additions & 7 deletions apps/web/src/composer-rich-text-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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",
Expand All @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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<string, unknown>;
const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---";
const source = horizontalRuleSource(block);
acc.runs.push({
kind: "prefix",
flatStart: acc.flat,
Expand Down
Loading