Improve caret behaviour (#589)

This commit is contained in:
George Berezhnoy 2019-01-12 04:57:37 +03:00 committed by GitHub
parent da9255a98d
commit 63a82d3424
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 289 additions and 78 deletions

3
.gitmodules vendored
View file

@ -34,3 +34,6 @@
[submodule "example/tools/table"]
path = example/tools/table
url = https://github.com/codex-editor/table
[submodule "example/tools/checklist"]
path = example/tools/checklist
url = https://github.com/codex-editor/checklist

File diff suppressed because one or more lines are too long

View file

@ -1,16 +1,20 @@
# Changelog
### 2.2.26 changelog
- `Improvements` *Caret* — Improvements of the caret behaviour: arrows, backspace and enter keys better handling.
### 2.2.25 changelog
- `New` *Autofocus — Now you can set focus at Editor after page has been loaded
- `New` *Autofocus* — Now you can set focus at Editor after page has been loaded
### 2.2.24 changelog
- `Improvements` *Paste handling — minor paste handling improvements
- `Improvements` *Paste* handling — minor paste handling improvements
### 2.2.23 changelog
- `New` *Shortcuts — copy and cut Blocks selected by CMD+A
- `New` *Shortcuts* — copy and cut Blocks selected by CMD+A
### 2.2—2.7 changelog

View file

@ -45,6 +45,7 @@
<script src="./tools/simple-image/dist/bundle.js"></script><!-- Image -->
<script src="./tools/delimiter/dist/bundle.js"></script><!-- Delimiter -->
<script src="./tools/list/dist/bundle.js"></script><!-- List -->
<script src="./tools/checklist/dist/bundle.js"></script><!-- Checklist -->
<script src="./tools/quote/dist/bundle.js"></script><!-- Quote -->
<script src="./tools/code/dist/bundle.js"></script><!-- Code -->
<script src="./tools/embed/dist/bundle.js"></script><!-- Embed -->
@ -101,6 +102,11 @@
inlineToolbar: true
},
checklist: {
class: Checklist,
inlineToolbar: true
},
quote: {
class: Quote,
inlineToolbar: true,

@ -0,0 +1 @@
Subproject commit 4e5231ada9633f7cc8a80653d5dd98277da5a87e

@ -1 +1 @@
Subproject commit 20559d4512752d95a664795e4f031d6424a70241
Subproject commit 0cc71289a9ba7f65340a792c07e9a3f9733bcdc9

View file

@ -1,6 +1,6 @@
{
"name": "codex.editor",
"version": "2.7.25",
"version": "2.7.26",
"description": "CodeX Editor. Native JS, based on API and Open Source",
"main": "dist/codex-editor.js",
"types": "./types/index.d.ts",

View file

@ -25,6 +25,7 @@ import _ from './utils';
import MoveUpTune from './block-tunes/block-tune-move-up';
import DeleteTune from './block-tunes/block-tune-delete';
import MoveDownTune from './block-tunes/block-tune-move-down';
import SelectionUtils from './selection';
/**
* @classdesc Abstract Block class that contains Block information, Tool name and Tool class instance
@ -60,10 +61,21 @@ export default class Block {
const content = this.holder;
const allowedInputTypes = ['text', 'password', 'email', 'number', 'search', 'tel', 'url'];
const selector = '[contenteditable], textarea, input, '
const selector = '[contenteditable], textarea, '
+ allowedInputTypes.map((type) => `input[type="${type}"]`).join(', ');
const inputs = _.array(content.querySelectorAll(selector));
let inputs = _.array(content.querySelectorAll(selector));
/**
* If contenteditable element contains block elements, treat them as inputs.
*/
inputs = inputs.reduce((result, input) => {
if ($.isNativeInput(input) || $.containsOnlyInlineElements(input)) {
return [...result, input];
}
return [...result, ...$.getDeepestBlockElements(input)];
}, []);
/**
* If inputs amount was changed we need to check if input index is bigger then inputs array length
@ -80,7 +92,7 @@ export default class Block {
*
* @returns {HTMLElement}
*/
get currentInput(): HTMLElement {
get currentInput(): HTMLElement | Node {
return this.inputs[this.inputIndex];
}
@ -89,8 +101,12 @@ export default class Block {
*
* @param {HTMLElement} element
*/
set currentInput(element: HTMLElement) {
this.inputIndex = this.inputs.findIndex((input) => input === element || input.contains(element));
set currentInput(element: HTMLElement | Node) {
const index = this.inputs.findIndex((input) => input === element || input.contains(element));
if (index !== -1) {
this.inputIndex = index;
}
}
/**
@ -288,6 +304,12 @@ export default class Block {
*/
private inputIndex = 0;
/**
* Mutation observer to handle DOM mutations
* @type {MutationObserver}
*/
private mutationObserver: MutationObserver;
/**
* @constructor
* @param {String} toolName - Tool name that passed on initialization
@ -310,6 +332,8 @@ export default class Block {
this.api = apiMethods;
this.holder = this.compose();
this.mutationObserver = new MutationObserver(this.didMutated);
/**
* @type {BlockTune[]}
*/
@ -432,6 +456,37 @@ export default class Block {
this.holder.classList.toggle(Block.CSS.dropTarget, state);
}
/**
* Update current input index with selection anchor node
*/
public updateCurrentInput(): void {
this.currentInput = SelectionUtils.anchorNode;
}
/**
* Is fired when Block will be selected as current
*/
public willSelect(): void {
/**
* Observe DOM mutations to update Block inputs
*/
this.mutationObserver.observe(this.holder, {childList: true, subtree: true});
}
/**
* Is fired when Block will be unselected
*/
public willUnselect() {
this.mutationObserver.disconnect();
}
/**
* Is fired when DOM mutation has been happened
*/
private didMutated = () => {
this.updateCurrentInput();
}
/**
* Make default Block wrappers and put Tool`s content there
* @returns {HTMLDivElement}

View file

@ -56,7 +56,7 @@ export default class Blocks {
return false;
}
instance.insert(index, block);
instance.insert(+index, block);
return true;
}
@ -73,7 +73,7 @@ export default class Blocks {
return instance[index];
}
return instance.get(index);
return instance.get(+index);
}
/**

View file

@ -28,6 +28,19 @@ export default class Dom {
].includes(tag.tagName);
}
/**
* Check if element is BR or WBR
*
* @param {HTMLElement} element
* @return {boolean}
*/
public static isLineBreakTag(element: HTMLElement) {
return element && element.tagName && [
'BR',
'WBR',
].includes(element.tagName);
}
/**
* Helper for making Elements with classname and attributes
*
@ -196,7 +209,11 @@ export default class Dom {
/**
* special case when child is single tag that can't contain any content
*/
if (Dom.isSingleTag(nodeChild as HTMLElement) && !Dom.isNativeInput(nodeChild)) {
if (
Dom.isSingleTag(nodeChild as HTMLElement) &&
!Dom.isNativeInput(nodeChild) &&
!Dom.isLineBreakTag(nodeChild as HTMLElement)
) {
/**
* 1) We need to check the next sibling. If it is Node Element then continue searching for deepest
* from sibling
@ -303,7 +320,7 @@ export default class Dom {
public static isNodeEmpty(node: Node): boolean {
let nodeText;
if (this.isSingleTag(node as HTMLElement)) {
if (this.isSingleTag(node as HTMLElement) && !this.isLineBreakTag(node as HTMLElement)) {
return false;
}
@ -484,4 +501,21 @@ export default class Dom {
return Array.from(wrapper.children).every(check);
}
/**
* Find and return all block elements in the passed parent (including subtree)
*
* @param {HTMLElement} parent
*
* @return {HTMLElement[]}
*/
public static getDeepestBlockElements(parent: HTMLElement): HTMLElement[] {
if (Dom.containsOnlyInlineElements(parent)) {
return [parent];
}
return Array.from(parent.children).reduce((result, element) => {
return [...result, ...Dom.getDeepestBlockElements(element as HTMLElement)];
}, []);
}
}

View file

@ -3,6 +3,7 @@
*/
import Module from '../__module';
import _ from '../utils';
import Block from '../block';
export default class BlockEvents extends Module {
/**
@ -271,11 +272,21 @@ export default class BlockEvents extends Module {
if (event.shiftKey) {
return;
}
let newCurrent = this.Editor.BlockManager.currentBlock;
/**
* Split the Current Block into two blocks
* Renew local current node after split
* If enter has been pressed at the start of the text, just insert paragraph Block above
*/
const newCurrent = this.Editor.BlockManager.split();
if (this.Editor.Caret.isAtStart && !this.Editor.BlockManager.currentBlock.hasMedia) {
this.Editor.BlockManager.insertAtIndex(this.Editor.BlockManager.currentBlockIndex);
} else {
/**
* Split the Current Block into two blocks
* Renew local current node after split
*/
newCurrent = this.Editor.BlockManager.split();
}
this.Editor.Caret.setToBlock(newCurrent);
@ -311,26 +322,24 @@ export default class BlockEvents extends Module {
/**
* Check if Block should be removed by current Backspace keydown
*/
if (currentBlock.selected || BlockManager.currentBlock.isEmpty) {
if (currentBlock.selected || currentBlock.isEmpty && currentBlock.currentInput === currentBlock.firstInput) {
if (BlockSelection.allBlocksSelected) {
BlockManager.removeAllBlocks();
} else {
BlockManager.removeBlock();
const index = BlockManager.currentBlockIndex;
/**
* In case of deletion first block we need to set caret to the current Block
* After BlockManager removes the Block (which is current now),
* pointer that references to the current Block, now points to the Next
*/
if (BlockManager.currentBlockIndex === 0) {
Caret.setToBlock(BlockManager.currentBlock);
} else if (BlockManager.currentBlock.inputs.length === 0) {
/** If previous (now current) block doesn't contain inputs, remove it */
if (BlockManager.previousBlock && BlockManager.previousBlock.inputs.length === 0) {
/** If previous block doesn't contain inputs, remove it */
BlockManager.removeBlock(index - 1);
} else {
/** If block is empty, just remove it */
BlockManager.removeBlock();
BlockManager.insert();
}
Caret.setToBlock(BlockManager.currentBlock, Caret.positions.END);
Caret.setToBlock(
BlockManager.currentBlock,
index ? Caret.positions.END : Caret.positions.START,
);
}
/** Close Toolbar */
@ -344,13 +353,15 @@ export default class BlockEvents extends Module {
/**
* Don't handle Backspaces when Tool sets enableLineBreaks to true.
* Uses for Tools like <code> where line breaks should be handled by default behaviour.
*
* But if caret is at start of the block, we allow to remove it by backspaces
*/
if (tool && tool[this.Editor.Tools.apiSettings.IS_ENABLED_LINE_BREAKS]) {
if (tool && tool[this.Editor.Tools.apiSettings.IS_ENABLED_LINE_BREAKS] && !Caret.isAtStart) {
return;
}
const isFirstBlock = BlockManager.currentBlockIndex === 0;
const canMergeBlocks = Caret.isAtStart && !isFirstBlock;
const canMergeBlocks = Caret.isAtStart && currentBlock.currentInput === currentBlock.firstInput && !isFirstBlock;
if (canMergeBlocks) {
/**
@ -381,8 +392,8 @@ export default class BlockEvents extends Module {
* other case will handle as usual ARROW LEFT behaviour
*/
if (blockToMerge.name !== targetBlock.name || !targetBlock.mergeable) {
/** If target Block doesn't contain inputs, remove it */
if (targetBlock.inputs.length === 0) {
/** If target Block doesn't contain inputs or empty, remove it */
if (targetBlock.inputs.length === 0 || targetBlock.isEmpty) {
BlockManager.removeBlock(BlockManager.currentBlockIndex - 1);
Caret.setToBlock(BlockManager.currentBlock);
@ -416,6 +427,13 @@ export default class BlockEvents extends Module {
* Default behaviour moves cursor by 1 character, we need to prevent it
*/
event.preventDefault();
} else {
/**
* After caret is set, update Block input index
*/
_.delay(() => {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}, 20)();
}
}
@ -428,6 +446,13 @@ export default class BlockEvents extends Module {
* Default behaviour moves cursor by 1 character, we need to prevent it
*/
event.preventDefault();
} else {
/**
* After caret is set, update Block input index
*/
_.delay(() => {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}, 20)();
}
}

View file

@ -20,6 +20,30 @@ import {BlockTool, BlockToolConstructable, BlockToolData, PasteEvent, ToolConfig
*/
export default class BlockManager extends Module {
/**
* Returns current Block index
* @return {number}
*/
public get currentBlockIndex(): number {
return this._currentBlockIndex;
}
/**
* Set current Block index and fire Block lifecycle callbacks
* @param newIndex
*/
public set currentBlockIndex(newIndex: number) {
if (this._blocks[this._currentBlockIndex]) {
this._blocks[this._currentBlockIndex].willUnselect();
}
if (this._blocks[newIndex]) {
this._blocks[newIndex].willSelect();
}
this._currentBlockIndex = newIndex;
}
/**
* returns last Block
* @return {Block}
@ -101,7 +125,7 @@ export default class BlockManager extends Module {
*
* @type {number}
*/
public currentBlockIndex: number = -1;
private _currentBlockIndex: number = -1;
/**
* Proxy for Blocks instance {@link Blocks}
@ -227,6 +251,28 @@ export default class BlockManager extends Module {
return block;
}
/**
* Insert new initial block at passed index
*
* @param {number} index - index where Block should be inserted
* @param {boolean} needToFocus - if true, updates current Block index
*
* @return {Block} inserted Block
*/
public insertAtIndex(index: number, needToFocus: boolean = false) {
const block = this.composeBlock(this.config.initialBlock, {}, {});
this._blocks[index] = block;
if (needToFocus) {
this.currentBlockIndex = index;
} else if (index <= this.currentBlockIndex) {
this.currentBlockIndex++;
}
return block;
}
/**
* Always inserts at the end
* @return {Block}
@ -272,7 +318,7 @@ export default class BlockManager extends Module {
* @param {Number|null} index
*/
public removeBlock(index?: number): void {
if (!index) {
if (index === undefined) {
index = this.currentBlockIndex;
}
this._blocks.remove(index);
@ -287,7 +333,9 @@ export default class BlockManager extends Module {
if (!this.blocks.length) {
this.currentBlockIndex = -1;
this.insert();
this.currentBlock.firstInput.focus();
return;
} else if (index === 0) {
this.currentBlockIndex = 0;
}
}
@ -317,7 +365,7 @@ export default class BlockManager extends Module {
const extractedFragment = this.Editor.Caret.extractFragmentFromCaretPosition();
const wrapper = $.make('div');
wrapper.append(extractedFragment as DocumentFragment);
wrapper.appendChild(extractedFragment as DocumentFragment);
/**
* @todo make object in accordance with Tool
@ -411,10 +459,7 @@ export default class BlockManager extends Module {
* @param {string} caretPosition - position where to set caret
* @throws Error - when passed Node is not included at the Block
*/
public setCurrentBlockByChildNode(
childNode: Node,
caretPosition: string = this.Editor.Caret.positions.DEFAULT,
): void {
public setCurrentBlockByChildNode(childNode: Node): Block {
/**
* If node is Text TextNode
*/
@ -430,8 +475,7 @@ export default class BlockManager extends Module {
* @type {number}
*/
this.currentBlockIndex = this._blocks.nodes.indexOf(parentFirstLevelBlock as HTMLElement);
this.Editor.Caret.setToInput(childNode as HTMLElement, caretPosition);
return this.currentBlock;
} else {
throw new Error('Can not find a Block from this child Node');
}

View file

@ -55,9 +55,9 @@ export default class Caret extends Module {
return false;
}
const selection = Selection.get(),
anchorNode = selection.anchorNode,
firstNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput);
const selection = Selection.get();
const firstNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput);
let anchorNode = selection.anchorNode;
/** In case lastNode is native input */
if ($.isNativeInput(firstNode)) {
@ -75,6 +75,26 @@ export default class Caret extends Module {
firstLetterPosition = 0;
}
/**
* If caret was set by external code, it might be set to text node wrapper.
* <div>|hello</div> <---- Selection references to <div> instead of text node
*
* 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.
*/
let anchorOffset = selection.anchorOffset;
if (anchorNode.nodeType !== Node.TEXT_NODE && anchorNode.childNodes.length) {
if (anchorNode.childNodes[anchorOffset]) {
anchorNode = anchorNode.childNodes[anchorOffset];
anchorOffset = 0;
} else {
anchorNode = anchorNode.childNodes[anchorOffset - 1];
anchorOffset = anchorNode.textContent.length;
}
}
/**
* In case of
* <div contenteditable>
@ -82,11 +102,11 @@ export default class Caret extends Module {
* |adaddad <-- anchor node
* </div>
*/
if ($.isEmpty(firstNode)) {
const leftSiblings = this.getHigherLevelSiblings(anchorNode as HTMLElement, 'left'),
nothingAtLeft = leftSiblings.every( (node) => $.isEmpty(node) );
if ($.isLineBreakTag(firstNode as HTMLElement) || $.isEmpty(firstNode)) {
const leftSiblings = this.getHigherLevelSiblings(anchorNode as HTMLElement, 'left');
const nothingAtLeft = leftSiblings.every((node, i) => $.isEmpty(node));
if (nothingAtLeft && selection.anchorOffset === firstLetterPosition) {
if (nothingAtLeft && anchorOffset === firstLetterPosition) {
return true;
}
}
@ -95,7 +115,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 && selection.anchorOffset <= firstLetterPosition;
return firstNode === null || anchorNode === firstNode && anchorOffset <= firstLetterPosition;
}
/**
@ -110,15 +130,36 @@ export default class Caret extends Module {
return false;
}
const selection = Selection.get(),
anchorNode = selection.anchorNode,
lastNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput, true);
const selection = Selection.get();
let anchorNode = selection.anchorNode;
const lastNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput, true);
/** In case lastNode is native input */
if ($.isNativeInput(lastNode)) {
return (lastNode as HTMLInputElement).selectionEnd === (lastNode as HTMLInputElement).value.length;
}
/**
* If caret was set by external code, it might be set to text node wrapper.
* <div>hello|</div> <---- Selection references to <div> instead of text node
*
* 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.
*/
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;
} else {
anchorNode = anchorNode.childNodes[0];
anchorOffset = 0;
}
}
/**
* In case of
* <div contenteditable>
@ -126,11 +167,13 @@ export default class Caret extends Module {
* <p><b></b></p> <-- first (and deepest) node is <b></b>
* </div>
*/
if ($.isEmpty(lastNode)) {
const leftSiblings = this.getHigherLevelSiblings(anchorNode as HTMLElement, 'right'),
nothingAtRight = leftSiblings.every( (node) => $.isEmpty(node) );
if ($.isLineBreakTag(lastNode as HTMLElement) || $.isEmpty(lastNode)) {
const rightSiblings = this.getHigherLevelSiblings(anchorNode as HTMLElement, 'right');
const nothingAtRight = rightSiblings.every((node, i) => {
return i === 0 && $.isLineBreakTag(node as HTMLElement) || $.isEmpty(node);
});
if (nothingAtRight && selection.anchorOffset === anchorNode.textContent.length) {
if (nothingAtRight && anchorOffset === anchorNode.textContent.length) {
return true;
}
}
@ -147,7 +190,7 @@ export default class Caret extends Module {
* We use >= comparison for case:
* "Hello |" <--- selection.anchorOffset is 7, but rightTrimmedText is 6
*/
return anchorNode === lastNode && selection.anchorOffset >= rightTrimmedText.length;
return anchorNode === lastNode && anchorOffset >= rightTrimmedText.length;
}
/**
@ -263,7 +306,9 @@ export default class Caret extends Module {
selection.addRange(range);
/** If new cursor position is not visible, scroll to it */
const {top, bottom} = range.getBoundingClientRect();
const {top, bottom} = element.nodeType === Node.ELEMENT_NODE
? element.getBoundingClientRect()
: range.getBoundingClientRect();
const {innerHeight} = window;
if (top < 0) { window.scrollBy(0, top); }
@ -332,12 +377,7 @@ export default class Caret extends Module {
return false;
}
if (force) {
this.setToBlock(nextContentfulBlock, this.positions.START);
return true;
}
if (this.isAtEnd) {
if (force || this.isAtEnd) {
/** If next Tool`s input exists, focus on it. Otherwise set caret to the next Block */
if (!nextInput) {
this.setToBlock(nextContentfulBlock, this.positions.START);
@ -373,12 +413,7 @@ export default class Caret extends Module {
return false;
}
if (force) {
this.setToBlock( previousContentfulBlock, this.positions.END );
return true;
}
if (this.isAtStart) {
if (force || this.isAtStart) {
/** If previous Tool`s input exists, focus on it. Otherwise set caret to the previous Block */
if (!previousInput) {
this.setToBlock( previousContentfulBlock, this.positions.END );

View file

@ -67,9 +67,13 @@ export default class DragNDrop extends Module {
* If drop target (error will be thrown) is not part of the Block, set last Block as current.
*/
try {
BlockManager.setCurrentBlockByChildNode(dropEvent.target as Node, Caret.positions.END);
const targetBlock = BlockManager.setCurrentBlockByChildNode(dropEvent.target as Node);
this.Editor.Caret.setToBlock(targetBlock, Caret.positions.END);
} catch (e) {
BlockManager.setCurrentBlockByChildNode(BlockManager.lastBlock.holder, Caret.positions.END);
const targetBlock = BlockManager.setCurrentBlockByChildNode(BlockManager.lastBlock.holder);
this.Editor.Caret.setToBlock(targetBlock, Caret.positions.END);
}
Paste.processDataTransfer(dropEvent.dataTransfer, true);