. Browsers that want
-// elements at the end of each block will then have them added back in a later
-// fixCursor method call.
-let cleanupBRs = ( node, root, keepForBlankLine ) => {
- let brs = node.querySelectorAll( 'BR' );
- let brBreaksLine = [];
- let l = brs.length;
- let i, br, parent;
-
- // Must calculate whether the
breaks a line first, because if we
- // have two
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 ) {
- brBreaksLine[i] = isLineBreak( brs[i], keepForBlankLine );
- }
- while ( l-- ) {
- br = brs[l];
- // Cleanup may have removed it
- parent = br.parentNode;
- if ( !parent ) { continue; }
- // If it doesn't break a line, just remove it; it's not doing
- // anything useful. We'll add it back later if required by the
- // browser. If it breaks a line, wrap the content in div tags
- // and replace the brs.
- if ( !brBreaksLine[l] ) {
- detach( br );
- } else if ( !isInline( parent ) ) {
- fixContainer( parent, root );
- }
- }
-};
-
-// The (non-standard but supported enough) innerText property is based on the
-// render tree in Firefox and possibly other browsers, so we must insert the
-// DOM node into the document to ensure the text part is correct.
-let setClipboardData =
- ( event, contents, root, willCutCopy, toPlainText, plainTextOnly ) => {
- let clipboardData = event.clipboardData;
- let body = doc.body;
- let node = createElement( 'div' );
- let html, text;
-
- node.append( contents );
-
- html = node.innerHTML;
- if ( willCutCopy ) {
- html = willCutCopy( html );
- }
-
- if ( toPlainText ) {
- text = toPlainText( html );
- } else {
- // Firefox will add an extra new line for BRs at the end of block when
- // calculating innerText, even though they don't actually affect
- // display, so we need to remove them first.
- cleanupBRs( node, root, true );
- node.setAttribute( 'style',
- 'position:fixed;overflow:hidden;bottom:100%;right:100%;' );
- body.append( node );
- text = node.innerText || node.textContent;
- text = text.replace( NBSP, ' ' ); // Replace nbsp with regular space
- node.remove( );
- }
- // Firefox (and others?) returns unix line endings (\n) even on Windows.
- // If on Windows, normalise to \r\n, since Notepad and some other crappy
- // apps do not understand just \n.
- if ( isWin ) {
- text = text.replace( /\r?\n/g, '\r\n' );
- }
-
- if ( !plainTextOnly && text !== html ) {
- clipboardData.setData( 'text/html', html );
- }
- clipboardData.setData( 'text/plain', text );
- event.preventDefault();
-};
-
-let onCut = function ( event ) {
- let range = this.getSelection();
- let root = this._root;
- let self = this;
- let startBlock, endBlock, copyRoot, contents, parent, newContents;
-
- // Nothing to do
- if ( range.collapsed ) {
- event.preventDefault();
- return;
- }
-
- // Save undo checkpoint
- this.saveUndoState( range );
-
- // Edge only seems to support setting plain text as of 2016-03-11.
- if ( !isEdge && event.clipboardData ) {
- // Clipboard content should include all parents within block, or all
- // parents up to root if selection across blocks
- startBlock = getStartBlockOfRange( range, root );
- endBlock = getEndBlockOfRange( range, root );
- copyRoot = ( ( startBlock === endBlock ) && startBlock ) || root;
- // Extract the contents
- contents = deleteContentsOfRange( range, root );
- // Add any other parents not in extracted content, up to copy root
- parent = range.commonAncestorContainer;
- if ( parent.nodeType === TEXT_NODE ) {
- parent = parent.parentNode;
- }
- while ( parent && parent !== copyRoot ) {
- newContents = parent.cloneNode( false );
- newContents.append( contents );
- contents = newContents;
- parent = parent.parentNode;
- }
- // Set clipboard data
- setClipboardData(
- event, contents, root, this._config.willCutCopy, null, false );
- } else {
- setTimeout( () => {
- try {
- // If all content removed, ensure div at start of root.
- self._ensureBottomLine();
- } catch ( error ) {
- self.didError( error );
- }
- }, 0 );
- }
-
- this.setSelection( range );
-};
-
-let onCopy = function ( event ) {
- // Edge only seems to support setting plain text as of 2016-03-11.
- if ( !isEdge && event.clipboardData ) {
- let range = this.getSelection(), root = this._root,
- // Clipboard content should include all parents within block, or all
- // parents up to root if selection across blocks
- startBlock = getStartBlockOfRange( range, root ),
- endBlock = getEndBlockOfRange( range, root ),
- copyRoot = ( ( startBlock === endBlock ) && startBlock ) || root,
- contents, parent, newContents;
- // Clone range to mutate, then move up as high as possible without
- // passing the copy root node.
- range = range.cloneRange();
- moveRangeBoundariesDownTree( range );
- moveRangeBoundariesUpTree( range, copyRoot, copyRoot, root );
- // Extract the contents
- contents = range.cloneContents();
- // Add any other parents not in extracted content, up to copy root
- parent = range.commonAncestorContainer;
- if ( parent.nodeType === TEXT_NODE ) {
- parent = parent.parentNode;
- }
- while ( parent && parent !== copyRoot ) {
- newContents = parent.cloneNode( false );
- newContents.append( contents );
- contents = newContents;
- parent = parent.parentNode;
- }
- // Set clipboard data
- setClipboardData( event, contents, root, this._config.willCutCopy, null, false );
- }
-};
-
-// Need to monitor for shift key like this, as event.shiftKey is not available
-// in paste event.
-function monitorShiftKey ( event ) {
- this.isShiftDown = event.shiftKey;
-}
-
-let onPaste = function ( event ) {
- let clipboardData = event.clipboardData;
- let items = clipboardData && clipboardData.items;
- let choosePlain = this.isShiftDown;
- let fireDrop = false;
- let hasRTF = false;
- let hasImage = false;
- let plainItem = null;
- let htmlItem = null;
- let self = this;
- let l, item, type, types, data;
-
- // Current HTML5 Clipboard interface
- // ---------------------------------
- // https://html.spec.whatwg.org/multipage/interaction.html
- if ( items ) {
- l = items.length;
- while ( l-- ) {
- item = items[l];
- type = item.type;
- if ( type === 'text/html' ) {
- htmlItem = item;
- // iOS copy URL gives you type text/uri-list which is just a list
- // of 1 or more URLs separated by new lines. Can just treat as
- // plain text.
- } else if ( type === 'text/plain' || type === 'text/uri-list' ) {
- plainItem = item;
- } else if ( type === 'text/rtf' ) {
- hasRTF = true;
- } else if ( /^image\/.*/.test( type ) ) {
- hasImage = true;
- }
- }
-
- // Treat image paste as a drop of an image file. When you copy
- // an image in Chrome/Firefox (at least), it copies the image data
- // but also an HTML version (referencing the original URL of the image)
- // and a plain text version.
- //
- // However, when you copy in Excel, you get html, rtf, text, image;
- // in this instance you want the html version! So let's try using
- // the presence of text/rtf as an indicator to choose the html version
- // over the image.
- if ( hasImage && !( hasRTF && htmlItem ) ) {
- event.preventDefault();
- this.fireEvent( 'dragover', {
- dataTransfer: clipboardData,
- /*jshint loopfunc: true */
- preventDefault: () => fireDrop = true
- /*jshint loopfunc: false */
- });
- if ( fireDrop ) {
- this.fireEvent( 'drop', {
- dataTransfer: clipboardData
- });
- }
- return;
- }
-
- // Edge only provides access to plain text as of 2016-03-11 and gives no
- // indication there should be an HTML part. However, it does support
- // access to image data, so we check for that first. Otherwise though,
- // fall through to fallback clipboard handling methods
- if ( !isEdge ) {
- event.preventDefault();
- if ( htmlItem && ( !choosePlain || !plainItem ) ) {
- htmlItem.getAsString( html => self.insertHTML( html, true ) );
- } else if ( plainItem ) {
- plainItem.getAsString( text => self.insertPlainText( text, true ) );
- }
- return;
- }
- }
-
- // Safari (and indeed many other OS X apps) copies stuff as text/rtf
- // rather than text/html; even from a webpage in Safari. The only way
- // to get an HTML version is to fallback to letting the browser insert
- // the content. Same for getting image data. *Sigh*.
- types = clipboardData && clipboardData.types;
- if ( !isEdge && types && (
- indexOf( types, 'text/html' ) > -1 || (
- !isGecko &&
- indexOf( types, 'text/plain' ) > -1 &&
- indexOf( types, 'text/rtf' ) < 0 )
- )) {
- event.preventDefault();
- // Abiword on Linux copies a plain text and html version, but the HTML
- // version is the empty string! So always try to get HTML, but if none,
- // insert plain text instead. On iOS, Facebook (and possibly other
- // apps?) copy links as type text/uri-list, but also insert a **blank**
- // text/plain item onto the clipboard. Why? Who knows.
- if ( !choosePlain && ( data = clipboardData.getData( 'text/html' ) ) ) {
- this.insertHTML( data, true );
- } else if (
- ( data = clipboardData.getData( 'text/plain' ) ) ||
- ( data = clipboardData.getData( 'text/uri-list' ) ) ) {
- this.insertPlainText( data, true );
- }
- return;
- }
-};
-
-// On Windows you can drag an drop text. We can't handle this ourselves, because
-// as far as I can see, there's no way to get the drop insertion point. So just
-// save an undo state and hope for the best.
-let onDrop = function ( event ) {
- let types = event.dataTransfer.types;
- let l = types.length;
- let hasPlain = false;
- let hasHTML = false;
- while ( l-- ) {
- switch ( types[l] ) {
- case 'text/plain':
- hasPlain = true;
- break;
- case 'text/html':
- hasHTML = true;
- break;
- default:
- return;
- }
- }
- if ( hasHTML || hasPlain ) {
- this.saveUndoState();
- }
-};
-
-function mergeObjects ( base, extras, mayOverride ) {
- let 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 ) {
- this._root = root;
-
- this._events = {};
-
- this._isFocused = false;
- this._lastRange = null;
-
- this._hasZWS = false;
-
- this._lastAnchorNode = null;
- this._lastFocusNode = null;
- this._path = '';
-
- let _willUpdatePath;
- const selectionchange = () => {
- if (root.contains(doc.activeElement)) {
- if ( this._isFocused && !_willUpdatePath ) {
- _willUpdatePath = setTimeout( () => {
- _willUpdatePath = 0;
- this._updatePath( this.getSelection() );
- }, 0 );
- }
- } else {
- this.removeEventListener('selectionchange', selectionchange);
- }
- };
- this.addEventListener('selectstart', () => this.addEventListener('selectionchange', selectionchange));
-
- this._undoIndex = -1;
- this._undoStack = [];
- this._undoStackLength = 0;
- this._isInUndoState = false;
- this._ignoreChange = false;
- this._ignoreAllChanges = false;
-
- this._mutation = new MutationObserver( ()=>this._docWasChanged() );
- this._mutation.observe( root, {
- childList: true,
- attributes: true,
- characterData: true,
- subtree: true
- });
-
- // 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;
- // https://caniuse.com/mdn-api_document_pointerup_event
- this.addEventListener( 'blur', () => this._restoreSelection = true )
- .addEventListener( 'pointerdown mousedown touchstart', () => this._restoreSelection = false )
- .addEventListener( 'focus', () => this._restoreSelection && this.setSelection( this._lastRange ) )
- .addEventListener( 'cut', onCut )
- .addEventListener( 'copy', onCopy )
- .addEventListener( 'keydown keyup', monitorShiftKey )
- .addEventListener( 'paste', onPaste )
- .addEventListener( 'drop', onDrop )
- .addEventListener( 'keydown', onKey )
- .addEventListener( 'pointerup keyup mouseup touchend', ()=>this.getSelection() );
-
- // 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( '' );
-}
-
-let proto = Squire.prototype;
-
-let sanitizeToDOMFragment = ( html ) => {
- let frag = html ? win.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: win.DOMPurify && win.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.createDefaultBlock = function ( children ) {
- let config = this._config;
- return fixCursor(
- createElement( config.blockTag, config.blockAttributes, children ),
- this._root
- );
-};
-
-proto.didError = error => console.error( error );
-
-proto.getRoot = function () {
- return this._root;
-};
-
-proto.modifyDocument = function ( modificationCallback ) {
- let 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, {
+ this._mutation = new MutationObserver( ()=>this._docWasChanged() );
+ this._mutation.observe( root, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
- this._ignoreChange = false;
+
+ // 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;
+ // https://caniuse.com/mdn-api_document_pointerup_event
+ this.addEventListener( 'blur', () => this._restoreSelection = true )
+ .addEventListener( 'pointerdown mousedown touchstart', () => this._restoreSelection = false )
+ .addEventListener( 'focus', () => this._restoreSelection && this.setSelection( this._lastRange ) )
+ .addEventListener( 'cut', onCut )
+ .addEventListener( 'copy', onCopy )
+ .addEventListener( 'keydown keyup', monitorShiftKey )
+ .addEventListener( 'paste', onPaste )
+ .addEventListener( 'drop', onDrop )
+ .addEventListener( 'keydown', onKey )
+ .addEventListener( 'pointerup keyup mouseup touchend', ()=>this.getSelection() );
+
+ // 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( '' );
}
-};
-// --- Events ---
+ setConfig ( config ) {
+ config = mergeObjects({
+ blockTag: 'DIV',
+ undo: {
+ documentSizeThreshold: -1, // -1 means no threshold
+ undoLimit: -1 // -1 means no limit
+ },
+ sanitizeToDOMFragment: null,
+ addLinks: true
+ }, config, true );
-// 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.
-let customEvents = {
- pathChange: 1, select: 1, input: 1, undoStateChange: 1
-};
+ // Users may specify block tag in lower case
+ config.blockTag = config.blockTag.toUpperCase();
-proto.fireEvent = function ( type, event ) {
- let handlers = this._events[ type ];
- let 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 === doc.activeElement;
- if ( type === 'focus' ) {
- if ( !isFocused || this._isFocused ) {
- return this;
+ this._config = config;
+
+ return this;
+ }
+
+ createDefaultBlock ( children ) {
+ let config = this._config;
+ return fixCursor(
+ createElement( config.blockTag, null, children ),
+ this._root
+ );
+ }
+
+ getRoot () {
+ return this._root;
+ }
+
+ modifyDocument ( modificationCallback ) {
+ let mutation = this._mutation;
+ if ( mutation ) {
+ if ( mutation.takeRecords().length ) {
+ this._docWasChanged();
}
- this._isFocused = true;
- } else {
- if ( isFocused || !this._isFocused ) {
- return this;
- }
- this._isFocused = false;
+ mutation.disconnect();
}
- }
- 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 () {
- let events = this._events;
- let type;
+ this._ignoreAllChanges = true;
+ modificationCallback();
+ this._ignoreAllChanges = false;
- 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 ) {
- type.split(/\s+/).forEach(type=>{
- let handlers = this._events[ type ],
- target = this._root;
- if ( !fn ) {
- this.didError({
- name: 'Squire: addEventListener with null or undefined fn',
- message: 'Event type: ' + type
+ if ( mutation ) {
+ mutation.observe( this._root, {
+ childList: true,
+ attributes: true,
+ characterData: true,
+ subtree: true
});
- return this;
+ this._ignoreChange = false;
}
- if ( !handlers ) {
- handlers = this._events[ type ] = [];
- if ( !customEvents[ type ] ) {
- if ( type === 'selectionchange' ) {
- target = doc;
+ }
+
+ // --- Events ---
+
+ fireEvent ( type, event ) {
+ let handlers = this._events[ type ];
+ let 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 === doc.activeElement;
+ if ( type === 'focus' ) {
+ if ( !isFocused || this._isFocused ) {
+ return this;
}
- target.addEventListener( type, this, {capture:true,passive:'touchstart'===type} );
+ this._isFocused = true;
+ } else {
+ if ( isFocused || !this._isFocused ) {
+ return this;
+ }
+ this._isFocused = false;
}
}
- handlers.push( fn );
- });
- return this;
-};
-
-proto.removeEventListener = function ( type, fn ) {
- let handlers = this._events[ type ];
- let target = this._root;
- let l;
- if ( handlers ) {
- if ( fn ) {
+ 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-- ) {
- if ( handlers[l] === fn ) {
- handlers.splice( l, 1 );
+ 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;
+ didError( error );
}
}
- } else {
- handlers.length = 0;
}
- if ( !handlers.length ) {
- delete this._events[ type ];
- if ( !customEvents[ type ] ) {
- if ( type === 'selectionchange' ) {
- target = doc;
+ return this;
+ }
+
+ destroy () {
+ let events = this._events;
+ let 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;
+ }
+
+ handleEvent ( event ) {
+ this.fireEvent( event.type, event );
+ }
+
+ addEventListener ( type, fn ) {
+ type.split(/\s+/).forEach(type=>{
+ let handlers = this._events[ type ],
+ target = this._root;
+ if ( !fn ) {
+ 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 = doc;
+ }
+ target.addEventListener( type, this, {capture:true,passive:'touchstart'===type} );
}
- target.removeEventListener( type, this, true );
}
- }
- }
- return this;
-};
-
-// --- Selection and Path ---
-
-proto.createRange = ( range, startOffset, endContainer, endOffset ) => {
- if ( range instanceof win.Range ) {
- return range.cloneRange();
- }
- let domRange = 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
- let rect = range.getBoundingClientRect();
- let node, parent;
- if ( rect && !rect.top ) {
- this._ignoreChange = true;
- node = createElement( 'SPAN' );
- node.textContent = ZWS;
- insertNodeInRange( range, node );
- rect = node.getBoundingClientRect();
- parent = node.parentNode;
- node.remove( );
- mergeInlines( parent, range );
- }
- return rect;
-};
-
-proto.setSelection = function ( range ) {
- if ( range ) {
- this._lastRange = 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 ) {
- this._restoreSelection = true;
- } 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 ) {
- win.focus();
- }
- let sel = win.getSelection();
- 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 () {
- let sel = win.getSelection();
- let root = this._root;
- let range, startContainer, endContainer, node;
- // If not focused, always rely on cached range; another function may
- // have set it but the DOM is not modified until focus again
- if ( this._isFocused && sel && sel.rangeCount ) {
- range = sel.getRangeAt( 0 ).cloneRange();
- startContainer = range.startContainer;
- endContainer = range.endContainer;
- // FF can return the range as being inside an
![]()
. WTF?
- if ( startContainer && isLeaf( startContainer ) ) {
- range.setStartBefore( startContainer );
- }
- if ( endContainer && isLeaf( endContainer ) ) {
- range.setEndBefore( endContainer );
- }
- }
- if ( range && root.contains( range.commonAncestorContainer ) ) {
- this._lastRange = range;
- } else {
- range = this._lastRange;
- node = range.commonAncestorContainer;
- // Check the editor is in the live document; if not, the range has
- // probably been rewritten by the browser and is bogus
- if ( !doc.contains( node ) ) {
- range = null;
- }
- }
- return range || this.createRange( root.firstChild, 0 );
-};
-
-proto.getSelectionClosest = function (selector) {
- let range = this.getSelection();
- return range && getClosest(range.commonAncestorContainer, this._root, selector);
-};
-
-proto.selectionContains = function (selector) {
- let range = this.getSelection(),
- node = range && range.commonAncestorContainer;
- if (node && !range.collapsed) {
- node = node.querySelector ? node : node.parentElement;
- // TODO: isNodeContainedInRange( range, node ) for real selection match?
- return !!(node && node.querySelector(selector));
- }
- return false;
-};
-
-proto.getSelectedText = function () {
- let range = this.getSelection();
- if ( !range || range.collapsed ) {
- return '';
- }
- let walker = createTreeWalker(
- range.commonAncestorContainer,
- SHOW_TEXT|SHOW_ELEMENT,
- node => isNodeContainedInRange( range, node )
- );
- let startContainer = range.startContainer;
- let endContainer = range.endContainer;
- let node = walker.currentNode = startContainer;
- let textContent = '';
- let addedTextInBlock = false;
- let value;
-
- if ( filterAccept != walker.filter.acceptNode( node ) ) {
- node = walker.nextNode();
+ handlers.push( fn );
+ });
+ return this;
}
- while ( node ) {
- if ( node.nodeType === TEXT_NODE ) {
- value = node.data;
- if ( value && ( /\S/.test( value ) ) ) {
- if ( node === endContainer ) {
- value = value.slice( 0, range.endOffset );
+ removeEventListener ( type, fn ) {
+ let handlers = this._events[ type ];
+ let target = this._root;
+ let l;
+ if ( handlers ) {
+ if ( fn ) {
+ l = handlers.length;
+ while ( l-- ) {
+ if ( handlers[l] === fn ) {
+ handlers.splice( l, 1 );
+ }
}
- 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.
-let removeZWS = ( root, keepNode ) => {
- let walker = createTreeWalker( root, SHOW_TEXT );
- let 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;
- node.remove( );
- node = parent;
- walker.currentNode = parent;
- } while ( isInline( node ) && !getLength( node ) );
- break;
} else {
- node.deleteData( index, 1 );
+ handlers.length = 0;
+ }
+ if ( !handlers.length ) {
+ delete this._events[ type ];
+ if ( !customEvents[ type ] ) {
+ if ( type === 'selectionchange' ) {
+ target = doc;
+ }
+ target.removeEventListener( type, this, true );
+ }
}
}
+ return this;
}
-};
-proto._didAddZWS = function () {
- this._hasZWS = true;
-};
-proto._removeZWS = function () {
- if ( !this._hasZWS ) {
- return;
- }
- removeZWS( this._root );
- this._hasZWS = false;
-};
+ // --- Selection and Path ---
-// --- Path change events ---
-
-proto._updatePath = function ( range, force ) {
- if ( !range ) {
- return;
- }
- let 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 } );
+ getCursorPosition ( range ) {
+ if ( ( !range && !( range = this.getSelection() ) ) ||
+ !range.getBoundingClientRect ) {
+ return null;
}
- }
- this.fireEvent( range.collapsed ? 'cursor' : 'select', {
- range: range
- });
-};
-
-// --- Focus ---
-
-proto.focus = function () {
- this._root.focus({ preventScroll: true });
- return this;
-};
-
-proto.blur = function () {
- this._root.blur();
- return this;
-};
-
-// --- Bookmarking ---
-
-let startSelectionId = 'squire-selection-start';
-let endSelectionId = 'squire-selection-end';
-
-const createBookmarkNodes = () => [
- createElement( 'INPUT', {
- id: startSelectionId,
- type: 'hidden'
- }),
- createElement( 'INPUT', {
- id: endSelectionId,
- type: 'hidden'
- })
-];
-
-proto._saveRangeToBookmark = function ( range ) {
- let [startNode, endNode] = createBookmarkNodes(this),
- 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;
+ // Get the bounding rect
+ let rect = range.getBoundingClientRect();
+ let node, parent;
+ if ( rect && !rect.top ) {
+ this._ignoreChange = true;
+ node = createElement( 'SPAN' );
+ node.textContent = ZWS;
+ insertNodeInRange( range, node );
+ rect = node.getBoundingClientRect();
+ parent = node.parentNode;
+ node.remove( );
+ mergeInlines( parent, range );
+ }
+ return rect;
}
- range.setStartAfter( startNode );
- range.setEndBefore( endNode );
-};
-
-proto._getRangeAndRemoveBookmark = function ( range ) {
- let root = this._root,
- start = root.querySelector( '#' + startSelectionId ),
- end = root.querySelector( '#' + endSelectionId );
-
- if ( start && end ) {
- let startContainer = start.parentNode,
- endContainer = end.parentNode,
- startOffset = indexOf( startContainer.childNodes, start ),
- endOffset = indexOf( endContainer.childNodes, end );
-
- if ( startContainer === endContainer ) {
- --endOffset;
+ setSelection ( range ) {
+ if ( range ) {
+ this._lastRange = 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 ) {
+ this._restoreSelection = true;
+ } 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 ) {
+ win.focus();
+ }
+ let sel = win.getSelection();
+ 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;
+ }
- detach( start );
- detach( end );
-
- if ( !range ) {
- range = 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 ) {
+ getSelection () {
+ let sel = win.getSelection();
+ let root = this._root;
+ let range, startContainer, endContainer, node;
+ // If not focused, always rely on cached range; another function may
+ // have set it but the DOM is not modified until focus again
+ if ( this._isFocused && sel && sel.rangeCount ) {
+ range = sel.getRangeAt( 0 ).cloneRange();
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 );
- }
+ endContainer = range.endContainer;
+ // FF can return the range as being inside an
![]()
. WTF?
+ if ( startContainer && isLeaf( startContainer ) ) {
+ range.setStartBefore( startContainer );
+ }
+ if ( endContainer && isLeaf( endContainer ) ) {
+ range.setEndBefore( endContainer );
}
}
- }
- return range || null;
-};
-
-// --- Undo ---
-
-proto._keyUpDetectChange = event => {
- let 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[osKey] && !event.altKey &&
- ( code < 16 || code > 20 ) &&
- ( code < 33 || code > 45 ) ) {
- this._docWasChanged();
- }
-};
-
-proto._docWasChanged = function () {
- nodeCategoryCache = new WeakMap();
- if ( this._ignoreAllChanges ) {
- return;
- }
-
- if ( 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
- let undoIndex = this._undoIndex;
- let undoStack = this._undoStack;
- let undoConfig = this._config.undo;
- let undoThreshold = undoConfig.documentSizeThreshold;
- let undoLimit = undoConfig.undoLimit;
- let html;
-
- if ( !replace ) {
- ++undoIndex;
- }
-
- // 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;
+ if ( range && root.contains( range.commonAncestorContainer ) ) {
+ this._lastRange = range;
+ } else {
+ range = this._lastRange;
+ node = range.commonAncestorContainer;
+ // Check the editor is in the live document; if not, the range has
+ // probably been rewritten by the browser and is bogus
+ if ( !doc.contains( node ) ) {
+ range = null;
}
}
-
- // Save data
- undoStack[ undoIndex ] = html;
- this._undoIndex = undoIndex;
- ++this._undoStackLength;
- this._isInUndoState = true;
+ return range || createRange( root.firstChild, 0 );
}
-};
-proto.saveUndoState = function ( range ) {
- if ( range === undefined ) {
- range = this.getSelection();
+ getSelectionClosest (selector) {
+ let range = this.getSelection();
+ return range && getClosest(range.commonAncestorContainer, this._root, selector);
}
- 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;
- this._setHTML( this._undoStack[ this._undoIndex ] );
- let range = this._getRangeAndRemoveBookmark();
- if ( range ) {
- this.setSelection( range );
+ selectionContains (selector) {
+ let range = this.getSelection(),
+ node = range && range.commonAncestorContainer;
+ if (node && !range.collapsed) {
+ node = node.querySelector ? node : node.parentElement;
+ // TODO: isNodeContainedInRange( range, node ) for real selection match?
+ return !!(node && node.querySelector(selector));
}
- 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.
- let undoIndex = this._undoIndex,
- undoStackLength = this._undoStackLength;
- if ( undoIndex + 1 < undoStackLength && this._isInUndoState ) {
- ++this._undoIndex;
- this._setHTML( this._undoStack[ this._undoIndex ] );
- let 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 );
+ getSelectedText () {
+ let range = this.getSelection();
+ if ( !range || range.collapsed ) {
+ return '';
+ }
+ let walker = createTreeWalker(
+ range.commonAncestorContainer,
+ SHOW_TEXT|SHOW_ELEMENT,
+ node => isNodeContainedInRange( range, node )
+ );
+ let startContainer = range.startContainer;
+ let endContainer = range.endContainer;
+ let node = walker.currentNode = startContainer;
+ let textContent = '';
+ let addedTextInBlock = false;
+ let value;
+
+ if ( filterAccept != walker.filter.acceptNode( 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;
}
- // If the common ancestor is inside the tag we require, we definitely
- // have the format.
- let root = this._root;
- let common = range.commonAncestorContainer;
- let walker, node;
- if ( getNearest( common, root, tag, attributes ) ) {
- return true;
+ getPath () {
+ return this._path;
}
- // 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;
+ // --- Workaround for browsers that can't focus empty text nodes ---
+
+ // WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=15256
+
+ _addZWS () {
+ if ( isWebKit ) {
+ this._hasZWS = true;
+ return doc.createTextNode( ZWS );
+ }
+ return doc.createTextNode( '' );
+ }
+ _removeZWS () {
+ if ( this._hasZWS ) {
+ removeZWS( this._root );
+ this._hasZWS = 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 = createTreeWalker( common, SHOW_TEXT, node => isNodeContainedInRange( range, node ) );
+ // --- Path change events ---
- let seenNode = false;
- while ( node = walker.nextNode() ) {
- if ( !getNearest( node, root, tag, attributes ) ) {
+ _updatePath ( range, force ) {
+ if ( !range ) {
+ return;
+ }
+ let 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
+ });
+ }
+
+ // --- Focus ---
+
+ focus () {
+ this._root.focus({ preventScroll: true });
+ return this;
+ }
+
+ blur () {
+ this._root.blur();
+ return this;
+ }
+
+ // --- Bookmarking ---
+
+ _saveRangeToBookmark ( range ) {
+ let [startNode, endNode] = createBookmarkNodes(this),
+ 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 );
+ }
+
+ _getRangeAndRemoveBookmark ( range ) {
+ let root = this._root,
+ start = root.querySelector( '#' + startSelectionId ),
+ end = root.querySelector( '#' + endSelectionId );
+
+ if ( start && end ) {
+ let startContainer = start.parentNode,
+ endContainer = end.parentNode,
+ startOffset = indexOf( startContainer.childNodes, start ),
+ endOffset = indexOf( endContainer.childNodes, end );
+
+ if ( startContainer === endContainer ) {
+ --endOffset;
+ }
+
+ detach( start );
+ detach( end );
+
+ if ( !range ) {
+ range = 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 ---
+
+ _docWasChanged () {
+ nodeCategoryCache = new WeakMap();
+ if ( this._ignoreAllChanges ) {
+ return;
+ }
+
+ if ( this._ignoreChange ) {
+ this._ignoreChange = false;
+ return;
+ }
+ if ( this._isInUndoState ) {
+ this._isInUndoState = false;
+ this.fireEvent( 'undoStateChange', {
+ canUndo: true,
+ canRedo: false
+ });
+ }
+ this.fireEvent( 'input' );
+ }
+
+ // Leaves bookmark
+ _recordUndoState ( range, replace ) {
+ // Don't record if we're already in an undo state
+ if ( !this._isInUndoState|| replace ) {
+ // Advance pointer to new position
+ let undoIndex = this._undoIndex;
+ let undoStack = this._undoStack;
+ let undoConfig = this._config.undo;
+ let undoThreshold = undoConfig.documentSizeThreshold;
+ let undoLimit = undoConfig.undoLimit;
+ let html;
+
+ if ( !replace ) {
+ ++undoIndex;
+ }
+
+ // 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;
+ this._isInUndoState = true;
+ }
+ }
+
+ saveUndoState ( range ) {
+ if ( range === undefined ) {
+ range = this.getSelection();
+ }
+ this._recordUndoState( range, this._isInUndoState );
+ this._getRangeAndRemoveBookmark( range );
+
+ return this;
+ }
+
+ undo () {
+ // 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;
+ this._setHTML( this._undoStack[ this._undoIndex ] );
+ let 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;
+ }
+
+ redo () {
+ // Sanity check: must not be at end of stack and must be in an undo
+ // state.
+ let undoIndex = this._undoIndex,
+ undoStackLength = this._undoStackLength;
+ if ( undoIndex + 1 < undoStackLength && this._isInUndoState ) {
+ ++this._undoIndex;
+ this._setHTML( this._undoStack[ this._undoIndex ] );
+ let 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.
+ hasFormat ( tag, attributes, range ) {
+ // 1. Normalise the arguments and get selection
+ tag = tag.toUpperCase();
+ if ( !attributes ) { attributes = {}; }
+ if ( !range && !( range = this.getSelection() ) ) {
return false;
}
- seenNode = true;
+
+ // 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.
+ let root = this._root;
+ let common = range.commonAncestorContainer;
+ let 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 = createTreeWalker( common, SHOW_TEXT, node => isNodeContainedInRange( range, node ) );
+
+ let seenNode = false;
+ while ( node = walker.nextNode() ) {
+ if ( !getNearest( node, root, tag, attributes ) ) {
+ return false;
+ }
+ seenNode = true;
+ }
+
+ return seenNode;
}
- 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.
+ getFontInfo ( range ) {
+ let fontInfo = {
+ color: undefined,
+ backgroundColor: undefined,
+ family: undefined,
+ size: undefined
+ };
+ let seenAttributes = 0;
+ let element, style, attr;
-// 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 ) {
- let fontInfo = {
- color: undefined,
- backgroundColor: undefined,
- family: undefined,
- size: undefined
- };
- let seenAttributes = 0;
- let element, style, attr;
+ if ( !range && !( range = this.getSelection() ) ) {
+ return fontInfo;
+ }
- if ( !range && !( range = this.getSelection() ) ) {
+ 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;
+ }
+ if ( !fontInfo.backgroundColor &&
+ ( attr = style.backgroundColor ) ) {
+ fontInfo.backgroundColor = attr;
+ ++seenAttributes;
+ }
+ if ( !fontInfo.family && ( attr = style.fontFamily ) ) {
+ fontInfo.family = attr;
+ ++seenAttributes;
+ }
+ if ( !fontInfo.size && ( attr = style.fontSize ) ) {
+ fontInfo.size = attr;
+ ++seenAttributes;
+ }
+ }
+ element = element.parentNode;
+ }
+ }
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;
- }
- if ( !fontInfo.backgroundColor &&
- ( attr = style.backgroundColor ) ) {
- fontInfo.backgroundColor = attr;
- ++seenAttributes;
- }
- if ( !fontInfo.family && ( attr = style.fontFamily ) ) {
- fontInfo.family = attr;
- ++seenAttributes;
- }
- if ( !fontInfo.size && ( attr = style.fontSize ) ) {
- fontInfo.size = attr;
- ++seenAttributes;
- }
+ _addFormat ( tag, attributes, range ) {
+ // If the range is collapsed we simply insert the node by wrapping
+ // it round the range and focus it.
+ let root = this._root;
+ let el, walker, startContainer, endContainer, startOffset, endOffset,
+ node, needsFormat, block;
+
+ if ( range.collapsed ) {
+ el = fixCursor( 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;
}
- element = element.parentNode;
+ removeZWS( block, el );
}
- }
- return fontInfo;
-};
+ // 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 = createTreeWalker(
+ range.commonAncestorContainer,
+ SHOW_TEXT|SHOW_ELEMENT,
+ node => ( node.nodeType === TEXT_NODE ||
+ node.nodeName === 'BR' ||
+ node.nodeName === 'IMG'
+ ) && isNodeContainedInRange( range, node )
+ );
-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.
- let root = this._root;
- let el, walker, startContainer, endContainer, startOffset, endOffset,
- node, needsFormat, block;
+ // 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;
- if ( range.collapsed ) {
- el = fixCursor( createElement( tag, attributes ), root );
- insertNodeInRange( range, el );
- range.setStart( el.firstChild, el.firstChild.length );
- range.collapse( true );
+ // Make sure we start with a valid node.
+ walker.currentNode = startContainer;
+ if ( filterAccept != walker.filter.acceptNode( startContainer ) ) {
+ startContainer = walker.nextNode();
+ startOffset = 0;
+ }
- // 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 = createTreeWalker(
- range.commonAncestorContainer,
- SHOW_TEXT|SHOW_ELEMENT,
- node => ( node.nodeType === TEXT_NODE ||
- node.nodeName === 'BR' ||
- node.nodeName === 'IMG'
- ) && isNodeContainedInRange( range, node )
- );
+ // If there are no interesting nodes in the selection, abort
+ if ( !startContainer ) {
+ return range;
+ }
- // 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 ( filterAccept != walker.filter.acceptNode( 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;
+ 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 );
}
- startContainer = node;
- startOffset = 0;
+ if ( node === startContainer && startOffset ) {
+ node = node.splitText( startOffset );
+ if ( endContainer === startContainer ) {
+ endContainer = node;
+ endOffset -= startOffset;
+ }
+ startContainer = node;
+ startOffset = 0;
+ }
+ el = createElement( tag, attributes );
+ node.replaceWith( el );
+ el.append( node );
}
- el = createElement( tag, attributes );
- node.replaceWith( el );
- el.append( node );
- }
- } while ( walker.nextNode() );
+ } 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;
+ // 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 = createRange(
+ startContainer, startOffset, endContainer, endOffset );
+ }
+ return range;
+ }
+
+ _removeFormat ( tag, attributes, range, partial ) {
+ // Add bookmark
+ this._saveRangeToBookmark( range );
+
+ // We need a node in the selection to break the surrounding
+ // formatted text.
+ let fixer;
+ if ( range.collapsed ) {
+ fixer = this._addZWS();
+ insertNodeInRange( range, fixer );
}
- // Now set the selection to as it was before
- range = this.createRange(
- startContainer, startOffset, endContainer, endOffset );
+ // Find block-level ancestor of selection
+ let 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.
+ let startContainer = range.startContainer,
+ startOffset = range.startOffset,
+ endContainer = range.endContainer,
+ endOffset = range.endOffset,
+ toWrap = [],
+ examineNode = ( 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;
+ }
+
+ let 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 ) ) {
+ // 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 ),
+ el => isNodeContainedInRange( range, el ) && hasTagAttributes( el, tag, attributes )
+ );
+
+ if ( !partial ) {
+ formatTags.forEach( node => examineNode( node, node ) );
+ }
+
+ // Now wrap unselected nodes in the tag
+ toWrap.forEach( item => {
+ // [ exemplar, node ] tuple
+ let el = item[0].cloneNode( false ),
+ node = item[1];
+ node.replaceWith( el );
+ el.append( node );
+ });
+ // and remove old formatting tags.
+ formatTags.forEach( el => el.replaceWith( empty( el ) ) );
+
+ // Merge adjacent inlines:
+ this._getRangeAndRemoveBookmark( range );
+ if ( fixer ) {
+ range.collapse( false );
+ }
+ mergeInlines( root, range );
+
+ return range;
}
- 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.
- let fixer;
- if ( range.collapsed ) {
- if ( isWebKit ) {
- fixer = doc.createTextNode( ZWS );
- this._didAddZWS();
+ toggleTag ( name, remove ) {
+ let range = this.getSelection();
+ if ( this.hasFormat( name, null, range ) ) {
+ this.changeFormat ( null, { tag: name }, range );
} else {
- fixer = doc.createTextNode( '' );
+ this.changeFormat ( { tag: name }, remove ? { tag: remove } : null, range );
}
- insertNodeInRange( range, fixer );
}
- // Find block-level ancestor of selection
- let 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.
- let startContainer = range.startContainer,
- startOffset = range.startOffset,
- endContainer = range.endContainer,
- endOffset = range.endOffset,
- toWrap = [],
- examineNode = ( 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;
- }
-
- let 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 ) ) {
- // 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 ),
- el => isNodeContainedInRange( range, el ) && hasTagAttributes( el, tag, attributes )
- );
-
- if ( !partial ) {
- formatTags.forEach( node => examineNode( node, node ) );
- }
-
- // Now wrap unselected nodes in the tag
- toWrap.forEach( item => {
- // [ exemplar, node ] tuple
- let el = item[0].cloneNode( false ),
- node = item[1];
- node.replaceWith( el );
- el.append( node );
- });
- // and remove old formatting tags.
- formatTags.forEach( el => el.replaceWith( 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 );
-
- return this;
-};
-
-// --- Block formatting ---
-
-let tagAfterSplit = {
- DT: 'DD',
- DD: 'DT',
- LI: 'LI',
- PRE: 'PRE'
-};
-
-let splitBlock = ( self, block, node, offset ) => {
- let 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( splitTag, splitProperties );
- if ( nodeAfterSplit.dir ) {
- block.dir = nodeAfterSplit.dir;
+ changeFormat ( add, remove, range, partial ) {
+ // Normalise the arguments and get selection
+ if ( !range && !( range = this.getSelection() ) ) {
+ return this;
}
- nodeAfterSplit.replaceWith( block );
- block.append( empty( nodeAfterSplit ) );
- nodeAfterSplit = block;
- }
- return nodeAfterSplit;
-};
-proto.forEachBlock = function ( fn, mutates, range ) {
- if ( !range && !( range = this.getSelection() ) ) {
- return this;
- }
-
- // Save undo checkpoint
- if ( mutates ) {
+ // 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 );
+
+ return this;
}
- let root = this._root;
- let start = getStartBlockOfRange( range, root );
- let end = getEndBlockOfRange( range, root );
- if ( start && end ) {
- do {
- if ( fn( start ) || start === end ) { break; }
- } while ( start = getNextBlock( start, root ) );
- }
+ // --- Block formatting ---
+
+ forEachBlock ( fn, range ) {
+ if ( !range && !( range = this.getSelection() ) ) {
+ return this;
+ }
+
+ // Save undo checkpoint
+ this.saveUndoState( range );
+
+ let root = this._root;
+ let start = getStartBlockOfRange( range, root );
+ let 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 );
- }
- 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 );
-
- let root = this._root;
- let 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 );
-
- return this;
-};
-
-let increaseBlockQuoteLevel = function ( frag ) {
- return createElement( 'BLOCKQUOTE',
- this._config.tagAttributes.blockquote, [
- frag
- ]);
-};
-
-let decreaseBlockQuoteLevel = frag => {
- var blockquotes = frag.querySelectorAll( 'blockquote' );
- Array.prototype.filter.call( blockquotes, el =>
- !getClosest( el.parentNode, frag, 'BLOCKQUOTE' )
- ).forEach( el => el.replaceWith( empty( el ) ) );
- return frag;
-};
-
-let makeList = ( self, frag, type ) => {
- let 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 = createElement( 'LI', listItemAttrs );
- if ( node.dir ) {
- newLi.dir = node.dir;
- }
-
- // Have we replaced the previous block with a new /?
- if ( ( prev = node.previousSibling ) && prev.nodeName === type ) {
- prev.append( newLi );
- detach( node );
- }
- // Otherwise, replace this block with the /
- else {
- node.replaceWith(
- createElement( type, listAttrs, [
- newLi
- ])
- );
- }
- newLi.append( empty( node ) );
- walker.currentNode = newLi;
- } else {
- node = node.parentNode;
- tag = node.nodeName;
- if ( tag !== type && ( /^[OU]L$/.test( tag ) ) ) {
- node.replaceWith(
- createElement( type, listAttrs, [ empty( node ) ] )
- );
- }
- }
- }
-};
-
-let makeUnorderedList = function ( frag ) {
- makeList( this, frag, 'UL' );
- return frag;
-};
-
-let makeOrderedList = function ( frag ) {
- makeList( this, frag, 'OL' );
- return frag;
-};
-
-let removeList = function ( frag ) {
- let lists = frag.querySelectorAll( 'UL, OL' ),
- items = frag.querySelectorAll( 'LI' ),
- root = this._root,
- i, l, list, listFrag, item;
- for ( i = 0, l = lists.length; i < l; ++i ) {
- list = lists[i];
- listFrag = empty( list );
- fixContainer( listFrag, root );
- list.replaceWith( listFrag );
- }
-
- for ( i = 0, l = items.length; i < l; ++i ) {
- item = items[i];
- if ( isBlock( item ) ) {
- item.replaceWith(
- this.createDefaultBlock([ empty( item ) ])
- );
- } else {
- fixContainer( item, root );
- item.replaceWith( empty( item ) );
- }
- }
- return frag;
-};
-
-let getListSelection = ( range, root ) => {
- // Get start+end li in single common ancestor
- let list = range.commonAncestorContainer;
- let startLi = range.startContainer;
- let endLi = range.endContainer;
- while ( list && list !== root && !/^[OU]L$/.test( list.nodeName ) ) {
- list = list.parentNode;
- }
- if ( !list || list === root ) {
- return null;
- }
- if ( startLi === list ) {
- startLi = startLi.childNodes[ range.startOffset ];
- }
- if ( endLi === list ) {
- endLi = endLi.childNodes[ range.endOffset ];
- }
- while ( startLi && startLi.parentNode !== list ) {
- startLi = startLi.parentNode;
- }
- while ( endLi && endLi.parentNode !== list ) {
- endLi = endLi.parentNode;
- }
- return [ list, startLi, endLi ];
-};
-
-proto.increaseListLevel = function ( range ) {
- if ( !range && !( range = this.getSelection() ) ) {
- return this.focus();
- }
-
- let root = this._root;
- let listSelection = getListSelection( range, root );
- if ( !listSelection ) {
- return this.focus();
- }
-
- let list = listSelection[0];
- let startLi = listSelection[1];
- let endLi = listSelection[2];
- if ( !startLi || startLi === list.firstChild ) {
- return this.focus();
- }
-
- // Save undo checkpoint and bookmark selection
- this._recordUndoState( range, this._isInUndoState );
-
- // Increase list depth
- let type = list.nodeName;
- let newParent = startLi.previousSibling;
- let listAttrs, next;
- if ( newParent.nodeName !== type ) {
- listAttrs = this._config.tagAttributes[ type.toLowerCase() ];
- newParent = createElement( type, listAttrs );
- startLi.before( newParent );
- }
- do {
- next = startLi === endLi ? null : startLi.nextSibling;
- newParent.append( startLi );
- } while ( ( startLi = next ) );
- next = newParent.nextSibling;
- if ( next ) {
- mergeContainers( next, root );
- }
-
- // Restore selection
- this._getRangeAndRemoveBookmark( range );
- this.setSelection( range );
- this._updatePath( range, true );
-
- return this.focus();
-};
-
-proto.decreaseListLevel = function ( range ) {
- if ( !range && !( range = this.getSelection() ) ) {
- return this.focus();
- }
-
- let root = this._root;
- let listSelection = getListSelection( range, root );
- if ( !listSelection ) {
- return this.focus();
- }
-
- let list = listSelection[0];
- let startLi = listSelection[1];
- let endLi = listSelection[2];
- let newParent, next, insertBefore, makeNotList;
- if ( !startLi ) {
- startLi = list.firstChild;
- }
- if ( !endLi ) {
- endLi = list.lastChild;
- }
-
- // Save undo checkpoint and bookmark selection
- this._recordUndoState( range, this._isInUndoState );
-
- if ( startLi ) {
- // Find the new parent list node
- newParent = list.parentNode;
-
- // Split list if necesary
- insertBefore = !endLi.nextSibling ?
- list.nextSibling :
- split( list, endLi.nextSibling, newParent, root );
-
- if ( newParent !== root && newParent.nodeName === 'LI' ) {
- newParent = newParent.parentNode;
- while ( insertBefore ) {
- next = insertBefore.nextSibling;
- endLi.append( insertBefore );
- insertBefore = next;
- }
- insertBefore = list.parentNode.nextSibling;
+ modifyBlocks ( modify, range ) {
+ if ( !range && !( range = this.getSelection() ) ) {
+ return this;
}
- makeNotList = !/^[OU]L$/.test( newParent.nodeName );
+ // 1. Save undo checkpoint and bookmark selection
+ this._recordUndoState( range, this._isInUndoState );
+
+ let root = this._root;
+ let 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 );
+
+ return this;
+ }
+
+ increaseListLevel ( range ) {
+ if ( !range && !( range = this.getSelection() ) ) {
+ return this.focus();
+ }
+
+ let root = this._root;
+ let listSelection = getListSelection( range, root );
+ if ( !listSelection ) {
+ return this.focus();
+ }
+
+ let list = listSelection[0];
+ let startLi = listSelection[1];
+ let endLi = listSelection[2];
+ if ( !startLi || startLi === list.firstChild ) {
+ return this.focus();
+ }
+
+ // Save undo checkpoint and bookmark selection
+ this._recordUndoState( range, this._isInUndoState );
+
+ // Increase list depth
+ let type = list.nodeName;
+ let newParent = startLi.previousSibling;
+ let next;
+ if ( newParent.nodeName !== type ) {
+ newParent = createElement( type );
+ startLi.before( newParent );
+ }
do {
next = startLi === endLi ? null : startLi.nextSibling;
- startLi.remove( );
- if ( makeNotList && startLi.nodeName === 'LI' ) {
- startLi = this.createDefaultBlock([ empty( startLi ) ]);
- }
- newParent.insertBefore( startLi, insertBefore );
- } while (( startLi = next ));
- }
+ newParent.append( startLi );
+ } while ( ( startLi = next ) );
+ next = newParent.nextSibling;
+ if ( next ) {
+ mergeContainers( next, root );
+ }
- if ( !list.firstChild ) {
- detach( list );
- }
-
- if ( insertBefore ) {
- mergeContainers( insertBefore, root );
- }
-
- // Restore selection
- this._getRangeAndRemoveBookmark( range );
- this.setSelection( range );
- this._updatePath( range, true );
-
- return this.focus();
-};
-
-proto._ensureBottomLine = function () {
- let root = this._root;
- let last = root.lastElementChild;
- if ( !last ||
- last.nodeName !== this._config.blockTag || !isBlock( last ) ) {
- root.append( this.createDefaultBlock() );
- }
-};
-
-// --- Keyboard interaction ---
-
-proto.setKeyHandler = function ( key, fn ) {
- this._keyHandlers[ key ] = fn;
- return this;
-};
-
-// --- Get/Set data ---
-
-proto._getHTML = function () {
- return this._root.innerHTML;
-};
-
-proto._setHTML = function ( html ) {
- let root = this._root;
- let node = root;
- node.innerHTML = html;
- do {
- fixCursor( node, root );
- } while ( node = getNextBlock( node, root ) );
- this._ignoreChange = true;
-};
-
-proto.getHTML = function ( withBookMark ) {
- let html, range;
- if ( withBookMark && ( range = this.getSelection() ) ) {
- this._saveRangeToBookmark( range );
- }
- html = this._getHTML().replace( /\u200B/g, '' );
- if ( range ) {
+ // Restore selection
this._getRangeAndRemoveBookmark( range );
- }
- return html;
-};
+ this.setSelection( range );
+ this._updatePath( range, true );
-proto.setHTML = function ( html ) {
- let config = this._config;
- let sanitizeToDOMFragment = config.isSetHTMLSanitized ?
- config.sanitizeToDOMFragment : null;
- let root = this._root;
- let div, frag, child;
-
- // Parse HTML into DOM tree
- if ( typeof sanitizeToDOMFragment === 'function' ) {
- frag = sanitizeToDOMFragment( html, false, this );
- } else {
- div = createElement( 'DIV' );
- div.innerHTML = html;
- frag = doc.createDocumentFragment();
- frag.append( empty( div ) );
+ return this.focus();
}
- cleanTree( frag, config );
- cleanupBRs( frag, root, false );
+ decreaseListLevel ( range ) {
+ if ( !range && !( range = this.getSelection() ) ) {
+ return this.focus();
+ }
- fixContainer( frag, root );
-
- // Fix cursor
- let node, walker = getBlockWalker( frag, root );
- while ( (node = walker.nextNode()) && node !== root ) {
- fixCursor( node, root );
- }
-
- // Don't fire an input event
- this._ignoreChange = true;
-
- // Remove existing root children
- while ( child = root.lastChild ) {
- child.remove( );
- }
-
- // And insert new content
- root.append( frag );
- fixCursor( root, root );
-
- // Reset the undo stack
- this._undoIndex = -1;
- this._undoStack.length = 0;
- this._undoStackLength = 0;
- this._isInUndoState = false;
-
- // Record undo state
- let range = this._getRangeAndRemoveBookmark() ||
- this.createRange( root.firstChild, 0 );
- this.saveUndoState( range );
- // IE will also set focus when selecting text so don't use
- // setSelection. Instead, just store it in lastSelection, so if
- // anything calls getSelection before first focus, we have a range
- // to return.
- this._lastRange = range;
- this._restoreSelection = true;
- 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.
let root = this._root;
- let splitNode = getStartBlockOfRange( range, root ) || root;
- let parent, nodeAfterSplit;
- // While at end of container node, move up DOM tree.
- while ( splitNode !== root && !splitNode.nextSibling ) {
- splitNode = splitNode.parentNode;
+ let listSelection = getListSelection( range, root );
+ if ( !listSelection ) {
+ return this.focus();
}
- // If in the middle of a container node, split up to root.
- if ( splitNode !== root ) {
- parent = splitNode.parentNode;
- nodeAfterSplit = split( parent, splitNode.nextSibling, root, root );
+
+ let list = listSelection[0];
+ let startLi = listSelection[1];
+ let endLi = listSelection[2];
+ let newParent, next, insertBefore, makeNotList;
+ if ( !startLi ) {
+ startLi = list.firstChild;
}
- if ( nodeAfterSplit ) {
- nodeAfterSplit.before( el );
- } else {
- root.append( el );
- // Insert blank line below block.
- nodeAfterSplit = this.createDefaultBlock();
- root.append( nodeAfterSplit );
+ if ( !endLi ) {
+ endLi = list.lastChild;
}
- range.setStart( nodeAfterSplit, 0 );
- range.setEnd( nodeAfterSplit, 0 );
- moveRangeBoundariesDownTree( range );
- }
- this.focus();
- this.setSelection( range );
- this._updatePath( range );
- return this;
-};
+ // Save undo checkpoint and bookmark selection
+ this._recordUndoState( range, this._isInUndoState );
-proto.insertImage = function ( src, attributes ) {
- let img = createElement( 'IMG', mergeObjects({
- src: src
- }, attributes, true ));
- this.insertElement( img );
- return img;
-};
+ if ( startLi ) {
+ // Find the new parent list node
+ newParent = list.parentNode;
-proto.linkRegExp = /\b(?:((?:(?:ht|f)tps?:\/\/|www\d{0,3}[.]|[a-z0-9][a-z0-9.-]*[.][a-z]{2,}\/)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:[^\s?&`!()[\]{};:'".,<>«»“”‘’]|\([^\s()<>]+\)))|([\w\-.%+]+@(?:[\w-]+\.)+[a-z]{2,}\b(?:[?][^&?\s]+=[^\s?&`!()[\]{};:'".,<>«»“”‘’]+(?:&[^&?\s]+=[^\s?&`!()[\]{};:'".,<>«»“”‘’]+)*)?))/i;
+ // Split list if necesary
+ insertBefore = !endLi.nextSibling ?
+ list.nextSibling :
+ split( list, endLi.nextSibling, newParent, root );
-let addLinks = ( frag, root, self ) => {
- let walker = createTreeWalker( frag, SHOW_TEXT, node => !getClosest( node, root, 'A' ) );
- let linkRegExp = self.linkRegExp;
- let defaultAttributes = self._config.tagAttributes.a;
- let node, data, parent, match, index, endIndex, child;
- if ( !linkRegExp ) {
- return;
- }
- 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 );
+ if ( newParent !== root && newParent.nodeName === 'LI' ) {
+ newParent = newParent.parentNode;
+ while ( insertBefore ) {
+ next = insertBefore.nextSibling;
+ endLi.append( insertBefore );
+ insertBefore = next;
+ }
+ insertBefore = list.parentNode.nextSibling;
}
- child = createElement( 'A', mergeObjects({
- href: match[1] ?
- /^(?:ht|f)tps?:/i.test( match[1] ) ?
- match[1] :
- 'http://' + match[1] :
- 'mailto:' + match[0]
- }, defaultAttributes, false ));
- child.textContent = data.slice( index, endIndex );
- parent.insertBefore( child, node );
- node.data = data = data.slice( endIndex );
+
+ makeNotList = !/^[OU]L$/.test( newParent.nodeName );
+ do {
+ next = startLi === endLi ? null : startLi.nextSibling;
+ startLi.remove( );
+ if ( makeNotList && startLi.nodeName === 'LI' ) {
+ startLi = this.createDefaultBlock([ empty( startLi ) ]);
+ }
+ newParent.insertBefore( startLi, insertBefore );
+ } while (( startLi = next ));
+ }
+
+ if ( !list.firstChild ) {
+ detach( list );
+ }
+
+ if ( insertBefore ) {
+ mergeContainers( insertBefore, root );
+ }
+
+ // Restore selection
+ this._getRangeAndRemoveBookmark( range );
+ this.setSelection( range );
+ this._updatePath( range, true );
+
+ return this.focus();
+ }
+
+ _ensureBottomLine () {
+ let root = this._root;
+ let last = root.lastElementChild;
+ if ( !last ||
+ last.nodeName !== this._config.blockTag || !isBlock( last ) ) {
+ root.append( this.createDefaultBlock() );
}
}
-};
-// Insert HTML at the cursor location. If the selection is not collapsed
-// insertTreeFragmentIntoRange will delete the selection so that it is replaced
-// by the html being inserted.
-proto.insertHTML = function ( html, isPaste ) {
- let config = this._config;
- let sanitizeToDOMFragment = config.isInsertedHTMLSanitized ?
- config.sanitizeToDOMFragment : null;
- let range = this.getSelection();
- let startFragmentIndex, endFragmentIndex;
- let div, frag, root, node, event;
+ // --- Keyboard interaction ---
- // Edge doesn't just copy the fragment, but includes the surrounding guff
- // including the full of the page. Need to strip this out. If
- // available use DOMPurify to parse and sanitise.
- if ( typeof sanitizeToDOMFragment === 'function' ) {
- frag = sanitizeToDOMFragment( html, isPaste, this );
- } else {
+ setKeyHandler ( key, fn ) {
+ this._keyHandlers[ key ] = fn;
+ return this;
+ }
+
+ // --- Get/Set data ---
+
+ _getHTML () {
+ return this._root.innerHTML;
+ }
+
+ _setHTML ( html ) {
+ let root = this._root;
+ let node = root;
+ node.innerHTML = html;
+ do {
+ fixCursor( node, root );
+ } while ( node = getNextBlock( node, root ) );
+ this._ignoreChange = true;
+ }
+
+ getHTML ( withBookMark ) {
+ let html, range;
+ if ( withBookMark && ( range = this.getSelection() ) ) {
+ this._saveRangeToBookmark( range );
+ }
+ html = this._getHTML().replace( /\u200B/g, '' );
+ if ( range ) {
+ this._getRangeAndRemoveBookmark( range );
+ }
+ return html;
+ }
+
+ setHTML ( html ) {
+ let config = this._config;
+ let sanitizeToDOMFragment = config.sanitizeToDOMFragment;
+ let root = this._root;
+ let div, frag, child;
+
+ // Parse HTML into DOM tree
+ if ( typeof sanitizeToDOMFragment === 'function' ) {
+ frag = sanitizeToDOMFragment( html, false, this );
+ } else {
+ div = createElement( 'DIV' );
+ div.innerHTML = html;
+ frag = doc.createDocumentFragment();
+ frag.append( empty( div ) );
+ }
+
+ cleanTree( frag, config );
+ cleanupBRs( frag, root, false );
+
+ fixContainer( frag, root );
+
+ // Fix cursor
+ let node, walker = getBlockWalker( frag, root );
+ while ( (node = walker.nextNode()) && node !== root ) {
+ fixCursor( node, root );
+ }
+
+ // Don't fire an input event
+ this._ignoreChange = true;
+
+ // Remove existing root children
+ while ( child = root.lastChild ) {
+ child.remove( );
+ }
+
+ // And insert new content
+ root.append( frag );
+ fixCursor( root, root );
+
+ // Reset the undo stack
+ this._undoIndex = -1;
+ this._undoStack.length = 0;
+ this._undoStackLength = 0;
+ this._isInUndoState = false;
+
+ // Record undo state
+ let range = this._getRangeAndRemoveBookmark() ||
+ createRange( root.firstChild, 0 );
+ this.saveUndoState( range );
+ // IE will also set focus when selecting text so don't use
+ // setSelection. Instead, just store it in lastSelection, so if
+ // anything calls getSelection before first focus, we have a range
+ // to return.
+ this._lastRange = range;
+ this._restoreSelection = true;
+ this._updatePath( range, true );
+
+ return this;
+ }
+
+ insertElement ( el, range ) {
+ if ( !range ) {
+ range = this.getSelection();
+ }
+ range.collapse( true );
+ if ( isInline( el ) ) {
+ insertNodeInRange( range, el );
+ range.setStartAfter( el );
+ } else {
+ // Get containing block node.
+ let root = this._root;
+ let splitNode = getStartBlockOfRange( range, root ) || root;
+ let parent, nodeAfterSplit;
+ // While at end of container node, move up DOM tree.
+ while ( splitNode !== root && !splitNode.nextSibling ) {
+ splitNode = splitNode.parentNode;
+ }
+ // If in the middle of a container node, split up to root.
+ if ( splitNode !== root ) {
+ parent = splitNode.parentNode;
+ nodeAfterSplit = split( parent, splitNode.nextSibling, root, root );
+ }
+ if ( nodeAfterSplit ) {
+ nodeAfterSplit.before( el );
+ } else {
+ root.append( el );
+ // Insert blank line below block.
+ nodeAfterSplit = this.createDefaultBlock();
+ root.append( nodeAfterSplit );
+ }
+ range.setStart( nodeAfterSplit, 0 );
+ range.setEnd( nodeAfterSplit, 0 );
+ moveRangeBoundariesDownTree( range );
+ }
+ this.focus();
+ this.setSelection( range );
+ this._updatePath( range );
+
+ return this;
+ }
+
+ insertImage ( src, attributes ) {
+ let img = createElement( 'IMG', mergeObjects({
+ src: src
+ }, attributes, true ));
+ this.insertElement( img );
+ return img;
+ }
+
+ // Insert HTML at the cursor location. If the selection is not collapsed
+ // insertTreeFragmentIntoRange will delete the selection so that it is replaced
+ // by the html being inserted.
+ insertHTML ( html, isPaste ) {
+ let config = this._config;
+ let sanitizeToDOMFragment = config.sanitizeToDOMFragment;
+ let range = this.getSelection();
+ let startFragmentIndex, endFragmentIndex;
+ let div, frag, root, node, event;
+
+ // Edge doesn't just copy the fragment, but includes the surrounding guff
+ // including the full of the page. Need to strip this out.
if ( isPaste ) {
startFragmentIndex = html.indexOf( '' );
endFragmentIndex = html.lastIndexOf( '' );
@@ -3949,517 +3830,501 @@ proto.insertHTML = function ( html, isPaste ) {
html = html.slice( startFragmentIndex + 20, endFragmentIndex );
}
}
- // Wrap with if html contains dangling | tags
- if ( /<\/td>((?!<\/tr>)[\s\S])*$/i.test( html ) ) {
- html = ' |
' + html + '
';
+ if ( typeof sanitizeToDOMFragment === 'function' ) {
+ frag = sanitizeToDOMFragment( html, isPaste, this );
+ } else {
+ // Wrap with if html contains dangling | tags
+ if ( /<\/td>((?!<\/tr>)[\s\S])*$/i.test( html ) ) {
+ html = ' |
' + html + '
';
+ }
+ // Wrap with if html contains dangling tags
+ if ( /<\/tr>((?!<\/table>)[\s\S])*$/i.test( html ) ) {
+ html = '';
+ }
+ // Parse HTML into DOM tree
+ div = createElement( 'DIV' );
+ div.innerHTML = html;
+ frag = doc.createDocumentFragment();
+ frag.append( empty( div ) );
}
- // Wrap with if html contains dangling tags
- if ( /<\/tr>((?!<\/table>)[\s\S])*$/i.test( html ) ) {
- html = '';
+
+ // Record undo checkpoint
+ this.saveUndoState( range );
+
+ try {
+ root = this._root;
+ node = frag;
+ event = {
+ fragment: frag,
+ preventDefault: function () {
+ this.defaultPrevented = true;
+ },
+ defaultPrevented: false
+ };
+
+ addLinks( frag, frag, this );
+ cleanTree( frag, config );
+ cleanupBRs( frag, root, false );
+ removeEmptyInlines( frag );
+ frag.normalize();
+
+ while ( node = getNextBlock( node, frag ) ) {
+ fixCursor( node, root );
+ }
+
+ if ( isPaste ) {
+ this.fireEvent( 'willPaste', event );
+ }
+
+ if ( !event.defaultPrevented ) {
+ insertTreeFragmentIntoRange( range, event.fragment, root );
+ range.collapse( false );
+
+ // After inserting the fragment, check whether the cursor is inside
+ // an element and if so if there is an equivalent cursor
+ // position after the element. If there is, move it there.
+ moveRangeBoundaryOutOf( range, 'A', root );
+
+ this._ensureBottomLine();
+ }
+
+ this.setSelection( range );
+ this._updatePath( range, true );
+ // Safari sometimes loses focus after paste. Weird.
+ if ( isPaste ) {
+ this.focus();
+ }
+ } catch ( error ) {
+ didError( error );
}
- // Parse HTML into DOM tree
- div = createElement( 'DIV' );
- div.innerHTML = html;
- frag = doc.createDocumentFragment();
- frag.append( empty( div ) );
+ return this;
}
- // Record undo checkpoint
- this.saveUndoState( range );
+ insertPlainText ( plainText, isPaste ) {
+ let range = this.getSelection();
+ if ( range.collapsed &&
+ getClosest( range.startContainer, this._root, 'PRE' ) ) {
+ let node = range.startContainer;
+ let offset = range.startOffset;
+ let text, event;
+ if ( !node || node.nodeType !== TEXT_NODE ) {
+ text = doc.createTextNode( '' );
+ node && node.childNodes[ offset ].before( text );
+ node = text;
+ offset = 0;
+ }
+ event = {
+ text: plainText,
+ preventDefault: function () {
+ this.defaultPrevented = true;
+ },
+ defaultPrevented: false
+ };
+ if ( isPaste ) {
+ this.fireEvent( 'willPaste', event );
+ }
- try {
- root = this._root;
- node = frag;
- event = {
- fragment: frag,
- preventDefault: function () {
- this.defaultPrevented = true;
- },
- defaultPrevented: false
- };
+ if ( !event.defaultPrevented ) {
+ plainText = event.text;
+ node.insertData( offset, plainText );
+ range.setStart( node, offset + plainText.length );
+ range.collapse( true );
+ }
+ this.setSelection( range );
+ return this;
+ }
+ let lines = plainText.split( '\n' );
+ let config = this._config;
+ let tag = config.blockTag;
+ let closeBlock = '' + tag + '>';
+ let openBlock = '<' + tag + '>';
+ let i, l, line;
- addLinks( frag, frag, this );
- cleanTree( frag, config );
- cleanupBRs( frag, root, false );
- removeEmptyInlines( frag );
- frag.normalize();
+ for ( i = 0, l = lines.length; i < l; ++i ) {
+ line = lines[i];
+ line = escapeHTML( line ).replace( / (?= )/g, ' ' );
+ // We don't wrap the first line in the block, so if it gets inserted
+ // into a blank line it keeps that line's formatting.
+ // Wrap each line in
+ if ( i ) {
+ line = openBlock + ( line || '
' ) + closeBlock;
+ }
+ lines[i] = line;
+ }
+ return this.insertHTML( lines.join( '' ), isPaste );
+ }
- while ( node = getNextBlock( node, frag ) ) {
- fixCursor( node, root );
+ // --- Formatting ---
+
+ addStyles ( styles ) {
+ if ( styles ) {
+ let head = doc.documentElement.firstChild,
+ style = createElement( 'STYLE', {
+ type: 'text/css'
+ });
+ style.append( doc.createTextNode( styles ) );
+ head.append( style );
+ }
+ return this;
+ }
+
+ makeLink ( url, attributes ) {
+ let range = this.getSelection();
+ if ( range.collapsed ) {
+ let protocolEnd = url.indexOf( ':' ) + 1;
+ if ( protocolEnd ) {
+ while ( url[ protocolEnd ] === '/' ) { ++protocolEnd; }
+ }
+ insertNodeInRange(
+ range,
+ doc.createTextNode( url.slice( protocolEnd ) )
+ );
+ }
+ attributes = mergeObjects(
+ mergeObjects({
+ href: url
+ }, attributes, true ),
+ null,
+ false
+ );
+
+ this.changeFormat({
+ tag: 'A',
+ attributes: attributes
+ }, {
+ tag: 'A'
+ }, range );
+ return this.focus();
+ }
+
+ removeLink () {
+ this.changeFormat( null, {
+ tag: 'A'
+ }, this.getSelection(), true );
+ return this.focus();
+ }
+
+ setStyle ( style ) {
+ let range = this.getSelection();
+ let start = range ? range.startContainer : 0;
+ let end = range ? range.endContainer : 0;
+ // When the selection is all the text inside an element, set style on the element itself
+ if ( start && TEXT_NODE === start.nodeType && 0 === range.startOffset && start === end && end.length === range.endOffset ) {
+ this.saveUndoState( range );
+ setStyle( start.parentNode, style );
+ this.setSelection( range );
+ this._updatePath( range, true );
+ }
+ // Else create a span element
+ else {
+ this.changeFormat({
+ tag: 'SPAN',
+ attributes: {
+ style: style
+ }
+ }, null, range);
+ }
+ return this.focus();
+ }
+
+ setTextColor ( color ) {
+ return this.setStyle({
+ color: color
+ });
+ }
+
+ setBackgroundColor ( color ) {
+ return this.setStyle({
+ backgroundColor: color
+ });
+ }
+
+ setTextAlignment ( alignment ) {
+ this.forEachBlock( block => {
+ block.style.textAlign = alignment || '';
+ } );
+ return this.focus();
+ }
+
+ setTextDirection ( direction ) {
+ this.forEachBlock( block => {
+ if ( direction ) {
+ block.dir = direction;
+ } else {
+ block.removeAttribute( 'dir' );
+ }
+ } );
+ return this.focus();
+ }
+
+ // ---
+
+ code () {
+ let range = this.getSelection();
+ if ( range.collapsed || isContainer( range.commonAncestorContainer ) ) {
+ this.modifyBlocks( frag => {
+ let root = this._root;
+ let output = doc.createDocumentFragment();
+ let walker = getBlockWalker( frag, root );
+ let node;
+ // 1. Extract inline content; drop all blocks and contains.
+ while (( node = walker.nextNode() )) {
+ // 2. Replace
with \n in content
+ let nodes = node.querySelectorAll( 'BR' );
+ let brBreaksLine = [];
+ let l = nodes.length;
+ let i, br;
+
+ // Must calculate whether the
breaks a line first, because if we
+ // have two
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 ) {
+ brBreaksLine[i] = isLineBreak( nodes[i], false );
+ }
+ while ( l-- ) {
+ br = nodes[l];
+ if ( !brBreaksLine[l] ) {
+ detach( br );
+ } else {
+ br.replaceWith( doc.createTextNode( '\n' ) );
+ }
+ }
+ // 3. Remove ; its format clashes with
+ nodes = node.querySelectorAll( 'CODE' );
+ l = nodes.length;
+ while ( l-- ) {
+ detach( nodes[l] );
+ }
+ if ( output.childNodes.length ) {
+ output.append( doc.createTextNode( '\n' ) );
+ }
+ output.append( empty( node ) );
+ }
+ // 4. Replace nbsp with regular sp
+ walker = createTreeWalker( output, SHOW_TEXT );
+ while (( node = walker.nextNode() )) {
+ node.data = node.data.replace( NBSP, ' ' ); // nbsp -> sp
+ }
+ output.normalize();
+ return fixCursor( createElement( 'PRE',
+ null, [
+ output
+ ]), root );
+ }, range );
+ } else {
+ this.changeFormat({
+ tag: 'CODE'
+ }, null, range );
+ }
+ return this.focus();
+ }
+
+ removeCode () {
+ let range = this.getSelection();
+ let ancestor = range.commonAncestorContainer;
+ let inPre = getClosest( ancestor, this._root, 'PRE' );
+ if ( inPre ) {
+ this.modifyBlocks( frag => {
+ let root = this._root;
+ let pres = frag.querySelectorAll( 'PRE' );
+ let l = pres.length;
+ let pre, walker, node, value, contents, index;
+ while ( l-- ) {
+ pre = pres[l];
+ walker = createTreeWalker( pre, SHOW_TEXT );
+ while (( node = walker.nextNode() )) {
+ value = node.data;
+ value = value.replace( / (?= )/g, NBSP ); // sp -> nbsp
+ contents = doc.createDocumentFragment();
+ while (( index = value.indexOf( '\n' ) ) > -1 ) {
+ contents.append(
+ doc.createTextNode( value.slice( 0, index ) )
+ );
+ contents.append( createElement( 'BR' ) );
+ value = value.slice( index + 1 );
+ }
+ node.before( contents );
+ node.data = value;
+ }
+ fixContainer( pre, root );
+ pre.replaceWith( empty( pre ) );
+ }
+ return frag;
+ }, range );
+ } else {
+ this.changeFormat( null, { tag: 'CODE' }, range );
+ }
+ return this.focus();
+ }
+
+ toggleCode () {
+ if ( this.hasFormat( 'PRE' ) || this.hasFormat( 'CODE' ) ) {
+ this.removeCode();
+ } else {
+ this.code();
+ }
+ return this;
+ }
+
+ // ---
+
+ removeAllFormatting ( range ) {
+ if ( !range && !( range = this.getSelection() ) || range.collapsed ) {
+ return this;
}
- if ( isPaste ) {
- this.fireEvent( 'willPaste', event );
+ let root = this._root;
+ let stopNode = range.commonAncestorContainer;
+ while ( stopNode && !isBlock( stopNode ) ) {
+ stopNode = stopNode.parentNode;
+ }
+ if ( !stopNode ) {
+ expandRangeToBlockBoundaries( range, root );
+ stopNode = root;
+ }
+ if ( stopNode.nodeType === TEXT_NODE ) {
+ return this;
}
- if ( !event.defaultPrevented ) {
- insertTreeFragmentIntoRange( range, event.fragment, root );
- range.collapse( false );
+ // Record undo point
+ this.saveUndoState( range );
- // After inserting the fragment, check whether the cursor is inside
- // an element and if so if there is an equivalent cursor
- // position after the element. If there is, move it there.
- moveRangeBoundaryOutOf( range, 'A', root );
+ // Avoid splitting where we're already at edges.
+ moveRangeBoundariesUpTree( range, stopNode, stopNode, root );
- this._ensureBottomLine();
+ // Split the selection up to the block, or if whole selection in same
+ // block, expand range boundaries to ends of block and split up to root.
+ let startContainer = range.startContainer;
+ let startOffset = range.startOffset;
+ let endContainer = range.endContainer;
+ let endOffset = range.endOffset;
+
+ // Split end point first to avoid problems when end and start
+ // in same container.
+ let formattedNodes = doc.createDocumentFragment();
+ let cleanNodes = doc.createDocumentFragment();
+ let nodeAfterSplit = split( endContainer, endOffset, stopNode, root );
+ let nodeInSplit = split( startContainer, startOffset, stopNode, root );
+ let nextNode, childNodes;
+
+ // Then replace contents in split with a cleaned version of the same:
+ // blocks become default blocks, text and leaf nodes survive, everything
+ // else is obliterated.
+ while ( nodeInSplit !== nodeAfterSplit ) {
+ nextNode = nodeInSplit.nextSibling;
+ formattedNodes.append( nodeInSplit );
+ nodeInSplit = nextNode;
}
+ removeFormatting( this, formattedNodes, cleanNodes );
+ cleanNodes.normalize();
+ nodeInSplit = cleanNodes.firstChild;
+ nextNode = cleanNodes.lastChild;
+
+ // Restore selection
+ childNodes = stopNode.childNodes;
+ if ( nodeInSplit ) {
+ stopNode.insertBefore( cleanNodes, nodeAfterSplit );
+ startOffset = indexOf( childNodes, nodeInSplit );
+ endOffset = indexOf( childNodes, nextNode ) + 1;
+ } else {
+ startOffset = indexOf( childNodes, nodeAfterSplit );
+ endOffset = startOffset;
+ }
+
+ // Merge text nodes at edges, if possible
+ range.setStart( stopNode, startOffset );
+ range.setEnd( stopNode, endOffset );
+ mergeInlines( stopNode, range );
+
+ // And move back down the tree
+ moveRangeBoundariesDownTree( range );
this.setSelection( range );
this._updatePath( range, true );
- // Safari sometimes loses focus after paste. Weird.
- if ( isPaste ) {
- this.focus();
- }
- } catch ( error ) {
- this.didError( error );
+
+ return this.focus();
}
- return this;
-};
-let escapeHTML = text => text.replace( '&', '&' )
- .replace( '<', '<' )
- .replace( '>', '>' )
- .replace( '"', '"' );
-
-proto.insertPlainText = function ( plainText, isPaste ) {
- let range = this.getSelection();
- if ( range.collapsed &&
- getClosest( range.startContainer, this._root, 'PRE' ) ) {
- let node = range.startContainer;
- let offset = range.startOffset;
- let text, event;
- if ( !node || node.nodeType !== TEXT_NODE ) {
- text = doc.createTextNode( '' );
- node && node.childNodes[ offset ].before( text );
- node = text;
- offset = 0;
+ changeIndentationLevel (direction) {
+ let parent = this.getSelectionClosest('UL,OL,BLOCKQUOTE');
+ if (parent || 'increase' === direction) {
+ let method = ( !parent || 'BLOCKQUOTE' === parent.nodeName ) ? 'Quote' : 'List';
+ this[ direction + method + 'Level' ]();
}
- event = {
- text: plainText,
- preventDefault: function () {
- this.defaultPrevented = true;
- },
- defaultPrevented: false
- };
- if ( isPaste ) {
- this.fireEvent( 'willPaste', event );
- }
-
- if ( !event.defaultPrevented ) {
- plainText = event.text;
- node.insertData( offset, plainText );
- range.setStart( node, offset + plainText.length );
- range.collapse( true );
- }
- this.setSelection( range );
- return this;
}
- let lines = plainText.split( '\n' );
- let config = this._config;
- let tag = config.blockTag;
- let attributes = config.blockAttributes;
- let closeBlock = '' + tag + '>';
- let openBlock = '<' + tag;
- let attr, i, l, line;
- for ( attr in attributes ) {
- openBlock += ' ' + attr + '="' +
- escapeHTML( attributes[ attr ] ) +
- '"';
- }
- openBlock += '>';
-
- for ( i = 0, l = lines.length; i < l; ++i ) {
- line = lines[i];
- line = escapeHTML( line ).replace( / (?= )/g, ' ' );
- // We don't wrap the first line in the block, so if it gets inserted
- // into a blank line it keeps that line's formatting.
- // Wrap each line in
- if ( i ) {
- line = openBlock + ( line || '
' ) + closeBlock;
- }
- lines[i] = line;
- }
- return this.insertHTML( lines.join( '' ), isPaste );
-};
-
-// --- Formatting ---
-
-let command = ( method, arg, arg2 ) => function () {
- this[ method ]( arg, arg2 );
- return this.focus();
-};
-
-proto.addStyles = function ( styles ) {
- if ( styles ) {
- let head = doc.documentElement.firstChild,
- style = createElement( 'STYLE', {
- type: 'text/css'
- });
- style.append( doc.createTextNode( styles ) );
- head.append( style );
- }
- return this;
-};
-
-proto.bold = command( 'changeFormat', { tag: 'B' } );
-proto.italic = command( 'changeFormat', { tag: 'I' } );
-proto.underline = command( 'changeFormat', { tag: 'U' } );
-proto.strikethrough = command( 'changeFormat', { tag: 'S' } );
-proto.subscript = command( 'changeFormat', { tag: 'SUB' }, { tag: 'SUP' } );
-proto.superscript = command( 'changeFormat', { tag: 'SUP' }, { tag: 'SUB' } );
-
-proto.removeBold = command( 'changeFormat', null, { tag: 'B' } );
-proto.removeItalic = command( 'changeFormat', null, { tag: 'I' } );
-proto.removeUnderline = command( 'changeFormat', null, { tag: 'U' } );
-proto.removeStrikethrough = command( 'changeFormat', null, { tag: 'S' } );
-proto.removeSubscript = command( 'changeFormat', null, { tag: 'SUB' } );
-proto.removeSuperscript = command( 'changeFormat', null, { tag: 'SUP' } );
-
-proto.makeLink = function ( url, attributes ) {
- let range = this.getSelection();
- if ( range.collapsed ) {
- let protocolEnd = url.indexOf( ':' ) + 1;
- if ( protocolEnd ) {
- while ( url[ protocolEnd ] === '/' ) { ++protocolEnd; }
- }
- insertNodeInRange(
- range,
- doc.createTextNode( url.slice( protocolEnd ) )
+ increaseQuoteLevel ( range ) {
+ this.modifyBlocks(
+ frag => createElement( 'BLOCKQUOTE', null, [ frag ]),
+ range
);
+ return this.focus();
}
- attributes = mergeObjects(
- mergeObjects({
- href: url
- }, attributes, true ),
- this._config.tagAttributes.a,
- false
- );
- this.changeFormat({
- tag: 'A',
- attributes: attributes
- }, {
- tag: 'A'
- }, range );
- return this.focus();
-};
-proto.removeLink = function () {
- this.changeFormat( null, {
- tag: 'A'
- }, this.getSelection(), true );
- return this.focus();
-};
+ decreaseQuoteLevel ( range ) {
+ this.modifyBlocks(
+ frag => {
+ var blockquotes = frag.querySelectorAll( 'blockquote' );
+ Array.prototype.filter.call( blockquotes, el =>
+ !getClosest( el.parentNode, frag, 'BLOCKQUOTE' )
+ ).forEach( el => el.replaceWith( empty( el ) ) );
+ return frag;
+ },
+ range
+ );
+ return this.focus();
+ }
-proto.setFontFace = function ( name ) {
- let className = this._config.classNames.fontFamily;
- this.changeFormat( name ? {
- tag: 'SPAN',
- attributes: {
- 'class': className,
- style: 'font-family: ' + name + ', sans-serif;'
- }
- } : null, {
- tag: 'SPAN',
- attributes: { 'class': className }
- });
- return this.focus();
-};
-proto.setFontSize = function ( size ) {
- let className = this._config.classNames.fontSize;
- this.changeFormat( size ? {
- tag: 'SPAN',
- attributes: {
- 'class': className,
- style: 'font-size: ' +
- ( typeof size === 'number' ? size + 'px' : size )
- }
- } : null, {
- tag: 'SPAN',
- attributes: { 'class': className }
- });
- return this.focus();
-};
+ makeUnorderedList () {
+ this.modifyBlocks( frag => makeList( this, frag, 'UL' ) );
+ return this.focus();
+ }
-proto.setTextColour = function ( colour ) {
- let className = this._config.classNames.colour;
- this.changeFormat( colour ? {
- tag: 'SPAN',
- attributes: {
- 'class': className,
- style: 'color:' + colour
- }
- } : null, {
- tag: 'SPAN',
- attributes: { 'class': className }
- });
- return this.focus();
-};
+ makeOrderedList () {
+ this.modifyBlocks( frag => makeList( this, frag, 'OL' ) );
+ return this.focus();
+ }
-proto.setHighlightColour = function ( colour ) {
- let className = this._config.classNames.highlight;
- this.changeFormat( colour ? {
- tag: 'SPAN',
- attributes: {
- 'class': className,
- style: 'background-color:' + colour
- }
- } : colour, {
- tag: 'SPAN',
- attributes: { 'class': className }
- });
- return this.focus();
-};
-
-proto.setTextAlignment = function ( alignment ) {
- this.forEachBlock( block => {
- block.style.textAlign = alignment || '';
- }, true );
- return this.focus();
-};
-
-proto.setTextDirection = function ( direction ) {
- this.forEachBlock( block => {
- if ( direction ) {
- block.dir = direction;
- } else {
- block.removeAttribute( 'dir' );
- }
- }, true );
- return this.focus();
-};
-
-// ---
-
-let addPre = function ( frag ) {
- let root = this._root;
- let output = doc.createDocumentFragment();
- let walker = getBlockWalker( frag, root );
- let node;
- // 1. Extract inline content; drop all blocks and contains.
- while (( node = walker.nextNode() )) {
- // 2. Replace
with \n in content
- let nodes = node.querySelectorAll( 'BR' );
- let brBreaksLine = [];
- let l = nodes.length;
- let i, br;
-
- // Must calculate whether the
breaks a line first, because if we
- // have two
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 ) {
- brBreaksLine[i] = isLineBreak( nodes[i], false );
- }
- while ( l-- ) {
- br = nodes[l];
- if ( !brBreaksLine[l] ) {
- detach( br );
- } else {
- br.replaceWith( doc.createTextNode( '\n' ) );
+ removeList () {
+ this.modifyBlocks( frag => {
+ let lists = frag.querySelectorAll( 'UL, OL' ),
+ items = frag.querySelectorAll( 'LI' ),
+ root = this._root,
+ i, l, list, listFrag, item;
+ for ( i = 0, l = lists.length; i < l; ++i ) {
+ list = lists[i];
+ listFrag = empty( list );
+ fixContainer( listFrag, root );
+ list.replaceWith( listFrag );
}
- }
- // 3. Remove ; its format clashes with
- nodes = node.querySelectorAll( 'CODE' );
- l = nodes.length;
- while ( l-- ) {
- detach( nodes[l] );
- }
- if ( output.childNodes.length ) {
- output.append( doc.createTextNode( '\n' ) );
- }
- output.append( empty( node ) );
- }
- // 4. Replace nbsp with regular sp
- walker = createTreeWalker( output, SHOW_TEXT );
- while (( node = walker.nextNode() )) {
- node.data = node.data.replace( NBSP, ' ' ); // nbsp -> sp
- }
- output.normalize();
- return fixCursor( createElement( 'PRE',
- this._config.tagAttributes.pre, [
- output
- ]), root );
-};
-let removePre = function ( frag ) {
- let root = this._root;
- let pres = frag.querySelectorAll( 'PRE' );
- let l = pres.length;
- let pre, walker, node, value, contents, index;
- while ( l-- ) {
- pre = pres[l];
- walker = createTreeWalker( pre, SHOW_TEXT );
- while (( node = walker.nextNode() )) {
- value = node.data;
- value = value.replace( / (?= )/g, NBSP ); // sp -> nbsp
- contents = doc.createDocumentFragment();
- while (( index = value.indexOf( '\n' ) ) > -1 ) {
- contents.append(
- doc.createTextNode( value.slice( 0, index ) )
- );
- contents.append( createElement( 'BR' ) );
- value = value.slice( index + 1 );
+ for ( i = 0, l = items.length; i < l; ++i ) {
+ item = items[i];
+ if ( isBlock( item ) ) {
+ item.replaceWith(
+ this.createDefaultBlock([ empty( item ) ])
+ );
+ } else {
+ fixContainer( item, root );
+ item.replaceWith( empty( item ) );
+ }
}
- node.before( contents );
- node.data = value;
- }
- fixContainer( pre, root );
- pre.replaceWith( empty( pre ) );
- }
- return frag;
-};
-proto.code = function () {
- let range = this.getSelection();
- if ( range.collapsed || isContainer( range.commonAncestorContainer ) ) {
- this.modifyBlocks( addPre, range );
- } else {
- this.changeFormat({
- tag: 'CODE',
- attributes: this._config.tagAttributes.code
- }, null, range );
+ return frag;
+ });
+ return this.focus();
}
- return this.focus();
-};
-proto.removeCode = function () {
- let range = this.getSelection();
- let ancestor = range.commonAncestorContainer;
- let inPre = getClosest( ancestor, this._root, 'PRE' );
- if ( inPre ) {
- this.modifyBlocks( removePre, range );
- } else {
- this.changeFormat( null, { tag: 'CODE' }, range );
- }
- return this.focus();
-};
-
-proto.toggleCode = function () {
- if ( this.hasFormat( 'PRE' ) || this.hasFormat( 'CODE' ) ) {
- this.removeCode();
- } else {
- this.code();
- }
- return this;
-};
-
-// ---
-
-function removeFormatting ( self, root, clean ) {
- let node, next;
- for ( node = root.firstChild; node; node = next ) {
- next = node.nextSibling;
- if ( isInline( node ) ) {
- if ( node.nodeType === TEXT_NODE || node.nodeName === 'BR' || node.nodeName === 'IMG' ) {
- clean.append( node );
- continue;
- }
- } else if ( isBlock( node ) ) {
- clean.append( self.createDefaultBlock([
- removeFormatting(
- self, node, doc.createDocumentFragment() )
- ]));
- continue;
- }
- removeFormatting( self, node, clean );
- }
- return clean;
+ bold () { this.toggleTag( 'B' ); }
+ italic () { this.toggleTag( 'I' ); }
+ underline () { this.toggleTag( 'U' ); }
+ strikethrough () { this.toggleTag( 'S' ); }
+ subscript () { this.toggleTag( 'SUB', 'SUP' ); }
+ superscript () { this.toggleTag( 'SUP', 'SUB' ); }
}
-proto.removeAllFormatting = function ( range ) {
- if ( !range && !( range = this.getSelection() ) || range.collapsed ) {
- return this;
- }
-
- let root = this._root;
- let stopNode = range.commonAncestorContainer;
- while ( stopNode && !isBlock( stopNode ) ) {
- stopNode = stopNode.parentNode;
- }
- if ( !stopNode ) {
- expandRangeToBlockBoundaries( range, root );
- stopNode = root;
- }
- if ( stopNode.nodeType === TEXT_NODE ) {
- return this;
- }
-
- // Record undo point
- this.saveUndoState( range );
-
- // Avoid splitting where we're already at edges.
- moveRangeBoundariesUpTree( range, stopNode, stopNode, root );
-
- // Split the selection up to the block, or if whole selection in same
- // block, expand range boundaries to ends of block and split up to root.
- let startContainer = range.startContainer;
- let startOffset = range.startOffset;
- let endContainer = range.endContainer;
- let endOffset = range.endOffset;
-
- // Split end point first to avoid problems when end and start
- // in same container.
- let formattedNodes = doc.createDocumentFragment();
- let cleanNodes = doc.createDocumentFragment();
- let nodeAfterSplit = split( endContainer, endOffset, stopNode, root );
- let nodeInSplit = split( startContainer, startOffset, stopNode, root );
- let nextNode, childNodes;
-
- // Then replace contents in split with a cleaned version of the same:
- // blocks become default blocks, text and leaf nodes survive, everything
- // else is obliterated.
- while ( nodeInSplit !== nodeAfterSplit ) {
- nextNode = nodeInSplit.nextSibling;
- formattedNodes.append( nodeInSplit );
- nodeInSplit = nextNode;
- }
- removeFormatting( this, formattedNodes, cleanNodes );
- cleanNodes.normalize();
- nodeInSplit = cleanNodes.firstChild;
- nextNode = cleanNodes.lastChild;
-
- // Restore selection
- childNodes = stopNode.childNodes;
- if ( nodeInSplit ) {
- stopNode.insertBefore( cleanNodes, nodeAfterSplit );
- startOffset = indexOf( childNodes, nodeInSplit );
- endOffset = indexOf( childNodes, nextNode ) + 1;
- } else {
- startOffset = indexOf( childNodes, nodeAfterSplit );
- endOffset = startOffset;
- }
-
- // Merge text nodes at edges, if possible
- range.setStart( stopNode, startOffset );
- range.setEnd( stopNode, endOffset );
- mergeInlines( stopNode, range );
-
- // And move back down the tree
- moveRangeBoundariesDownTree( range );
-
- this.setSelection( range );
- this._updatePath( range, true );
-
- return this.focus();
-};
-
-proto.increaseQuoteLevel = command( 'modifyBlocks', increaseBlockQuoteLevel );
-proto.decreaseQuoteLevel = command( 'modifyBlocks', decreaseBlockQuoteLevel );
-
-proto.changeIndentationLevel = function (direction) {
- let parent = this.getSelectionClosest('UL,OL,BLOCKQUOTE');
- if (parent || 'increase' === direction) {
- let method = ( !parent || 'BLOCKQUOTE' === parent.nodeName ) ? 'Quote' : 'List';
- this[ direction + method + 'Level' ]();
- }
-};
-
-proto.makeUnorderedList = command( 'modifyBlocks', makeUnorderedList );
-proto.makeOrderedList = command( 'modifyBlocks', makeOrderedList );
-proto.removeList = command( 'modifyBlocks', removeList );
-
win.Squire = Squire;
})( document );