From bfeb0b3a6c2a912a7ec6b39e70bb4e076c7c14a4 Mon Sep 17 00:00:00 2001 From: shilohlee98 Date: Fri, 14 Aug 2026 22:05:47 +0800 Subject: [PATCH 1/3] feat: improve code block creation UX --- src/components/editor/Editor.tsx | 60 +++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 32a8159c..7fffbd4f 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -109,6 +109,60 @@ import { FolderPlusIcon, } from "../icons"; +const ScratchCodeBlock = CodeBlockLowlight.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockView); + }, + + addKeyboardShortcuts() { + return { + ...this.parent?.(), + "Mod-Alt-c": () => { + if (this.editor.isActive(this.name)) { + return this.editor.commands.toggleCodeBlock(); + } + + const { selection } = this.editor.state; + if (selection.empty && selection.$from.parentOffset > 0) { + return this.editor + .chain() + .splitBlock({ keepMarks: false }) + .setCodeBlock() + .run(); + } + + return this.editor.commands.setCodeBlock(); + }, + }; + }, + + 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, { @@ -1054,11 +1108,7 @@ export function Editor({ }, codeBlock: false, }), - CodeBlockLowlight.extend({ - addNodeView() { - return ReactNodeViewRenderer(CodeBlockView); - }, - }).configure({ + ScratchCodeBlock.configure({ lowlight, defaultLanguage: null, }), From 67e16b989c30d20882ae0262556cb09ba562dc4e Mon Sep 17 00:00:00 2001 From: shilohlee98 Date: Fri, 14 Aug 2026 22:31:33 +0800 Subject: [PATCH 2/3] fix: create code blocks based on cursor position --- src/components/editor/Editor.tsx | 37 +++++++++++++++++--------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 7fffbd4f..8ada6dd1 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -109,6 +109,24 @@ 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); @@ -117,22 +135,7 @@ const ScratchCodeBlock = CodeBlockLowlight.extend({ addKeyboardShortcuts() { return { ...this.parent?.(), - "Mod-Alt-c": () => { - if (this.editor.isActive(this.name)) { - return this.editor.commands.toggleCodeBlock(); - } - - const { selection } = this.editor.state; - if (selection.empty && selection.$from.parentOffset > 0) { - return this.editor - .chain() - .splitBlock({ keepMarks: false }) - .setCodeBlock() - .run(); - } - - return this.editor.commands.setCodeBlock(); - }, + "Mod-Alt-c": () => toggleCodeBlockAtCursor(this.editor), }; }, @@ -407,7 +410,7 @@ function FormatBar({ editor.chain().focus().toggleCodeBlock().run()} + onClick={() => toggleCodeBlockAtCursor(editor)} isActive={editor.isActive("codeBlock")} title={`Code Block (${mod}${isMac ? "" : "+"}${alt}${isMac ? "" : "+"}C)`} > From e213c6b9a7e89cdfeddb2702789f9540c6491bac Mon Sep 17 00:00:00 2001 From: shilohlee98 Date: Fri, 14 Aug 2026 22:36:16 +0800 Subject: [PATCH 3/3] fix: support cursor-aware code block creation --- src/components/editor/Editor.tsx | 43 +++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 8ada6dd1..b651b9a7 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -133,9 +133,50 @@ const ScratchCodeBlock = CodeBlockLowlight.extend({ }, addKeyboardShortcuts() { + const parentShortcuts = this.parent?.() ?? {}; + return { - ...this.parent?.(), + ...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; + }, }; },