fix(composer): prevent phantom newline on Enter by moving submit into Tiptap extension (#324)

This commit is contained in:
Taylor Ho
2026-04-15 05:38:43 -10:00
committed by GitHub
parent 94d4e16461
commit c236287977
2 changed files with 41 additions and 22 deletions

View File

@@ -19,6 +19,11 @@ export type RichTextEditorOptions = {
editable?: boolean;
mentionNames?: string[];
channelNames?: string[];
/** Called on plain Enter (submit). Handled inside Tiptap's extension system
* so it fires *before* ProseMirror's default splitBlock behaviour. */
onSubmit?: () => void;
/** When true, plain Enter is passed through (e.g. to select an autocomplete item). */
isAutocompleteOpen?: React.RefObject<boolean>;
};
/**
@@ -36,10 +41,15 @@ export function useRichTextEditor({
editable = true,
mentionNames,
channelNames,
onSubmit,
isAutocompleteOpen,
}: RichTextEditorOptions) {
const onUpdateRef = React.useRef(onUpdate);
onUpdateRef.current = onUpdate;
const onSubmitRef = React.useRef(onSubmit);
onSubmitRef.current = onSubmit;
const placeholderRef = React.useRef(placeholder);
placeholderRef.current = placeholder;
@@ -148,6 +158,24 @@ export function useRichTextEditor({
};
},
}),
// Plain Enter → submit the message. This runs inside ProseMirror's
// keymap pipeline so it fires *before* the default splitBlock command,
// preventing the phantom paragraph-split that caused \n\n in messages.
Extension.create({
name: "submitOnEnter",
addKeyboardShortcuts() {
return {
Enter: () => {
// Let autocomplete dropdowns consume Enter first.
if (isAutocompleteOpen?.current) return false;
// No submit callback → fall through to default behaviour.
if (!onSubmitRef.current) return false;
onSubmitRef.current();
return true; // prevents splitBlock
},
};
},
}),
MentionHighlightExtension,
Placeholder.configure({
placeholder: () => placeholderRef.current ?? "Write a message…",

View File

@@ -110,6 +110,13 @@ export function MessageComposer({
editTargetRef.current = editTarget;
channelIdRef.current = channelId;
// ── Refs consumed by Tiptap's submitOnEnter extension ──────────────
const isAutocompleteOpenRef = React.useRef(false);
isAutocompleteOpenRef.current =
mentions.isMentionOpen || channelLinks.isChannelOpen;
const submitMessageRef = React.useRef<() => void>(() => {});
// ── Computed placeholder ─────────────────────────────────────────────
const computedPlaceholder = editTarget
? "Edit your message"
@@ -124,6 +131,8 @@ export function MessageComposer({
editable: !disabled,
mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames,
onSubmit: () => submitMessageRef.current(),
isAutocompleteOpen: isAutocompleteOpenRef,
onUpdate: ({ markdown, text }) => {
setContent(markdown);
contentRef.current = markdown;
@@ -361,6 +370,7 @@ export function MessageComposer({
richText.clearContent,
richText.setContent,
]);
submitMessageRef.current = submitMessage;
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
@@ -372,8 +382,9 @@ export function MessageComposer({
// ── Keyboard handling ───────────────────────────────────────────────
// Tiptap handles formatting shortcuts (⌘B, ⌘I, etc.) natively.
// We intercept Enter (submit) and arrow keys (autocomplete) via a
// keydown listener on the editor wrapper.
// Plain Enter → submit is now handled inside the Tiptap `submitOnEnter`
// extension (fires before ProseMirror's splitBlock). This wrapper only
// handles autocomplete arrow/enter keys and Escape for edit mode.
const handleEditorKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
// Let autocomplete handle keys first
@@ -399,32 +410,12 @@ export function MessageComposer({
onCancelEdit();
return;
}
if (event.key !== "Enter" || event.nativeEvent.isComposing) {
return;
}
// Shift+Enter or Ctrl+Enter → newline (Tiptap handles this natively
// via hard break, so let it through)
if (event.shiftKey || event.ctrlKey) {
return;
}
// Meta/Alt+Enter → let through (system shortcuts)
if (event.metaKey || event.altKey) {
return;
}
// Plain Enter → submit
event.preventDefault();
void submitMessage();
},
[
channelLinks.handleChannelKeyDown,
applyChannelInsert,
mentions.handleMentionKeyDown,
applyMentionInsert,
submitMessage,
onCancelEdit,
],
);