. Browsers that want
// elements at the end of each block will then have them added back in a later
// fixCursor method call.
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.
setClipboardData =
( event, contents, root ) => {
let clipboardData = event.clipboardData;
let body = doc.body;
let node = createElement( 'div' );
let html, text;
node.append( contents );
html = node.innerHTML;
// 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 ( text !== html ) {
clipboardData.setData( 'text/html', html );
}
clipboardData.setData( 'text/plain', text );
event.preventDefault();
},
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;
},
// --- Events ---
// Subscribing to these events won't automatically add a listener to the
// document node, since these events are fired in a custom manner by the
// editor code.
customEvents = {
pathChange: 1, select: 1, input: 1, undoStateChange: 1
},
// --- 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.
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 );
}
}
}
},
// --- Bookmarking ---
startSelectionId = 'squire-selection-start',
endSelectionId = 'squire-selection-end',
createBookmarkNodes = () => [
createElement( 'INPUT', {
id: startSelectionId,
type: 'hidden'
}),
createElement( 'INPUT', {
id: endSelectionId,
type: 'hidden'
})
],
// --- Block formatting ---
tagAfterSplit = {
DT: 'DD',
DD: 'DT',
LI: 'LI',
PRE: 'PRE'
},
splitBlock = ( self, block, node, offset ) => {
let splitTag = tagAfterSplit[ block.nodeName ],
nodeAfterSplit = split( node, offset, block.parentNode, self._root ),
config = self._config;
if ( !splitTag ) {
splitTag = config.blockTag;
}
// Make sure the new node is the correct type.
if ( !hasTagAttributes( nodeAfterSplit, splitTag, {} ) ) {
block = createElement( splitTag );
if ( nodeAfterSplit.dir ) {
block.dir = nodeAfterSplit.dir;
}
nodeAfterSplit.replaceWith( block );
block.append( empty( nodeAfterSplit ) );
nodeAfterSplit = block;
}
return nodeAfterSplit;
},
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 ];
},
makeList = ( self, frag, type ) => {
let walker = getBlockWalker( frag, self._root ),
node, tag, prev, newLi;
while ( node = walker.nextNode() ) {
if ( node.parentNode.nodeName === 'LI' ) {
node = node.parentNode;
walker.currentNode = node.lastChild;
}
if ( node.nodeName !== 'LI' ) {
newLi = createElement( 'LI' );
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, null, [
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, null, [ empty( node ) ] )
);
}
}
}
return frag;
},
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,
addLinks = ( frag, root ) => {
let walker = createTreeWalker( frag, SHOW_TEXT, node => !getClosest( node, root, 'A' ) );
let node, data, parent, match, index, endIndex, child;
while (( node = walker.nextNode() )) {
data = node.data;
parent = node.parentNode;
while (( match = linkRegExp.exec( data ) )) {
index = match.index;
endIndex = index + match[0].length;
if ( index ) {
child = doc.createTextNode( data.slice( 0, index ) );
parent.insertBefore( child, node );
}
child = createElement( 'A', mergeObjects({
href: match[1] ?
/^(?:ht|f)tps?:/i.test( match[1] ) ?
match[1] :
'http://' + match[1] :
'mailto:' + match[0]
}, null, false ));
child.textContent = data.slice( index, endIndex );
parent.insertBefore( child, node );
node.data = data = data.slice( endIndex );
}
}
},
escapeHTML = text => text.replace( '&', '&' )
.replace( '<', '<' )
.replace( '>', '>' )
.replace( '"', '"' ),
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;
};
let contentWalker,
nodeCategoryCache = new WeakMap();
// Previous node in post-order.
TreeWalker.prototype.previousPONode = function () {
let current = this.currentNode,
root = this.root,
nodeType = this.nodeType,
filter = this.filter,
node;
while ( current ) {
node = current.lastChild;
while ( !node && current && current !== root) {
node = current.previousSibling;
if ( !node ) { current = current.parentNode; }
}
if ( node && ( typeToBitArray[ node.nodeType ] & nodeType ) && filter( node ) ) {
this.currentNode = node;
return node;
}
current = node;
}
return null;
};
function onKey ( event ) {
if ( event.defaultPrevented ) {
return;
}
let key = event.key.toLowerCase(),
modifiers = '',
range = this.getSelection();
// We need to apply the backspace/delete handlers regardless of
// control key modifiers.
if ( key !== 'backspace' && key !== 'delete' ) {
if ( event.altKey ) { modifiers += 'alt-'; }
if ( event[osKey] ) { modifiers += ctrlKey; }
if ( event.shiftKey ) { modifiers += 'shift-'; }
}
// However, on Windows, shift-delete is apparently "cut" (WTF right?), so
// we want to let the browser handle shift-delete in this situation.
if ( isWin && event.shiftKey && key === 'delete' ) {
modifiers += 'shift-';
}
key = modifiers + key;
if ( this._keyHandlers[ key ] ) {
this._keyHandlers[ key ]( this, event, range );
// !event.isComposing stops us from blatting Kana-Kanji conversion in Safari
} else if ( !range.collapsed && !event.isComposing &&
!event[osKey] &&
key.length === 1 ) {
// Record undo checkpoint.
this.saveUndoState( range );
// Delete the selection
deleteContentsOfRange( range, this._root );
this._ensureBottomLine();
this.setSelection( range );
this._updatePath( range, true );
}
}
function onCut ( 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 );
} else {
setTimeout( () => {
try {
// If all content removed, ensure div at start of root.
self._ensureBottomLine();
} catch ( error ) {
didError( error );
}
}, 0 );
}
this.setSelection( range );
}
function onCopy ( 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 );
}
}
// Need to monitor for shift key like this, as event.shiftKey is not available
// in paste event.
function monitorShiftKey ( event ) {
this.isShiftDown = event.shiftKey;
}
function onPaste ( 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.
function onDrop ( 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();
}
}
let keyHandlers = {
// This song and dance is to force iOS to do enable the shift key
// automatically on enter. When you do the DOM split manipulation yourself,
// WebKit doesn't reset the IME state and so presents auto-complete options
// as though you were continuing to type on the previous line, and doesn't
// auto-enable the shift key. The old trick of blurring and focussing
// again no longer works in iOS 13, and I tried various execCommand options
// but they didn't seem to do anything. The only solution I've found is to
// let iOS handle the enter key, then after it's done that reset the HTML
// to what it was before and handle it properly in Squire; the IME state of
// course doesn't reset so you end up in the correct state!
enter: isIOS ? ( self, event, range ) => {
self._saveRangeToBookmark( range );
let html = self._getHTML();
let restoreAndDoEnter = () => {
self.removeEventListener( 'keyup', restoreAndDoEnter );
self._setHTML( html );
range = self._getRangeAndRemoveBookmark();
// Ignore the shift key on iOS, as this is for auto-capitalisation.
handleEnter( self, false, range );
};
self.addEventListener( 'keyup', restoreAndDoEnter );
} : ( self, event, range ) => {
event.preventDefault();
handleEnter( self, event.shiftKey, range );
},
'shift-enter': ( self, event, range ) => self._keyHandlers.enter( self, event, range ),
backspace: ( self, event, range ) => {
let root = self._root;
self._removeZWS();
// Record undo checkpoint.
self.saveUndoState( range );
// If not collapsed, delete contents
if ( !range.collapsed ) {
event.preventDefault();
deleteContentsOfRange( range, root );
afterDelete( self, range );
}
// If at beginning of block, merge with previous
else if ( rangeDoesStartAtBlockBoundary( range, root ) ) {
event.preventDefault();
let current = getStartBlockOfRange( range, root );
let previous;
if ( !current ) {
return;
}
// In case inline data has somehow got between blocks.
fixContainer( current.parentNode, root );
// Now get previous block
previous = getPreviousBlock( current, root );
// Must not be at the very beginning of the text area.
if ( previous ) {
// If not editable, just delete whole block.
if ( !previous.isContentEditable ) {
detachUneditableNode( previous, root );
return;
}
// Otherwise merge.
mergeWithBlock( previous, current, range, root );
// If deleted line between containers, merge newly adjacent
// containers.
current = previous.parentNode;
while ( current !== root && !current.nextSibling ) {
current = current.parentNode;
}
if ( current !== root && ( current = current.nextSibling ) ) {
mergeContainers( current, root );
}
self.setSelection( range );
}
// If at very beginning of text area, allow backspace
// to break lists/blockquote.
else if ( current ) {
let parent = getClosest( current, root, 'UL,OL,BLOCKQUOTE' );
if (parent) {
return ( 'BLOCKQUOTE' === parent.nodeName )
// Break blockquote
? self.decreaseQuoteLevel( range )
// Break list
: self.decreaseListLevel( range );
}
self.setSelection( range );
self._updatePath( range, true );
}
}
// Otherwise, leave to browser but check afterwards whether it has
// left behind an empty inline tag.
else {
self.setSelection( range );
setTimeout( () => afterDelete( self ), 0 );
}
},
'delete': ( self, event, range ) => {
let root = self._root;
let current, next, originalRange,
cursorContainer, cursorOffset, nodeAfterCursor;
self._removeZWS();
// Record undo checkpoint.
self.saveUndoState( range );
// If not collapsed, delete contents
if ( !range.collapsed ) {
event.preventDefault();
deleteContentsOfRange( range, root );
afterDelete( self, range );
}
// If at end of block, merge next into this block
else if ( rangeDoesEndAtBlockBoundary( range, root ) ) {
event.preventDefault();
current = getStartBlockOfRange( range, root );
if ( !current ) {
return;
}
// In case inline data has somehow got between blocks.
fixContainer( current.parentNode, root );
// Now get next block
next = getNextBlock( current, root );
// Must not be at the very end of the text area.
if ( next ) {
// If not editable, just delete whole block.
if ( !next.isContentEditable ) {
detachUneditableNode( next, root );
return;
}
// Otherwise merge.
mergeWithBlock( current, next, range, root );
// If deleted line between containers, merge newly adjacent
// containers.
next = current.parentNode;
while ( next !== root && !next.nextSibling ) {
next = next.parentNode;
}
if ( next !== root && ( next = next.nextSibling ) ) {
mergeContainers( next, root );
}
self.setSelection( range );
self._updatePath( range, true );
}
}
// Otherwise, leave to browser but check afterwards whether it has
// left behind an empty inline tag.
else {
// But first check if the cursor is just before an IMG tag. If so,
// delete it ourselves, because the browser won't if it is not
// inline.
originalRange = range.cloneRange();
moveRangeBoundariesUpTree( range, root, root, root );
cursorContainer = range.endContainer;
cursorOffset = range.endOffset;
if ( cursorContainer.nodeType === ELEMENT_NODE ) {
nodeAfterCursor = cursorContainer.childNodes[ cursorOffset ];
if ( nodeAfterCursor && nodeAfterCursor.nodeName === 'IMG' ) {
event.preventDefault();
detach( nodeAfterCursor );
moveRangeBoundariesDownTree( range );
afterDelete( self, range );
return;
}
}
self.setSelection( originalRange );
setTimeout( () => afterDelete( self ), 0 );
}
},
tab: ( self, event, range ) => {
let root = self._root;
let node, parent;
self._removeZWS();
// If no selection and at start of block
if ( range.collapsed && rangeDoesStartAtBlockBoundary( range, root ) ) {
node = getStartBlockOfRange( range, root );
// Iterate through the block's parents
while ( ( parent = node.parentNode ) ) {
// If we find a UL or OL (so are in a list, node must be an LI)
if ( parent.nodeName === 'UL' || parent.nodeName === 'OL' ) {
// Then increase the list level
event.preventDefault();
self.increaseListLevel( range );
break;
}
node = parent;
}
}
},
'shift-tab': ( self, event, range ) => {
let root = self._root;
let node;
self._removeZWS();
// If no selection and at start of block
if ( range.collapsed && rangeDoesStartAtBlockBoundary( range, root ) ) {
// Break list
node = range.startContainer;
if ( getClosest( node, root, 'UL,OL' ) ) {
event.preventDefault();
self.decreaseListLevel( range );
}
}
},
space: ( self, _, range ) => {
let node;
let root = self._root;
self._recordUndoState( range );
if ( self._config.addLinks ) {
addLinks( range.startContainer, root, self );
}
self._getRangeAndRemoveBookmark( range );
// If the cursor is at the end of a link (foo|) then move it
// outside of the link (foo|) so that the space is not part of
// the link text.
node = range.endContainer;
if ( range.collapsed && range.endOffset === getLength( node ) ) {
do {
if ( node.nodeName === 'A' ) {
range.setStartAfter( node );
break;
}
} while ( !node.nextSibling &&
( node = node.parentNode ) && node !== root );
}
// Delete the selection if not collapsed
if ( !range.collapsed ) {
deleteContentsOfRange( range, root );
self._ensureBottomLine();
self.setSelection( range );
self._updatePath( range, true );
}
self.setSelection( range );
},
arrowleft: self => self._removeZWS(),
arrowright: self => self._removeZWS()
};
// System standard for page up/down on Mac is to just scroll, not move the
// cursor. On Linux/Windows, it should move the cursor, but some browsers don't
// implement this natively. Override to support it.
function _moveCursorTo( self, toStart ) {
let root = self._root,
range = createRange( root, toStart ? 0 : root.childNodes.length );
moveRangeBoundariesDownTree( range );
self.setSelection( range );
return self;
}
if ( !isMac ) {
keyHandlers.pageup = self => _moveCursorTo( self, true );
keyHandlers.pagedown = self => _moveCursorTo( self, false );
}
keyHandlers[ ctrlKey + 'b' ] = mapKeyToFormat( 'B' );
keyHandlers[ ctrlKey + 'i' ] = mapKeyToFormat( 'I' );
keyHandlers[ ctrlKey + 'u' ] = mapKeyToFormat( 'U' );
keyHandlers[ ctrlKey + 'shift-7' ] = mapKeyToFormat( 'S' );
keyHandlers[ ctrlKey + 'shift-5' ] = mapKeyToFormat( 'SUB', 'SUP' );
keyHandlers[ ctrlKey + 'shift-6' ] = mapKeyToFormat( 'SUP', 'SUB' );
keyHandlers[ ctrlKey + 'shift-8' ] = toggleList( 'UL', 'makeUnorderedList' );
keyHandlers[ ctrlKey + 'shift-9' ] = toggleList( 'OL', 'makeOrderedList' );
keyHandlers[ ctrlKey + '[' ] = changeIndentationLevel( 'decrease' );
keyHandlers[ ctrlKey + ']' ] = changeIndentationLevel( 'increase' );
keyHandlers[ ctrlKey + 'd' ] = mapKeyTo( 'toggleCode' );
keyHandlers[ ctrlKey + 'y' ] = mapKeyTo( 'redo' );
keyHandlers[ 'redo' ] = mapKeyTo( 'redo' );
keyHandlers[ ctrlKey + 'z' ] = mapKeyTo( 'undo' );
keyHandlers[ 'undo' ] = mapKeyTo( 'undo' );
keyHandlers[ ctrlKey + 'shift-z' ] = mapKeyTo( 'redo' );
class Squire
{
constructor (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( '' );
}
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 );
// Users may specify block tag in lower case
config.blockTag = config.blockTag.toUpperCase();
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();
}
mutation.disconnect();
}
this._ignoreAllChanges = true;
modificationCallback();
this._ignoreAllChanges = false;
if ( mutation ) {
mutation.observe( this._root, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
this._ignoreChange = false;
}
}
// --- Events ---
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;
}
this._isFocused = true;
} else {
if ( isFocused || !this._isFocused ) {
return this;
}
this._isFocused = false;
}
}
if ( handlers ) {
if ( !event ) {
event = {};
}
if ( event.type !== type ) {
event.type = type;
}
// Clone handlers array, so any handlers added/removed do not affect it.
handlers = handlers.slice();
l = handlers.length;
while ( l-- ) {
obj = handlers[l];
try {
if ( obj.handleEvent ) {
obj.handleEvent( event );
} else {
obj.call( this, event );
}
} catch ( error ) {
error.details = 'Squire: fireEvent error. Event type: ' + type;
didError( error );
}
}
}
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} );
}
}
handlers.push( fn );
});
return this;
}
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 );
}
}
} else {
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;
}
// --- Selection and Path ---
getCursorPosition ( 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;
}
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;
}
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;
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 || createRange( root.firstChild, 0 );
}
getSelectionClosest (selector) {
let range = this.getSelection();
return range && getClosest(range.commonAncestorContainer, this._root, selector);
}
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));
}
return false;
}
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;
}
getPath () {
return this._path;
}
// --- 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;
}
}
// --- Path change events ---
_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;
}
// 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;
}
// 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;
if ( !range && !( range = this.getSelection() ) ) {
return fontInfo;
}
element = range.commonAncestorContainer;
if ( range.collapsed || element.nodeType === TEXT_NODE ) {
if ( element.nodeType === TEXT_NODE ) {
element = element.parentNode;
}
while ( seenAttributes < 4 && element ) {
if ( style = element.style ) {
if ( !fontInfo.color && ( attr = style.color ) ) {
fontInfo.color = attr;
++seenAttributes;
}
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;
}
_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;
}
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 )
);
// 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;
}
startContainer = node;
startOffset = 0;
}
el = createElement( tag, attributes );
node.replaceWith( el );
el.append( node );
}
} while ( walker.nextNode() );
// If we don't finish inside a text node, offset may have changed.
if ( endContainer.nodeType !== TEXT_NODE ) {
if ( node.nodeType === TEXT_NODE ) {
endContainer = node;
endOffset = node.length;
} else {
// If
, we must have just wrapped it, so it must have only
// one child
endContainer = node.parentNode;
endOffset = 1;
}
}
// Now set the selection to as it was before
range = 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 );
}
// 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;
}
toggleTag ( name, remove ) {
let range = this.getSelection();
if ( this.hasFormat( name, null, range ) ) {
this.changeFormat ( null, { tag: name }, range );
} else {
this.changeFormat ( { tag: name }, remove ? { tag: remove } : null, range );
}
}
changeFormat ( 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 ---
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 ) );
}
this.setSelection( range );
// Path may have changed
this._updatePath( range, true );
return this;
}
modifyBlocks ( 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;
}
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;
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();
}
decreaseListLevel ( 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;
}
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() );
}
}
// --- Keyboard interaction ---
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( '' );
if ( startFragmentIndex > -1 && endFragmentIndex > -1 ) {
html = html.slice( startFragmentIndex + 20, endFragmentIndex );
}
}
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 ) );
}
// 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 );
}
return this;
}
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 );
}
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;
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 ---
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;
}
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();
}
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' ]();
}
}
increaseQuoteLevel ( range ) {
this.modifyBlocks(
frag => createElement( 'BLOCKQUOTE', null, [ frag ]),
range
);
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();
}
makeUnorderedList () {
this.modifyBlocks( frag => makeList( this, frag, 'UL' ) );
return this.focus();
}
makeOrderedList () {
this.modifyBlocks( frag => makeList( this, frag, 'OL' ) );
return this.focus();
}
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 );
}
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;
});
return this.focus();
}
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' ); }
}
win.Squire = Squire;
})( document );