2025-05-27 20:36:57 +03:00
|
|
|
/**
|
|
|
|
|
* https://github.com/TriliumNext/Notes/issues/1002
|
|
|
|
|
*/
|
|
|
|
|
|
2025-05-29 13:24:32 +03:00
|
|
|
import { Command, DocumentSelection, Element, Node, Plugin } from 'ckeditor5';
|
2025-05-27 20:36:57 +03:00
|
|
|
|
|
|
|
|
export default class MoveBlockUpDownPlugin extends Plugin {
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
const editor = this.editor;
|
|
|
|
|
|
|
|
|
|
editor.commands.add('moveBlockUp', new MoveBlockUpCommand(editor));
|
|
|
|
|
editor.commands.add('moveBlockDown', new MoveBlockDownCommand(editor));
|
|
|
|
|
|
2025-06-09 10:04:10 +08:00
|
|
|
// 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);
|
2025-05-27 20:36:57 +03:00
|
|
|
}
|
2025-06-09 10:04:10 +08:00
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-05-27 20:36:57 +03:00
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
abstract class MoveBlockUpDownCommand extends Command {
|
|
|
|
|
|
2025-05-29 13:24:32 +03:00
|
|
|
abstract getSibling(selectedBlock: Element): Node | null;
|
2025-05-27 20:36:57 +03:00
|
|
|
abstract get offset(): "before" | "after";
|
|
|
|
|
|
2025-05-29 13:22:38 +03:00
|
|
|
override execute() {
|
2025-05-27 20:36:57 +03:00
|
|
|
const model = this.editor.model;
|
|
|
|
|
const selection = model.document.selection;
|
|
|
|
|
const selectedBlocks = this.getSelectedBlocks(selection);
|
2025-06-08 16:30:10 +08:00
|
|
|
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
|
2025-06-09 10:04:10 +08:00
|
|
|
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;
|
2025-05-27 20:36:57 +03:00
|
|
|
|
|
|
|
|
model.change((writer) => {
|
2025-06-08 16:30:10 +08:00
|
|
|
// Move blocks
|
|
|
|
|
for (const block of movingBlocks) {
|
|
|
|
|
const sibling = this.getSibling(block);
|
2025-05-27 20:36:57 +03:00
|
|
|
if (sibling) {
|
2025-06-08 16:30:10 +08:00
|
|
|
const range = model.createRangeOn(block);
|
2025-05-27 20:36:57 +03:00
|
|
|
writer.move(range, sibling, this.offset);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-08 16:30:10 +08:00
|
|
|
// Restore selection to all items if many have been moved
|
2025-06-09 10:04:10 +08:00
|
|
|
if (
|
|
|
|
|
startOffset <= (firstBlock.maxOffset ?? Infinity) &&
|
|
|
|
|
endOffset <= (lastBlock.maxOffset ?? Infinity)
|
|
|
|
|
) {
|
|
|
|
|
writer.setSelection(
|
|
|
|
|
writer.createRange(
|
|
|
|
|
writer.createPositionAt(firstBlock, startOffset),
|
|
|
|
|
writer.createPositionAt(lastBlock, endOffset)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-06-08 16:49:02 +08:00
|
|
|
|
|
|
|
|
this.scrollToSelection();
|
2025-06-08 16:30:10 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-09 10:04:10 +08:00
|
|
|
getSelectedBlocks(selection: DocumentSelection) {
|
|
|
|
|
const blocks = [...selection.getSelectedBlocks()];
|
|
|
|
|
|
|
|
|
|
// If the selected block is an object, such as a block quote or admonition, return the entire block.
|
|
|
|
|
if (blocks.length === 1) {
|
|
|
|
|
const block = blocks[0];
|
|
|
|
|
const parent = block.parent;
|
|
|
|
|
if (!parent?.name?.startsWith('$')) {
|
|
|
|
|
return [parent as Element];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return blocks;
|
|
|
|
|
}
|
2025-06-08 16:49:02 +08:00
|
|
|
|
|
|
|
|
scrollToSelection() {
|
|
|
|
|
// Ensure scroll happens in sync with DOM updates
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
this.editor.editing.view.scrollToTheSelection();
|
|
|
|
|
});
|
|
|
|
|
};
|
2025-06-08 16:30:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class MoveBlockUpCommand extends MoveBlockUpDownCommand {
|
2025-05-27 20:36:57 +03:00
|
|
|
|
|
|
|
|
getSibling(selectedBlock: Element) {
|
|
|
|
|
return selectedBlock.previousSibling;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get offset() {
|
|
|
|
|
return "before" as const;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class MoveBlockDownCommand extends MoveBlockUpDownCommand {
|
|
|
|
|
|
|
|
|
|
/** @override */
|
|
|
|
|
getSibling(selectedBlock: Element) {
|
|
|
|
|
return selectedBlock.nextSibling;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @override */
|
|
|
|
|
get offset() {
|
|
|
|
|
return "after" as const;
|
|
|
|
|
}
|
|
|
|
|
}
|