From de225803113e9431c08e2559c1a78386bad20a58 Mon Sep 17 00:00:00 2001 From: James Boyden Date: Wed, 30 Apr 2025 14:46:56 +0100 Subject: [PATCH 1/5] Fix unescapableCommand handling, swallow commands for snippets --- .../official/stack-snippets/src/commands.ts | 25 ++++ .../stack-snippets/src/stackSnippetPlugin.ts | 7 +- .../stack-snippets/test/commands.test.ts | 47 ++++-- src/rich-text/commands/index.ts | 93 ++++++++---- src/rich-text/editor.ts | 9 +- src/rich-text/key-bindings.ts | 5 +- test/rich-text/commands/index.test.ts | 136 +++++++++++++++++- 7 files changed, 277 insertions(+), 45 deletions(-) diff --git a/plugins/official/stack-snippets/src/commands.ts b/plugins/official/stack-snippets/src/commands.ts index d7c32e7b..7a142498 100644 --- a/plugins/official/stack-snippets/src/commands.ts +++ b/plugins/official/stack-snippets/src/commands.ts @@ -7,6 +7,8 @@ import { import { Node } from "prosemirror-model"; import { EditorView } from "prosemirror-view"; import { BASE_VIEW_KEY } from "../../../../src/shared/prosemirror-plugins/base-view-state"; +import { EditorState } from "prosemirror-state"; +import { caseNormalizeKeymap } from "../../../../src/shared/prosemirror-plugins/case-normalize-keymap"; /** Builds a function that will update a snippet node on the up-to-date state (at time of execution) **/ function buildUpdateDocumentCallback(view: EditorView) { @@ -115,3 +117,26 @@ export function openSnippetModal(options?: StackSnippetOptions): MenuCommand { return true; }; } + +const swallowSnippetCommand = (state: EditorState): boolean => { + const fromNodeType = state.selection.$from.node().type.name; + + if(fromNodeType === "stack_snippet" || fromNodeType === "stack_snippet_lang"){ + return true; + } +} + +export const swallowedCommandList = { + "Mod-Enter": swallowSnippetCommand, + "Shift-Enter": swallowSnippetCommand, + "Mod-r": swallowSnippetCommand, +}; + +/** + * Snippets are comprised of a container around customized codeblocks. Some of the default behaviour for key-binds makes them behave + * very strangely. + * + * In these cases, we override the command to (contextually) do nothing if the current context is a snippet + * This is possible because returning truthy consumes the event. + * **/ +export const stackSnippetCommandRedactor = caseNormalizeKeymap(swallowedCommandList); diff --git a/plugins/official/stack-snippets/src/stackSnippetPlugin.ts b/plugins/official/stack-snippets/src/stackSnippetPlugin.ts index bcbf3d4f..d195c12a 100644 --- a/plugins/official/stack-snippets/src/stackSnippetPlugin.ts +++ b/plugins/official/stack-snippets/src/stackSnippetPlugin.ts @@ -11,7 +11,7 @@ import { EditorView } from "prosemirror-view"; import { StackSnippetView } from "./snippet-view"; import { StackSnippetOptions } from "./common"; import { stackSnippetPasteHandler } from "./paste-handler"; -import { openSnippetModal } from "./commands"; +import { openSnippetModal, stackSnippetCommandRedactor } from "./commands"; /** * Build the StackSnippet plugin using hoisted options that can be specified at runtime @@ -30,7 +30,10 @@ export const stackSnippetPlugin: (opts?: StackSnippetOptions) => EditorPlugin = return new StackSnippetView(node, view, getPos, opts); }, }, - plugins: [stackSnippetPasteHandler], + plugins: [ + stackSnippetPasteHandler, + stackSnippetCommandRedactor, + ], }, extendSchema: (schema) => { schema.nodes = schema.nodes.append(stackSnippetRichTextNodeSpec); diff --git a/plugins/official/stack-snippets/test/commands.test.ts b/plugins/official/stack-snippets/test/commands.test.ts index 7b0e00f1..3d180b5d 100644 --- a/plugins/official/stack-snippets/test/commands.test.ts +++ b/plugins/official/stack-snippets/test/commands.test.ts @@ -1,13 +1,13 @@ import { Node } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; import { SnippetMetadata, StackSnippetOptions } from "../src/common"; -import { openSnippetModal } from "../src/commands"; +import { openSnippetModal, swallowedCommandList } from "../src/commands"; import { RichTextHelpers } from "../../../../test"; import { buildSnippetSchema, snippetExternalProvider, validBegin, - validEnd, + validEnd, validJs, validSnippetRenderCases, } from "./stack-snippet-helpers"; import { parseSnippetBlockForProsemirror } from "../src/paste-handler"; @@ -17,6 +17,14 @@ import MarkdownIt from "markdown-it"; describe("commands", () => { const schema = buildSnippetSchema(); + function richView(markdownInput: string, opts?: StackSnippetOptions) { + return new RichTextEditor( + document.createElement("div"), + markdownInput, + snippetExternalProvider(opts), + {} + ); + } const whenOpenSnippetCommandCalled = ( state: EditorState, @@ -130,14 +138,6 @@ describe("commands", () => { describe("callback", () => { const mdit = new MarkdownIt("default", {}); mdit.use(markdownPlugin); - function richView(markdownInput: string, opts?: StackSnippetOptions) { - return new RichTextEditor( - document.createElement("div"), - markdownInput, - snippetExternalProvider(opts), - {} - ); - } const callbackTestCaseJs: string = ` @@ -225,4 +225,31 @@ describe("commands", () => { } ); }); + + describe("redactor", () => { + //Note: we're testing this functionality once with a command that is universal across Macs and PC. + // In the pipeline this is likely using a Linux environment, in which case "Mod" means "Ctrl" too, but + // the main concern is on other development environments. + it("should swallow commands when in a Snippet context", () => { + const view = richView(`${validBegin}${validJs}${validEnd}`); + const expectedHTML = view.editorView.dom.innerHTML; + const event = new KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter'}) + + view.editorView.someProp('handleKeyDown', (f) => f(view.editorView, event)) + + //The Dom is exactly the same - no change has occured + expect(view.editorView.dom.innerHTML).toBe(expectedHTML); + }) + + it("should not swallow commands when in a non-Snippet context", () => { + const view = richView("```javascript\nconsole.log('test');\n```"); + const expectedHTML = view.editorView.dom.innerHTML; + const event = new KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter'}) + + view.editorView.someProp('handleKeyDown', (f) => f(view.editorView, event)) + + //The Dom is exactly the same - no change has occured + expect(view.editorView.dom.innerHTML).not.toBe(expectedHTML); + }) + }) }); diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index d762fa7f..19a00e87 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -433,30 +433,78 @@ export function exitInclusiveMarkCommand( * Ensure there's a next block to move into - Adds an additional blank paragraph block * if the next node available is unselectable and there is no node afterwards that is selectable. * */ -export function escapeUnselectableCommand( +export function escapeUnselectableCommandDown( state: EditorState, dispatch: (tr: Transaction) => void ): boolean { //A resolved position of the cursor. Functionally: The place we're calculating the next line for. const selectionEndPos = state.selection.$to; + const topLevelParent = selectionEndPos.node(1) || selectionEndPos.parent; + const isLastNode = state.doc.lastChild.eq(topLevelParent); + const isSelectingWholeDoc = state.doc.eq(selectionEndPos.parent); - //If you're already at the end of the document, do the default action (nothing) - // Note: We're checking for either the last Inline character or the last node being selected here. - const isLastNode = state.doc.lastChild.eq(state.selection.$to.parent); - const isSelectingWholeDoc = state.doc.eq(state.selection.$to.parent); - if (isLastNode || isSelectingWholeDoc) { + //If we're selecting the whole document, don't mess with the node structure + if (isSelectingWholeDoc) { return false; } - //Calculate the position starting at the next line in the doc (the start point to check at) - const findStartPos = selectionEndPos.posAtIndex( - selectionEndPos.indexAfter(0), - 0 - ); + //If this is the last node and we're at document-level, no need to go further. + if(isLastNode && selectionEndPos.depth == 1) + { + return false; + } + + //Ensure that one of the following elements is selectable, or add a paragraph + if(!isTextSelectableInRange(selectionEndPos.after(), state.doc.content.size, state)){ + insertBlankParagraph(state.doc.content.size, state, dispatch) + } + + //No matter what, we want the default behaviour to take over from here. + // Either we've created a new line to edit into just in time, or there was already something for it to move to + return false; +} + +/** + * Ensure there's a next block to move into - Adds an additional blank paragraph block + * if the previous node available is unselectable and there is no node before that is selectable. + * */ +export function escapeUnselectableCommandUp( + state: EditorState, + dispatch: (tr: Transaction) => void +): boolean { + //A resolved position of the cursor. Functionally: The place we're calculating the next line for. + const selectionBeginPos = state.selection.$to; + const topLevelParent = selectionBeginPos.node(1) || selectionBeginPos.parent; + const isFirstNode = state.doc.firstChild.eq(topLevelParent); + const isSelectingWholeDoc = state.doc.eq(selectionBeginPos.parent); + + //If we're selecting the whole document, don't mess with the node structure + if (isSelectingWholeDoc) { + return false; + } + //If this is the last node and we're at document-level, no need to go further. + if(isFirstNode && selectionBeginPos.depth == 1) + { + return false; + } + + //If there's not something to move into, add it now + if(!isTextSelectableInRange(0, selectionBeginPos.before(), state)){ + insertBlankParagraph(0, state, dispatch) + } + + //No matter what, we want the default behaviour to take over from here. + // Either we've created a new line to edit into just in time, or there was already something for it to move to + return false; +} + +function isTextSelectableInRange(beginPos: number, endPos: number, state: EditorState): boolean { //Starting from the next node position down, check all the nodes for being a text block. + // We care whether there's at least one - not necessarily the node that's found let foundSelectable: boolean = false; - state.doc.nodesBetween(findStartPos, state.doc.content.size, (node) => { + + state.doc.nodesBetween(beginPos, endPos, (node) => { //Already found one, no need to delve deeper. if (foundSelectable) return !foundSelectable; @@ -470,19 +518,16 @@ export function escapeUnselectableCommand( return true; }); - //If there's not something to move into, add it now - if (!foundSelectable) { - dispatch( - state.tr.insert( - state.doc.content.size, - state.schema.nodes.paragraph.create() - ) - ); - } + return foundSelectable; +} - //No matter what, we want the default behaviour to take over from here. - // Either we've created a new line to edit into just in time, or there was already something for it to move to - return false; +function insertBlankParagraph(pos: number, state: EditorState, dispatch: (tr: Transaction) => void){ + dispatch( + state.tr.insert( + pos, + state.schema.nodes.paragraph.create() + ) + ); } export function splitCodeBlockAtStartOfDoc( diff --git a/src/rich-text/editor.ts b/src/rich-text/editor.ts index d8e3c0e9..7bf2d96f 100644 --- a/src/rich-text/editor.ts +++ b/src/rich-text/editor.ts @@ -118,10 +118,6 @@ export class RichTextEditor extends BaseView { plugins: [ baseViewStatePlugin(this), history(), - ...allKeymaps( - this.finalizedSchema, - this.options.parserFeatures - ), menu, richTextInputRules( this.finalizedSchema, @@ -142,6 +138,11 @@ export class RichTextEditor extends BaseView { readonlyPlugin(), spoilerToggle, ...this.externalPluginProvider.plugins.richText, + //Keymaps are executed in order and can be consuming, so we let external plugins register first + ...allKeymaps( + this.finalizedSchema, + this.options.parserFeatures + ), // Paste handlers are consuming, so we let external plugins try first tables, richTextCodePasteHandler, diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts index ef41661b..3b23e655 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -32,7 +32,7 @@ import { toggleList, splitCodeBlockAtStartOfDoc, exitInclusiveMarkCommand, - escapeUnselectableCommand, + escapeUnselectableCommandDown, escapeUnselectableCommandUp, } from "./commands"; export function allKeymaps( @@ -90,7 +90,8 @@ export function allKeymaps( "Mod-'": toggleMark(schema.marks.kbd), // exit inline code block using the right arrow key "ArrowRight": exitInclusiveMarkCommand, - "ArrowDown": escapeUnselectableCommand, + "ArrowUp": escapeUnselectableCommandUp, + "ArrowDown": escapeUnselectableCommandDown, }); const keymaps = [ diff --git a/test/rich-text/commands/index.test.ts b/test/rich-text/commands/index.test.ts index 1bcf0bff..5c1d5bbd 100644 --- a/test/rich-text/commands/index.test.ts +++ b/test/rich-text/commands/index.test.ts @@ -5,7 +5,7 @@ import { Transaction, } from "prosemirror-state"; import { - escapeUnselectableCommand, + escapeUnselectableCommandDown, escapeUnselectableCommandUp, exitInclusiveMarkCommand, insertRichTextHorizontalRuleCommand, toggleHeadingLevel, @@ -787,7 +787,7 @@ describe("commands", () => { }); }); - describe("escapeUnselectableCommand", () => { + describe("escapeUnselectableCommandDown", () => { const whenEscapeUnselectableCommandCalled = ( state: EditorState, shouldMatchTrans?: (tr: Transaction) => boolean @@ -799,7 +799,7 @@ describe("commands", () => { dispatchTr = tr; }; - const result = escapeUnselectableCommand(state, captureDispatch); + const result = escapeUnselectableCommandDown(state, captureDispatch); return { result, @@ -918,4 +918,134 @@ describe("commands", () => { expect(matchedTransaction).toBe(true); }); }); + + describe("escapeUnselectableCommandUp", () => { + const whenEscapeUnselectableCommandCalled = ( + state: EditorState, + shouldMatchTrans?: (tr: Transaction) => boolean + ) => { + let dispatchCalled = false; + let dispatchTr: Transaction = null; + const captureDispatch = (tr: Transaction) => { + dispatchCalled = true; + dispatchTr = tr; + }; + + const result = escapeUnselectableCommandUp(state, captureDispatch); + + return { + result, + dispatchCalled, + matchedTransaction: shouldMatchTrans + ? shouldMatchTrans(dispatchTr) + : null, + }; + }; + + it("should do nothing if first node in the document is selected", () => { + //Selection is the only line in the document, therefore the end. + let state = createState( + "Here's a paragraph - a text block mind you", + [] + ); + state = state.apply( + state.tr.setSelection(NodeSelection.create(state.doc, 0)) + ); + + const { result, dispatchCalled } = + whenEscapeUnselectableCommandCalled(state); + + expect(result).toBe(false); + expect(dispatchCalled).toBe(false); + }); + + it("should do nothing if first inline text in the document is selected", () => { + //Selection is the only line in the document, therefore the beginning. + let state = createState( + "Here's a paragraph - a text block mind you", + [] + ); + state = state.apply( + state.tr.setSelection( + TextSelection.create( + state.doc, + 0,0 + ) + ) + ); + + const { result, dispatchCalled } = + whenEscapeUnselectableCommandCalled(state); + + expect(result).toBe(false); + expect(dispatchCalled).toBe(false); + }); + + it("should not alter the document if there is a preceding textblock node", () => { + let state = createState( + "Here's a paragraph - a text block mind you", + [] + ); + const firstNodePosEnd = state.doc.lastChild.firstChild.nodeSize + 1; + state = state.apply( + state.tr.insert( + 0, + parseHtmlToDoc("This is another node, wild!", false) + ) + ); + state = state.apply( + state.tr.setSelection( + TextSelection.create( + state.doc, + firstNodePosEnd - 3, + firstNodePosEnd + ) + ) + ); + + const { result, dispatchCalled } = + whenEscapeUnselectableCommandCalled(state); + + expect(result).toBe(false); + expect(dispatchCalled).toBe(false); + }); + + it("should add a paragraph block if there are no preceding textblock nodes", () => { + let state = EditorState.create({ + doc: parseHtmlToDoc( + "
Header 1Header 2
onetwo
", + false + ), + schema: testRichTextSchema, + plugins: [], + }); + + const selection = TextSelection.create( + state.doc, + 4 + ); + expect(selection.$to.parent.textContent).toBe("Header 1"); + expect(selection.$from.parent.textContent).toBe("Header 1"); + state = state.apply(state.tr.setSelection(selection)); + + const { result, dispatchCalled, matchedTransaction } = + whenEscapeUnselectableCommandCalled(state, (tr) => { + if (tr.steps.length !== 1) return false; + + const step = tr.steps[0] as ReplaceStep; + if (step.slice === undefined) return false; + + if (step.slice.content.childCount !== 1) return false; + if (step.slice.content.firstChild.type.name !== "paragraph") + return false; + if (step.slice.content.firstChild.textContent != "") + return false; + return true; + }); + + expect(result).toBe(false); + expect(dispatchCalled).toBe(true); + expect(matchedTransaction).toBe(true); + }); + }); }); From 9578ffb1cb7517b0b11df8d7bb61f46097787a37 Mon Sep 17 00:00:00 2001 From: James Boyden Date: Wed, 30 Apr 2025 14:52:22 +0100 Subject: [PATCH 2/5] linting --- .../official/stack-snippets/src/commands.ts | 10 +++-- .../stack-snippets/src/stackSnippetPlugin.ts | 5 +-- .../stack-snippets/test/commands.test.ts | 27 ++++++++---- src/rich-text/commands/index.ts | 42 +++++++++++-------- src/rich-text/key-bindings.ts | 3 +- test/rich-text/commands/index.test.ts | 20 ++++----- 6 files changed, 62 insertions(+), 45 deletions(-) diff --git a/plugins/official/stack-snippets/src/commands.ts b/plugins/official/stack-snippets/src/commands.ts index 7a142498..327639f1 100644 --- a/plugins/official/stack-snippets/src/commands.ts +++ b/plugins/official/stack-snippets/src/commands.ts @@ -121,10 +121,13 @@ export function openSnippetModal(options?: StackSnippetOptions): MenuCommand { const swallowSnippetCommand = (state: EditorState): boolean => { const fromNodeType = state.selection.$from.node().type.name; - if(fromNodeType === "stack_snippet" || fromNodeType === "stack_snippet_lang"){ + if ( + fromNodeType === "stack_snippet" || + fromNodeType === "stack_snippet_lang" + ) { return true; } -} +}; export const swallowedCommandList = { "Mod-Enter": swallowSnippetCommand, @@ -139,4 +142,5 @@ export const swallowedCommandList = { * In these cases, we override the command to (contextually) do nothing if the current context is a snippet * This is possible because returning truthy consumes the event. * **/ -export const stackSnippetCommandRedactor = caseNormalizeKeymap(swallowedCommandList); +export const stackSnippetCommandRedactor = + caseNormalizeKeymap(swallowedCommandList); diff --git a/plugins/official/stack-snippets/src/stackSnippetPlugin.ts b/plugins/official/stack-snippets/src/stackSnippetPlugin.ts index d195c12a..ba9d3a8f 100644 --- a/plugins/official/stack-snippets/src/stackSnippetPlugin.ts +++ b/plugins/official/stack-snippets/src/stackSnippetPlugin.ts @@ -30,10 +30,7 @@ export const stackSnippetPlugin: (opts?: StackSnippetOptions) => EditorPlugin = return new StackSnippetView(node, view, getPos, opts); }, }, - plugins: [ - stackSnippetPasteHandler, - stackSnippetCommandRedactor, - ], + plugins: [stackSnippetPasteHandler, stackSnippetCommandRedactor], }, extendSchema: (schema) => { schema.nodes = schema.nodes.append(stackSnippetRichTextNodeSpec); diff --git a/plugins/official/stack-snippets/test/commands.test.ts b/plugins/official/stack-snippets/test/commands.test.ts index 3d180b5d..50e3ba2a 100644 --- a/plugins/official/stack-snippets/test/commands.test.ts +++ b/plugins/official/stack-snippets/test/commands.test.ts @@ -7,7 +7,8 @@ import { buildSnippetSchema, snippetExternalProvider, validBegin, - validEnd, validJs, + validEnd, + validJs, validSnippetRenderCases, } from "./stack-snippet-helpers"; import { parseSnippetBlockForProsemirror } from "../src/paste-handler"; @@ -233,23 +234,33 @@ describe("commands", () => { it("should swallow commands when in a Snippet context", () => { const view = richView(`${validBegin}${validJs}${validEnd}`); const expectedHTML = view.editorView.dom.innerHTML; - const event = new KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter'}) + const event = new KeyboardEvent("keydown", { + ctrlKey: true, + key: "Enter", + }); - view.editorView.someProp('handleKeyDown', (f) => f(view.editorView, event)) + view.editorView.someProp("handleKeyDown", (f) => + f(view.editorView, event) + ); //The Dom is exactly the same - no change has occured expect(view.editorView.dom.innerHTML).toBe(expectedHTML); - }) + }); it("should not swallow commands when in a non-Snippet context", () => { const view = richView("```javascript\nconsole.log('test');\n```"); const expectedHTML = view.editorView.dom.innerHTML; - const event = new KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter'}) + const event = new KeyboardEvent("keydown", { + ctrlKey: true, + key: "Enter", + }); - view.editorView.someProp('handleKeyDown', (f) => f(view.editorView, event)) + view.editorView.someProp("handleKeyDown", (f) => + f(view.editorView, event) + ); //The Dom is exactly the same - no change has occured expect(view.editorView.dom.innerHTML).not.toBe(expectedHTML); - }) - }) + }); + }); }); diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index 19a00e87..c6ff819b 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -449,14 +449,19 @@ export function escapeUnselectableCommandDown( } //If this is the last node and we're at document-level, no need to go further. - if(isLastNode && selectionEndPos.depth == 1) - { + if (isLastNode && selectionEndPos.depth == 1) { return false; } //Ensure that one of the following elements is selectable, or add a paragraph - if(!isTextSelectableInRange(selectionEndPos.after(), state.doc.content.size, state)){ - insertBlankParagraph(state.doc.content.size, state, dispatch) + if ( + !isTextSelectableInRange( + selectionEndPos.after(), + state.doc.content.size, + state + ) + ) { + insertBlankParagraph(state.doc.content.size, state, dispatch); } //No matter what, we want the default behaviour to take over from here. @@ -474,7 +479,8 @@ export function escapeUnselectableCommandUp( ): boolean { //A resolved position of the cursor. Functionally: The place we're calculating the next line for. const selectionBeginPos = state.selection.$to; - const topLevelParent = selectionBeginPos.node(1) || selectionBeginPos.parent; + const topLevelParent = + selectionBeginPos.node(1) || selectionBeginPos.parent; const isFirstNode = state.doc.firstChild.eq(topLevelParent); const isSelectingWholeDoc = state.doc.eq(selectionBeginPos.parent); @@ -484,14 +490,13 @@ export function escapeUnselectableCommandUp( } //If this is the last node and we're at document-level, no need to go further. - if(isFirstNode && selectionBeginPos.depth == 1) - { + if (isFirstNode && selectionBeginPos.depth == 1) { return false; } //If there's not something to move into, add it now - if(!isTextSelectableInRange(0, selectionBeginPos.before(), state)){ - insertBlankParagraph(0, state, dispatch) + if (!isTextSelectableInRange(0, selectionBeginPos.before(), state)) { + insertBlankParagraph(0, state, dispatch); } //No matter what, we want the default behaviour to take over from here. @@ -499,7 +504,11 @@ export function escapeUnselectableCommandUp( return false; } -function isTextSelectableInRange(beginPos: number, endPos: number, state: EditorState): boolean { +function isTextSelectableInRange( + beginPos: number, + endPos: number, + state: EditorState +): boolean { //Starting from the next node position down, check all the nodes for being a text block. // We care whether there's at least one - not necessarily the node that's found let foundSelectable: boolean = false; @@ -521,13 +530,12 @@ function isTextSelectableInRange(beginPos: number, endPos: number, state: Editor return foundSelectable; } -function insertBlankParagraph(pos: number, state: EditorState, dispatch: (tr: Transaction) => void){ - dispatch( - state.tr.insert( - pos, - state.schema.nodes.paragraph.create() - ) - ); +function insertBlankParagraph( + pos: number, + state: EditorState, + dispatch: (tr: Transaction) => void +) { + dispatch(state.tr.insert(pos, state.schema.nodes.paragraph.create())); } export function splitCodeBlockAtStartOfDoc( diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts index aec3067a..9d1cdc0a 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -32,7 +32,8 @@ import { toggleList, splitCodeBlockAtStartOfDoc, exitInclusiveMarkCommand, - escapeUnselectableCommandDown, escapeUnselectableCommandUp, + escapeUnselectableCommandDown, + escapeUnselectableCommandUp, openCodeBlockLanguagePicker, } from "./commands"; diff --git a/test/rich-text/commands/index.test.ts b/test/rich-text/commands/index.test.ts index 5c1d5bbd..a053251c 100644 --- a/test/rich-text/commands/index.test.ts +++ b/test/rich-text/commands/index.test.ts @@ -5,7 +5,8 @@ import { Transaction, } from "prosemirror-state"; import { - escapeUnselectableCommandDown, escapeUnselectableCommandUp, + escapeUnselectableCommandDown, + escapeUnselectableCommandUp, exitInclusiveMarkCommand, insertRichTextHorizontalRuleCommand, toggleHeadingLevel, @@ -799,7 +800,10 @@ describe("commands", () => { dispatchTr = tr; }; - const result = escapeUnselectableCommandDown(state, captureDispatch); + const result = escapeUnselectableCommandDown( + state, + captureDispatch + ); return { result, @@ -966,12 +970,7 @@ describe("commands", () => { [] ); state = state.apply( - state.tr.setSelection( - TextSelection.create( - state.doc, - 0,0 - ) - ) + state.tr.setSelection(TextSelection.create(state.doc, 0, 0)) ); const { result, dispatchCalled } = @@ -1020,10 +1019,7 @@ describe("commands", () => { plugins: [], }); - const selection = TextSelection.create( - state.doc, - 4 - ); + const selection = TextSelection.create(state.doc, 4); expect(selection.$to.parent.textContent).toBe("Header 1"); expect(selection.$from.parent.textContent).toBe("Header 1"); state = state.apply(state.tr.setSelection(selection)); From 59ee9c467c9c9bf134699e24a5716466c40bb31b Mon Sep 17 00:00:00 2001 From: James Boyden Date: Wed, 30 Apr 2025 14:53:55 +0100 Subject: [PATCH 3/5] missing changeset --- .changeset/little-ears-rescue.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/little-ears-rescue.md diff --git a/.changeset/little-ears-rescue.md b/.changeset/little-ears-rescue.md new file mode 100644 index 00000000..8c5ac5cf --- /dev/null +++ b/.changeset/little-ears-rescue.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": patch +--- + +Fix the ability to move around Snippets (enter and exit with arrow keys) and swallows commands that are incompatible with snippets (e.g. creating horizontal rules) From 8b1fe02c93529f52e0e4487fce87674831e86908 Mon Sep 17 00:00:00 2001 From: James Boyden Date: Wed, 30 Apr 2025 15:16:37 +0100 Subject: [PATCH 4/5] lint fixes --- plugins/official/stack-snippets/test/commands.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/official/stack-snippets/test/commands.test.ts b/plugins/official/stack-snippets/test/commands.test.ts index 50e3ba2a..6ac48a3a 100644 --- a/plugins/official/stack-snippets/test/commands.test.ts +++ b/plugins/official/stack-snippets/test/commands.test.ts @@ -1,7 +1,7 @@ import { Node } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; import { SnippetMetadata, StackSnippetOptions } from "../src/common"; -import { openSnippetModal, swallowedCommandList } from "../src/commands"; +import { openSnippetModal } from "../src/commands"; import { RichTextHelpers } from "../../../../test"; import { buildSnippetSchema, From 64de98543cda6f850783873ed7618ecd1e6f52b5 Mon Sep 17 00:00:00 2001 From: James Boyden Date: Thu, 1 May 2025 09:23:19 +0100 Subject: [PATCH 5/5] Update plugins/official/stack-snippets/test/commands.test.ts Co-authored-by: Aliza Berger --- plugins/official/stack-snippets/test/commands.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/official/stack-snippets/test/commands.test.ts b/plugins/official/stack-snippets/test/commands.test.ts index 6ac48a3a..3fff649a 100644 --- a/plugins/official/stack-snippets/test/commands.test.ts +++ b/plugins/official/stack-snippets/test/commands.test.ts @@ -259,7 +259,7 @@ describe("commands", () => { f(view.editorView, event) ); - //The Dom is exactly the same - no change has occured + //The Dom is not the same - a change has occured expect(view.editorView.dom.innerHTML).not.toBe(expectedHTML); }); });