mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(composer): preserve caret when inserting mentions mid-message (#6531)
**Category:** fix **User Impact:** Users can insert mentions earlier in a draft and continue typing without the caret corrupting the rest of the message. **Problem:** Caret correction ran after every document change, so typing a mention before existing text repeatedly advanced across the mention separator and interleaved spaces into the draft. **Solution:** Limit correction to the autocomplete settlement it was designed for, with transaction-level and browser-level regression coverage for known and unregistered mentions. <details> <summary>File changes</summary> **desktop/src/features/messages/lib/mentionHighlightExtension.ts** Restricts trailing-space caret advancement to an armed autocomplete settlement instead of every document change. **desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs** Exercises the real ProseMirror plugin state and verifies mid-draft mention typing, unknown tokens, end-of-message typing, and completed-mention separators. **desktop/tests/e2e/mentions.spec.ts** Reproduces the reported composer workflow in Chromium and covers the same corruption path for an unregistered `@token`. </details> ## Reproduction steps 1. Open a channel and enter `hello world` in the composer. 2. Move the caret between `hello` and ` world`. 3. Type ` @bo`, select `bob` from autocomplete, and continue typing `abc`. 4. Confirm the composer reads `hello @bob abc world` with the caret after `abc`. 5. Repeat with an unregistered token such as ` @zzq` and confirm the existing text remains intact. ## Before / After | Before | After | | --- | --- | | Typing after a mid-draft mention walks the caret through the existing message. | Continued typing stays after the inserted mention. | |  |  | --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { EditorState, TextSelection } from "@tiptap/pm/state";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
createMentionCaretSettlement,
|
||||
findHighlightMatches,
|
||||
insertPosForMentionTextInput,
|
||||
MentionHighlightExtension,
|
||||
mentionTextInputInsertPos,
|
||||
positionAfterArrowLeftThroughMentionSpace,
|
||||
selectionAfterMentionTrailingSpace,
|
||||
@@ -213,7 +215,6 @@ test("shouldAdvanceMentionCaret restores a remap while this editor is settling",
|
||||
from: 7,
|
||||
next: 8,
|
||||
settling: true,
|
||||
docChanged: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -225,21 +226,18 @@ test("shouldAdvanceMentionCaret does not steal ArrowLeft after settlement is can
|
||||
from: 7,
|
||||
next: 8,
|
||||
settling: false,
|
||||
docChanged: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldAdvanceMentionCaret still advances after a document change", () => {
|
||||
test("shouldAdvanceMentionCaret does not advance on an unsettled document change", () => {
|
||||
// Regression: `docChanged` used to force an advance. That walked the caret
|
||||
// across a mention's trailing space on every keystroke, so typing `@name`
|
||||
// before existing text interleaved spaces into the draft.
|
||||
assert.equal(
|
||||
shouldAdvanceMentionCaret({
|
||||
from: 7,
|
||||
next: 8,
|
||||
settling: false,
|
||||
docChanged: true,
|
||||
}),
|
||||
true,
|
||||
shouldAdvanceMentionCaret({ from: 7, next: 8, settling: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -312,3 +310,71 @@ test("assignMentionHighlightNames updates when a new mention is added", () => {
|
||||
);
|
||||
assert.deepEqual(storage.names, ["bob", "quinn"]);
|
||||
});
|
||||
|
||||
// ── caret regression: typing a mention before existing text ───────────
|
||||
//
|
||||
// Drives the real plugin through EditorState so `appendTransaction` runs.
|
||||
// Before the fix, every keystroke advanced the caret across a mention's
|
||||
// trailing space, interleaving spaces into the rest of the draft.
|
||||
|
||||
function editorStateWithMentionHighlight(initialText, names) {
|
||||
// The plugin factory only reads `this.storage`, so a minimal stand-in is
|
||||
// enough to exercise it without constructing a full editor (needs a DOM).
|
||||
const plugins = MentionHighlightExtension.config.addProseMirrorPlugins.call({
|
||||
storage: { names, agentNames: [], channelNames: [] },
|
||||
});
|
||||
return EditorState.create({
|
||||
doc: document(paragraph(text(initialText))),
|
||||
schema,
|
||||
plugins,
|
||||
});
|
||||
}
|
||||
|
||||
/** Insert `typed` one character at a time at `startPos`, as typing does. */
|
||||
function typeAt(state, startPos, typed) {
|
||||
let next = state.apply(
|
||||
state.tr.setSelection(TextSelection.create(state.doc, startPos)),
|
||||
);
|
||||
for (const char of typed) {
|
||||
const { from, to } = next.selection;
|
||||
const tr = next.tr.insertText(char, from, to);
|
||||
tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(to, 1)));
|
||||
next = next.apply(tr);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
test("typing a mention before existing text does not interleave spaces", () => {
|
||||
// Caret placed just after "hello" in "hello world", then " @qu" is typed.
|
||||
const state = editorStateWithMentionHighlight("hello world", ["quinn"]);
|
||||
const typed = typeAt(state, 1 + "hello".length, " @qu");
|
||||
assert.equal(typed.doc.textContent, "hello @qu world");
|
||||
});
|
||||
|
||||
test("typing a mention before existing text keeps the caret with the text", () => {
|
||||
const state = editorStateWithMentionHighlight("hello world", ["quinn"]);
|
||||
const typed = typeAt(state, 1 + "hello".length, " @qu");
|
||||
assert.equal(typed.selection.from, 1 + "hello @qu".length);
|
||||
});
|
||||
|
||||
test("an unknown @token before existing text is not rewritten either", () => {
|
||||
// The trailing-space scan is purely textual, so it fired even for names
|
||||
// that were never registered as mentions.
|
||||
const state = editorStateWithMentionHighlight("hello world", []);
|
||||
const typed = typeAt(state, 1 + "hello".length, " @zz");
|
||||
assert.equal(typed.doc.textContent, "hello @zz world");
|
||||
});
|
||||
|
||||
test("typing a mention at the end of a message still works", () => {
|
||||
const state = editorStateWithMentionHighlight("hello world", ["quinn"]);
|
||||
const typed = typeAt(state, 1 + "hello world".length, " @qu");
|
||||
assert.equal(typed.doc.textContent, "hello world @qu");
|
||||
});
|
||||
|
||||
test("typing after a completed mention keeps the separator intact", () => {
|
||||
// "@quinn " already exists; typing at the very end must not consume or
|
||||
// duplicate the trailing space.
|
||||
const state = editorStateWithMentionHighlight("@quinn world", ["quinn"]);
|
||||
const typed = typeAt(state, 1 + "@quinn world".length, "!");
|
||||
assert.equal(typed.doc.textContent, "@quinn world!");
|
||||
});
|
||||
|
||||
@@ -40,19 +40,22 @@ export function createMentionCaretSettlement(): MentionCaretSettlement {
|
||||
* Whether to move an empty caret from `from` to `next` after a mention
|
||||
* trailing space. Settlement is per editor: autocomplete arms it, and
|
||||
* ArrowLeft/click cancel it so we do not steal an intentional caret.
|
||||
*
|
||||
* Only an armed settlement may advance the caret. Advancing on any
|
||||
* document change instead walked the caret across the separator on every
|
||||
* keystroke, so typing `@name` before existing text interleaved spaces
|
||||
* into the draft (`hello @q uworld`).
|
||||
*/
|
||||
export function shouldAdvanceMentionCaret({
|
||||
from,
|
||||
next,
|
||||
settling,
|
||||
docChanged,
|
||||
}: {
|
||||
from: number;
|
||||
next: number;
|
||||
settling: boolean;
|
||||
docChanged: boolean;
|
||||
}): boolean {
|
||||
return next !== from && (settling || docChanged);
|
||||
return next !== from && settling;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,7 +337,7 @@ export const MentionHighlightExtension = Extension.create({
|
||||
return oldDecorations.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
appendTransaction(transactions, _oldState, newState) {
|
||||
appendTransaction(_transactions, _oldState, newState) {
|
||||
if (!newState.selection.empty) {
|
||||
settlement.cancel();
|
||||
return null;
|
||||
@@ -346,7 +349,6 @@ export const MentionHighlightExtension = Extension.create({
|
||||
from,
|
||||
next,
|
||||
settling: settlement.peek() !== null,
|
||||
docChanged: transactions.some((tr) => tr.docChanged),
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
|
||||
@@ -885,6 +885,53 @@ test("clicking a person mention chip edge is not treated as after the trailing s
|
||||
expect(text).not.toMatch(/@bob x/);
|
||||
});
|
||||
|
||||
test("typing a mention before existing text does not interleave spaces", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Regression (the reported repro): with a draft already written, place the
|
||||
// caret earlier in the message, type a partial mention, pick a suggestion,
|
||||
// then keep typing. Caret correction used to fire on every document change
|
||||
// and walk the caret across the mention's trailing space, so each keystroke
|
||||
// pushed a space further into the rest of the draft.
|
||||
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.fill("hello world");
|
||||
|
||||
// Click between "hello" and " world", then open autocomplete there.
|
||||
await input.focus();
|
||||
for (let i = 0; i < " world".length; i++) {
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
}
|
||||
await page.keyboard.type(" @bo");
|
||||
await autocomplete(page).getByText("bob").click();
|
||||
await page.keyboard.type("abc");
|
||||
|
||||
await expect(input).toHaveText("hello @bob abc world");
|
||||
});
|
||||
|
||||
test("typing an unregistered @token before existing text is left alone", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The trailing-space scan is purely textual, so it also fired for tokens
|
||||
// that were never registered as mentions.
|
||||
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.fill("hello world");
|
||||
await input.focus();
|
||||
for (let i = 0; i < " world".length; i++) {
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
}
|
||||
await page.keyboard.type(" @zzq");
|
||||
|
||||
await expect(input).toHaveText("hello @zzq world");
|
||||
});
|
||||
|
||||
test("channel references keep caret movement through the channel name", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user