Skip to content

Commit cfd68f4

Browse files
authored
Merge pull request #2220 from TriliumNext/moveupdown
fix(move_block): move multiple lines up/down
2 parents 5090b38 + 3a56a16 commit cfd68f4

File tree

1 file changed

+105
-37
lines changed

1 file changed

+105
-37
lines changed

packages/ckeditor5/src/plugins/move_block_updown.ts

Lines changed: 105 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,144 @@
22
* https://github.com/TriliumNext/Notes/issues/1002
33
*/
44

5-
import { Command, DocumentSelection, Element, Node, Plugin } from 'ckeditor5';
6-
5+
import { Command, DocumentSelection, Element, Node, Plugin, Range } from 'ckeditor5';
76
export default class MoveBlockUpDownPlugin extends Plugin {
87

98
init() {
109
const editor = this.editor;
11-
editor.config.define('moveBlockUp', {
12-
keystroke: ['ctrl+arrowup', 'alt+arrowup'],
13-
});
14-
editor.config.define('moveBlockDown', {
15-
keystroke: ['ctrl+arrowdown', 'alt+arrowdown'],
16-
});
1710

1811
editor.commands.add('moveBlockUp', new MoveBlockUpCommand(editor));
1912
editor.commands.add('moveBlockDown', new MoveBlockDownCommand(editor));
2013

21-
for (const keystroke of editor.config.get('moveBlockUp.keystroke') ?? []) {
22-
editor.keystrokes.set(keystroke, 'moveBlockUp');
23-
}
24-
for (const keystroke of editor.config.get('moveBlockDown.keystroke') ?? []) {
25-
editor.keystrokes.set(keystroke, 'moveBlockDown');
26-
}
14+
// Use native DOM capturing to intercept Ctrl/Alt + ↑/↓,
15+
// as plugin-level keystroke handling may fail when the selection is near an object.
16+
this.bindMoveBlockShortcuts(editor);
2717
}
18+
19+
bindMoveBlockShortcuts(editor: any) {
20+
editor.editing.view.once('render', () => {
21+
const domRoot = editor.editing.view.getDomRoot();
22+
if (!domRoot) return;
23+
24+
const handleKeydown = (e: KeyboardEvent) => {
25+
const keyMap = {
26+
ArrowUp: 'moveBlockUp',
27+
ArrowDown: 'moveBlockDown'
28+
};
29+
30+
const command = keyMap[e.key];
31+
const isCtrl = e.ctrlKey || e.metaKey;
32+
const hasModifier = (isCtrl || e.altKey) && !(isCtrl && e.altKey);
33+
34+
if (command && hasModifier) {
35+
e.preventDefault();
36+
e.stopImmediatePropagation();
37+
editor.execute(command);
38+
}
39+
};
40+
41+
domRoot.addEventListener('keydown', handleKeydown, { capture: true });
42+
});
43+
}
2844

2945
}
3046

