mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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,
|
||||
]);
|
||||
|
||||
272
desktop/src/features/messages/lib/plainTextProjection.test.mjs
Normal file
272
desktop/src/features/messages/lib/plainTextProjection.test.mjs
Normal 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)})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
237
desktop/src/features/messages/lib/plainTextProjection.ts
Normal file
237
desktop/src/features/messages/lib/plainTextProjection.ts
Normal 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 };
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user