diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx
index 32a8159c..b651b9a7 100644
--- a/src/components/editor/Editor.tsx
+++ b/src/components/editor/Editor.tsx
@@ -109,6 +109,104 @@ import {
FolderPlusIcon,
} from "../icons";
+function toggleCodeBlockAtCursor(editor: TiptapEditor): boolean {
+ if (editor.isActive("codeBlock")) {
+ return editor.chain().focus().toggleCodeBlock().run();
+ }
+
+ const { selection } = editor.state;
+ if (selection.empty && selection.$from.parentOffset > 0) {
+ return editor
+ .chain()
+ .focus()
+ .splitBlock({ keepMarks: false })
+ .setCodeBlock()
+ .run();
+ }
+
+ return editor.chain().focus().setCodeBlock().run();
+}
+
+const ScratchCodeBlock = CodeBlockLowlight.extend({
+ addNodeView() {
+ return ReactNodeViewRenderer(CodeBlockView);
+ },
+
+ addKeyboardShortcuts() {
+ const parentShortcuts = this.parent?.() ?? {};
+
+ return {
+ ...parentShortcuts,
+ "Mod-Alt-c": () => toggleCodeBlockAtCursor(this.editor),
+ Enter: (props) => {
+ const { selection } = this.editor.state;
+ const { $from } = selection;
+
+ if (
+ selection.empty &&
+ !this.editor.isActive(this.name) &&
+ !this.editor.isActive("code")
+ ) {
+ const textBeforeCursor = $from.parent.textBetween(
+ 0,
+ $from.parentOffset,
+ );
+ const match = textBeforeCursor.match(
+ /```([a-zA-Z0-9_+#.-]*)$/,
+ );
+
+ if (match) {
+ const triggerFrom = selection.from - match[0].length;
+ const command = this.editor
+ .chain()
+ .deleteRange({ from: triggerFrom, to: selection.from });
+
+ if ($from.parentOffset > match[0].length) {
+ command
+ .setTextSelection(triggerFrom)
+ .splitBlock({ keepMarks: false });
+ }
+
+ return command
+ .setCodeBlock(
+ match[1] ? { language: match[1].toLowerCase() } : undefined,
+ )
+ .run();
+ }
+ }
+
+ return parentShortcuts.Enter?.(props) ?? false;
+ },
+ };
+ },
+
+ addInputRules() {
+ return [
+ new InputRule({
+ // Convert after the first space following ``` or ```language.
+ // Unlike TipTap's default rule, this also works after text.
+ find: /```([a-zA-Z0-9_+#.-]*) $/,
+ handler: ({ state, range, match, chain }) => {
+ const command = chain().deleteRange(range);
+
+ if (state.doc.resolve(range.from).parentOffset > 0) {
+ command
+ .setTextSelection(range.from)
+ .splitBlock({ keepMarks: false });
+ }
+
+ command
+ .setCodeBlock(
+ match[1] ? { language: match[1].toLowerCase() } : undefined,
+ )
+ .run();
+ },
+ }),
+ ...(this.parent?.() ?? []),
+ ];
+ },
+});
+
function formatDateTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString(undefined, {
@@ -353,7 +451,7 @@ function FormatBar({
editor.chain().focus().toggleCodeBlock().run()}
+ onClick={() => toggleCodeBlockAtCursor(editor)}
isActive={editor.isActive("codeBlock")}
title={`Code Block (${mod}${isMac ? "" : "+"}${alt}${isMac ? "" : "+"}C)`}
>
@@ -1054,11 +1152,7 @@ export function Editor({
},
codeBlock: false,
}),
- CodeBlockLowlight.extend({
- addNodeView() {
- return ReactNodeViewRenderer(CodeBlockView);
- },
- }).configure({
+ ScratchCodeBlock.configure({
lowlight,
defaultLanguage: null,
}),