From 8dcb1039a87ad8a09aff4c057b2f2efde3a2a7f3 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Mon, 20 Jun 2022 18:22:16 +0200 Subject: [PATCH] #450@trivial: Continue on Range implementation. --- .../src/nodes/element/DOMRectListFactory.ts | 33 ++ .../happy-dom/src/nodes/element/Element.ts | 13 +- .../src/nodes/element/IDOMRectList.ts | 11 + .../happy-dom/src/nodes/element/IElement.ts | 9 +- packages/happy-dom/src/nodes/node/Node.ts | 10 +- .../happy-dom/src/nodes/node/NodeUtility.ts | 20 +- packages/happy-dom/src/range/Range.ts | 549 ++++++++++++++++-- packages/happy-dom/src/range/RangeUtility.ts | 165 +----- .../happy-dom/src/xml-parser/XMLParser.ts | 22 +- 9 files changed, 629 insertions(+), 203 deletions(-) create mode 100644 packages/happy-dom/src/nodes/element/DOMRectListFactory.ts create mode 100644 packages/happy-dom/src/nodes/element/IDOMRectList.ts diff --git a/packages/happy-dom/src/nodes/element/DOMRectListFactory.ts b/packages/happy-dom/src/nodes/element/DOMRectListFactory.ts new file mode 100644 index 000000000..e0817e0d3 --- /dev/null +++ b/packages/happy-dom/src/nodes/element/DOMRectListFactory.ts @@ -0,0 +1,33 @@ +import DOMRect from './DOMRect'; +import IDOMRectList from './IDOMRectList'; + +/** + * DOM rect list factory. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/getClientRects + */ +export default class DOMRectListFactory { + /** + * Creates an HTMLCollection. + * + * @param list Nodes. + * @returns HTMLCollection. + */ + public static create(list?: DOMRect[]): IDOMRectList { + list = list ? list.slice() : []; + Object.defineProperty(list, 'item', { + value: this.getItem.bind(null, list) + }); + return >list; + } + + /** + * Returns node by index. + * + * @param list + * @param index Index. + */ + private static getItem(list: DOMRect[], index: number): DOMRect { + return list[index] || null; + } +} diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 0ad762eea..aa04248f0 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -25,6 +25,8 @@ import INodeList from '../node/INodeList'; import HTMLCollectionFactory from './HTMLCollectionFactory'; import { TInsertAdjacentPositions } from './IElement'; import IText from '../text/IText'; +import IDOMRectList from './IDOMRectList'; +import DOMRectListFactory from './DOMRectListFactory'; /** * Element. @@ -680,16 +682,19 @@ export default class Element extends Node implements IElement { * @returns DOM rect. */ public getBoundingClientRect(): DOMRect { + // TODO: Not full implementation return new DOMRect(); } /** - * Returns a range. + * Returns a collection of DOMRect objects that indicate the bounding rectangles for each CSS border box in a client. * - * @returns Range. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/getClientRects + * @returns DOM rect list. */ - public createTextRange(): Range { - return new Range(); + public getClientRects(): IDOMRectList { + // TODO: Not full implementation + return DOMRectListFactory.create([this.getBoundingClientRect()]); } /** diff --git a/packages/happy-dom/src/nodes/element/IDOMRectList.ts b/packages/happy-dom/src/nodes/element/IDOMRectList.ts new file mode 100644 index 000000000..e0996e964 --- /dev/null +++ b/packages/happy-dom/src/nodes/element/IDOMRectList.ts @@ -0,0 +1,11 @@ +/** + * HTMLCollection. + */ +export default interface IDOMRectList extends Array { + /** + * Returns item by index. + * + * @param index Index. + */ + item(index: number): T; +} diff --git a/packages/happy-dom/src/nodes/element/IElement.ts b/packages/happy-dom/src/nodes/element/IElement.ts index 146d4f9d4..72c0cab6f 100644 --- a/packages/happy-dom/src/nodes/element/IElement.ts +++ b/packages/happy-dom/src/nodes/element/IElement.ts @@ -1,12 +1,12 @@ import IShadowRoot from '../shadow-root/IShadowRoot'; import Attr from '../../attribute/Attr'; import DOMRect from './DOMRect'; -import Range from './Range'; import IDOMTokenList from '../../dom-token-list/IDOMTokenList'; import INode from './../node/INode'; import IChildNode from '../child-node/IChildNode'; import IParentNode from '../parent-node/IParentNode'; import INonDocumentTypeChildNode from '../child-node/INonDocumentTypeChildNode'; +import IDOMRectList from './IDOMRectList'; export type TInsertAdjacentPositions = 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend'; @@ -161,11 +161,12 @@ export default interface IElement extends IChildNode, INonDocumentTypeChildNode, getBoundingClientRect(): DOMRect; /** - * Returns a range. + * Returns a collection of DOMRect objects that indicate the bounding rectangles for each CSS border box in a client. * - * @returns Range. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/getClientRects + * @returns DOM rect list. */ - createTextRange(): Range; + getClientRects(): IDOMRectList; /** * The matches() method checks to see if the Element would be selected by the provided selectorString. diff --git a/packages/happy-dom/src/nodes/node/Node.ts b/packages/happy-dom/src/nodes/node/Node.ts index 14d751f17..664a60d7a 100644 --- a/packages/happy-dom/src/nodes/node/Node.ts +++ b/packages/happy-dom/src/nodes/node/Node.ts @@ -1,7 +1,7 @@ import EventTarget from '../../event/EventTarget'; import MutationRecord from '../../mutation-observer/MutationRecord'; import MutationTypeEnum from '../../mutation-observer/MutationTypeEnum'; -import MutationObserverListener from '../../mutation-observer/MutationListener'; +import MutationListener from '../../mutation-observer/MutationListener'; import Event from '../../event/Event'; import INode from './INode'; import DOMException from '../../exception/DOMException'; @@ -37,10 +37,10 @@ export default class Node extends EventTarget implements INode { public readonly nodeType: number; public readonly childNodes: INodeList = NodeListFactory.create(); public readonly isConnected: boolean = false; - public _rootNode: INode = null; // Custom Properties (not part of HTML standard) - protected _observers: MutationObserverListener[] = []; + public _rootNode: INode = null; + public _observers: MutationListener[] = []; /** * Constructor. @@ -460,7 +460,7 @@ export default class Node extends EventTarget implements INode { * * @param listener Listener. */ - public _observe(listener: MutationObserverListener): void { + public _observe(listener: MutationListener): void { this._observers.push(listener); if (listener.options.subtree) { for (const node of this.childNodes) { @@ -475,7 +475,7 @@ export default class Node extends EventTarget implements INode { * * @param listener Listener. */ - public _unobserve(listener: MutationObserverListener): void { + public _unobserve(listener: MutationListener): void { const index = this._observers.indexOf(listener); if (index !== -1) { this._observers.splice(index, 1); diff --git a/packages/happy-dom/src/nodes/node/NodeUtility.ts b/packages/happy-dom/src/nodes/node/NodeUtility.ts index 970f974f1..93ae584c0 100644 --- a/packages/happy-dom/src/nodes/node/NodeUtility.ts +++ b/packages/happy-dom/src/nodes/node/NodeUtility.ts @@ -93,7 +93,7 @@ export default class NodeUtility { * @param [root] Root. * @returns Following node. */ - private static following(node: INode, root?: INode): INode { + public static following(node: INode, root?: INode): INode { const firstChild = node.firstChild; if (firstChild) { @@ -118,4 +118,22 @@ export default class NodeUtility { return null; } + + /** + * Returns the next sibling or parents sibling. + * + * @param node Node. + * @returns Next decentant node. + */ + public static nextDecendantNode(node: INode): INode { + while (node && !node.nextSibling) { + node = node.parentNode; + } + + if (!node) { + return null; + } + + return node.nextSibling; + } } diff --git a/packages/happy-dom/src/range/Range.ts b/packages/happy-dom/src/range/Range.ts index 6031d4edd..0cb3f14b9 100644 --- a/packages/happy-dom/src/range/Range.ts +++ b/packages/happy-dom/src/range/Range.ts @@ -8,6 +8,13 @@ import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; import RangeUtility from './RangeUtility'; import NodeTypeEnum from '../nodes/node/NodeTypeEnum'; import NodeUtility from '../nodes/node/NodeUtility'; +import XMLParser from '../xml-parser/XMLParser'; +import IComment from '../nodes/comment/IComment'; +import IText from '../nodes/text/IText'; +import MutationListener from '../mutation-observer/MutationListener'; +import Node from '../nodes/node/Node'; +import DOMRectListFactory from '../nodes/element/DOMRectListFactory'; +import IDOMRectList from '../nodes/element/IDOMRectList'; /** * Range. @@ -29,6 +36,8 @@ export default class Range { public readonly endOffset: number = 0; public readonly startContainer: INode = null; public readonly endContainer: INode = null; + public _startObserver: MutationListener = null; + public _endObserver: MutationListener = null; public readonly _ownerDocument: IDocument = null; /** @@ -36,8 +45,8 @@ export default class Range { */ constructor() { this._ownerDocument = (this.constructor)._ownerDocument; - this.startContainer = this._ownerDocument; - this.endContainer = this._ownerDocument; + this._setStartContainer(this._ownerDocument, 0); + this._setEndContainer(this._ownerDocument, 0); } /** @@ -86,11 +95,9 @@ export default class Range { */ public collapse(toStart = false): void { if (toStart && !this.startContainer.contains(this.endContainer)) { - (this.endContainer) = this.startContainer; - (this.endOffset) = this.startOffset; + this._setEndContainer(this.startContainer, this.startOffset); } else { - (this.startContainer) = this.endContainer; - (this.startOffset) = this.endOffset; + this._setStartContainer(this.endContainer, this.endOffset); } } @@ -210,11 +217,135 @@ export default class Range { /** * Returns a DocumentFragment copying the objects of type Node included in the Range. * + * Based on: + * https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/range/Range-impl.js#L306 + * + * @see https://dom.spec.whatwg.org/#concept-range-clone * @returns Document fragment. */ public cloneContents(): IDocumentFragment { - // TODO: Implement - return null; + const fragment = this._ownerDocument.createDocumentFragment(); + + if (this.collapsed) { + return fragment; + } + + if ( + this.startContainer === this.endContainer && + (this.startContainer.nodeType === NodeTypeEnum.textNode || + this.startContainer.nodeType === NodeTypeEnum.processingInstructionNode || + this.startContainer.nodeType === NodeTypeEnum.commentNode) + ) { + const clone = (this.startContainer).cloneNode(false); + clone['_data'] = clone.substringData(this.startOffset, this.endOffset - this.startOffset); + fragment.appendChild(clone); + return fragment; + } + + let commonAncestor = this.startContainer; + while (!NodeUtility.isInclusiveAncestor(commonAncestor, this.endContainer)) { + commonAncestor = commonAncestor.parentNode; + } + + let firstPartialContainedChild = null; + if (!NodeUtility.isInclusiveAncestor(this.startContainer, this.endContainer)) { + let candidate = commonAncestor.firstChild; + while (!firstPartialContainedChild) { + if (RangeUtility.isPartiallyContained(candidate, this)) { + firstPartialContainedChild = candidate; + } + + candidate = candidate.nextSibling; + } + } + + let lastPartiallyContainedChild = null; + if (!NodeUtility.isInclusiveAncestor(this.endContainer, this.startContainer)) { + let candidate = commonAncestor.lastChild; + while (!lastPartiallyContainedChild) { + if (RangeUtility.isPartiallyContained(candidate, this)) { + lastPartiallyContainedChild = candidate; + } + + candidate = candidate.previousSibling; + } + } + + const containedChildren = []; + let hasDoctypeChildren = false; + + for (const node of commonAncestor.childNodes) { + if (RangeUtility.isContained(node, this)) { + if (node.nodeType === NodeTypeEnum.documentTypeNode) { + hasDoctypeChildren = true; + } + containedChildren.push(node); + } + } + + if (hasDoctypeChildren) { + throw new DOMException( + 'Invalid document type element.', + DOMExceptionNameEnum.hierarchyRequestError + ); + } + + if ( + firstPartialContainedChild !== null && + (firstPartialContainedChild.nodeType === NodeTypeEnum.textNode || + firstPartialContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || + firstPartialContainedChild.nodeType === NodeTypeEnum.commentNode) + ) { + const clone = (this.startContainer).cloneNode(false); + clone['_data'] = clone.substringData( + this.startOffset, + this.startContainer.childNodes.length - this.startOffset + ); + + fragment.appendChild(clone); + } else if (firstPartialContainedChild !== null) { + const clone = firstPartialContainedChild.cloneNode(); + fragment.appendChild(clone); + + const subRange = new Range(); + subRange._setStartContainer(this.startContainer, this.startOffset); + subRange._setEndContainer( + firstPartialContainedChild, + firstPartialContainedChild.childNodes.length + ); + + const subDocumentFragment = subRange.cloneContents(); + clone.appendChild(subDocumentFragment); + } + + for (const containedChild of containedChildren) { + const clone = containedChild.cloneNode(true); + fragment.appendChild(clone); + } + + if ( + lastPartiallyContainedChild !== null && + (lastPartiallyContainedChild.nodeType === NodeTypeEnum.textNode || + lastPartiallyContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || + lastPartiallyContainedChild.nodeType === NodeTypeEnum.commentNode) + ) { + const clone = (this.endContainer).cloneNode(false); + clone['_data'] = clone.substringData(0, this.endOffset); + + fragment.appendChild(clone); + } else if (lastPartiallyContainedChild !== null) { + const clone = lastPartiallyContainedChild.cloneNode(false); + fragment.appendChild(clone); + + const subRange = new Range(); + subRange._setStartContainer(lastPartiallyContainedChild, 0); + subRange._setEndContainer(this.endContainer, this.endOffset); + + const subFragment = subRange.cloneContents(); + clone.appendChild(subFragment); + } + + return fragment; } /** @@ -223,33 +354,119 @@ export default class Range { * @returns Range. */ public cloneRange(): Range { - // TODO: Implement - return null; + const clone = new Range(); + + (clone.startContainer) = this.startContainer; + (clone.startOffset) = this.startOffset; + (clone.endContainer) = this.endContainer; + (clone.endOffset) = this.endOffset; + + return clone; } /** * Returns a DocumentFragment by invoking the HTML fragment parsing algorithm or the XML fragment parsing algorithm with the start of the range (the parent of the selected node) as the context node. The HTML fragment parsing algorithm is used if the range belongs to a Document whose HTMLness bit is set. In the HTML case, if the context node would be html, for historical reasons the fragment parsing algorithm is invoked with body as the context instead. * - * @param _tagString Tag string. + * @see https://w3c.github.io/DOM-Parsing/#dfn-fragment-parsing-algorithm + * @param tagString Tag string. * @returns Document fragment. */ - public createContextualFragment(_tagString: string): IDocumentFragment { - // TODO: Implement - return null; + public createContextualFragment(tagString: string): IDocumentFragment { + // TODO: We only have support for HTML in the parser currently, so it is not necessary to check which context it is + return XMLParser.parse(this._ownerDocument, tagString); } /** * Removes the contents of the Range from the Document. */ public deleteContents(): void { - // TODO: Implement + if (this.collapsed) { + return; + } + + if ( + this.startContainer === this.endContainer && + (this.startContainer.nodeType === NodeTypeEnum.textNode || + this.startContainer.nodeType === NodeTypeEnum.processingInstructionNode || + this.startContainer.nodeType === NodeTypeEnum.commentNode) + ) { + (this.startContainer).replaceData( + this.startOffset, + this.endOffset - this.startOffset, + '' + ); + return; + } + + const nodesToRemove = []; + let currentNode = this.startContainer; + const endNode = NodeUtility.nextDecendantNode(this.endContainer); + while (currentNode && currentNode !== endNode) { + if ( + RangeUtility.isContained(currentNode, this) && + !RangeUtility.isContained(currentNode.parentNode, this) + ) { + nodesToRemove.push(currentNode); + } + + currentNode = NodeUtility.following(currentNode); + } + + let newNode; + let newOffset; + if (NodeUtility.isInclusiveAncestor(this.startContainer, this.endContainer)) { + newNode = this.startContainer; + newOffset = this.startOffset; + } else { + let referenceNode = this.startContainer; + + while ( + referenceNode && + !NodeUtility.isInclusiveAncestor(referenceNode.parentNode, this.endContainer) + ) { + referenceNode = referenceNode.parentNode; + } + + newNode = referenceNode.parentNode; + newOffset = referenceNode.parentNode.childNodes.indexOf(referenceNode) + 1; + } + + if ( + this.startContainer.nodeType === NodeTypeEnum.textNode || + this.startContainer.nodeType === NodeTypeEnum.processingInstructionNode || + this.startContainer.nodeType === NodeTypeEnum.commentNode + ) { + (this.startContainer).replaceData( + this.startOffset, + this.startContainer.childNodes.length - this.startOffset, + '' + ); + } + + for (const node of nodesToRemove) { + const parent = node.parentNode; + parent.removeChild(node); + } + + if ( + this.endContainer.nodeType === NodeTypeEnum.textNode || + this.endContainer.nodeType === NodeTypeEnum.processingInstructionNode || + this.endContainer.nodeType === NodeTypeEnum.commentNode + ) { + (this.endContainer).replaceData(0, this.endOffset, ''); + } + + this._setStartContainer(newNode, newOffset); + this._setEndContainer(newNode, newOffset); } /** * Does nothing. It used to disable the Range object and enable the browser to release associated resources. The method has been kept for compatibility. + * + * @see https://dom.spec.whatwg.org/#dom-range-detach */ public detach(): void { - // Do nothing + // Do nothing by spec } /** @@ -258,8 +475,165 @@ export default class Range { * @returns Document fragment. */ public extractContents(): IDocumentFragment { - // TODO: Implement - return null; + const fragment = this._ownerDocument.createDocumentFragment(); + + if (this.collapsed) { + return fragment; + } + + if ( + this.startContainer === this.endContainer && + (this.startContainer.nodeType === NodeTypeEnum.textNode || + this.startContainer.nodeType === NodeTypeEnum.processingInstructionNode || + this.startContainer.nodeType === NodeTypeEnum.commentNode) + ) { + const clone = this.startContainer.cloneNode(false); + clone['_data'] = clone.substringData(this.startOffset, this.endOffset - this.startOffset); + + fragment.appendChild(clone); + + (this.startContainer).replaceData( + this.startOffset, + this.endOffset - this.startOffset, + '' + ); + + return fragment; + } + + let commonAncestor = this.startContainer; + while (!NodeUtility.isInclusiveAncestor(commonAncestor, this.endContainer)) { + commonAncestor = commonAncestor.parentNode; + } + + let firstPartialContainedChild = null; + if (!NodeUtility.isInclusiveAncestor(this.startContainer, this.endContainer)) { + let candidate = commonAncestor.firstChild; + while (!firstPartialContainedChild) { + if (RangeUtility.isPartiallyContained(candidate, this)) { + firstPartialContainedChild = candidate; + } + + candidate = candidate.nextSibling; + } + } + + let lastPartiallyContainedChild = null; + if (!NodeUtility.isInclusiveAncestor(this.endContainer, this.startContainer)) { + let candidate = commonAncestor.lastChild; + while (!lastPartiallyContainedChild) { + if (RangeUtility.isPartiallyContained(candidate, this)) { + lastPartiallyContainedChild = candidate; + } + + candidate = candidate.previousSibling; + } + } + + const containedChildren = []; + let hasDoctypeChildren = false; + + for (const node of commonAncestor.childNodes) { + if (RangeUtility.isContained(node, this)) { + if (node.nodeType === NodeTypeEnum.documentTypeNode) { + hasDoctypeChildren = true; + } + containedChildren.push(node); + } + } + + if (hasDoctypeChildren) { + throw new DOMException( + 'Invalid document type element.', + DOMExceptionNameEnum.hierarchyRequestError + ); + } + + let newNode; + let newOffset; + if (NodeUtility.isInclusiveAncestor(this.startContainer, this.endContainer)) { + newNode = this.startContainer; + newOffset = this.startOffset; + } else { + let referenceNode = this.startContainer; + + while ( + referenceNode && + !NodeUtility.isInclusiveAncestor(referenceNode.parentNode, this.endContainer) + ) { + referenceNode = referenceNode.parentNode; + } + + newNode = referenceNode.parentNode; + newOffset = referenceNode.parentNode.childNodes.indexOf(referenceNode) + 1; + } + + if ( + firstPartialContainedChild !== null && + (firstPartialContainedChild.nodeType === NodeTypeEnum.textNode || + firstPartialContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || + firstPartialContainedChild.nodeType === NodeTypeEnum.commentNode) + ) { + const clone = this.startContainer.cloneNode(false); + clone['_data'] = clone.substringData( + this.startOffset, + this.startContainer.childNodes.length - this.startOffset + ); + + fragment.appendChild(clone); + + (this.startContainer).replaceData( + this.startOffset, + this.startContainer.childNodes.length - this.startOffset, + '' + ); + } else if (firstPartialContainedChild !== null) { + const clone = firstPartialContainedChild.cloneNode(false); + fragment.appendChild(clone); + + const subRange = new Range(); + subRange._setStartContainer(this.startContainer, this.startOffset); + subRange._setEndContainer( + firstPartialContainedChild, + firstPartialContainedChild.childNodes.length + ); + + const subFragment = subRange.extractContents(); + clone.appendChild(subFragment); + } + + for (const containedChild of containedChildren) { + fragment.appendChild(containedChild); + } + + if ( + lastPartiallyContainedChild !== null && + (lastPartiallyContainedChild.nodeType === NodeTypeEnum.textNode || + lastPartiallyContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || + lastPartiallyContainedChild.nodeType === NodeTypeEnum.commentNode) + ) { + const clone = this.endContainer.cloneNode(false); + clone['_data'] = clone.substringData(0, this.endOffset); + + fragment.appendChild(clone); + + (this.endContainer).replaceData(0, this.endOffset, ''); + } else if (lastPartiallyContainedChild !== null) { + const clone = lastPartiallyContainedChild.cloneNode(false); + fragment.appendChild(clone); + + const subRange = new Range(); + subRange._setStartContainer(lastPartiallyContainedChild, 0); + subRange._setEndContainer(this.endContainer, this.endOffset); + + const subFragment = subRange.extractContents(); + clone.appendChild(subFragment); + } + + this._setStartContainer(newNode, newOffset); + this._setEndContainer(newNode, newOffset); + + return fragment; } /** @@ -268,8 +642,8 @@ export default class Range { * @returns DOMRect object. */ public getBoundingClientRect(): DOMRect { - // TODO: Implement - return null; + // TODO: Not full implementation + return new DOMRect(); } /** @@ -277,9 +651,9 @@ export default class Range { * * @returns DOMRect objects. */ - public getClientRects(): DOMRect[] { - // TODO: Implement - return null; + public getClientRects(): IDOMRectList { + // TODO: Not full implementation + return DOMRectListFactory.create(); } /** @@ -290,8 +664,22 @@ export default class Range { * @returns "true" if in range. */ public isPointInRange(_referenceNode: INode, _offset = 0): boolean { - // TODO: Implement - return false; + if (node.ownerDocument !== this._ownerDocument) { + return false; + } + + validateSetBoundaryPoint(node, offset); + + const bp = { node, offset }; + + if ( + compareBoundaryPointsPosition(bp, this._start) === -1 || + compareBoundaryPointsPosition(bp, this._end) === 1 + ) { + return false; + } + + return true; } /** @@ -330,10 +718,8 @@ export default class Range { const index = referenceNode.parentNode.childNodes.indexOf(referenceNode); - (this.startContainer) = referenceNode.parentNode; - (this.endContainer) = referenceNode.parentNode; - (this.startOffset) = index; - (this.endOffset) = index + 1; + this._setStartContainer(referenceNode.parentNode, index); + this._setEndContainer(referenceNode.parentNode, index + 1); } /** @@ -342,10 +728,8 @@ export default class Range { * @param referenceNode Reference node. */ public selectNodeContents(referenceNode: INode): void { - (this.startContainer) = referenceNode; - (this.endContainer) = referenceNode; - (this.startOffset) = 0; - (this.endOffset) = referenceNode.childNodes.length; + this._setStartContainer(referenceNode, 0); + this._setEndContainer(referenceNode, referenceNode.childNodes.length); } /** @@ -384,8 +768,7 @@ export default class Range { ) { this.setStart(endNode, endOffset); } - (this.endContainer) = endNode; - (this.endOffset) = endOffset; + this._setEndContainer(endNode, endOffset); } /** @@ -424,8 +807,7 @@ export default class Range { ) { this.setEnd(startNode, startOffset); } - (this.startContainer) = startNode; - (this.startOffset) = startOffset; + this._setStartContainer(startNode, startOffset); } /** @@ -503,4 +885,97 @@ export default class Range { public toString(): string { return ''; } + + /** + * Sets start container. + * + * @param container Container. + * @param offset Offset. + */ + public _setStartContainer(container: INode, offset: number): void { + if ( + this.startContainer && + this._startObserver && + (this.startContainer !== container || this.startOffset !== offset) + ) { + (this.startContainer)._unobserve(this._startObserver); + } + + (this.startContainer) = container; + (this.startOffset) = offset; + + if (offset !== 0) { + this._startObserver = this._getMutationListener(container, 'startOffset'); + (container)._observe(this._startObserver); + } + } + + /** + * Sets end container. + * + * @param container Container. + * @param offset Offset. + */ + public _setEndContainer(container: INode, offset: number): void { + if ( + this.endContainer && + this._endObserver && + (this.endContainer !== container || this.endOffset !== offset) + ) { + (this.endContainer)._unobserve(this._endObserver); + } + + (this.endContainer) = container; + (this.endOffset) = offset; + + if (offset !== 0) { + this._endObserver = this._getMutationListener(container, 'endOffset'); + (container)._observe(this._endObserver); + } + } + + /** + * Returns a mutation listener based on node type. + * + * @param node Node to observe. + * @param offsetProperty + */ + protected _getMutationListener( + node: INode, + offsetProperty: 'startOffset' | 'endOffset' + ): MutationListener { + if ( + node.nodeType === NodeTypeEnum.textNode || + node.nodeType === NodeTypeEnum.processingInstructionNode || + node.nodeType === NodeTypeEnum.commentNode + ) { + return { + options: { + characterData: true + }, + callback: () => { + const length = (node).data.length; + if (this[offsetProperty] > length - 1) { + (this[offsetProperty]) = length - 1; + } else if (length === 0 && this[offsetProperty] > 0) { + (this[offsetProperty]) = 0; + } + } + }; + } + + return { + options: { + childList: true + }, + callback: () => { + const length = node.childNodes.length; + if (this[offsetProperty] > length - 1) { + (this[offsetProperty]) = length - 1; + } else if (length === 0 && this[offsetProperty] > 0) { + (this[offsetProperty]) = 0; + } + } + }; + } } diff --git a/packages/happy-dom/src/range/RangeUtility.ts b/packages/happy-dom/src/range/RangeUtility.ts index 3fa12446f..ca20deb97 100644 --- a/packages/happy-dom/src/range/RangeUtility.ts +++ b/packages/happy-dom/src/range/RangeUtility.ts @@ -1,11 +1,5 @@ -import DOMException from '../exception/DOMException'; -import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; -import IComment from '../nodes/comment/IComment'; -import IDocumentFragment from '../nodes/document-documentFragment/IDocumentFragment'; import INode from '../nodes/node/INode'; -import NodeTypeEnum from '../nodes/node/NodeTypeEnum'; import NodeUtility from '../nodes/node/NodeUtility'; -import IText from '../nodes/text/IText'; import Range from './Range'; type BoundaryPoint = { node: INode; offset: number }; @@ -20,6 +14,9 @@ export default class RangeUtility { /** * Compares boundary points. * + * Based on: + * https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/range/boundary-point.js + * * @see https://dom.spec.whatwg.org/#concept-range-bp-after * @param pointA Point A. * @param pointB Point B. @@ -60,144 +57,6 @@ export default class RangeUtility { return -1; } - /** .............................. - * Returns a DocumentFragment copying the objects of type Node included in the Range. - * - * Based on: - * https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/range/Range-impl.js#L306 - * - * @see https://dom.spec.whatwg.org/#concept-range-clone - * @param range Range. - * @returns Document documentFragment. - */ - /** - * - * @param range - */ - public static cloneRangeContent(range: Range): IDocumentFragment { - const documentFragment = range._ownerDocument.createDocumentFragment(); - - if (range.collapsed) { - return documentFragment; - } - - if ( - range.startContainer === range.endContainer && - (range.startContainer.nodeType === NodeTypeEnum.textNode || - range.startContainer.nodeType === NodeTypeEnum.processingInstructionNode || - range.startContainer.nodeType === NodeTypeEnum.commentNode) - ) { - const clone = (range.startContainer).cloneNode(false); - clone['_data'] = clone.substringData(range.startOffset, range.endOffset - range.startOffset); - documentFragment.appendChild(clone); - return documentFragment; - } - - let commonAncestor = range.startContainer; - while (!NodeUtility.isInclusiveAncestor(commonAncestor, range.endContainer)) { - commonAncestor = commonAncestor.parentNode; - } - - let firstPartialContainedChild = null; - if (!NodeUtility.isInclusiveAncestor(range.startContainer, range.endContainer)) { - let candidate = commonAncestor.firstChild; - while (!firstPartialContainedChild) { - if (isPartiallyContained(candidate, range)) { - firstPartialContainedChild = candidate; - } - - candidate = candidate.nextSibling; - } - } - - let lastPartiallyContainedChild = null; - if (!NodeUtility.isInclusiveAncestor(range.endContainer, range.startContainer)) { - let candidate = commonAncestor.lastChild; - while (!lastPartiallyContainedChild) { - if (isPartiallyContained(candidate, range)) { - lastPartiallyContainedChild = candidate; - } - - candidate = candidate.previousSibling; - } - } - - const containedChildren = []; - let hasDoctypeChildren = false; - - for (const node of commonAncestor.childNodes) { - if (this._isContained(node, range)) { - if (node.nodeType === NodeTypeEnum.documentTypeNode) { - hasDoctypeChildren = true; - } - containedChildren.push(node); - } - } - - if (hasDoctypeChildren) { - throw new DOMException( - 'Invalid document type element.', - DOMExceptionNameEnum.hierarchyRequestError - ); - } - - if ( - firstPartialContainedChild !== null && - (firstPartialContainedChild.nodeType === NodeTypeEnum.textNode || - firstPartialContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || - firstPartialContainedChild.nodeType === NodeTypeEnum.commentNode) - ) { - const clone = (range.startContainer).cloneNode(false); - clone['_data'] = clone.substringData( - range.startOffset, - range.startContainer.childNodes.length - range.startOffset - ); - - documentFragment.appendChild(clone); - } else if (firstPartialContainedChild !== null) { - const cloned = clone(firstPartialContainedChild); - documentFragment.appendChild(cloned); - - const subrange = Range.createImpl(_globalObject, [], { - start: { node: range.startContainer, offset: range.startOffset }, - end: { node: firstPartialContainedChild, offset: nodeLength(firstPartialContainedChild) } - }); - - const subdocumentFragment = cloneRange(subrange); - cloned.appendChild(subdocumentFragment); - } - - for (const containedChild of containedChildren) { - const cloned = clone(containedChild, undefined, true); - documentFragment.appendChild(cloned); - } - - if ( - lastPartiallyContainedChild !== null && - (lastPartiallyContainedChild.nodeType === NodeTypeEnum.textNode || - lastPartiallyContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || - lastPartiallyContainedChild.nodeType === NodeTypeEnum.commentNode) - ) { - const cloned = clone(range.endContainer); - cloned._data = cloned.substringData(0, range.endOffset); - - documentFragment.appendChild(cloned); - } else if (lastPartiallyContainedChild !== null) { - const cloned = clone(lastPartiallyContainedChild); - documentFragment.appendChild(cloned); - - const subrange = Range.createImpl(_globalObject, [], { - start: { node: lastPartiallyContainedChild, offset: 0 }, - end: { node: range.endContainer, offset: range.endOffset } - }); - - const subdocumentFragment = cloneRange(subrange); - cloned.appendChild(subdocumentFragment); - } - - return documentFragment; - } - /** * Returns "true" if contained. * @@ -205,7 +64,7 @@ export default class RangeUtility { * @param range Range. * @returns "true" if contained. */ - private static _isContained(node: INode, range: Range): boolean { + public static isContained(node: INode, range: Range): boolean { return ( this.compareBoundaryPointsPosition( { node, offset: 0 }, @@ -217,4 +76,20 @@ export default class RangeUtility { ) === -1 ); } + + /** + * Returns "true" if partially contained. + * + * @param node Node. + * @param range Range. + * @returns "true" if partially contained. + */ + public static isPartiallyContained(node: INode, range: Range): boolean { + return ( + (NodeUtility.isInclusiveAncestor(node, range.startContainer) && + !NodeUtility.isInclusiveAncestor(node, range.endContainer)) || + (!NodeUtility.isInclusiveAncestor(node, range.startContainer) && + NodeUtility.isInclusiveAncestor(node, range.endContainer)) + ); + } } diff --git a/packages/happy-dom/src/xml-parser/XMLParser.ts b/packages/happy-dom/src/xml-parser/XMLParser.ts index a0bdf7142..9adea57a5 100755 --- a/packages/happy-dom/src/xml-parser/XMLParser.ts +++ b/packages/happy-dom/src/xml-parser/XMLParser.ts @@ -10,6 +10,7 @@ import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement'; import INode from '../nodes/node/INode'; import IElement from '../nodes/element/IElement'; import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement'; +import IDocumentFragment from '../nodes/document-fragment/IDocumentFragment'; const MARKUP_REGEXP = /<(\/?)([a-z][-.0-9_a-z]*)\s*([^>]*?)(\/?)>/gi; const COMMENT_REGEXP = /|<([!?])([^>]*)>/gi; @@ -28,11 +29,15 @@ export default class XMLParser { * @param [evaluateScripts = false] Set to "true" to enable script execution. * @returns Root element. */ - public static parse(document: IDocument, data: string, evaluateScripts = false): IElement { - const root = document.createElement('root'); - const stack = [root]; + public static parse( + document: IDocument, + data: string, + evaluateScripts = false + ): IDocumentFragment { + const root = document.createDocumentFragment(); + const stack: Array = [root]; const markupRegexp = new RegExp(MARKUP_REGEXP, 'gi'); - let parent = root; + let parent: IDocumentFragment | IElement = root; let parentUnnestableTagName = null; let lastTextIndex = 0; let match: RegExpExecArray; @@ -47,7 +52,10 @@ export default class XMLParser { } if (isStartTag) { - const namespaceURI = tagName === 'svg' ? NamespaceURI.svg : parent.namespaceURI; + const namespaceURI = + tagName === 'svg' + ? NamespaceURI.svg + : (parent).namespaceURI || NamespaceURI.html; const newElement = document.createElementNS(namespaceURI, tagName); // Scripts are not allowed to be executed when they are parsed using innerHTML, outerHTML, replaceWith() etc. @@ -115,8 +123,8 @@ export default class XMLParser { * @param element Element. * @returns Tag name if element is unnestable. */ - private static getUnnestableTagName(element: IElement): string { - const tagName = element.tagName.toLowerCase(); + private static getUnnestableTagName(element: IElement | IDocumentFragment): string { + const tagName = (element).tagName ? (element).tagName.toLowerCase() : null; return tagName && UnnestableElements.includes(tagName) ? tagName : null; }