fix(composer): use native ProseMirror inserts for autocomplete (mentions / channels / emoji) (#618)

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Quinn (sprout agent) <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-19 20:28:40 -04:00
committed by GitHub
parent b881d5319c
commit 2392e6523a
10 changed files with 697 additions and 274 deletions

View File

@@ -96,40 +96,47 @@ export function ForumComposer({
setContent(markdown);
contentRef.current = markdown;
const { cursor } = richText.getTextAndCursor();
const { cursor } = richText.getPlainTextAndCursor();
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
},
});
// ── Mention / channel autocomplete insertion ────────────────────────
// Native ProseMirror transactions — no markdown round-trip.
const applyMentionInsert = React.useCallback(
(suggestion: MentionSuggestion) => {
const { text, cursor } = richText.getTextAndCursor();
const result = mentions.insertMention(suggestion, text, cursor);
richText.setContentWithTrailingSpace(result.nextContent);
setContent(result.nextContent);
contentRef.current = result.nextContent;
const { cursor } = richText.getPlainTextAndCursor();
const { replaceFromOffset, replaceToOffset, insertText } =
mentions.insertMention(suggestion, cursor);
richText.replacePlainTextRange(
replaceFromOffset,
replaceToOffset,
insertText,
);
},
[
mentions.insertMention,
richText.getTextAndCursor,
richText.setContentWithTrailingSpace,
richText.getPlainTextAndCursor,
richText.replacePlainTextRange,
],
);
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
const { text, cursor } = richText.getTextAndCursor();
const result = channelLinks.insertChannel(suggestion, text, cursor);
richText.setContentWithTrailingSpace(result.nextContent);
setContent(result.nextContent);
contentRef.current = result.nextContent;
const { cursor } = richText.getPlainTextAndCursor();
const { replaceFromOffset, replaceToOffset, insertText } =
channelLinks.insertChannel(suggestion, cursor);
richText.replacePlainTextRange(
replaceFromOffset,
replaceToOffset,
insertText,
);
},
[
channelLinks.insertChannel,
richText.getTextAndCursor,
richText.setContentWithTrailingSpace,
richText.getPlainTextAndCursor,
richText.replacePlainTextRange,
],
);
@@ -147,7 +154,7 @@ export function ForumComposer({
// ── @ mention picker (toolbar button) ───────────────────────────────
const openMentionPicker = React.useCallback(() => {
if (!richText.editor) return;
const { text, cursor } = richText.getTextAndCursor();
const { text, cursor } = richText.getPlainTextAndCursor();
const beforeCursor = text.slice(0, cursor);
if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) {
@@ -162,12 +169,12 @@ export function ForumComposer({
richText.editor.chain().focus().insertContent(prefix).run();
setIsEmojiPickerOpen(false);
const updatedText = richText.editor.state.doc.textContent;
const { cursor: updatedCursor } = richText.getTextAndCursor();
const { text: updatedText, cursor: updatedCursor } =
richText.getPlainTextAndCursor();
mentions.updateMentionQuery(updatedText, updatedCursor);
}, [
richText.editor,
richText.getTextAndCursor,
richText.getPlainTextAndCursor,
richText.focus,
mentions.updateMentionQuery,
]);

View File

@@ -0,0 +1,272 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { buildPlainTextProjection } from "./plainTextProjection.ts";
// ── Build the actual Tiptap schema we use in the composer ─────────────
// Matching useRichTextEditor's StarterKit configuration (minus things
// that don't affect the schema shape). This guarantees the projection
// is tested against the *real* node names and types.
const schema = getSchema([
StarterKit.configure({
hardBreak: { keepMarks: true },
heading: false,
trailingNode: false,
link: false,
}),
]);
const para = (...c) => schema.nodes.paragraph.create(null, c);
const t = (s) => schema.text(s);
const br = () => schema.nodes.hardBreak.create();
const li = (...c) => schema.nodes.listItem.create(null, c);
const ul = (...c) => schema.nodes.bulletList.create(null, c);
function doc(...content) {
return schema.nodes.doc.create(null, content);
}
// ── Helper: assert text equals textBetween(blockSep, leafText="\n") ───
function assertMatchesTextBetween(d, p) {
const expected = d.textBetween(0, d.content.size, "\n", "\n");
assert.equal(
p.text,
expected,
`projection.text should equal doc.textBetween(0..size, "\\n", "\\n")`,
);
}
// ── Single-paragraph ──────────────────────────────────────────────────
test("single paragraph: text is the paragraph's content", () => {
const d = doc(para(t("hello")));
const p = buildPlainTextProjection(d);
assert.equal(p.text, "hello");
assertMatchesTextBetween(d, p);
});
test("single paragraph: PM↔text mapping is identity within text node", () => {
const d = doc(para(t("hello")));
const p = buildPlainTextProjection(d);
// PM: para=0, "hello"=1..6
assert.equal(p.mapPMToTextOffset(1), 0);
assert.equal(p.mapPMToTextOffset(3), 2);
assert.equal(p.mapPMToTextOffset(6), 5);
assert.equal(p.mapTextOffsetToPM(0), 1);
assert.equal(p.mapTextOffsetToPM(2), 3);
assert.equal(p.mapTextOffsetToPM(5), 6);
});
// ── HardBreak ─────────────────────────────────────────────────────────
test("hardBreak contributes a single \\n", () => {
const d = doc(para(t("hello"), br(), t("world")));
const p = buildPlainTextProjection(d);
assert.equal(p.text, "hello\nworld");
assertMatchesTextBetween(d, p);
});
test("cursor before <br> maps to text offset just before the \\n", () => {
// PM: para=0, "hello"=1..5, <br>=6, "world"=7..11
const d = doc(para(t("hello"), br(), t("world")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapPMToTextOffset(6), 5);
});
test("cursor after <br> maps to text offset just after the \\n", () => {
const d = doc(para(t("hello"), br(), t("world")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapPMToTextOffset(7), 6);
});
test("text offset right after \\n maps to PM after the break", () => {
const d = doc(para(t("hello"), br(), t("world")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapTextOffsetToPM(6), 7);
});
test("text offset just before \\n maps to PM before the break", () => {
const d = doc(para(t("hello"), br(), t("world")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapTextOffsetToPM(5), 6);
});
// ── Multi-paragraph ───────────────────────────────────────────────────
test("two paragraphs: block boundary contributes a single \\n", () => {
const d = doc(para(t("aaa")), para(t("bbb")));
const p = buildPlainTextProjection(d);
assert.equal(p.text, "aaa\nbbb");
assertMatchesTextBetween(d, p);
});
test("two paragraphs: cursor in second paragraph maps past the boundary \\n", () => {
// PM: p1 nodeSize=5 (token + 3 chars + token), p2 opens at PM=5
// "aaa" text at 1..4 (size 3), p1 closes at PM=4..5, p2 opens at PM=5,
// "bbb" text at 6..9
const d = doc(para(t("aaa")), para(t("bbb")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapPMToTextOffset(6), 4);
assert.equal(p.mapTextOffsetToPM(4), 6);
});
test("three paragraphs: cumulative block boundaries", () => {
const d = doc(para(t("aaa")), para(t("bbb")), para(t("ccc")));
const p = buildPlainTextProjection(d);
assert.equal(p.text, "aaa\nbbb\nccc");
assertMatchesTextBetween(d, p);
// "ccc" text starts at PM=11 → text offset 8
assert.equal(p.mapPMToTextOffset(11), 8);
assert.equal(p.mapTextOffsetToPM(8), 11);
});
// ── HardBreak + multi-paragraph ──────────────────────────────────────
test("paragraph with <br> then second paragraph", () => {
const d = doc(para(t("line1"), br(), t("line2")), para(t("para2")));
const p = buildPlainTextProjection(d);
assert.equal(p.text, "line1\nline2\npara2");
assertMatchesTextBetween(d, p);
});
test("range crossing a <br>", () => {
// PM: para=0, "line1"=1..5, <br>=6, "line2"=7..11
// textOffset 2..8 = "ne1\nli" → PM 3..9
const d = doc(para(t("line1"), br(), t("line2")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapTextOffsetToPM(2), 3);
assert.equal(p.mapTextOffsetToPM(8), 9);
});
// ── List items (nested blocks under bulletList) ──────────────────────
test("bullet list: items separated by \\n (matches textBetween)", () => {
const d = doc(ul(li(para(t("first"))), li(para(t("second")))));
const p = buildPlainTextProjection(d);
assert.equal(p.text, "first\nsecond");
assertMatchesTextBetween(d, p);
});
test("paragraph + bullet list", () => {
const d = doc(para(t("intro")), ul(li(para(t("a"))), li(para(t("b")))));
const p = buildPlainTextProjection(d);
assert.equal(p.text, "intro\na\nb");
assertMatchesTextBetween(d, p);
});
test("list item: PM↔text round-trip lands in the right item", () => {
const d = doc(ul(li(para(t("first"))), li(para(t("second")))));
const p = buildPlainTextProjection(d);
const pm = p.mapTextOffsetToPM(6); // start of "second"
assert.equal(p.mapPMToTextOffset(pm), 6);
});
// ── Edge cases ───────────────────────────────────────────────────────
test("offset 0 maps inside the first text node", () => {
const d = doc(para(t("hello")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapTextOffsetToPM(0), 1);
});
test("offset past end clamps to end-of-doc content position", () => {
const d = doc(para(t("hello")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapTextOffsetToPM(999), 6);
});
test("PM position past doc clamps to text.length", () => {
const d = doc(para(t("hello")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapPMToTextOffset(999), 5);
});
test("empty paragraph: empty text, mappings clamp safely", () => {
const d = doc(para());
const p = buildPlainTextProjection(d);
assert.equal(p.text, "");
assert.equal(p.mapPMToTextOffset(0), 0);
assert.equal(p.mapPMToTextOffset(1), 0);
// Inside the empty paragraph → PM=1
assert.equal(p.mapTextOffsetToPM(0), 1);
});
// ── Empty leaf blocks (paste / draft restore can produce these) ─────
test("empty paragraph after text: trailing \\n preserved", () => {
const d = doc(para(t("a")), para());
const p = buildPlainTextProjection(d);
assert.equal(p.text, "a\n");
assertMatchesTextBetween(d, p);
});
test("empty paragraph after text: offset round-trips into the empty block", () => {
// PM: p1=0..2 (size 3: <p>1 + "a"1 + </p>1), text "a" at 1..2
// p2=3..4 (size 2: <p>1 + </p>1), empty interior at PM=4
const d = doc(para(t("a")), para());
const p = buildPlainTextProjection(d);
// offset 2 (right after the `\n`) → PM=4 (inside empty p2) → back to 2
assert.equal(p.mapTextOffsetToPM(2), 4);
assert.equal(p.mapPMToTextOffset(4), 2);
});
test("empty paragraph before text: leading \\n preserved", () => {
const d = doc(para(), para(t("a")));
const p = buildPlainTextProjection(d);
assert.equal(p.text, "\na");
assertMatchesTextBetween(d, p);
});
test("empty paragraph before text: offset 0 lands inside the empty block", () => {
// PM: p1=0..1 (size 2: <p>1 + </p>1), empty interior at PM=1
// p2=2..4 (size 3), "a" at PM=3
const d = doc(para(), para(t("a")));
const p = buildPlainTextProjection(d);
assert.equal(p.mapTextOffsetToPM(0), 1);
assert.equal(p.mapPMToTextOffset(1), 0);
});
test("two empty paragraphs: single \\n separator, both interiors reachable", () => {
const d = doc(para(), para());
const p = buildPlainTextProjection(d);
assert.equal(p.text, "\n");
assertMatchesTextBetween(d, p);
// PM: p1=0..1 (size 2), interior=1. p2=2..3 (size 2), interior=3.
assert.equal(p.mapTextOffsetToPM(0), 1);
assert.equal(p.mapTextOffsetToPM(1), 3);
assert.equal(p.mapPMToTextOffset(1), 0);
assert.equal(p.mapPMToTextOffset(3), 1);
});
test("empty list item: interior reachable, separators preserved", () => {
const d = doc(ul(li(para()), li(para(t("x")))));
const p = buildPlainTextProjection(d);
assert.equal(p.text, "\nx");
assertMatchesTextBetween(d, p);
});
// ── Property: round-trip ─────────────────────────────────────────────
test("round-trip: text offset → PM → text offset is identity", () => {
const d = doc(
para(t("line1"), br(), t("line2")),
para(),
para(t("para3")),
ul(li(para(t("item-a"))), li(para()), li(para(t("item-c")))),
);
const p = buildPlainTextProjection(d);
for (let offset = 0; offset <= p.text.length; offset++) {
const pm = p.mapTextOffsetToPM(offset);
const back = p.mapPMToTextOffset(pm);
assert.equal(
back,
offset,
`offset ${offset} → pm ${pm} → offset ${back} (text=${JSON.stringify(p.text)})`,
);
}
});

View File

@@ -0,0 +1,237 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
/**
* Plain-text projection of a ProseMirror document.
*
* Plain-text is what a textarea-shaped consumer (mention/channel/emoji
* autocomplete) reads: hard breaks render as `\n`, and a single `\n`
* separates content in different leaf blocks (paragraphs, list items,
* etc.) — the same convention as `doc.textBetween(from, to, "\n", "\n")`.
*
* The same walk is used to map *both* directions:
* - PM position → plain-text offset (`mapPMToTextOffset`)
* - text offset → PM position (`mapTextOffsetToPM`)
*
* Keeping a single source of truth means the two mappings can't drift,
* which is the historic source of off-by-one bugs around `hardBreak` and
* multi-block docs.
*
* Pure function — no editor / view / React dependency.
*/
export type PlainTextProjection = {
/** The plain-text projection of the document. */
text: string;
/** Map a ProseMirror position to a plain-text offset. Clamps to [0, text.length]. */
mapPMToTextOffset: (pm: number) => number;
/** Map a plain-text offset to a ProseMirror position. Clamps to a valid in-doc position. */
mapTextOffsetToPM: (offset: number) => number;
};
type Segment =
// A text node: pm range and text range have equal length.
| {
kind: "text";
pmFrom: number;
pmTo: number;
textFrom: number;
textTo: number;
}
// A hardBreak: 1 PM position wide, contributes one `\n`.
| {
kind: "hardBreak";
pmFrom: number;
pmTo: number;
textFrom: number;
textTo: number;
}
// A boundary between two leaf-block siblings (paragraphs, list items,
// headings, etc.) — zero PM positions wide. Both sides resolve to
// `pmAt`, which is the boundary point between the two blocks (= start
// of the next leaf-block's content, minus its opening token).
| {
kind: "blockBoundary";
pmAt: number;
textFrom: number;
textTo: number;
}
// The interior of an empty leaf block — a zero-width "content slot"
// at PM position `pmAt` and text offset `textAt`. Lets cursor / range
// endpoints land inside empty paragraphs (paste, draft restore) and
// round-trip correctly through the mapping.
| {
kind: "emptyBlockContent";
pmAt: number;
textAt: number;
};
/**
* Build a `PlainTextProjection` for the given doc.
*
* Walks the doc once and records each text node, hard break, and the
* boundary between consecutive leaf-blocks. A "leaf block" is any block
* node that does not itself contain blocks — `doc.textBetween` treats
* exactly those boundaries as inserting the blockSeparator, and we do
* the same so our text projection equals `doc.textBetween(0, end, "\n", "\n")`.
*/
export function buildPlainTextProjection(
doc: ProseMirrorNode,
): PlainTextProjection {
const segments: Segment[] = [];
const textParts: string[] = [];
let cursorText = 0;
/**
* True once we've entered at least one leaf-block. Subsequent leaf-blocks
* emit a boundary `\n` before their content — matching `textBetween`.
*/
let sawLeafBlock = false;
doc.descendants((node, pos) => {
// ── Leaf inline: text ──────────────────────────────────────────
if (node.isText) {
const t = node.text ?? "";
segments.push({
kind: "text",
pmFrom: pos,
pmTo: pos + t.length,
textFrom: cursorText,
textTo: cursorText + t.length,
});
textParts.push(t);
cursorText += t.length;
return false; // text nodes have no children
}
// ── Leaf inline: hard break ────────────────────────────────────
if (node.type.name === "hardBreak") {
segments.push({
kind: "hardBreak",
pmFrom: pos,
pmTo: pos + 1,
textFrom: cursorText,
textTo: cursorText + 1,
});
textParts.push("\n");
cursorText += 1;
return false;
}
// ── Block ──────────────────────────────────────────────────────
if (node.isBlock) {
// Only "leaf blocks" (those with inline content — paragraphs,
// headings, code blocks) produce text in `textBetween`'s sense.
// Mixed-content blocks (lists, blockquotes) just contain other
// blocks, so their *inner* leaf blocks record boundaries instead.
const isLeafBlock = !!node.type.inlineContent;
if (isLeafBlock) {
if (sawLeafBlock) {
// Boundary between the previous leaf-block and this one. The
// PM position of the boundary is `pos` — the opening token of
// the new leaf-block. textBetween emits the separator here.
segments.push({
kind: "blockBoundary",
pmAt: pos,
textFrom: cursorText,
textTo: cursorText + 1,
});
textParts.push("\n");
cursorText += 1;
}
sawLeafBlock = true;
// If the leaf block is empty, no text/hardBreak segment will be
// recorded inside it — but we still need a target for the
// *interior* PM position so cursor / range endpoints sitting
// inside the empty block round-trip correctly. Record a
// zero-width content slot at `pos + 1` (= the position right
// after this block's opening token).
if (node.content.size === 0) {
segments.push({
kind: "emptyBlockContent",
pmAt: pos + 1,
textAt: cursorText,
});
}
}
return true; // descend into block children
}
// Other inline leaf nodes (none today) — skip silently.
return true;
});
const text = textParts.join("");
function mapPMToTextOffset(pm: number): number {
if (pm <= 0) return 0;
for (const seg of segments) {
if (seg.kind === "text") {
if (pm <= seg.pmTo) {
if (pm <= seg.pmFrom) return seg.textFrom;
return seg.textFrom + (pm - seg.pmFrom);
}
} else if (seg.kind === "hardBreak") {
if (pm <= seg.pmFrom) return seg.textFrom;
if (pm <= seg.pmTo) return seg.textTo;
} else if (seg.kind === "blockBoundary") {
// Zero PM-width.
if (pm <= seg.pmAt) return seg.textFrom;
} else {
// emptyBlockContent — interior of an empty leaf block.
if (pm <= seg.pmAt) return seg.textAt;
}
}
return text.length;
}
function mapTextOffsetToPM(offset: number): number {
if (offset <= 0) {
// Position right inside the first content-carrying location.
const first = segments.find(
(s) =>
s.kind === "text" ||
s.kind === "hardBreak" ||
s.kind === "emptyBlockContent",
);
if (first) {
return first.kind === "emptyBlockContent" ? first.pmAt : first.pmFrom;
}
return doc.content.size > 0 ? 1 : 0;
}
// Iterate segments; an offset that falls *exactly* at the right edge
// of a separator segment (hardBreak / blockBoundary) is interpreted
// as "start of the next content node" and so falls through to be
// claimed by the next segment (a text segment, or an
// emptyBlockContent slot if the next leaf-block is empty).
for (const seg of segments) {
if (seg.kind === "text") {
if (offset <= seg.textTo) {
return seg.pmFrom + (offset - seg.textFrom);
}
} else if (seg.kind === "hardBreak") {
if (offset < seg.textTo) {
// Anywhere before the right edge of the `\n` → before the break.
return seg.pmFrom;
}
// offset === seg.textTo → fall through to the next segment.
} else if (seg.kind === "blockBoundary") {
// Zero PM-width.
// offset < textTo → "end of previous block" → pmAt
// offset === textTo → "start of next block" → fall through.
if (offset < seg.textTo) return seg.pmAt;
} else {
// emptyBlockContent — interior of an empty leaf block.
// offset === textAt → land inside the empty block at pmAt.
if (offset <= seg.textAt) return seg.pmAt;
}
}
// Beyond all content → end-of-doc text position.
const last = segments[segments.length - 1];
if (!last) return doc.content.size > 0 ? 1 : 0;
if (last.kind === "text" || last.kind === "hardBreak") return last.pmTo;
if (last.kind === "emptyBlockContent") return last.pmAt;
return last.pmAt;
}
return { text, mapPMToTextOffset, mapTextOffsetToPM };
}

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import type { AutocompleteEdit } from "./useRichTextEditor";
export type ChannelSuggestion = {
id: string;
@@ -74,27 +75,23 @@ export function useChannelLinks() {
const isChannelOpen = channelQuery !== null && channelSuggestions.length > 0;
const insertChannel = React.useCallback(
(
suggestion: ChannelSuggestion,
content: string,
selectionEnd: number,
): { nextContent: string; nextCursor: number } => {
(suggestion: ChannelSuggestion, selectionEnd: number): AutocompleteEdit => {
// Cancel any pending debounced detection — user already selected
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const before = content.slice(0, channelStartIndex);
const after = content.slice(selectionEnd);
const inserted = `#${suggestion.name} `;
const nextContent = `${before}${inserted}${after}`;
const nextCursor = before.length + inserted.length;
const insertText = `#${suggestion.name} `;
setChannelQuery(null);
setChannelSelectedIndex(0);
return { nextContent, nextCursor };
return {
replaceFromOffset: channelStartIndex,
replaceToOffset: selectionEnd,
insertText,
};
},
[channelStartIndex],
);

View File

@@ -3,6 +3,8 @@ import * as React from "react";
import { init, SearchIndex } from "emoji-mart";
import data from "@emoji-mart/data";
import type { AutocompleteEdit } from "./useRichTextEditor";
export type EmojiSuggestion = {
id: string;
name: string;
@@ -101,26 +103,22 @@ export function useEmojiAutocomplete() {
const isEmojiAutocompleteOpen = emojiQuery !== null && suggestions.length > 0;
const insertEmoji = React.useCallback(
(
suggestion: EmojiSuggestion,
content: string,
selectionEnd: number,
): { nextContent: string; nextCursor: number } => {
(suggestion: EmojiSuggestion, selectionEnd: number): AutocompleteEdit => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const before = content.slice(0, emojiStartIndex);
const after = content.slice(selectionEnd);
const inserted = `${suggestion.native} `;
const nextContent = `${before}${inserted}${after}`;
const nextCursor = before.length + inserted.length;
const insertText = `${suggestion.native} `;
setEmojiQuery(null);
setEmojiSelectedIndex(0);
return { nextContent, nextCursor };
return {
replaceFromOffset: emojiStartIndex,
replaceToOffset: selectionEnd,
insertText,
};
},
[emojiStartIndex],
);

View File

@@ -6,6 +6,7 @@ import {
} from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import type { AutocompleteEdit } from "./useRichTextEditor";
import type { ChannelMember } from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
@@ -169,11 +170,7 @@ export function useMentions(
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
const insertMention = React.useCallback(
(
suggestion: MentionSuggestion,
content: string,
selectionEnd: number,
): { nextContent: string; nextCursor: number } => {
(suggestion: MentionSuggestion, selectionEnd: number): AutocompleteEdit => {
// Cancel any pending debounced detection — user already selected
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
@@ -181,11 +178,7 @@ export function useMentions(
}
const displayName = suggestion.displayName;
const before = content.slice(0, mentionStartIndex);
const after = content.slice(selectionEnd);
const inserted = `@${displayName} `;
const nextContent = `${before}${inserted}${after}`;
const nextCursor = before.length + inserted.length;
const insertText = `@${displayName} `;
const mentions = mentionMapRef.current;
mentions.set(displayName, suggestion.pubkey);
@@ -193,7 +186,11 @@ export function useMentions(
setMentionQuery(null);
setMentionSelectedIndex(0);
return { nextContent, nextCursor };
return {
replaceFromOffset: mentionStartIndex,
replaceToOffset: selectionEnd,
insertText,
};
},
[mentionStartIndex],
);

View File

@@ -1,126 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
/**
* Pure extraction of the ProseMirror → plain-text cursor mapping logic
* from getTextAndCursor in useRichTextEditor.ts.
*
* Takes a list of "visited nodes" (as the descendants callback would see them)
* and a ProseMirror anchor position, returns the plain-text offset.
*/
function mapAnchorToPlainText(nodes, anchor) {
let offset = 0;
let found = false;
for (const { isText, isBlock, pos, nodeSize } of nodes) {
if (found) break;
if (isText) {
const nodeEnd = pos + nodeSize;
if (anchor <= nodeEnd) {
offset += anchor - pos;
found = true;
break;
}
offset += nodeSize;
} else if (isBlock && pos > 0) {
offset += 1;
}
}
return found ? offset : -1; // -1 means "fell through"
}
// ── Single paragraph ──────────────────────────────────────────────────
test("cursor at start of single paragraph", () => {
// doc > paragraph(pos=0) > text "hello"(pos=1, size=5)
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
];
// Anchor at pos=1 → plain-text offset 0
assert.equal(mapAnchorToPlainText(nodes, 1), 0);
});
test("cursor at end of single paragraph", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
];
// Anchor at pos=6 → plain-text offset 5
assert.equal(mapAnchorToPlainText(nodes, 6), 5);
});
test("cursor mid-word in single paragraph", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
];
// Anchor at pos=3 → plain-text offset 2 (after "he")
assert.equal(mapAnchorToPlainText(nodes, 3), 2);
});
// ── Two paragraphs (the bug scenario) ─────────────────────────────────
// doc structure: doc > p1("hello") > p2("world")
// ProseMirror positions: doc=0, p1=0, "hello"=1..5, /p1=6, p2=7, "world"=8..12, /p2=13
// textContent = "hello\nworld" (11 chars)
test("cursor in second paragraph accounts for block boundary newline", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 }, // p1
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 }, // "hello"
{ isText: false, isBlock: true, pos: 7, nodeSize: 7 }, // p2 (pos > 0 → newline)
{ isText: true, isBlock: false, pos: 8, nodeSize: 5 }, // "world"
];
// Anchor at pos=8 → start of "world" → plain-text offset 6 ("hello\n" = 6 chars)
assert.equal(mapAnchorToPlainText(nodes, 8), 6);
});
test("cursor mid-word in second paragraph", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
{ isText: false, isBlock: true, pos: 7, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 8, nodeSize: 5 },
];
// Anchor at pos=10 → "wo|rld" → plain-text offset 8 ("hello\nwo" = 8 chars)
assert.equal(mapAnchorToPlainText(nodes, 10), 8);
});
// ── Three paragraphs (cumulative drift) ───────────────────────────────
// "aaa\nbbb\nccc" — without the fix, offset would drift by 1 per boundary
test("cursor in third paragraph accounts for two block boundaries", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 5 }, // p1
{ isText: true, isBlock: false, pos: 1, nodeSize: 3 }, // "aaa"
{ isText: false, isBlock: true, pos: 5, nodeSize: 5 }, // p2
{ isText: true, isBlock: false, pos: 6, nodeSize: 3 }, // "bbb"
{ isText: false, isBlock: true, pos: 10, nodeSize: 5 }, // p3
{ isText: true, isBlock: false, pos: 11, nodeSize: 3 }, // "ccc"
];
// Anchor at pos=11 → start of "ccc" → plain-text offset 8 ("aaa\nbbb\n" = 8 chars)
assert.equal(mapAnchorToPlainText(nodes, 11), 8);
});
test("cursor at end of third paragraph", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 5 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 3 },
{ isText: false, isBlock: true, pos: 5, nodeSize: 5 },
{ isText: true, isBlock: false, pos: 6, nodeSize: 3 },
{ isText: false, isBlock: true, pos: 10, nodeSize: 5 },
{ isText: true, isBlock: false, pos: 11, nodeSize: 3 },
];
// Anchor at pos=14 → end of "ccc" → plain-text offset 11 ("aaa\nbbb\nccc" = 11 chars)
assert.equal(mapAnchorToPlainText(nodes, 14), 11);
});
// ── First paragraph is unaffected (pos === 0, no newline) ─────────────
test("first block boundary at pos 0 does not add newline", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
];
// Anchor at pos=4 → plain-text offset 3 (no extra newline for first block)
assert.equal(mapAnchorToPlainText(nodes, 4), 3);
});

View File

@@ -14,6 +14,18 @@ import {
MentionHighlightExtension,
mentionHighlightKey,
} from "./mentionHighlightExtension";
import { buildPlainTextProjection } from "./plainTextProjection";
/**
* Plain-text edit descriptor returned by autocomplete hooks
* (mentions / channel links / emoji). Offsets are in plain-text space —
* see `buildPlainTextProjection`.
*/
export type AutocompleteEdit = {
replaceFromOffset: number;
replaceToOffset: number;
insertText: string;
};
export type RichTextEditorOptions = {
placeholder?: string;
@@ -262,7 +274,11 @@ export function useRichTextEditor({
},
onUpdate: ({ editor: ed }) => {
const markdown = getMarkdownFromEditor(ed);
const text = ed.state.doc.textContent;
// Use the same plain-text projection that `getPlainTextAndCursor`
// uses, so autocomplete detection sees the *same* string the
// cursor offset is mapped against. `state.doc.textContent` would
// diverge by 1 per hard-break / block boundary.
const text = buildPlainTextProjection(ed.state.doc).text;
onUpdateRef.current?.({ markdown, text });
},
},
@@ -332,86 +348,76 @@ export function useRichTextEditor({
}, [editor]);
/**
* Replace editor content and append a trailing space that survives parsing.
* Plain-text view of the document plus the cursor position in
* plain-text offset space. Used by autocomplete detection (mentions,
* channel links, emoji) which is shaped like a textarea.
*
* `setContent(markdown)` roundtrips through TipTap's markdown parser which
* strips trailing whitespace from text nodes. TipTap's `insertContent(" ")`
* also normalises it away. This method bypasses both by creating a raw
* ProseMirror text node and inserting it via a direct transaction — the
* only path that reliably preserves a literal trailing space.
*
* Used by mention and channel-link autocomplete insertion.
* The plain-text projection treats both `hardBreak` and inter-block
* boundaries as `\n` — matching `doc.textBetween(0, end, "\n", "\n")`.
* See `plainTextProjection.ts`.
*/
const setContentWithTrailingSpace = React.useCallback(
(markdown: string) => {
const getPlainTextAndCursor = React.useCallback((): {
text: string;
cursor: number;
} => {
if (!editor) return { text: "", cursor: 0 };
const projection = buildPlainTextProjection(editor.state.doc);
const anchor = editor.state.selection.anchor;
return {
text: projection.text,
cursor: projection.mapPMToTextOffset(anchor),
};
}, [editor]);
/**
* Replace a plain-text range with literal text, in a single native
* ProseMirror transaction.
*
* `fromOffset` and `toOffset` are in plain-text-offset space (the
* same space as `getPlainTextAndCursor`). `text` is inserted verbatim
* — including any trailing space — without a markdown re-parse.
*
* This replaces the old `setContentWithTrailingSpace` + full-doc
* markdown round-trip used by autocomplete: by going through
* `tr.insertText` we preserve active marks, hard breaks, list
* structure, undo history continuity, and any whitespace.
*
* Returns the new cursor PM position, mapped through `tr.mapping` so
* callers get a position that's valid after the transaction is
* applied.
*/
const replacePlainTextRange = React.useCallback(
(fromOffset: number, toOffset: number, text: string) => {
if (!editor) return;
editor.commands.setContent(markdown);
// Insert a literal space via a raw ProseMirror transaction so it
// bypasses TipTap's content parser which strips trailing whitespace.
const { tr, schema, doc } = editor.state;
const endPos = doc.content.size - 1; // before the closing node token
const spaceNode = schema.text(" ");
tr.insert(endPos, spaceNode);
// Place cursor after the inserted space.
const cursorPos = endPos + spaceNode.nodeSize;
tr.setSelection(TextSelection.create(tr.doc, cursorPos));
const projection = buildPlainTextProjection(editor.state.doc);
const fromPM = projection.mapTextOffsetToPM(fromOffset);
const toPM = projection.mapTextOffsetToPM(toOffset);
const tr = editor.state.tr.insertText(text, fromPM, toPM);
// Place cursor at the end of the inserted text. We map `toPM` (the
// right end of the replaced range) through the transaction's
// mapping — that's the post-transaction position right after the
// inserted text, valid even if mark normalisation shifted things.
// (Mapping `fromPM + text.length` directly would be a pre-image
// position that may not exist in the original doc, which throws
// "Position N out of range".)
const cursorPM = tr.mapping.map(toPM);
tr.setSelection(TextSelection.create(tr.doc, cursorPM));
editor.view.dispatch(tr);
editor.view.focus();
},
[editor],
);
/**
* Returns the plain-text content and an approximate cursor offset.
* Used to bridge the existing useMentions / useChannelLinks hooks which
* were designed for a plain <textarea>.
*/
const getTextAndCursor = React.useCallback((): {
text: string;
cursor: number;
} => {
if (!editor) return { text: "", cursor: 0 };
const { state } = editor;
const text = state.doc.textContent;
// Map ProseMirror position → plain-text offset.
// Walk through text nodes and accumulate length until we pass the anchor.
const anchor = state.selection.anchor;
let offset = 0;
let found = false;
state.doc.descendants((node, pos) => {
if (found) return false;
if (node.isText) {
const nodeEnd = pos + node.nodeSize;
if (anchor <= nodeEnd) {
offset += anchor - pos;
found = true;
return false;
}
offset += node.nodeSize;
} else if (node.isBlock && pos > 0) {
// Block boundaries add a newline in textContent
// (but only between blocks, not at the very start)
offset += 1;
}
return undefined;
});
if (!found) {
offset = text.length;
}
return { text, cursor: offset };
}, [editor]);
return {
editor,
getMarkdown,
isEmpty,
clearContent,
setContent,
setContentWithTrailingSpace,
focus,
getTextAndCursor,
getPlainTextAndCursor,
replacePlainTextRange,
};
}

View File

@@ -19,7 +19,10 @@ import {
hasMentionClipboardHtml,
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import {
type AutocompleteEdit,
useRichTextEditor,
} from "@/features/messages/lib/useRichTextEditor";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -153,8 +156,10 @@ export function MessageComposer({
setContent(markdown);
contentRef.current = markdown;
// Bridge to existing mention/channel/emoji detection hooks.
const { cursor } = richText.getTextAndCursor();
// Bridge to mention/channel/emoji detection hooks. `text` and the
// cursor are both in plain-text-offset space (treating hardBreak
// and inter-block boundaries as `\n`).
const { cursor } = richText.getPlainTextAndCursor();
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
@@ -226,51 +231,53 @@ export function MessageComposer({
// ── Autofocus on mount / channel switch ─────────────────────────────
useComposerAutofocus(richText.focus, effectiveDraftKey, disabled);
// ── Mention / channel autocomplete insertion ────────────────────────
// ── Mention / channel / emoji autocomplete insertion ────────────────
// Hooks return a plain-text edit descriptor; `replacePlainTextRange`
// applies it as a single ProseMirror transaction (no markdown round-trip).
const applyAutocompleteEdit = React.useCallback(
(edit: AutocompleteEdit) => {
richText.replacePlainTextRange(
edit.replaceFromOffset,
edit.replaceToOffset,
edit.insertText,
);
},
[richText.replacePlainTextRange],
);
const applyMentionInsert = React.useCallback(
(suggestion: MentionSuggestion) => {
const { text, cursor } = richText.getTextAndCursor();
const result = mentions.insertMention(suggestion, text, cursor);
// setContentWithTrailingSpace re-injects a space after the markdown
// roundtrip so the cursor lands ready for the next word.
richText.setContentWithTrailingSpace(result.nextContent);
setContent(result.nextContent);
contentRef.current = result.nextContent;
const { cursor } = richText.getPlainTextAndCursor();
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
},
[
applyAutocompleteEdit,
mentions.insertMention,
richText.getTextAndCursor,
richText.setContentWithTrailingSpace,
richText.getPlainTextAndCursor,
],
);
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
const { text, cursor } = richText.getTextAndCursor();
const result = channelLinks.insertChannel(suggestion, text, cursor);
richText.setContentWithTrailingSpace(result.nextContent);
setContent(result.nextContent);
contentRef.current = result.nextContent;
const { cursor } = richText.getPlainTextAndCursor();
applyAutocompleteEdit(channelLinks.insertChannel(suggestion, cursor));
},
[
applyAutocompleteEdit,
channelLinks.insertChannel,
richText.getTextAndCursor,
richText.setContentWithTrailingSpace,
richText.getPlainTextAndCursor,
],
);
const applyEmojiInsert = React.useCallback(
(suggestion: EmojiSuggestion) => {
const { text, cursor } = richText.getTextAndCursor();
const result = emojiAutocomplete.insertEmoji(suggestion, text, cursor);
richText.setContentWithTrailingSpace(result.nextContent);
setContent(result.nextContent);
contentRef.current = result.nextContent;
const { cursor } = richText.getPlainTextAndCursor();
applyAutocompleteEdit(emojiAutocomplete.insertEmoji(suggestion, cursor));
},
[
applyAutocompleteEdit,
emojiAutocomplete.insertEmoji,
richText.getTextAndCursor,
richText.setContentWithTrailingSpace,
richText.getPlainTextAndCursor,
],
);
@@ -288,7 +295,7 @@ export function MessageComposer({
// ── @ mention picker (toolbar button) ───────────────────────────────
const openMentionPicker = React.useCallback(() => {
if (!richText.editor) return;
const { text, cursor } = richText.getTextAndCursor();
const { text, cursor } = richText.getPlainTextAndCursor();
// Check if there's already an @-query in progress
const beforeCursor = text.slice(0, cursor);
@@ -306,12 +313,12 @@ export function MessageComposer({
setIsEmojiPickerOpen(false);
// Trigger mention detection after inserting @
const updatedText = richText.editor.state.doc.textContent;
const { cursor: updatedCursor } = richText.getTextAndCursor();
const { text: updatedText, cursor: updatedCursor } =
richText.getPlainTextAndCursor();
mentions.updateMentionQuery(updatedText, updatedCursor);
}, [
richText.editor,
richText.getTextAndCursor,
richText.getPlainTextAndCursor,
richText.focus,
mentions.updateMentionQuery,
]);

View File

@@ -74,6 +74,34 @@ test("mention button opens autocomplete and inserts a selected member", async ({
await expect(input).toHaveText("Hey @alice ");
});
test("inserting a mention preserves Shift+Enter newlines (regression: bug #2)", async ({
page,
}) => {
// Before PR #618, mention insertion round-tripped through
// `setContent(markdown)`, which collapsed every Shift+Enter hard
// break to a single space. After the fix, autocomplete uses a
// native ProseMirror `tr.insertText` transaction and the line
// breaks survive.
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.click();
await page.keyboard.type("line one");
await page.keyboard.press("Shift+Enter");
await page.keyboard.type("line two @ali");
const dropdown = autocomplete(page);
await expect(dropdown.getByText("alice")).toBeVisible();
await dropdown.getByText("alice").click();
// Both lines must still be present, separated by a real line break
// (rendered as a `<br>` by Tiptap; the projection sees `\n`).
await expect(input).toHaveText(/line one[\s\S]*line two @alice/);
await expect(input.locator("br")).toHaveCount(1);
});
test("keyboard navigation selects mention with Enter", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();