schlechtenburg/packages/rich-text/lib/to-dom.ts
2024-10-09 16:47:31 +02:00

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();
}
}
}