From 79614ef557de864cabd74e384a8244f9c628187d Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 29 Apr 2026 14:52:20 +0200 Subject: [PATCH 1/3] fix(core): trigger codeblock input rule on Enter and place cursor inside Fix the codeblock input rule (```ts + space) which had been broken by a two-transaction handler that read stale block info, then extend it so Enter triggers the same rule via a sidecar plugin in ExtensionManager. After conversion, place the cursor inside the new block instead of leaving it after. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/blocks/Code/block.test.ts | 258 ++++++++++++++++++ .../editor/managers/ExtensionManager/index.ts | 94 +++++-- 2 files changed, 327 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/blocks/Code/block.test.ts diff --git a/packages/core/src/blocks/Code/block.test.ts b/packages/core/src/blocks/Code/block.test.ts new file mode 100644 index 0000000000..b687c03b22 --- /dev/null +++ b/packages/core/src/blocks/Code/block.test.ts @@ -0,0 +1,258 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { PartialBlock } from "../defaultBlocks.js"; +import { getLanguageId, type CodeBlockOptions } from "./block.js"; + +/** + * @vitest-environment jsdom + */ + +/** + * Simulate typing text into the editor at the current cursor position. + * This triggers input rules by calling the view's handleTextInput prop, + * which is how ProseMirror processes keyboard text input. + */ +function simulateTextInput(editor: BlockNoteEditor, text: string) { + const view = editor.prosemirrorView; + const { from, to } = view.state.selection; + const deflt = () => view.state.tr.insertText(text, from, to); + const handled = view.someProp("handleTextInput", (f) => + f(view, from, to, text, deflt), + ); + if (!handled) { + view.dispatch(deflt()); + } +} + +function typeString(editor: BlockNoteEditor, str: string) { + for (const char of str) { + simulateTextInput(editor, char); + } +} + +/** + * Simulate a keyboard shortcut by invoking the view's handleKeyDown prop, + * which is how ProseMirror routes keymap-based handlers like Enter. + */ +function pressKey(editor: BlockNoteEditor, key: string) { + const view = editor.prosemirrorView; + const event = new KeyboardEvent("keydown", { key }); + view.someProp("handleKeyDown", (f) => f(view, event)); +} + +describe("Code block input rule", () => { + let editor: BlockNoteEditor; + const div = document.createElement("div"); + + beforeAll(() => { + editor = BlockNoteEditor.create(); + editor.mount(div); + }); + + afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + }); + + beforeEach(() => { + const testDoc: PartialBlock[] = [ + { + id: "test-paragraph", + type: "paragraph", + content: "", + }, + ]; + editor.replaceBlocks(editor.document, testDoc); + editor.setTextCursorPosition("test-paragraph", "start"); + }); + + it("converts ```ts + space into a codeBlock", () => { + typeString(editor, "```ts "); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + // Without supportedLanguages configured, the raw alias is used + expect((block.props as any).language).toBe("ts"); + }); + + it("converts ``` + space into a codeBlock with empty language", () => { + typeString(editor, "``` "); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + expect((block.props as any).language).toBe(""); + }); + + it("converts ```javascript + space into a codeBlock", () => { + typeString(editor, "```javascript "); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + expect((block.props as any).language).toBe("javascript"); + }); + + it("does not trigger input rule without trailing space", () => { + typeString(editor, "```ts"); + + const block = editor.document[0]; + expect(block.type).toBe("paragraph"); + }); + + it("does not trigger with only two backticks", () => { + typeString(editor, "``ts "); + + const block = editor.document[0]; + expect(block.type).toBe("paragraph"); + }); + + it("does not trigger in non-empty paragraph with preceding text", () => { + typeString(editor, "some text ```ts "); + + const block = editor.document[0]; + // The ^ anchor in the regex means it only triggers at the start of a block + expect(block.type).toBe("paragraph"); + }); + + it("code block content is empty after conversion", () => { + typeString(editor, "```ts "); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + expect(block.content).toEqual([]); + }); + + it("converts ```ts + Enter into a codeBlock", () => { + typeString(editor, "```ts"); + pressKey(editor, "Enter"); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + expect((block.props as any).language).toBe("ts"); + expect(block.content).toEqual([]); + }); + + it("converts ``` + Enter into a codeBlock with empty language", () => { + typeString(editor, "```"); + pressKey(editor, "Enter"); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + expect((block.props as any).language).toBe(""); + }); + + it("converts ```javascript + Enter into a codeBlock", () => { + typeString(editor, "```javascript"); + pressKey(editor, "Enter"); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + expect((block.props as any).language).toBe("javascript"); + }); + + it("does not trigger Enter conversion in non-empty paragraph with preceding text", () => { + typeString(editor, "some text ```ts"); + pressKey(editor, "Enter"); + + const block = editor.document[0]; + expect(block.type).toBe("paragraph"); + }); + + it("does not trigger Enter conversion with only two backticks", () => { + typeString(editor, "``ts"); + pressKey(editor, "Enter"); + + const block = editor.document[0]; + expect(block.type).toBe("paragraph"); + }); + + it("places cursor inside the new code block after space conversion", () => { + typeString(editor, "```ts "); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + + const { block: cursorBlock } = editor.getTextCursorPosition(); + expect(cursorBlock.id).toBe(block.id); + + // Typing should now go into the code block, not after it. + typeString(editor, "hello"); + const after = editor.document[0]; + expect(after.type).toBe("codeBlock"); + expect(after.id).toBe(block.id); + expect((after.content as Array<{ type: string; text: string }>)[0].text).toBe( + "hello", + ); + }); + + it("places cursor inside the new code block after Enter conversion", () => { + typeString(editor, "```ts"); + pressKey(editor, "Enter"); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + + const { block: cursorBlock } = editor.getTextCursorPosition(); + expect(cursorBlock.id).toBe(block.id); + + typeString(editor, "world"); + const after = editor.document[0]; + expect(after.type).toBe("codeBlock"); + expect(after.id).toBe(block.id); + expect((after.content as Array<{ type: string; text: string }>)[0].text).toBe( + "world", + ); + }); + + it("Enter inside an existing code block does not retrigger conversion", () => { + typeString(editor, "```ts "); + + const block = editor.document[0]; + expect(block.type).toBe("codeBlock"); + + typeString(editor, "```js"); + pressKey(editor, "Enter"); + + // Enter inside a code block should insert a newline, not convert again. + const after = editor.document[0]; + expect(after.type).toBe("codeBlock"); + expect((after.props as any).language).toBe("ts"); + }); +}); + +describe("getLanguageId", () => { + const options: CodeBlockOptions = { + supportedLanguages: { + typescript: { + name: "TypeScript", + aliases: ["ts", "typescript"], + }, + javascript: { + name: "JavaScript", + aliases: ["js", "javascript"], + }, + python: { + name: "Python", + aliases: ["py", "python"], + }, + }, + }; + + it("resolves alias to language id", () => { + expect(getLanguageId(options, "ts")).toBe("typescript"); + expect(getLanguageId(options, "js")).toBe("javascript"); + expect(getLanguageId(options, "py")).toBe("python"); + }); + + it("resolves language id directly", () => { + expect(getLanguageId(options, "typescript")).toBe("typescript"); + expect(getLanguageId(options, "javascript")).toBe("javascript"); + }); + + it("returns undefined for unknown language", () => { + expect(getLanguageId(options, "unknown")).toBeUndefined(); + }); + + it("returns undefined with no supportedLanguages", () => { + expect(getLanguageId({}, "ts")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 67b50871ed..539b72f528 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -7,8 +7,9 @@ import { Extension as TiptapExtension, } from "@tiptap/core"; import { keymap } from "@tiptap/pm/keymap"; -import { Plugin } from "prosemirror-state"; +import { Plugin, TextSelection } from "prosemirror-state"; import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { setTextCursorPosition } from "../../../api/blockManipulation/selections/textCursorPosition.js"; import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; import { sortByDependencies } from "../../../util/topo-sort.js"; import type { @@ -369,7 +370,38 @@ export class ExtensionManager { // Append in reverse priority order rules.push(...inputRulesByPriority.get(priority)!); }); - return [inputRulesPlugin({ rules })]; + const inputRules = inputRulesPlugin({ rules }); + // Sidecar plugin: triggers the same input rules on Enter by + // delegating to the inputRules plugin's handleTextInput with a + // synthetic "\n" insertion. The handlewithcare regex `\s$` already + // matches `\n`, so any rule that fires on space fires on Enter too. + // We call its handleTextInput directly (rather than via + // view.someProp) so other plugins don't observe the synthetic input, + // and so the rule's undo metadata is keyed to the same plugin + // instance that Tiptap's `commands.undoInputRule` reads from. + const inputRulesEnter = new Plugin({ + props: { + handleKeyDown(view, event) { + if (event.key !== "Enter") { + return false; + } + const { $cursor } = view.state.selection as TextSelection; + if (!$cursor) { + return false; + } + return !!inputRules.props.handleTextInput?.call( + inputRules, + view, + $cursor.pos, + $cursor.pos, + "\n", + () => + view.state.tr.insertText("\n", $cursor.pos, $cursor.pos), + ); + }, + }, + }); + return [inputRules, inputRulesEnter]; }, }), ); @@ -408,30 +440,42 @@ export class ExtensionManager { if (extension.inputRules?.length) { inputRules.push( ...extension.inputRules.map((inputRule) => { - return new InputRule(inputRule.find, (state, match, start, end) => { - const replaceWith = inputRule.replace({ - match, - range: { from: start, to: end }, - editor: this.editor, - }); - if (replaceWith) { - const cursorPosition = this.editor.getTextCursorPosition(); - - if ( - this.editor.schema.blockSchema[cursorPosition.block.type] - .content !== "inline" - ) { - return null; + return new InputRule( + inputRule.find, + (state, match, start, end) => { + const replaceWith = inputRule.replace({ + match, + range: { from: start, to: end }, + editor: this.editor, + }); + if (replaceWith) { + const tr = state.tr; + const blockInfo = getBlockInfoFromTransaction(tr); + + if ( + !blockInfo.isBlockContainer || + this.editor.schema.blockSchema[blockInfo.blockNoteType] + ?.content !== "inline" + ) { + return null; + } + + tr.deleteRange(start, end); + updateBlockTr(tr, blockInfo.bnBlock.beforePos, replaceWith); + // updateBlockTr's replaceWith path leaves the selection after + // the new block when the content is replaced wholesale (e.g. + // when the rule returns content: []). Move the cursor back + // inside the new block so the user can keep typing. + const blockId = blockInfo.bnBlock.node.attrs.id; + if (blockId) { + setTextCursorPosition(tr, blockId, "start"); + } + return tr; } - - const blockInfo = getBlockInfoFromTransaction(state.tr); - const tr = state.tr.deleteRange(start, end); - - updateBlockTr(tr, blockInfo.bnBlock.beforePos, replaceWith); - return tr; - } - return null; - }); + return null; + }, + { undoable: true }, + ); }), ); } From 6be224a07fcb70be07be1fa37708c84be5c106a4 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 29 Apr 2026 16:45:46 +0200 Subject: [PATCH 2/3] fix(core): only trigger Enter input rule on plain Enter, not modifier combos Skip the sidecar's input-rule trigger when Shift/Ctrl/Meta/Alt+Enter is pressed so soft-break, submit, and similar handlers can run. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/editor/managers/ExtensionManager/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 539b72f528..d34521fecc 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -385,6 +385,17 @@ export class ExtensionManager { if (event.key !== "Enter") { return false; } + // Only trigger on plain Enter — modifier combos like + // Shift/Cmd/Ctrl/Alt+Enter are reserved for other handlers + // (e.g. soft-break, submit) and should fall through. + if ( + event.shiftKey || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return false; + } const { $cursor } = view.state.selection as TextSelection; if (!$cursor) { return false; From 88c9a50df22e91c74a47497feea7c11ec867d42b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 6 May 2026 17:43:02 +0200 Subject: [PATCH 3/3] test(xl-pdf-exporter): bump testTimeout to 15s to avoid CI flakiness The first "should export a document" test cold-starts PDFExporter and occasionally exceeds the default 5s timeout in CI (observed at 5029ms), while subsequent tests in the same file run in <400ms. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/xl-pdf-exporter/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/xl-pdf-exporter/vite.config.ts b/packages/xl-pdf-exporter/vite.config.ts index 0e3b6568f2..115ca2c340 100644 --- a/packages/xl-pdf-exporter/vite.config.ts +++ b/packages/xl-pdf-exporter/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig((conf) => ({ test: { environment: "jsdom", setupFiles: ["./vitestSetup.ts"], + testTimeout: 15000, // assetsInclude: [ // "**/*.woff", // "**/*.woff2",