feat: add code block support to message composer (#788)

This commit is contained in:
Will Pfleger
2026-05-29 11:38:10 -07:00
committed by GitHub
parent 7beb0f8e68
commit 85861f33fe
5 changed files with 150 additions and 8 deletions
@@ -0,0 +1,79 @@
import { Extension, InputRule } from "@tiptap/core";
import type { Editor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
const FENCE_AT_START = /^(?:```|~~~)([a-z+]*)$/;
const FENCE_AFTER_BREAK = /(?:```|~~~)([a-z+]*)$/;
/**
* Detect ``` / ~~~ fence on Enter and create a code block instead of
* submitting. Returns true/false for the keyboard shortcut handler,
* or undefined when no fence was detected (caller should proceed).
*/
export function handleCodeFenceEnter(ed: Editor): boolean | undefined {
if (ed.isActive("codeBlock")) return undefined;
const { $cursor } = ed.state.selection as TextSelection;
if (!$cursor) return undefined;
const textBefore = $cursor.parent.textBetween(
0,
$cursor.parentOffset,
null,
"",
);
if (FENCE_AT_START.test(textBefore)) return false;
const m = textBefore.match(FENCE_AFTER_BREAK);
if (!m) return undefined;
const { tr, schema } = ed.state;
const hardBreakDocPos =
$cursor.start() + ($cursor.parentOffset - m[0].length);
const afterParagraph = $cursor.after();
tr.delete(hardBreakDocPos, $cursor.pos);
const mapped = tr.mapping.map(afterParagraph);
const attrs = m[1] ? { language: m[1] } : {};
tr.insert(mapped, schema.nodes.codeBlock.create(attrs));
tr.setSelection(TextSelection.near(tr.doc.resolve(mapped + 1)));
ed.view.dispatch(tr);
return true;
}
export function insertNewlineInCodeBlock(ed: Editor): boolean {
return ed
.chain()
.focus()
.command(({ tr, dispatch }) => {
if (dispatch) {
tr.replaceSelectionWith(ed.state.schema.text("\n"));
}
return true;
})
.run();
}
export const CodeBlockAfterHardBreak = Extension.create({
name: "codeBlockAfterHardBreak",
addInputRules() {
const codeBlockType = this.editor.schema.nodes.codeBlock;
return [
new InputRule({
find: /\n(?:```|~~~)([a-z+]*)[\s]$/,
handler: ({ state, range, match }) => {
const $from = state.doc.resolve(range.from);
if ($from.parent.type.name === "codeBlock") return null;
const afterParagraph = $from.after();
const attrs = match[1] ? { language: match[1] } : {};
state.tr.delete(range.from, range.to);
const mapped = state.tr.mapping.map(afterParagraph);
state.tr.insert(mapped, codeBlockType.create(attrs));
state.tr.setSelection(
TextSelection.near(state.tr.doc.resolve(mapped + 1)),
);
},
}),
];
},
});
@@ -15,6 +15,11 @@ import {
mentionHighlightKey,
} from "./mentionHighlightExtension";
import { buildPlainTextProjection } from "./plainTextProjection";
import {
CodeBlockAfterHardBreak,
handleCodeFenceEnter,
insertNewlineInCodeBlock,
} from "./codeBlockExtensions";
/**
* Plain-text edit descriptor returned by autocomplete hooks
@@ -199,6 +204,9 @@ export function useRichTextEditor({
return {
"Shift-Enter": ({ editor: ed }) => {
if (ed.isActive("codeBlock")) {
return insertNewlineInCodeBlock(ed);
}
// Empty last list item → exit list to paragraph below.
if (exitListIfEmptyLast(ed)) return true;
// Non-empty or non-last list item → split.
@@ -231,17 +239,20 @@ export function useRichTextEditor({
name: "submitOnEnter",
addKeyboardShortcuts() {
return {
Enter: () => {
// Let autocomplete dropdowns consume Enter first.
Enter: ({ editor: ed }) => {
if (isAutocompleteOpen?.current) return false;
// No submit callback → fall through to default behaviour.
if (!onSubmitRef.current) return false;
const fenceResult = handleCodeFenceEnter(ed);
if (fenceResult !== undefined) return fenceResult;
onSubmitRef.current();
return true; // prevents splitBlock
return true;
},
};
},
}),
CodeBlockAfterHardBreak,
MentionHighlightExtension,
Placeholder.configure({
placeholder: () => placeholderRef.current ?? "Write a message…",
@@ -8,6 +8,7 @@ import {
List,
ListOrdered,
Quote,
SquareCode,
Strikethrough,
} from "lucide-react";
@@ -24,6 +25,7 @@ type ActiveStates = {
italic: boolean;
strike: boolean;
code: boolean;
codeBlock: boolean;
link: boolean;
bulletList: boolean;
orderedList: boolean;
@@ -36,6 +38,7 @@ function getActiveStates(editor: Editor): ActiveStates {
italic: editor.isActive("italic"),
strike: editor.isActive("strike"),
code: editor.isActive("code"),
codeBlock: editor.isActive("codeBlock"),
link: editor.isActive("link"),
bulletList: editor.isActive("bulletList"),
orderedList: editor.isActive("orderedList"),
@@ -88,6 +91,10 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
editor?.chain().focus().toggleCode().run();
}, [editor]);
const toggleCodeBlock = React.useCallback(() => {
editor?.chain().focus().toggleCodeBlock().run();
}, [editor]);
const toggleLink = React.useCallback(() => {
if (!editor) return;
@@ -156,6 +163,12 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
action: toggleCode,
active: activeStates.code,
},
{
icon: SquareCode,
label: "Code block",
action: toggleCodeBlock,
active: activeStates.codeBlock,
},
{
icon: Link,
label: "Link",
+3 -2
View File
@@ -263,11 +263,12 @@ function createMarkdownComponents(
),
br: () => <br />,
code: ({ children, className, ...props }: React.ComponentProps<"code">) => {
const code = String(children).replace(/\n$/, "");
const rawCode = String(children);
const code = rawCode.replace(/\n$/, "");
const isFencedCodeBlock =
typeof className === "string" && className.includes("language-");
if (isFencedCodeBlock || code.includes("\n")) {
if (isFencedCodeBlock || rawCode.endsWith("\n") || code.includes("\n")) {
return (
<code
{...props}
+40 -2
View File
@@ -311,6 +311,35 @@ class ComposeBar extends HookConsumerWidget {
focusNode.requestFocus();
}
void applyCodeBlock() {
final text = controller.text;
final sel = controller.selection;
if (!sel.isValid) return;
if (sel.isCollapsed) {
final offset = sel.baseOffset;
const open = '```\n';
const close = '\n```';
final updated =
'${text.substring(0, offset)}$open$close${text.substring(offset)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: offset + open.length,
);
} else {
final selected = text.substring(sel.start, sel.end);
const open = '```\n';
const close = '\n```';
final updated =
'${text.substring(0, sel.start)}$open$selected$close${text.substring(sel.end)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: sel.start + open.length + selected.length + close.length,
);
}
focusNode.requestFocus();
}
// ----- Widget tree ----------------------------------------------------
final hasSuggestions =
@@ -365,7 +394,10 @@ class ComposeBar extends HookConsumerWidget {
children: [
// Formatting toolbar (toggled via Aa button).
if (showFormatting.value)
_FormattingToolbar(onFormat: applyFormat),
_FormattingToolbar(
onFormat: applyFormat,
onCodeBlock: applyCodeBlock,
),
if (hasAttachments || hasPendingUploads) ...[
_AttachmentStrip(
@@ -820,8 +852,9 @@ class _ChannelSuggestions extends StatelessWidget {
class _FormattingToolbar extends StatelessWidget {
final void Function(String prefix, [String? suffix]) onFormat;
final VoidCallback onCodeBlock;
const _FormattingToolbar({required this.onFormat});
const _FormattingToolbar({required this.onFormat, required this.onCodeBlock});
@override
Widget build(BuildContext context) {
@@ -849,6 +882,11 @@ class _FormattingToolbar extends StatelessWidget {
tooltip: 'Code',
onTap: () => onFormat('`'),
),
_FormatButton(
icon: LucideIcons.squareCode,
tooltip: 'Code block',
onTap: onCodeBlock,
),
],
),
);