/*global DOCUMENT_POSITION_PRECEDING, ELEMENT_NODE, TEXT_NODE, SHOW_ELEMENT, SHOW_TEXT, win, isIOS, isMac, isGecko, isIE8or9or10, isIE8, isOpera, ctrlKey, useTextFixer, cantFocusEmptyTextNodes, losesSelectionOnBlur, hasBuggySplit, notWS, indexOf, TreeWalker, hasTagAttributes, isLeaf, isInline, isBlock, isContainer, getBlockWalker, getPreviousBlock, getNextBlock, getNearest, getPath, getLength, detach, replaceWith, empty, fixCursor, split, mergeInlines, mergeWithBlock, mergeContainers, fixContainer, createElement, forEachTextNodeInRange, getTextContentInRange, insertNodeInRange, extractContentsOfRange, deleteContentsOfRange, insertTreeFragmentIntoRange, isNodeContainedInRange, moveRangeBoundariesDownTree, moveRangeBoundariesUpTree, getStartBlockOfRange, getEndBlockOfRange, rangeDoesStartAtBlockBoundary, rangeDoesEndAtBlockBoundary, expandRangeToBlockBoundaries, top, console, setTimeout */ /*jshint strict:false */ 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( isOpera ? '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 ) { 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 ) {} } 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 ); } } }; 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 . 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 instead of 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