/* Copyright © 2011-2015 by Neil Jenkins. MIT Licensed. */
/* eslint max-len: 0 */
/**
TODO: modifyBlocks function doesn't work very good.
For example you have: UL > LI > [cursor here in text]
Then create blockquote at cursor, the result is: BLOCKQUOTE > UL > LI
not UL > LI > BLOCKQUOTE
*/
( doc => {
const
DOCUMENT_POSITION_PRECEDING = 2, // Node.DOCUMENT_POSITION_PRECEDING
ELEMENT_NODE = 1, // Node.ELEMENT_NODE,
TEXT_NODE = 3, // Node.TEXT_NODE,
DOCUMENT_FRAGMENT_NODE = 11, // Node.DOCUMENT_FRAGMENT_NODE,
SHOW_ELEMENT = 1, // NodeFilter.SHOW_ELEMENT,
SHOW_TEXT = 4, // NodeFilter.SHOW_TEXT,
SHOW_ELEMENT_OR_TEXT = 5,
START_TO_START = 0, // Range.START_TO_START
START_TO_END = 1, // Range.START_TO_END
END_TO_END = 2, // Range.END_TO_END
END_TO_START = 3, // Range.END_TO_START
ZWS = '\u200B',
NBSP = '\u00A0',
win = doc.defaultView,
ua = navigator.userAgent,
isMac = /Mac OS X/.test( ua ),
isIOS = /iP(?:ad|hone|od)/.test( ua ) || ( isMac && !!navigator.maxTouchPoints ),
isWebKit = /WebKit\//.test( ua ),
ctrlKey = isMac ? 'meta-' : 'ctrl-',
osKey = isMac ? 'metaKey' : 'ctrlKey',
// Use [^ \t\r\n] instead of \S so that nbsp does not count as white-space
notWS = /[^ \t\r\n]/,
indexOf = (array, value) => Array.prototype.indexOf.call(array, value),
filterAccept = NodeFilter.FILTER_ACCEPT,
typeToBitArray = {
// ELEMENT_NODE
1: 1,
// ATTRIBUTE_NODE
2: 2,
// TEXT_NODE
3: 4,
// COMMENT_NODE
8: 128,
// DOCUMENT_NODE
9: 256,
// DOCUMENT_FRAGMENT_NODE
11: 1024
},
inlineNodeNames = /^(?:#text|A|ABBR|ACRONYM|B|BR|BD[IO]|CITE|CODE|DATA|DEL|DFN|EM|FONT|HR|I|IMG|INPUT|INS|KBD|Q|RP|RT|RUBY|S|SAMP|SMALL|SPAN|STR(IKE|ONG)|SU[BP]|TIME|U|VAR|WBR)$/,
// phrasingElements = 'ABBR,AUDIO,B,BDO,BR,BUTTON,CANVAS,CITE,CODE,COMMAND,DATA,DATALIST,DFN,EM,EMBED,I,IFRAME,IMG,INPUT,KBD,KEYGEN,LABEL,MARK,MATH,METER,NOSCRIPT,OBJECT,OUTPUT,PROGRESS,Q,RUBY,SAMP,SCRIPT,SELECT,SMALL,SPAN,STRONG,SUB,SUP,SVG,TEXTAREA,TIME,VAR,VIDEO,WBR',
leafNodeNames = {
BR: 1,
HR: 1,
IMG: 1
},
UNKNOWN = 0,
INLINE = 1,
BLOCK = 2,
CONTAINER = 3,
isLeaf = node => node.nodeType === ELEMENT_NODE && !!leafNodeNames[ node.nodeName ],
getNodeCategory = node => {
switch ( node.nodeType ) {
case TEXT_NODE:
return INLINE;
case ELEMENT_NODE:
case DOCUMENT_FRAGMENT_NODE:
if ( nodeCategoryCache.has( node ) ) {
return nodeCategoryCache.get( node );
}
break;
default:
return UNKNOWN;
}
let nodeCategory =
Array.prototype.every.call( node.childNodes, isInline )
? ( inlineNodeNames.test( node.nodeName ) ? INLINE : BLOCK )
// Malformed HTML can have block tags inside inline tags. Need to treat
// these as containers rather than inline. See #239.
: CONTAINER;
nodeCategoryCache.set( node, nodeCategory );
return nodeCategory;
},
isInline = node => getNodeCategory( node ) === INLINE,
isBlock = node => getNodeCategory( node ) === BLOCK,
isContainer = node => getNodeCategory( node ) === CONTAINER,
createTreeWalker = (root, whatToShow, filter) => doc.createTreeWalker( root, whatToShow, filter ? {
acceptNode: node => filter(node) ? filterAccept : NodeFilter.FILTER_SKIP
} : null
),
getBlockWalker = ( node, root ) => {
let walker = createTreeWalker( root, SHOW_ELEMENT, isBlock );
walker.currentNode = node;
return walker;
},
getPreviousBlock = ( node, root ) => {
// node = getClosest( node, root, blockElementNames );
node = getBlockWalker( node, root ).previousNode();
return node !== root ? node : null;
},
getNextBlock = ( node, root ) => {
// node = getClosest( node, root, blockElementNames );
node = getBlockWalker( node, root ).nextNode();
return node !== root ? node : null;
},
isEmptyBlock = block => !block.textContent && !block.querySelector( 'IMG' ),
areAlike = ( node, node2 ) => {
return !isLeaf( node ) && (
node.nodeType === node2.nodeType &&
node.nodeName === node2.nodeName &&
node.nodeName !== 'A' &&
node.className === node2.className &&
node.style?.cssText === node2.style?.cssText
);
},
hasTagAttributes = ( node, tag, attributes ) => {
return node.nodeName === tag && Object.entries(attributes || {}).every(([k,v]) => node.getAttribute(k) === v);
},
getClosest = ( node, root, selector ) => {
node = (node && !node.closest) ? node.parentElement : node;
node = node?.closest(selector);
return (node && root.contains(node)) ? node : null;
},
getNearest = ( node, root, tag, attributes ) => {
while ( node && node !== root ) {
if ( hasTagAttributes( node, tag, attributes ) ) {
return node;
}
node = node.parentNode;
}
return null;
},
getPath = ( node, root, config ) => {
let path = '', style;
if ( node && node !== root ) {
path = getPath( node.parentNode, root, config );
if ( node.nodeType === ELEMENT_NODE ) {
path += ( path ? '>' : '' ) + node.nodeName;
if ( node.id ) {
path += '#' + node.id;
}
if ( node.classList.length ) {
path += '.' + [...node.classList].sort().join( '.' );
}
if ( node.dir ) {
path += '[dir=' + node.dir + ']';
}
if ( style = node.style.cssText ) {
path += '[style=' + style + ']';
}
}
}
return path;
},
getLength = node => {
let nodeType = node.nodeType;
return nodeType === ELEMENT_NODE || nodeType === DOCUMENT_FRAGMENT_NODE ?
node.childNodes.length : node.length || 0;
},
detach = node => {
// node.remove();
node.parentNode?.removeChild( node );
return node;
},
empty = node => {
let frag = doc.createDocumentFragment(),
childNodes = node.childNodes;
childNodes && frag.append( ...childNodes );
return frag;
},
setStyle = ( node, style ) => {
if (typeof style === 'object') {
Object.entries(style).forEach(([k,v]) => node.style[k] = v);
} else if ( style !== undefined ) {
node.setAttribute( 'style', style );
}
},
createElement = ( tag, props, children ) => {
let el = doc.createElement( tag );
if ( props instanceof Array ) {
children = props;
props = null;
}
props && Object.entries(props).forEach(([k,v]) => {
if ('style' === k) {
setStyle( el, v );
} else if ( v !== undefined ) {
el.setAttribute( k, v );
}
});
children && el.append( ...children );
return el;
},
fixCursor = ( node, root ) => {
// In Webkit and Gecko, block level elements are collapsed and
// unfocusable if they have no content (:empty). To remedy this, a
must be
// inserted. In Opera and IE, we just need a textnode in order for the
// cursor to appear.
let self = root.__squire__;
let originalNode = node;
let fixer, child;
if ( node === root ) {
if ( !( child = node.firstChild ) || child.nodeName === 'BR' ) {
fixer = self.createDefaultBlock();
if ( child ) {
child.replaceWith( fixer );
}
else {
node.append( fixer );
}
node = fixer;
fixer = null;
}
}
if ( node.nodeType === TEXT_NODE ) {
return originalNode;
}
if ( isInline( node ) ) {
child = node.firstChild;
while ( isWebKit && child?.nodeType === TEXT_NODE && !child.data ) {
child.remove( );
child = node.firstChild;
}
if ( !child ) {
fixer = self._addZWS();
}
// } else if ( !node.querySelector( 'BR' ) ) {
// } else if ( !node.innerText.trim().length ) {
} else if ( node.matches( ':empty' ) ) {
fixer = createElement( 'BR' );
while ( ( child = node.lastElementChild ) && !isInline( child ) ) {
node = child;
}
}
if ( fixer ) {
try {
node.append( fixer );
} catch ( error ) {
didError({
name: 'Squire: fixCursor – ' + error,
message: 'Parent: ' + node.nodeName + '/' + node.innerHTML +
' appendChild: ' + fixer.nodeName
});
}
}
return originalNode;
},
// Recursively examine container nodes and wrap any inline children.
fixContainer = ( container, root ) => {
let children = container.childNodes;
let wrapper = null;
let i = 0, l = children.length, child, isBR;
for ( ; i < l; ++i ) {
child = children[i];
isBR = child.nodeName === 'BR';
if ( !isBR && isInline( child )
// && (root.__squire__._config.blockTag !== 'DIV' || (child.matches && !child.matches(phrasingElements)))
) {
if ( !wrapper ) {
wrapper = createElement( 'div' );
}
wrapper.append( child );
--i;
--l;
} else if ( isBR || wrapper ) {
if ( !wrapper ) {
wrapper = createElement( 'div' );
}
fixCursor( wrapper, root );
if ( isBR ) {
child.replaceWith( wrapper );
} else {
child.before( wrapper );
++i;
++l;
}
wrapper = null;
}
isContainer( child ) && fixContainer( child, root );
}
/*
// Not live
[...container.children].forEach(child => {
isBR = child.nodeName === 'BR';
if ( !isBR && isInline( child )
// && (root.__squire__._config.blockTag !== 'DIV' || (child.matches && !child.matches(phrasingElements)))
) {
if ( !wrapper ) {
wrapper = createElement( 'div' );
}
wrapper.append( child );
} else if ( isBR || wrapper ) {
if ( !wrapper ) {
wrapper = createElement( 'div' );
}
fixCursor( wrapper, root );
if ( isBR ) {
child.replaceWith( wrapper );
} else {
child.before( wrapper );
}
wrapper = null;
}
if ( isContainer( child ) ) {
fixContainer( child, root );
}
});
*/
wrapper && container.append( fixCursor( wrapper, root ) );
return container;
},
split = ( node, offset, stopNode, root ) => {
let nodeType = node.nodeType,
parent, clone, next;
if ( nodeType === TEXT_NODE && node !== stopNode ) {
return split(
node.parentNode, node.splitText( offset ), stopNode, root );
}
if ( nodeType === ELEMENT_NODE ) {
if ( typeof( offset ) === 'number' ) {
offset = offset < node.childNodes.length ?
node.childNodes[ offset ] : null;
}
if ( node === stopNode ) {
return offset;
}
// Clone node without children
parent = node.parentNode;
clone = node.cloneNode( false );
// Add right-hand siblings to the clone
while ( offset ) {
next = offset.nextSibling;
clone.append( offset );
offset = next;
}
// Maintain li numbering if inside a quote.
if ( node.nodeName === 'OL' &&
getClosest( node, root, 'BLOCKQUOTE' ) ) {
clone.start = ( +node.start || 1 ) + node.childNodes.length - 1;
}
// DO NOT NORMALISE. This may undo the fixCursor() call
// of a node lower down the tree!
// We need something in the element in order for the cursor to appear.
fixCursor( node, root );
fixCursor( clone, root );
// Inject clone after original node
node.after( clone );
// Keep on splitting up the tree
return split( parent, clone, stopNode, root );
}
return offset;
},
_mergeInlines = ( node, fakeRange ) => {
let children = node.childNodes,
l = children.length,
frags = [],
child, prev;
while ( l-- ) {
child = children[l];
prev = l && children[ l - 1 ];
if ( l && isInline( child ) && areAlike( child, prev ) &&
!leafNodeNames[ child.nodeName ] ) {
if ( fakeRange.startContainer === child ) {
fakeRange.startContainer = prev;
fakeRange.startOffset += getLength( prev );
}
if ( fakeRange.endContainer === child ) {
fakeRange.endContainer = prev;
fakeRange.endOffset += getLength( prev );
}
if ( fakeRange.startContainer === node ) {
if ( fakeRange.startOffset > l ) {
--fakeRange.startOffset;
}
else if ( fakeRange.startOffset === l ) {
fakeRange.startContainer = prev;
fakeRange.startOffset = getLength( prev );
}
}
if ( fakeRange.endContainer === node ) {
if ( fakeRange.endOffset > l ) {
--fakeRange.endOffset;
}
else if ( fakeRange.endOffset === l ) {
fakeRange.endContainer = prev;
fakeRange.endOffset = getLength( prev );
}
}
detach( child );
if ( child.nodeType === TEXT_NODE ) {
prev.appendData( child.data );
}
else {
frags.push( empty( child ) );
}
}
else if ( child.nodeType === ELEMENT_NODE ) {
child.append(...frags.reverse());
frags = [];
_mergeInlines( child, fakeRange );
}
}
},
mergeInlines = ( node, range ) => {
if ( node.nodeType === TEXT_NODE ) {
node = node.parentNode;
}
if ( node.nodeType === ELEMENT_NODE ) {
let fakeRange = {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset
};
_mergeInlines( node, fakeRange );
range.setStart( fakeRange.startContainer, fakeRange.startOffset );
range.setEnd( fakeRange.endContainer, fakeRange.endOffset );
}
},
mergeWithBlock = ( block, next, range, root ) => {
let container = next;
let parent, last, offset;
while ( ( parent = container.parentNode ) &&
parent !== root &&
parent.nodeType === ELEMENT_NODE &&
parent.childNodes.length === 1 ) {
container = parent;
}
detach( container );
offset = block.childNodes.length;
// Remove extra
fixer if present.
last = block.lastChild;
if ( last?.nodeName === 'BR' ) {
last.remove( );
--offset;
}
block.append( empty( next ) );
range.setStart( block, offset );
range.collapse( true );
mergeInlines( block, range );
},
mergeContainers = ( node, root ) => {
let prev = node.previousSibling,
first = node.firstChild,
isListItem = ( node.nodeName === 'LI' ),
needsFix, block;
// Do not merge LIs, unless it only contains a UL
if ( isListItem && ( !first || !/^[OU]L$/.test( first.nodeName ) ) ) {
return;
}
if ( prev && areAlike( prev, node ) ) {
if ( !isContainer( prev ) ) {
if ( !isListItem ) {
return;
}
block = createElement( 'DIV' );
block.append( empty( prev ) );
prev.append( block );
}
detach( node );
needsFix = !isContainer( node );
prev.append( empty( node ) );
if ( needsFix ) {
fixContainer( prev, root );
}
if ( first ) {
mergeContainers( first, root );
}
} else if ( isListItem ) {
prev = createElement( 'DIV' );
node.insertBefore( prev, first );
fixCursor( prev, root );
}
},
getNodeBefore = ( node, offset ) => {
let children = node.childNodes;
while ( offset && node.nodeType === ELEMENT_NODE ) {
node = children[ offset - 1 ];
children = node.childNodes;
offset = children.length;
}
return node;
},
getNodeAfter = ( node, offset ) => {
if ( node.nodeType === ELEMENT_NODE ) {
let children = node.childNodes;
if ( offset < children.length ) {
node = children[ offset ];
} else {
while ( node && !node.nextSibling ) {
node = node.parentNode;
}
if ( node ) { node = node.nextSibling; }
}
}
return node;
},
insertNodeInRange = ( range, node ) => {
// Insert at start.
let startContainer = range.startContainer,
startOffset = range.startOffset,
endContainer = range.endContainer,
endOffset = range.endOffset,
parent, children, childCount, afterSplit;
// If part way through a text node, split it.
if ( startContainer.nodeType === TEXT_NODE ) {
parent = startContainer.parentNode;
children = parent.childNodes;
if ( startOffset === startContainer.length ) {
startOffset = indexOf( children, startContainer ) + 1;
if ( range.collapsed ) {
endContainer = parent;
endOffset = startOffset;
}
} else {
if ( startOffset ) {
afterSplit = startContainer.splitText( startOffset );
if ( endContainer === startContainer ) {
endOffset -= startOffset;
endContainer = afterSplit;
}
else if ( endContainer === parent ) {
++endOffset;
}
startContainer = afterSplit;
}
startOffset = indexOf( children, startContainer );
}
startContainer = parent;
} else {
children = startContainer.childNodes;
}
childCount = children.length;
if ( startOffset === childCount ) {
startContainer.append( node );
} else {
startContainer.insertBefore( node, children[ startOffset ] );
}
if ( startContainer === endContainer ) {
endOffset += children.length - childCount;
}
range.setStart( startContainer, startOffset );
range.setEnd( endContainer, endOffset );
},
extractContentsOfRange = ( range, common, root ) => {
let startContainer = range.startContainer,
startOffset = range.startOffset,
endContainer = range.endContainer,
endOffset = range.endOffset;
if ( !common ) {
common = range.commonAncestorContainer;
}
if ( common.nodeType === TEXT_NODE ) {
common = common.parentNode;
}
let endNode = split( endContainer, endOffset, common, root ),
startNode = split( startContainer, startOffset, common, root ),
frag = doc.createDocumentFragment(),
next, before, after, beforeText, afterText;
// End node will be null if at end of child nodes list.
while ( startNode !== endNode ) {
next = startNode.nextSibling;
frag.append( startNode );
startNode = next;
}
startContainer = common;
startOffset = endNode ?
indexOf( common.childNodes, endNode ) :
common.childNodes.length;
// Merge text nodes if adjacent. IE10 in particular will not focus
// between two text nodes
after = common.childNodes[ startOffset ];
before = after?.previousSibling;
if ( before?.nodeType === TEXT_NODE && after.nodeType === TEXT_NODE ) {
startContainer = before;
startOffset = before.length;
beforeText = before.data;
afterText = after.data;
// If we now have two adjacent spaces, the second one needs to become
// a nbsp, otherwise the browser will swallow it due to HTML whitespace
// collapsing.
if ( beforeText.charAt( beforeText.length - 1 ) === ' ' &&
afterText.charAt( 0 ) === ' ' ) {
afterText = NBSP + afterText.slice( 1 ); // nbsp
}
before.appendData( afterText );
detach( after );
}
range.setStart( startContainer, startOffset );
range.collapse( true );
fixCursor( common, root );
return frag;
},
deleteContentsOfRange = ( range, root ) => {
let startBlock = getStartBlockOfRange( range, root );
let endBlock = getEndBlockOfRange( range, root );
let needsMerge = ( startBlock !== endBlock );
let frag, child;
// Move boundaries up as much as possible without exiting block,
// to reduce need to split.
moveRangeBoundariesDownTree( range );
moveRangeBoundariesUpTree( range, startBlock, endBlock, root );
// Remove selected range
frag = extractContentsOfRange( range, null, root );
// Move boundaries back down tree as far as possible.
moveRangeBoundariesDownTree( range );
// If we split into two different blocks, merge the blocks.
if ( needsMerge ) {
// endBlock will have been split, so need to refetch
endBlock = getEndBlockOfRange( range, root );
if ( startBlock && endBlock && startBlock !== endBlock ) {
mergeWithBlock( startBlock, endBlock, range, root );
}
}
// Ensure block has necessary children
if ( startBlock ) {
fixCursor( startBlock, root );
}
// Ensure root has a block-level element in it.
child = root.firstChild;
if ( child && child.nodeName !== 'BR' ) {
range.collapse( true );
} else {
fixCursor( root, root );
range.selectNodeContents( root.firstChild );
}
return frag;
},
// Contents of range will be deleted.
// After method, range will be around inserted content
insertTreeFragmentIntoRange = ( range, frag, root ) => {
let firstInFragIsInline = frag.firstChild && isInline( frag.firstChild );
let node, block, blockContentsAfterSplit, stopPoint, container, offset;
let replaceBlock, firstBlockInFrag, nodeAfterSplit, nodeBeforeSplit;
let tempRange;
// Fixup content: ensure no top-level inline, and add cursor fix elements.
fixContainer( frag, root );
node = frag;
while ( ( node = getNextBlock( node, root ) ) ) {
fixCursor( node, root );
}
// Delete any selected content.
if ( !range.collapsed ) {
deleteContentsOfRange( range, root );
}
// Move range down into text nodes.
moveRangeBoundariesDownTree( range );
range.collapse( false ); // collapse to end
// Where will we split up to? First blockquote parent, otherwise root.
stopPoint = getClosest( range.endContainer, root, 'BLOCKQUOTE' ) || root;
// Merge the contents of the first block in the frag with the focused block.
// If there are contents in the block after the focus point, collect this
// up to insert in the last block later. This preserves the style that was
// present in this bit of the page.
//
// If the block being inserted into is empty though, replace it instead of
// merging if the fragment had block contents.
// e.g.
// This seems a reasonable approximation of user intent. block = getStartBlockOfRange( range, root ); firstBlockInFrag = getNextBlock( frag, frag ); replaceBlock = !firstInFragIsInline && !!block && isEmptyBlock( block ); if ( block && firstBlockInFrag && !replaceBlock && // Don't merge table cells or PRE elements into block !getClosest( firstBlockInFrag, frag, 'PRE,TABLE' ) ) { moveRangeBoundariesUpTree( range, block, block, root ); range.collapse( true ); // collapse to start container = range.endContainer; offset = range.endOffset; // Remove trailingFoo