From 5206a59c59e4ba0c2396354cc834ed53f614183e Mon Sep 17 00:00:00 2001 From: Ying Zhong <0x00eeee@gmail.com> Date: Wed, 17 Jun 2026 09:55:30 +0800 Subject: [PATCH] Improve 3-backtick to insert code blocks --- CoreEditor/src/modules/commands/index.ts | 26 +++++- .../src/modules/input/insertCodeBlock.ts | 84 ++++++++++++++----- .../src/modules/snippets/insertSnippet.ts | 5 +- CoreEditor/test/commands.test.ts | 45 ++++++++++ CoreEditor/test/input.test.ts | 42 ++++++++++ 5 files changed, 179 insertions(+), 23 deletions(-) diff --git a/CoreEditor/src/modules/commands/index.ts b/CoreEditor/src/modules/commands/index.ts index a541a0e28..67b0b9166 100644 --- a/CoreEditor/src/modules/commands/index.ts +++ b/CoreEditor/src/modules/commands/index.ts @@ -21,6 +21,7 @@ import toggleLineLeadingMark from './toggleLineLeadingMark'; import toggleListStyle from './toggleListStyle'; import replaceSelections from './replaceSelections'; import insertBlockWithMarks from './insertBlockWithMarks'; +import insertSnippet from '../snippets/insertSnippet'; export function toggleBold() { toggleBlockWithMarks('**', '**', 'StrongEmphasis', 'EmphasisMark'); @@ -88,7 +89,30 @@ export function insertHorizontalRule() { } export function insertCodeBlock() { - insertBlockWithMarks('```'); + const state = window.editor.state; + const { main, ranges } = state.selection; + const selected = state.sliceDoc(main.from, main.to); + + // Snippets only handle a single, single-line selection, otherwise keep the plain wrapping behavior. + // Note: sliceDoc always uses LF regardless of state.lineBreak, so detect line breaks with '\n'. + if (ranges.length > 1 || selected.includes('\n')) { + insertBlockWithMarks('```'); + return; + } + + const doc = state.doc; + const lineBreak = state.lineBreak; + const fence = '`'.repeat(3); + const prefix = doc.lineAt(main.from).from === main.from ? '' : lineBreak; + const suffix = doc.lineAt(main.to).to === main.to ? '' : lineBreak; + const content = selected.replace(/[{}]/g, ch => `\\${ch}`); // Escape snippet placeholder delimiters + + // Placeholders for the language and the content, on their own lines + insertSnippet( + `${prefix}${fence}#{}${lineBreak}#{${content}}${lineBreak}${fence}${suffix}`, + '', + { from: main.from, to: main.to }, + ); } export function insertMathBlock() { diff --git a/CoreEditor/src/modules/input/insertCodeBlock.ts b/CoreEditor/src/modules/input/insertCodeBlock.ts index 0749825c4..f13b96b1c 100644 --- a/CoreEditor/src/modules/input/insertCodeBlock.ts +++ b/CoreEditor/src/modules/input/insertCodeBlock.ts @@ -1,5 +1,12 @@ -import { EditorSelection } from '@codemirror/state'; +import { EditorSelection, EditorState } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; +import { SyntaxNode } from '@lezer/common'; +import insertSnippet from '../snippets/insertSnippet'; + +const mark = '`'; +const pair = mark.repeat(2); +const fence = mark.repeat(3); /** * When backtick key is detected, try inserting a code block if we already have two backticks. @@ -7,27 +14,64 @@ import { EditorView } from '@codemirror/view'; export default function insertCodeBlock(editor: EditorView) { const state = editor.state; const doc = state.doc; - const mark = '`'; - const prefix = mark + state.lineBreak; - - editor.dispatch(state.changeByRange(({ from, to }) => { - if (doc.sliceString(from - 2, from) !== `${mark}${mark}`) { - // Fallback to inserting only one backtick - return { - range: EditorSelection.cursor(from + mark.length), - changes: { from, to, insert: mark }, - }; - } + const { from } = state.selection.main; + + // Insert a code block, always placing it on its own lines + if (shouldInsertCodeBlock(state, from)) { + const line = doc.lineAt(from); + const lineBreak = state.lineBreak; + + // Break out surrounding text so the fences never share a line with other content + const leading = doc.sliceString(line.from, from - 2).trim() === '' ? '' : lineBreak; + const trailing = doc.sliceString(from, line.to).trim() === '' ? '' : lineBreak; + + // Replace the two existing backticks so the opening fence can start on its own line + insertSnippet( + `${leading}${fence}#{}${lineBreak}#{}${lineBreak}${fence}${trailing}`, + '', + { from: from - 2, to: from }, + ); - // Insert an empty code block and move the cursor to the empty line - return { - range: EditorSelection.cursor(from + mark.length + 1), // Don't use prefix.length, it doesn't work for CRLF - changes: { - from, to, insert: prefix + `${state.lineBreak}${mark}${mark}${mark}`, - }, - }; - })); + return true; + } + + // Fallback to inserting only one backtick + editor.dispatch(state.changeByRange(({ from, to }) => ({ + range: EditorSelection.cursor(from + mark.length), + changes: { from, to, insert: mark }, + }))); // Intercepted, default behavior is ignored return true; } + +/** + * A code block is inserted when two backticks precede an empty cursor and we are not already inside code. + */ +function shouldInsertCodeBlock(state: EditorState, pos: number) { + // Only expand for a single empty selection with room for two preceding backticks. + // Multiple ranges fall through to the per-range fallback so the backtick isn't dropped. + if (pos < 2 || state.selection.ranges.length > 1 || !state.selection.main.empty) { + return false; + } + + // Requires exactly two backticks right before the cursor + const doc = state.doc; + if (doc.sliceString(pos - 2, pos) !== pair) { + return false; + } + + // Don't start a new block when the cursor is already inside code + for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); node !== null; node = node.parent) { + if (node.name === 'FencedCode' || node.name === 'CodeBlock') { + return false; + } + + // Ignore an inline span that our just-typed backticks would open, only bail when truly inside one + if (node.name === 'InlineCode' && node.from < pos - 2) { + return false; + } + } + + return true; +} diff --git a/CoreEditor/src/modules/snippets/insertSnippet.ts b/CoreEditor/src/modules/snippets/insertSnippet.ts index eb04cc6c6..e02bc8f5e 100644 --- a/CoreEditor/src/modules/snippets/insertSnippet.ts +++ b/CoreEditor/src/modules/snippets/insertSnippet.ts @@ -4,10 +4,11 @@ import { snippet } from '@codemirror/autocomplete'; * Insert snippet with placeholder tokens, it only handles the main selection. * * @param template Template string as described in https://codemirror.net/docs/ref/#autocomplete.snippet + * @param range Text range to replace, defaults to the main selection */ -export default function insertSnippet(template: string, label = '') { +export default function insertSnippet(template: string, label = '', range?: { from: number; to: number }) { const editor = window.editor; - const { from, to } = editor.state.selection.main; + const { from, to } = range ?? editor.state.selection.main; // Make #{} the last one to be the border snippet(template + '#{}')(editor, { label }, from, to); diff --git a/CoreEditor/test/commands.test.ts b/CoreEditor/test/commands.test.ts index 450566d90..cfcc6d8d1 100644 --- a/CoreEditor/test/commands.test.ts +++ b/CoreEditor/test/commands.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from '@jest/globals'; +import { EditorSelection, EditorState } from '@codemirror/state'; import * as editor from './utils/editor'; import * as commands from '../src/modules/commands'; @@ -106,3 +107,47 @@ describe('Commands module', () => { expect(editor.getText()).toBe('Hello'); }); }); + +describe('insertCodeBlock command', () => { + function run(doc: string, ranges: [number, number][]) { + editor.setUp(doc, EditorState.allowMultipleSelections.of(true)); + window.editor.dispatch({ + selection: EditorSelection.create(ranges.map(([a, b]) => EditorSelection.range(a, b))), + }); + + commands.insertCodeBlock(); + return editor.getText(); + } + + test('inserts an empty block for an empty selection', () => { + expect(run('', [[0, 0]])).toBe('```\n\n```'); + }); + + test('wraps a single-line selection as the block content', () => { + expect(run('code', [[0, 4]])).toBe('```\ncode\n```'); + }); + + test('breaks the fences onto their own lines for a mid-line selection', () => { + expect(run('abcXYZdef', [[3, 6]])).toBe('abc\n```\nXYZ\n```\ndef'); + }); + + test('preserves braces in the selected content', () => { + expect(run('a{b}c', [[0, 5]])).toBe('```\na{b}c\n```'); + }); + + test('preserves backslashes in the selected content', () => { + expect(run('C:\\path\\', [[0, 8]])).toBe('```\nC:\\path\\\n```'); + }); + + test('falls back to plain wrapping for a multi-line selection', () => { + const out = run('l1\nl2', [[0, 5]]); + expect(out).not.toContain('#{'); + expect(out).toBe('```\nl1\nl2\n```\n'); + }); + + test('falls back to plain wrapping for multiple selections', () => { + const out = run('ab', [[0, 0], [2, 2]]); + expect(out).not.toContain('#{'); + expect((out.match(/```/g) ?? []).length).toBe(4); + }); +}); diff --git a/CoreEditor/test/input.test.ts b/CoreEditor/test/input.test.ts index 4bdaf174d..cd71b4d37 100644 --- a/CoreEditor/test/input.test.ts +++ b/CoreEditor/test/input.test.ts @@ -3,6 +3,7 @@ import { EditorSelection, EditorState } from '@codemirror/state'; import { filterTransaction, observeChanges } from '../src/modules/input'; import { editingState } from '../src/common/store'; import wrapBlock from '../src/modules/input/wrapBlock'; +import insertCodeBlock from '../src/modules/input/insertCodeBlock'; import * as editor from './utils/editor'; describe('Input module', () => { @@ -18,6 +19,47 @@ describe('Input module', () => { }); }); +describe('insertCodeBlock (backtick expansion)', () => { + function type(doc: string, ranges: [number, number][]) { + editor.setUp(doc, EditorState.allowMultipleSelections.of(true)); + window.editor.dispatch({ + selection: EditorSelection.create(ranges.map(([a, b]) => EditorSelection.range(a, b))), + }); + insertCodeBlock(window.editor); + return editor.getText(); + } + + test('expands two backticks and an empty cursor into a block', () => { + expect(type('``', [[2, 2]])).toBe('```\n\n```'); + }); + + test('breaks the block onto its own lines around surrounding text', () => { + expect(type('foo``', [[5, 5]])).toBe('foo\n```\n\n```'); + expect(type('``bar', [[2, 2]])).toBe('```\n\n```\nbar'); + }); + + test('does not expand when the cursor is inside a fenced code block', () => { + expect(type('```js\n``\n```', [[8, 8]])).toBe('```js\n```\n```'); + }); + + test('does not expand when the cursor is inside an existing inline code span', () => { + expect(type('``x``', [[5, 5]])).toBe('``x```'); + }); + + test('inserts a single backtick when fewer than two precede the cursor', () => { + expect(type('', [[0, 0]])).toBe('`'); + expect(type('`', [[1, 1]])).toBe('``'); + }); + + test('does not expand with a non-empty selection', () => { + expect(type('``cd', [[2, 4]])).toBe('```'); + }); + + test('does not expand with multiple cursors, so no backtick is dropped', () => { + expect(type('``\n``', [[2, 2], [5, 5]])).toBe('```\n```'); + }); +}); + describe('Composition over-delete clamp', () => { afterEach(() => { editingState.compositionEnded = true;