Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion CoreEditor/src/modules/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Comment thread
cyanzhong marked this conversation as resolved.

// 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 },
);
Comment thread
cyanzhong marked this conversation as resolved.
}

export function insertMathBlock() {
Expand Down
84 changes: 64 additions & 20 deletions CoreEditor/src/modules/input/insertCodeBlock.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,77 @@
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.
*/
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)) {
Comment thread
cyanzhong marked this conversation as resolved.
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;
Comment thread
cyanzhong marked this conversation as resolved.
}

// 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;
}
5 changes: 3 additions & 2 deletions CoreEditor/src/modules/snippets/insertSnippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions CoreEditor/test/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
42 changes: 42 additions & 0 deletions CoreEditor/test/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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;
Expand Down