[Feature] Keyboard cbs (#824)

This commit is contained in:
George Berezhnoy 2019-07-01 02:15:31 +03:00 committed by GitHub
commit 2243f55b04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 271 additions and 168 deletions

6
dist/editor.js vendored

File diff suppressed because one or more lines are too long

View file

@ -82,7 +82,7 @@ export default class BlockEvents extends Module {
if (!isShortcut) {
this.Editor.BlockManager.clearFocused();
this.Editor.BlockSelection.clearSelection();
this.Editor.BlockSelection.clearSelection(event);
}
}
}
@ -93,6 +93,14 @@ export default class BlockEvents extends Module {
* - shows conversion toolbar with 85% of block selection
*/
public keyup(event): void {
/**
* If shift key was pressed some special shortcut is used (eg. cross block selection via shift + arrows)
*/
if (event.shiftKey) {
return;
}
const { InlineToolbar, ConversionToolbar, UI, BlockManager } = this.Editor;
const block = BlockManager.getBlock(event.target);
@ -157,7 +165,7 @@ export default class BlockEvents extends Module {
* @param {MouseEvent} event
*/
public mouseDown(event: MouseEvent): void {
this.Editor.MouseSelection.watchSelection(event);
this.Editor.CrossBlockSelection.watchSelection(event);
}
/**
@ -168,7 +176,7 @@ export default class BlockEvents extends Module {
/**
* Clear blocks selection by tab
*/
this.Editor.BlockSelection.clearSelection();
this.Editor.BlockSelection.clearSelection(event);
const { BlockManager, Tools, ConversionToolbar, InlineToolbar } = this.Editor;
const currentBlock = BlockManager.currentBlock;
@ -213,7 +221,7 @@ export default class BlockEvents extends Module {
/**
* Clear blocks selection by ESC
*/
this.Editor.BlockSelection.clearSelection();
this.Editor.BlockSelection.clearSelection(event);
if (this.Editor.Toolbox.opened) {
this.Editor.Toolbox.close();
@ -296,7 +304,7 @@ export default class BlockEvents extends Module {
Caret.setToBlock(BlockManager.insertInitialBlockAtIndex(selectionPositionIndex, true), Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection();
BlockSelection.clearSelection(event);
}
/**
@ -416,7 +424,7 @@ export default class BlockEvents extends Module {
this.Editor.Toolbar.close();
/** Clear selection */
BlockSelection.clearSelection();
BlockSelection.clearSelection(event);
return;
}
@ -492,6 +500,13 @@ export default class BlockEvents extends Module {
* Handle right and down keyboard keys
*/
private arrowRightAndDown(event: KeyboardEvent): void {
const shouldEnableCBS = this.Editor.Caret.isAtEnd || this.Editor.BlockSelection.anyBlockSelected;
if (event.shiftKey && event.keyCode === _.keyCodes.DOWN && shouldEnableCBS) {
this.Editor.CrossBlockSelection.toggleBlockSelectedState();
return;
}
if (this.Editor.Caret.navigateNext()) {
/**
* Default behaviour moves cursor by 1 character, we need to prevent it
@ -512,13 +527,20 @@ export default class BlockEvents extends Module {
/**
* Clear blocks selection by arrows
*/
this.Editor.BlockSelection.clearSelection();
this.Editor.BlockSelection.clearSelection(event);
}
/**
* Handle left and up keyboard keys
*/
private arrowLeftAndUp(event: KeyboardEvent): void {
const shouldEnableCBS = this.Editor.Caret.isAtStart || this.Editor.BlockSelection.anyBlockSelected;
if (event.shiftKey && event.keyCode === _.keyCodes.UP && shouldEnableCBS) {
this.Editor.CrossBlockSelection.toggleBlockSelectedState(false);
return;
}
if (this.Editor.Caret.navigatePrevious()) {
/**
* Default behaviour moves cursor by 1 character, we need to prevent it
@ -539,7 +561,7 @@ export default class BlockEvents extends Module {
/**
* Clear blocks selection by arrows
*/
this.Editor.BlockSelection.clearSelection();
this.Editor.BlockSelection.clearSelection(event);
}
/**

View file

@ -80,7 +80,7 @@ export default class BlockSelection extends Module {
* @return {Block[]}
*/
public get selectedBlocks(): Block[] {
return this.Editor.BlockManager.blocks.filter((block) => block.selected);
return this.Editor.BlockManager.blocks.filter((block: Block) => block.selected);
}
/**
@ -161,14 +161,33 @@ export default class BlockSelection extends Module {
/**
* Clear selection from Blocks
*
* @param {Event} reason - event caused clear of selection
* @param {boolean} restoreSelection - if true, restore saved selection
*/
public clearSelection(restoreSelection = false) {
const {RectangleSelection} = this.Editor;
public clearSelection(reason?: Event, restoreSelection = false) {
const {BlockManager, Caret, RectangleSelection} = this.Editor;
this.needToSelectAll = false;
this.nativeInputSelected = false;
this.readyToBlockSelection = false;
/**
* If reason caused clear of the selection was printable key and any block is selected,
* remove selected blocks and insert pressed key
*/
if (this.anyBlockSelected && reason && reason instanceof KeyboardEvent && _.isPrintableKey(reason.keyCode)) {
const indexToInsert = BlockManager.removeSelectedBlocks();
BlockManager.insertInitialBlockAtIndex(indexToInsert, true);
Caret.setToBlock(BlockManager.currentBlock);
_.delay(() => {
Caret.insertContentAtCaretPosition(reason.key);
}, 20)();
}
this.Editor.CrossBlockSelection.clear(reason);
if (!this.anyBlockSelected || RectangleSelection.isRectActivated()) {
this.Editor.RectangleSelection.clearSelection();
return;

View file

@ -48,16 +48,9 @@ export default class Caret extends Module {
* @return {boolean}
*/
public get isAtStart(): boolean {
/**
* Don't handle ranges
*/
if (!Selection.isCollapsed) {
return false;
}
const selection = Selection.get();
const firstNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput);
let anchorNode = selection.anchorNode;
let focusNode = selection.focusNode;
/** In case lastNode is native input */
if ($.isNativeInput(firstNode)) {
@ -75,7 +68,7 @@ export default class Caret extends Module {
* @type {number}
*/
let firstLetterPosition = anchorNode.textContent.search(/\S/);
let firstLetterPosition = focusNode.textContent.search(/\S/);
if (firstLetterPosition === -1) { // empty text
firstLetterPosition = 0;
@ -88,16 +81,16 @@ export default class Caret extends Module {
* In this case, anchor node has ELEMENT_NODE node type.
* Anchor offset shows amount of children between start of the element and caret position.
*
* So we use child with anchorOffset index as new anchorNode.
* So we use child with focusOffset index as new anchorNode.
*/
let anchorOffset = selection.anchorOffset;
if (anchorNode.nodeType !== Node.TEXT_NODE && anchorNode.childNodes.length) {
if (anchorNode.childNodes[anchorOffset]) {
anchorNode = anchorNode.childNodes[anchorOffset];
anchorOffset = 0;
let focusOffset = selection.focusOffset;
if (focusNode.nodeType !== Node.TEXT_NODE && focusNode.childNodes.length) {
if (focusNode.childNodes[focusOffset]) {
focusNode = focusNode.childNodes[focusOffset];
focusOffset = 0;
} else {
anchorNode = anchorNode.childNodes[anchorOffset - 1];
anchorOffset = anchorNode.textContent.length;
focusNode = focusNode.childNodes[focusOffset - 1];
focusOffset = focusNode.textContent.length;
}
}
@ -105,11 +98,11 @@ export default class Caret extends Module {
* In case of
* <div contenteditable>
* <p><b></b></p> <-- first (and deepest) node is <b></b>
* |adaddad <-- anchor node
* |adaddad <-- focus node
* </div>
*/
if ($.isLineBreakTag(firstNode as HTMLElement) || $.isEmpty(firstNode)) {
const leftSiblings = this.getHigherLevelSiblings(anchorNode as HTMLElement, 'left');
const leftSiblings = this.getHigherLevelSiblings(focusNode as HTMLElement, 'left');
const nothingAtLeft = leftSiblings.every((node) => {
/**
* Workaround case when block starts with several <br>'s (created by SHIFT+ENTER)
@ -126,7 +119,7 @@ export default class Caret extends Module {
return $.isEmpty(node) && !isLineBreak;
});
if (nothingAtLeft && anchorOffset === firstLetterPosition) {
if (nothingAtLeft && focusOffset === firstLetterPosition) {
return true;
}
}
@ -135,7 +128,7 @@ export default class Caret extends Module {
* We use <= comparison for case:
* "| Hello" <--- selection.anchorOffset is 0, but firstLetterPosition is 1
*/
return firstNode === null || anchorNode === firstNode && anchorOffset <= firstLetterPosition;
return firstNode === null || focusNode === firstNode && focusOffset <= firstLetterPosition;
}
/**
@ -143,15 +136,8 @@ export default class Caret extends Module {
* @return {boolean}
*/
public get isAtEnd(): boolean {
/**
* Don't handle ranges
*/
if (!Selection.isCollapsed) {
return false;
}
const selection = Selection.get();
let anchorNode = selection.anchorNode;
let focusNode = selection.focusNode;
const lastNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput, true);
@ -161,7 +147,7 @@ export default class Caret extends Module {
}
/** Case when selection have been cleared programmatically, for example after CBS */
if (!selection.anchorNode) {
if (!selection.focusNode) {
return false;
}
@ -172,16 +158,16 @@ export default class Caret extends Module {
* In this case, anchor node has ELEMENT_NODE node type.
* Anchor offset shows amount of children between start of the element and caret position.
*
* So we use child with anchorOffset - 1 as new anchorNode.
* So we use child with anchofocusOffset - 1 as new focusNode.
*/
let anchorOffset = selection.anchorOffset;
if (anchorNode.nodeType !== Node.TEXT_NODE && anchorNode.childNodes.length) {
if (anchorNode.childNodes[anchorOffset - 1]) {
anchorNode = anchorNode.childNodes[anchorOffset - 1];
anchorOffset = anchorNode.textContent.length;
let focusOffset = selection.focusOffset;
if (focusNode.nodeType !== Node.TEXT_NODE && focusNode.childNodes.length) {
if (focusNode.childNodes[focusOffset - 1]) {
focusNode = focusNode.childNodes[focusOffset - 1];
focusOffset = focusNode.textContent.length;
} else {
anchorNode = anchorNode.childNodes[0];
anchorOffset = 0;
focusNode = focusNode.childNodes[0];
focusOffset = 0;
}
}
@ -193,8 +179,7 @@ export default class Caret extends Module {
* </div>
*/
if ($.isLineBreakTag(lastNode as HTMLElement) || $.isEmpty(lastNode)) {
const rightSiblings = this.getHigherLevelSiblings(anchorNode as HTMLElement, 'right');
const rightSiblings = this.getHigherLevelSiblings(focusNode as HTMLElement, 'right');
const nothingAtRight = rightSiblings.every((node, i) => {
/**
* If last right sibling is BR isEmpty returns false, but there actually nothing at right
@ -204,7 +189,7 @@ export default class Caret extends Module {
return (isLastBR) || $.isEmpty(node) && !$.isLineBreakTag(node);
});
if (nothingAtRight && anchorOffset === anchorNode.textContent.length) {
if (nothingAtRight && focusOffset === focusNode.textContent.length) {
return true;
}
}
@ -221,7 +206,7 @@ export default class Caret extends Module {
* We use >= comparison for case:
* "Hello |" <--- selection.anchorOffset is 7, but rightTrimmedText is 6
*/
return anchorNode === lastNode && anchorOffset >= rightTrimmedText.length;
return focusNode === lastNode && focusOffset >= rightTrimmedText.length;
}
/**

View file

@ -0,0 +1,183 @@
import Module from '../__module';
import Block from '../block';
import SelectionUtils from '../selection';
import _ from '../utils';
export default class CrossBlockSelection extends Module {
/**
* Block where selection is started
*/
private firstSelectedBlock: Block;
/**
* Last selected Block
*/
private lastSelectedBlock: Block;
/**
* Sets up listeners
*
* @param {MouseEvent} event - mouse down event
*/
public watchSelection(event: MouseEvent): void {
const {BlockManager, UI, Listeners} = this.Editor;
this.firstSelectedBlock = BlockManager.getBlock(event.target as HTMLElement);
this.lastSelectedBlock = this.firstSelectedBlock;
Listeners.on(document, 'mouseover', this.onMouseOver);
Listeners.on(document, 'mouseup', this.onMouseUp);
}
/**
* 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: boolean = true): void {
const {BlockManager} = this.Editor;
if (!this.lastSelectedBlock) {
this.lastSelectedBlock = this.firstSelectedBlock = BlockManager.currentBlock;
}
if (this.firstSelectedBlock === this.lastSelectedBlock) {
this.firstSelectedBlock.selected = true;
SelectionUtils.get().removeAllRanges();
}
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;
} else {
this.lastSelectedBlock.selected = false;
}
this.lastSelectedBlock = nextBlock;
}
/**
* Clear saved state
*
* @param {Event} reason - event caused clear of selection
*/
public clear(reason?: Event) {
const {BlockManager, BlockSelection, Caret} = this.Editor;
const fIndex = BlockManager.blocks.indexOf(this.firstSelectedBlock);
const lIndex = BlockManager.blocks.indexOf(this.lastSelectedBlock);
if (BlockSelection.anyBlockSelected && fIndex > -1 && lIndex > -1) {
if (reason && reason instanceof KeyboardEvent) {
/**
* Set caret depending on pressed key if pressed key is an arrow.
*/
switch (reason.keyCode) {
case _.keyCodes.DOWN:
case _.keyCodes.RIGHT:
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
break;
case _.keyCodes.UP:
case _.keyCodes.LEFT:
Caret.setToBlock(BlockManager.blocks[Math.min(fIndex, lIndex)], Caret.positions.START);
break;
default:
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
}
} else {
/**
* By default set caret at the end of the last selected block
*/
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
}
}
this.firstSelectedBlock = this.lastSelectedBlock = null;
}
/**
* Mouse up event handler.
* Removes the listeners
*/
private onMouseUp = (): void => {
const {Listeners} = this.Editor;
Listeners.off(document, 'mouseover', this.onMouseOver);
Listeners.off(document, 'mouseup', this.onMouseUp);
}
/**
* Mouse over event handler
* Gets target and related blocks and change selected state for blocks in between
*
* @param {MouseEvent} event
*/
private onMouseOver = (event: MouseEvent): void => {
const {BlockManager} = this.Editor;
const relatedBlock = BlockManager.getBlockByChildNode(event.relatedTarget as Node) || this.lastSelectedBlock;
const targetBlock = BlockManager.getBlockByChildNode(event.target as Node);
if (!relatedBlock || !targetBlock) {
return;
}
if (targetBlock === relatedBlock) {
return;
}
if (relatedBlock === this.firstSelectedBlock) {
SelectionUtils.get().removeAllRanges();
relatedBlock.selected = true;
targetBlock.selected = true;
return;
}
if (targetBlock === this.firstSelectedBlock) {
relatedBlock.selected = false;
targetBlock.selected = false;
return;
}
this.toggleBlocksSelectedState(relatedBlock, targetBlock);
this.lastSelectedBlock = targetBlock;
}
/**
* Change blocks selection state between passed two blocks.
*
* @param {Block} firstBlock
* @param {Block} lastBlock
*/
private toggleBlocksSelectedState(firstBlock: Block, lastBlock: Block): void {
const {BlockManager} = 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;
for (let i = Math.min(fIndex, lIndex); i <= Math.max(fIndex, lIndex); i++) {
const block = BlockManager.blocks[i];
if (
block !== this.firstSelectedBlock &&
block !== (shouldntSelectFirstBlock ? firstBlock : lastBlock)
) {
BlockManager.blocks[i].selected = !BlockManager.blocks[i].selected;
}
}
}
}

View file

@ -1,106 +0,0 @@
import Module from '../__module';
import Block from '../block';
import SelectionUtils from '../selection';
export default class MouseSelection extends Module {
/**
* Block where selection is started
*/
private firstSelectedBlock: Block;
/**
* Sets up listeners
*
* @param {MouseEvent} event - mouse down event
*/
public watchSelection(event: MouseEvent): void {
const {BlockManager, UI, Listeners} = this.Editor;
this.firstSelectedBlock = BlockManager.getBlock(event.target as HTMLElement);
Listeners.on(UI.nodes.redactor, 'mouseover', (mouseOverEvent) => {
this.onMouseOver(mouseOverEvent as MouseEvent);
});
Listeners.on(UI.nodes.redactor, 'mouseup', () => {
this.onMouseUp();
});
}
/**
* Mouse up event handler.
* Removes the listeners because selection is finished
*/
private onMouseUp(): void {
const {Listeners, UI} = this.Editor;
Listeners.off(UI.nodes.redactor, 'mouseover');
Listeners.off(UI.nodes.redactor, 'mouseup');
}
/**
* Mouse over event handler
* Gets target and related blocks and change selected state for blocks in between
*
* @param {MouseEvent} event
*/
private onMouseOver(event: MouseEvent): void {
const {BlockManager} = this.Editor;
const relatedBlock = BlockManager.getBlockByChildNode(event.relatedTarget as Node);
const targetBlock = BlockManager.getBlockByChildNode(event.target as Node);
if (!relatedBlock || !targetBlock) {
return;
}
if (targetBlock === relatedBlock) {
return;
}
if (relatedBlock === this.firstSelectedBlock) {
SelectionUtils.get().removeAllRanges();
relatedBlock.selected = true;
targetBlock.selected = true;
return;
}
if (targetBlock === this.firstSelectedBlock) {
relatedBlock.selected = false;
targetBlock.selected = false;
return;
}
this.toggleBlocksSelection(relatedBlock, targetBlock);
}
/**
* Change blocks selection state between passed two blocks.
*
* @param {Block} firstBlock
* @param {Block} lastBlock
*/
private toggleBlocksSelection(firstBlock: Block, lastBlock: Block): void {
const {BlockManager} = 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;
for (let i = Math.min(fIndex, lIndex); i <= Math.max(fIndex, lIndex); i++) {
const block = BlockManager.blocks[i];
if (
block !== this.firstSelectedBlock &&
block !== (shouldntSelectFirstBlock ? firstBlock : lastBlock)
) {
BlockManager.blocks[i].selected = !BlockManager.blocks[i].selected;
}
}
}
}

View file

@ -356,7 +356,7 @@ export default class UI extends Module {
Caret.setToBlock(BlockManager.insertInitialBlockAtIndex(selectionPositionIndex, true), Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection();
BlockSelection.clearSelection(event);
/**
* Stop propagations
@ -439,7 +439,7 @@ export default class UI extends Module {
Caret.setToBlock(BlockManager.insertInitialBlockAtIndex(selectionPositionIndex, true), Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection();
BlockSelection.clearSelection(event);
/**
* Stop propagations
@ -479,7 +479,7 @@ export default class UI extends Module {
this.Editor.Toolbar.plusButton.show();
}
this.Editor.BlockSelection.clearSelection();
this.Editor.BlockSelection.clearSelection(event);
}
/**
@ -504,7 +504,7 @@ export default class UI extends Module {
this.Editor.BlockManager.dropPointer();
this.Editor.InlineToolbar.close();
this.Editor.Toolbar.close();
this.Editor.BlockSelection.clearSelection();
this.Editor.BlockSelection.clearSelection(event);
this.Editor.ConversionToolbar.close();
}

View file

@ -31,7 +31,7 @@ import Saver from '../components/modules/saver';
import BlockSelection from '../components/modules/blockSelection';
import RectangleSelection from '../components/modules/RectangleSelection';
import InlineToolbarAPI from '../components/modules/api/inlineToolbar';
import MouseSelection from '../components/modules/mouseSelection';
import CrossBlockSelection from '../components/modules/crossBlockSelection';
import ConversionToolbar from '../components/modules/toolbar/conversion';
export interface EditorModules {
@ -68,6 +68,6 @@ export interface EditorModules {
StylesAPI: StylesAPI;
ToolbarAPI: ToolbarAPI;
InlineToolbarAPI: InlineToolbarAPI;
MouseSelection: MouseSelection;
CrossBlockSelection: CrossBlockSelection;
NotifierAPI: NotifierAPI;
}