diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index d0597549..e5c94e0d 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -1,4 +1,9 @@ -import { setBlockType, toggleMark, wrapIn } from "prosemirror-commands"; +import { + setBlockType, + splitBlock, + toggleMark, + wrapIn, +} from "prosemirror-commands"; import { Mark, MarkType, NodeType, Schema } from "prosemirror-model"; import { Command, @@ -581,3 +586,26 @@ export function exitInclusiveMarkCommand( return true; } + +export function splitCodeBlockAtStartOfDoc( + state: EditorState, + dispatch: (tr: Transaction) => void +) { + const { $from } = state.selection; + const parent = $from.parent; + + if (parent.type.name !== "code_block") { + return false; + } + + if ($from.parentOffset !== 0) { + return false; + } + + // Is this code block the first child of the doc (i.e. no other nodes above it)? + if ($from.depth !== 1 || $from.index(0) !== 0) { + return false; + } + + return splitBlock(state, dispatch); +} diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts index 962b4f41..a3422186 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -30,6 +30,7 @@ import { toggleHeadingLevel, toggleTagLinkCommand, toggleList, + splitCodeBlockAtStartOfDoc, exitInclusiveMarkCommand, } from "./commands"; @@ -42,6 +43,7 @@ export function allKeymaps( "Shift-Tab": unindentCodeBlockLinesCommand, "Mod-]": indentCodeBlockLinesCommand, "Mod-[": unindentCodeBlockLinesCommand, + "Enter": splitCodeBlockAtStartOfDoc, }); const tableKeymap = caseNormalizeKeymap({ @@ -91,8 +93,8 @@ export function allKeymaps( const keymaps = [ richTextKeymap, - caseNormalizeKeymap(baseKeymap), codeBlockKeymap, + caseNormalizeKeymap(baseKeymap), ]; if (parserFeatures.tables) { diff --git a/test/rich-text/commands/index.test.ts b/test/rich-text/commands/index.test.ts index bf31c10b..647296aa 100644 --- a/test/rich-text/commands/index.test.ts +++ b/test/rich-text/commands/index.test.ts @@ -7,6 +7,7 @@ import { toggleHeadingLevel, toggleTagLinkCommand, toggleWrapIn, + splitCodeBlockAtStartOfDoc, } from "../../../src/rich-text/commands"; import { applyNodeSelection, @@ -1255,4 +1256,92 @@ describe("commands", () => { }); }); }); + + describe("splitCodeBlockAtStartOfDoc", () => { + it("splits if the code block is first in the doc and the selection is at offset 0", () => { + // A doc where the *very first* node is a code block with text "Some code". + const state = createState("
Some code
", []); + + // We want to place the cursor at the very start of the code block. + const selectionAtStart = applySelection(state, 0, 0); + + const { newState, isValid } = executeTransaction( + selectionAtStart, + splitCodeBlockAtStartOfDoc + ); + + // Because it's the first child + offset 0 in code_block, the command should handle it: + expect(isValid).toBe(true); + + // The doc should now have an empty paragraph inserted above the code block. + expect(newState.doc.childCount).toBe(2); + + // The first node should be an empty paragraph: + expect(newState.doc.firstChild.type.name).toBe("paragraph"); + expect(newState.doc.firstChild.textContent).toBe(""); + + // The second node should be the code block with the original text: + expect(newState.doc.lastChild.type.name).toBe("code_block"); + expect(newState.doc.lastChild.textContent).toBe("Some code"); + }); + + it("returns false if selection is NOT at offset 0 (even if code block is first)", () => { + // Same doc as above - code block is first child. + const state = createState("
Some code
", []); + + // Place the cursor in the middle of the code block this time. + const selectionInMiddle = applySelection(state, 5, 5); + + const { newState, isValid } = executeTransaction( + selectionInMiddle, + splitCodeBlockAtStartOfDoc + ); + + // Should not handle it: + expect(isValid).toBe(false); + // Doc should remain unchanged. + expect(newState.doc.toString()).toEqual(state.doc.toString()); + }); + + it("returns false if the code block is NOT the first child in the doc", () => { + // A doc with a paragraph first, THEN a code block. + const state = createState( + "

Intro

Some code
", + [] + ); + + // Even if we place the cursor at offset 0 of the code block, it’s not the doc’s first child. + const selectionAtStartOfBlock = applySelection(state, 7, 7); + + // First, verify that this test is actually valid - we want to be in the code_block at offset 0. + const sel = selectionAtStartOfBlock.selection; + expect(sel.$from.parent.type.name).toBe("code_block"); + expect(sel.$from.parentOffset).toBe(0); + + const { newState, isValid } = executeTransaction( + selectionAtStartOfBlock, + splitCodeBlockAtStartOfDoc + ); + + // Should not handle it because the code block isn’t the doc's first node: + expect(isValid).toBe(false); + expect(newState.doc.toString()).toEqual(state.doc.toString()); + }); + + it("returns false if the parent node is not a code_block", () => { + // If the first node in the doc is a paragraph instead. + const state = createState("

First paragraph

", []); + // Cursor at the start of that paragraph + const selectionInParagraph = applySelection(state, 0, 0); + + const { newState, isValid } = executeTransaction( + selectionInParagraph, + splitCodeBlockAtStartOfDoc + ); + + // Not a code block, so do nothing: + expect(isValid).toBe(false); + expect(newState.doc.toString()).toEqual(state.doc.toString()); + }); + }); });