0
Fork 0
mirror of https://github.com/fastmail/Squire.git synced 2024-12-22 15:23:29 -05:00
Squire/source/Editor.js
2014-10-02 16:36:39 +07:00

2332 lines
74 KiB
JavaScript

/*jshint strict:false, undef:false, unused:false */
var instances = [];
function Squire ( doc ) {
var win = doc.defaultView;
var body = doc.body;
this._win = win;
this._doc = doc;
this._body = body;
this._events = {};
this._sel = win.getSelection();
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 );
win.addEventListener( 'focus', this, false );
win.addEventListener( 'blur', this, false );
this._undoIndex = -1;
this._undoStack = [];
this._undoStackLength = 0;
this._isInUndoState = false;
this.defaultBlockProperties = undefined;
this.addEventListener( 'keyup', this._docWasChanged );
// 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( isIE8or9or10 ? 'beforecut' : 'cut', this._onCut );
this.addEventListener( isIE8or9or10 ? 'beforepaste' : 'paste', this._onPaste );
if ( isIE8 ) {
this.addEventListener( 'keyup', this._ieSelAllClean );
}
// Opera does not fire keydown repeatedly.
this.addEventListener( isPresto ? 'keypress' : 'keydown', this._onKey );
// Fix IE8/9'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.
if ( hasBuggySplit( doc ) ) {
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;
};
}
body.setAttribute( 'contenteditable', 'true' );
this.setHTML( '' );
// Remove Firefox's built-in controls
try {
doc.execCommand( 'enableObjectResizing', false, 'false' );
doc.execCommand( 'enableInlineTableEditing', false, 'false' );
} catch ( error ) {}
instances.push( this );
}
var proto = Squire.prototype;
proto.createElement = function ( tag, props, children ) {
return createElement( this._doc, tag, props, children );
};
proto.createDefaultBlock = function ( children ) {
return fixCursor(
this.createElement( 'DIV', this.defaultBlockProperties, children )
);
};
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 = {
focus: 1, blur: 1,
pathChange: 1, select: 1, input: 1, undoStateChange: 1
};
proto.fireEvent = function ( type, event ) {
var handlers = this._events[ type ],
i, 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();
for ( i = 0, l = handlers.length; i < l; i += 1 ) {
obj = handlers[i];
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 win = this._win,
doc = this._doc,
events = this._events,
type;
win.removeEventListener( 'focus', this, false );
win.removeEventListener( 'blur', this, false );
for ( type in events ) {
if ( !customEvents[ type ] ) {
doc.removeEventListener( type, this, false );
}
}
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._doc.addEventListener( type, this, false );
}
}
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._doc.removeEventListener( type, this, false );
}
}
}
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.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 = this._sel;
sel.removeAllRanges();
sel.addRange( range );
}
return this;
};
proto.getSelection = function () {
var sel = this._sel,
selection, startContainer, endContainer;
if ( sel.rangeCount ) {
selection = sel.getRangeAt( 0 ).cloneRange();
startContainer = selection.startContainer;
endContainer = selection.endContainer;
// FF sometimes throws an error reading the isLeaf property. Let's
// catch and log it to see if we can find what's going on.
try {
// 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 );
}
} catch ( error ) {
this.didError({
name: 'Squire#getSelection error',
message: 'Starts: ' + startContainer.nodeName +
'\nEnds: ' + endContainer.nodeName
});
}
this._lastSelection = selection;
} else {
selection = this._lastSelection;
}
if ( !selection ) {
selection = this._createRange( this._body.firstChild, 0 );
}
return selection;
};
proto.getSelectedText = function () {
return getTextContentInRange( this.getSelection() );
};
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
proto._didAddZWS = function () {
this._hasZWS = true;
};
proto._removeZWS = function () {
if ( !this._hasZWS ) {
return;
}
var walker = new TreeWalker( this._body, SHOW_TEXT, function () {
return true;
}, false ),
node, index;
while ( node = walker.nextNode() ) {
while ( ( index = node.data.indexOf( '\u200B' ) ) > -1 ) {
node.deleteData( index, 1 );
}
}
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 ) : '(selection)' : '';
if ( this._path !== newPath ) {
this._path = newPath;
this.fireEvent( 'pathChange', { path: newPath } );
}
}
if ( anchor !== focus ) {
this.fireEvent( 'select' );
}
};
proto._updatePathOnEvent = function () {
this._updatePath( this.getSelection() );
};
// --- Focus ---
proto.focus = function () {
// FF seems to need the body to be focussed
// (at least on first load).
if ( isGecko ) {
this._body.focus();
}
this._win.focus();
return this;
};
proto.blur = function () {
// IE will remove the whole browser window from focus if you call
// win.blur() or body.blur(), so instead we call top.focus() to focus
// the top frame, thus blurring this frame. This works in everything
// except FF, so we need to call body.blur() in that as well.
if ( isGecko ) {
this._body.blur();
}
top.focus();
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 doc = this._doc,
start = doc.getElementById( startSelectionId ),
end = doc.getElementById( 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 = 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._docWasChanged = function ( event ) {
var code = event && 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 || ( !event.ctrlKey && !event.metaKey && !event.altKey &&
( code < 16 || code > 20 ) &&
( code < 33 || code > 45 ) ) ) {
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.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;
}
// If the common ancestor is inside the tag we require, we definitely
// have the format.
var root = range.commonAncestorContainer,
walker, node;
if ( getNearest( 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 ( root.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( root, SHOW_TEXT, function ( node ) {
return isNodeContainedInRange( range, node, true );
}, false );
var seenNode = false;
while ( node = walker.nextNode() ) {
if ( !getNearest( node, tag, attributes ) ) {
return false;
}
seenNode = true;
}
return seenNode;
};
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 el, walker, startContainer, endContainer, startOffset, endOffset,
textNode, needsFormat;
if ( range.collapsed ) {
el = fixCursor( this.createElement( tag, attributes ) );
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 {
// We don't want to apply formatting twice so we check each text
// node to see if it has an ancestor with the formatting already.
// Create an iterator to walk over all the text nodes under this
// ancestor which are in the range and not already formatted
// correctly.
walker = new TreeWalker(
range.commonAncestorContainer,
SHOW_TEXT,
function ( node ) {
return 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 inside a text node.
walker.currentNode = startContainer;
if ( startContainer.nodeType !== TEXT_NODE ) {
startContainer = walker.nextNode();
startOffset = 0;
}
do {
textNode = walker.currentNode;
needsFormat = !getNearest( textNode, tag, attributes );
if ( needsFormat ) {
if ( textNode === endContainer &&
textNode.length > endOffset ) {
textNode.splitText( endOffset );
}
if ( textNode === startContainer && startOffset ) {
textNode = textNode.splitText( startOffset );
startContainer = textNode;
startOffset = 0;
}
el = this.createElement( tag, attributes );
replaceWith( textNode, el );
el.appendChild( textNode );
}
} while ( walker.nextNode() );
// Make sure we finish inside a text node. Otherwise offset may have
// changed.
if ( endContainer.nodeType !== TEXT_NODE ) {
endContainer = textNode;
endOffset = textNode.length;
}
// 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( '\u200B' );
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._recordUndoState( range );
this._getRangeAndRemoveBookmark( 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
this._docWasChanged();
return this;
};
// --- Block formatting ---
var tagAfterSplit = {
DIV: 'DIV',
PRE: 'DIV',
H1: 'DIV',
H2: 'DIV',
H3: 'DIV',
H4: 'DIV',
H5: 'DIV',
H6: 'DIV',
P: 'DIV',
DT: 'DD',
DD: 'DT',
LI: 'LI'
};
var splitBlock = function ( block, node, offset ) {
var splitTag = tagAfterSplit[ block.nodeName ],
nodeAfterSplit = split( node, offset, block.parentNode );
// Make sure the new node is the correct type.
if ( nodeAfterSplit.nodeName !== splitTag ) {
block = createElement( nodeAfterSplit.ownerDocument, splitTag );
block.className = nodeAfterSplit.dir === 'rtl' ? 'dir-rtl' : '';
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._recordUndoState( range );
this._getRangeAndRemoveBookmark( range );
}
var start = getStartBlockOfRange( range ),
end = getEndBlockOfRange( range );
if ( start && end ) {
do {
if ( fn( start ) || start === end ) { break; }
} while ( start = getNextBlock( start ) );
}
if ( mutates ) {
this.setSelection( range );
// Path may have changed
this._updatePath( range, true );
// We're not still in an undo state
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 );
}
// 2. Expand range to block boundaries
expandRangeToBlockBoundaries( range );
// 3. Remove range.
var body = this._body,
frag;
moveRangeBoundariesUpTree( range, body );
frag = extractContentsOfRange( range, body );
// 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 ] );
}
mergeContainers( range.startContainer.childNodes[ range.startOffset ] );
// 6. Restore selection
this._getRangeAndRemoveBookmark( range );
this.setSelection( range );
this._updatePath( range, true );
// 7. We're not still in an undo state
this._docWasChanged();
return this;
};
var increaseBlockQuoteLevel = function ( frag ) {
return this.createElement( 'BLOCKQUOTE', [
frag
]);
};
var decreaseBlockQuoteLevel = function ( frag ) {
var blockquotes = frag.querySelectorAll( 'blockquote' );
Array.prototype.filter.call( blockquotes, function ( el ) {
return !getNearest( el.parentNode, '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 ),
node, tag, prev, newLi;
while ( node = walker.nextNode() ) {
tag = node.parentNode.nodeName;
if ( tag !== 'LI' ) {
newLi = self.createElement( 'LI', {
'class': node.dir === 'rtl' ? 'dir-rtl' : undefined,
dir: node.dir || undefined
});
// 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, [
newLi
])
);
}
newLi.appendChild( node );
} else {
node = node.parentNode.parentNode;
tag = node.nodeName;
if ( tag !== type && ( /^[OU]L$/.test( tag ) ) ) {
replaceWith( node,
self.createElement( type, [ 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 );
replaceWith( list, listFrag );
}
return frag;
};
var increaseListLevel = function ( frag ) {
var items = frag.querySelectorAll( 'LI' ),
i, l, item,
type, newParent;
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 ) {
replaceWith(
item,
this.createElement( 'LI', [
newParent = this.createElement( type )
])
);
}
newParent.appendChild( item );
}
}
return frag;
};
var decreaseListLevel = function ( frag ) {
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 );
}
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 );
}
while ( item !== frag && !item.childNodes.length ) {
parent = item.parentNode;
parent.removeChild( item );
item = parent;
}
}, this );
fixContainer( frag );
return frag;
};
// --- Clean ---
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 ) {
var doc = frag.ownerDocument,
walker = new TreeWalker( frag, SHOW_TEXT,
function ( node ) {
return !getNearest( node, '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 );
}
}
};
var allowedBlock = /^(?:A(?:DDRESS|RTICLE|SIDE)|BLOCKQUOTE|CAPTION|D(?:[DLT]|IV)|F(?:IGURE|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 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 removeEmptyInlines = function ( root ) {
var children = root.childNodes,
l = children.length,
child;
while ( l-- ) {
child = children[l];
if ( child.nodeType === ELEMENT_NODE && child.nodeName !== 'IMG' ) {
removeEmptyInlines( child );
if ( isInline( child ) && !child.firstChild ) {
root.removeChild( child );
}
}
}
};
/*
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 ( node, allowStyles ) {
var children = node.childNodes,
i, l, child, nodeName, nodeType, rewriter, childLength,
data, j, ll;
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 ( !allowedBlock.test( nodeName ) &&
!isInline( child ) ) {
i -= 1;
l += childLength - 1;
node.replaceChild( empty( child ), child );
continue;
} else if ( !allowStyles && child.style.cssText ) {
child.removeAttribute( 'style' );
}
if ( childLength ) {
cleanTree( child, allowStyles );
}
} else {
if ( nodeType === TEXT_NODE ) {
data = child.data;
// Use \S instead of notWS, because we want to remove nodes
// which are just nbsp, in order to cleanup <div>nbsp<br></div>
// construct.
if ( /\S/.test( data ) ) {
// If the parent node is inline, don't trim this node as
// it probably isn't at the end of the block.
if ( isInline( node ) ) {
continue;
}
j = 0;
ll = data.length;
if ( !i || !isInline( children[ i - 1 ] ) ) {
while ( j < ll && !notWS.test( data.charAt( j ) ) ) {
j += 1;
}
if ( j ) {
child.data = data = data.slice( j );
ll -= j;
}
}
if ( i + 1 === l || !isInline( children[ i + 1 ] ) ) {
j = ll;
while ( j > 0 && !notWS.test( data.charAt( j - 1 ) ) ) {
j -= 1;
}
if ( j < ll ) {
child.data = data.slice( 0, j );
}
}
continue;
}
// If we have just white space, it may still be important if it
// separates two inline nodes, e.g. "<a>link</a> <a>link</a>".
else if ( i && i + 1 < l &&
isInline( children[ i - 1 ] ) &&
isInline( children[ i + 1 ] ) ) {
child.data = ' ';
continue;
}
}
node.removeChild( child );
i -= 1;
l -= 1;
}
}
return node;
};
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 with a split of the block element containing it (and wrapping
// any not inside a block). 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 ( root ) {
var brs = root.querySelectorAll( 'BR' ),
brBreaksLine = [],
l = brs.length,
i, br, block;
// 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
block = br.parentNode;
if ( !block ) { continue; }
while ( isInline( block ) ) {
block = block.parentNode;
}
// If this is not inside a block, replace it by wrapping
// inlines in a <div>.
if ( !isBlock( block ) ) {
fixContainer( block );
}
else {
// 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, split the block or leave it as
// appropriate.
if ( brBreaksLine[l] ) {
// If in a <div>, split, but anywhere else we might change
// the formatting too much (e.g. <li> -> to two list items!)
// so just play it safe and leave it.
if ( block.nodeName !== 'DIV' ) {
continue;
}
split( br.parentNode, br, block.parentNode );
}
detach( br );
}
}
};
proto._ensureBottomLine = function () {
var body = this._body,
div = body.lastChild;
if ( !div || div.nodeName !== 'DIV' || !isBlock( div ) ) {
body.appendChild( this.createDefaultBlock() );
}
};
// --- Cut and Paste ---
proto._onCut = function () {
// Save undo checkpoint
var range = this.getSelection();
var self = this;
this._recordUndoState( range );
this._getRangeAndRemoveBookmark( range );
this.setSelection( range );
setTimeout( function () {
try {
// If all content removed, ensure div at start of body.
self._ensureBottomLine();
} catch ( error ) {
self.didError( error );
}
}, 0 );
};
proto._onPaste = function ( event ) {
if ( this._awaitingPaste ) { return; }
// Treat image paste as a drop of an image file.
var clipboardData = event.clipboardData,
items = clipboardData && clipboardData.items,
fireDrop = false,
hasImage = false,
l, type;
if ( items ) {
l = items.length;
while ( l-- ) {
type = items[l].type;
if ( type === 'text/html' ) {
hasImage = false;
break;
}
if ( /^image\/.*/.test( type ) ) {
hasImage = true;
}
}
if ( hasImage ) {
event.preventDefault();
this.fireEvent( 'dragover', {
dataTransfer: clipboardData,
/*jshint loopfunc: true */
preventDefault: function () {
fireDrop = true;
}
/*jshint loopfunc: false */
});
if ( fireDrop ) {
this.fireEvent( 'drop', {
dataTransfer: clipboardData
});
}
return;
}
}
this._awaitingPaste = true;
var self = this,
body = this._body,
range = this.getSelection(),
startContainer = range.startContainer,
startOffset = range.startOffset,
endContainer = range.endContainer,
endOffset = range.endOffset,
startBlock = getStartBlockOfRange( range );
// Record undo checkpoint
self._recordUndoState( range );
self._getRangeAndRemoveBookmark( range );
// We need to position the pasteArea in the visible portion of the screen
// to stop the browser auto-scrolling.
var pasteArea = this.createElement( 'DIV', {
style: 'position: absolute; overflow: hidden; top:' +
( body.scrollTop +
( startBlock ? startBlock.getBoundingClientRect().top : 0 ) ) +
'px; left: 0; 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 {
// Get the pasted content and clean
var frag = empty( detach( pasteArea ) ),
first = frag.firstChild,
range = self._createRange(
startContainer, startOffset, endContainer, endOffset );
// Was anything actually pasted?
if ( first ) {
// Safari and IE like putting extra divs around things.
if ( first === frag.lastChild &&
first.nodeName === 'DIV' ) {
frag.replaceChild( empty( first ), first );
}
frag.normalize();
addLinks( frag );
cleanTree( frag, false );
cleanupBRs( frag );
removeEmptyInlines( frag );
var node = frag,
doPaste = true;
while ( node = getNextBlock( node ) ) {
fixCursor( node );
}
self.fireEvent( 'willPaste', {
fragment: frag,
preventDefault: function () {
doPaste = false;
}
});
// Insert pasted data
if ( doPaste ) {
insertTreeFragmentIntoRange( range, frag );
self._docWasChanged();
range.collapse( false );
self._ensureBottomLine();
}
}
self.setSelection( range );
self._updatePath( range, true );
self._awaitingPaste = false;
} catch ( error ) {
self.didError( error );
}
}, 0 );
};
// --- Keyboard interaction ---
var keys = {
8: 'backspace',
9: 'tab',
13: 'enter',
32: 'space',
37: 'left',
39: 'right',
46: 'delete',
219: '[',
221: ']'
};
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 ) {
try {
var range = self.getSelection(),
node = range.startContainer,
parent;
if ( node.nodeType === TEXT_NODE ) {
node = node.parentNode;
}
// If focussed in empty inline element
if ( isInline( node ) && !node.textContent ) {
do {
parent = node.parentNode;
} while ( isInline( parent ) &&
!parent.textContent && ( node = parent ) );
range.setStart( parent,
indexOf.call( parent.childNodes, node ) );
range.collapse( true );
parent.removeChild( node );
if ( !isBlock( parent ) ) {
parent = getPreviousBlock( parent );
}
fixCursor( parent );
moveRangeBoundariesDownTree( range );
self.setSelection( range );
self._updatePath( range );
}
self._ensureBottomLine();
} catch ( error ) {
self.didError( error );
}
};
// If you select all in IE8 then type, it makes a P; replace it with
// a DIV.
if ( isIE8 ) {
proto._ieSelAllClean = function () {
var firstChild = this._body.firstChild;
if ( firstChild.nodeName === 'P' ) {
this._saveRangeToBookmark( this.getSelection() );
replaceWith( firstChild, this.createDefaultBlock([
empty( firstChild )
]));
this.setSelection( this._getRangeAndRemoveBookmark() );
}
};
}
var keyHandlers = {
enter: function ( self, event ) {
// We handle this ourselves
event.preventDefault();
// Must have some form of selection
var range = self.getSelection(),
block, parent, tag, splitTag, nodeAfterSplit;
if ( !range ) { return; }
// 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 );
}
block = getStartBlockOfRange( range );
if ( block && ( parent = getNearest( block, 'LI' ) ) ) {
block = parent;
}
tag = block ? block.nodeName : 'DIV';
splitTag = tagAfterSplit[ tag ];
// If this is a malformed bit of document, just play it safe
// and insert a <br>.
if ( !block ) {
insertNodeInRange( range, self.createElement( 'BR' ) );
range.collapse( false );
self.setSelection( range );
self._updatePath( range, true );
self._docWasChanged();
return;
}
// We need to wrap the contents in divs.
var splitNode = range.startContainer,
splitOffset = range.startOffset,
replacement;
if ( !splitTag ) {
// If the selection point is inside the block, we're going to
// rewrite it so our saved reference points won't be valid.
// Pick a node at a deeper point in the tree to avoid this.
if ( splitNode === block ) {
splitNode = splitOffset ?
splitNode.childNodes[ splitOffset - 1 ] : null;
splitOffset = 0;
if ( splitNode ) {
if ( splitNode.nodeName === 'BR' ) {
splitNode = splitNode.nextSibling;
} else {
splitOffset = getLength( splitNode );
}
if ( !splitNode || splitNode.nodeName === 'BR' ) {
replacement = fixCursor( self.createElement( 'DIV' ) );
if ( splitNode ) {
block.replaceChild( replacement, splitNode );
} else {
block.appendChild( replacement );
}
splitNode = replacement;
}
}
}
fixContainer( block );
splitTag = 'DIV';
if ( !splitNode ) {
splitNode = block.firstChild;
}
range.setStart( splitNode, splitOffset );
range.setEnd( splitNode, splitOffset );
block = getStartBlockOfRange( range );
}
if ( !block.textContent ) {
// Break list
if ( getNearest( block, 'UL' ) || getNearest( block, 'OL' ) ) {
return self.modifyBlocks( decreaseListLevel, range );
}
// Break blockquote
else if ( getNearest( block, 'BLOCKQUOTE' ) ) {
return self.modifyBlocks( removeBlockQuote, range );
}
}
// Otherwise, split at cursor point.
nodeAfterSplit = splitBlock( block, splitNode, splitOffset );
// 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' ) {
replaceWith( nodeAfterSplit, empty( nodeAfterSplit ) );
nodeAfterSplit = child;
continue;
}
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 );
// Scroll into view
if ( nodeAfterSplit.nodeType === TEXT_NODE ) {
nodeAfterSplit = nodeAfterSplit.parentNode;
}
var doc = self._doc,
body = self._body;
if ( nodeAfterSplit.offsetTop + nodeAfterSplit.offsetHeight >
( doc.documentElement.scrollTop || body.scrollTop ) +
body.offsetHeight ) {
nodeAfterSplit.scrollIntoView( false );
}
// We're not still in an undo state
self._docWasChanged();
},
backspace: function ( self, event ) {
self._removeZWS();
// Record undo checkpoint.
var range = self.getSelection();
self._recordUndoState( range );
self._getRangeAndRemoveBookmark( range );
// If not collapsed, delete contents
if ( !range.collapsed ) {
event.preventDefault();
deleteContentsOfRange( range );
self._ensureBottomLine();
self.setSelection( range );
self._updatePath( range, true );
}
// If at beginning of block, merge with previous
else if ( rangeDoesStartAtBlockBoundary( range ) ) {
event.preventDefault();
var current = getStartBlockOfRange( range ),
previous = current && getPreviousBlock( current );
// 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 );
}
self.setSelection( range );
}
// If at very beginning of text area, allow backspace
// to break lists/blockquote.
else if ( current ) {
// Break list
if ( getNearest( current, 'UL' ) ||
getNearest( current, 'OL' ) ) {
return self.modifyBlocks( decreaseListLevel, range );
}
// Break blockquote
else if ( getNearest( current, '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 ) {
self._removeZWS();
// Record undo checkpoint.
var range = self.getSelection();
self._recordUndoState( range );
self._getRangeAndRemoveBookmark( range );
// If not collapsed, delete contents
if ( !range.collapsed ) {
event.preventDefault();
deleteContentsOfRange( range );
self._ensureBottomLine();
self.setSelection( range );
self._updatePath( range, true );
}
// If at end of block, merge next into this block
else if ( rangeDoesEndAtBlockBoundary( range ) ) {
event.preventDefault();
var current = getStartBlockOfRange( range ),
next = current && getNextBlock( current );
// 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 );
}
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 );
}
},
tab: function ( self, event ) {
self._removeZWS();
var range = self.getSelection(),
node, parent;
// If no selection and in an empty block
if ( range.collapsed &&
rangeDoesStartAtBlockBoundary( range ) &&
rangeDoesEndAtBlockBoundary( range ) ) {
node = getStartBlockOfRange( range );
// 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;
}
event.preventDefault();
}
},
space: function ( self ) {
var range = self.getSelection();
self._recordUndoState( range );
addLinks( range.startContainer );
self._getRangeAndRemoveBookmark( range );
self.setSelection( range );
},
left: function ( self ) {
self._removeZWS();
},
right: function ( self ) {
self._removeZWS();
}
};
// Firefox 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 && win.getSelection().modify ) {
keyHandlers[ 'meta-left' ] = function ( self, event ) {
event.preventDefault();
self._sel.modify( 'move', 'backward', 'lineboundary' );
};
keyHandlers[ 'meta-right' ] = function ( self, event ) {
event.preventDefault();
self._sel.modify( 'move', 'forward', 'lineboundary' );
};
}
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' );
// Ref: http://unixpapa.com/js/key.html
proto._onKey = function ( event ) {
var code = event.keyCode,
key = keys[ code ],
modifiers = '';
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 modifiers.
if ( key !== 'backspace' && key !== 'delete' ) {
if ( event.altKey ) { modifiers += 'alt-'; }
if ( event.ctrlKey ) { modifiers += 'ctrl-'; }
if ( event.metaKey ) { modifiers += 'meta-'; }
if ( event.shiftKey ) { modifiers += 'shift-'; }
key = modifiers + key;
}
if ( keyHandlers[ key ] ) {
keyHandlers[ key ]( this, event );
}
};
// --- Get/Set data ---
proto._getHTML = function () {
return this._body.innerHTML;
};
proto._setHTML = function ( html ) {
var node = this._body;
node.innerHTML = html;
do {
fixCursor( node );
} while ( node = getNextBlock( node ) );
};
proto.getHTML = function ( withBookMark ) {
var brs = [],
node, fixer, html, l, range;
if ( withBookMark && ( range = this.getSelection() ) ) {
this._saveRangeToBookmark( range );
}
if ( useTextFixer ) {
node = this._body;
while ( node = getNextBlock( node ) ) {
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(),
div = this.createElement( 'DIV' ),
child;
// Parse HTML into DOM tree
div.innerHTML = html;
frag.appendChild( empty( div ) );
cleanTree( frag, true );
cleanupBRs( frag );
fixContainer( frag );
// Fix cursor
var node = frag;
while ( node = getNextBlock( node ) ) {
fixCursor( node );
}
// Remove existing body children
var body = this._body;
while ( child = body.lastChild ) {
body.removeChild( child );
}
// And insert new content
body.appendChild( frag );
fixCursor( body );
// 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( body.firstChild, 0 );
this._recordUndoState( range );
this._getRangeAndRemoveBookmark( 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.
if ( losesSelectionOnBlur ) {
this._lastSelection = range;
} else {
this.setSelection( 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 body = this._body,
splitNode = getStartBlockOfRange( range ) || body,
parent, nodeAfterSplit;
// While at end of container node, move up DOM tree.
while ( splitNode !== body && !splitNode.nextSibling ) {
splitNode = splitNode.parentNode;
}
// If in the middle of a container node, split up to body.
if ( splitNode !== body ) {
parent = splitNode.parentNode;
nodeAfterSplit = split( parent, splitNode.nextSibling, body );
}
if ( nodeAfterSplit ) {
body.insertBefore( el, nodeAfterSplit );
range.setStart( nodeAfterSplit, 0 );
range.setStart( nodeAfterSplit, 0 );
moveRangeBoundariesDownTree( range );
} else {
body.appendChild( el );
// Insert blank line below block.
body.appendChild( this.createDefaultBlock() );
range.setStart( el, 0 );
range.setEnd( el, 0 );
}
this.focus();
this.setSelection( range );
this._updatePath( range );
}
return this;
};
proto.insertImage = function ( src ) {
var img = this.createElement( 'IMG', {
src: src
});
this.insertElement( img );
return img;
};
// --- 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'
});
if ( style.styleSheet ) {
// IE8: must append to document BEFORE adding styles
// or you get the IE7 CSS parser!
head.appendChild( style );
style.styleSheet.cssText = styles;
} else {
// Everyone else
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 ) {
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 ) )
);
}
this.changeFormat({
tag: 'A',
attributes: {
href: url
}
}, {
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.className = ( block.className
.split( /\s+/ )
.filter( function ( klass ) {
return !( /dir/.test( klass ) );
})
.join( ' ' ) +
' dir-' + direction ).trim();
block.dir = direction;
}, 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 );