From 66f2c1811494b519244fccd9976718d75f47d25f Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Thu, 27 Feb 2025 10:55:13 +0000 Subject: [PATCH 1/6] This should work, but doesn't --- src/rich-text/commands/index.ts | 11 ++++++++++- src/rich-text/key-bindings.ts | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index 0c66224b..5188880b 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -1,4 +1,4 @@ -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 +581,12 @@ export function exitInclusiveMarkCommand( return true; } + +export function maybeSplitCodeBlock(state: EditorState, dispatch: (tr: Transaction) => void) { + console.log('maybeSplitCodeBlock'); + const { $from } = state.selection; + if ($from.parentOffset === 0) { + return splitBlock(state, dispatch); + } + return false; + } \ No newline at end of file diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts index eda9c81a..9a33ebbd 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -32,6 +32,7 @@ import { toggleHeadingLevel, toggleTagLinkCommand, toggleList, + maybeSplitCodeBlock, } from "./commands"; export function allKeymaps( @@ -43,6 +44,8 @@ export function allKeymaps( "Shift-Tab": unindentCodeBlockLinesCommand, "Mod-]": indentCodeBlockLinesCommand, "Mod-[": unindentCodeBlockLinesCommand, + "Mod-s": maybeSplitCodeBlock, + "Enter": maybeSplitCodeBlock, }); const tableKeymap = caseNormalizeKeymap({ From aa7a74847a69954cc4e10f084577944b41babd29 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Thu, 27 Feb 2025 14:06:25 +0000 Subject: [PATCH 2/6] Swap keymap order so ours takes priority over the base --- src/rich-text/key-bindings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts index 9a33ebbd..25d4a58c 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -96,8 +96,8 @@ export function allKeymaps( const keymaps = [ richTextKeymap, - caseNormalizeKeymap(baseKeymap), codeBlockKeymap, + caseNormalizeKeymap(baseKeymap), ]; if (parserFeatures.tables) { From 10349c5fa7ee9bf6ad38f61d6032e9735af3f453 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Thu, 27 Feb 2025 14:14:28 +0000 Subject: [PATCH 3/6] Only run in code block --- src/rich-text/commands/index.ts | 7 +++---- src/rich-text/key-bindings.ts | 5 ++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index 5188880b..5b063c3c 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -582,11 +582,10 @@ export function exitInclusiveMarkCommand( return true; } -export function maybeSplitCodeBlock(state: EditorState, dispatch: (tr: Transaction) => void) { - console.log('maybeSplitCodeBlock'); +export function splitCodeBlockAtStart(state: EditorState, dispatch: (tr: Transaction) => void) { const { $from } = state.selection; - if ($from.parentOffset === 0) { + if ($from.parent.type.name === "code_block" && $from.parentOffset === 0) { return splitBlock(state, dispatch); } return false; - } \ No newline at end of file +} \ No newline at end of file diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts index 25d4a58c..f987fb85 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -32,7 +32,7 @@ import { toggleHeadingLevel, toggleTagLinkCommand, toggleList, - maybeSplitCodeBlock, + splitCodeBlockAtStart, } from "./commands"; export function allKeymaps( @@ -44,8 +44,7 @@ export function allKeymaps( "Shift-Tab": unindentCodeBlockLinesCommand, "Mod-]": indentCodeBlockLinesCommand, "Mod-[": unindentCodeBlockLinesCommand, - "Mod-s": maybeSplitCodeBlock, - "Enter": maybeSplitCodeBlock, + "Enter": splitCodeBlockAtStart, }); const tableKeymap = caseNormalizeKeymap({ From 6482018ef5022341335458a26d37fb09dc965b77 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 28 Feb 2025 09:03:50 +0000 Subject: [PATCH 4/6] Only split at start of doc, add test --- src/rich-text/commands/index.ts | 32 +++++++-- src/rich-text/key-bindings.ts | 4 +- test/rich-text/commands/index.test.ts | 94 +++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index 5b063c3c..74f8fec5 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -1,4 +1,9 @@ -import { setBlockType, splitBlock, toggleMark, wrapIn } from "prosemirror-commands"; +import { + setBlockType, + splitBlock, + toggleMark, + wrapIn, +} from "prosemirror-commands"; import { Mark, MarkType, NodeType, Schema } from "prosemirror-model"; import { Command, @@ -582,10 +587,25 @@ export function exitInclusiveMarkCommand( return true; } -export function splitCodeBlockAtStart(state: EditorState, dispatch: (tr: Transaction) => void) { +export function splitCodeBlockAtStartOfDoc( + state: EditorState, + dispatch: (tr: Transaction) => void +) { const { $from } = state.selection; - if ($from.parent.type.name === "code_block" && $from.parentOffset === 0) { - return splitBlock(state, dispatch); + const parent = $from.parent; + + if (parent.type.name !== "code_block") { + return false; + } + + if ($from.parentOffset !== 0) { + return false; } - return false; -} \ No newline at end of file + + // 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 f987fb85..b7cfc198 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -32,7 +32,7 @@ import { toggleHeadingLevel, toggleTagLinkCommand, toggleList, - splitCodeBlockAtStart, + splitCodeBlockAtStartOfDoc, } from "./commands"; export function allKeymaps( @@ -44,7 +44,7 @@ export function allKeymaps( "Shift-Tab": unindentCodeBlockLinesCommand, "Mod-]": indentCodeBlockLinesCommand, "Mod-[": unindentCodeBlockLinesCommand, - "Enter": splitCodeBlockAtStart, + "Enter": splitCodeBlockAtStartOfDoc, }); const tableKeymap = caseNormalizeKeymap({ diff --git a/test/rich-text/commands/index.test.ts b/test/rich-text/commands/index.test.ts index e22c3177..55fbb47d 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, @@ -1238,4 +1239,97 @@ 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. + // This is how splitBlock typically behaves. The exact structure may differ by schema, + // but we expect at least 2 top-level nodes now. + // + // You might check the doc structure with your own matchers or "toString". + // For a quick check: + 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: code block is first, but we place the cursor in the middle of the text. + const state = createState("
Some code
", []); + // Let's say "Some code" is ~9 characters (ignoring the space?), so picking pos=5 is "me c" area. + // You may need to tweak this, e.g. pos=6, etc. + 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. + // If the paragraph occupies the first few positions, the code block might start at pos=8 or so. + const selectionAtStartOfBlock = applySelection(state, 7, 7); + + // Verify we actually landed 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()); + }); + }); }); From 5704c33f0a0668b1f82babe94b628690dbc05f2c Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 28 Feb 2025 11:07:36 +0000 Subject: [PATCH 5/6] Update test comments --- test/rich-text/commands/index.test.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/test/rich-text/commands/index.test.ts b/test/rich-text/commands/index.test.ts index 55fbb47d..efb6b951 100644 --- a/test/rich-text/commands/index.test.ts +++ b/test/rich-text/commands/index.test.ts @@ -1257,11 +1257,6 @@ describe("commands", () => { expect(isValid).toBe(true); // The doc should now have an empty paragraph inserted above the code block. - // This is how splitBlock typically behaves. The exact structure may differ by schema, - // but we expect at least 2 top-level nodes now. - // - // You might check the doc structure with your own matchers or "toString". - // For a quick check: expect(newState.doc.childCount).toBe(2); // The first node should be an empty paragraph: @@ -1274,10 +1269,10 @@ describe("commands", () => { }); it("returns false if selection is NOT at offset 0 (even if code block is first)", () => { - // Same doc: code block is first, but we place the cursor in the middle of the text. + // Same doc as above - code block is first child. const state = createState("
Some code
", []); - // Let's say "Some code" is ~9 characters (ignoring the space?), so picking pos=5 is "me c" area. - // You may need to tweak this, e.g. pos=6, etc. + + // Place the cursor in the middle of the code block this time. const selectionInMiddle = applySelection(state, 5, 5); const { newState, isValid } = executeTransaction( @@ -1297,11 +1292,11 @@ describe("commands", () => { "

Intro

Some code
", [] ); + // Even if we place the cursor at offset 0 of the code block, it’s not the doc’s first child. - // If the paragraph occupies the first few positions, the code block might start at pos=8 or so. const selectionAtStartOfBlock = applySelection(state, 7, 7); - // Verify we actually landed in the code_block at offset 0. + // 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); From 0f535c50ff7ac74cb07c358b8859aae288209993 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Fri, 28 Feb 2025 11:08:06 +0000 Subject: [PATCH 6/6] Format --- test/rich-text/commands/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/rich-text/commands/index.test.ts b/test/rich-text/commands/index.test.ts index efb6b951..90f25ab6 100644 --- a/test/rich-text/commands/index.test.ts +++ b/test/rich-text/commands/index.test.ts @@ -1271,7 +1271,7 @@ describe("commands", () => { 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); @@ -1292,7 +1292,7 @@ describe("commands", () => { "

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);