/** * Internal dependencies */ import { toTree } from './to-tree'; import { createElement } from './create-element'; import { isRangeEqual } from './is-range-equal'; import { RichTextValue } from './types'; /** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Creates a path as an array of indices from the given root node to the given * node. * * @param {Node} node Node to find the path of. * @param {HTMLElement} rootNode Root node to find the path from. * @param {Array} path Initial path to build on. * * @return {Array} The path from the root node to the node. */ function createPathToNode( node: Node, rootNode: HTMLElement, path: number[] ) { let workingNode: Node|null = node; const parentNode = node.parentNode!; let i = 0; while ( ( workingNode = workingNode.previousSibling ) ) { i++; } path = [ i, ...path ]; if ( parentNode !== rootNode ) { path = createPathToNode( parentNode, rootNode, path ); } return path; } /** * Gets a node given a path (array of indices) from the given node. * * @param {HTMLElement} node Root node to find the wanted node in. * @param {Array} path Path (indices) to the wanted node. * * @return {Object} Object with the found node and the remaining offset (if any). */ function getNodeByPath( node: HTMLElement, path: number[] ) { let workingNode: Node = node; path = [ ...path ]; while ( workingNode && path.length > 1 ) { workingNode = workingNode.childNodes[ (path.shift() || 0) ]; } return { node, offset: path[ 0 ], }; } function append( element: HTMLElement, child) { if ( child.html !== undefined ) { return ( element.innerHTML += child.html ); } if ( typeof child === 'string' ) { child = element.ownerDocument.createTextNode( child ); } const { type, attributes } = child; if ( type ) { child = element.ownerDocument.createElement( type ); for ( const key in attributes ) { child.setAttribute( key, attributes[ key ] ); } } return element.appendChild( child ); } function appendText( node, text ) { node.appendData( text ); } function getLastChild( { lastChild } ) { return lastChild; } function getParent( { parentNode } ) { return parentNode; } function isText( node ) { return node.nodeType === node.TEXT_NODE; } function getText( { nodeValue } ) { return nodeValue; } function remove( node ) { return node.parentNode.removeChild( node ); } export function toDom({ value, prepareEditableTree, isEditableTree = true, placeholder, doc = document, }: { value: RichTextValue, prepareEditableTree: Function, isEditableTree?: boolean, placeholder?: string, doc?: Document, }) { let startPath: number[] = []; let endPath: number[] = []; if ( prepareEditableTree ) { value = { ...value, formats: prepareEditableTree( value ), }; } /** * Returns a new instance of a DOM tree upon which RichText operations can be * applied. * * Note: The current implementation will return a shared reference, reset on * each call to `createEmpty`. Therefore, you should not hold a reference to * the value to operate upon asynchronously, as it may have unexpected results. * * @return {Object} RichText tree. */ const createEmpty = () => createElement( doc, '' ); const tree = toTree( { value, createEmpty, append, getLastChild, getParent, isText, getText, remove, appendText, onStartIndex( body, pointer ) { startPath = createPathToNode( pointer, body, [ pointer.nodeValue.length, ] ); }, onEndIndex( body, pointer ) { endPath = createPathToNode( pointer, body, [ pointer.nodeValue.length, ] ); }, isEditableTree, placeholder, } ); return { body: tree, selection: { startPath, endPath }, }; } /** * Create an `Element` tree from a Rich Text value and applies the difference to * the `Element` tree contained by `current`. * * @param {Object} $1 Named arguments. * @param {RichTextValue} $1.value Value to apply. * @param {HTMLElement} $1.current The live root node to apply the element tree to. * @param {Function} [$1.prepareEditableTree] Function to filter editorable formats. * @param {boolean} [$1.__unstableDomOnly] Only apply elements, no selection. * @param {string} [$1.placeholder] Placeholder text. */ export function apply( { value, current, prepareEditableTree, placeholder, }: { value: RichTextValue, current: HTMLElement, prepareEditableTree: Function, placeholder: string, }) { // Construct a new element tree in memory. const { body, selection } = toDom( { value, prepareEditableTree, placeholder, doc: current.ownerDocument, } ); applyValue( body, current ); if ( value.start !== undefined) { applySelection( selection, current ); } } export function applyValue( future: HTMLElement, current: HTMLElement ) { let i = 0; let futureChild: HTMLElement|null = null; while ( ( futureChild = future.firstChild as HTMLElement ) ) { const currentChild = current.childNodes[ i ] as HTMLElement; if ( ! currentChild ) { current.appendChild( futureChild ); } else if ( ! currentChild.isEqualNode( futureChild ) ) { if ( currentChild.nodeName !== futureChild.nodeName || ( currentChild.nodeType === currentChild.TEXT_NODE && currentChild.data !== futureChild.data ) ) { current.replaceChild( futureChild, currentChild ); } else { const currentAttributes = currentChild.attributes; const futureAttributes = futureChild.attributes; if ( currentAttributes ) { let ii = currentAttributes.length; // Reverse loop because `removeAttribute` on `currentChild` // changes `currentAttributes`. while ( ii-- ) { const { name } = currentAttributes[ ii ]; if ( ! futureChild.getAttribute( name ) ) { currentChild.removeAttribute( name ); } } } if ( futureAttributes ) { for ( let ii = 0; ii < futureAttributes.length; ii++ ) { const { name, value } = futureAttributes[ ii ]; if ( currentChild.getAttribute( name ) !== value ) { currentChild.setAttribute( name, value ); } } } applyValue( futureChild, currentChild ); future.removeChild( futureChild ); } } else { future.removeChild( futureChild ); } i++; } while ( current.childNodes[ i ] ) { current.removeChild( current.childNodes[ i ] ); } } export function applySelection( { startPath, endPath }: { startPath: number[], endPath: number[] }, current: HTMLElement ) { const { node: startContainer, offset: startOffset } = getNodeByPath( current, startPath ); const { node: endContainer, offset: endOffset } = getNodeByPath( current, endPath ); const { ownerDocument } = current; const { defaultView } = ownerDocument; const selection = (defaultView as Window).getSelection() as Selection; const range = ownerDocument.createRange(); range.setStart( startContainer, startOffset ); range.setEnd( endContainer, endOffset ); const { activeElement } = ownerDocument; if ( selection.rangeCount > 0 ) { // If the to be added range and the live range are the same, there's no // need to remove the live range and add the equivalent range. if ( isRangeEqual( range, selection.getRangeAt( 0 ) ) ) { return; } selection.removeAllRanges(); } selection.addRange( range ); // This function is not intended to cause a shift in focus. Since the above // selection manipulations may shift focus, ensure that focus is restored to // its previous state. if ( activeElement !== ownerDocument.activeElement ) { // The `instanceof` checks protect against edge cases where the focused // element is not of the interface HTMLElement (does not have a `focus` // or `blur` property). // // See: https://github.com/Microsoft/TypeScript/issues/5901#issuecomment-431649653 if ( activeElement instanceof HTMLElement ) { activeElement.focus(); } } }