feat: Add block movement with Cmd+Alt+Arrow keys (#9502)

* feat: Add block movement with Cmd+Alt+Arrow keys

- Add getCurrentBlock helper function to find current block node
- Implement moveBlockUp and moveBlockDown commands in Keys extension
- Support Cmd+Alt+ArrowUp to move current block up
- Support Cmd+Alt+ArrowDown to move current block down
- Follow ProseMirror best practices for node manipulation
- Maintain cursor position after block movement

Fixes #9486

* feat: Add block movement shortcuts to KeyboardShortcuts component

- Add Cmd+Alt+↑ shortcut for moving blocks up
- Add Cmd+Alt+↓ shortcut for moving blocks down
- Added to Formatting section alongside other editing shortcuts

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
This commit is contained in:
codegen-sh[bot]
2025-06-29 04:56:48 -04:00
committed by GitHub
parent 8e56f58102
commit c97e5fd181
3 changed files with 136 additions and 0 deletions
+92
View File
@@ -6,8 +6,10 @@ import {
TextSelection,
EditorState,
Command,
Transaction,
} from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { getCurrentBlock } from "@shared/editor/queries/getCurrentBlock";
import { isInCode } from "@shared/editor/queries/isInCode";
export default class Keys extends Extension {
@@ -24,6 +26,93 @@ export default class Keys extends Extension {
return false;
};
const moveBlockUp = (
state: EditorState,
dispatch?: (tr: Transaction) => void
) => {
if (!state.selection.empty) {
return false;
}
const result = getCurrentBlock(state);
if (!result) {
return false;
}
const [currentBlock, currentPos] = result;
const $pos = state.doc.resolve(currentPos);
// Check if there's a previous sibling block
if (!$pos.nodeBefore || !$pos.nodeBefore.isBlock) {
return false;
}
const prevBlock = $pos.nodeBefore;
const prevBlockPos = currentPos - prevBlock.nodeSize;
if (!dispatch) {
return true;
}
const { tr } = state;
// Move current block before the previous block
dispatch(
tr
.delete(currentPos, currentPos + currentBlock.nodeSize)
.insert(prevBlockPos, currentBlock)
.setSelection(TextSelection.near(tr.doc.resolve(prevBlockPos + 1)))
);
return true;
};
const moveBlockDown = (
state: EditorState,
dispatch?: (tr: Transaction) => void
) => {
if (!state.selection.empty) {
return false;
}
const result = getCurrentBlock(state);
if (!result) {
return false;
}
const [currentBlock, currentPos] = result;
const $pos = state.doc.resolve(currentPos + currentBlock.nodeSize);
// Check if there's a next sibling block
if (!$pos.nodeAfter || !$pos.nodeAfter.isBlock) {
return false;
}
const nextBlock = $pos.nodeAfter;
const nextBlockEndPos =
currentPos + currentBlock.nodeSize + nextBlock.nodeSize;
if (!dispatch) {
return true;
}
const { tr } = state;
// Move current block after the next block
dispatch(
tr
.insert(nextBlockEndPos, currentBlock)
.delete(currentPos, currentPos + currentBlock.nodeSize)
.setSelection(
TextSelection.near(
tr.doc.resolve(nextBlockEndPos - currentBlock.nodeSize + 1)
)
)
);
return true;
};
return {
// Shortcuts for when editor has separate edit mode
"Mod-Escape": onCancel,
@@ -46,6 +135,9 @@ export default class Keys extends Extension {
(this.editor.view.dom as HTMLElement).blur();
return true;
},
// Block movement shortcuts
"Mod-Alt-ArrowUp": moveBlockUp,
"Mod-Alt-ArrowDown": moveBlockDown,
};
}
+18
View File
@@ -260,6 +260,24 @@ function KeyboardShortcuts() {
),
label: t("Redo"),
},
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key symbol>{altDisplay}</Key>{" "}
+ <Key symbol></Key>
</>
),
label: t("Move block up"),
},
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key symbol>{altDisplay}</Key>{" "}
+ <Key symbol></Key>
</>
),
label: t("Move block down"),
},
],
},
{
+26
View File
@@ -0,0 +1,26 @@
import { Node } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
/**
* Gets the current block node that contains the selection
* @param state The editor state
* @returns The current block node and its position, or undefined if not found
*/
export function getCurrentBlock(
state: EditorState
): [Node, number] | undefined {
const { $head } = state.selection;
// Walk up the tree to find the first block node
for (let d = $head.depth; d >= 0; d--) {
const node = $head.node(d);
const pos = $head.before(d);
if (node.isBlock && d > 0) {
// Don't return the document itself
return [node, pos];
}
}
return undefined;
}