@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
266 lines (216 loc) • 7.76 kB
text/typescript
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
} from "vite-plus/test";
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).toEqual([
{ type: "text", text: "hello", styles: {} },
]);
});
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).toEqual([
{ type: "text", text: "world", styles: {} },
]);
});
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();
});
});