Skip to content

Commit b0d0985

Browse files
authored
fix(core): trigger codeblock input rule on Enter and place cursor inside (#2686)
1 parent e077c1b commit b0d0985

3 files changed

Lines changed: 339 additions & 25 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
2+
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
3+
import type { PartialBlock } from "../defaultBlocks.js";
4+
import { getLanguageId, type CodeBlockOptions } from "./block.js";
5+
6+
/**
7+
* @vitest-environment jsdom
8+
*/
9+
10+
/**
11+
* Simulate typing text into the editor at the current cursor position.
12+
* This triggers input rules by calling the view's handleTextInput prop,
13+
* which is how ProseMirror processes keyboard text input.
14+
*/
15+
function simulateTextInput(editor: BlockNoteEditor, text: string) {
16+
const view = editor.prosemirrorView;
17+
const { from, to } = view.state.selection;
18+
const deflt = () => view.state.tr.insertText(text, from, to);
19+
const handled = view.someProp("handleTextInput", (f) =>
20+
f(view, from, to, text, deflt),
21+
);
22+
if (!handled) {
23+
view.dispatch(deflt());
24+
}
25+
}
26+
27+
function typeString(editor: BlockNoteEditor, str: string) {
28+
for (const char of str) {
29+
simulateTextInput(editor, char);
30+
}
31+
}
32+
33+
/**
34+
* Simulate a keyboard shortcut by invoking the view's handleKeyDown prop,
35+
* which is how ProseMirror routes keymap-based handlers like Enter.
36+
*/
37+
function pressKey(editor: BlockNoteEditor, key: string) {
38+
const view = editor.prosemirrorView;
39+
const event = new KeyboardEvent("keydown", { key });
40+
view.someProp("handleKeyDown", (f) => f(view, event));
41+
}
42+
43+
describe("Code block input rule", () => {
44+
let editor: BlockNoteEditor;
45+
const div = document.createElement("div");
46+
47+
beforeAll(() => {
48+
editor = BlockNoteEditor.create();
49+
editor.mount(div);
50+
});
51+
52+
afterAll(() => {
53+
editor._tiptapEditor.destroy();
54+
editor = undefined as any;
55+
});
56+
57+
beforeEach(() => {
58+
const testDoc: PartialBlock[] = [
59+
{
60+
id: "test-paragraph",
61+
type: "paragraph",
62+
content: "",
63+
},
64+
];
65+
editor.replaceBlocks(editor.document, testDoc);
66+
editor.setTextCursorPosition("test-paragraph", "start");
67+
});
68+
69+
it("converts ```ts + space into a codeBlock", () => {
70+
typeString(editor, "```ts ");
71+
72+
const block = editor.document[0];
73+
expect(block.type).toBe("codeBlock");
74+
// Without supportedLanguages configured, the raw alias is used
75+
expect((block.props as any).language).toBe("ts");
76+
});
77+
78+
it("converts ``` + space into a codeBlock with empty language", () => {
79+
typeString(editor, "``` ");
80+
81+
const block = editor.document[0];
82+
expect(block.type).toBe("codeBlock");
83+
expect((block.props as any).language).toBe("");
84+
});
85+
86+
it("converts ```javascript + space into a codeBlock", () => {
87+
typeString(editor, "```javascript ");
88+
89+
const block = editor.document[0];
90+
expect(block.type).toBe("codeBlock");
91+
expect((block.props as any).language).toBe("javascript");
92+
});
93+
94+
it("does not trigger input rule without trailing space", () => {
95+
typeString(editor, "```ts");
96+
97+
const block = editor.document[0];
98+
expect(block.type).toBe("paragraph");
99+
});
100+
101+
it("does not trigger with only two backticks", () => {
102+
typeString(editor, "``ts ");
103+
104+
const block = editor.document[0];
105+
expect(block.type).toBe("paragraph");
106+
});
107+
108+
it("does not trigger in non-empty paragraph with preceding text", () => {
109+
typeString(editor, "some text ```ts ");
110+
111+
const block = editor.document[0];
112+
// The ^ anchor in the regex means it only triggers at the start of a block
113+
expect(block.type).toBe("paragraph");
114+
});
115+
116+
it("code block content is empty after conversion", () => {
117+
typeString(editor, "```ts ");
118+
119+
const block = editor.document[0];
120+
expect(block.type).toBe("codeBlock");
121+
expect(block.content).toEqual([]);
122+
});
123+
124+
it("converts ```ts + Enter into a codeBlock", () => {
125+
typeString(editor, "```ts");
126+
pressKey(editor, "Enter");
127+
128+
const block = editor.document[0];
129+
expect(block.type).toBe("codeBlock");
130+
expect((block.props as any).language).toBe("ts");
131+
expect(block.content).toEqual([]);
132+
});
133+
134+
it("converts ``` + Enter into a codeBlock with empty language", () => {
135+
typeString(editor, "```");
136+
pressKey(editor, "Enter");
137+
138+
const block = editor.document[0];
139+
expect(block.type).toBe("codeBlock");
140+
expect((block.props as any).language).toBe("");
141+
});
142+
143+
it("converts ```javascript + Enter into a codeBlock", () => {
144+
typeString(editor, "```javascript");
145+
pressKey(editor, "Enter");
146+
147+
const block = editor.document[0];
148+
expect(block.type).toBe("codeBlock");
149+
expect((block.props as any).language).toBe("javascript");
150+
});
151+
152+
it("does not trigger Enter conversion in non-empty paragraph with preceding text", () => {
153+
typeString(editor, "some text ```ts");
154+
pressKey(editor, "Enter");
155+
156+
const block = editor.document[0];
157+
expect(block.type).toBe("paragraph");
158+
});
159+
160+
it("does not trigger Enter conversion with only two backticks", () => {
161+
typeString(editor, "``ts");
162+
pressKey(editor, "Enter");
163+
164+
const block = editor.document[0];
165+
expect(block.type).toBe("paragraph");
166+
});
167+
168+
it("places cursor inside the new code block after space conversion", () => {
169+
typeString(editor, "```ts ");
170+
171+
const block = editor.document[0];
172+
expect(block.type).toBe("codeBlock");
173+
174+
const { block: cursorBlock } = editor.getTextCursorPosition();
175+
expect(cursorBlock.id).toBe(block.id);
176+
177+
// Typing should now go into the code block, not after it.
178+
typeString(editor, "hello");
179+
const after = editor.document[0];
180+
expect(after.type).toBe("codeBlock");
181+
expect(after.id).toBe(block.id);
182+
expect((after.content as Array<{ type: string; text: string }>)[0].text).toBe(
183+
"hello",
184+
);
185+
});
186+
187+
it("places cursor inside the new code block after Enter conversion", () => {
188+
typeString(editor, "```ts");
189+
pressKey(editor, "Enter");
190+
191+
const block = editor.document[0];
192+
expect(block.type).toBe("codeBlock");
193+
194+
const { block: cursorBlock } = editor.getTextCursorPosition();
195+
expect(cursorBlock.id).toBe(block.id);
196+
197+
typeString(editor, "world");
198+
const after = editor.document[0];
199+
expect(after.type).toBe("codeBlock");
200+
expect(after.id).toBe(block.id);
201+
expect((after.content as Array<{ type: string; text: string }>)[0].text).toBe(
202+
"world",
203+
);
204+
});
205+
206+
it("Enter inside an existing code block does not retrigger conversion", () => {
207+
typeString(editor, "```ts ");
208+
209+
const block = editor.document[0];
210+
expect(block.type).toBe("codeBlock");
211+
212+
typeString(editor, "```js");
213+
pressKey(editor, "Enter");
214+
215+
// Enter inside a code block should insert a newline, not convert again.
216+
const after = editor.document[0];
217+
expect(after.type).toBe("codeBlock");
218+
expect((after.props as any).language).toBe("ts");
219+
});
220+
});
221+
222+
describe("getLanguageId", () => {
223+
const options: CodeBlockOptions = {
224+
supportedLanguages: {
225+
typescript: {
226+
name: "TypeScript",
227+
aliases: ["ts", "typescript"],
228+
},
229+
javascript: {
230+
name: "JavaScript",
231+
aliases: ["js", "javascript"],
232+
},
233+
python: {
234+
name: "Python",
235+
aliases: ["py", "python"],
236+
},
237+
},
238+
};
239+
240+
it("resolves alias to language id", () => {
241+
expect(getLanguageId(options, "ts")).toBe("typescript");
242+
expect(getLanguageId(options, "js")).toBe("javascript");
243+
expect(getLanguageId(options, "py")).toBe("python");
244+
});
245+
246+
it("resolves language id directly", () => {
247+
expect(getLanguageId(options, "typescript")).toBe("typescript");
248+
expect(getLanguageId(options, "javascript")).toBe("javascript");
249+
});
250+
251+
it("returns undefined for unknown language", () => {
252+
expect(getLanguageId(options, "unknown")).toBeUndefined();
253+
});
254+
255+
it("returns undefined with no supportedLanguages", () => {
256+
expect(getLanguageId({}, "ts")).toBeUndefined();
257+
});
258+
});

packages/core/src/editor/managers/ExtensionManager/index.ts

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import {
77
Extension as TiptapExtension,
88
} from "@tiptap/core";
99
import { keymap } from "@tiptap/pm/keymap";
10-
import { Plugin } from "prosemirror-state";
10+
import { Plugin, TextSelection } from "prosemirror-state";
1111
import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
12+
import { setTextCursorPosition } from "../../../api/blockManipulation/selections/textCursorPosition.js";
1213
import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js";
1314
import { sortByDependencies } from "../../../util/topo-sort.js";
1415
import type {
@@ -369,7 +370,49 @@ export class ExtensionManager {
369370
// Append in reverse priority order
370371
rules.push(...inputRulesByPriority.get(priority)!);
371372
});
372-
return [inputRulesPlugin({ rules })];
373+
const inputRules = inputRulesPlugin({ rules });
374+
// Sidecar plugin: triggers the same input rules on Enter by
375+
// delegating to the inputRules plugin's handleTextInput with a
376+
// synthetic "\n" insertion. The handlewithcare regex `\s$` already
377+
// matches `\n`, so any rule that fires on space fires on Enter too.
378+
// We call its handleTextInput directly (rather than via
379+
// view.someProp) so other plugins don't observe the synthetic input,
380+
// and so the rule's undo metadata is keyed to the same plugin
381+
// instance that Tiptap's `commands.undoInputRule` reads from.
382+
const inputRulesEnter = new Plugin({
383+
props: {
384+
handleKeyDown(view, event) {
385+
if (event.key !== "Enter") {
386+
return false;
387+
}
388+
// Only trigger on plain Enter — modifier combos like
389+
// Shift/Cmd/Ctrl/Alt+Enter are reserved for other handlers
390+
// (e.g. soft-break, submit) and should fall through.
391+
if (
392+
event.shiftKey ||
393+
event.ctrlKey ||
394+
event.metaKey ||
395+
event.altKey
396+
) {
397+
return false;
398+
}
399+
const { $cursor } = view.state.selection as TextSelection;
400+
if (!$cursor) {
401+
return false;
402+
}
403+
return !!inputRules.props.handleTextInput?.call(
404+
inputRules,
405+
view,
406+
$cursor.pos,
407+
$cursor.pos,
408+
"\n",
409+
() =>
410+
view.state.tr.insertText("\n", $cursor.pos, $cursor.pos),
411+
);
412+
},
413+
},
414+
});
415+
return [inputRules, inputRulesEnter];
373416
},
374417
}),
375418
);
@@ -408,30 +451,42 @@ export class ExtensionManager {
408451
if (extension.inputRules?.length) {
409452
inputRules.push(
410453
...extension.inputRules.map((inputRule) => {
411-
return new InputRule(inputRule.find, (state, match, start, end) => {
412-
const replaceWith = inputRule.replace({
413-
match,
414-
range: { from: start, to: end },
415-
editor: this.editor,
416-
});
417-
if (replaceWith) {
418-
const cursorPosition = this.editor.getTextCursorPosition();
419-
420-
if (
421-
this.editor.schema.blockSchema[cursorPosition.block.type]
422-
.content !== "inline"
423-
) {
424-
return null;
454+
return new InputRule(
455+
inputRule.find,
456+
(state, match, start, end) => {
457+
const replaceWith = inputRule.replace({
458+
match,
459+
range: { from: start, to: end },
460+
editor: this.editor,
461+
});
462+
if (replaceWith) {
463+
const tr = state.tr;
464+
const blockInfo = getBlockInfoFromTransaction(tr);
465+
466+
if (
467+
!blockInfo.isBlockContainer ||
468+
this.editor.schema.blockSchema[blockInfo.blockNoteType]
469+
?.content !== "inline"
470+
) {
471+
return null;
472+
}
473+
474+
tr.deleteRange(start, end);
475+
updateBlockTr(tr, blockInfo.bnBlock.beforePos, replaceWith);
476+
// updateBlockTr's replaceWith path leaves the selection after
477+
// the new block when the content is replaced wholesale (e.g.
478+
// when the rule returns content: []). Move the cursor back
479+
// inside the new block so the user can keep typing.
480+
const blockId = blockInfo.bnBlock.node.attrs.id;
481+
if (blockId) {
482+
setTextCursorPosition(tr, blockId, "start");
483+
}
484+
return tr;
425485
}
426-
427-
const blockInfo = getBlockInfoFromTransaction(state.tr);
428-
const tr = state.tr.deleteRange(start, end);
429-
430-
updateBlockTr(tr, blockInfo.bnBlock.beforePos, replaceWith);
431-
return tr;
432-
}
433-
return null;
434-
});
486+
return null;
487+
},
488+
{ undoable: true },
489+
);
435490
}),
436491
);
437492
}

packages/xl-pdf-exporter/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default defineConfig((conf) => ({
99
test: {
1010
environment: "jsdom",
1111
setupFiles: ["./vitestSetup.ts"],
12+
testTimeout: 15000,
1213
// assetsInclude: [
1314
// "**/*.woff",
1415
// "**/*.woff2",

0 commit comments

Comments
 (0)