diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 012799b37..94432712f 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -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, ]); diff --git a/desktop/src/features/messages/lib/plainTextProjection.test.mjs b/desktop/src/features/messages/lib/plainTextProjection.test.mjs new file mode 100644 index 000000000..762c56c04 --- /dev/null +++ b/desktop/src/features/messages/lib/plainTextProjection.test.mjs @@ -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
maps to text offset just before the \\n", () => { + // PM: para=0, "hello"=1..5,
=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
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
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
", () => { + // PM: para=0, "line1"=1..5,
=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:

1 + "a"1 +

1), text "a" at 1..2 + // p2=3..4 (size 2:

1 +

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:

1 +

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)})`, + ); + } +}); diff --git a/desktop/src/features/messages/lib/plainTextProjection.ts b/desktop/src/features/messages/lib/plainTextProjection.ts new file mode 100644 index 000000000..109ae79a7 --- /dev/null +++ b/desktop/src/features/messages/lib/plainTextProjection.ts @@ -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 }; +} diff --git a/desktop/src/features/messages/lib/useChannelLinks.ts b/desktop/src/features/messages/lib/useChannelLinks.ts index 7ecb43f07..c33888863 100644 --- a/desktop/src/features/messages/lib/useChannelLinks.ts +++ b/desktop/src/features/messages/lib/useChannelLinks.ts @@ -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], ); diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts index d655911d2..beedd1f34 100644 --- a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -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], ); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 819a8dee3..d09eec76c 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -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], ); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.test.mjs b/desktop/src/features/messages/lib/useRichTextEditor.test.mjs deleted file mode 100644 index ce53969e1..000000000 --- a/desktop/src/features/messages/lib/useRichTextEditor.test.mjs +++ /dev/null @@ -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); -}); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index d04720e30..3c0c4399c 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -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. + * + * The plain-text projection treats both `hardBreak` and inter-block + * boundaries as `\n` — matching `doc.textBetween(0, end, "\n", "\n")`. + * See `plainTextProjection.ts`. + */ + 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. * - * `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. + * 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. * - * Used by mention and channel-link autocomplete insertion. + * Returns the new cursor PM position, mapped through `tr.mapping` so + * callers get a position that's valid after the transaction is + * applied. */ - const setContentWithTrailingSpace = React.useCallback( - (markdown: string) => { + 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