/*jshint strict:false, undef:false, unused:false */ function mergeObjects ( base, extras, mayOverride ) { var prop, value; if ( !base ) { base = {}; } if ( extras ) { for ( prop in extras ) { if ( mayOverride || !( prop in base ) ) { value = extras[ prop ]; base[ prop ] = ( value && value.constructor === Object ) ? mergeObjects( base[ prop ], value, mayOverride ) : value; } } } return base; } function Squire ( root, config ) { if ( root.nodeType === DOCUMENT_NODE ) { root = root.body; } var doc = root.ownerDocument; var win = doc.defaultView; var mutation; this._win = win; this._doc = doc; this._root = root; this._events = {}; this._isFocused = false; this._lastSelection = null; this._hasZWS = false; this._lastAnchorNode = null; this._lastFocusNode = null; this._path = ''; this._willUpdatePath = false; if ( 'onselectionchange' in doc ) { this.addEventListener( 'selectionchange', this._updatePathOnEvent ); } else { this.addEventListener( 'keyup', this._updatePathOnEvent ); this.addEventListener( 'mouseup', this._updatePathOnEvent ); } this._undoIndex = -1; this._undoStack = []; this._undoStackLength = 0; this._isInUndoState = false; this._ignoreChange = false; this._ignoreAllChanges = false; if ( canObserveMutations ) { mutation = new MutationObserver( this._docWasChanged.bind( this ) ); mutation.observe( root, { childList: true, attributes: true, characterData: true, subtree: true }); this._mutation = mutation; } else { this.addEventListener( 'keyup', this._keyUpDetectChange ); } // On blur, restore focus except if the user taps or clicks to focus a // specific point. Can't actually use click event because focus happens // before click, so use mousedown/touchstart this._restoreSelection = false; this.addEventListener( 'blur', enableRestoreSelection ); this.addEventListener( 'mousedown', disableRestoreSelection ); this.addEventListener( 'touchstart', disableRestoreSelection ); this.addEventListener( 'focus', restoreSelection ); // 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( 'cut', onCut ); this.addEventListener( 'copy', onCopy ); this.addEventListener( 'keydown', monitorShiftKey ); this.addEventListener( 'keyup', monitorShiftKey ); this.addEventListener( 'paste', onPaste ); this.addEventListener( 'drop', onDrop ); this.addEventListener( 'keydown', onKey ); // Add key handlers this._keyHandlers = Object.create( keyHandlers ); // Override default properties this.setConfig( config ); root.setAttribute( 'contenteditable', 'true' ); // Grammarly breaks the editor, *sigh* root.setAttribute( 'data-gramm', 'false' ); // Remove Firefox's built-in controls try { doc.execCommand( 'enableObjectResizing', false, 'false' ); doc.execCommand( 'enableInlineTableEditing', false, 'false' ); } catch ( error ) {} root.__squire__ = this; // Need to register instance before calling setHTML, so that the fixCursor // function can lookup any default block tag options set. this.setHTML( '' ); } var proto = Squire.prototype; var sanitizeToDOMFragment = function ( html, isPaste, self ) { var doc = self._doc; var frag = html ? DOMPurify.sanitize( html, { ALLOW_UNKNOWN_PROTOCOLS: true, WHOLE_DOCUMENT: false, RETURN_DOM: true, RETURN_DOM_FRAGMENT: true }) : null; return frag ? doc.importNode( frag, true ) : doc.createDocumentFragment(); }; proto.setConfig = function ( config ) { config = mergeObjects({ blockTag: 'DIV', blockAttributes: null, tagAttributes: { blockquote: null, ul: null, ol: null, li: null, a: null }, classNames: { colour: 'colour', fontFamily: 'font', fontSize: 'size', highlight: 'highlight' }, leafNodeNames: leafNodeNames, undo: { documentSizeThreshold: -1, // -1 means no threshold undoLimit: -1 // -1 means no limit }, isInsertedHTMLSanitized: true, isSetHTMLSanitized: true, sanitizeToDOMFragment: typeof DOMPurify !== 'undefined' && DOMPurify.isSupported ? sanitizeToDOMFragment : null, willCutCopy: null, addLinks: true }, config, true ); // Users may specify block tag in lower case config.blockTag = config.blockTag.toUpperCase(); this._config = config; return this; }; proto.createElement = function ( tag, props, children ) { return createElement( this._doc, tag, props, children ); }; proto.createDefaultBlock = function ( children ) { var config = this._config; return fixCursor( this.createElement( config.blockTag, config.blockAttributes, children ), this._root ); }; proto.didError = function ( error ) { console.log( error ); }; proto.getDocument = function () { return this._doc; }; proto.getRoot = function () { return this._root; }; proto.modifyDocument = function ( modificationCallback ) { var mutation = this._mutation; if ( mutation ) { if ( mutation.takeRecords().length ) { this._docWasChanged(); } mutation.disconnect(); } this._ignoreAllChanges = true; modificationCallback(); this._ignoreAllChanges = false; if ( mutation ) { mutation.observe( this._root, { childList: true, attributes: true, characterData: true, subtree: true }); this._ignoreChange = false; } }; // --- Events --- // Subscribing to these events won't automatically add a listener to the // document node, since these events are fired in a custom manner by the // editor code. var customEvents = { pathChange: 1, select: 1, input: 1, undoStateChange: 1 }; proto.fireEvent = function ( type, event ) { var handlers = this._events[ type ]; var isFocused, l, obj; // UI code, especially modal views, may be monitoring for focus events and // immediately removing focus. In certain conditions, this can cause the // focus event to fire after the blur event, which can cause an infinite // loop. So we detect whether we're actually focused/blurred before firing. if ( /^(?:focus|blur)/.test( type ) ) { isFocused = this._root === this._doc.activeElement; if ( type === 'focus' ) { if ( !isFocused || this._isFocused ) { return this; } this._isFocused = true; } else { if ( isFocused || !this._isFocused ) { return this; } this._isFocused = false; } } if ( handlers ) { if ( !event ) { event = {}; } if ( event.type !== type ) { event.type = type; } // Clone handlers array, so any handlers added/removed do not affect it. handlers = handlers.slice(); l = handlers.length; while ( l-- ) { obj = handlers[l]; try { if ( obj.handleEvent ) { obj.handleEvent( event ); } else { obj.call( this, event ); } } catch ( error ) { error.details = 'Squire: fireEvent error. Event type: ' + type; this.didError( error ); } } } return this; }; proto.destroy = function () { var events = this._events; var type; for ( type in events ) { this.removeEventListener( type ); } if ( this._mutation ) { this._mutation.disconnect(); } delete this._root.__squire__; // Destroy undo stack this._undoIndex = -1; this._undoStack = []; this._undoStackLength = 0; }; proto.handleEvent = function ( event ) { this.fireEvent( event.type, event ); }; proto.addEventListener = function ( type, fn ) { var handlers = this._events[ type ]; var target = this._root; 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 ] ) { if ( type === 'selectionchange' ) { target = this._doc; } target.addEventListener( type, this, true ); } } handlers.push( fn ); return this; }; proto.removeEventListener = function ( type, fn ) { var handlers = this._events[ type ]; var target = this._root; var l; if ( handlers ) { if ( fn ) { l = handlers.length; while ( l-- ) { if ( handlers[l] === fn ) { handlers.splice( l, 1 ); } } } else { handlers.length = 0; } if ( !handlers.length ) { delete this._events[ type ]; if ( !customEvents[ type ] ) { if ( type === 'selectionchange' ) { target = this._doc; } target.removeEventListener( type, this, true ); } } } return this; }; // --- Selection and Path --- proto.createRange = function ( range, startOffset, endContainer, endOffset ) { if ( range instanceof this._win.Range ) { return range.cloneRange(); } var domRange = this._doc.createRange(); domRange.setStart( range, startOffset ); if ( endContainer ) { domRange.setEnd( endContainer, endOffset ); } else { domRange.setEnd( range, startOffset ); } return domRange; }; proto.getCursorPosition = function ( range ) { if ( ( !range && !( range = this.getSelection() ) ) || !range.getBoundingClientRect ) { return null; } // Get the bounding rect var rect = range.getBoundingClientRect(); var node, parent; if ( rect && !rect.top ) { this._ignoreChange = true; node = this._doc.createElement( 'SPAN' ); node.textContent = ZWS; insertNodeInRange( range, node ); rect = node.getBoundingClientRect(); parent = node.parentNode; parent.removeChild( node ); mergeInlines( parent, range ); } return rect; }; proto._moveCursorTo = function ( toStart ) { var root = this._root, range = this.createRange( root, toStart ? 0 : root.childNodes.length ); moveRangeBoundariesDownTree( range ); this.setSelection( range ); return this; }; proto.moveCursorToStart = function () { return this._moveCursorTo( true ); }; proto.moveCursorToEnd = function () { return this._moveCursorTo( false ); }; var getWindowSelection = function ( self ) { return self._win.getSelection() || null; }; proto.setSelection = function ( range ) { if ( range ) { this._lastSelection = range; // If we're setting selection, that automatically, and synchronously, // triggers a focus event. So just store the selection and mark it as // needing restore on focus. if ( !this._isFocused ) { enableRestoreSelection.call( this ); } else { // iOS bug: if you don't focus the iframe before setting the // selection, you can end up in a state where you type but the input // doesn't get directed into the contenteditable area but is instead // lost in a black hole. Very strange. if ( isIOS ) { this._win.focus(); } var sel = getWindowSelection( this ); if ( sel && sel.setBaseAndExtent ) { sel.setBaseAndExtent( range.startContainer, range.startOffset, range.endContainer, range.endOffset, ); } else if ( sel ) { // This is just for IE11 sel.removeAllRanges(); sel.addRange( range ); } } } return this; }; proto.getSelection = function () { var sel = getWindowSelection( this ); var root = this._root; var selection, startContainer, endContainer, node; // If not focused, always rely on cached selection; another function may // have set it but the DOM is not modified until focus again if ( this._isFocused && sel && sel.rangeCount ) { selection = sel.getRangeAt( 0 ).cloneRange(); startContainer = selection.startContainer; endContainer = selection.endContainer; // FF can return the selection as being inside an . WTF? if ( startContainer && isLeaf( startContainer ) ) { selection.setStartBefore( startContainer ); } if ( endContainer && isLeaf( endContainer ) ) { selection.setEndBefore( endContainer ); } } if ( selection && isOrContains( root, selection.commonAncestorContainer ) ) { this._lastSelection = selection; } else { selection = this._lastSelection; node = selection.commonAncestorContainer; // Check the editor is in the live document; if not, the range has // probably been rewritten by the browser and is bogus if ( !isOrContains( node.ownerDocument, node ) ) { selection = null; } } if ( !selection ) { selection = this.createRange( root.firstChild, 0 ); } return selection; }; function enableRestoreSelection () { this._restoreSelection = true; } function disableRestoreSelection () { this._restoreSelection = false; } function restoreSelection () { if ( this._restoreSelection ) { this.setSelection( this._lastSelection ); } } proto.getSelectedText = function () { var range = this.getSelection(); if ( !range || range.collapsed ) { return ''; } var walker = new TreeWalker( range.commonAncestorContainer, SHOW_TEXT|SHOW_ELEMENT, function ( node ) { return isNodeContainedInRange( range, node, true ); } ); var startContainer = range.startContainer; var endContainer = range.endContainer; var node = walker.currentNode = startContainer; var textContent = ''; var addedTextInBlock = false; var value; if ( !walker.filter( node ) ) { node = walker.nextNode(); } while ( node ) { if ( node.nodeType === TEXT_NODE ) { value = node.data; if ( value && ( /\S/.test( value ) ) ) { if ( node === endContainer ) { value = value.slice( 0, range.endOffset ); } if ( node === startContainer ) { value = value.slice( range.startOffset ); } textContent += value; addedTextInBlock = true; } } else if ( node.nodeName === 'BR' || addedTextInBlock && !isInline( node ) ) { textContent += '\n'; addedTextInBlock = false; } node = walker.nextNode(); } return textContent; }; proto.getPath = function () { return this._path; }; // --- Workaround for browsers that can't focus empty text nodes --- // WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=15256 // Walk down the tree starting at the root and remove any ZWS. If the node only // contained ZWS space then remove it too. We may want to keep one ZWS node at // the bottom of the tree so the block can be selected. Define that node as the // keepNode. var removeZWS = function ( root, keepNode ) { var walker = new TreeWalker( root, SHOW_TEXT ); var parent, node, index; while ( node = walker.nextNode() ) { while ( ( index = node.data.indexOf( ZWS ) ) > -1 && ( !keepNode || node.parentNode !== keepNode ) ) { if ( node.length === 1 ) { do { parent = node.parentNode; parent.removeChild( node ); node = parent; walker.currentNode = parent; } while ( isInline( node ) && !getLength( node ) ); break; } else { node.deleteData( index, 1 ); } } } }; proto._didAddZWS = function () { this._hasZWS = true; }; proto._removeZWS = function () { if ( !this._hasZWS ) { return; } removeZWS( this._root ); this._hasZWS = false; }; // --- Path change events --- proto._updatePath = function ( range, force ) { if ( !range ) { return; } var anchor = range.startContainer, focus = range.endContainer, newPath; if ( force || anchor !== this._lastAnchorNode || focus !== this._lastFocusNode ) { this._lastAnchorNode = anchor; this._lastFocusNode = focus; newPath = ( anchor && focus ) ? ( anchor === focus ) ? getPath( focus, this._root, this._config ) : '(selection)' : ''; if ( this._path !== newPath ) { this._path = newPath; this.fireEvent( 'pathChange', { path: newPath } ); } } this.fireEvent( range.collapsed ? 'cursor' : 'select', { range: range }); }; // selectionchange is fired synchronously in IE when removing current selection // and when setting new selection; keyup/mouseup may have processing we want // to do first. Either way, send to next event loop. proto._updatePathOnEvent = function () { var self = this; if ( self._isFocused && !self._willUpdatePath ) { self._willUpdatePath = true; setTimeout( function () { self._willUpdatePath = false; self._updatePath( self.getSelection() ); }, 0 ); } }; // --- Focus --- proto.focus = function () { this._root.focus({ preventScroll: true }); if ( isIE ) { this.fireEvent( 'focus' ); } return this; }; proto.blur = function () { this._root.blur(); if ( isIE ) { this.fireEvent( 'blur' ); } return this; }; // --- Bookmarking --- var startSelectionId = 'squire-selection-start'; var endSelectionId = 'squire-selection-end'; proto._saveRangeToBookmark = function ( range ) { var startNode = this.createElement( 'INPUT', { id: startSelectionId, type: 'hidden' }), endNode = this.createElement( 'INPUT', { id: endSelectionId, type: 'hidden' }), temp; insertNodeInRange( range, startNode ); range.collapse( false ); insertNodeInRange( range, endNode ); // In a collapsed range, the start is sometimes inserted after the end! if ( startNode.compareDocumentPosition( endNode ) & DOCUMENT_POSITION_PRECEDING ) { startNode.id = endSelectionId; endNode.id = startSelectionId; temp = startNode; startNode = endNode; endNode = temp; } range.setStartAfter( startNode ); range.setEndBefore( endNode ); }; proto._getRangeAndRemoveBookmark = function ( range ) { var root = this._root, start = root.querySelector( '#' + startSelectionId ), end = root.querySelector( '#' + endSelectionId ); if ( start && end ) { var startContainer = start.parentNode, endContainer = end.parentNode, startOffset = indexOf.call( startContainer.childNodes, start ), endOffset = indexOf.call( endContainer.childNodes, end ); if ( startContainer === endContainer ) { endOffset -= 1; } detach( start ); detach( end ); if ( !range ) { range = this._doc.createRange(); } range.setStart( startContainer, startOffset ); range.setEnd( endContainer, endOffset ); // Merge any text nodes we split mergeInlines( startContainer, range ); if ( startContainer !== endContainer ) { mergeInlines( endContainer, range ); } // If we didn't split a text node, we should move into any adjacent // text node to current selection point if ( range.collapsed ) { startContainer = range.startContainer; if ( startContainer.nodeType === TEXT_NODE ) { endContainer = startContainer.childNodes[ range.startOffset ]; if ( !endContainer || endContainer.nodeType !== TEXT_NODE ) { endContainer = startContainer.childNodes[ range.startOffset - 1 ]; } if ( endContainer && endContainer.nodeType === TEXT_NODE ) { range.setStart( endContainer, 0 ); range.collapse( true ); } } } } return range || null; }; // --- Undo --- proto._keyUpDetectChange = function ( event ) { var code = event.keyCode; // Presume document was changed if: // 1. A modifier key (other than shift) wasn't held down // 2. The key pressed is not in range 16<=x<=20 (control keys) // 3. The key pressed is not in range 33<=x<=45 (navigation keys) if ( !event.ctrlKey && !event.metaKey && !event.altKey && ( code < 16 || code > 20 ) && ( code < 33 || code > 45 ) ) { this._docWasChanged(); } }; proto._docWasChanged = function () { if ( canWeakMap ) { nodeCategoryCache = new WeakMap(); } if ( this._ignoreAllChanges ) { return; } if ( canObserveMutations && this._ignoreChange ) { this._ignoreChange = false; return; } if ( this._isInUndoState ) { this._isInUndoState = false; this.fireEvent( 'undoStateChange', { canUndo: true, canRedo: false }); } this.fireEvent( 'input' ); }; // Leaves bookmark proto._recordUndoState = function ( range, replace ) { // Don't record if we're already in an undo state if ( !this._isInUndoState|| replace ) { // Advance pointer to new position var undoIndex = this._undoIndex; var undoStack = this._undoStack; var undoConfig = this._config.undo; var undoThreshold = undoConfig.documentSizeThreshold; var undoLimit = undoConfig.undoLimit; var html; if ( !replace ) { undoIndex += 1; } // Truncate stack if longer (i.e. if has been previously undone) if ( undoIndex < this._undoStackLength ) { undoStack.length = this._undoStackLength = undoIndex; } // Get data if ( range ) { this._saveRangeToBookmark( range ); } html = this._getHTML(); // If this document is above the configured size threshold, // limit the number of saved undo states. // Threshold is in bytes, JS uses 2 bytes per character if ( undoThreshold > -1 && html.length * 2 > undoThreshold ) { if ( undoLimit > -1 && undoIndex > undoLimit ) { undoStack.splice( 0, undoIndex - undoLimit ); undoIndex = undoLimit; this._undoStackLength = undoLimit; } } // Save data undoStack[ undoIndex ] = html; this._undoIndex = undoIndex; this._undoStackLength += 1; this._isInUndoState = true; } }; proto.saveUndoState = function ( range ) { if ( range === undefined ) { range = this.getSelection(); } this._recordUndoState( range, this._isInUndoState ); this._getRangeAndRemoveBookmark( range ); return this; }; proto.undo = function () { // Sanity check: must not be at beginning of the history stack if ( this._undoIndex !== 0 || !this._isInUndoState ) { // Make sure any changes since last checkpoint are saved. this._recordUndoState( this.getSelection(), false ); 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; } // Sanitize range to prevent weird IE artifacts if ( !range.collapsed && range.startContainer.nodeType === TEXT_NODE && range.startOffset === range.startContainer.length && range.startContainer.nextSibling ) { range.setStartBefore( range.startContainer.nextSibling ); } if ( !range.collapsed && range.endContainer.nodeType === TEXT_NODE && range.endOffset === 0 && range.endContainer.previousSibling ) { range.setEndAfter( range.endContainer.previousSibling ); } // If the common ancestor is inside the tag we require, we definitely // have the format. var root = this._root; var common = range.commonAncestorContainer; var walker, node; if ( getNearest( common, root, tag, attributes ) ) { return true; } // If common ancestor is a text node and doesn't have the format, we // definitely don't have it. if ( common.nodeType === TEXT_NODE ) { return false; } // Otherwise, check each text node at least partially contained within // the selection and make sure all of them have the format we want. walker = new TreeWalker( common, SHOW_TEXT, function ( node ) { return isNodeContainedInRange( range, node, true ); }); var seenNode = false; while ( node = walker.nextNode() ) { if ( !getNearest( node, root, tag, attributes ) ) { return false; } seenNode = true; } return seenNode; }; // Extracts the font-family and font-size (if any) of the element // holding the cursor. If there's a selection, returns an empty object. proto.getFontInfo = function ( range ) { var fontInfo = { color: undefined, backgroundColor: undefined, family: undefined, size: undefined }; var seenAttributes = 0; var element, style, attr; if ( !range && !( range = this.getSelection() ) ) { return fontInfo; } element = range.commonAncestorContainer; if ( range.collapsed || element.nodeType === TEXT_NODE ) { if ( element.nodeType === TEXT_NODE ) { element = element.parentNode; } while ( seenAttributes < 4 && element ) { if ( style = element.style ) { if ( !fontInfo.color && ( attr = style.color ) ) { fontInfo.color = attr; seenAttributes += 1; } if ( !fontInfo.backgroundColor && ( attr = style.backgroundColor ) ) { fontInfo.backgroundColor = attr; seenAttributes += 1; } if ( !fontInfo.family && ( attr = style.fontFamily ) ) { fontInfo.family = attr; seenAttributes += 1; } if ( !fontInfo.size && ( attr = style.fontSize ) ) { fontInfo.size = attr; seenAttributes += 1; } } element = element.parentNode; } } return fontInfo; }; proto._addFormat = function ( tag, attributes, range ) { // If the range is collapsed we simply insert the node by wrapping // it round the range and focus it. var root = this._root; var el, walker, startContainer, endContainer, startOffset, endOffset, node, needsFormat, block; if ( range.collapsed ) { el = fixCursor( this.createElement( tag, attributes ), root ); insertNodeInRange( range, el ); range.setStart( el.firstChild, el.firstChild.length ); range.collapse( true ); // Clean up any previous formats that may have been set on this block // that are unused. block = el; while ( isInline( block ) ) { block = block.parentNode; } removeZWS( block, 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 { // Create an iterator to walk over all the text nodes under this // ancestor which are in the range and not already formatted // correctly. // // In Blink/WebKit, empty blocks may have no text nodes, just a
. // Therefore we wrap this in the tag as well, as this will then cause it // to apply when the user types something in the block, which is // presumably what was intended. // // IMG tags are included because we may want to create a link around // them, and adding other styles is harmless. walker = new TreeWalker( range.commonAncestorContainer, SHOW_TEXT|SHOW_ELEMENT, function ( node ) { return ( node.nodeType === TEXT_NODE || node.nodeName === 'BR' || node.nodeName === 'IMG' ) && isNodeContainedInRange( range, node, true ); } ); // Start at the beginning node of the range and iterate through // all the nodes in the range that need formatting. startContainer = range.startContainer; startOffset = range.startOffset; endContainer = range.endContainer; endOffset = range.endOffset; // Make sure we start with a valid node. walker.currentNode = startContainer; if ( !walker.filter( startContainer ) ) { startContainer = walker.nextNode(); startOffset = 0; } // If there are no interesting nodes in the selection, abort if ( !startContainer ) { return range; } do { node = walker.currentNode; needsFormat = !getNearest( node, root, tag, attributes ); if ( needsFormat ) { //
can never be a container node, so must have a text node // if node == (end|start)Container if ( node === endContainer && node.length > endOffset ) { node.splitText( endOffset ); } if ( node === startContainer && startOffset ) { node = node.splitText( startOffset ); if ( endContainer === startContainer ) { endContainer = node; endOffset -= startOffset; } startContainer = node; startOffset = 0; } el = this.createElement( tag, attributes ); replaceWith( node, el ); el.appendChild( node ); } } while ( walker.nextNode() ); // If we don't finish inside a text node, offset may have changed. if ( endContainer.nodeType !== TEXT_NODE ) { if ( node.nodeType === TEXT_NODE ) { endContainer = node; endOffset = node.length; } else { // If
, we must have just wrapped it, so it must have only // one child endContainer = node.parentNode; endOffset = 1; } } // Now set the selection to as it was before range = this.createRange( startContainer, startOffset, endContainer, endOffset ); } return range; }; proto._removeFormat = function ( tag, attributes, range, partial ) { // Add bookmark this._saveRangeToBookmark( range ); // We need a node in the selection to break the surrounding // formatted text. var doc = this._doc, fixer; if ( range.collapsed ) { if ( cantFocusEmptyTextNodes ) { fixer = doc.createTextNode( ZWS ); this._didAddZWS(); } else { fixer = doc.createTextNode( '' ); } insertNodeInRange( range, fixer ); } // Find block-level ancestor of selection var root = range.commonAncestorContainer; while ( isInline( root ) ) { root = root.parentNode; } // Find text nodes inside formatTags that are not in selection and // add an extra tag with the same formatting. var startContainer = range.startContainer, startOffset = range.startOffset, endContainer = range.endContainer, endOffset = range.endOffset, toWrap = [], examineNode = function ( node, exemplar ) { // If the node is completely contained by the range then // we're going to remove all formatting so ignore it. if ( isNodeContainedInRange( range, node, false ) ) { return; } var isText = ( node.nodeType === TEXT_NODE ), child, next; // If not at least partially contained, wrap entire contents // in a clone of the tag we're removing and we're done. if ( !isNodeContainedInRange( range, node, true ) ) { // Ignore bookmarks and empty text nodes if ( node.nodeName !== 'INPUT' && ( !isText || node.data ) ) { toWrap.push([ exemplar, node ]); } return; } // Split any partially selected text nodes. if ( isText ) { if ( node === endContainer && endOffset !== node.length ) { toWrap.push([ exemplar, node.splitText( endOffset ) ]); } if ( node === startContainer && startOffset ) { node.splitText( startOffset ); toWrap.push([ exemplar, node ]); } } // If not a text node, recurse onto all children. // Beware, the tree may be rewritten with each call // to examineNode, hence find the next sibling first. else { for ( child = node.firstChild; child; child = next ) { next = child.nextSibling; examineNode( child, exemplar ); } } }, formatTags = Array.prototype.filter.call( root.getElementsByTagName( tag ), function ( el ) { return isNodeContainedInRange( range, el, true ) && hasTagAttributes( el, tag, attributes ); } ); if ( !partial ) { formatTags.forEach( function ( node ) { examineNode( node, node ); }); } // Now wrap unselected nodes in the tag toWrap.forEach( function ( item ) { // [ exemplar, node ] tuple var el = item[0].cloneNode( false ), node = item[1]; replaceWith( node, el ); el.appendChild( node ); }); // and remove old formatting tags. formatTags.forEach( function ( el ) { replaceWith( el, empty( el ) ); }); // Merge adjacent inlines: this._getRangeAndRemoveBookmark( range ); if ( fixer ) { range.collapse( false ); } mergeInlines( root, range ); return range; }; proto.changeFormat = function ( add, remove, range, partial ) { // Normalise the arguments and get selection if ( !range && !( range = this.getSelection() ) ) { return this; } // Save undo checkpoint this.saveUndoState( range ); if ( remove ) { range = this._removeFormat( remove.tag.toUpperCase(), remove.attributes || {}, range, partial ); } if ( add ) { range = this._addFormat( add.tag.toUpperCase(), add.attributes || {}, range ); } this.setSelection( range ); this._updatePath( range, true ); // We're not still in an undo state if ( !canObserveMutations ) { this._docWasChanged(); } return this; }; // --- Block formatting --- var tagAfterSplit = { DT: 'DD', DD: 'DT', LI: 'LI', PRE: 'PRE' }; var splitBlock = function ( self, block, node, offset ) { var splitTag = tagAfterSplit[ block.nodeName ], splitProperties = null, nodeAfterSplit = split( node, offset, block.parentNode, self._root ), config = self._config; if ( !splitTag ) { splitTag = config.blockTag; splitProperties = config.blockAttributes; } // Make sure the new node is the correct type. if ( !hasTagAttributes( nodeAfterSplit, splitTag, splitProperties ) ) { block = createElement( nodeAfterSplit.ownerDocument, splitTag, splitProperties ); if ( nodeAfterSplit.dir ) { block.dir = nodeAfterSplit.dir; } replaceWith( nodeAfterSplit, block ); block.appendChild( empty( nodeAfterSplit ) ); nodeAfterSplit = block; } return nodeAfterSplit; }; proto.forEachBlock = function ( fn, mutates, range ) { if ( !range && !( range = this.getSelection() ) ) { return this; } // Save undo checkpoint if ( mutates ) { this.saveUndoState( range ); } var root = this._root; var start = getStartBlockOfRange( range, root ); var end = getEndBlockOfRange( range, root ); if ( start && end ) { do { if ( fn( start ) || start === end ) { break; } } while ( start = getNextBlock( start, root ) ); } if ( mutates ) { this.setSelection( range ); // Path may have changed this._updatePath( range, true ); // We're not still in an undo state if ( !canObserveMutations ) { this._docWasChanged(); } } return this; }; proto.modifyBlocks = function ( modify, range ) { if ( !range && !( range = this.getSelection() ) ) { return this; } // 1. Save undo checkpoint and bookmark selection this._recordUndoState( range, this._isInUndoState ); var root = this._root; var frag; // 2. Expand range to block boundaries expandRangeToBlockBoundaries( range, root ); // 3. Remove range. moveRangeBoundariesUpTree( range, root, root, root ); frag = extractContentsOfRange( range, root, root ); // 4. Modify tree of fragment and reinsert. insertNodeInRange( range, modify.call( this, frag ) ); // 5. Merge containers at edges if ( range.endOffset < range.endContainer.childNodes.length ) { mergeContainers( range.endContainer.childNodes[ range.endOffset ], root ); } mergeContainers( range.startContainer.childNodes[ range.startOffset ], root ); // 6. Restore selection this._getRangeAndRemoveBookmark( range ); this.setSelection( range ); this._updatePath( range, true ); // 7. We're not still in an undo state if ( !canObserveMutations ) { this._docWasChanged(); } return this; }; var increaseBlockQuoteLevel = function ( frag ) { return this.createElement( 'BLOCKQUOTE', this._config.tagAttributes.blockquote, [ frag ]); }; var decreaseBlockQuoteLevel = function ( frag ) { var root = this._root; var blockquotes = frag.querySelectorAll( 'blockquote' ); Array.prototype.filter.call( blockquotes, function ( el ) { return !getNearest( el.parentNode, root, 'BLOCKQUOTE' ); }).forEach( function ( el ) { replaceWith( el, empty( el ) ); }); return frag; }; var removeBlockQuote = function (/* frag */) { return this.createDefaultBlock([ this.createElement( 'INPUT', { id: startSelectionId, type: 'hidden' }), this.createElement( 'INPUT', { id: endSelectionId, type: 'hidden' }) ]); }; var makeList = function ( self, frag, type ) { var walker = getBlockWalker( frag, self._root ), node, tag, prev, newLi, tagAttributes = self._config.tagAttributes, listAttrs = tagAttributes[ type.toLowerCase() ], listItemAttrs = tagAttributes.li; while ( node = walker.nextNode() ) { if ( node.parentNode.nodeName === 'LI' ) { node = node.parentNode; walker.currentNode = node.lastChild; } if ( node.nodeName !== 'LI' ) { newLi = self.createElement( 'LI', listItemAttrs ); if ( node.dir ) { newLi.dir = node.dir; } // Have we replaced the previous block with a new