import { toTree } from './to-tree'; import { FormatTypeStore } from './use-format-types'; import { createElement } from './create-element'; import { isRangeEqual } from './is-range-equal'; import { RichTextValue } from './types'; /** * Creates a path as an array of indices from the given root node to the given * 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. */ 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, }, store: FormatTypeStore, ) { 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. */ 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, }, store ); 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`. */ export function apply( { value, current, prepareEditableTree, placeholder, }: { value: RichTextValue, current: HTMLElement, prepareEditableTree: Function, placeholder: string, }, store: FormatTypeStore, ) { // Construct a new element tree in memory. const { body, selection } = toDom({ value, prepareEditableTree, placeholder, doc: current.ownerDocument, }, store ); 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(); } } }