editor.js/src/components/modules/crossBlockSelection.ts
2025-11-15 04:49:30 +03:00

301 lines
8 KiB
TypeScript

import Module from '../__module';
import type Block from '../block';
import SelectionUtils from '../selection';
import * as _ from '../utils';
/**
*
*/
export default class CrossBlockSelection extends Module {
/**
* Block where selection is started
*/
private firstSelectedBlock: Block | null = null;
/**
* Last selected Block
*/
private lastSelectedBlock: Block | null = null;
/**
* Module preparation
*
* @returns {Promise}
*/
public async prepare(): Promise<void> {
this.listeners.on(document, 'mousedown', (event: Event) => {
this.enableCrossBlockSelection(event as MouseEvent);
});
}
/**
* Sets up listeners
*
* @param {MouseEvent} event - mouse down event
*/
public watchSelection(event: MouseEvent): void {
if (event.button !== _.mouseButtons.LEFT) {
return;
}
const { BlockManager } = this.Editor;
const block = BlockManager.getBlock(event.target as HTMLElement);
if (!block) {
return;
}
this.firstSelectedBlock = block;
this.lastSelectedBlock = block;
this.listeners.on(document, 'mouseover', this.onMouseOver);
this.listeners.on(document, 'mouseup', this.onMouseUp);
}
/**
* Return boolean is cross block selection started:
* there should be at least 2 selected blocks
*/
public get isCrossBlockSelectionStarted(): boolean {
return !!this.firstSelectedBlock && !!this.lastSelectedBlock && this.firstSelectedBlock !== this.lastSelectedBlock;
}
/**
* Change selection state of the next Block
* Used for CBS via Shift + arrow keys
*
* @param {boolean} next - if true, toggle next block. Previous otherwise
*/
public toggleBlockSelectedState(next = true): void {
const { BlockManager, BlockSelection } = this.Editor;
const currentBlock = BlockManager.currentBlock;
if (!this.lastSelectedBlock && !currentBlock) {
return;
}
if (!this.lastSelectedBlock && currentBlock) {
this.lastSelectedBlock = this.firstSelectedBlock = currentBlock;
}
if (this.firstSelectedBlock === this.lastSelectedBlock && this.firstSelectedBlock) {
this.firstSelectedBlock.selected = true;
BlockSelection.clearCache();
SelectionUtils.get()?.removeAllRanges();
/**
* Hide the Toolbar when cross-block selection starts.
*/
this.Editor.Toolbar.close();
}
if (!this.lastSelectedBlock) {
return;
}
const nextBlockIndex = BlockManager.blocks.indexOf(this.lastSelectedBlock) + (next ? 1 : -1);
const nextBlock = BlockManager.blocks[nextBlockIndex];
if (!nextBlock) {
return;
}
if (this.lastSelectedBlock.selected !== nextBlock.selected) {
nextBlock.selected = true;
BlockSelection.clearCache();
this.Editor.Toolbar.close();
} else {
this.lastSelectedBlock.selected = false;
BlockSelection.clearCache();
this.Editor.Toolbar.close();
}
this.lastSelectedBlock = nextBlock;
/** close InlineToolbar when Blocks selected */
this.Editor.InlineToolbar.close();
nextBlock.holder.scrollIntoView({
block: 'nearest',
});
}
/**
* Clear saved state
*
* @param {Event} reason - event caused clear of selection
*/
public clear(reason?: Event): void {
const { BlockManager, BlockSelection, Caret } = this.Editor;
if (!this.firstSelectedBlock || !this.lastSelectedBlock) {
return;
}
const fIndex = BlockManager.blocks.indexOf(this.firstSelectedBlock);
const lIndex = BlockManager.blocks.indexOf(this.lastSelectedBlock);
if (!BlockSelection.anyBlockSelected || fIndex === -1 || lIndex === -1) {
this.firstSelectedBlock = this.lastSelectedBlock = null;
return;
}
if (reason && reason instanceof KeyboardEvent) {
/**
* Set caret depending on pressed key if pressed key is an arrow.
*/
switch (reason.key) {
case 'ArrowDown':
case 'ArrowRight':
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
break;
case 'ArrowUp':
case 'ArrowLeft':
Caret.setToBlock(BlockManager.blocks[Math.min(fIndex, lIndex)], Caret.positions.START);
break;
default:
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
}
}
this.firstSelectedBlock = this.lastSelectedBlock = null;
}
/**
* Enables Cross Block Selection
*
* @param {MouseEvent} event - mouse down event
*/
private enableCrossBlockSelection(event: MouseEvent): void {
const { UI } = this.Editor;
/**
* Each mouse down on must disable selectAll state
*/
if (!SelectionUtils.isCollapsed) {
this.Editor.BlockSelection.clearSelection(event);
}
/**
* If mouse down is performed inside the editor, we should watch CBS
*/
if (UI.nodes.redactor.contains(event.target as Node)) {
this.watchSelection(event);
} else {
/**
* Otherwise, clear selection
*/
this.Editor.BlockSelection.clearSelection(event);
}
}
/**
* Mouse up event handler.
* Removes the listeners
*/
private onMouseUp = (): void => {
this.listeners.off(document, 'mouseover', this.onMouseOver);
this.listeners.off(document, 'mouseup', this.onMouseUp);
};
/**
* Mouse over event handler
* Gets target and related blocks and change selected state for blocks in between
*
* @param {Event} event - mouse over event
*/
private onMouseOver = (event: Event): void => {
const mouseEvent = event as MouseEvent;
const { BlockManager, BlockSelection } = this.Editor;
/**
* Probably, editor is not initialized yet
*/
if (mouseEvent.relatedTarget === null && mouseEvent.target === null) {
return;
}
const relatedBlock = BlockManager.getBlockByChildNode(mouseEvent.relatedTarget as Node) || this.lastSelectedBlock;
const targetBlock = BlockManager.getBlockByChildNode(mouseEvent.target as Node);
if (!relatedBlock || !targetBlock) {
return;
}
if (targetBlock === relatedBlock) {
return;
}
if (this.firstSelectedBlock && relatedBlock === this.firstSelectedBlock) {
SelectionUtils.get()?.removeAllRanges();
relatedBlock.selected = true;
targetBlock.selected = true;
BlockSelection.clearCache();
return;
}
if (this.firstSelectedBlock && targetBlock === this.firstSelectedBlock) {
relatedBlock.selected = false;
targetBlock.selected = false;
BlockSelection.clearCache();
return;
}
this.Editor.InlineToolbar.close();
this.toggleBlocksSelectedState(relatedBlock, targetBlock);
this.lastSelectedBlock = targetBlock;
};
/**
* Change blocks selection state between passed two blocks.
*
* @param {Block} firstBlock - first block in range
* @param {Block} lastBlock - last block in range
*/
private toggleBlocksSelectedState(firstBlock: Block, lastBlock: Block): void {
const { BlockManager, BlockSelection } = this.Editor;
const fIndex = BlockManager.blocks.indexOf(firstBlock);
const lIndex = BlockManager.blocks.indexOf(lastBlock);
/**
* If first and last block have the different selection state
* it means we should't toggle selection of the first selected block.
* In the other case we shouldn't toggle the last selected block.
*/
const shouldntSelectFirstBlock = firstBlock.selected !== lastBlock.selected;
const startIndex = Math.min(fIndex, lIndex);
const endIndex = Math.max(fIndex, lIndex);
for (const i of Array.from({ length: endIndex - startIndex + 1 }, (unused, idx) => startIndex + idx)) {
const block = BlockManager.blocks[i];
if (
block !== this.firstSelectedBlock &&
block !== (shouldntSelectFirstBlock ? firstBlock : lastBlock)
) {
BlockManager.blocks[i].selected = !BlockManager.blocks[i].selected;
BlockSelection.clearCache();
}
}
/**
* Do not keep the Toolbar visible while range selection is active.
*/
this.Editor.Toolbar.close();
}
}