3147
abstract class MoveBlockUpDownCommand extends Command {
3248

33-
abstract getSelectedBlocks(selection: DocumentSelection): Element[];
3449
abstract getSibling(selectedBlock: Element): Node | null;
3550
abstract get offset(): "before" | "after";
3651

37-
override refresh() {
38-
const selection = this.editor.model.document.selection;
39-
const selectedBlocks = this.getSelectedBlocks(selection);
40-
41-
this.isEnabled = true;
42-
for (const selectedBlock of selectedBlocks) {
43-
if (!this.getSibling(selectedBlock)) this.isEnabled = false;
44-
}
45-
}
46-
4752
override execute() {
4853
const model = this.editor.model;
4954
const selection = model.document.selection;
5055
const selectedBlocks = this.getSelectedBlocks(selection);
56+
const isEnabled = selectedBlocks.length > 0
57+
&& selectedBlocks.every(block => !!this.getSibling(block));
58+
59+
if (!isEnabled) {
60+
return;
61+
}
62+
63+
const movingBlocks = this.offset === 'before'
64+
? selectedBlocks
65+
: [...selectedBlocks].reverse();
66+
67+
// Store selection offsets
68+
const firstBlock = selectedBlocks[0];
69+
const lastBlock = selectedBlocks[selectedBlocks.length - 1];
70+
const startOffset = model.document.selection.getFirstPosition()?.offset ?? 0;
71+
const endOffset = model.document.selection.getLastPosition()?.offset ?? 0;
5172

5273
model.change((writer) => {
53-
for (const selectedBlock of selectedBlocks) {
54-
const sibling = this.getSibling(selectedBlock);
74+
// Move blocks
75+
for (const block of movingBlocks) {
76+
const sibling = this.getSibling(block);
5577
if (sibling) {
56-
const range = model.createRangeOn(selectedBlock);
78+
const range = model.createRangeOn(block);
5779
writer.move(range, sibling, this.offset);
5880
}
5981
}
82+
83+
// Restore selection
84+
let range: Range;
85+
const maxStart = firstBlock.maxOffset ?? startOffset;
86+
const maxEnd = lastBlock.maxOffset ?? endOffset;
87+
// If original offsets valid within bounds, restore partial selection
88+
if (startOffset <= maxStart && endOffset <= maxEnd) {
89+
const clampedStart = Math.min(startOffset, maxStart);
90+
const clampedEnd = Math.min(endOffset, maxEnd);
91+
range = writer.createRange(
92+
writer.createPositionAt(firstBlock, clampedStart),
93+
writer.createPositionAt(lastBlock, clampedEnd)
94+
);
95+
} else { // Fallback: select entire moved blocks (handles tables)
96+
range = writer.createRange(
97+
writer.createPositionBefore(firstBlock),
98+
writer.createPositionAfter(lastBlock)
99+
);
100+
}
101+
writer.setSelection(range);
102+
this.editor.editing.view.focus();
103+
104+
this.scrollToSelection();
60105
});
106+
}
107+
108+
getSelectedBlocks(selection: DocumentSelection) {
109+
const blocks = [...selection.getSelectedBlocks()];
110+
const resolved: Element[] = [];
111+
112+
// Selects elements (such as Mermaid) when there are no blocks
113+
if (!blocks.length) {
114+
const selectedObj = selection.getSelectedElement();
115+
if (selectedObj) {
116+
return [selectedObj];
117+
}
118+
}
119+
120+
for (const block of blocks) {
121+
let el: Element = block;
122+
// Traverse up until the parent is the root ($root) or there is no parent
123+
while (el.parent && el.parent.name !== '$root') {
124+
el = el.parent as Element;
125+
}
126+
resolved.push(el);
127+
}
128+
129+
// Deduplicate adjacent duplicates (e.g., nested selections resolving to same block)
130+
return resolved.filter((blk, idx) => idx === 0 || blk !== resolved[idx - 1]);
61131
}
132+
133+
scrollToSelection() {
134+
// Ensure scroll happens in sync with DOM updates
135+
requestAnimationFrame(() => {
136+
this.editor.editing.view.scrollToTheSelection();
137+
});
138+
};
62139
}
63140

64141
class MoveBlockUpCommand extends MoveBlockUpDownCommand {
65142

66-
getSelectedBlocks(selection: DocumentSelection) {
67-
return [...selection.getSelectedBlocks()];
68-
}
69-
70143
getSibling(selectedBlock: Element) {
71144
return selectedBlock.previousSibling;
72145
}
@@ -79,11 +152,6 @@ class MoveBlockUpCommand extends MoveBlockUpDownCommand {
79152

80153
class MoveBlockDownCommand extends MoveBlockUpDownCommand {
81154

82-
/** @override */
83-
getSelectedBlocks(selection: DocumentSelection) {
84-
return [...selection.getSelectedBlocks()].reverse();
85-
}
86-
87155
/** @override */
88156
getSibling(selectedBlock: Element) {
89157
return selectedBlock.nextSibling;

0 commit comments

Comments
 (0)