editor.js/codex-editor.js

917 lines
21 KiB
JavaScript
Raw Normal View History

2016-02-02 13:45:43 +01:00
/**
* CodeX Editor
* https://ifmo.su/editor
* @author CodeX team team@ifmo.su
*/
2016-02-02 13:45:43 +01:00
var cEditor = (function (cEditor) {
// Default settings
cEditor.settings = {
2016-02-05 09:25:52 +01:00
tools : ['header', 'picture', 'list', 'quote', 'code', 'twitter', 'instagram', 'smile'],
textareaId : 'codex-editor',
// First-level tags viewing as separated blocks. Other'll be inserted as child
blockTags : ['P','BLOCKQUOTE','UL','CODE','OL','H1','H2','H3','H4','H5','H6']
2016-02-02 13:45:43 +01:00
};
// Static nodes
cEditor.nodes = {
textarea : null,
2016-02-05 09:25:52 +01:00
wrapper : null,
toolbar : null,
2016-02-05 16:29:29 +01:00
toolbarButtons : {}, // { type : DomEl, ... }
2016-02-05 09:25:52 +01:00
redactor : null
}
// Current editor state
cEditor.state = {
html : '',
blocks : []
2016-02-02 13:45:43 +01:00
}
/**
* Initialization
* @uses Promise cEditor.core.prepare
* @param {} userSettings are :
* - tools [],
* - textareaId String
* ...
*/
cEditor.start = function (userSettings) {
// Prepare editor settings
this.core.prepare(userSettings)
2016-02-05 09:25:52 +01:00
// If all ok, make UI, bind events and parse initial-content
2016-02-02 13:45:43 +01:00
.then(this.ui.make)
.then(this.ui.bindEvents)
2016-02-05 09:25:52 +01:00
.then(this.parser.parseTextareaContent)
2016-02-02 13:45:43 +01:00
.catch(function (error) {
cEditor.core.log('Initialization failed with error: %o', 'warn', error);
})
};
return cEditor;
})({});
/**
* Redactor core methods
* Methods:
* - init
* - log
2016-02-05 09:25:52 +01:00
* - insertAfter
* - isDomNode
2016-02-02 13:45:43 +01:00
*/
cEditor.core = {
/**
* Editor preparing method
* @return Promise
*/
prepare : function (userSettings) {
return new Promise(function(resolve, reject){
if ( userSettings ) {
cEditor.settings.tools = userSettings.tools || cEditor.settings.tools;
}
cEditor.nodes.textarea = document.getElementById(userSettings.textareaId || cEditor.settings.textareaId);
if (typeof cEditor.nodes.textarea == undefined || cEditor.nodes.textarea == null) {
reject(Error("Textarea wasn't found by ID: #" + userSettings.textareaId));
} else {
resolve();
}
});
},
/**
* Logging method
* @param type = ['log', 'info', 'warn']
*/
log : function (msg, type, arg) {
type = type || 'log';
if (!arg) {
arg = msg || 'undefined';
msg = '[codex-editor]: %o';
} else {
msg = '[codex-editor]: ' + msg;
}
try{
if ( 'console' in window && console[ type ] ){
if ( arg ) console[ type ]( msg , arg );
else console[ type ]( msg );
}
}catch(e){}
},
/**
2016-02-05 09:25:52 +01:00
* Helper for insert one element after another
2016-02-02 13:45:43 +01:00
*/
2016-02-05 09:25:52 +01:00
insertAfter : function (target, element) {
target.parentNode.insertBefore(element, target.nextSibling);
},
2016-02-02 13:45:43 +01:00
2016-02-05 09:25:52 +01:00
/**
2016-02-05 16:29:29 +01:00
* Readable DOM-node types map
2016-02-05 09:25:52 +01:00
*/
nodeTypes : {
TAG : 1,
TEXT : 3,
COMMENT : 8
},
2016-02-02 13:45:43 +01:00
2016-02-05 16:29:29 +01:00
/**
* Readable keys map
*/
keys : { BACKSPACE: 8, TAB: 9, ENTER: 13, SHIFT: 16, CTRL: 17, ALT: 18, ESC: 27, SPACE: 32, LEFT: 37, UP: 38, DOWN: 40, RIGHT: 39, DELETE: 46, META: 91 },
2016-02-05 16:29:29 +01:00
2016-02-05 09:25:52 +01:00
/**
* Check object for DOM node
*/
isDomNode : function (el) {
return el && typeof el === 'object' && el.nodeType && el.nodeType == this.nodeTypes.TAG;
2016-02-02 13:45:43 +01:00
}
}
cEditor.ui = {
/**
* Making main interface
*/
make : function () {
2016-02-05 09:25:52 +01:00
var wrapper,
toolbar,
tool,
redactor;
2016-02-02 13:45:43 +01:00
2016-02-05 09:25:52 +01:00
/** Make editor wrapper */
wrapper = cEditor.draw.wrapper();
2016-02-02 13:45:43 +01:00
2016-02-05 09:25:52 +01:00
/** Append editor wrapper after initial textarea */
cEditor.core.insertAfter(cEditor.nodes.textarea, wrapper);
2016-02-02 13:45:43 +01:00
2016-02-05 09:25:52 +01:00
/** Make toolbar and content-editable redactor */
toolbar = cEditor.draw.toolbar();
redactor = cEditor.draw.redactor();
wrapper.appendChild(toolbar);
wrapper.appendChild(redactor);
/** Make toolbar buttons */
cEditor.settings.tools.forEach(function(type) {
tool = cEditor.draw.toolbarButton(type);
toolbar.appendChild(tool);
2016-02-02 13:45:43 +01:00
2016-02-05 09:25:52 +01:00
/** Save tools to static nodes */
cEditor.nodes.toolbarButtons[type] = tool;
});
/** Save created ui-elements to static nodes state */
cEditor.nodes.wrapper = wrapper;
cEditor.nodes.toolbar = toolbar;
2016-02-05 09:25:52 +01:00
cEditor.nodes.redactor = redactor;
2016-02-02 13:45:43 +01:00
},
/**
* Bind editor UI events
*/
bindEvents : function () {
cEditor.core.log('ui.bindEvents fired', 'info');
2016-02-05 16:29:29 +01:00
/** All keydowns on Document */
document.addEventListener('keydown', function (event) {
2016-02-05 16:29:29 +01:00
cEditor.callback.globalKeydown(event);
}, false );
/** All keydowns on Document */
document.addEventListener('keyup', function (event) {
cEditor.callback.globalKeyup(event);
}, false );
/** Mouse click to radactor */
cEditor.nodes.redactor.addEventListener('click', function (event) {
cEditor.callback.redactorClicked(event);
2016-06-04 19:25:03 +02:00
cEditor.content.saveCaretPosition();
}, false );
/** Any redactor changes: keyboard input, mouse cut/paste, drag-n-drop text */
cEditor.nodes.redactor.addEventListener('input', function (event) {
cEditor.callback.redactorInputEvent(event);
}, false );
/** Bind click listeners on toolbar buttons */
for (button in cEditor.nodes.toolbarButtons){
cEditor.nodes.toolbarButtons[button].addEventListener('click', function (event) {
cEditor.callback.toolbarButtonClicked(event, this);
}, false);
}
2016-03-29 20:55:28 +02:00
2016-02-02 13:45:43 +01:00
}
2016-02-05 16:29:29 +01:00
};
cEditor.callback = {
2016-03-29 20:55:28 +02:00
redactorSyncTimeout : null,
2016-02-05 16:29:29 +01:00
globalKeydown : function(event){
switch (event.keyCode){
case cEditor.core.keys.TAB : this.tabKeyPressed(event); break;
case cEditor.core.keys.ENTER : this.enterKeyPressed(event); break;
case cEditor.core.keys.ESC : this.escapeKeyPressed(event); break;
}
},
globalKeyup : function(event){
switch (event.keyCode){
case cEditor.core.keys.UP :
2016-03-08 21:07:11 +01:00
case cEditor.core.keys.DOWN : this.arrowKeyPressed(event); break;
2016-02-05 16:29:29 +01:00
}
},
2016-02-05 16:29:29 +01:00
tabKeyPressed : function(event){
if ( !cEditor.toolbar.opened ) {
cEditor.toolbar.open();
} else {
cEditor.toolbar.leaf();
}
event.preventDefault();
2016-02-05 16:29:29 +01:00
},
enterKeyPressed : function(event){
2016-03-29 20:55:28 +02:00
cEditor.content.workingNodeChanged();
if (cEditor.toolbar.opened && event.target == cEditor.nodes.redactor) {
2016-04-27 19:47:02 +02:00
event.preventDefault();
2016-03-29 20:55:28 +02:00
cEditor.toolbar.toolClicked(event);
cEditor.toolbar.close();
};
2016-02-05 16:29:29 +01:00
},
escapeKeyPressed : function(event){
cEditor.toolbar.close();
event.preventDefault();
},
2016-02-05 16:29:29 +01:00
arrowKeyPressed : function(event){
2016-03-29 20:55:28 +02:00
cEditor.content.workingNodeChanged();
cEditor.toolbar.close();
2016-03-29 20:55:28 +02:00
cEditor.toolbar.move();
},
redactorClicked : function (event) {
2016-03-29 20:55:28 +02:00
cEditor.content.workingNodeChanged();
2016-03-29 20:55:28 +02:00
cEditor.toolbar.move();
2016-03-29 20:55:28 +02:00
cEditor.toolbar.open();
},
/**
* Toolbar button click handler
* @param this - cursor to the button
*/
toolbarButtonClicked : function (event, button) {
cEditor.toolbar.current = button.dataset.type;
cEditor.toolbar.toolClicked(event);
cEditor.toolbar.close();
},
redactorInputEvent : function (event) {
/**
* Clear previous sync-timeout
*/
2016-03-29 20:55:28 +02:00
if (this.redactorSyncTimeout){
clearTimeout(this.redactorSyncTimeout);
}
/**
* Start waiting to input finish and sync redactor
*/
2016-03-29 20:55:28 +02:00
this.redactorSyncTimeout = setTimeout(function() {
cEditor.content.sync();
}, 500);
2016-03-08 21:07:11 +01:00
}
2016-02-05 16:29:29 +01:00
};
cEditor.content = {
2016-03-29 20:55:28 +02:00
currentNode : null,
/**
* @var {int} caretOffset - caret position in a text node.
*/
caretOffset : null,
/**
* @var {int} focusedNodeIndex - we get index of child node from first-level block
*/
focusedNodeIndex: null,
/**
* Synchronizes redactor with original textarea
*/
sync : function () {
cEditor.core.log('syncing...');
/**
* Save redactor content to cEditor.state
*/
cEditor.state.html = cEditor.nodes.redactor.innerHTML;
/**
* Put it to the textarea
*/
cEditor.nodes.textarea.value = cEditor.state.html;
2016-03-29 20:55:28 +02:00
},
/**
* We need to save caret before we change the block,
* so that we could return it to original position in a new tag.
* We save caret offset in a text and index of child node.
*/
saveCaretPosition () {
var selection = window.getSelection();
var previousElement = selection.anchorNode.previousSibling,
nodeIndex = 0;
while (previousElement != null) {
nodeIndex ++;
previousElement = previousElement.previousSibling;
}
this.caretOffset = selection.anchorOffset;
this.focusedNodeIndex = nodeIndex;
},
/**
* Creates Documnt Range and sets caret to the NodeElement.
* @param {Element} NodeElement - Changed Node.
*/
setCaret : function(NodeElement) {
var nodeIndex = this.focusedNodeIndex || 0,
caretOffset = this.caretOffset || 0;
var childs = NodeElement.childNodes,
nodeChild = childs[nodeIndex];
var range = document.createRange(),
selection = window.getSelection();
range.setStart(nodeChild, caretOffset);
range.setEnd(nodeChild, caretOffset);
selection.removeAllRanges();
selection.addRange(range);
},
2016-03-29 20:55:28 +02:00
getNodeFocused : function() {
var selection = window.getSelection(),
focused;
if (selection.anchorNode != null) {
2016-04-27 19:47:02 +02:00
if ( selection.anchorNode.nodeType == cEditor.core.nodeTypes.TAG ) {
focused = selection.anchorNode;
} else {
focused = selection.focusNode.parentElement;
}
2016-03-29 20:55:28 +02:00
}
2016-04-27 19:47:02 +02:00
if ( !cEditor.parser.isFirstLevelBlock(focused) ) {
focused = focused.parentElement;
}
2016-03-29 20:55:28 +02:00
if (focused != cEditor.nodes.redactor){
return focused;
}
return null;
},
/**
* Trigger this event when working node changed
*/
workingNodeChanged : function (setCurrent) {
this.currentNode = setCurrent || this.getNodeFocused();
},
switchBlock : function (targetBlock, newBlockTagname) {
if (!targetBlock || !newBlockTagname) return;
var nodeToReplace;
/**
* First-level nodes replaces as-is,
* otherwise we need to replace parent node
*/
if (cEditor.parser.isFirstLevelBlock(targetBlock)) {
nodeToReplace = targetBlock;
} else {
nodeToReplace = targetBlock.parentNode;
}
/**
* Make new node with original content
*/
var nodeCreated = cEditor.draw.block(newBlockTagname, targetBlock.innerHTML);
2016-06-04 11:33:09 +02:00
/** Mark node as redactor block*/
nodeCreated.classList.add('ce_block');
2016-03-29 20:55:28 +02:00
/**
* If it is a first-level node, replace as-is.
*/
if (cEditor.parser.isFirstLevelBlock(nodeCreated)) {
cEditor.nodes.redactor.replaceChild(nodeCreated, nodeToReplace);
/**
* Set new node as current
*/
2016-03-29 20:55:28 +02:00
cEditor.content.workingNodeChanged(nodeCreated);
/**
* Setting caret
*/
cEditor.content.setCaret(nodeCreated);
2016-03-29 20:55:28 +02:00
return;
}
/**
* If it is not a first-level node, for example LI or IMG
* we need to wrap it in block-tag (<p> or <ul>)
*/
var newNodeWrapperTagname,
newNodeWrapper;
switch (newBlockTagname){
case 'LI' : newNodeWrapperTagname = 'UL'; break;
default : newNodeWrapperTagname = 'P'; break;
}
newNodeWrapper = cEditor.draw.block(newNodeWrapperTagname);
newNodeWrapper.appendChild(nodeCreated);
2016-03-29 20:55:28 +02:00
cEditor.nodes.redactor.replaceChild(newNodeWrapper, nodeToReplace);
/**
* Set new node as current
*/
cEditor.content.workingNodeChanged(nodeCreated);
cEditor.content.setCaret(nodeCreated);
}
}
2016-02-05 16:29:29 +01:00
cEditor.toolbar = {
/**
* Margin between focused node and toolbar
*/
defaultToolbarHeight : 43,
defaultOffset : 10,
2016-03-08 21:07:11 +01:00
opened : false,
2016-02-05 16:29:29 +01:00
current : null,
open : function (){
if (this.opened) {
return;
}
2016-02-05 16:29:29 +01:00
cEditor.nodes.toolbar.classList.add('opened');
this.opened = true;
},
close : function(){
cEditor.nodes.toolbar.classList.remove('opened');
this.opened = false;
this.current = null;
for (button in cEditor.nodes.toolbarButtons){
cEditor.nodes.toolbarButtons[button].classList.remove('selected');
}
},
toggle : function(){
if ( !this.opened ){
this.open();
} else {
this.close();
}
},
leaf : function(){
var currentTool = this.current,
tools = cEditor.settings.tools,
barButtons = cEditor.nodes.toolbarButtons,
nextToolIndex;
if ( !currentTool ) {
for (toolToSelect in barButtons) break;
} else {
nextToolIndex = tools.indexOf(currentTool) + 1;
if ( nextToolIndex == tools.length) nextToolIndex = 0;
toolToSelect = tools[nextToolIndex];
}
for (button in barButtons) barButtons[button].classList.remove('selected')
barButtons[toolToSelect].classList.add('selected');
this.current = toolToSelect;
},
2016-02-05 16:29:29 +01:00
/**
* Transforming selected node type into selected toolbar element type
*/
2016-03-29 20:55:28 +02:00
toolClicked : function(event) {
2016-03-29 20:55:28 +02:00
var workingNode = cEditor.content.currentNode,
2016-03-08 21:07:11 +01:00
newTag;
switch (cEditor.toolbar.current) {
case 'header' : newTag = 'H1'; break;
case 'quote' : newTag = 'BLOCKQUOTE'; break;
case 'code' : newTag = 'CODE'; break;
2016-03-29 20:55:28 +02:00
case 'list' : newTag = 'LI'; break;
};
2016-02-05 16:29:29 +01:00
2016-03-29 20:55:28 +02:00
cEditor.content.switchBlock(workingNode, newTag);
2016-03-08 21:07:11 +01:00
},
/**
* Moving toolbar to the specified node
*/
2016-03-29 20:55:28 +02:00
move : function() {
2016-03-08 21:07:11 +01:00
2016-03-29 20:55:28 +02:00
if (!cEditor.content.currentNode) {
return;
}
2016-02-05 16:29:29 +01:00
var toolbarHeight = cEditor.nodes.toolbar.clientHeight || cEditor.toolbar.defaultToolbarHeight,
newYCoordinate = cEditor.content.currentNode.offsetTop - cEditor.toolbar.defaultOffset - toolbarHeight;
2016-03-29 20:55:28 +02:00
cEditor.nodes.toolbar.style.transform = "translateY(" + newYCoordinate + "px)";
}
};
2016-02-05 09:25:52 +01:00
/**
* Content parsing module
*/
cEditor.parser = {
/**
* Asynchronously parses textarea input string to HTML editor blocks
*/
parseTextareaContent : function () {
var initialContent = cEditor.nodes.textarea.value;
if ( initialContent.trim().length === 0 ) return true;
cEditor.parser
/** Get child nodes async-aware */
.getNodesFromString(initialContent)
/** Then append nodes to the redactor */
.then(cEditor.parser.appendNodesToRedactor)
/** Write log if something goes wrong */
.catch(function(error) {
cEditor.core.log('Error while parsing content: %o', 'warn', error);
})
},
/**
* Parses string to nodeList
* @param string inputString
* @return Primise -> nodeList
*/
getNodesFromString : function (inputString) {
return Promise.resolve().then(function() {
var contentHolder = document.createElement('div');
contentHolder.innerHTML = inputString;
/**
* Returning childNodes will include:
* - Elements (html-tags),
* - Texts (empty-spaces or non-wrapped strings )
* - Comments and other
*/
return contentHolder.childNodes;
});
},
/**
* Appends nodes to the redactor
* @param nodeList nodes - list for nodes to append
*/
appendNodesToRedactor : function(nodes) {
/**
* Sequence of one-by-one nodes appending
* Uses to save blocks order after async-handler
*/
var nodeSequence = Promise.resolve();
for (var index = 0; index < nodes.length ; index++ ) {
/** Add node to sequence at specified index */
cEditor.parser.appendNodeAtIndex(nodeSequence, nodes, index);
}
},
/**
* Append node at specified index
*/
appendNodeAtIndex : function (nodeSequence, nodes, index) {
/** We need to append node to sequence */
nodeSequence
/** first, get node async-aware */
.then(function() {
return cEditor.parser.getNodeAsync(nodes , index);
})
/**
* second, compose editor-block from node
* and append it to redactor
*/
.then(function(node){
var block = cEditor.parser.createBlockByDomNode(node);
if ( cEditor.core.isDomNode(block) ) {
2016-06-04 11:33:09 +02:00
block.contentEditable = "true";
/** Mark node as redactor block*/
block.classList.add('ce_block');
2016-02-05 09:25:52 +01:00
/** Append block to the redactor */
cEditor.nodes.redactor.appendChild(block);
/** Save block to the cEditor.state array */
cEditor.state.blocks.push(block);
};
})
/** Log if something wrong with node */
.catch(function(error) {
cEditor.core.log('Node skipped while parsing because %o', 'warn', error);
});
},
/**
* Asynchronously returns node from nodeList by index
* @return Promise to node
*/
getNodeAsync : function (nodeList, index) {
return Promise.resolve().then(function() {
return nodeList.item(index);
});
},
/**
* Creates editor block by DOM node
*
* First-level blocks (see cEditor.settings.blockTags) saves as-is,
* other wrapps with <p>-tag
*
* @param DOMnode node
* @return First-level node (paragraph)
*/
createBlockByDomNode : function (node) {
/** First level nodes already appears as blocks */
if ( cEditor.parser.isFirstLevelBlock(node) ){
return node;
}
/** Other nodes wraps into parent block (paragraph-tag) */
var parentBlock,
nodeContent = node.textContent.trim(),
isPlainTextNode = node.nodeType != cEditor.core.nodeTypes.TAG;
/** Skip empty textNodes with space-symbols */
if (isPlainTextNode && !nodeContent.length) return null;
/** Make <p> tag */
parentBlock = cEditor.draw.block('P');
if (isPlainTextNode){
parentBlock.textContent = nodeContent.replace(/(\s){2,}/, '$1'); // remove double spaces
} else {
parentBlock.appendChild(node);
}
return parentBlock;
},
/**
* Check DOM node for display style: separated block or child-view
*/
isFirstLevelBlock : function (node) {
return node.nodeType == cEditor.core.nodeTypes.TAG &&
cEditor.settings.blockTags.indexOf(node.tagName) !== -1;
}
};
/**
* Creates HTML elements
*/
cEditor.draw = {
/**
* Base editor wrapper
*/
wrapper : function () {
var wrapper = document.createElement('div');
wrapper.className += 'ce_wrapper';
return wrapper;
},
/**
* Content-editable holder
*/
redactor : function () {
var redactor = document.createElement('div');
redactor.className += 'ce_redactor';
return redactor;
},
/**
* Empty toolbar with toggler
*/
toolbar : function () {
var bar = document.createElement('div');
bar.className += 'ce_toolbar';
/** Toggler button*/
bar.innerHTML = '<span class="toggler">' +
'<i class="plus_btn ce_icon-plus-circled-1"></i>'+
'</span>';
return bar;
},
/**
* Toolbar button
*/
toolbarButton : function (type) {
var button = document.createElement("li");
button.dataset.type = type;
button.innerHTML = '<i class="ce_icon-' + type + '"></i>';
return button;
},
/**
* Redactor block
*/
block : function (tagName, content) {
var node = document.createElement(tagName);
node.innerHTML = content || '';
return node;
}
}