/* Copyright © 2011 by Neil Jenkins. Licensed under the MIT license. */ /*global Range, window, document, setTimeout */ document.addEventListener( 'DOMContentLoaded', function () { "use strict"; // --- Constants --- var DOCUMENT_POSITION_PRECEDING = 2, // Node.DOCUMENT_POSITION_PRECEDING ELEMENT_NODE = 1, // Node.ELEMENT_NODE, TEXT_NODE = 3, // Node.TEXT_NODE, SHOW_TEXT = 4, // NodeFilter.SHOW_TEXT, SHOW_ELEMENT = 1, // NodeFilter.SHOW_ELEMENT, FILTER_ACCEPT = 1, // NodeFilter.FILTER_ACCEPT, FILTER_SKIP = 3; // NodeFilter.FILTER_SKIP; var doc = document, win = doc.defaultView, body = doc.body; var isOpera = !!win.opera; var isIE = !!win.ie; var useTextFixer = isOpera || isOpera; // --- DOM Sugar --- var createElement = function ( tag, props, children ) { var el = doc.createElement( tag ), attr, i, l; if ( props instanceof Array ) { children = props; props = null; } if ( props ) { for ( attr in props ) { el.setAttribute( attr, props[ attr ] ); } } if ( children ) { for ( i = 0, l = children.length; i < l; i += 1 ) { el.appendChild( children[i] ); } } return el; }; // --- Events --- var events = {}, customEvents = { cut: 1, paste: 1, focus: 1, blur: 1, pathChange: 1, select: 1, input: 1, undoStateChange: 1 }; var fireEvent = function ( type, event ) { var handlers = events[ type ], l, obj; if ( handlers ) { if ( typeof event !== 'object' ) { event = { data: event }; } if ( event.type !== type ) { event.type = type; } l = handlers.length; while ( l-- ) { obj = handlers[l]; if ( obj.handleEvent ) { obj.handleEvent( event ); } else { obj( event ); } } } }; var propagateEvent = function ( event ) { fireEvent( event.type, event ); }; var addEventListener = function ( type, fn ) { var handlers = events[ type ]; if ( !handlers ) { handlers = events[ type ] = []; if ( !customEvents[ type ] ) { doc.addEventListener( type, propagateEvent, false ); } } handlers.push( fn ); }; var removeEventListener = function ( type, fn ) { var handlers = events[ type ], l; if ( handlers ) { l = handlers.length; while ( l-- ) { if ( handlers[l] === fn ) { handlers.splice( l, 1 ); } } if ( !handlers.length ) { delete events[ type ]; if ( !customEvents[ type ] ) { doc.removeEventListener( type, propagateEvent, false ); } } } }; // --- Selection and Path --- var createRange = function ( range, startOffset, endContainer, endOffset ) { if ( range instanceof Range ) { return range.cloneRange(); } var domRange = doc.createRange(); domRange.setStart( range, startOffset ); if ( endContainer ) { domRange.setEnd( endContainer, endOffset ); } else { domRange.setEnd( range, startOffset ); } return domRange; }; var sel = win.getSelection(); var lastSelection = null; var getSelection = function () { if ( sel.rangeCount ) { lastSelection = sel.getRangeAt( 0 ).cloneRange(); } return lastSelection; }; // IE9 loses selection state of iframe on blur, so make sure we // cache it just before it loses focus. if ( win.ie ) { win.addEventListener( 'beforedeactivate', getSelection, true ); } var lastAnchorNode; var lastFocusNode; var path = ''; var updatePath = function ( force ) { var anchor = sel.anchorNode, focus = sel.focusNode, newPath; if ( force || anchor !== lastAnchorNode || focus !== lastFocusNode ) { lastAnchorNode = anchor; lastFocusNode = focus; newPath = ( anchor && focus ) ? ( anchor === focus ) ? focus.getPath() : '(selection)' : ''; if ( path !== newPath ) { path = newPath; fireEvent( 'pathChange', newPath ); } } if ( anchor !== focus ) { fireEvent( 'select' ); } }; addEventListener( 'keyup', updatePath ); addEventListener( 'mouseup', updatePath ); var setSelection = function ( range ) { if ( range ) { sel.removeAllRanges(); sel.addRange( range ); } updatePath(); }; // --- Focus --- var focus = function () { win.focus(); }; var blur = function () { win.blur(); }; win.addEventListener( 'focus', propagateEvent, false ); win.addEventListener( 'blur', propagateEvent, false ); // --- Get/Set data --- var getHTML = function () { return body.innerHTML; }; var setHTML = function ( html ) { body.innerHTML = html; body.fixCursor(); }; var insertElement = function ( el, range ) { if ( !range ) { range = getSelection(); } range.collapse( true ); range._insertNode( el ); range.setStartAfter( el ); setSelection( range ); }; // --- Bookmarking --- var startSelectionId = 'ss-' + Date.now() + '-' + Math.random(); var endSelectionId = 'es-' + Date.now() + '-' + Math.random(); var saveRangeToBookmark = function ( range ) { var startNode = createElement( 'INPUT', { id: startSelectionId, type: 'hidden' }), endNode = createElement( 'INPUT', { id: endSelectionId, type: 'hidden' }), temp; range._insertNode( startNode ); range.collapse( false ); range._insertNode( 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 ); }; var indexOf = Array.prototype.indexOf; var getRangeAndRemoveBookmark = function ( range ) { var start = doc.getElementById( startSelectionId ), end = doc.getElementById( endSelectionId ); if ( start && end ) { var startContainer = start.parentNode, endContainer = end.parentNode; var _range = { startContainer: startContainer, endContainer: endContainer, startOffset: indexOf.call( startContainer.childNodes, start ), endOffset: indexOf.call( endContainer.childNodes, end ) }; if ( startContainer === endContainer ) { _range.endOffset -= 1; } start.detach(); end.detach(); // Merge any text nodes we split startContainer.mergeInlines( _range ); if ( startContainer !== endContainer ) { endContainer.mergeInlines( _range ); } if ( !range ) { range = doc.createRange(); } range.setStart( _range.startContainer, _range.startOffset ); range.setEnd( _range.endContainer, _range.endOffset ); if ( !range.collapsed ) { range.moveBoundariesDownTree(); } } return range; }; // --- Undo --- var undoIndex, // = -1, undoStack, // = [], undoStackLength, // = 0, isInUndoState, // = false, docWasChanged = function docWasChanged () { if ( isInUndoState ) { isInUndoState = false; fireEvent( 'undoStateChange', { canUndo: true, canRedo: false }); } fireEvent( 'input' ); }; body.addEventListener( 'DOMCharacterDataModified', docWasChanged, false ); // Leaves bookmark var recordUndoState = function ( range ) { // Don't record if we're already in an undo state if ( !isInUndoState ) { // Advance pointer to new position undoIndex += 1; // Truncate stack if longer (i.e. if has been previously undone) if ( undoIndex < undoStackLength) { undoStack.length = undoStackLength = undoIndex; } // Write out data if ( range ) { saveRangeToBookmark( range ); } undoStack[ undoIndex ] = getHTML(); undoStackLength += 1; isInUndoState = true; } }; var undo = function () { // Sanity check: must not be at beginning of the history stack if ( undoIndex !== 0 || !isInUndoState ) { // Make sure any changes since last checkpoint are saved. recordUndoState( getSelection() ); undoIndex -= 1; setHTML( undoStack[ undoIndex ] ); var range = getRangeAndRemoveBookmark(); if ( range ) { setSelection( range ); } isInUndoState = true; fireEvent( 'undoStateChange', { canUndo: undoIndex !== 0, canRedo: true }); fireEvent( 'input' ); } }; var redo = function () { // Sanity check: must not be at end of stack and must be in an undo // state. if ( undoIndex + 1 < undoStackLength && isInUndoState ) { undoIndex += 1; setHTML( undoStack[ undoIndex ] ); var range = getRangeAndRemoveBookmark(); if ( range ) { setSelection( range ); } fireEvent( 'undoStateChange', { canUndo: true, canRedo: undoIndex + 1 < undoStackLength }); fireEvent( 'input' ); } }; // --- Inline formatting --- // Looks for matching tag and attributes, so won't work // if instead of etc. var hasFormat = function ( tag, attributes, range ) { // 1. Normalise the arguments and get selection tag = tag.toUpperCase(); if ( !attributes ) { attributes = {}; } if ( !range && !( range = 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 ( root.nearest( 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 = doc.createTreeWalker( root, SHOW_TEXT, function ( node ) { return range.containsNode( node, true ) ? FILTER_ACCEPT : FILTER_SKIP; }, false ); while ( node = walker.nextNode() ) { if ( !node.nearest( tag, attributes ) ) { return false; } } return true; }; var addFormat = function ( tag, attributes, range ) { // If the range is collapsed we simply insert the node by wrapping // it round the range and focus it. if ( range.collapsed ) { var el = createElement( tag, attributes ).fixCursor(); range._insertNode( el ); range.selectNodeContents( el ); } // 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. var walker = doc.createTreeWalker( range.commonAncestorContainer, SHOW_TEXT, function ( node ) { return range.containsNode( node, true ) ? FILTER_ACCEPT : FILTER_SKIP; }, false ); // Start at the beginning node of the range and iterate through // all the nodes in the range that need formatting. var startContainer, endContainer, startOffset = 0, endOffset = 0, textnode = walker.currentNode = range.startContainer, needsFormat; if ( textnode.nodeType !== TEXT_NODE ) { textnode = walker.nextNode(); } do { needsFormat = !textnode.nearest( tag, attributes ); if ( textnode === range.endContainer ) { if ( needsFormat && textnode.length > range.endOffset ) { textnode.splitText( range.endOffset ); } else { endOffset = range.endOffset; } } if ( textnode === range.startContainer ) { if ( needsFormat && range.startOffset ) { textnode = textnode.splitText( range.startOffset ); } else { startOffset = range.startOffset; } } if ( needsFormat ) { createElement( tag, attributes ).wraps( textnode ); endOffset = textnode.length; } endContainer = textnode; if ( !startContainer ) { startContainer = endContainer; } } while ( textnode = walker.nextNode() ); // Now set the selection to as it was before range = createRange( startContainer, startOffset, endContainer, endOffset ); } return range; }; var removeFormat = function ( tag, attributes, range ) { // Add bookmark saveRangeToBookmark( range ); // Find block-level ancestor of selection var root = range.commonAncestorContainer; while ( root.isInline() ) { 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 examineNode ( node, exemplar ) { // If the node is completely contained by the range then // we're going to remove all formatting so ignore it. if ( range.containsNode( 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 ( !range.containsNode( node, true ) ) { // Tidy up as we go... if ( isText && !node.length ) { node.detach(); } // Ignore bookmarks else if ( node.nodeName !== 'INPUT' ) { 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 range.containsNode( el, true ) && el.is( tag, attributes ); } ); formatTags.forEach( function ( node ) { examineNode( node, node ); }); // Now wrap unselected nodes in the tag toWrap.forEach( function ( item ) { // [ exemplar, node ] tuple item[0].cloneNode( false ).wraps( item[1] ); }); // and remove old formatting tags. formatTags.forEach( function ( el ) { el.replaceWith( el.empty() ); }); // Merge adjacent inlines: range = getRangeAndRemoveBookmark(); var _range = { startContainer: range.startContainer, startOffset: range.startOffset, endContainer: range.endContainer, endOffset: range.endOffset }; root.mergeInlines( _range ); range.setStart( _range.startContainer, _range.startOffset ); range.setEnd( _range.endContainer, _range.endOffset ); return range; }; var changeFormat = function ( add, remove, range ) { // Normalise the arguments and get selection if ( !range && !( range = getSelection() ) ) { return; } // Save undo checkpoint recordUndoState( range ); getRangeAndRemoveBookmark( range ); if ( remove ) { range = removeFormat( remove.tag.toUpperCase(), remove.attributes || {}, range ); } if ( add ) { range = addFormat( add.tag.toUpperCase(), add.attributes || {}, range ); } setSelection( range ); // We're not still in an undo state docWasChanged(); }; // --- Block formatting --- var forEachBlock = function ( fn, range ) { if ( !range && !( range = getSelection() ) ) { return; } // Save undo checkpoint recordUndoState( range ); getRangeAndRemoveBookmark( range ); var start = range.getStartBlock(), end = range.getEndBlock(); if ( start && end ) { while ( true ) { fn( start ); if ( start === end ) { break; } start = start.getNextBlock(); } } // Path may have changed updatePath( true ); // We're not still in an undo state docWasChanged(); }; var modifyBlocks = function ( modify, range ) { if ( !range && !( range = getSelection() ) ) { return; } // 1. Stop firefox adding an extra
to // if we remove everything. Don't want to do this in Opera // as it can cause focus problems. if ( !isOpera ) { body.setAttribute( 'contenteditable', 'false' ); } // 2. Save undo checkpoint and bookmark selection if ( isInUndoState ) { saveRangeToBookmark( range ); } else { recordUndoState( range ); } // 3. Expand range to block boundaries range.expandToBlockBoundaries(); // 4. Remove range. range.moveBoundariesUpTree( body ); var frag = range._extractContents( body ); // 5. Modify tree of fragment and reinsert. range._insertNode( modify( frag ) ); // 6. Merge containers at edges if ( range.endOffset < range.endContainer.childNodes.length ) { range.endContainer.childNodes[ range.endOffset ].mergeContainers(); } range.startContainer.childNodes[ range.startOffset ].mergeContainers(); // 7. Make it editable again if ( !isOpera ) { body.setAttribute( 'contenteditable', 'true' ); } // 8. Restore selection setSelection( getRangeAndRemoveBookmark() ); // 9. We're not still in an undo state docWasChanged(); }; var increaseBlockQuoteLevel = function ( frag ) { return createElement( 'BLOCKQUOTE', [ frag ]); }; var decreaseBlockQuoteLevel = function ( frag ) { var blockquotes = frag.querySelectorAll( 'blockquote' ); Array.prototype.filter.call( blockquotes, function ( el ) { return !el.parentNode.nearest( 'BLOCKQUOTE' ); }).forEach( function ( el ) { el.replaceWith( el.empty() ); }); return frag; }; var makeList = function makeList ( nodes, type ) { var i, l, node, tag, prev, replacement; for ( i = 0, l = nodes.length; i < l; i += 1 ) { node = nodes[i]; tag = node.nodeName; if ( node.isBlock() ) { if ( tag !== 'LI' ) { replacement = createElement( 'LI', [ node.empty() ]); if ( node.parentNode.nodeName === type ) { node.replaceWith( replacement ); } else if ( ( prev = node.previousSibling ) && prev.nodeName === type ) { prev.appendChild( replacement ); node.detach(); i -= 1; l -= 1; } else { node.replaceWith( createElement( type, [ replacement ]) ); } } } else if ( node.isContainer() ) { if ( tag !== type && ( /^[DOU]L$/.test( tag ) ) ) { node.replaceWith( createElement( type, [ node.empty() ]) ); } else { makeList( node.childNodes, type ); } } } }; var makeUnorderedList = function ( frag ) { makeList( frag.childNodes, 'UL' ); return frag; }; var makeOrderedList = function ( frag ) { makeList( frag.childNodes, 'OL' ); return frag; }; var decreaseListLevel = function ( frag ) { var lists = frag.querySelectorAll( 'UL, OL' ); Array.prototype.filter.call( lists, function ( el ) { return !el.parentNode.nearest( 'UL' ) && !el.parentNode.nearest( 'OL' ); }).forEach( function ( el ) { var frag = el.empty(), children = frag.childNodes, l = children.length, child; while ( l-- ) { child = children[l]; if ( child.nodeName === 'LI' ) { frag.replaceChild( createElement( 'DIV', [ child.empty() ]), child ); } } el.replaceWith( frag ); }); return frag; }; 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 = node.split( offset, block.parentNode ); // Make sure the new node is the correct type. if ( nodeAfterSplit.nodeName !== splitTag ) { block = createElement( splitTag ); block.replaces( nodeAfterSplit ) .appendChild( nodeAfterSplit.empty() ); nodeAfterSplit = block; } return nodeAfterSplit; }; // --- Clean --- 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 spanToSemantic = { color: { regexp: /\S/, replace: function ( color ) { return createElement( 'SPAN', { 'class': 'colour', style: 'color:' + color }); } }, fontWeight: { regexp: /^bold/i, replace: function () { return createElement( 'B' ); } }, fontStyle: { regexp: /^italic/i, replace: function () { return createElement( 'I' ); } }, fontFamily: { regexp: /\S/, replace: function ( family ) { return createElement( 'SPAN', { 'class': 'font', style: 'font-family:' + family }); } }, fontSize: { regexp: /\S/, replace: function ( size ) { return createElement( 'SPAN', { 'class': 'size', style: 'font-size:' + size }); } } }; var stylesRewriters = { SPAN: function ( span, parent ) { var style = span.style, 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( css ); if ( newTreeBottom ) { newTreeBottom.appendChild( el ); } newTreeBottom = el; if ( !newTreeTop ) { newTreeTop = el; } } } if ( newTreeTop ) { newTreeBottom.appendChild( span.empty() ); parent.replaceChild( newTreeTop, span ); } return newTreeBottom || span; }, STRONG: function ( node, parent ) { var el = createElement( 'B' ); parent.replaceChild( el, node ); el.appendChild( node.empty() ); return el; }, EM: function ( node, parent ) { var el = createElement( 'I' ); parent.replaceChild( el, node ); el.appendChild( node.empty() ); return el; } }; /* Two purposes: 1. Remove nodes we don't want, such as weird 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, parent; for ( i = 0, l = children.length; i < l; i += 1 ) { child = children[i]; nodeName = child.nodeName; nodeType = child.nodeType; rewriter = stylesRewriters[ nodeName ]; if ( rewriter ) { child = rewriter( child, node ); } else if ( ( !allowedBlock.test( nodeName ) && !child.isInline() ) || ( nodeType === TEXT_NODE && !( /\S/.test( child.data ) ) ) ) { node.removeChild( child ); i -= 1; l -= 1; continue; } if ( nodeType === ELEMENT_NODE ) { if ( !allowStyles && child.style.cssText ) { child.removeAttribute( 'style' ); } if ( child.childNodes.length ) { cleanTree( child, allowStyles ); } } } return node; }; var wrapTopLevelInline = function ( root, tag ) { var children = root.childNodes, wrapper = null, i, l, child, isBR; for ( i = 0, l = children.length; i < l; i += 1 ) { child = children[i]; isBR = child.nodeName === 'BR'; if ( !isBR && child.isInline() ) { if ( !wrapper ) { wrapper = createElement( tag ); } wrapper.appendChild( child ); i -= 1; l -= 1; } else if ( isBR || wrapper ) { if ( !wrapper ) { wrapper = createElement( tag ); } wrapper.fixCursor(); if ( isBR ) { root.replaceChild( wrapper, child ); } else { root.insertBefore( wrapper, child ); i += 1; l += 1; } wrapper = null; } } if ( wrapper ) { root.appendChild( wrapper.fixCursor() ); } return root; }; var cleanupBRs = function ( root ) { var brs = root.querySelectorAll( 'BR' ), l = brs.length, br, block, nodeAfterSplit, div, next; while ( l-- ) { br = brs[l]; // Cleanup may have removed it block = br.parentNode; if ( !block ) { continue; } if ( br.nextSibling && br.previousSibling ) { while ( block.isInline() ) { block = block.parentNode; } // If this is not inside a block, replace it by wrapping // inlines in DIV. if ( !block.isBlock() ) { wrapTopLevelInline( block, 'DIV' ); } // If in a block we can split, split it instead else if ( tagAfterSplit[ block.nodeName ] ) { splitBlock( block, br.parentNode, br ); br.detach(); } // Otherwise leave the br alone. } else { br.detach(); } } }; // --- Cut and Paste --- doc.addEventListener( isIE ? 'beforecut' : 'cut', function () { // Save undo checkpoint var range = getSelection(); recordUndoState( range ); getRangeAndRemoveBookmark( range ); // If all content removed, ensure div at start of body. setTimeout( function () { body.fixCursor(); }, 0 ); }, false ); doc.addEventListener( isIE ? 'beforepaste' : 'paste', function () { var range = getSelection(), startContainer = range.startContainer, startOffset = range.startOffset, endContainer = range.endContainer, endOffset = range.endOffset; var pasteArea = createElement( 'DIV', { style: 'position: absolute; overflow: hidden;' + 'top: -100px; left: -100px; width: 1px; height: 1px;' }); body.appendChild( pasteArea ); range.selectNodeContents( pasteArea ); 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 () { // Get the pasted content and clean var frag = pasteArea.detach().empty(), first = frag.firstChild; // Safari likes putting extra divs around things. if ( first && first === frag.lastChild && first.nodeName === 'DIV' ) { frag.replaceChild( first.empty(), first ); } frag.normalize(); cleanTree( frag, false ); cleanupBRs( frag ); var node = frag; while ( node = node.getNextBlock() ) { node.fixCursor(); } // Restore the previous selection range.setStart( startContainer, startOffset ); range.setEnd( endContainer, endOffset ); // Insert pasted data range.insertTreeFragment( frag ); docWasChanged(); range.collapse( false ); setSelection( range ); }, 0 ); }, false ); // --- Keyboard interaction --- var keys = { 8: 'backspace', 9: 'tab', 13: 'enter', 32: 'space', 46: 'delete' }; var mapKeyTo = function ( fn ) { return function ( event ) { event.preventDefault(); fn(); }; }; var mapKeyToFormat = function ( tag ) { return function ( event ) { event.preventDefault(); var range = getSelection(); if ( hasFormat( tag, null, range ) ) { changeFormat( null, { tag: tag }, range ); } else { changeFormat( { tag: tag }, null, range ); } }; }; var keyHandlers = { tab: function ( event ) { event.preventDefault(); }, enter: function ( event ) { // We handle this ourselves event.preventDefault(); // Must have some form of selection var range = getSelection(); if ( !range ) { return; } // Save undo checkpoint recordUndoState( range ); getRangeAndRemoveBookmark( range ); // Selected text is overwritten, therefore delete the contents // to collapse selection. if ( !range.collapsed ) { range._deleteContents(); } var block = range.getStartBlock(), tag = block ? block.nodeName : 'DIV', splitTag = tagAfterSplit[ tag ], nodeAfterSplit; // If this is a malformed bit of document, just play it safe // and insert a
. if ( !block ) { range._insertNode( createElement( 'BR' ) ); range.collapse( false ); setSelection( range ); 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 referece 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 = splitNode.getLength(); } if ( !splitNode || splitNode.nodeName === 'BR' ) { replacement = createElement( 'DIV' ).fixCursor(); if ( splitNode ) { block.replaceChild( replacement, splitNode ); } else { block.appendChild( replacement ); } splitNode = replacement; } } } wrapTopLevelInline( block, 'DIV' ); splitTag = 'DIV'; if ( !splitNode ) { splitNode = block.firstChild; } range.setStart( splitNode, splitOffset ); range.setEnd( splitNode, splitOffset ); block = range.getStartBlock(); } if ( !block.textContent ) { // Break list if ( block.nearest( 'UL' ) || block.nearest( 'OL' ) ) { return modifyBlocks( decreaseListLevel, range ); } // Break blockquote else if ( block.nearest( 'BLOCKQUOTE' ) ) { return modifyBlocks( decreaseBlockQuoteLevel, range ); } } // Otherwise, split at cursor point. nodeAfterSplit = splitBlock( block, splitNode, splitOffset ); // Focus cursor // If there's a / etc. at the beginning of the split // make sure we focus inside it. while ( nodeAfterSplit.nodeType === ELEMENT_NODE) { var child = nodeAfterSplit.firstChild, next; while ( child && child.nodeType === TEXT_NODE && !child.data ) { next = child.nextSibling; if ( !next || next.nodeName === 'BR' ) { break; } child.detach(); 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 && !isOpera ) ) { break; } nodeAfterSplit = child; } setSelection( createRange( nodeAfterSplit, 0 ) ); // We're not still in an undo state docWasChanged(); }, backspace: function ( event ) { var range = getSelection(); // If not collapsed, delete contents if ( !range.collapsed ) { event.preventDefault(); range._deleteContents(); setSelection( range ); } // If at beginning of block, merge with previous else if ( range.startsAtBlockBoundary() ) { event.preventDefault(); var current = range.getStartBlock(), previous = current.getPreviousBlock(); // Must not be at the very beginning of the text area. if ( previous ) { previous.mergeWithBlock( current, range ); setSelection( range ); } // If at very beginning of text area, allow backspace // to break lists/blockquote. else { // Break list if ( current.nearest( 'UL' ) || current.nearest( 'OL' ) ) { return modifyBlocks( decreaseListLevel, range ); } // Break blockquote else if ( current.nearest( 'BLOCKQUOTE' ) ) { return modifyBlocks( decreaseBlockQuoteLevel, range ); } setSelection( range ); } } // All other cases can be safely left to the browser (I hope!). }, 'delete': function ( event ) { var range = getSelection(); // If not collapsed, delete contents if ( !range.collapsed ) { event.preventDefault(); range._deleteContents(); setSelection( range ); } // If at end of block, merge next into this block else if ( range.endsAtBlockBoundary() ) { event.preventDefault(); var current = range.getStartBlock(), next = current.getNextBlock(); // Must not be at the very end of the text area. if ( next ) { current.mergeWithBlock( next, range ); setSelection( range ); } } // All other cases can be safely left to the browser (I hope!). }, space: function () { var range = getSelection(); recordUndoState( range ); getRangeAndRemoveBookmark( range ); setSelection( range ); }, 'ctrl-b': mapKeyToFormat( 'B' ), 'ctrl-i': mapKeyToFormat( 'I' ), 'ctrl-u': mapKeyToFormat( 'U' ), 'ctrl-y': mapKeyTo( redo ), 'ctrl-z': mapKeyTo( undo ), 'ctrl-shift-z': mapKeyTo( redo ) }; addEventListener( 'keydown', function ( event ) { // Ref: http://unixpapa.com/js/key.html var code = event.keyCode || event.which, key = keys[ code ] || String.fromCharCode( code ).toLowerCase(), modifiers = ''; // Function keys if ( 111 < code && code < 124 ) { key = 'f' + ( code - 111 ); } if ( event.altKey ) { modifiers += 'alt-'; } if ( event.ctrlKey || event.metaKey ) { modifiers += 'ctrl-'; } if ( event.shiftKey ) { modifiers += 'shift-'; } key = modifiers + key; if ( keyHandlers[ key ] ) { keyHandlers[ key ]( event ); } else { fireEvent( 'keydown', event ); } }); // --- Export --- var styleExtractor = /]*>([\s\S]*?)<\/style>/gi; var chain = function ( fn ) { return function () { fn.apply( null, arguments ); return this; }; }; var command = function ( fn, arg, arg2 ) { return function () { fn( arg, arg2 ); focus(); return this; }; }; win.editor = { addEventListener: chain( addEventListener ), removeEventListener: chain( removeEventListener ), focus: chain( focus ), blur: chain( blur ), getDocument: function () { return doc; }, getHTML: function () { var brs = [], node, fixer, html, l; if ( useTextFixer ) { node = body; while ( node = node.getNextBlock() ) { if ( !node.textContent && !node.querySelector( 'BR' ) ) { fixer = createElement( 'BR' ); node.appendChild( fixer ); brs.push( fixer ); } } } html = getHTML(); if ( useTextFixer ) { l = brs.length; while ( l-- ) { brs[l].detach(); } } return html; }, setHTML: function ( html ) { var frag = doc.createDocumentFragment(), div = createElement( 'DIV' ), styles = '', child; // Extract styles to insert into styleExtractor.lastIndex = 0; html = html.replace( styleExtractor, function ( _, rules ) { styles += rules.replace( //g, '' ); return ''; }); // Parse HTML into DOM tree div.innerHTML = html; frag.appendChild( div.empty() ); cleanTree( frag, true ); cleanupBRs( frag ); wrapTopLevelInline( frag, 'DIV' ); // Fix cursor var node = frag; while ( node = node.getNextBlock() ) { node.fixCursor(); } // Remove existing body children while ( child = body.lastChild ) { body.removeChild( child ); } // And insert new content // Add the styles if ( styles ) { var head = doc.documentElement.firstChild, style = 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( doc.createTextNode( styles ) ); head.appendChild( style ); } } body.appendChild( frag ); body.fixCursor(); // Reset the undo stack undoIndex = -1; undoStack = []; undoStackLength = 0; isInUndoState = false; // Record undo state var range = createRange( body.firstChild, 0 ); recordUndoState( range ); setSelection( getRangeAndRemoveBookmark( range ) ); return this; }, insertImage: function ( src ) { var img = createElement( 'IMG', { src: src }); insertElement( img ); return img; }, getPath: function () { return path; }, getSelection: getSelection, setSelection: chain( setSelection ), undo: chain( undo ), redo: chain( redo ), hasFormat: hasFormat, changeFormat: chain( changeFormat ), bold: command( changeFormat, { tag: 'B' } ), italic: command( changeFormat, { tag: 'I' } ), underline: command( changeFormat, { tag: 'U' } ), removeBold: command( changeFormat, null, { tag: 'B' } ), removeItalic: command( changeFormat, null, { tag: 'I' } ), removeUnderline: command( changeFormat, null, { tag: 'U' } ), makeLink: function ( url ) { changeFormat({ tag: 'A', attributes: { href: url } }, { tag: 'A' }); focus(); return this; }, setFontFace: function ( name ) { changeFormat({ tag: 'SPAN', attributes: { 'class': 'font', style: 'font-family: ' + name + ', sans-serif;' } }, { tag: 'SPAN', 'class': 'font' }); focus(); return this; }, setFontSize: function ( size ) { changeFormat({ tag: 'SPAN', attributes: { 'class': 'size', style: 'font-size: ' + ( typeof size === 'number' ? size + 'px' : size ) } }, { tag: 'SPAN', 'class': 'size' }); focus(); return this; }, setTextAlignment: function ( dir ) { forEachBlock( function ( block ) { block.className = 'align-' + dir; block.style.textAlign = dir; }); focus(); return this; }, modifyBlocks: chain( modifyBlocks ), incQuoteLevel: command( modifyBlocks, increaseBlockQuoteLevel ), decQuoteLevel: command( modifyBlocks, decreaseBlockQuoteLevel ), makeUnorderedList: command( modifyBlocks, makeUnorderedList ), makeOrderedList: command( modifyBlocks, makeOrderedList ), removeList: command( modifyBlocks, decreaseListLevel ) }; // --- Initialise --- body.setAttribute( 'contenteditable', 'true' ); win.editor.setHTML( '' ); }, false );