Skip to content

fix(move_block): move multiple lines up/down #2220

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 11, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 105 additions & 37 deletions packages/ckeditor5/src/plugins/move_block_updown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,71 +2,144 @@
* https://github.com/TriliumNext/Notes/issues/1002
*/

import { Command, DocumentSelection, Element, Node, Plugin } from 'ckeditor5';

import { Command, DocumentSelection, Element, Node, Plugin, Range } from 'ckeditor5';
export default class MoveBlockUpDownPlugin extends Plugin {

init() {
const editor = this.editor;
editor.config.define('moveBlockUp', {
keystroke: ['ctrl+arrowup', 'alt+arrowup'],
});
editor.config.define('moveBlockDown', {
keystroke: ['ctrl+arrowdown', 'alt+arrowdown'],
});

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

for (const keystroke of editor.config.get('moveBlockUp.keystroke') ?? []) {
editor.keystrokes.set(keystroke, 'moveBlockUp');
}
for (const keystroke of editor.config.get('moveBlockDown.keystroke') ?? []) {
editor.keystrokes.set(keystroke, 'moveBlockDown');
}
// Use native DOM capturing to intercept Ctrl/Alt + ↑/↓,
// as plugin-level keystroke handling may fail when the selection is near an object.
this.bindMoveBlockShortcuts(editor);
}

bindMoveBlockShortcuts(editor: any) {
editor.editing.view.once('render', () => {
const domRoot = editor.editing.view.getDomRoot();
if (!domRoot) return;

const handleKeydown = (e: KeyboardEvent) => {
const keyMap = {
ArrowUp: 'moveBlockUp',
ArrowDown: 'moveBlockDown'
};

const command = keyMap[e.key];
const isCtrl = e.ctrlKey || e.metaKey;
const hasModifier = (isCtrl || e.altKey) && !(isCtrl && e.altKey);

if (command && hasModifier) {
e.preventDefault();
e.stopImmediatePropagation();
editor.execute(command);
}
};

domRoot.addEventListener('keydown', handleKeydown, { capture: true });
});
}

}

abstract class MoveBlockUpDownCommand extends Command {

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

override refresh() {
const selection = this.editor.model.document.selection;
const selectedBlocks = this.getSelectedBlocks(selection);

this.isEnabled = true;
for (const selectedBlock of selectedBlocks) {
if (!this.getSibling(selectedBlock)) this.isEnabled = false;
}
}

override execute() {
const model = this.editor.model;
const selection = model.document.selection;
const selectedBlocks = this.getSelectedBlocks(selection);
const isEnabled = selectedBlocks.length > 0
&& selectedBlocks.every(block => !!this.getSibling(block));

if (!isEnabled) {
return;
}

const movingBlocks = this.offset === 'before'
? selectedBlocks
: [...selectedBlocks].reverse();

// Store selection offsets
const firstBlock = selectedBlocks[0];
const lastBlock = selectedBlocks[selectedBlocks.length - 1];
const startOffset = model.document.selection.getFirstPosition()?.offset ?? 0;
const endOffset = model.document.selection.getLastPosition()?.offset ?? 0;

model.change((writer) => {
for (const selectedBlock of selectedBlocks) {
const sibling = this.getSibling(selectedBlock);
// Move blocks
for (const block of movingBlocks) {
const sibling = this.getSibling(block);
if (sibling) {
const range = model.createRangeOn(selectedBlock);
const range = model.createRangeOn(block);
writer.move(range, sibling, this.offset);
}
}

// Restore selection
let range: Range;
const maxStart = firstBlock.maxOffset ?? startOffset;
const maxEnd = lastBlock.maxOffset ?? endOffset;
// If original offsets valid within bounds, restore partial selection
if (startOffset <= maxStart && endOffset <= maxEnd) {
const clampedStart = Math.min(startOffset, maxStart);
const clampedEnd = Math.min(endOffset, maxEnd);
range = writer.createRange(
writer.createPositionAt(firstBlock, clampedStart),
writer.createPositionAt(lastBlock, clampedEnd)
);
} else { // Fallback: select entire moved blocks (handles tables)
range = writer.createRange(
writer.createPositionBefore(firstBlock),
writer.createPositionAfter(lastBlock)
);
}
writer.setSelection(range);
this.editor.editing.view.focus();

this.scrollToSelection();
});
}

getSelectedBlocks(selection: DocumentSelection) {
const blocks = [...selection.getSelectedBlocks()];
const resolved: Element[] = [];

// Selects elements (such as Mermaid) when there are no blocks
if (!blocks.length) {
const selectedObj = selection.getSelectedElement();
if (selectedObj) {
return [selectedObj];
}
}

for (const block of blocks) {
let el: Element = block;
// Traverse up until the parent is the root ($root) or there is no parent
while (el.parent && el.parent.name !== '$root') {
el = el.parent as Element;
}
resolved.push(el);
}

// Deduplicate adjacent duplicates (e.g., nested selections resolving to same block)
return resolved.filter((blk, idx) => idx === 0 || blk !== resolved[idx - 1]);
}

scrollToSelection() {
// Ensure scroll happens in sync with DOM updates
requestAnimationFrame(() => {
this.editor.editing.view.scrollToTheSelection();
});
};
}

class MoveBlockUpCommand extends MoveBlockUpDownCommand {

getSelectedBlocks(selection: DocumentSelection) {
return [...selection.getSelectedBlocks()];
}

getSibling(selectedBlock: Element) {
return selectedBlock.previousSibling;
}
Expand All @@ -79,11 +152,6 @@ class MoveBlockUpCommand extends MoveBlockUpDownCommand {

class MoveBlockDownCommand extends MoveBlockUpDownCommand {

/** @override */
getSelectedBlocks(selection: DocumentSelection) {
return [...selection.getSelectedBlocks()].reverse();
}

/** @override */
getSibling(selectedBlock: Element) {
return selectedBlock.nextSibling;
Expand Down