/* 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.

Foo

// 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 trailing
– we don't want this considered content to be // inserted again later cleanupBRs( block, root, false ); if ( isInline( container ) ) { // Split up to block parent. nodeAfterSplit = split( container, offset, getPreviousBlock( container, root ), root ); container = nodeAfterSplit.parentNode; offset = indexOf( container.childNodes, nodeAfterSplit ); } if ( /*isBlock( container ) && */offset !== getLength( container ) ) { // Collect any inline contents of the block after the range point blockContentsAfterSplit = doc.createDocumentFragment(); while ( ( node = container.childNodes[ offset ] ) ) { blockContentsAfterSplit.append( node ); } } // And merge the first block in. mergeWithBlock( container, firstBlockInFrag, range, root ); // And where we will insert offset = indexOf( container.parentNode.childNodes, container ) + 1; container = container.parentNode; range.setEnd( container, offset ); } // Is there still any content in the fragment? if ( getLength( frag ) ) { if ( replaceBlock ) { range.setEndBefore( block ); range.collapse( false ); detach( block ); } moveRangeBoundariesUpTree( range, stopPoint, stopPoint, root ); // Now split after block up to blockquote (if a parent) or root nodeAfterSplit = split( range.endContainer, range.endOffset, stopPoint, root ); nodeBeforeSplit = nodeAfterSplit ? nodeAfterSplit.previousSibling : stopPoint.lastChild; stopPoint.insertBefore( frag, nodeAfterSplit ); if ( nodeAfterSplit ) { range.setEndBefore( nodeAfterSplit ); } else { range.setEnd( stopPoint, getLength( stopPoint ) ); } block = getEndBlockOfRange( range, root ); // Get a reference that won't be invalidated if we merge containers. moveRangeBoundariesDownTree( range ); container = range.endContainer; offset = range.endOffset; // Merge inserted containers with edges of split if ( nodeAfterSplit && isContainer( nodeAfterSplit ) ) { mergeContainers( nodeAfterSplit, root ); } nodeAfterSplit = nodeBeforeSplit?.nextSibling; if ( nodeAfterSplit && isContainer( nodeAfterSplit ) ) { mergeContainers( nodeAfterSplit, root ); } range.setEnd( container, offset ); } // Insert inline content saved from before. if ( blockContentsAfterSplit ) { tempRange = range.cloneRange(); mergeWithBlock( block, blockContentsAfterSplit, tempRange, root ); range.setEnd( tempRange.endContainer, tempRange.endOffset ); } moveRangeBoundariesDownTree( range ); }, isNodeContainedInRange = ( range, node, partial = true ) => { let nodeRange = doc.createRange(); nodeRange.selectNode( node ); return partial // Node must not finish before range starts or start after range finishes. ? range.compareBoundaryPoints( END_TO_START, nodeRange ) < 0 && range.compareBoundaryPoints( START_TO_END, nodeRange ) > 0 // Node must start after range starts and finish before range finishes : range.compareBoundaryPoints( START_TO_START, nodeRange ) < 1 && range.compareBoundaryPoints( END_TO_END, nodeRange ) > -1; }, moveRangeBoundariesDownTree = range => { let startContainer = range.startContainer, startOffset = range.startOffset, endContainer = range.endContainer, endOffset = range.endOffset, maySkipBR = true, child; while ( startContainer.nodeType !== TEXT_NODE ) { child = startContainer.childNodes[ startOffset ]; if ( !child || isLeaf( child ) ) { break; } startContainer = child; startOffset = 0; } if ( endOffset ) { while ( endContainer.nodeType !== TEXT_NODE ) { child = endContainer.childNodes[ endOffset - 1 ]; if ( !child || isLeaf( child ) ) { if ( maySkipBR && child?.nodeName === 'BR' ) { --endOffset; maySkipBR = false; continue; } break; } endContainer = child; endOffset = getLength( endContainer ); } } else { while ( endContainer.nodeType !== TEXT_NODE ) { child = endContainer.firstChild; if ( !child || isLeaf( child ) ) { break; } endContainer = child; } } // If collapsed, this algorithm finds the nearest text node positions // *outside* the range rather than inside, but also it flips which is // assigned to which. if ( range.collapsed ) { range.setStart( endContainer, endOffset ); range.setEnd( startContainer, startOffset ); } else { range.setStart( startContainer, startOffset ); range.setEnd( endContainer, endOffset ); } }, moveRangeBoundariesUpTree = ( range, startMax, endMax, root ) => { let startContainer = range.startContainer; let startOffset = range.startOffset; let endContainer = range.endContainer; let endOffset = range.endOffset; let maySkipBR = true; let parent; if ( !startMax ) { startMax = range.commonAncestorContainer; } if ( !endMax ) { endMax = startMax; } while ( !startOffset && startContainer !== startMax && startContainer !== root ) { parent = startContainer.parentNode; startOffset = indexOf( parent.childNodes, startContainer ); startContainer = parent; } while ( endContainer !== endMax && endContainer !== root ) { if ( maySkipBR && endContainer.nodeType !== TEXT_NODE && endContainer.childNodes[ endOffset ] && endContainer.childNodes[ endOffset ].nodeName === 'BR' ) { ++endOffset; maySkipBR = false; } if ( endOffset !== getLength( endContainer ) ) { break; } parent = endContainer.parentNode; endOffset = indexOf( parent.childNodes, endContainer ) + 1; endContainer = parent; } range.setStart( startContainer, startOffset ); range.setEnd( endContainer, endOffset ); }, moveRangeBoundaryOutOf = ( range, nodeName, root ) => { let parent = getClosest( range.endContainer, root, 'A' ); if ( parent ) { let clone = range.cloneRange(); parent = parent.parentNode; moveRangeBoundariesUpTree( clone, parent, parent, root ); if ( clone.endContainer === parent ) { range.setStart( clone.endContainer, clone.endOffset ); range.setEnd( clone.endContainer, clone.endOffset ); } } return range; }, // Returns the first block at least partially contained by the range, // or null if no block is contained by the range. getStartBlockOfRange = ( range, root ) => { let container = range.startContainer, block; // If inline, get the containing block. if ( isInline( container ) ) { block = getPreviousBlock( container, root ); } else if ( container !== root && isBlock( container ) ) { block = container; } else { block = getNextBlock( getNodeBefore( container, range.startOffset ), root ); } // Check the block actually intersects the range return block && isNodeContainedInRange( range, block ) ? block : null; }, // Returns the last block at least partially contained by the range, // or null if no block is contained by the range. getEndBlockOfRange = ( range, root ) => { let container = range.endContainer, block, child; // If inline, get the containing block. if ( isInline( container ) ) { block = getPreviousBlock( container, root ); } else if ( container !== root && isBlock( container ) ) { block = container; } else { block = getNodeAfter( container, range.endOffset ); if ( !block || !root.contains( block ) ) { block = root; while ( child = block.lastChild ) { block = child; } } block = getPreviousBlock( block, root ); } // Check the block actually intersects the range return block && isNodeContainedInRange( range, block ) ? block : null; }, newContentWalker = root => createTreeWalker( root, SHOW_ELEMENT_OR_TEXT, node => node.nodeType === TEXT_NODE ? notWS.test( node.data ) : node.nodeName === 'IMG' ), rangeDoesStartAtBlockBoundary = ( range, root ) => { let startContainer = range.startContainer; let startOffset = range.startOffset; let startBlock = getStartBlockOfRange( range, root ); let nodeAfterCursor; if (!startBlock) { return false; } // If in the middle or end of a text node, we're not at the boundary. if ( startContainer.nodeType === TEXT_NODE ) { if ( startOffset ) { return false; } nodeAfterCursor = startContainer; } else { nodeAfterCursor = getNodeAfter( startContainer, startOffset ); if ( nodeAfterCursor && !root.contains( nodeAfterCursor ) ) { nodeAfterCursor = null; } // The cursor was right at the end of the document if ( !nodeAfterCursor ) { nodeAfterCursor = getNodeBefore( startContainer, startOffset ); if ( nodeAfterCursor.nodeType === TEXT_NODE && nodeAfterCursor.length ) { return false; } } } // Otherwise, look for any previous content in the same block. contentWalker = newContentWalker(getStartBlockOfRange( range, root )); contentWalker.currentNode = nodeAfterCursor; return !contentWalker.previousNode(); }, rangeDoesEndAtBlockBoundary = ( range, root ) => { let endContainer = range.endContainer, endOffset = range.endOffset, length; // Otherwise, look for any further content in the same block. contentWalker = newContentWalker(getStartBlockOfRange( range, root )); // If in a text node with content, and not at the end, we're not // at the boundary if ( endContainer.nodeType === TEXT_NODE ) { length = endContainer.data.length; if ( length && endOffset < length ) { return false; } contentWalker.currentNode = endContainer; } else { contentWalker.currentNode = getNodeBefore( endContainer, endOffset ); } return !contentWalker.nextNode(); }, expandRangeToBlockBoundaries = ( range, root ) => { let start = getStartBlockOfRange( range, root ), end = getEndBlockOfRange( range, root ); if ( start && end ) { range.setStart( start, 0 ); range.setEnd( end, end.childNodes.length ); // parent = start.parentNode; // range.setStart( parent, indexOf( parent.childNodes, start ) ); // parent = end.parentNode; // range.setEnd( parent, indexOf( parent.childNodes, end ) + 1 ); } }, didError = error => console.error( error ), createRange = ( range, startOffset, endContainer, endOffset ) => { if ( range instanceof 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; }, mapKeyTo = method => ( self, event ) => { event.preventDefault(); self[ method ](); }, mapKeyToFormat = ( tag, remove ) => { return ( self, event ) => { event.preventDefault(); self.toggleTag(tag, remove); }; }, // If you delete the content inside a span with a font styling, Webkit will // replace it with a tag (!). If you delete all the text inside a // link in Opera, it won't delete the link. Let's make things consistent. If // you delete all text inside an inline tag, remove the inline tag. afterDelete = ( self, range ) => { try { if ( !range ) { range = self.getSelection(); } let node = range.startContainer, parent; // Climb the tree from the focus point while we are inside an empty // inline element if ( node.nodeType === TEXT_NODE ) { node = node.parentNode; } parent = node; while ( isInline( parent ) && ( !parent.textContent || parent.textContent === ZWS ) ) { node = parent; parent = node.parentNode; } // If focused in empty inline element if ( node !== parent ) { // Move focus to just before empty inline(s) range.setStart( parent, indexOf( parent.childNodes, node ) ); range.collapse( true ); // Remove empty inline(s) node.remove( ); // Fix cursor in block if ( !isBlock( parent ) ) { parent = getPreviousBlock( parent, self._root ); } fixCursor( parent, self._root ); // Move cursor into text node moveRangeBoundariesDownTree( range ); } // If you delete the last character in the sole
in Chrome, // it removes the div and replaces it with just a
inside the // root. Detach the
; the _ensureBottomLine call will insert a new // block. if ( node === self._root && ( node = node.firstChild ) && node.nodeName === 'BR' ) { detach( node ); } self._ensureBottomLine(); self.setSelection( range ); self._updatePath( range, true ); } catch ( error ) { didError( error ); } }, detachUneditableNode = ( node, root ) => { let parent; while (( parent = node.parentNode )) { if ( parent === root || parent.isContentEditable ) { break; } node = parent; } detach( node ); }, handleEnter = ( self, shiftKey, range ) => { let root = self._root; let block, parent, node, offset, nodeAfterSplit; // Save undo checkpoint and add any links in the preceding section. // Remove any zws so we don't think there's content in an empty // block. self._recordUndoState( range, false ); if ( self._config.addLinks ) { addLinks( range.startContainer, root, self ); } self._removeZWS(); self._getRangeAndRemoveBookmark( range ); // Selected text is overwritten, therefore delete the contents // to collapse selection. if ( !range.collapsed ) { deleteContentsOfRange( range, root ); } block = getStartBlockOfRange( range, root ); // Inside a PRE, insert literal newline, unless on blank line. if ( block && ( parent = getClosest( block, root, 'PRE' ) ) ) { moveRangeBoundariesDownTree( range ); node = range.startContainer; offset = range.startOffset; if ( node.nodeType !== TEXT_NODE ) { node = doc.createTextNode( '' ); parent.insertBefore( node, parent.firstChild ); } // If blank line: split and insert default block if ( !shiftKey && ( node.data.charAt( offset - 1 ) === '\n' || rangeDoesStartAtBlockBoundary( range, root ) ) && ( node.data.charAt( offset ) === '\n' || rangeDoesEndAtBlockBoundary( range, root ) ) ) { node.deleteData( offset && offset - 1, offset ? 2 : 1 ); nodeAfterSplit = split( node, offset && offset - 1, root, root ); node = nodeAfterSplit.previousSibling; if ( !node.textContent ) { detach( node ); } node = self.createDefaultBlock(); nodeAfterSplit.before( node ); if ( !nodeAfterSplit.textContent ) { detach( nodeAfterSplit ); } range.setStart( node, 0 ); } else { node.insertData( offset, '\n' ); fixCursor( parent, root ); // Firefox bug: if you set the selection in the text node after // the new line, it draws the cursor before the line break still // but if you set the selection to the equivalent position // in the parent, it works. if ( node.length === offset + 1 ) { range.setStartAfter( node ); } else { range.setStart( node, offset + 1 ); } } range.collapse( true ); self.setSelection( range ); self._updatePath( range, true ); self._docWasChanged(); return; } // If this is a malformed bit of document or in a table; // just play it safe and insert a
. if ( !block || shiftKey || /^T[HD]$/.test( block.nodeName ) ) { // If inside an , move focus out moveRangeBoundaryOutOf( range, 'A', root ); insertNodeInRange( range, createElement( 'BR' ) ); range.collapse( false ); self.setSelection( range ); self._updatePath( range, true ); return; } // If in a list, we'll split the LI instead. block = getClosest( block, root, 'LI' ) || block; if ( isEmptyBlock( block ) && ( parent = getClosest( block, root, 'UL,OL,BLOCKQUOTE' ) ) ) { return 'BLOCKQUOTE' === parent.nodeName // Break blockquote ? self.modifyBlocks( (/* frag */) => self.createDefaultBlock( createBookmarkNodes( self ) ), range ) // Break list : self.decreaseListLevel( range ); } // Otherwise, split at cursor point. nodeAfterSplit = splitBlock( self, block, range.startContainer, range.startOffset ); // Clean up any empty inlines if we hit enter at the beginning of the block removeZWS( block ); removeEmptyInlines( block ); fixCursor( block, root ); // Focus cursor // If there's a / etc. at the beginning of the split // make sure we focus inside it. while ( nodeAfterSplit.nodeType === ELEMENT_NODE ) { let child = nodeAfterSplit.firstChild, next; // Don't continue links over a block break; unlikely to be the // desired outcome. if ( nodeAfterSplit.nodeName === 'A' && ( !nodeAfterSplit.textContent || nodeAfterSplit.textContent === ZWS ) ) { child = doc.createTextNode( '' ); nodeAfterSplit.replaceWith( child ); nodeAfterSplit = child; break; } while ( child?.nodeType === TEXT_NODE && !child.data ) { next = child.nextSibling; if ( !next || next.nodeName === 'BR' ) { break; } detach( child ); child = next; } // 'BR's essentially don't count; they're a browser hack. // If you try to select the contents of a 'BR', FF will not let // you type anything! if ( !child || child.nodeName === 'BR' || child.nodeType === TEXT_NODE ) { break; } nodeAfterSplit = child; } range = createRange( nodeAfterSplit, 0 ); self.setSelection( range ); self._updatePath( range, true ); }, changeIndentationLevel = direction => ( self, event ) => { event.preventDefault(); self.changeIndentationLevel(direction); }, toggleList = ( type, methodIfNotInList ) => ( self, event ) => { event.preventDefault(); let parent = self.getSelectionClosest('UL,OL'); if (type == parent?.nodeName) { self.removeList(); } else { self[ methodIfNotInList ](); } }, fontSizes = { 1: 'x-small', 2: 'small', 3: 'medium', 4: 'large', 5: 'x-large', 6: 'xx-large', 7: 'xxx-large', '-1': 'smaller', '+1': 'larger' }, styleToSemantic = { fontWeight: { regexp: /^bold|^700/i, replace: () => createElement( 'B' ) }, fontStyle: { regexp: /^italic/i, replace: () => createElement( 'I' ) }, fontFamily: { regexp: notWS, replace: ( doc, family ) => createElement( 'SPAN', { style: 'font-family:' + family }) }, fontSize: { regexp: notWS, replace: ( doc, size ) => createElement( 'SPAN', { style: 'font-size:' + size }) }, textDecoration: { regexp: /^underline/i, replace: () => createElement( 'U' ) } /* textDecoration: { regexp: /^line-through/i, replace: doc => createElement( 'S' ) } */ }, replaceWithTag = tag => node => { let el = createElement( tag ); Array.prototype.forEach.call( node.attributes, attr => el.setAttribute( attr.name, attr.value ) ); node.replaceWith( el ); el.append( empty( node ) ); return el; }, replaceStyles = node => { let style = node.style; let css, newTreeBottom, newTreeTop, el; Object.entries(styleToSemantic).forEach(([attr,converter])=>{ css = style[ attr ]; if ( css && converter.regexp.test( css ) ) { el = converter.replace( doc, css ); if ( el.nodeName === node.nodeName && el.className === node.className ) { return; } if ( !newTreeTop ) { newTreeTop = el; } if ( newTreeBottom ) { newTreeBottom.append( el ); } newTreeBottom = el; node.style[ attr ] = ''; } }); if ( newTreeTop ) { newTreeBottom.append( empty( node ) ); node.append( newTreeTop ); } return newTreeBottom || node; }, stylesRewriters = { SPAN: replaceStyles, STRONG: replaceWithTag( 'B' ), EM: replaceWithTag( 'I' ), INS: replaceWithTag( 'U' ), STRIKE: replaceWithTag( 'S' ), FONT: node => { let face = node.face, size = node.size, color = node.color, newTag = createElement( 'SPAN' ), css = newTag.style; if ( face ) { css.fontFamily = face; } if ( size ) { css.fontSize = fontSizes[ size ]; } if ( color && /^#?([\dA-F]{3}){1,2}$/i.test( color ) ) { if ( color.charAt( 0 ) !== '#' ) { color = '#' + color; } css.color = color; } node.replaceWith( newTag ); newTag.append( empty( node ) ); return newTag; }, // KBD: // VAR: // CODE: // SAMP: TT: node => { let el = createElement( 'SPAN', { style: 'font-family:menlo,consolas,"courier new",monospace' }); node.replaceWith( el ); el.append( empty( node ) ); return el; } }, allowedBlock = /^(?:A(?:DDRESS|RTICLE|SIDE|UDIO)|BLOCKQUOTE|CAPTION|D(?:[DLT]|IV)|F(?:IGURE|IGCAPTION|OOTER)|H[1-6]|HEADER|L(?:ABEL|EGEND|I)|O(?:L|UTPUT)|P(?:RE)?|SECTION|T(?:ABLE|BODY|D|FOOT|H|HEAD|R)|COL(?:GROUP)?|UL)$/, blacklist = /^(?:HEAD|META|STYLE)/, /* // Previous node in post-order. previousPONode = walker => { let current = walker.currentNode, root = walker.root, nodeType = walker.nodeType, // whatToShow? filter = walker.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 ) ) { walker.currentNode = node; return node; } current = node; } return null; }, */ /* Two purposes: 1. Remove nodes we don't want, such as weird tags, comment nodes and whitespace nodes. 2. Convert inline tags into our preferred format. */ cleanTree = ( node, config, preserveWS ) => { let children = node.childNodes, nonInlineParent, i, l, child, nodeName, nodeType, childLength; // startsWithWS, endsWithWS, data, sibling; nonInlineParent = node; while ( isInline( nonInlineParent ) ) { nonInlineParent = nonInlineParent.parentNode; } // let walker = createTreeWalker( nonInlineParent, SHOW_ELEMENT_OR_TEXT ); for ( i = 0, l = children.length; i < l; ++i ) { child = children[i]; nodeName = child.nodeName; nodeType = child.nodeType; if ( nodeType === ELEMENT_NODE ) { childLength = child.childNodes.length; if ( stylesRewriters[ nodeName ] ) { child = stylesRewriters[ nodeName ]( child ); } else if ( blacklist.test( nodeName ) ) { child.remove( ); --i; --l; continue; } else if ( !allowedBlock.test( nodeName ) && !isInline( child ) ) { --i; l += childLength - 1; child.replaceWith( empty( child ) ); continue; } if ( childLength ) { cleanTree( child, config, preserveWS || ( nodeName === 'PRE' ) ); } /* } else { if ( nodeType === TEXT_NODE ) { data = child.data; startsWithWS = !notWS.test( data.charAt( 0 ) ); endsWithWS = !notWS.test( data.charAt( data.length - 1 ) ); if ( preserveWS || ( !startsWithWS && !endsWithWS ) ) { continue; } // Iterate through the nodes; if we hit some other content // before the start of a new block we don't trim if ( startsWithWS ) { walker.currentNode = child; while ( sibling = previousPONode(walker) ) { nodeName = sibling.nodeName; if ( nodeName === 'IMG' || ( nodeName === '#text' && notWS.test( sibling.data ) ) ) { break; } if ( !isInline( sibling ) ) { sibling = null; break; } } data = data.replace( /^[ \r\n]+/g, sibling ? ' ' : '' ); } if ( endsWithWS ) { walker.currentNode = child; while ( sibling = walker.nextNode() ) { if ( nodeName === 'IMG' || ( nodeName === '#text' && notWS.test( sibling.data ) ) ) { break; } if ( !isInline( sibling ) ) { sibling = null; break; } } data = data.replace( /[ \r\n]+$/g, sibling ? ' ' : '' ); } if ( data ) { child.data = data; continue; } } child.remove( ); --i; --l; */ } } return node; }, // --- removeEmptyInlines = node => { let children = node.childNodes, l = children.length, child; while ( l-- ) { child = children[l]; if ( child.nodeType === ELEMENT_NODE && !isLeaf( child ) ) { removeEmptyInlines( child ); if ( !child.firstChild && isInline( child ) ) { child.remove( ); } } else if ( child.nodeType === TEXT_NODE && !child.data ) { child.remove( ); } } }, // --- notWSTextNode = node => node.nodeType === ELEMENT_NODE ? node.nodeName === 'BR' : notWS.test( node.data ), isLineBreak = ( br, isLBIfEmptyBlock ) => { let walker, block = br.parentNode; while ( isInline( block ) ) { block = block.parentNode; } walker = createTreeWalker( block, SHOW_ELEMENT_OR_TEXT, notWSTextNode ); walker.currentNode = br; return !!walker.nextNode() || ( isLBIfEmptyBlock && !walker.previousNode() ); }, //
elements are treated specially, and differently depending on the // browser, when in rich text editor mode. When adding HTML from external // sources, we must remove them, replacing the ones that actually affect // line breaks by wrapping the inline text in a
. 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 l = brs.length; let br, parent; 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 ( !isLineBreak( br, keepForBlankLine ) ) { 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).replace( NBSP, ' ' ); // Replace nbsp with regular space node.remove( ); if ( text !== html ) { clipboardData.setData( 'text/html', html ); } clipboardData.setData( 'text/plain', text ); event.preventDefault(); }, mergeObjects = ( base, extras, mayOverride ) => { base = base || {}; extras && Object.entries(extras).forEach(([prop,value])=>{ if ( mayOverride || !( prop in base ) ) { base[ prop ] = ( 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 ] || self._config.blockTag, nodeAfterSplit = split( node, offset, block.parentNode, self._root ); // 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