mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add code block support to message composer (#788)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user