296 lines
6.9 KiB
TypeScript
296 lines
6.9 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|