mirror of
https://github.com/fastmail/Squire.git
synced 2024-12-22 23:40:35 -05:00
072a2d9fce
Scrolling depends so much on the integration. Better to allow the cursor position to be fetched, and then the integration can do the scrolling when it wants (for example on each "input" event).
4079 lines
128 KiB
JavaScript
4079 lines
128 KiB
JavaScript
/* Copyright © 2011-2015 by Neil Jenkins. MIT Licensed. */
|
|
|
|
( function ( doc, undefined ) {
|
|
|
|
"use strict";
|
|
|
|
var DOCUMENT_POSITION_PRECEDING = 2; // Node.DOCUMENT_POSITION_PRECEDING
|
|
var ELEMENT_NODE = 1; // Node.ELEMENT_NODE;
|
|
var TEXT_NODE = 3; // Node.TEXT_NODE;
|
|
var DOCUMENT_NODE = 9; // Node.DOCUMENT_NODE;
|
|
var DOCUMENT_FRAGMENT_NODE = 11; // Node.DOCUMENT_FRAGMENT_NODE;
|
|
var SHOW_ELEMENT = 1; // NodeFilter.SHOW_ELEMENT;
|
|
var SHOW_TEXT = 4; // NodeFilter.SHOW_TEXT;
|
|
|
|
var START_TO_START = 0; // Range.START_TO_START
|
|
var START_TO_END = 1; // Range.START_TO_END
|
|
var END_TO_END = 2; // Range.END_TO_END
|
|
var END_TO_START = 3; // Range.END_TO_START
|
|
|
|
var ZWS = '\u200B';
|
|
|
|
var win = doc.defaultView;
|
|
|
|
var ua = navigator.userAgent;
|
|
|
|
var isIOS = /iP(?:ad|hone|od)/.test( ua );
|
|
var isMac = /Mac OS X/.test( ua );
|
|
|
|
var isGecko = /Gecko\//.test( ua );
|
|
var isIElt11 = /Trident\/[456]\./.test( ua );
|
|
var isPresto = !!win.opera;
|
|
var isEdge = /Edge\//.test( ua );
|
|
var isWebKit = !isEdge && /WebKit\//.test( ua );
|
|
|
|
var ctrlKey = isMac ? 'meta-' : 'ctrl-';
|
|
|
|
var useTextFixer = isIElt11 || isPresto;
|
|
var cantFocusEmptyTextNodes = isIElt11 || isWebKit;
|
|
var losesSelectionOnBlur = isIElt11;
|
|
|
|
var canObserveMutations = typeof MutationObserver !== 'undefined';
|
|
|
|
// Use [^ \t\r\n] instead of \S so that nbsp does not count as white-space
|
|
var notWS = /[^ \t\r\n]/;
|
|
|
|
var indexOf = Array.prototype.indexOf;
|
|
|
|
// Polyfill for FF3.5
|
|
if ( !Object.create ) {
|
|
Object.create = function ( proto ) {
|
|
var F = function () {};
|
|
F.prototype = proto;
|
|
return new F();
|
|
};
|
|
}
|
|
|
|
/*
|
|
Native TreeWalker is buggy in IE and Opera:
|
|
* IE9/10 sometimes throw errors when calling TreeWalker#nextNode or
|
|
TreeWalker#previousNode. No way to feature detect this.
|
|
* Some versions of Opera have a bug in TreeWalker#previousNode which makes
|
|
it skip to the wrong node.
|
|
|
|
Rather than risk further bugs, it's easiest just to implement our own
|
|
(subset) of the spec in all browsers.
|
|
*/
|
|
|
|
var typeToBitArray = {
|
|
// ELEMENT_NODE
|
|
1: 1,
|
|
// ATTRIBUTE_NODE
|
|
2: 2,
|
|
// TEXT_NODE
|
|
3: 4,
|
|
// COMMENT_NODE
|
|
8: 128,
|
|
// DOCUMENT_NODE
|
|
9: 256,
|
|
// DOCUMENT_FRAGMENT_NODE
|
|
11: 1024
|
|
};
|
|
|
|
function TreeWalker ( root, nodeType, filter ) {
|
|
this.root = this.currentNode = root;
|
|
this.nodeType = nodeType;
|
|
this.filter = filter;
|
|
}
|
|
|
|
TreeWalker.prototype.nextNode = function () {
|
|
var current = this.currentNode,
|
|
root = this.root,
|
|
nodeType = this.nodeType,
|
|
filter = this.filter,
|
|
node;
|
|
while ( true ) {
|
|
node = current.firstChild;
|
|
while ( !node && current ) {
|
|
if ( current === root ) {
|
|
break;
|
|
}
|
|
node = current.nextSibling;
|
|
if ( !node ) { current = current.parentNode; }
|
|
}
|
|
if ( !node ) {
|
|
return null;
|
|
}
|
|
if ( ( typeToBitArray[ node.nodeType ] & nodeType ) &&
|
|
filter( node ) ) {
|
|
this.currentNode = node;
|
|
return node;
|
|
}
|
|
current = node;
|
|
}
|
|
};
|
|
|
|
TreeWalker.prototype.previousNode = function () {
|
|
var current = this.currentNode,
|
|
root = this.root,
|
|
nodeType = this.nodeType,
|
|
filter = this.filter,
|
|
node;
|
|
while ( true ) {
|
|
if ( current === root ) {
|
|
return null;
|
|
}
|
|
node = current.previousSibling;
|
|
if ( node ) {
|
|
while ( current = node.lastChild ) {
|
|
node = current;
|
|
}
|
|
} else {
|
|
node = current.parentNode;
|
|
}
|
|
if ( !node ) {
|
|
return null;
|
|
}
|
|
if ( ( typeToBitArray[ node.nodeType ] & nodeType ) &&
|
|
filter( node ) ) {
|
|
this.currentNode = node;
|
|
return node;
|
|
}
|
|
current = node;
|
|
}
|
|
};
|
|
|
|
// Previous node in post-order.
|
|
TreeWalker.prototype.previousPONode = function () {
|
|
var current = this.currentNode,
|
|
root = this.root,
|
|
nodeType = this.nodeType,
|
|
filter = this.filter,
|
|
node;
|
|
while ( true ) {
|
|
node = current.lastChild;
|
|
while ( !node && current ) {
|
|
if ( current === root ) {
|
|
break;
|
|
}
|
|
node = current.previousSibling;
|
|
if ( !node ) { current = current.parentNode; }
|
|
}
|
|
if ( !node ) {
|
|
return null;
|
|
}
|
|
if ( ( typeToBitArray[ node.nodeType ] & nodeType ) &&
|
|
filter( node ) ) {
|
|
this.currentNode = node;
|
|
return node;
|
|
}
|
|
current = node;
|
|
}
|
|
};
|
|
|
|
var inlineNodeNames = /^(?:#text|A(?:BBR|CRONYM)?|B(?:R|D[IO])?|C(?:ITE|ODE)|D(?:ATA|EL|FN)|EM|FONT|HR|I(?:MG|NPUT|NS)?|KBD|Q|R(?:P|T|UBY)|S(?:AMP|MALL|PAN|TR(?:IKE|ONG)|U[BP])?|U|VAR|WBR)$/;
|
|
|
|
var leafNodeNames = {
|
|
BR: 1,
|
|
IMG: 1,
|
|
INPUT: 1
|
|
};
|
|
|
|
function every ( nodeList, fn ) {
|
|
var l = nodeList.length;
|
|
while ( l-- ) {
|
|
if ( !fn( nodeList[l] ) ) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ---
|
|
|
|
function isLeaf ( node ) {
|
|
return node.nodeType === ELEMENT_NODE &&
|
|
!!leafNodeNames[ node.nodeName ];
|
|
}
|
|
function isInline ( node ) {
|
|
return inlineNodeNames.test( node.nodeName );
|
|
}
|
|
function isBlock ( node ) {
|
|
var type = node.nodeType;
|
|
return ( type === ELEMENT_NODE || type === DOCUMENT_FRAGMENT_NODE ) &&
|
|
!isInline( node ) && every( node.childNodes, isInline );
|
|
}
|
|
function isContainer ( node ) {
|
|
var type = node.nodeType;
|
|
return ( type === ELEMENT_NODE || type === DOCUMENT_FRAGMENT_NODE ) &&
|
|
!isInline( node ) && !isBlock( node );
|
|
}
|
|
|
|
function getBlockWalker ( node, root ) {
|
|
var walker = new TreeWalker( root, SHOW_ELEMENT, isBlock, false );
|
|
walker.currentNode = node;
|
|
return walker;
|
|
}
|
|
function getPreviousBlock ( node, root ) {
|
|
node = getBlockWalker( node, root ).previousNode();
|
|
return node !== root ? node : null;
|
|
}
|
|
function getNextBlock ( node, root ) {
|
|
node = getBlockWalker( node, root ).nextNode();
|
|
return node !== root ? node : null;
|
|
}
|
|
|
|
function areAlike ( node, node2 ) {
|
|
return !isLeaf( node ) && (
|
|
node.nodeType === node2.nodeType &&
|
|
node.nodeName === node2.nodeName &&
|
|
node.className === node2.className &&
|
|
( ( !node.style && !node2.style ) ||
|
|
node.style.cssText === node2.style.cssText )
|
|
);
|
|
}
|
|
function hasTagAttributes ( node, tag, attributes ) {
|
|
if ( node.nodeName !== tag ) {
|
|
return false;
|
|
}
|
|
for ( var attr in attributes ) {
|
|
if ( node.getAttribute( attr ) !== attributes[ attr ] ) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
function getNearest ( node, root, tag, attributes ) {
|
|
while ( node && node !== root ) {
|
|
if ( hasTagAttributes( node, tag, attributes ) ) {
|
|
return node;
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
return null;
|
|
}
|
|
function isOrContains ( parent, node ) {
|
|
while ( node ) {
|
|
if ( node === parent ) {
|
|
return true;
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getPath ( node, root ) {
|
|
var parent = node.parentNode,
|
|
path, id, className, classNames, dir;
|
|
if ( node === root ) {
|
|
path = '';
|
|
} else {
|
|
path = getPath( parent, root );
|
|
if ( node.nodeType === ELEMENT_NODE ) {
|
|
path += ( path ? '>' : '' ) + node.nodeName;
|
|
if ( id = node.id ) {
|
|
path += '#' + id;
|
|
}
|
|
if ( className = node.className.trim() ) {
|
|
classNames = className.split( /\s\s*/ );
|
|
classNames.sort();
|
|
path += '.';
|
|
path += classNames.join( '.' );
|
|
}
|
|
if ( dir = node.dir ) {
|
|
path += '[dir=' + dir + ']';
|
|
}
|
|
}
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function getLength ( node ) {
|
|
var nodeType = node.nodeType;
|
|
return nodeType === ELEMENT_NODE ?
|
|
node.childNodes.length : node.length || 0;
|
|
}
|
|
|
|
function detach ( node ) {
|
|
var parent = node.parentNode;
|
|
if ( parent ) {
|
|
parent.removeChild( node );
|
|
}
|
|
return node;
|
|
}
|
|
function replaceWith ( node, node2 ) {
|
|
var parent = node.parentNode;
|
|
if ( parent ) {
|
|
parent.replaceChild( node2, node );
|
|
}
|
|
}
|
|
function empty ( node ) {
|
|
var frag = node.ownerDocument.createDocumentFragment(),
|
|
childNodes = node.childNodes,
|
|
l = childNodes ? childNodes.length : 0;
|
|
while ( l-- ) {
|
|
frag.appendChild( node.firstChild );
|
|
}
|
|
return frag;
|
|
}
|
|
|
|
function createElement ( doc, tag, props, children ) {
|
|
var el = doc.createElement( tag ),
|
|
attr, value, i, l;
|
|
if ( props instanceof Array ) {
|
|
children = props;
|
|
props = null;
|
|
}
|
|
if ( props ) {
|
|
for ( attr in props ) {
|
|
value = props[ attr ];
|
|
if ( value !== undefined ) {
|
|
el.setAttribute( attr, props[ attr ] );
|
|
}
|
|
}
|
|
}
|
|
if ( children ) {
|
|
for ( i = 0, l = children.length; i < l; i += 1 ) {
|
|
el.appendChild( children[i] );
|
|
}
|
|
}
|
|
return el;
|
|
}
|
|
|
|
function fixCursor ( node, root ) {
|
|
// In Webkit and Gecko, block level elements are collapsed and
|
|
// unfocussable if they have no content. To remedy this, a <BR> must be
|
|
// inserted. In Opera and IE, we just need a textnode in order for the
|
|
// cursor to appear.
|
|
var doc = node.ownerDocument,
|
|
originalNode = node,
|
|
fixer, child;
|
|
|
|
if ( node === root ) {
|
|
if ( !( child = node.firstChild ) || child.nodeName === 'BR' ) {
|
|
fixer = getSquireInstance( doc ).createDefaultBlock();
|
|
if ( child ) {
|
|
node.replaceChild( fixer, child );
|
|
}
|
|
else {
|
|
node.appendChild( fixer );
|
|
}
|
|
node = fixer;
|
|
fixer = null;
|
|
}
|
|
}
|
|
|
|
if ( isInline( node ) ) {
|
|
child = node.firstChild;
|
|
while ( cantFocusEmptyTextNodes && child &&
|
|
child.nodeType === TEXT_NODE && !child.data ) {
|
|
node.removeChild( child );
|
|
child = node.firstChild;
|
|
}
|
|
if ( !child ) {
|
|
if ( cantFocusEmptyTextNodes ) {
|
|
fixer = doc.createTextNode( ZWS );
|
|
getSquireInstance( doc )._didAddZWS();
|
|
} else {
|
|
fixer = doc.createTextNode( '' );
|
|
}
|
|
}
|
|
} else {
|
|
if ( useTextFixer ) {
|
|
while ( node.nodeType !== TEXT_NODE && !isLeaf( node ) ) {
|
|
child = node.firstChild;
|
|
if ( !child ) {
|
|
fixer = doc.createTextNode( '' );
|
|
break;
|
|
}
|
|
node = child;
|
|
}
|
|
if ( node.nodeType === TEXT_NODE ) {
|
|
// Opera will collapse the block element if it contains
|
|
// just spaces (but not if it contains no data at all).
|
|
if ( /^ +$/.test( node.data ) ) {
|
|
node.data = '';
|
|
}
|
|
} else if ( isLeaf( node ) ) {
|
|
node.parentNode.insertBefore( doc.createTextNode( '' ), node );
|
|
}
|
|
}
|
|
else if ( !node.querySelector( 'BR' ) ) {
|
|
fixer = createElement( doc, 'BR' );
|
|
while ( ( child = node.lastElementChild ) && !isInline( child ) ) {
|
|
node = child;
|
|
}
|
|
}
|
|
}
|
|
if ( fixer ) {
|
|
node.appendChild( fixer );
|
|
}
|
|
|
|
return originalNode;
|
|
}
|
|
|
|
// Recursively examine container nodes and wrap any inline children.
|
|
function fixContainer ( container, root ) {
|
|
var children = container.childNodes,
|
|
doc = container.ownerDocument,
|
|
wrapper = null,
|
|
i, l, child, isBR,
|
|
config = getSquireInstance( doc )._config;
|
|
|
|
for ( i = 0, l = children.length; i < l; i += 1 ) {
|
|
child = children[i];
|
|
isBR = child.nodeName === 'BR';
|
|
if ( !isBR && isInline( child ) ) {
|
|
if ( !wrapper ) {
|
|
wrapper = createElement( doc,
|
|
config.blockTag, config.blockAttributes );
|
|
}
|
|
wrapper.appendChild( child );
|
|
i -= 1;
|
|
l -= 1;
|
|
} else if ( isBR || wrapper ) {
|
|
if ( !wrapper ) {
|
|
wrapper = createElement( doc,
|
|
config.blockTag, config.blockAttributes );
|
|
}
|
|
fixCursor( wrapper, root );
|
|
if ( isBR ) {
|
|
container.replaceChild( wrapper, child );
|
|
} else {
|
|
container.insertBefore( wrapper, child );
|
|
i += 1;
|
|
l += 1;
|
|
}
|
|
wrapper = null;
|
|
}
|
|
if ( isContainer( child ) ) {
|
|
fixContainer( child, root );
|
|
}
|
|
}
|
|
if ( wrapper ) {
|
|
container.appendChild( fixCursor( wrapper, root ) );
|
|
}
|
|
return container;
|
|
}
|
|
|
|
function split ( node, offset, stopNode, root ) {
|
|
var nodeType = node.nodeType,
|
|
parent, clone, next;
|
|
if ( nodeType === TEXT_NODE && node !== stopNode ) {
|
|
return split(
|
|
node.parentNode, node.splitText( offset ), stopNode, root );
|
|
}
|
|
if ( nodeType === ELEMENT_NODE ) {
|
|
if ( typeof( offset ) === 'number' ) {
|
|
offset = offset < node.childNodes.length ?
|
|
node.childNodes[ offset ] : null;
|
|
}
|
|
if ( node === stopNode ) {
|
|
return offset;
|
|
}
|
|
|
|
// Clone node without children
|
|
parent = node.parentNode;
|
|
clone = node.cloneNode( false );
|
|
|
|
// Add right-hand siblings to the clone
|
|
while ( offset ) {
|
|
next = offset.nextSibling;
|
|
clone.appendChild( offset );
|
|
offset = next;
|
|
}
|
|
|
|
// Maintain li numbering if inside a quote.
|
|
if ( node.nodeName === 'OL' &&
|
|
getNearest( node, root, 'BLOCKQUOTE' ) ) {
|
|
clone.start = ( +node.start || 1 ) + node.childNodes.length - 1;
|
|
}
|
|
|
|
// DO NOT NORMALISE. This may undo the fixCursor() call
|
|
// of a node lower down the tree!
|
|
|
|
// We need something in the element in order for the cursor to appear.
|
|
fixCursor( node, root );
|
|
fixCursor( clone, root );
|
|
|
|
// Inject clone after original node
|
|
if ( next = node.nextSibling ) {
|
|
parent.insertBefore( clone, next );
|
|
} else {
|
|
parent.appendChild( clone );
|
|
}
|
|
|
|
// Keep on splitting up the tree
|
|
return split( parent, clone, stopNode, root );
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
function mergeInlines ( node, range ) {
|
|
if ( node.nodeType !== ELEMENT_NODE ) {
|
|
return;
|
|
}
|
|
var children = node.childNodes,
|
|
l = children.length,
|
|
frags = [],
|
|
child, prev, len;
|
|
while ( l-- ) {
|
|
child = children[l];
|
|
prev = l && children[ l - 1 ];
|
|
if ( l && isInline( child ) && areAlike( child, prev ) &&
|
|
!leafNodeNames[ child.nodeName ] ) {
|
|
if ( range.startContainer === child ) {
|
|
range.startContainer = prev;
|
|
range.startOffset += getLength( prev );
|
|
}
|
|
if ( range.endContainer === child ) {
|
|
range.endContainer = prev;
|
|
range.endOffset += getLength( prev );
|
|
}
|
|
if ( range.startContainer === node ) {
|
|
if ( range.startOffset > l ) {
|
|
range.startOffset -= 1;
|
|
}
|
|
else if ( range.startOffset === l ) {
|
|
range.startContainer = prev;
|
|
range.startOffset = getLength( prev );
|
|
}
|
|
}
|
|
if ( range.endContainer === node ) {
|
|
if ( range.endOffset > l ) {
|
|
range.endOffset -= 1;
|
|
}
|
|
else if ( range.endOffset === l ) {
|
|
range.endContainer = prev;
|
|
range.endOffset = getLength( prev );
|
|
}
|
|
}
|
|
detach( child );
|
|
if ( child.nodeType === TEXT_NODE ) {
|
|
prev.appendData( child.data );
|
|
}
|
|
else {
|
|
frags.push( empty( child ) );
|
|
}
|
|
}
|
|
else if ( child.nodeType === ELEMENT_NODE ) {
|
|
len = frags.length;
|
|
while ( len-- ) {
|
|
child.appendChild( frags.pop() );
|
|
}
|
|
mergeInlines( child, range );
|
|
}
|
|
}
|
|
}
|
|
|
|
function mergeWithBlock ( block, next, range ) {
|
|
var container = next,
|
|
last, offset, _range;
|
|
while ( container.parentNode.childNodes.length === 1 ) {
|
|
container = container.parentNode;
|
|
}
|
|
detach( container );
|
|
|
|
offset = block.childNodes.length;
|
|
|
|
// Remove extra <BR> fixer if present.
|
|
last = block.lastChild;
|
|
if ( last && last.nodeName === 'BR' ) {
|
|
block.removeChild( last );
|
|
offset -= 1;
|
|
}
|
|
|
|
_range = {
|
|
startContainer: block,
|
|
startOffset: offset,
|
|
endContainer: block,
|
|
endOffset: offset
|
|
};
|
|
|
|
block.appendChild( empty( next ) );
|
|
mergeInlines( block, _range );
|
|
|
|
range.setStart( _range.startContainer, _range.startOffset );
|
|
range.collapse( true );
|
|
|
|
// Opera inserts a BR if you delete the last piece of text
|
|
// in a block-level element. Unfortunately, it then gets
|
|
// confused when setting the selection subsequently and
|
|
// refuses to accept the range that finishes just before the
|
|
// BR. Removing the BR fixes the bug.
|
|
// Steps to reproduce bug: Type "a-b-c" (where - is return)
|
|
// then backspace twice. The cursor goes to the top instead
|
|
// of after "b".
|
|
if ( isPresto && ( last = block.lastChild ) && last.nodeName === 'BR' ) {
|
|
block.removeChild( last );
|
|
}
|
|
}
|
|
|
|
function mergeContainers ( node, root ) {
|
|
var prev = node.previousSibling,
|
|
first = node.firstChild,
|
|
doc = node.ownerDocument,
|
|
isListItem = ( node.nodeName === 'LI' ),
|
|
needsFix, block;
|
|
|
|
// Do not merge LIs, unless it only contains a UL
|
|
if ( isListItem && ( !first || !/^[OU]L$/.test( first.nodeName ) ) ) {
|
|
return;
|
|
}
|
|
|
|
if ( prev && areAlike( prev, node ) ) {
|
|
if ( !isContainer( prev ) ) {
|
|
if ( isListItem ) {
|
|
block = createElement( doc, 'DIV' );
|
|
block.appendChild( empty( prev ) );
|
|
prev.appendChild( block );
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
detach( node );
|
|
needsFix = !isContainer( node );
|
|
prev.appendChild( empty( node ) );
|
|
if ( needsFix ) {
|
|
fixContainer( prev, root );
|
|
}
|
|
if ( first ) {
|
|
mergeContainers( first, root );
|
|
}
|
|
} else if ( isListItem ) {
|
|
prev = createElement( doc, 'DIV' );
|
|
node.insertBefore( prev, first );
|
|
fixCursor( prev, root );
|
|
}
|
|
}
|
|
|
|
var getNodeBefore = function ( node, offset ) {
|
|
var children = node.childNodes;
|
|
while ( offset && node.nodeType === ELEMENT_NODE ) {
|
|
node = children[ offset - 1 ];
|
|
children = node.childNodes;
|
|
offset = children.length;
|
|
}
|
|
return node;
|
|
};
|
|
|
|
var getNodeAfter = function ( node, offset ) {
|
|
if ( node.nodeType === ELEMENT_NODE ) {
|
|
var children = node.childNodes;
|
|
if ( offset < children.length ) {
|
|
node = children[ offset ];
|
|
} else {
|
|
while ( node && !node.nextSibling ) {
|
|
node = node.parentNode;
|
|
}
|
|
if ( node ) { node = node.nextSibling; }
|
|
}
|
|
}
|
|
return node;
|
|
};
|
|
|
|
// ---
|
|
|
|
var insertNodeInRange = function ( range, node ) {
|
|
// Insert at start.
|
|
var startContainer = range.startContainer,
|
|
startOffset = range.startOffset,
|
|
endContainer = range.endContainer,
|
|
endOffset = range.endOffset,
|
|
parent, children, childCount, afterSplit;
|
|
|
|
// If part way through a text node, split it.
|
|
if ( startContainer.nodeType === TEXT_NODE ) {
|
|
parent = startContainer.parentNode;
|
|
children = parent.childNodes;
|
|
if ( startOffset === startContainer.length ) {
|
|
startOffset = indexOf.call( children, startContainer ) + 1;
|
|
if ( range.collapsed ) {
|
|
endContainer = parent;
|
|
endOffset = startOffset;
|
|
}
|
|
} else {
|
|
if ( startOffset ) {
|
|
afterSplit = startContainer.splitText( startOffset );
|
|
if ( endContainer === startContainer ) {
|
|
endOffset -= startOffset;
|
|
endContainer = afterSplit;
|
|
}
|
|
else if ( endContainer === parent ) {
|
|
endOffset += 1;
|
|
}
|
|
startContainer = afterSplit;
|
|
}
|
|
startOffset = indexOf.call( children, startContainer );
|
|
}
|
|
startContainer = parent;
|
|
} else {
|
|
children = startContainer.childNodes;
|
|
}
|
|
|
|
childCount = children.length;
|
|
|
|
if ( startOffset === childCount ) {
|
|
startContainer.appendChild( node );
|
|
} else {
|
|
startContainer.insertBefore( node, children[ startOffset ] );
|
|
}
|
|
|
|
if ( startContainer === endContainer ) {
|
|
endOffset += children.length - childCount;
|
|
}
|
|
|
|
range.setStart( startContainer, startOffset );
|
|
range.setEnd( endContainer, endOffset );
|
|
};
|
|
|
|
var extractContentsOfRange = function ( range, common, root ) {
|
|
var startContainer = range.startContainer,
|
|
startOffset = range.startOffset,
|
|
endContainer = range.endContainer,
|
|
endOffset = range.endOffset;
|
|
|
|
if ( !common ) {
|
|
common = range.commonAncestorContainer;
|
|
}
|
|
|
|
if ( common.nodeType === TEXT_NODE ) {
|
|
common = common.parentNode;
|
|
}
|
|
|
|
var endNode = split( endContainer, endOffset, common, root ),
|
|
startNode = split( startContainer, startOffset, common, root ),
|
|
frag = common.ownerDocument.createDocumentFragment(),
|
|
next, before, after;
|
|
|
|
// End node will be null if at end of child nodes list.
|
|
while ( startNode !== endNode ) {
|
|
next = startNode.nextSibling;
|
|
frag.appendChild( startNode );
|
|
startNode = next;
|
|
}
|
|
|
|
startContainer = common;
|
|
startOffset = endNode ?
|
|
indexOf.call( common.childNodes, endNode ) :
|
|
common.childNodes.length;
|
|
|
|
// Merge text nodes if adjacent. IE10 in particular will not focus
|
|
// between two text nodes
|
|
after = common.childNodes[ startOffset ];
|
|
before = after && after.previousSibling;
|
|
if ( before &&
|
|
before.nodeType === TEXT_NODE &&
|
|
after.nodeType === TEXT_NODE ) {
|
|
startContainer = before;
|
|
startOffset = before.length;
|
|
before.appendData( after.data );
|
|
detach( after );
|
|
}
|
|
|
|
range.setStart( startContainer, startOffset );
|
|
range.collapse( true );
|
|
|
|
fixCursor( common, root );
|
|
|
|
return frag;
|
|
};
|
|
|
|
var deleteContentsOfRange = function ( range, root ) {
|
|
// Move boundaries up as much as possible to reduce need to split.
|
|
// But we need to check whether we've moved the boundary outside of a
|
|
// block. If so, the entire block will be removed, so we shouldn't merge
|
|
// later.
|
|
moveRangeBoundariesUpTree( range );
|
|
|
|
var startBlock = range.startContainer,
|
|
endBlock = range.endContainer,
|
|
needsMerge = ( isInline( startBlock ) || isBlock( startBlock ) ) &&
|
|
( isInline( endBlock ) || isBlock( endBlock ) );
|
|
|
|
// Remove selected range
|
|
var frag = extractContentsOfRange( range, null, root );
|
|
|
|
// Move boundaries back down tree so that they are inside the blocks.
|
|
// If we don't do this, the range may be collapsed to a point between
|
|
// two blocks, so get(Start|End)BlockOfRange will return null.
|
|
moveRangeBoundariesDownTree( range );
|
|
|
|
// If we split into two different blocks, merge the blocks.
|
|
if ( needsMerge ) {
|
|
startBlock = getStartBlockOfRange( range, root );
|
|
endBlock = getEndBlockOfRange( range, root );
|
|
if ( startBlock && endBlock && startBlock !== endBlock ) {
|
|
mergeWithBlock( startBlock, endBlock, range );
|
|
}
|
|
}
|
|
|
|
// Ensure block has necessary children
|
|
if ( startBlock ) {
|
|
fixCursor( startBlock, root );
|
|
}
|
|
|
|
// Ensure root has a block-level element in it.
|
|
var child = root.firstChild;
|
|
if ( !child || child.nodeName === 'BR' ) {
|
|
fixCursor( root, root );
|
|
range.selectNodeContents( root.firstChild );
|
|
} else {
|
|
range.collapse( false );
|
|
}
|
|
return frag;
|
|
};
|
|
|
|
// ---
|
|
|
|
var insertTreeFragmentIntoRange = function ( range, frag, root ) {
|
|
// Check if it's all inline content
|
|
var allInline = true,
|
|
children = frag.childNodes,
|
|
l = children.length;
|
|
while ( l-- ) {
|
|
if ( !isInline( children[l] ) ) {
|
|
allInline = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Delete any selected content
|
|
if ( !range.collapsed ) {
|
|
deleteContentsOfRange( range, root );
|
|
}
|
|
|
|
// Move range down into text nodes
|
|
moveRangeBoundariesDownTree( range );
|
|
|
|
if ( allInline ) {
|
|
// If inline, just insert at the current position.
|
|
insertNodeInRange( range, frag );
|
|
range.collapse( false );
|
|
} else {
|
|
// Otherwise...
|
|
// 1. Split up to blockquote (if a parent) or root
|
|
var splitPoint = range.startContainer,
|
|
nodeAfterSplit = split(
|
|
splitPoint,
|
|
range.startOffset,
|
|
getNearest( splitPoint.parentNode, root, 'BLOCKQUOTE' ) || root,
|
|
root
|
|
),
|
|
nodeBeforeSplit = nodeAfterSplit.previousSibling,
|
|
startContainer = nodeBeforeSplit,
|
|
startOffset = startContainer.childNodes.length,
|
|
endContainer = nodeAfterSplit,
|
|
endOffset = 0,
|
|
parent = nodeAfterSplit.parentNode,
|
|
child, node, prev, next, startAnchor;
|
|
|
|
// 2. Move down into edge either side of split and insert any inline
|
|
// nodes at the beginning/end of the fragment
|
|
while ( ( child = startContainer.lastChild ) &&
|
|
child.nodeType === ELEMENT_NODE ) {
|
|
if ( child.nodeName === 'BR' ) {
|
|
startOffset -= 1;
|
|
break;
|
|
}
|
|
startContainer = child;
|
|
startOffset = startContainer.childNodes.length;
|
|
}
|
|
while ( ( child = endContainer.firstChild ) &&
|
|
child.nodeType === ELEMENT_NODE &&
|
|
child.nodeName !== 'BR' ) {
|
|
endContainer = child;
|
|
}
|
|
startAnchor = startContainer.childNodes[ startOffset ] || null;
|
|
while ( ( child = frag.firstChild ) && isInline( child ) ) {
|
|
startContainer.insertBefore( child, startAnchor );
|
|
}
|
|
while ( ( child = frag.lastChild ) && isInline( child ) ) {
|
|
endContainer.insertBefore( child, endContainer.firstChild );
|
|
endOffset += 1;
|
|
}
|
|
|
|
// 3. Fix cursor then insert block(s) in the fragment
|
|
node = frag;
|
|
while ( node = getNextBlock( node, root ) ) {
|
|
fixCursor( node, root );
|
|
}
|
|
parent.insertBefore( frag, nodeAfterSplit );
|
|
|
|
// 4. Remove empty nodes created either side of split, then
|
|
// merge containers at the edges.
|
|
next = nodeBeforeSplit.nextSibling;
|
|
node = getPreviousBlock( next, root );
|
|
if ( !/\S/.test( node.textContent ) ) {
|
|
do {
|
|
parent = node.parentNode;
|
|
parent.removeChild( node );
|
|
node = parent;
|
|
} while ( parent && !parent.lastChild &&
|
|
parent.nodeName !== 'BODY' );
|
|
}
|
|
if ( !nodeBeforeSplit.parentNode ) {
|
|
nodeBeforeSplit = next.previousSibling;
|
|
}
|
|
if ( !startContainer.parentNode ) {
|
|
startContainer = nodeBeforeSplit || next.parentNode;
|
|
startOffset = nodeBeforeSplit ?
|
|
nodeBeforeSplit.childNodes.length : 0;
|
|
}
|
|
// Merge inserted containers with edges of split
|
|
if ( isContainer( next ) ) {
|
|
mergeContainers( next, root );
|
|
}
|
|
|
|
prev = nodeAfterSplit.previousSibling;
|
|
node = isBlock( nodeAfterSplit ) ?
|
|
nodeAfterSplit : getNextBlock( nodeAfterSplit, root );
|
|
if ( !/\S/.test( node.textContent ) ) {
|
|
do {
|
|
parent = node.parentNode;
|
|
parent.removeChild( node );
|
|
node = parent;
|
|
} while ( parent && !parent.lastChild &&
|
|
parent.nodeName !== 'BODY' );
|
|
}
|
|
if ( !nodeAfterSplit.parentNode ) {
|
|
nodeAfterSplit = prev.nextSibling;
|
|
}
|
|
if ( !endOffset ) {
|
|
endContainer = prev;
|
|
endOffset = prev.childNodes.length;
|
|
}
|
|
// Merge inserted containers with edges of split
|
|
if ( nodeAfterSplit && isContainer( nodeAfterSplit ) ) {
|
|
mergeContainers( nodeAfterSplit, root );
|
|
}
|
|
|
|
range.setStart( startContainer, startOffset );
|
|
range.setEnd( endContainer, endOffset );
|
|
moveRangeBoundariesDownTree( range );
|
|
}
|
|
};
|
|
|
|
// ---
|
|
|
|
var isNodeContainedInRange = function ( range, node, partial ) {
|
|
var nodeRange = node.ownerDocument.createRange();
|
|
|
|
nodeRange.selectNode( node );
|
|
|
|
if ( partial ) {
|
|
// Node must not finish before range starts or start after range
|
|
// finishes.
|
|
var nodeEndBeforeStart = ( range.compareBoundaryPoints(
|
|
END_TO_START, nodeRange ) > -1 ),
|
|
nodeStartAfterEnd = ( range.compareBoundaryPoints(
|
|
START_TO_END, nodeRange ) < 1 );
|
|
return ( !nodeEndBeforeStart && !nodeStartAfterEnd );
|
|
}
|
|
else {
|
|
// Node must start after range starts and finish before range
|
|
// finishes
|
|
var nodeStartAfterStart = ( range.compareBoundaryPoints(
|
|
START_TO_START, nodeRange ) < 1 ),
|
|
nodeEndBeforeEnd = ( range.compareBoundaryPoints(
|
|
END_TO_END, nodeRange ) > -1 );
|
|
return ( nodeStartAfterStart && nodeEndBeforeEnd );
|
|
}
|
|
};
|
|
|
|
var moveRangeBoundariesDownTree = function ( range ) {
|
|
var startContainer = range.startContainer,
|
|
startOffset = range.startOffset,
|
|
endContainer = range.endContainer,
|
|
endOffset = range.endOffset,
|
|
child;
|
|
|
|
while ( startContainer.nodeType !== TEXT_NODE ) {
|
|
child = startContainer.childNodes[ startOffset ];
|
|
if ( !child || isLeaf( child ) ) {
|
|
break;
|
|
}
|
|
startContainer = child;
|
|
startOffset = 0;
|
|
}
|
|
if ( endOffset ) {
|
|
while ( endContainer.nodeType !== TEXT_NODE ) {
|
|
child = endContainer.childNodes[ endOffset - 1 ];
|
|
if ( !child || isLeaf( child ) ) {
|
|
break;
|
|
}
|
|
endContainer = child;
|
|
endOffset = getLength( endContainer );
|
|
}
|
|
} else {
|
|
while ( endContainer.nodeType !== TEXT_NODE ) {
|
|
child = endContainer.firstChild;
|
|
if ( !child || isLeaf( child ) ) {
|
|
break;
|
|
}
|
|
endContainer = child;
|
|
}
|
|
}
|
|
|
|
// If collapsed, this algorithm finds the nearest text node positions
|
|
// *outside* the range rather than inside, but also it flips which is
|
|
// assigned to which.
|
|
if ( range.collapsed ) {
|
|
range.setStart( endContainer, endOffset );
|
|
range.setEnd( startContainer, startOffset );
|
|
} else {
|
|
range.setStart( startContainer, startOffset );
|
|
range.setEnd( endContainer, endOffset );
|
|
}
|
|
};
|
|
|
|
var moveRangeBoundariesUpTree = function ( range, common ) {
|
|
var startContainer = range.startContainer,
|
|
startOffset = range.startOffset,
|
|
endContainer = range.endContainer,
|
|
endOffset = range.endOffset,
|
|
parent;
|
|
|
|
if ( !common ) {
|
|
common = range.commonAncestorContainer;
|
|
}
|
|
|
|
while ( startContainer !== common && !startOffset ) {
|
|
parent = startContainer.parentNode;
|
|
startOffset = indexOf.call( parent.childNodes, startContainer );
|
|
startContainer = parent;
|
|
}
|
|
|
|
while ( endContainer !== common &&
|
|
endOffset === getLength( endContainer ) ) {
|
|
parent = endContainer.parentNode;
|
|
endOffset = indexOf.call( parent.childNodes, endContainer ) + 1;
|
|
endContainer = parent;
|
|
}
|
|
|
|
range.setStart( startContainer, startOffset );
|
|
range.setEnd( endContainer, endOffset );
|
|
};
|
|
|
|
// Returns the first block at least partially contained by the range,
|
|
// or null if no block is contained by the range.
|
|
var getStartBlockOfRange = function ( range, root ) {
|
|
var container = range.startContainer,
|
|
block;
|
|
|
|
// If inline, get the containing block.
|
|
if ( isInline( container ) ) {
|
|
block = getPreviousBlock( container, root );
|
|
} else if ( isBlock( container ) ) {
|
|
block = container;
|
|
} else {
|
|
block = getNodeBefore( container, range.startOffset );
|
|
block = getNextBlock( block, root );
|
|
}
|
|
// Check the block actually intersects the range
|
|
return block && isNodeContainedInRange( range, block, true ) ? block : null;
|
|
};
|
|
|
|
// Returns the last block at least partially contained by the range,
|
|
// or null if no block is contained by the range.
|
|
var getEndBlockOfRange = function ( range, root ) {
|
|
var container = range.endContainer,
|
|
block, child;
|
|
|
|
// If inline, get the containing block.
|
|
if ( isInline( container ) ) {
|
|
block = getPreviousBlock( container, root );
|
|
} else if ( isBlock( container ) ) {
|
|
block = container;
|
|
} else {
|
|
block = getNodeAfter( container, range.endOffset );
|
|
if ( !block ) {
|
|
block = root;
|
|
while ( child = block.lastChild ) {
|
|
block = child;
|
|
}
|
|
}
|
|
block = getPreviousBlock( block, root );
|
|
}
|
|
// Check the block actually intersects the range
|
|
return block && isNodeContainedInRange( range, block, true ) ? block : null;
|
|
};
|
|
|
|
var contentWalker = new TreeWalker( null,
|
|
SHOW_TEXT|SHOW_ELEMENT,
|
|
function ( node ) {
|
|
return node.nodeType === TEXT_NODE ?
|
|
notWS.test( node.data ) :
|
|
node.nodeName === 'IMG';
|
|
}
|
|
);
|
|
|
|
var rangeDoesStartAtBlockBoundary = function ( range, root ) {
|
|
var startContainer = range.startContainer,
|
|
startOffset = range.startOffset;
|
|
|
|
// If in the middle or end of a text node, we're not at the boundary.
|
|
contentWalker.root = null;
|
|
if ( startContainer.nodeType === TEXT_NODE ) {
|
|
if ( startOffset ) {
|
|
return false;
|
|
}
|
|
contentWalker.currentNode = startContainer;
|
|
} else {
|
|
contentWalker.currentNode = getNodeAfter( startContainer, startOffset );
|
|
}
|
|
|
|
// Otherwise, look for any previous content in the same block.
|
|
contentWalker.root = getStartBlockOfRange( range, root );
|
|
|
|
return !contentWalker.previousNode();
|
|
};
|
|
|
|
var rangeDoesEndAtBlockBoundary = function ( range, root ) {
|
|
var endContainer = range.endContainer,
|
|
endOffset = range.endOffset,
|
|
length;
|
|
|
|
// If in a text node with content, and not at the end, we're not
|
|
// at the boundary
|
|
contentWalker.root = null;
|
|
if ( endContainer.nodeType === TEXT_NODE ) {
|
|
length = endContainer.data.length;
|
|
if ( length && endOffset < length ) {
|
|
return false;
|
|
}
|
|
contentWalker.currentNode = endContainer;
|
|
} else {
|
|
contentWalker.currentNode = getNodeBefore( endContainer, endOffset );
|
|
}
|
|
|
|
// Otherwise, look for any further content in the same block.
|
|
contentWalker.root = getEndBlockOfRange( range, root );
|
|
|
|
return !contentWalker.nextNode();
|
|
};
|
|
|
|
var expandRangeToBlockBoundaries = function ( range, root ) {
|
|
var start = getStartBlockOfRange( range, root ),
|
|
end = getEndBlockOfRange( range, root ),
|
|
parent;
|
|
|
|
if ( start && end ) {
|
|
parent = start.parentNode;
|
|
range.setStart( parent, indexOf.call( parent.childNodes, start ) );
|
|
parent = end.parentNode;
|
|
range.setEnd( parent, indexOf.call( parent.childNodes, end ) + 1 );
|
|
}
|
|
};
|
|
|
|
var keys = {
|
|
8: 'backspace',
|
|
9: 'tab',
|
|
13: 'enter',
|
|
32: 'space',
|
|
33: 'pageup',
|
|
34: 'pagedown',
|
|
37: 'left',
|
|
39: 'right',
|
|
46: 'delete',
|
|
219: '[',
|
|
221: ']'
|
|
};
|
|
|
|
// Ref: http://unixpapa.com/js/key.html
|
|
var onKey = function ( event ) {
|
|
var code = event.keyCode,
|
|
key = keys[ code ],
|
|
modifiers = '',
|
|
range = this.getSelection();
|
|
|
|
if ( event.defaultPrevented ) {
|
|
return;
|
|
}
|
|
|
|
if ( !key ) {
|
|
key = String.fromCharCode( code ).toLowerCase();
|
|
// Only reliable for letters and numbers
|
|
if ( !/^[A-Za-z0-9]$/.test( key ) ) {
|
|
key = '';
|
|
}
|
|
}
|
|
|
|
// On keypress, delete and '.' both have event.keyCode 46
|
|
// Must check event.which to differentiate.
|
|
if ( isPresto && event.which === 46 ) {
|
|
key = '.';
|
|
}
|
|
|
|
// Function keys
|
|
if ( 111 < code && code < 124 ) {
|
|
key = 'f' + ( code - 111 );
|
|
}
|
|
|
|
// We need to apply the backspace/delete handlers regardless of
|
|
// control key modifiers.
|
|
if ( key !== 'backspace' && key !== 'delete' ) {
|
|
if ( event.altKey ) { modifiers += 'alt-'; }
|
|
if ( event.ctrlKey ) { modifiers += 'ctrl-'; }
|
|
if ( event.metaKey ) { modifiers += 'meta-'; }
|
|
}
|
|
// However, on Windows, shift-delete is apparently "cut" (WTF right?), so
|
|
// we want to let the browser handle shift-delete.
|
|
if ( event.shiftKey ) { modifiers += 'shift-'; }
|
|
|
|
key = modifiers + key;
|
|
|
|
if ( this._keyHandlers[ key ] ) {
|
|
this._keyHandlers[ key ]( this, event, range );
|
|
} else if ( key.length === 1 && !range.collapsed ) {
|
|
// Record undo checkpoint.
|
|
this.saveUndoState( range );
|
|
// Delete the selection
|
|
deleteContentsOfRange( range, this._root );
|
|
this._ensureBottomLine();
|
|
this.setSelection( range );
|
|
this._updatePath( range, true );
|
|
}
|
|
};
|
|
|
|
var mapKeyTo = function ( method ) {
|
|
return function ( self, event ) {
|
|
event.preventDefault();
|
|
self[ method ]();
|
|
};
|
|
};
|
|
|
|
var mapKeyToFormat = function ( tag, remove ) {
|
|
remove = remove || null;
|
|
return function ( self, event ) {
|
|
event.preventDefault();
|
|
var range = self.getSelection();
|
|
if ( self.hasFormat( tag, null, range ) ) {
|
|
self.changeFormat( null, { tag: tag }, range );
|
|
} else {
|
|
self.changeFormat( { tag: tag }, remove, range );
|
|
}
|
|
};
|
|
};
|
|
|
|
// If you delete the content inside a span with a font styling, Webkit will
|
|
// replace it with a <font> tag (!). If you delete all the text inside a
|
|
// link in Opera, it won't delete the link. Let's make things consistent. If
|
|
// you delete all text inside an inline tag, remove the inline tag.
|
|
var afterDelete = function ( self, range ) {
|
|
try {
|
|
if ( !range ) { range = self.getSelection(); }
|
|
var node = range.startContainer,
|
|
parent;
|
|
// Climb the tree from the focus point while we are inside an empty
|
|
// inline element
|
|
if ( node.nodeType === TEXT_NODE ) {
|
|
node = node.parentNode;
|
|
}
|
|
parent = node;
|
|
while ( isInline( parent ) &&
|
|
( !parent.textContent || parent.textContent === ZWS ) ) {
|
|
node = parent;
|
|
parent = node.parentNode;
|
|
}
|
|
// If focussed in empty inline element
|
|
if ( node !== parent ) {
|
|
// Move focus to just before empty inline(s)
|
|
range.setStart( parent,
|
|
indexOf.call( parent.childNodes, node ) );
|
|
range.collapse( true );
|
|
// Remove empty inline(s)
|
|
parent.removeChild( node );
|
|
// Fix cursor in block
|
|
if ( !isBlock( parent ) ) {
|
|
parent = getPreviousBlock( parent, self._root );
|
|
}
|
|
fixCursor( parent, self._root );
|
|
// Move cursor into text node
|
|
moveRangeBoundariesDownTree( range );
|
|
}
|
|
// If you delete the last character in the sole <div> in Chrome,
|
|
// it removes the div and replaces it with just a <br> inside the
|
|
// root. Detach the <br>; the _ensureBottomLine call will insert a new
|
|
// block.
|
|
if ( node === self._root &&
|
|
( node = node.firstChild ) && node.nodeName === 'BR' ) {
|
|
detach( node );
|
|
}
|
|
self._ensureBottomLine();
|
|
self.setSelection( range );
|
|
self._updatePath( range, true );
|
|
} catch ( error ) {
|
|
self.didError( error );
|
|
}
|
|
};
|
|
|
|
var keyHandlers = {
|
|
enter: function ( self, event, range ) {
|
|
var root = self._root;
|
|
var block, parent, nodeAfterSplit;
|
|
|
|
// We handle this ourselves
|
|
event.preventDefault();
|
|
|
|
// Save undo checkpoint and add any links in the preceding section.
|
|
// Remove any zws so we don't think there's content in an empty
|
|
// block.
|
|
self._recordUndoState( range );
|
|
addLinks( range.startContainer );
|
|
self._removeZWS();
|
|
self._getRangeAndRemoveBookmark( range );
|
|
|
|
// Selected text is overwritten, therefore delete the contents
|
|
// to collapse selection.
|
|
if ( !range.collapsed ) {
|
|
deleteContentsOfRange( range, root );
|
|
}
|
|
|
|
block = getStartBlockOfRange( range, root );
|
|
|
|
// If this is a malformed bit of document or in a table;
|
|
// just play it safe and insert a <br>.
|
|
if ( !block || /^T[HD]$/.test( block.nodeName ) ) {
|
|
insertNodeInRange( range, self.createElement( 'BR' ) );
|
|
range.collapse( false );
|
|
self.setSelection( range );
|
|
self._updatePath( range, true );
|
|
return;
|
|
}
|
|
|
|
// If in a list, we'll split the LI instead.
|
|
if ( parent = getNearest( block, root, 'LI' ) ) {
|
|
block = parent;
|
|
}
|
|
|
|
if ( !block.textContent ) {
|
|
// Break list
|
|
if ( getNearest( block, root, 'UL' ) ||
|
|
getNearest( block, root, 'OL' ) ) {
|
|
return self.modifyBlocks( decreaseListLevel, range );
|
|
}
|
|
// Break blockquote
|
|
else if ( getNearest( block, root, 'BLOCKQUOTE' ) ) {
|
|
return self.modifyBlocks( removeBlockQuote, range );
|
|
}
|
|
}
|
|
|
|
// Otherwise, split at cursor point.
|
|
nodeAfterSplit = splitBlock( self, block,
|
|
range.startContainer, range.startOffset );
|
|
|
|
// Clean up any empty inlines if we hit enter at the beginning of the
|
|
// block
|
|
removeZWS( block );
|
|
removeEmptyInlines( block );
|
|
fixCursor( block, root );
|
|
|
|
// Focus cursor
|
|
// If there's a <b>/<i> etc. at the beginning of the split
|
|
// make sure we focus inside it.
|
|
while ( nodeAfterSplit.nodeType === ELEMENT_NODE ) {
|
|
var child = nodeAfterSplit.firstChild,
|
|
next;
|
|
|
|
// Don't continue links over a block break; unlikely to be the
|
|
// desired outcome.
|
|
if ( nodeAfterSplit.nodeName === 'A' &&
|
|
( !nodeAfterSplit.textContent ||
|
|
nodeAfterSplit.textContent === ZWS ) ) {
|
|
child = self._doc.createTextNode( '' );
|
|
replaceWith( nodeAfterSplit, child );
|
|
nodeAfterSplit = child;
|
|
break;
|
|
}
|
|
|
|
while ( child && child.nodeType === TEXT_NODE && !child.data ) {
|
|
next = child.nextSibling;
|
|
if ( !next || next.nodeName === 'BR' ) {
|
|
break;
|
|
}
|
|
detach( child );
|
|
child = next;
|
|
}
|
|
|
|
// 'BR's essentially don't count; they're a browser hack.
|
|
// If you try to select the contents of a 'BR', FF will not let
|
|
// you type anything!
|
|
if ( !child || child.nodeName === 'BR' ||
|
|
( child.nodeType === TEXT_NODE && !isPresto ) ) {
|
|
break;
|
|
}
|
|
nodeAfterSplit = child;
|
|
}
|
|
range = self._createRange( nodeAfterSplit, 0 );
|
|
self.setSelection( range );
|
|
self._updatePath( range, true );
|
|
},
|
|
backspace: function ( self, event, range ) {
|
|
var root = self._root;
|
|
self._removeZWS();
|
|
// Record undo checkpoint.
|
|
self.saveUndoState( range );
|
|
// If not collapsed, delete contents
|
|
if ( !range.collapsed ) {
|
|
event.preventDefault();
|
|
deleteContentsOfRange( range, root );
|
|
afterDelete( self, range );
|
|
}
|
|
// If at beginning of block, merge with previous
|
|
else if ( rangeDoesStartAtBlockBoundary( range, root ) ) {
|
|
event.preventDefault();
|
|
var current = getStartBlockOfRange( range, root );
|
|
var previous;
|
|
if ( !current ) {
|
|
return;
|
|
}
|
|
// In case inline data has somehow got between blocks.
|
|
fixContainer( current.parentNode, root );
|
|
// Now get previous block
|
|
previous = getPreviousBlock( current, root );
|
|
// Must not be at the very beginning of the text area.
|
|
if ( previous ) {
|
|
// If not editable, just delete whole block.
|
|
if ( !previous.isContentEditable ) {
|
|
detach( previous );
|
|
return;
|
|
}
|
|
// Otherwise merge.
|
|
mergeWithBlock( previous, current, range );
|
|
// If deleted line between containers, merge newly adjacent
|
|
// containers.
|
|
current = previous.parentNode;
|
|
while ( current && !current.nextSibling ) {
|
|
current = current.parentNode;
|
|
}
|
|
if ( current && ( current = current.nextSibling ) ) {
|
|
mergeContainers( current, root );
|
|
}
|
|
self.setSelection( range );
|
|
}
|
|
// If at very beginning of text area, allow backspace
|
|
// to break lists/blockquote.
|
|
else if ( current ) {
|
|
// Break list
|
|
if ( getNearest( current, root, 'UL' ) ||
|
|
getNearest( current, root, 'OL' ) ) {
|
|
return self.modifyBlocks( decreaseListLevel, range );
|
|
}
|
|
// Break blockquote
|
|
else if ( getNearest( current, root, 'BLOCKQUOTE' ) ) {
|
|
return self.modifyBlocks( decreaseBlockQuoteLevel, range );
|
|
}
|
|
self.setSelection( range );
|
|
self._updatePath( range, true );
|
|
}
|
|
}
|
|
// Otherwise, leave to browser but check afterwards whether it has
|
|
// left behind an empty inline tag.
|
|
else {
|
|
self.setSelection( range );
|
|
setTimeout( function () { afterDelete( self ); }, 0 );
|
|
}
|
|
},
|
|
'delete': function ( self, event, range ) {
|
|
var root = self._root;
|
|
var current, next, originalRange,
|
|
cursorContainer, cursorOffset, nodeAfterCursor;
|
|
self._removeZWS();
|
|
// Record undo checkpoint.
|
|
self.saveUndoState( range );
|
|
// If not collapsed, delete contents
|
|
if ( !range.collapsed ) {
|
|
event.preventDefault();
|
|
deleteContentsOfRange( range, root );
|
|
afterDelete( self, range );
|
|
}
|
|
// If at end of block, merge next into this block
|
|
else if ( rangeDoesEndAtBlockBoundary( range, root ) ) {
|
|
event.preventDefault();
|
|
current = getStartBlockOfRange( range, root );
|
|
if ( !current ) {
|
|
return;
|
|
}
|
|
// In case inline data has somehow got between blocks.
|
|
fixContainer( current.parentNode, root );
|
|
// Now get next block
|
|
next = getNextBlock( current, root );
|
|
// Must not be at the very end of the text area.
|
|
if ( next ) {
|
|
// If not editable, just delete whole block.
|
|
if ( !next.isContentEditable ) {
|
|
detach( next );
|
|
return;
|
|
}
|
|
// Otherwise merge.
|
|
mergeWithBlock( current, next, range );
|
|
// If deleted line between containers, merge newly adjacent
|
|
// containers.
|
|
next = current.parentNode;
|
|
while ( next && !next.nextSibling ) {
|
|
next = next.parentNode;
|
|
}
|
|
if ( next && ( next = next.nextSibling ) ) {
|
|
mergeContainers( next, root );
|
|
}
|
|
self.setSelection( range );
|
|
self._updatePath( range, true );
|
|
}
|
|
}
|
|
// Otherwise, leave to browser but check afterwards whether it has
|
|
// left behind an empty inline tag.
|
|
else {
|
|
// But first check if the cursor is just before an IMG tag. If so,
|
|
// delete it ourselves, because the browser won't if it is not
|
|
// inline.
|
|
originalRange = range.cloneRange();
|
|
moveRangeBoundariesUpTree( range, self._root );
|
|
cursorContainer = range.endContainer;
|
|
cursorOffset = range.endOffset;
|
|
if ( cursorContainer.nodeType === ELEMENT_NODE ) {
|
|
nodeAfterCursor = cursorContainer.childNodes[ cursorOffset ];
|
|
if ( nodeAfterCursor && nodeAfterCursor.nodeName === 'IMG' ) {
|
|
event.preventDefault();
|
|
detach( nodeAfterCursor );
|
|
moveRangeBoundariesDownTree( range );
|
|
afterDelete( self, range );
|
|
return;
|
|
}
|
|
}
|
|
self.setSelection( originalRange );
|
|
setTimeout( function () { afterDelete( self ); }, 0 );
|
|
}
|
|
},
|
|
tab: function ( self, event, range ) {
|
|
var root = self._root;
|
|
var node, parent;
|
|
self._removeZWS();
|
|
// If no selection and at start of block
|
|
if ( range.collapsed && rangeDoesStartAtBlockBoundary( range, root ) ) {
|
|
node = getStartBlockOfRange( range, root );
|
|
// Iterate through the block's parents
|
|
while ( parent = node.parentNode ) {
|
|
// If we find a UL or OL (so are in a list, node must be an LI)
|
|
if ( parent.nodeName === 'UL' || parent.nodeName === 'OL' ) {
|
|
// AND the LI is not the first in the list
|
|
if ( node.previousSibling ) {
|
|
// Then increase the list level
|
|
event.preventDefault();
|
|
self.modifyBlocks( increaseListLevel, range );
|
|
}
|
|
break;
|
|
}
|
|
node = parent;
|
|
}
|
|
}
|
|
},
|
|
'shift-tab': function ( self, event, range ) {
|
|
var root = self._root;
|
|
var node;
|
|
self._removeZWS();
|
|
// If no selection and at start of block
|
|
if ( range.collapsed && rangeDoesStartAtBlockBoundary( range, root ) ) {
|
|
// Break list
|
|
node = range.startContainer;
|
|
if ( getNearest( node, root, 'UL' ) ||
|
|
getNearest( node, root, 'OL' ) ) {
|
|
event.preventDefault();
|
|
self.modifyBlocks( decreaseListLevel, range );
|
|
}
|
|
}
|
|
},
|
|
space: function ( self, _, range ) {
|
|
var node, parent;
|
|
self._recordUndoState( range );
|
|
addLinks( range.startContainer );
|
|
self._getRangeAndRemoveBookmark( range );
|
|
|
|
// If the cursor is at the end of a link (<a>foo|</a>) then move it
|
|
// outside of the link (<a>foo</a>|) so that the space is not part of
|
|
// the link text.
|
|
node = range.endContainer;
|
|
parent = node.parentNode;
|
|
if ( range.collapsed && parent.nodeName === 'A' &&
|
|
!node.nextSibling && range.endOffset === getLength( node ) ) {
|
|
range.setStartAfter( parent );
|
|
}
|
|
|
|
self.setSelection( range );
|
|
},
|
|
left: function ( self ) {
|
|
self._removeZWS();
|
|
},
|
|
right: function ( self ) {
|
|
self._removeZWS();
|
|
}
|
|
};
|
|
|
|
// Firefox pre v29 incorrectly handles Cmd-left/Cmd-right on Mac:
|
|
// it goes back/forward in history! Override to do the right
|
|
// thing.
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=289384
|
|
if ( isMac && isGecko ) {
|
|
keyHandlers[ 'meta-left' ] = function ( self, event ) {
|
|
event.preventDefault();
|
|
var sel = getWindowSelection( self );
|
|
if ( sel && sel.modify ) {
|
|
sel.modify( 'move', 'backward', 'lineboundary' );
|
|
}
|
|
};
|
|
keyHandlers[ 'meta-right' ] = function ( self, event ) {
|
|
event.preventDefault();
|
|
var sel = getWindowSelection( self );
|
|
if ( sel && sel.modify ) {
|
|
sel.modify( 'move', 'forward', 'lineboundary' );
|
|
}
|
|
};
|
|
}
|
|
|
|
// System standard for page up/down on Mac is to just scroll, not move the
|
|
// cursor. On Linux/Windows, it should move the cursor, but some browsers don't
|
|
// implement this natively. Override to support it.
|
|
if ( !isMac ) {
|
|
keyHandlers.pageup = function ( self ) {
|
|
self.moveCursorToStart();
|
|
};
|
|
keyHandlers.pagedown = function ( self ) {
|
|
self.moveCursorToEnd();
|
|
};
|
|
}
|
|
|
|
keyHandlers[ ctrlKey + 'b' ] = mapKeyToFormat( 'B' );
|
|
keyHandlers[ ctrlKey + 'i' ] = mapKeyToFormat( 'I' );
|
|
keyHandlers[ ctrlKey + 'u' ] = mapKeyToFormat( 'U' );
|
|
keyHandlers[ ctrlKey + 'shift-7' ] = mapKeyToFormat( 'S' );
|
|
keyHandlers[ ctrlKey + 'shift-5' ] = mapKeyToFormat( 'SUB', { tag: 'SUP' } );
|
|
keyHandlers[ ctrlKey + 'shift-6' ] = mapKeyToFormat( 'SUP', { tag: 'SUB' } );
|
|
keyHandlers[ ctrlKey + 'shift-8' ] = mapKeyTo( 'makeUnorderedList' );
|
|
keyHandlers[ ctrlKey + 'shift-9' ] = mapKeyTo( 'makeOrderedList' );
|
|
keyHandlers[ ctrlKey + '[' ] = mapKeyTo( 'decreaseQuoteLevel' );
|
|
keyHandlers[ ctrlKey + ']' ] = mapKeyTo( 'increaseQuoteLevel' );
|
|
keyHandlers[ ctrlKey + 'y' ] = mapKeyTo( 'redo' );
|
|
keyHandlers[ ctrlKey + 'z' ] = mapKeyTo( 'undo' );
|
|
keyHandlers[ ctrlKey + 'shift-z' ] = mapKeyTo( 'redo' );
|
|
|
|
var fontSizes = {
|
|
1: 10,
|
|
2: 13,
|
|
3: 16,
|
|
4: 18,
|
|
5: 24,
|
|
6: 32,
|
|
7: 48
|
|
};
|
|
|
|
var spanToSemantic = {
|
|
backgroundColor: {
|
|
regexp: notWS,
|
|
replace: function ( doc, colour ) {
|
|
return createElement( doc, 'SPAN', {
|
|
'class': 'highlight',
|
|
style: 'background-color:' + colour
|
|
});
|
|
}
|
|
},
|
|
color: {
|
|
regexp: notWS,
|
|
replace: function ( doc, colour ) {
|
|
return createElement( doc, 'SPAN', {
|
|
'class': 'colour',
|
|
style: 'color:' + colour
|
|
});
|
|
}
|
|
},
|
|
fontWeight: {
|
|
regexp: /^bold/i,
|
|
replace: function ( doc ) {
|
|
return createElement( doc, 'B' );
|
|
}
|
|
},
|
|
fontStyle: {
|
|
regexp: /^italic/i,
|
|
replace: function ( doc ) {
|
|
return createElement( doc, 'I' );
|
|
}
|
|
},
|
|
fontFamily: {
|
|
regexp: notWS,
|
|
replace: function ( doc, family ) {
|
|
return createElement( doc, 'SPAN', {
|
|
'class': 'font',
|
|
style: 'font-family:' + family
|
|
});
|
|
}
|
|
},
|
|
fontSize: {
|
|
regexp: notWS,
|
|
replace: function ( doc, size ) {
|
|
return createElement( doc, 'SPAN', {
|
|
'class': 'size',
|
|
style: 'font-size:' + size
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
var replaceWithTag = function ( tag ) {
|
|
return function ( node, parent ) {
|
|
var el = createElement( node.ownerDocument, tag );
|
|
parent.replaceChild( el, node );
|
|
el.appendChild( empty( node ) );
|
|
return el;
|
|
};
|
|
};
|
|
|
|
var stylesRewriters = {
|
|
SPAN: function ( span, parent ) {
|
|
var style = span.style,
|
|
doc = span.ownerDocument,
|
|
attr, converter, css, newTreeBottom, newTreeTop, el;
|
|
|
|
for ( attr in spanToSemantic ) {
|
|
converter = spanToSemantic[ attr ];
|
|
css = style[ attr ];
|
|
if ( css && converter.regexp.test( css ) ) {
|
|
el = converter.replace( doc, css );
|
|
if ( newTreeBottom ) {
|
|
newTreeBottom.appendChild( el );
|
|
}
|
|
newTreeBottom = el;
|
|
if ( !newTreeTop ) {
|
|
newTreeTop = el;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( newTreeTop ) {
|
|
newTreeBottom.appendChild( empty( span ) );
|
|
parent.replaceChild( newTreeTop, span );
|
|
}
|
|
|
|
return newTreeBottom || span;
|
|
},
|
|
STRONG: replaceWithTag( 'B' ),
|
|
EM: replaceWithTag( 'I' ),
|
|
STRIKE: replaceWithTag( 'S' ),
|
|
FONT: function ( node, parent ) {
|
|
var face = node.face,
|
|
size = node.size,
|
|
colour = node.color,
|
|
doc = node.ownerDocument,
|
|
fontSpan, sizeSpan, colourSpan,
|
|
newTreeBottom, newTreeTop;
|
|
if ( face ) {
|
|
fontSpan = createElement( doc, 'SPAN', {
|
|
'class': 'font',
|
|
style: 'font-family:' + face
|
|
});
|
|
newTreeTop = fontSpan;
|
|
newTreeBottom = fontSpan;
|
|
}
|
|
if ( size ) {
|
|
sizeSpan = createElement( doc, 'SPAN', {
|
|
'class': 'size',
|
|
style: 'font-size:' + fontSizes[ size ] + 'px'
|
|
});
|
|
if ( !newTreeTop ) {
|
|
newTreeTop = sizeSpan;
|
|
}
|
|
if ( newTreeBottom ) {
|
|
newTreeBottom.appendChild( sizeSpan );
|
|
}
|
|
newTreeBottom = sizeSpan;
|
|
}
|
|
if ( colour && /^#?([\dA-F]{3}){1,2}$/i.test( colour ) ) {
|
|
if ( colour.charAt( 0 ) !== '#' ) {
|
|
colour = '#' + colour;
|
|
}
|
|
colourSpan = createElement( doc, 'SPAN', {
|
|
'class': 'colour',
|
|
style: 'color:' + colour
|
|
});
|
|
if ( !newTreeTop ) {
|
|
newTreeTop = colourSpan;
|
|
}
|
|
if ( newTreeBottom ) {
|
|
newTreeBottom.appendChild( colourSpan );
|
|
}
|
|
newTreeBottom = colourSpan;
|
|
}
|
|
if ( !newTreeTop ) {
|
|
newTreeTop = newTreeBottom = createElement( doc, 'SPAN' );
|
|
}
|
|
parent.replaceChild( newTreeTop, node );
|
|
newTreeBottom.appendChild( empty( node ) );
|
|
return newTreeBottom;
|
|
},
|
|
TT: function ( node, parent ) {
|
|
var el = createElement( node.ownerDocument, 'SPAN', {
|
|
'class': 'font',
|
|
style: 'font-family:menlo,consolas,"courier new",monospace'
|
|
});
|
|
parent.replaceChild( el, node );
|
|
el.appendChild( empty( node ) );
|
|
return el;
|
|
}
|
|
};
|
|
|
|
var allowedBlock = /^(?:A(?:DDRESS|RTICLE|SIDE|UDIO)|BLOCKQUOTE|CAPTION|D(?:[DLT]|IV)|F(?:IGURE|IGCAPTION|OOTER)|H[1-6]|HEADER|L(?:ABEL|EGEND|I)|O(?:L|UTPUT)|P(?:RE)?|SECTION|T(?:ABLE|BODY|D|FOOT|H|HEAD|R)|UL)$/;
|
|
|
|
var blacklist = /^(?:HEAD|META|STYLE)/;
|
|
|
|
var walker = new TreeWalker( null, SHOW_TEXT|SHOW_ELEMENT, function () {
|
|
return true;
|
|
});
|
|
|
|
/*
|
|
Two purposes:
|
|
|
|
1. Remove nodes we don't want, such as weird <o:p> tags, comment nodes
|
|
and whitespace nodes.
|
|
2. Convert inline tags into our preferred format.
|
|
*/
|
|
var cleanTree = function cleanTree ( node ) {
|
|
var children = node.childNodes,
|
|
nonInlineParent, i, l, child, nodeName, nodeType, rewriter, childLength,
|
|
startsWithWS, endsWithWS, data, sibling;
|
|
|
|
nonInlineParent = node;
|
|
while ( isInline( nonInlineParent ) ) {
|
|
nonInlineParent = nonInlineParent.parentNode;
|
|
}
|
|
walker.root = nonInlineParent;
|
|
|
|
for ( i = 0, l = children.length; i < l; i += 1 ) {
|
|
child = children[i];
|
|
nodeName = child.nodeName;
|
|
nodeType = child.nodeType;
|
|
rewriter = stylesRewriters[ nodeName ];
|
|
if ( nodeType === ELEMENT_NODE ) {
|
|
childLength = child.childNodes.length;
|
|
if ( rewriter ) {
|
|
child = rewriter( child, node );
|
|
} else if ( blacklist.test( nodeName ) ) {
|
|
node.removeChild( child );
|
|
i -= 1;
|
|
l -= 1;
|
|
continue;
|
|
} else if ( !allowedBlock.test( nodeName ) && !isInline( child ) ) {
|
|
i -= 1;
|
|
l += childLength - 1;
|
|
node.replaceChild( empty( child ), child );
|
|
continue;
|
|
}
|
|
if ( childLength ) {
|
|
cleanTree( child );
|
|
}
|
|
} else {
|
|
if ( nodeType === TEXT_NODE ) {
|
|
data = child.data;
|
|
startsWithWS = !notWS.test( data.charAt( 0 ) );
|
|
endsWithWS = !notWS.test( data.charAt( data.length - 1 ) );
|
|
if ( !startsWithWS && !endsWithWS ) {
|
|
continue;
|
|
}
|
|
// Iterate through the nodes; if we hit some other content
|
|
// before the start of a new block we don't trim
|
|
if ( startsWithWS ) {
|
|
walker.currentNode = child;
|
|
while ( sibling = walker.previousPONode() ) {
|
|
nodeName = sibling.nodeName;
|
|
if ( nodeName === 'IMG' ||
|
|
( nodeName === '#text' &&
|
|
/\S/.test( sibling.data ) ) ) {
|
|
break;
|
|
}
|
|
if ( !isInline( sibling ) ) {
|
|
sibling = null;
|
|
break;
|
|
}
|
|
}
|
|
if ( !sibling ) {
|
|
data = data.replace( /^\s+/g, '' );
|
|
}
|
|
}
|
|
if ( endsWithWS ) {
|
|
walker.currentNode = child;
|
|
while ( sibling = walker.nextNode() ) {
|
|
if ( nodeName === 'IMG' ||
|
|
( nodeName === '#text' &&
|
|
/\S/.test( sibling.data ) ) ) {
|
|
break;
|
|
}
|
|
if ( !isInline( sibling ) ) {
|
|
sibling = null;
|
|
break;
|
|
}
|
|
}
|
|
if ( !sibling ) {
|
|
data = data.replace( /^\s+/g, '' );
|
|
}
|
|
}
|
|
if ( data ) {
|
|
child.data = data;
|
|
continue;
|
|
}
|
|
}
|
|
node.removeChild( child );
|
|
i -= 1;
|
|
l -= 1;
|
|
}
|
|
}
|
|
return node;
|
|
};
|
|
|
|
// ---
|
|
|
|
var removeEmptyInlines = function removeEmptyInlines ( node ) {
|
|
var children = node.childNodes,
|
|
l = children.length,
|
|
child;
|
|
while ( l-- ) {
|
|
child = children[l];
|
|
if ( child.nodeType === ELEMENT_NODE && !isLeaf( child ) ) {
|
|
removeEmptyInlines( child );
|
|
if ( isInline( child ) && !child.firstChild ) {
|
|
node.removeChild( child );
|
|
}
|
|
} else if ( child.nodeType === TEXT_NODE && !child.data ) {
|
|
node.removeChild( child );
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---
|
|
|
|
var notWSTextNode = function ( node ) {
|
|
return node.nodeType === ELEMENT_NODE ?
|
|
node.nodeName === 'BR' :
|
|
notWS.test( node.data );
|
|
};
|
|
var isLineBreak = function ( br ) {
|
|
var block = br.parentNode,
|
|
walker;
|
|
while ( isInline( block ) ) {
|
|
block = block.parentNode;
|
|
}
|
|
walker = new TreeWalker(
|
|
block, SHOW_ELEMENT|SHOW_TEXT, notWSTextNode );
|
|
walker.currentNode = br;
|
|
return !!walker.nextNode();
|
|
};
|
|
|
|
// <br> elements are treated specially, and differently depending on the
|
|
// browser, when in rich text editor mode. When adding HTML from external
|
|
// sources, we must remove them, replacing the ones that actually affect
|
|
// line breaks by wrapping the inline text in a <div>. Browsers that want <br>
|
|
// elements at the end of each block will then have them added back in a later
|
|
// fixCursor method call.
|
|
var cleanupBRs = function ( node, root ) {
|
|
var brs = node.querySelectorAll( 'BR' ),
|
|
brBreaksLine = [],
|
|
l = brs.length,
|
|
i, br, parent;
|
|
|
|
// Must calculate whether the <br> breaks a line first, because if we
|
|
// have two <br>s next to each other, after the first one is converted
|
|
// to a block split, the second will be at the end of a block and
|
|
// therefore seem to not be a line break. But in its original context it
|
|
// was, so we should also convert it to a block split.
|
|
for ( i = 0; i < l; i += 1 ) {
|
|
brBreaksLine[i] = isLineBreak( brs[i] );
|
|
}
|
|
while ( l-- ) {
|
|
br = brs[l];
|
|
// Cleanup may have removed it
|
|
parent = br.parentNode;
|
|
if ( !parent ) { continue; }
|
|
// If it doesn't break a line, just remove it; it's not doing
|
|
// anything useful. We'll add it back later if required by the
|
|
// browser. If it breaks a line, wrap the content in div tags
|
|
// and replace the brs.
|
|
if ( !brBreaksLine[l] ) {
|
|
detach( br );
|
|
} else if ( !isInline( parent ) ) {
|
|
fixContainer( parent, root );
|
|
}
|
|
}
|
|
};
|
|
|
|
var onCut = function ( event ) {
|
|
var clipboardData = event.clipboardData;
|
|
var range = this.getSelection();
|
|
var node = this.createElement( 'div' );
|
|
var root = this._root;
|
|
var self = this;
|
|
|
|
// Save undo checkpoint
|
|
this.saveUndoState( range );
|
|
|
|
// Edge only seems to support setting plain text as of 2016-03-11.
|
|
// Mobile Safari flat out doesn't work:
|
|
// https://bugs.webkit.org/show_bug.cgi?id=143776
|
|
if ( !isEdge && !isIOS && clipboardData ) {
|
|
moveRangeBoundariesUpTree( range, root );
|
|
node.appendChild( deleteContentsOfRange( range, root ) );
|
|
clipboardData.setData( 'text/html', node.innerHTML );
|
|
clipboardData.setData( 'text/plain',
|
|
node.innerText || node.textContent );
|
|
event.preventDefault();
|
|
} else {
|
|
setTimeout( function () {
|
|
try {
|
|
// If all content removed, ensure div at start of root.
|
|
self._ensureBottomLine();
|
|
} catch ( error ) {
|
|
self.didError( error );
|
|
}
|
|
}, 0 );
|
|
}
|
|
|
|
this.setSelection( range );
|
|
};
|
|
|
|
var onCopy = function ( event ) {
|
|
var clipboardData = event.clipboardData;
|
|
var range = this.getSelection();
|
|
var node = this.createElement( 'div' );
|
|
|
|
// Edge only seems to support setting plain text as of 2016-03-11.
|
|
// Mobile Safari flat out doesn't work:
|
|
// https://bugs.webkit.org/show_bug.cgi?id=143776
|
|
if ( !isEdge && !isIOS && clipboardData ) {
|
|
node.appendChild( range.cloneContents() );
|
|
clipboardData.setData( 'text/html', node.innerHTML );
|
|
clipboardData.setData( 'text/plain',
|
|
node.innerText || node.textContent );
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
var onPaste = function ( event ) {
|
|
var clipboardData = event.clipboardData,
|
|
items = clipboardData && clipboardData.items,
|
|
fireDrop = false,
|
|
hasImage = false,
|
|
plainItem = null,
|
|
self = this,
|
|
l, item, type, types, data;
|
|
|
|
// Current HTML5 Clipboard interface
|
|
// ---------------------------------
|
|
// https://html.spec.whatwg.org/multipage/interaction.html
|
|
|
|
// Edge only provides access to plain text as of 2016-03-11.
|
|
if ( !isEdge && items ) {
|
|
event.preventDefault();
|
|
l = items.length;
|
|
while ( l-- ) {
|
|
item = items[l];
|
|
type = item.type;
|
|
if ( type === 'text/html' ) {
|
|
/*jshint loopfunc: true */
|
|
item.getAsString( function ( html ) {
|
|
self.insertHTML( html, true );
|
|
});
|
|
/*jshint loopfunc: false */
|
|
return;
|
|
}
|
|
if ( type === 'text/plain' ) {
|
|
plainItem = item;
|
|
}
|
|
if ( /^image\/.*/.test( type ) ) {
|
|
hasImage = true;
|
|
}
|
|
}
|
|
// Treat image paste as a drop of an image file.
|
|
if ( hasImage ) {
|
|
this.fireEvent( 'dragover', {
|
|
dataTransfer: clipboardData,
|
|
/*jshint loopfunc: true */
|
|
preventDefault: function () {
|
|
fireDrop = true;
|
|
}
|
|
/*jshint loopfunc: false */
|
|
});
|
|
if ( fireDrop ) {
|
|
this.fireEvent( 'drop', {
|
|
dataTransfer: clipboardData
|
|
});
|
|
}
|
|
} else if ( plainItem ) {
|
|
item.getAsString( function ( text ) {
|
|
self.insertPlainText( text, true );
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Old interface
|
|
// -------------
|
|
|
|
// Safari (and indeed many other OS X apps) copies stuff as text/rtf
|
|
// rather than text/html; even from a webpage in Safari. The only way
|
|
// to get an HTML version is to fallback to letting the browser insert
|
|
// the content. Same for getting image data. *Sigh*.
|
|
//
|
|
// Firefox is even worse: it doesn't even let you know that there might be
|
|
// an RTF version on the clipboard, but it will also convert to HTML if you
|
|
// let the browser insert the content. I've filed
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1254028
|
|
types = clipboardData && clipboardData.types;
|
|
if ( !isEdge && types && (
|
|
indexOf.call( types, 'text/html' ) > -1 || (
|
|
!isGecko &&
|
|
indexOf.call( types, 'text/plain' ) > -1 &&
|
|
indexOf.call( types, 'text/rtf' ) < 0 )
|
|
)) {
|
|
event.preventDefault();
|
|
// Abiword on Linux copies a plain text and html version, but the HTML
|
|
// version is the empty string! So always try to get HTML, but if none,
|
|
// insert plain text instead.
|
|
if (( data = clipboardData.getData( 'text/html' ) )) {
|
|
this.insertHTML( data, true );
|
|
} else if (( data = clipboardData.getData( 'text/plain' ) )) {
|
|
this.insertPlainText( data, true );
|
|
}
|
|
return;
|
|
}
|
|
|
|
// No interface. Includes all versions of IE :(
|
|
// --------------------------------------------
|
|
|
|
this._awaitingPaste = true;
|
|
|
|
var body = this._doc.body,
|
|
range = this.getSelection(),
|
|
startContainer = range.startContainer,
|
|
startOffset = range.startOffset,
|
|
endContainer = range.endContainer,
|
|
endOffset = range.endOffset;
|
|
|
|
// We need to position the pasteArea in the visible portion of the screen
|
|
// to stop the browser auto-scrolling.
|
|
var pasteArea = this.createElement( 'DIV', {
|
|
contenteditable: 'true',
|
|
style: 'position:fixed; overflow:hidden; top:0; right:100%; width:1px; height:1px;'
|
|
});
|
|
body.appendChild( pasteArea );
|
|
range.selectNodeContents( pasteArea );
|
|
this.setSelection( range );
|
|
|
|
// A setTimeout of 0 means this is added to the back of the
|
|
// single javascript thread, so it will be executed after the
|
|
// paste event.
|
|
setTimeout( function () {
|
|
try {
|
|
// IE sometimes fires the beforepaste event twice; make sure it is
|
|
// not run again before our after paste function is called.
|
|
self._awaitingPaste = false;
|
|
|
|
// Get the pasted content and clean
|
|
var html = '',
|
|
next = pasteArea,
|
|
first, range;
|
|
|
|
// #88: Chrome can apparently split the paste area if certain
|
|
// content is inserted; gather them all up.
|
|
while ( pasteArea = next ) {
|
|
next = pasteArea.nextSibling;
|
|
detach( pasteArea );
|
|
// Safari and IE like putting extra divs around things.
|
|
first = pasteArea.firstChild;
|
|
if ( first && first === pasteArea.lastChild &&
|
|
first.nodeName === 'DIV' ) {
|
|
pasteArea = first;
|
|
}
|
|
html += pasteArea.innerHTML;
|
|
}
|
|
|
|
range = self._createRange(
|
|
startContainer, startOffset, endContainer, endOffset );
|
|
self.setSelection( range );
|
|
|
|
if ( html ) {
|
|
self.insertHTML( html, true );
|
|
}
|
|
} catch ( error ) {
|
|
self.didError( error );
|
|
}
|
|
}, 0 );
|
|
};
|
|
|
|
var instances = [];
|
|
|
|
function getSquireInstance ( doc ) {
|
|
var l = instances.length,
|
|
instance;
|
|
while ( l-- ) {
|
|
instance = instances[l];
|
|
if ( instance._doc === doc ) {
|
|
return instance;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mergeObjects ( base, extras ) {
|
|
var prop, value;
|
|
if ( !base ) {
|
|
base = {};
|
|
}
|
|
for ( prop in extras ) {
|
|
value = extras[ prop ];
|
|
base[ prop ] = ( value && value.constructor === Object ) ?
|
|
mergeObjects( base[ prop ], value ) :
|
|
value;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
function Squire ( root, config ) {
|
|
if ( root.nodeType === DOCUMENT_NODE ) {
|
|
root = root.body;
|
|
}
|
|
var doc = root.ownerDocument;
|
|
var win = doc.defaultView;
|
|
var mutation;
|
|
|
|
this._win = win;
|
|
this._doc = doc;
|
|
this._root = root;
|
|
|
|
this._events = {};
|
|
|
|
this._lastSelection = null;
|
|
|
|
// IE loses selection state of iframe on blur, so make sure we
|
|
// cache it just before it loses focus.
|
|
if ( losesSelectionOnBlur ) {
|
|
this.addEventListener( 'beforedeactivate', this.getSelection );
|
|
}
|
|
|
|
this._hasZWS = false;
|
|
|
|
this._lastAnchorNode = null;
|
|
this._lastFocusNode = null;
|
|
this._path = '';
|
|
|
|
this.addEventListener( 'keyup', this._updatePathOnEvent );
|
|
this.addEventListener( 'mouseup', this._updatePathOnEvent );
|
|
|
|
this._undoIndex = -1;
|
|
this._undoStack = [];
|
|
this._undoStackLength = 0;
|
|
this._isInUndoState = false;
|
|
this._ignoreChange = false;
|
|
|
|
if ( canObserveMutations ) {
|
|
mutation = new MutationObserver( this._docWasChanged.bind( this ) );
|
|
mutation.observe( root, {
|
|
childList: true,
|
|
attributes: true,
|
|
characterData: true,
|
|
subtree: true
|
|
});
|
|
this._mutation = mutation;
|
|
} else {
|
|
this.addEventListener( 'keyup', this._keyUpDetectChange );
|
|
}
|
|
|
|
// IE sometimes fires the beforepaste event twice; make sure it is not run
|
|
// again before our after paste function is called.
|
|
this._awaitingPaste = false;
|
|
this.addEventListener( isIElt11 ? 'beforecut' : 'cut', onCut );
|
|
this.addEventListener( 'copy', onCopy );
|
|
this.addEventListener( isIElt11 ? 'beforepaste' : 'paste', onPaste );
|
|
|
|
// Opera does not fire keydown repeatedly.
|
|
this.addEventListener( isPresto ? 'keypress' : 'keydown', onKey );
|
|
|
|
// Add key handlers
|
|
this._keyHandlers = Object.create( keyHandlers );
|
|
|
|
// Override default properties
|
|
this.setConfig( config );
|
|
|
|
// Fix IE<10's buggy implementation of Text#splitText.
|
|
// If the split is at the end of the node, it doesn't insert the newly split
|
|
// node into the document, and sets its value to undefined rather than ''.
|
|
// And even if the split is not at the end, the original node is removed
|
|
// from the document and replaced by another, rather than just having its
|
|
// data shortened.
|
|
// We used to feature test for this, but then found the feature test would
|
|
// sometimes pass, but later on the buggy behaviour would still appear.
|
|
// I think IE10 does not have the same bug, but it doesn't hurt to replace
|
|
// its native fn too and then we don't need yet another UA category.
|
|
if ( isIElt11 ) {
|
|
win.Text.prototype.splitText = function ( offset ) {
|
|
var afterSplit = this.ownerDocument.createTextNode(
|
|
this.data.slice( offset ) ),
|
|
next = this.nextSibling,
|
|
parent = this.parentNode,
|
|
toDelete = this.length - offset;
|
|
if ( next ) {
|
|
parent.insertBefore( afterSplit, next );
|
|
} else {
|
|
parent.appendChild( afterSplit );
|
|
}
|
|
if ( toDelete ) {
|
|
this.deleteData( offset, toDelete );
|
|
}
|
|
return afterSplit;
|
|
};
|
|
}
|
|
|
|
root.setAttribute( 'contenteditable', 'true' );
|
|
|
|
// Remove Firefox's built-in controls
|
|
try {
|
|
doc.execCommand( 'enableObjectResizing', false, 'false' );
|
|
doc.execCommand( 'enableInlineTableEditing', false, 'false' );
|
|
} catch ( error ) {}
|
|
|
|
instances.push( this );
|
|
|
|
// Need to register instance before calling setHTML, so that the fixCursor
|
|
// function can lookup any default block tag options set.
|
|
this.setHTML( '' );
|
|
}
|
|
|
|
var proto = Squire.prototype;
|
|
|
|
proto.setConfig = function ( config ) {
|
|
config = mergeObjects({
|
|
blockTag: 'DIV',
|
|
blockAttributes: null,
|
|
tagAttributes: {
|
|
blockquote: null,
|
|
ul: null,
|
|
ol: null,
|
|
li: null
|
|
}
|
|
}, config );
|
|
|
|
// Users may specify block tag in lower case
|
|
config.blockTag = config.blockTag.toUpperCase();
|
|
|
|
this._config = config;
|
|
|
|
return this;
|
|
};
|
|
|
|
proto.createElement = function ( tag, props, children ) {
|
|
return createElement( this._doc, tag, props, children );
|
|
};
|
|
|
|
proto.createDefaultBlock = function ( children ) {
|
|
var config = this._config;
|
|
return fixCursor(
|
|
this.createElement( config.blockTag, config.blockAttributes, children ),
|
|
this._root
|
|
);
|
|
};
|
|
|
|
proto.didError = function ( error ) {
|
|
console.log( error );
|
|
};
|
|
|
|
proto.getDocument = function () {
|
|
return this._doc;
|
|
};
|
|
|
|
// --- Events ---
|
|
|
|
// Subscribing to these events won't automatically add a listener to the
|
|
// document node, since these events are fired in a custom manner by the
|
|
// editor code.
|
|
var customEvents = {
|
|
pathChange: 1, select: 1, input: 1, undoStateChange: 1
|
|
};
|
|
|
|
proto.fireEvent = function ( type, event ) {
|
|
var handlers = this._events[ type ],
|
|
l, obj;
|
|
if ( handlers ) {
|
|
if ( !event ) {
|
|
event = {};
|
|
}
|
|
if ( event.type !== type ) {
|
|
event.type = type;
|
|
}
|
|
// Clone handlers array, so any handlers added/removed do not affect it.
|
|
handlers = handlers.slice();
|
|
l = handlers.length;
|
|
while ( l-- ) {
|
|
obj = handlers[l];
|
|
try {
|
|
if ( obj.handleEvent ) {
|
|
obj.handleEvent( event );
|
|
} else {
|
|
obj.call( this, event );
|
|
}
|
|
} catch ( error ) {
|
|
error.details = 'Squire: fireEvent error. Event type: ' + type;
|
|
this.didError( error );
|
|
}
|
|
}
|
|
}
|
|
return this;
|
|
};
|
|
|
|
proto.destroy = function () {
|
|
var root = this._root,
|
|
events = this._events,
|
|
type;
|
|
for ( type in events ) {
|
|
if ( !customEvents[ type ] ) {
|
|
root.removeEventListener( type, this, true );
|
|
}
|
|
}
|
|
if ( this._mutation ) {
|
|
this._mutation.disconnect();
|
|
}
|
|
var l = instances.length;
|
|
while ( l-- ) {
|
|
if ( instances[l] === this ) {
|
|
instances.splice( l, 1 );
|
|
}
|
|
}
|
|
};
|
|
|
|
proto.handleEvent = function ( event ) {
|
|
this.fireEvent( event.type, event );
|
|
};
|
|
|
|
proto.addEventListener = function ( type, fn ) {
|
|
var handlers = this._events[ type ];
|
|
if ( !fn ) {
|
|
this.didError({
|
|
name: 'Squire: addEventListener with null or undefined fn',
|
|
message: 'Event type: ' + type
|
|
});
|
|
return this;
|
|
}
|
|
if ( !handlers ) {
|
|
handlers = this._events[ type ] = [];
|
|
if ( !customEvents[ type ] ) {
|
|
this._root.addEventListener( type, this, true );
|
|
}
|
|
}
|
|
handlers.push( fn );
|
|
return this;
|
|
};
|
|
|
|
proto.removeEventListener = function ( type, fn ) {
|
|
var handlers = this._events[ type ],
|
|
l;
|
|
if ( handlers ) {
|
|
l = handlers.length;
|
|
while ( l-- ) {
|
|
if ( handlers[l] === fn ) {
|
|
handlers.splice( l, 1 );
|
|
}
|
|
}
|
|
if ( !handlers.length ) {
|
|
delete this._events[ type ];
|
|
if ( !customEvents[ type ] ) {
|
|
this._root.removeEventListener( type, this, true );
|
|
}
|
|
}
|
|
}
|
|
return this;
|
|
};
|
|
|
|
// --- Selection and Path ---
|
|
|
|
proto._createRange =
|
|
function ( range, startOffset, endContainer, endOffset ) {
|
|
if ( range instanceof this._win.Range ) {
|
|
return range.cloneRange();
|
|
}
|
|
var domRange = this._doc.createRange();
|
|
domRange.setStart( range, startOffset );
|
|
if ( endContainer ) {
|
|
domRange.setEnd( endContainer, endOffset );
|
|
} else {
|
|
domRange.setEnd( range, startOffset );
|
|
}
|
|
return domRange;
|
|
};
|
|
|
|
proto.getCursorPosition = function ( range ) {
|
|
if ( !range && !( range = this.getSelection() ) ) {
|
|
return null;
|
|
}
|
|
// Get the bounding rect
|
|
var rect = range.getBoundingClientRect();
|
|
var node, parent;
|
|
if ( rect && !rect.top ) {
|
|
this._ignoreChange = true;
|
|
node = this._doc.createElement( 'SPAN' );
|
|
node.textContent = ZWS;
|
|
insertNodeInRange( range, node );
|
|
rect = node.getBoundingClientRect();
|
|
parent = node.parentNode;
|
|
parent.removeChild( node );
|
|
mergeInlines( parent, {
|
|
startContainer: range.startContainer,
|
|
endContainer: range.endContainer,
|
|
startOffset: range.startOffset,
|
|
endOffset: range.endOffset
|
|
});
|
|
}
|
|
return rect;
|
|
};
|
|
|
|
proto._moveCursorTo = function ( toStart ) {
|
|
var root = this._root,
|
|
range = this._createRange( root, toStart ? 0 : root.childNodes.length );
|
|
moveRangeBoundariesDownTree( range );
|
|
this.setSelection( range );
|
|
return this;
|
|
};
|
|
proto.moveCursorToStart = function () {
|
|
return this._moveCursorTo( true );
|
|
};
|
|
proto.moveCursorToEnd = function () {
|
|
return this._moveCursorTo( false );
|
|
};
|
|
|
|
var getWindowSelection = function ( self ) {
|
|
return self._win.getSelection() || null;
|
|
};
|
|
|
|
proto.setSelection = function ( range ) {
|
|
if ( range ) {
|
|
// iOS bug: if you don't focus the iframe before setting the
|
|
// selection, you can end up in a state where you type but the input
|
|
// doesn't get directed into the contenteditable area but is instead
|
|
// lost in a black hole. Very strange.
|
|
if ( isIOS ) {
|
|
this._win.focus();
|
|
}
|
|
var sel = getWindowSelection( this );
|
|
if ( sel ) {
|
|
sel.removeAllRanges();
|
|
sel.addRange( range );
|
|
}
|
|
}
|
|
return this;
|
|
};
|
|
|
|
proto.getSelection = function () {
|
|
var sel = getWindowSelection( this );
|
|
var root = this._root;
|
|
var selection, startContainer, endContainer;
|
|
if ( sel && sel.rangeCount ) {
|
|
selection = sel.getRangeAt( 0 ).cloneRange();
|
|
startContainer = selection.startContainer;
|
|
endContainer = selection.endContainer;
|
|
// FF can return the selection as being inside an <img>. WTF?
|
|
if ( startContainer && isLeaf( startContainer ) ) {
|
|
selection.setStartBefore( startContainer );
|
|
}
|
|
if ( endContainer && isLeaf( endContainer ) ) {
|
|
selection.setEndBefore( endContainer );
|
|
}
|
|
}
|
|
if ( selection &&
|
|
isOrContains( root, selection.commonAncestorContainer ) ) {
|
|
this._lastSelection = selection;
|
|
} else {
|
|
selection = this._lastSelection;
|
|
}
|
|
if ( !selection ) {
|
|
selection = this._createRange( root.firstChild, 0 );
|
|
}
|
|
return selection;
|
|
};
|
|
|
|
proto.getSelectedText = function () {
|
|
var range = this.getSelection(),
|
|
walker = new TreeWalker(
|
|
range.commonAncestorContainer,
|
|
SHOW_TEXT|SHOW_ELEMENT,
|
|
function ( node ) {
|
|
return isNodeContainedInRange( range, node, true );
|
|
}
|
|
),
|
|
startContainer = range.startContainer,
|
|
endContainer = range.endContainer,
|
|
node = walker.currentNode = startContainer,
|
|
textContent = '',
|
|
addedTextInBlock = false,
|
|
value;
|
|
|
|
if ( !walker.filter( node ) ) {
|
|
node = walker.nextNode();
|
|
}
|
|
|
|
while ( node ) {
|
|
if ( node.nodeType === TEXT_NODE ) {
|
|
value = node.data;
|
|
if ( value && ( /\S/.test( value ) ) ) {
|
|
if ( node === endContainer ) {
|
|
value = value.slice( 0, range.endOffset );
|
|
}
|
|
if ( node === startContainer ) {
|
|
value = value.slice( range.startOffset );
|
|
}
|
|
textContent += value;
|
|
addedTextInBlock = true;
|
|
}
|
|
} else if ( node.nodeName === 'BR' ||
|
|
addedTextInBlock && !isInline( node ) ) {
|
|
textContent += '\n';
|
|
addedTextInBlock = false;
|
|
}
|
|
node = walker.nextNode();
|
|
}
|
|
|
|
return textContent;
|
|
};
|
|
|
|
proto.getPath = function () {
|
|
return this._path;
|
|
};
|
|
|
|
// --- Workaround for browsers that can't focus empty text nodes ---
|
|
|
|
// WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=15256
|
|
|
|
var removeZWS = function ( root ) {
|
|
var walker = new TreeWalker( root, SHOW_TEXT, function () {
|
|
return true;
|
|
}, false ),
|
|
parent, node, index;
|
|
while ( node = walker.nextNode() ) {
|
|
while ( ( index = node.data.indexOf( ZWS ) ) > -1 ) {
|
|
if ( node.length === 1 ) {
|
|
do {
|
|
parent = node.parentNode;
|
|
parent.removeChild( node );
|
|
node = parent;
|
|
walker.currentNode = parent;
|
|
} while ( isInline( node ) && !getLength( node ) );
|
|
break;
|
|
} else {
|
|
node.deleteData( index, 1 );
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
proto._didAddZWS = function () {
|
|
this._hasZWS = true;
|
|
};
|
|
proto._removeZWS = function () {
|
|
if ( !this._hasZWS ) {
|
|
return;
|
|
}
|
|
removeZWS( this._root );
|
|
this._hasZWS = false;
|
|
};
|
|
|
|
// --- Path change events ---
|
|
|
|
proto._updatePath = function ( range, force ) {
|
|
var anchor = range.startContainer,
|
|
focus = range.endContainer,
|
|
newPath;
|
|
if ( force || anchor !== this._lastAnchorNode ||
|
|
focus !== this._lastFocusNode ) {
|
|
this._lastAnchorNode = anchor;
|
|
this._lastFocusNode = focus;
|
|
newPath = ( anchor && focus ) ? ( anchor === focus ) ?
|
|
getPath( focus, this._root ) : '(selection)' : '';
|
|
if ( this._path !== newPath ) {
|
|
this._path = newPath;
|
|
this.fireEvent( 'pathChange', { path: newPath } );
|
|
}
|
|
}
|
|
if ( !range.collapsed ) {
|
|
this.fireEvent( 'select' );
|
|
}
|
|
};
|
|
|
|
proto._updatePathOnEvent = function () {
|
|
this._updatePath( this.getSelection() );
|
|
};
|
|
|
|
// --- Focus ---
|
|
|
|
proto.focus = function () {
|
|
this._root.focus();
|
|
return this;
|
|
};
|
|
|
|
proto.blur = function () {
|
|
this._root.blur();
|
|
return this;
|
|
};
|
|
|
|
// --- Bookmarking ---
|
|
|
|
var startSelectionId = 'squire-selection-start';
|
|
var endSelectionId = 'squire-selection-end';
|
|
|
|
proto._saveRangeToBookmark = function ( range ) {
|
|
var startNode = this.createElement( 'INPUT', {
|
|
id: startSelectionId,
|
|
type: 'hidden'
|
|
}),
|
|
endNode = this.createElement( 'INPUT', {
|
|
id: endSelectionId,
|
|
type: 'hidden'
|
|
}),
|
|
temp;
|
|
|
|
insertNodeInRange( range, startNode );
|
|
range.collapse( false );
|
|
insertNodeInRange( range, endNode );
|
|
|
|
// In a collapsed range, the start is sometimes inserted after the end!
|
|
if ( startNode.compareDocumentPosition( endNode ) &
|
|
DOCUMENT_POSITION_PRECEDING ) {
|
|
startNode.id = endSelectionId;
|
|
endNode.id = startSelectionId;
|
|
temp = startNode;
|
|
startNode = endNode;
|
|
endNode = temp;
|
|
}
|
|
|
|
range.setStartAfter( startNode );
|
|
range.setEndBefore( endNode );
|
|
};
|
|
|
|
proto._getRangeAndRemoveBookmark = function ( range ) {
|
|
var root = this._root,
|
|
start = root.querySelector( '#' + startSelectionId ),
|
|
end = root.querySelector( '#' + endSelectionId );
|
|
|
|
if ( start && end ) {
|
|
var startContainer = start.parentNode,
|
|
endContainer = end.parentNode,
|
|
collapsed;
|
|
|
|
var _range = {
|
|
startContainer: startContainer,
|
|
endContainer: endContainer,
|
|
startOffset: indexOf.call( startContainer.childNodes, start ),
|
|
endOffset: indexOf.call( endContainer.childNodes, end )
|
|
};
|
|
|
|
if ( startContainer === endContainer ) {
|
|
_range.endOffset -= 1;
|
|
}
|
|
|
|
detach( start );
|
|
detach( end );
|
|
|
|
// Merge any text nodes we split
|
|
mergeInlines( startContainer, _range );
|
|
if ( startContainer !== endContainer ) {
|
|
mergeInlines( endContainer, _range );
|
|
}
|
|
|
|
if ( !range ) {
|
|
range = this._doc.createRange();
|
|
}
|
|
range.setStart( _range.startContainer, _range.startOffset );
|
|
range.setEnd( _range.endContainer, _range.endOffset );
|
|
collapsed = range.collapsed;
|
|
|
|
moveRangeBoundariesDownTree( range );
|
|
if ( collapsed ) {
|
|
range.collapse( true );
|
|
}
|
|
}
|
|
return range || null;
|
|
};
|
|
|
|
// --- Undo ---
|
|
|
|
proto._keyUpDetectChange = function ( event ) {
|
|
var code = event.keyCode;
|
|
// Presume document was changed if:
|
|
// 1. A modifier key (other than shift) wasn't held down
|
|
// 2. The key pressed is not in range 16<=x<=20 (control keys)
|
|
// 3. The key pressed is not in range 33<=x<=45 (navigation keys)
|
|
if ( !event.ctrlKey && !event.metaKey && !event.altKey &&
|
|
( code < 16 || code > 20 ) &&
|
|
( code < 33 || code > 45 ) ) {
|
|
this._docWasChanged();
|
|
}
|
|
};
|
|
|
|
proto._docWasChanged = function () {
|
|
if ( canObserveMutations && this._ignoreChange ) {
|
|
this._ignoreChange = false;
|
|
return;
|
|
}
|
|
if ( this._isInUndoState ) {
|
|
this._isInUndoState = false;
|
|
this.fireEvent( 'undoStateChange', {
|
|
canUndo: true,
|
|
canRedo: false
|
|
});
|
|
}
|
|
this.fireEvent( 'input' );
|
|
};
|
|
|
|
// Leaves bookmark
|
|
proto._recordUndoState = function ( range ) {
|
|
// Don't record if we're already in an undo state
|
|
if ( !this._isInUndoState ) {
|
|
// Advance pointer to new position
|
|
var undoIndex = this._undoIndex += 1,
|
|
undoStack = this._undoStack;
|
|
|
|
// Truncate stack if longer (i.e. if has been previously undone)
|
|
if ( undoIndex < this._undoStackLength ) {
|
|
undoStack.length = this._undoStackLength = undoIndex;
|
|
}
|
|
|
|
// Write out data
|
|
if ( range ) {
|
|
this._saveRangeToBookmark( range );
|
|
}
|
|
undoStack[ undoIndex ] = this._getHTML();
|
|
this._undoStackLength += 1;
|
|
this._isInUndoState = true;
|
|
}
|
|
};
|
|
|
|
proto.saveUndoState = function ( range ) {
|
|
if ( range === undefined ) {
|
|
range = this.getSelection();
|
|
}
|
|
if ( !this._isInUndoState ) {
|
|
this._recordUndoState( range );
|
|
this._getRangeAndRemoveBookmark( range );
|
|
}
|
|
return this;
|
|
};
|
|
|
|
proto.undo = function () {
|
|
// Sanity check: must not be at beginning of the history stack
|
|
if ( this._undoIndex !== 0 || !this._isInUndoState ) {
|
|
// Make sure any changes since last checkpoint are saved.
|
|
this._recordUndoState( this.getSelection() );
|
|
|
|
this._undoIndex -= 1;
|
|
this._setHTML( this._undoStack[ this._undoIndex ] );
|
|
var range = this._getRangeAndRemoveBookmark();
|
|
if ( range ) {
|
|
this.setSelection( range );
|
|
}
|
|
this._isInUndoState = true;
|
|
this.fireEvent( 'undoStateChange', {
|
|
canUndo: this._undoIndex !== 0,
|
|
canRedo: true
|
|
});
|
|
this.fireEvent( 'input' );
|
|
}
|
|
return this;
|
|
};
|
|
|
|
proto.redo = function () {
|
|
// Sanity check: must not be at end of stack and must be in an undo
|
|
// state.
|
|
var undoIndex = this._undoIndex,
|
|
undoStackLength = this._undoStackLength;
|
|
if ( undoIndex + 1 < undoStackLength && this._isInUndoState ) {
|
|
this._undoIndex += 1;
|
|
this._setHTML( this._undoStack[ this._undoIndex ] );
|
|
var range = this._getRangeAndRemoveBookmark();
|
|
if ( range ) {
|
|
this.setSelection( range );
|
|
}
|
|
this.fireEvent( 'undoStateChange', {
|
|
canUndo: true,
|
|
canRedo: undoIndex + 2 < undoStackLength
|
|
});
|
|
this.fireEvent( 'input' );
|
|
}
|
|
return this;
|
|
};
|
|
|
|
// --- Inline formatting ---
|
|
|
|
// Looks for matching tag and attributes, so won't work
|
|
// if <strong> instead of <b> etc.
|
|
proto.hasFormat = function ( tag, attributes, range ) {
|
|
// 1. Normalise the arguments and get selection
|
|
tag = tag.toUpperCase();
|
|
if ( !attributes ) { attributes = {}; }
|
|
if ( !range && !( range = this.getSelection() ) ) {
|
|
return false;
|
|
}
|
|
|
|
// Sanitize range to prevent weird IE artifacts
|
|
if ( !range.collapsed &&
|
|
range.startContainer.nodeType === TEXT_NODE &&
|
|
range.startOffset === range.startContainer.length &&
|
|
range.startContainer.nextSibling ) {
|
|
range.setStartBefore( range.startContainer.nextSibling );
|
|
}
|
|
if ( !range.collapsed &&
|
|
range.endContainer.nodeType === TEXT_NODE &&
|
|
range.endOffset === 0 &&
|
|
range.endContainer.previousSibling ) {
|
|
range.setEndAfter( range.endContainer.previousSibling );
|
|
}
|
|
|
|
// If the common ancestor is inside the tag we require, we definitely
|
|
// have the format.
|
|
var root = this._root;
|
|
var common = range.commonAncestorContainer;
|
|
var walker, node;
|
|
if ( getNearest( common, root, tag, attributes ) ) {
|
|
return true;
|
|
}
|
|
|
|
// If common ancestor is a text node and doesn't have the format, we
|
|
// definitely don't have it.
|
|
if ( common.nodeType === TEXT_NODE ) {
|
|
return false;
|
|
}
|
|
|
|
// Otherwise, check each text node at least partially contained within
|
|
// the selection and make sure all of them have the format we want.
|
|
walker = new TreeWalker( common, SHOW_TEXT, function ( node ) {
|
|
return isNodeContainedInRange( range, node, true );
|
|
}, false );
|
|
|
|
var seenNode = false;
|
|
while ( node = walker.nextNode() ) {
|
|
if ( !getNearest( node, root, tag, attributes ) ) {
|
|
return false;
|
|
}
|
|
seenNode = true;
|
|
}
|
|
|
|
return seenNode;
|
|
};
|
|
|
|
// Extracts the font-family and font-size (if any) of the element
|
|
// holding the cursor. If there's a selection, returns an empty object.
|
|
proto.getFontInfo = function ( range ) {
|
|
var fontInfo = {
|
|
color: undefined,
|
|
backgroundColor: undefined,
|
|
family: undefined,
|
|
size: undefined
|
|
};
|
|
var seenAttributes = 0;
|
|
var element, style, attr;
|
|
|
|
if ( !range && !( range = this.getSelection() ) ) {
|
|
return fontInfo;
|
|
}
|
|
|
|
element = range.commonAncestorContainer;
|
|
if ( range.collapsed || element.nodeType === TEXT_NODE ) {
|
|
if ( element.nodeType === TEXT_NODE ) {
|
|
element = element.parentNode;
|
|
}
|
|
while ( seenAttributes < 4 && element ) {
|
|
if ( style = element.style ) {
|
|
if ( !fontInfo.color && ( attr = style.color ) ) {
|
|
fontInfo.color = attr;
|
|
seenAttributes += 1;
|
|
}
|
|
if ( !fontInfo.backgroundColor &&
|
|
( attr = style.backgroundColor ) ) {
|
|
fontInfo.backgroundColor = attr;
|
|
seenAttributes += 1;
|
|
}
|
|
if ( !fontInfo.family && ( attr = style.fontFamily ) ) {
|
|
fontInfo.family = attr;
|
|
seenAttributes += 1;
|
|
}
|
|
if ( !fontInfo.size && ( attr = style.fontSize ) ) {
|
|
fontInfo.size = attr;
|
|
seenAttributes += 1;
|
|
}
|
|
}
|
|
element = element.parentNode;
|
|
}
|
|
}
|
|
return fontInfo;
|
|
};
|
|
|
|
proto._addFormat = function ( tag, attributes, range ) {
|
|
// If the range is collapsed we simply insert the node by wrapping
|
|
// it round the range and focus it.
|
|
var root = this._root;
|
|
var el, walker, startContainer, endContainer, startOffset, endOffset,
|
|
node, needsFormat;
|
|
|
|
if ( range.collapsed ) {
|
|
el = fixCursor( this.createElement( tag, attributes ), root );
|
|
insertNodeInRange( range, el );
|
|
range.setStart( el.firstChild, el.firstChild.length );
|
|
range.collapse( true );
|
|
}
|
|
// Otherwise we find all the textnodes in the range (splitting
|
|
// partially selected nodes) and if they're not already formatted
|
|
// correctly we wrap them in the appropriate tag.
|
|
else {
|
|
// Create an iterator to walk over all the text nodes under this
|
|
// ancestor which are in the range and not already formatted
|
|
// correctly.
|
|
//
|
|
// In Blink/WebKit, empty blocks may have no text nodes, just a <br>.
|
|
// Therefore we wrap this in the tag as well, as this will then cause it
|
|
// to apply when the user types something in the block, which is
|
|
// presumably what was intended.
|
|
//
|
|
// IMG tags are included because we may want to create a link around them,
|
|
// and adding other styles is harmless.
|
|
walker = new TreeWalker(
|
|
range.commonAncestorContainer,
|
|
SHOW_TEXT|SHOW_ELEMENT,
|
|
function ( node ) {
|
|
return ( node.nodeType === TEXT_NODE ||
|
|
node.nodeName === 'BR' ||
|
|
node.nodeName === 'IMG'
|
|
) && isNodeContainedInRange( range, node, true );
|
|
},
|
|
false
|
|
);
|
|
|
|
// Start at the beginning node of the range and iterate through
|
|
// all the nodes in the range that need formatting.
|
|
startContainer = range.startContainer;
|
|
startOffset = range.startOffset;
|
|
endContainer = range.endContainer;
|
|
endOffset = range.endOffset;
|
|
|
|
// Make sure we start with a valid node.
|
|
walker.currentNode = startContainer;
|
|
if ( !walker.filter( startContainer ) ) {
|
|
startContainer = walker.nextNode();
|
|
startOffset = 0;
|
|
}
|
|
|
|
// If there are no interesting nodes in the selection, abort
|
|
if ( !startContainer ) {
|
|
return range;
|
|
}
|
|
|
|
do {
|
|
node = walker.currentNode;
|
|
needsFormat = !getNearest( node, root, tag, attributes );
|
|
if ( needsFormat ) {
|
|
// <br> can never be a container node, so must have a text node
|
|
// if node == (end|start)Container
|
|
if ( node === endContainer && node.length > endOffset ) {
|
|
node.splitText( endOffset );
|
|
}
|
|
if ( node === startContainer && startOffset ) {
|
|
node = node.splitText( startOffset );
|
|
if ( endContainer === startContainer ) {
|
|
endContainer = node;
|
|
endOffset -= startOffset;
|
|
}
|
|
startContainer = node;
|
|
startOffset = 0;
|
|
}
|
|
el = this.createElement( tag, attributes );
|
|
replaceWith( node, el );
|
|
el.appendChild( node );
|
|
}
|
|
} while ( walker.nextNode() );
|
|
|
|
// If we don't finish inside a text node, offset may have changed.
|
|
if ( endContainer.nodeType !== TEXT_NODE ) {
|
|
if ( node.nodeType === TEXT_NODE ) {
|
|
endContainer = node;
|
|
endOffset = node.length;
|
|
} else {
|
|
// If <br>, we must have just wrapped it, so it must have only
|
|
// one child
|
|
endContainer = node.parentNode;
|
|
endOffset = 1;
|
|
}
|
|
}
|
|
|
|
// Now set the selection to as it was before
|
|
range = this._createRange(
|
|
startContainer, startOffset, endContainer, endOffset );
|
|
}
|
|
return range;
|
|
};
|
|
|
|
proto._removeFormat = function ( tag, attributes, range, partial ) {
|
|
// Add bookmark
|
|
this._saveRangeToBookmark( range );
|
|
|
|
// We need a node in the selection to break the surrounding
|
|
// formatted text.
|
|
var doc = this._doc,
|
|
fixer;
|
|
if ( range.collapsed ) {
|
|
if ( cantFocusEmptyTextNodes ) {
|
|
fixer = doc.createTextNode( ZWS );
|
|
this._didAddZWS();
|
|
} else {
|
|
fixer = doc.createTextNode( '' );
|
|
}
|
|
insertNodeInRange( range, fixer );
|
|
}
|
|
|
|
// Find block-level ancestor of selection
|
|
var root = range.commonAncestorContainer;
|
|
while ( isInline( root ) ) {
|
|
root = root.parentNode;
|
|
}
|
|
|
|
// Find text nodes inside formatTags that are not in selection and
|
|
// add an extra tag with the same formatting.
|
|
var startContainer = range.startContainer,
|
|
startOffset = range.startOffset,
|
|
endContainer = range.endContainer,
|
|
endOffset = range.endOffset,
|
|
toWrap = [],
|
|
examineNode = function ( node, exemplar ) {
|
|
// If the node is completely contained by the range then
|
|
// we're going to remove all formatting so ignore it.
|
|
if ( isNodeContainedInRange( range, node, false ) ) {
|
|
return;
|
|
}
|
|
|
|
var isText = ( node.nodeType === TEXT_NODE ),
|
|
child, next;
|
|
|
|
// If not at least partially contained, wrap entire contents
|
|
// in a clone of the tag we're removing and we're done.
|
|
if ( !isNodeContainedInRange( range, node, true ) ) {
|
|
// Ignore bookmarks and empty text nodes
|
|
if ( node.nodeName !== 'INPUT' &&
|
|
( !isText || node.data ) ) {
|
|
toWrap.push([ exemplar, node ]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Split any partially selected text nodes.
|
|
if ( isText ) {
|
|
if ( node === endContainer && endOffset !== node.length ) {
|
|
toWrap.push([ exemplar, node.splitText( endOffset ) ]);
|
|
}
|
|
if ( node === startContainer && startOffset ) {
|
|
node.splitText( startOffset );
|
|
toWrap.push([ exemplar, node ]);
|
|
}
|
|
}
|
|
// If not a text node, recurse onto all children.
|
|
// Beware, the tree may be rewritten with each call
|
|
// to examineNode, hence find the next sibling first.
|
|
else {
|
|
for ( child = node.firstChild; child; child = next ) {
|
|
next = child.nextSibling;
|
|
examineNode( child, exemplar );
|
|
}
|
|
}
|
|
},
|
|
formatTags = Array.prototype.filter.call(
|
|
root.getElementsByTagName( tag ), function ( el ) {
|
|
return isNodeContainedInRange( range, el, true ) &&
|
|
hasTagAttributes( el, tag, attributes );
|
|
}
|
|
);
|
|
|
|
if ( !partial ) {
|
|
formatTags.forEach( function ( node ) {
|
|
examineNode( node, node );
|
|
});
|
|
}
|
|
|
|
// Now wrap unselected nodes in the tag
|
|
toWrap.forEach( function ( item ) {
|
|
// [ exemplar, node ] tuple
|
|
var el = item[0].cloneNode( false ),
|
|
node = item[1];
|
|
replaceWith( node, el );
|
|
el.appendChild( node );
|
|
});
|
|
// and remove old formatting tags.
|
|
formatTags.forEach( function ( el ) {
|
|
replaceWith( el, empty( el ) );
|
|
});
|
|
|
|
// Merge adjacent inlines:
|
|
this._getRangeAndRemoveBookmark( range );
|
|
if ( fixer ) {
|
|
range.collapse( false );
|
|
}
|
|
var _range = {
|
|
startContainer: range.startContainer,
|
|
startOffset: range.startOffset,
|
|
endContainer: range.endContainer,
|
|
endOffset: range.endOffset
|
|
};
|
|
mergeInlines( root, _range );
|
|
range.setStart( _range.startContainer, _range.startOffset );
|
|
range.setEnd( _range.endContainer, _range.endOffset );
|
|
|
|
return range;
|
|
};
|
|
|
|
proto.changeFormat = function ( add, remove, range, partial ) {
|
|
// Normalise the arguments and get selection
|
|
if ( !range && !( range = this.getSelection() ) ) {
|
|
return;
|
|
}
|
|
|
|
// Save undo checkpoint
|
|
this.saveUndoState( range );
|
|
|
|
if ( remove ) {
|
|
range = this._removeFormat( remove.tag.toUpperCase(),
|
|
remove.attributes || {}, range, partial );
|
|
}
|
|
if ( add ) {
|
|
range = this._addFormat( add.tag.toUpperCase(),
|
|
add.attributes || {}, range );
|
|
}
|
|
|
|
this.setSelection( range );
|
|
this._updatePath( range, true );
|
|
|
|
// We're not still in an undo state
|
|
if ( !canObserveMutations ) {
|
|
this._docWasChanged();
|
|
}
|
|
|
|
return this;
|
|
};
|
|
|
|
// --- Block formatting ---
|
|
|
|
var tagAfterSplit = {
|
|
DT: 'DD',
|
|
DD: 'DT',
|
|
LI: 'LI'
|
|
};
|
|
|
|
var splitBlock = function ( self, block, node, offset ) {
|
|
var splitTag = tagAfterSplit[ block.nodeName ],
|
|
splitProperties = null,
|
|
nodeAfterSplit = split( node, offset, block.parentNode, self._root ),
|
|
config = self._config;
|
|
|
|
if ( !splitTag ) {
|
|
splitTag = config.blockTag;
|
|
splitProperties = config.blockAttributes;
|
|
}
|
|
|
|
// Make sure the new node is the correct type.
|
|
if ( !hasTagAttributes( nodeAfterSplit, splitTag, splitProperties ) ) {
|
|
block = createElement( nodeAfterSplit.ownerDocument,
|
|
splitTag, splitProperties );
|
|
if ( nodeAfterSplit.dir ) {
|
|
block.dir = nodeAfterSplit.dir;
|
|
}
|
|
replaceWith( nodeAfterSplit, block );
|
|
block.appendChild( empty( nodeAfterSplit ) );
|
|
nodeAfterSplit = block;
|
|
}
|
|
return nodeAfterSplit;
|
|
};
|
|
|
|
proto.forEachBlock = function ( fn, mutates, range ) {
|
|
if ( !range && !( range = this.getSelection() ) ) {
|
|
return this;
|
|
}
|
|
|
|
// Save undo checkpoint
|
|
if ( mutates ) {
|
|
this.saveUndoState( range );
|
|
}
|
|
|
|
var root = this._root;
|
|
var start = getStartBlockOfRange( range, root );
|
|
var end = getEndBlockOfRange( range, root );
|
|
if ( start && end ) {
|
|
do {
|
|
if ( fn( start ) || start === end ) { break; }
|
|
} while ( start = getNextBlock( start, root ) );
|
|
}
|
|
|
|
if ( mutates ) {
|
|
this.setSelection( range );
|
|
|
|
// Path may have changed
|
|
this._updatePath( range, true );
|
|
|
|
// We're not still in an undo state
|
|
if ( !canObserveMutations ) {
|
|
this._docWasChanged();
|
|
}
|
|
}
|
|
return this;
|
|
};
|
|
|
|
proto.modifyBlocks = function ( modify, range ) {
|
|
if ( !range && !( range = this.getSelection() ) ) {
|
|
return this;
|
|
}
|
|
|
|
// 1. Save undo checkpoint and bookmark selection
|
|
if ( this._isInUndoState ) {
|
|
this._saveRangeToBookmark( range );
|
|
} else {
|
|
this._recordUndoState( range );
|
|
}
|
|
|
|
var root = this._root;
|
|
var frag;
|
|
|
|
// 2. Expand range to block boundaries
|
|
expandRangeToBlockBoundaries( range, root );
|
|
|
|
// 3. Remove range.
|
|
moveRangeBoundariesUpTree( range, root );
|
|
frag = extractContentsOfRange( range, root, root );
|
|
|
|
// 4. Modify tree of fragment and reinsert.
|
|
insertNodeInRange( range, modify.call( this, frag ) );
|
|
|
|
// 5. Merge containers at edges
|
|
if ( range.endOffset < range.endContainer.childNodes.length ) {
|
|
mergeContainers( range.endContainer.childNodes[ range.endOffset ], root );
|
|
}
|
|
mergeContainers( range.startContainer.childNodes[ range.startOffset ], root );
|
|
|
|
// 6. Restore selection
|
|
this._getRangeAndRemoveBookmark( range );
|
|
this.setSelection( range );
|
|
this._updatePath( range, true );
|
|
|
|
// 7. We're not still in an undo state
|
|
if ( !canObserveMutations ) {
|
|
this._docWasChanged();
|
|
}
|
|
|
|
return this;
|
|
};
|
|
|
|
var increaseBlockQuoteLevel = function ( frag ) {
|
|
return this.createElement( 'BLOCKQUOTE',
|
|
this._config.tagAttributes.blockquote, [
|
|
frag
|
|
]);
|
|
};
|
|
|
|
var decreaseBlockQuoteLevel = function ( frag ) {
|
|
var root = this._root;
|
|
var blockquotes = frag.querySelectorAll( 'blockquote' );
|
|
Array.prototype.filter.call( blockquotes, function ( el ) {
|
|
return !getNearest( el.parentNode, root, 'BLOCKQUOTE' );
|
|
}).forEach( function ( el ) {
|
|
replaceWith( el, empty( el ) );
|
|
});
|
|
return frag;
|
|
};
|
|
|
|
var removeBlockQuote = function (/* frag */) {
|
|
return this.createDefaultBlock([
|
|
this.createElement( 'INPUT', {
|
|
id: startSelectionId,
|
|
type: 'hidden'
|
|
}),
|
|
this.createElement( 'INPUT', {
|
|
id: endSelectionId,
|
|
type: 'hidden'
|
|
})
|
|
]);
|
|
};
|
|
|
|
var makeList = function ( self, frag, type ) {
|
|
var walker = getBlockWalker( frag, self._root ),
|
|
node, tag, prev, newLi,
|
|
tagAttributes = self._config.tagAttributes,
|
|
listAttrs = tagAttributes[ type.toLowerCase() ],
|
|
listItemAttrs = tagAttributes.li;
|
|
|
|
while ( node = walker.nextNode() ) {
|
|
tag = node.parentNode.nodeName;
|
|
if ( tag !== 'LI' ) {
|
|
newLi = self.createElement( 'LI', listItemAttrs );
|
|
if ( node.dir ) {
|
|
newLi.dir = node.dir;
|
|
}
|
|
|
|
// Have we replaced the previous block with a new <ul>/<ol>?
|
|
if ( ( prev = node.previousSibling ) &&
|
|
prev.nodeName === type ) {
|
|
prev.appendChild( newLi );
|
|
}
|
|
// Otherwise, replace this block with the <ul>/<ol>
|
|
else {
|
|
replaceWith(
|
|
node,
|
|
self.createElement( type, listAttrs, [
|
|
newLi
|
|
])
|
|
);
|
|
}
|
|
newLi.appendChild( node );
|
|
} else {
|
|
node = node.parentNode.parentNode;
|
|
tag = node.nodeName;
|
|
if ( tag !== type && ( /^[OU]L$/.test( tag ) ) ) {
|
|
replaceWith( node,
|
|
self.createElement( type, listAttrs, [ empty( node ) ] )
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
var makeUnorderedList = function ( frag ) {
|
|
makeList( this, frag, 'UL' );
|
|
return frag;
|
|
};
|
|
|
|
var makeOrderedList = function ( frag ) {
|
|
makeList( this, frag, 'OL' );
|
|
return frag;
|
|
};
|
|
|
|
var removeList = function ( frag ) {
|
|
var lists = frag.querySelectorAll( 'UL, OL' ),
|
|
i, l, ll, list, listFrag, children, child;
|
|
for ( i = 0, l = lists.length; i < l; i += 1 ) {
|
|
list = lists[i];
|
|
listFrag = empty( list );
|
|
children = listFrag.childNodes;
|
|
ll = children.length;
|
|
while ( ll-- ) {
|
|
child = children[ll];
|
|
replaceWith( child, empty( child ) );
|
|
}
|
|
fixContainer( listFrag, this._root );
|
|
replaceWith( list, listFrag );
|
|
}
|
|
return frag;
|
|
};
|
|
|
|
var increaseListLevel = function ( frag ) {
|
|
var items = frag.querySelectorAll( 'LI' ),
|
|
i, l, item,
|
|
type, newParent,
|
|
tagAttributes = this._config.tagAttributes,
|
|
listItemAttrs = tagAttributes.li,
|
|
listAttrs;
|
|
for ( i = 0, l = items.length; i < l; i += 1 ) {
|
|
item = items[i];
|
|
if ( !isContainer( item.firstChild ) ) {
|
|
// type => 'UL' or 'OL'
|
|
type = item.parentNode.nodeName;
|
|
newParent = item.previousSibling;
|
|
if ( !newParent || !( newParent = newParent.lastChild ) ||
|
|
newParent.nodeName !== type ) {
|
|
listAttrs = tagAttributes[ type.toLowerCase() ];
|
|
replaceWith(
|
|
item,
|
|
this.createElement( 'LI', listItemAttrs, [
|
|
newParent = this.createElement( type, listAttrs )
|
|
])
|
|
);
|
|
}
|
|
newParent.appendChild( item );
|
|
}
|
|
}
|
|
return frag;
|
|
};
|
|
|
|
var decreaseListLevel = function ( frag ) {
|
|
var root = this._root;
|
|
var items = frag.querySelectorAll( 'LI' );
|
|
Array.prototype.filter.call( items, function ( el ) {
|
|
return !isContainer( el.firstChild );
|
|
}).forEach( function ( item ) {
|
|
var parent = item.parentNode,
|
|
newParent = parent.parentNode,
|
|
first = item.firstChild,
|
|
node = first,
|
|
next;
|
|
if ( item.previousSibling ) {
|
|
parent = split( parent, item, newParent, root );
|
|
}
|
|
while ( node ) {
|
|
next = node.nextSibling;
|
|
if ( isContainer( node ) ) {
|
|
break;
|
|
}
|
|
newParent.insertBefore( node, parent );
|
|
node = next;
|
|
}
|
|
if ( newParent.nodeName === 'LI' && first.previousSibling ) {
|
|
split( newParent, first, newParent.parentNode, root );
|
|
}
|
|
while ( item !== frag && !item.childNodes.length ) {
|
|
parent = item.parentNode;
|
|
parent.removeChild( item );
|
|
item = parent;
|
|
}
|
|
}, this );
|
|
fixContainer( frag, root );
|
|
return frag;
|
|
};
|
|
|
|
proto._ensureBottomLine = function () {
|
|
var root = this._root;
|
|
var last = root.lastElementChild;
|
|
if ( !last ||
|
|
last.nodeName !== this._config.blockTag || !isBlock( last ) ) {
|
|
root.appendChild( this.createDefaultBlock() );
|
|
}
|
|
};
|
|
|
|
// --- Keyboard interaction ---
|
|
|
|
proto.setKeyHandler = function ( key, fn ) {
|
|
this._keyHandlers[ key ] = fn;
|
|
return this;
|
|
};
|
|
|
|
// --- Get/Set data ---
|
|
|
|
proto._getHTML = function () {
|
|
return this._root.innerHTML;
|
|
};
|
|
|
|
proto._setHTML = function ( html ) {
|
|
var root = this._root;
|
|
var node = root;
|
|
node.innerHTML = html;
|
|
do {
|
|
fixCursor( node, root );
|
|
} while ( node = getNextBlock( node, root ) );
|
|
this._ignoreChange = true;
|
|
};
|
|
|
|
proto.getHTML = function ( withBookMark ) {
|
|
var brs = [],
|
|
root, node, fixer, html, l, range;
|
|
if ( withBookMark && ( range = this.getSelection() ) ) {
|
|
this._saveRangeToBookmark( range );
|
|
}
|
|
if ( useTextFixer ) {
|
|
root = this._root;
|
|
node = root;
|
|
while ( node = getNextBlock( node, root ) ) {
|
|
if ( !node.textContent && !node.querySelector( 'BR' ) ) {
|
|
fixer = this.createElement( 'BR' );
|
|
node.appendChild( fixer );
|
|
brs.push( fixer );
|
|
}
|
|
}
|
|
}
|
|
html = this._getHTML().replace( /\u200B/g, '' );
|
|
if ( useTextFixer ) {
|
|
l = brs.length;
|
|
while ( l-- ) {
|
|
detach( brs[l] );
|
|
}
|
|
}
|
|
if ( range ) {
|
|
this._getRangeAndRemoveBookmark( range );
|
|
}
|
|
return html;
|
|
};
|
|
|
|
proto.setHTML = function ( html ) {
|
|
var frag = this._doc.createDocumentFragment();
|
|
var div = this.createElement( 'DIV' );
|
|
var root = this._root;
|
|
var child;
|
|
|
|
// Parse HTML into DOM tree
|
|
div.innerHTML = html;
|
|
frag.appendChild( empty( div ) );
|
|
|
|
cleanTree( frag );
|
|
cleanupBRs( frag, root );
|
|
|
|
fixContainer( frag, root );
|
|
|
|
// Fix cursor
|
|
var node = frag;
|
|
while ( node = getNextBlock( node, root ) ) {
|
|
fixCursor( node, root );
|
|
}
|
|
|
|
// Don't fire an input event
|
|
this._ignoreChange = true;
|
|
|
|
// Remove existing root children
|
|
while ( child = root.lastChild ) {
|
|
root.removeChild( child );
|
|
}
|
|
|
|
// And insert new content
|
|
root.appendChild( frag );
|
|
fixCursor( root, root );
|
|
|
|
// Reset the undo stack
|
|
this._undoIndex = -1;
|
|
this._undoStack.length = 0;
|
|
this._undoStackLength = 0;
|
|
this._isInUndoState = false;
|
|
|
|
// Record undo state
|
|
var range = this._getRangeAndRemoveBookmark() ||
|
|
this._createRange( root.firstChild, 0 );
|
|
this.saveUndoState( range );
|
|
// IE will also set focus when selecting text so don't use
|
|
// setSelection. Instead, just store it in lastSelection, so if
|
|
// anything calls getSelection before first focus, we have a range
|
|
// to return.
|
|
this._lastSelection = range;
|
|
this._updatePath( range, true );
|
|
|
|
return this;
|
|
};
|
|
|
|
proto.insertElement = function ( el, range ) {
|
|
if ( !range ) { range = this.getSelection(); }
|
|
range.collapse( true );
|
|
if ( isInline( el ) ) {
|
|
insertNodeInRange( range, el );
|
|
range.setStartAfter( el );
|
|
} else {
|
|
// Get containing block node.
|
|
var root = this._root;
|
|
var splitNode = getStartBlockOfRange( range, root ) || root;
|
|
var parent, nodeAfterSplit;
|
|
// While at end of container node, move up DOM tree.
|
|
while ( splitNode !== root && !splitNode.nextSibling ) {
|
|
splitNode = splitNode.parentNode;
|
|
}
|
|
// If in the middle of a container node, split up to root.
|
|
if ( splitNode !== root ) {
|
|
parent = splitNode.parentNode;
|
|
nodeAfterSplit = split( parent, splitNode.nextSibling, root, root );
|
|
}
|
|
if ( nodeAfterSplit ) {
|
|
root.insertBefore( el, nodeAfterSplit );
|
|
} else {
|
|
root.appendChild( el );
|
|
// Insert blank line below block.
|
|
nodeAfterSplit = this.createDefaultBlock();
|
|
root.appendChild( nodeAfterSplit );
|
|
}
|
|
range.setStart( nodeAfterSplit, 0 );
|
|
range.setEnd( nodeAfterSplit, 0 );
|
|
moveRangeBoundariesDownTree( range );
|
|
}
|
|
this.focus();
|
|
this.setSelection( range );
|
|
this._updatePath( range );
|
|
return this;
|
|
};
|
|
|
|
proto.insertImage = function ( src, attributes ) {
|
|
var img = this.createElement( 'IMG', mergeObjects({
|
|
src: src
|
|
}, attributes ));
|
|
this.insertElement( img );
|
|
return img;
|
|
};
|
|
|
|
var linkRegExp = /\b((?:(?:ht|f)tps?:\/\/|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,}\/)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\((?:[^\s()<>]+|(?:\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))|([\w\-.%+]+@(?:[\w\-]+\.)+[A-Z]{2,}\b)/i;
|
|
|
|
var addLinks = function ( frag, root ) {
|
|
var doc = frag.ownerDocument,
|
|
walker = new TreeWalker( frag, SHOW_TEXT,
|
|
function ( node ) {
|
|
return !getNearest( node, root, 'A' );
|
|
}, false ),
|
|
node, data, parent, match, index, endIndex, child;
|
|
while ( node = walker.nextNode() ) {
|
|
data = node.data;
|
|
parent = node.parentNode;
|
|
while ( match = linkRegExp.exec( data ) ) {
|
|
index = match.index;
|
|
endIndex = index + match[0].length;
|
|
if ( index ) {
|
|
child = doc.createTextNode( data.slice( 0, index ) );
|
|
parent.insertBefore( child, node );
|
|
}
|
|
child = doc.createElement( 'A' );
|
|
child.textContent = data.slice( index, endIndex );
|
|
child.href = match[1] ?
|
|
/^(?:ht|f)tps?:/.test( match[1] ) ?
|
|
match[1] :
|
|
'http://' + match[1] :
|
|
'mailto:' + match[2];
|
|
parent.insertBefore( child, node );
|
|
node.data = data = data.slice( endIndex );
|
|
}
|
|
}
|
|
};
|
|
|
|
// Insert HTML at the cursor location. If the selection is not collapsed
|
|
// insertTreeFragmentIntoRange will delete the selection so that it is replaced
|
|
// by the html being inserted.
|
|
proto.insertHTML = function ( html, isPaste ) {
|
|
var range = this.getSelection(),
|
|
frag = this._doc.createDocumentFragment(),
|
|
div = this.createElement( 'DIV' );
|
|
|
|
// Parse HTML into DOM tree
|
|
div.innerHTML = html;
|
|
frag.appendChild( empty( div ) );
|
|
|
|
// Record undo checkpoint
|
|
this.saveUndoState( range );
|
|
|
|
try {
|
|
var root = this._root;
|
|
var node = frag;
|
|
var event = {
|
|
fragment: frag,
|
|
preventDefault: function () {
|
|
this.defaultPrevented = true;
|
|
},
|
|
defaultPrevented: false
|
|
};
|
|
|
|
addLinks( frag, root );
|
|
cleanTree( frag );
|
|
cleanupBRs( frag, root );
|
|
removeEmptyInlines( frag );
|
|
frag.normalize();
|
|
|
|
while ( node = getNextBlock( node, root ) ) {
|
|
fixCursor( node, root );
|
|
}
|
|
|
|
if ( isPaste ) {
|
|
this.fireEvent( 'willPaste', event );
|
|
}
|
|
|
|
if ( !event.defaultPrevented ) {
|
|
insertTreeFragmentIntoRange( range, event.fragment, root );
|
|
if ( !canObserveMutations ) {
|
|
this._docWasChanged();
|
|
}
|
|
range.collapse( false );
|
|
this._ensureBottomLine();
|
|
}
|
|
|
|
this.setSelection( range );
|
|
this._updatePath( range, true );
|
|
} catch ( error ) {
|
|
this.didError( error );
|
|
}
|
|
return this;
|
|
};
|
|
|
|
proto.insertPlainText = function ( plainText, isPaste ) {
|
|
var lines = plainText.split( '\n' ),
|
|
i, l, line;
|
|
for ( i = 0, l = lines.length; i < l; i += 1 ) {
|
|
line = lines[i];
|
|
line = line.split( '&' ).join( '&' )
|
|
.split( '<' ).join( '<' )
|
|
.split( '>' ).join( '>' )
|
|
.replace( / (?= )/g, ' ' );
|
|
// Wrap all but first/last lines in <div></div>
|
|
if ( i && i + 1 < l ) {
|
|
line = '<DIV>' + ( line || '<BR>' ) + '</DIV>';
|
|
}
|
|
lines[i] = line;
|
|
}
|
|
return this.insertHTML( lines.join( '' ), isPaste );
|
|
};
|
|
|
|
// --- Formatting ---
|
|
|
|
var command = function ( method, arg, arg2 ) {
|
|
return function () {
|
|
this[ method ]( arg, arg2 );
|
|
return this.focus();
|
|
};
|
|
};
|
|
|
|
proto.addStyles = function ( styles ) {
|
|
if ( styles ) {
|
|
var head = this._doc.documentElement.firstChild,
|
|
style = this.createElement( 'STYLE', {
|
|
type: 'text/css'
|
|
});
|
|
style.appendChild( this._doc.createTextNode( styles ) );
|
|
head.appendChild( style );
|
|
}
|
|
return this;
|
|
};
|
|
|
|
proto.bold = command( 'changeFormat', { tag: 'B' } );
|
|
proto.italic = command( 'changeFormat', { tag: 'I' } );
|
|
proto.underline = command( 'changeFormat', { tag: 'U' } );
|
|
proto.strikethrough = command( 'changeFormat', { tag: 'S' } );
|
|
proto.subscript = command( 'changeFormat', { tag: 'SUB' }, { tag: 'SUP' } );
|
|
proto.superscript = command( 'changeFormat', { tag: 'SUP' }, { tag: 'SUB' } );
|
|
|
|
proto.removeBold = command( 'changeFormat', null, { tag: 'B' } );
|
|
proto.removeItalic = command( 'changeFormat', null, { tag: 'I' } );
|
|
proto.removeUnderline = command( 'changeFormat', null, { tag: 'U' } );
|
|
proto.removeStrikethrough = command( 'changeFormat', null, { tag: 'S' } );
|
|
proto.removeSubscript = command( 'changeFormat', null, { tag: 'SUB' } );
|
|
proto.removeSuperscript = command( 'changeFormat', null, { tag: 'SUP' } );
|
|
|
|
proto.makeLink = function ( url, attributes ) {
|
|
var range = this.getSelection();
|
|
if ( range.collapsed ) {
|
|
var protocolEnd = url.indexOf( ':' ) + 1;
|
|
if ( protocolEnd ) {
|
|
while ( url[ protocolEnd ] === '/' ) { protocolEnd += 1; }
|
|
}
|
|
insertNodeInRange(
|
|
range,
|
|
this._doc.createTextNode( url.slice( protocolEnd ) )
|
|
);
|
|
}
|
|
|
|
if ( !attributes ) {
|
|
attributes = {};
|
|
}
|
|
attributes.href = url;
|
|
|
|
this.changeFormat({
|
|
tag: 'A',
|
|
attributes: attributes
|
|
}, {
|
|
tag: 'A'
|
|
}, range );
|
|
return this.focus();
|
|
};
|
|
proto.removeLink = function () {
|
|
this.changeFormat( null, {
|
|
tag: 'A'
|
|
}, this.getSelection(), true );
|
|
return this.focus();
|
|
};
|
|
|
|
proto.setFontFace = function ( name ) {
|
|
this.changeFormat({
|
|
tag: 'SPAN',
|
|
attributes: {
|
|
'class': 'font',
|
|
style: 'font-family: ' + name + ', sans-serif;'
|
|
}
|
|
}, {
|
|
tag: 'SPAN',
|
|
attributes: { 'class': 'font' }
|
|
});
|
|
return this.focus();
|
|
};
|
|
proto.setFontSize = function ( size ) {
|
|
this.changeFormat({
|
|
tag: 'SPAN',
|
|
attributes: {
|
|
'class': 'size',
|
|
style: 'font-size: ' +
|
|
( typeof size === 'number' ? size + 'px' : size )
|
|
}
|
|
}, {
|
|
tag: 'SPAN',
|
|
attributes: { 'class': 'size' }
|
|
});
|
|
return this.focus();
|
|
};
|
|
|
|
proto.setTextColour = function ( colour ) {
|
|
this.changeFormat({
|
|
tag: 'SPAN',
|
|
attributes: {
|
|
'class': 'colour',
|
|
style: 'color:' + colour
|
|
}
|
|
}, {
|
|
tag: 'SPAN',
|
|
attributes: { 'class': 'colour' }
|
|
});
|
|
return this.focus();
|
|
};
|
|
|
|
proto.setHighlightColour = function ( colour ) {
|
|
this.changeFormat({
|
|
tag: 'SPAN',
|
|
attributes: {
|
|
'class': 'highlight',
|
|
style: 'background-color:' + colour
|
|
}
|
|
}, {
|
|
tag: 'SPAN',
|
|
attributes: { 'class': 'highlight' }
|
|
});
|
|
return this.focus();
|
|
};
|
|
|
|
proto.setTextAlignment = function ( alignment ) {
|
|
this.forEachBlock( function ( block ) {
|
|
block.className = ( block.className
|
|
.split( /\s+/ )
|
|
.filter( function ( klass ) {
|
|
return !( /align/.test( klass ) );
|
|
})
|
|
.join( ' ' ) +
|
|
' align-' + alignment ).trim();
|
|
block.style.textAlign = alignment;
|
|
}, true );
|
|
return this.focus();
|
|
};
|
|
|
|
proto.setTextDirection = function ( direction ) {
|
|
this.forEachBlock( function ( block ) {
|
|
block.dir = direction;
|
|
}, true );
|
|
return this.focus();
|
|
};
|
|
|
|
function removeFormatting ( self, root, clean ) {
|
|
var node, next;
|
|
for ( node = root.firstChild; node; node = next ) {
|
|
next = node.nextSibling;
|
|
if ( isInline( node ) ) {
|
|
if ( node.nodeType === TEXT_NODE || node.nodeName === 'BR' || node.nodeName === 'IMG' ) {
|
|
clean.appendChild( node );
|
|
continue;
|
|
}
|
|
} else if ( isBlock( node ) ) {
|
|
clean.appendChild( self.createDefaultBlock([
|
|
removeFormatting(
|
|
self, node, self._doc.createDocumentFragment() )
|
|
]));
|
|
continue;
|
|
}
|
|
removeFormatting( self, node, clean );
|
|
}
|
|
return clean;
|
|
}
|
|
|
|
proto.removeAllFormatting = function ( range ) {
|
|
if ( !range && !( range = this.getSelection() ) || range.collapsed ) {
|
|
return this;
|
|
}
|
|
|
|
var root = this._root;
|
|
var stopNode = range.commonAncestorContainer;
|
|
while ( stopNode && !isBlock( stopNode ) ) {
|
|
stopNode = stopNode.parentNode;
|
|
}
|
|
if ( !stopNode ) {
|
|
expandRangeToBlockBoundaries( range, root );
|
|
stopNode = root;
|
|
}
|
|
if ( stopNode.nodeType === TEXT_NODE ) {
|
|
return this;
|
|
}
|
|
|
|
// Record undo point
|
|
this.saveUndoState( range );
|
|
|
|
// Avoid splitting where we're already at edges.
|
|
moveRangeBoundariesUpTree( range, stopNode );
|
|
|
|
// Split the selection up to the block, or if whole selection in same
|
|
// block, expand range boundaries to ends of block and split up to root.
|
|
var doc = stopNode.ownerDocument;
|
|
var startContainer = range.startContainer;
|
|
var startOffset = range.startOffset;
|
|
var endContainer = range.endContainer;
|
|
var endOffset = range.endOffset;
|
|
|
|
// Split end point first to avoid problems when end and start
|
|
// in same container.
|
|
var formattedNodes = doc.createDocumentFragment();
|
|
var cleanNodes = doc.createDocumentFragment();
|
|
var nodeAfterSplit = split( endContainer, endOffset, stopNode, root );
|
|
var nodeInSplit = split( startContainer, startOffset, stopNode, root );
|
|
var nextNode, _range, childNodes;
|
|
|
|
// Then replace contents in split with a cleaned version of the same:
|
|
// blocks become default blocks, text and leaf nodes survive, everything
|
|
// else is obliterated.
|
|
while ( nodeInSplit !== nodeAfterSplit ) {
|
|
nextNode = nodeInSplit.nextSibling;
|
|
formattedNodes.appendChild( nodeInSplit );
|
|
nodeInSplit = nextNode;
|
|
}
|
|
removeFormatting( this, formattedNodes, cleanNodes );
|
|
cleanNodes.normalize();
|
|
nodeInSplit = cleanNodes.firstChild;
|
|
nextNode = cleanNodes.lastChild;
|
|
|
|
// Restore selection
|
|
childNodes = stopNode.childNodes;
|
|
if ( nodeInSplit ) {
|
|
stopNode.insertBefore( cleanNodes, nodeAfterSplit );
|
|
startOffset = indexOf.call( childNodes, nodeInSplit );
|
|
endOffset = indexOf.call( childNodes, nextNode ) + 1;
|
|
} else {
|
|
startOffset = indexOf.call( childNodes, nodeAfterSplit );
|
|
endOffset = startOffset;
|
|
}
|
|
|
|
// Merge text nodes at edges, if possible
|
|
_range = {
|
|
startContainer: stopNode,
|
|
startOffset: startOffset,
|
|
endContainer: stopNode,
|
|
endOffset: endOffset
|
|
};
|
|
mergeInlines( stopNode, _range );
|
|
range.setStart( _range.startContainer, _range.startOffset );
|
|
range.setEnd( _range.endContainer, _range.endOffset );
|
|
|
|
// And move back down the tree
|
|
moveRangeBoundariesDownTree( range );
|
|
|
|
this.setSelection( range );
|
|
this._updatePath( range, true );
|
|
|
|
return this.focus();
|
|
};
|
|
|
|
proto.increaseQuoteLevel = command( 'modifyBlocks', increaseBlockQuoteLevel );
|
|
proto.decreaseQuoteLevel = command( 'modifyBlocks', decreaseBlockQuoteLevel );
|
|
|
|
proto.makeUnorderedList = command( 'modifyBlocks', makeUnorderedList );
|
|
proto.makeOrderedList = command( 'modifyBlocks', makeOrderedList );
|
|
proto.removeList = command( 'modifyBlocks', removeList );
|
|
|
|
proto.increaseListLevel = command( 'modifyBlocks', increaseListLevel );
|
|
proto.decreaseListLevel = command( 'modifyBlocks', decreaseListLevel );
|
|
|
|
if ( typeof exports === 'object' ) {
|
|
module.exports = Squire;
|
|
} else if ( typeof define === 'function' && define.amd ) {
|
|
define( function () {
|
|
return Squire;
|
|
});
|
|
} else {
|
|
win.Squire = Squire;
|
|
|
|
if ( top !== win &&
|
|
doc.documentElement.getAttribute( 'data-squireinit' ) === 'true' ) {
|
|
win.editor = new Squire( doc );
|
|
if ( win.onEditorLoad ) {
|
|
win.onEditorLoad( win.editor );
|
|
win.onEditorLoad = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
}( document ) );
|