diff --git a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts index 04e773d78..5d490ffb2 100644 --- a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts +++ b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts @@ -2,6 +2,9 @@ enum DOMExceptionNameEnum { invalidStateError = 'InvalidStateError', indexSizeError = 'IndexSizeError', syntaxError = 'SyntaxError', - hierarchyRequestError = 'HierarchyRequestError' + hierarchyRequestError = 'HierarchyRequestError', + notSupportedError = 'NotSupportedError', + wrongDocumentError = 'WrongDocumentError', + invalidNodeTypeError = 'InvalidNodeTypeError' } export default DOMExceptionNameEnum; diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index 15d0d7185..a8b98ce05 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -38,6 +38,7 @@ import DocumentReadyStateManager from './DocumentReadyStateManager'; import Location from '../../location/Location'; import Selection from '../../selection/Selection'; import IShadowRoot from '../shadow-root/IShadowRoot'; +import Range from '../../range/Range'; /** * Document. @@ -754,6 +755,15 @@ export default class Document extends Node implements IDocument { return clone; } + /** + * Creates a range. + * + * @returns Range. + */ + public createRange(): Range { + return new Range(); + } + /** * Adopts a node. * diff --git a/packages/happy-dom/src/nodes/document/IDocument.ts b/packages/happy-dom/src/nodes/document/IDocument.ts index aac4a2e1b..a1967ec27 100644 --- a/packages/happy-dom/src/nodes/document/IDocument.ts +++ b/packages/happy-dom/src/nodes/document/IDocument.ts @@ -18,6 +18,7 @@ import CSSStyleSheet from '../../css/CSSStyleSheet'; import Location from '../../location/Location'; import DocumentReadyStateEnum from './DocumentReadyStateEnum'; import INodeList from '../node/INodeList'; +import Range from '../../range/Range'; /** * Document. @@ -143,6 +144,13 @@ export default interface IDocument extends IParentNode { */ importNode(node: INode): INode; + /** + * Creates a range. + * + * @returns Range. + */ + createRange(): Range; + /** * Returns an element by ID. * diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index c61029923..e73ad9191 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -2,7 +2,7 @@ import Node from '../node/Node'; import ShadowRoot from '../shadow-root/ShadowRoot'; import Attr from '../../attribute/Attr'; import DOMRect from './DOMRect'; -import Range from './Range'; +import Range from '../../range/Range'; import DOMTokenList from '../../dom-token-list/DOMTokenList'; import IDOMTokenList from '../../dom-token-list/IDOMTokenList'; import QuerySelector from '../../query-selector/QuerySelector'; diff --git a/packages/happy-dom/src/nodes/element/Range.ts b/packages/happy-dom/src/nodes/element/Range.ts deleted file mode 100644 index c2478a0d5..000000000 --- a/packages/happy-dom/src/nodes/element/Range.ts +++ /dev/null @@ -1,237 +0,0 @@ -import Node from '../node/Node'; -import DocumentFragment from '../document-fragment/DocumentFragment'; -import DOMRect from './DOMRect'; - -/** - * Range object. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/Range - */ -export default class Range { - private _startContainer: Node = null; - private _endContainer: Node = null; - private _startOffset = -1; - private _endOffset = -1; - private _collapsed = false; - - /** - * Returns collapsed. - * - * @returns "true" if collapsed. - */ - public get collapsed(): boolean { - return this._collapsed; - } - - /** - * Returns common ancestor container. - * - * @returns Node. - */ - public get commonAncestorContainer(): Node { - return null; - } - - /** - * Returns end container. - * - * @returns Node. - */ - public get endContainer(): Node { - return this._endContainer; - } - - /** - * Returns start container. - * - * @returns Node. - */ - public get startContainer(): Node { - return this._startContainer; - } - - /** - * Returns end offset. - * - * @returns Offset. - */ - public get endOffset(): number { - return this._endOffset; - } - - /** - * Returns start offset. - * - * @returns Offset. - */ - public get startOffset(): number { - return this._startOffset; - } - - /** - * Sets start. - * - * @param startNode Start node. - * @param startOffset Start offset. - */ - public setStart(startNode: Node, startOffset: number): void { - this._startContainer = startNode; - this._startOffset = startOffset; - } - - /** - * Sets end. - * - * @param endNode End node. - * @param endOffset End offset. - */ - public setEnd(endNode: Node, endOffset: number): void { - this._endContainer = endNode; - this._endOffset = endOffset; - } - - /** - * Sets start before. - */ - public setStartBefore(): void {} - - /** - * Sets start after. - */ - public setStartAfter(): void {} - - /** - * Sets end before. - */ - public setEndBefore(): void {} - - /** - * Sets end after. - */ - public setEndAfter(): void {} - - /** - * Selects a node. - */ - public selectNode(): void {} - - /** - * Selects node content. - */ - public selectNodeContents(): void {} - - /** - * Collapses the Range to one of its boundary points. - */ - public collapse(): void { - this._collapsed = true; - } - - /** - * Removes the contents of a Range from the Document. - */ - public deleteContents(): void {} - - /** - * Moves contents of a Range from the document tree into a DocumentFragment. - */ - public extractContents(): DocumentFragment { - return new DocumentFragment(); - } - - /** - * Insert a Node at the start of a Range. - */ - public insertNode(): void {} - - /** - * Moves content of a Range into a new Node. - */ - public surroundContents(): void {} - - /** - * Compares the boundary points of the Range with another Range. - * - * @returns "true" when equal. - */ - public compareBoundaryPoints(): boolean { - return false; - } - - /** - * Clones the range. - * - * @returns Range. - */ - public cloneRange(): Range { - return new Range(); - } - - /** - * Releases the Range from use to improve performance. - */ - public detach(): void {} - - /** - * Returns the text of the Range. - * - * @returns Text. - */ - public toString(): string { - return ''; - } - - /** - * Returns -1, 0, or 1 indicating whether the point occurs before, inside, or after the Range. - * - * @returns Number. - */ - public comparePoint(): number { - return 0; - } - - /** - * Returns a DocumentFragment created from a given string of code. - * - * @returns Document fragment. - */ - public createContextualFragment(): DocumentFragment { - return new DocumentFragment(); - } - - /** - * Returns a DOMRect object which bounds the entire contents of the Range; this would be the union of all the rectangles returned by range.getClientRects(). - * - * @returns DOM rect. - */ - public getBoundingClientRect(): DOMRect { - return new DOMRect(); - } - - /** - * Returns a list of DOMRect objects that aggregates the results of Element.getClientRects() for all the elements in the Range. - * - * @returns DOM rect. - */ - public getClientRects(): DOMRect { - return new DOMRect(); - } - - /** - * Returns a boolean indicating whether the given node intersects the Range. - * - * @returns "true" when intersecting. - */ - public intersectsNode(): boolean { - return false; - } - - /** - * Returns a boolean indicating whether the given point is in the Range. - * - * @returns "true" when in range. - */ - public isPointInRange(): boolean { - return false; - } -} diff --git a/packages/happy-dom/src/nodes/node/NodeUtility.ts b/packages/happy-dom/src/nodes/node/NodeUtility.ts new file mode 100644 index 000000000..f82ec3052 --- /dev/null +++ b/packages/happy-dom/src/nodes/node/NodeUtility.ts @@ -0,0 +1,45 @@ +import INode from './INode'; + +/** + * Node utility. + */ +export default class NodeUtility { + /** + * Returns boolean indicating if nodeB is an inclusive ancestor of nodeA. + * + * @see https://dom.spec.whatwg.org/#concept-tree-inclusive-ancestor + * @param nodeA Node A. + * @param nodeB Node B. + * @returns "true" if following. + */ + public static isInclusiveAncestor(nodeA: INode, nodeB: INode): boolean { + let parent: INode = nodeA; + while (parent) { + if (parent === nodeB) { + return true; + } + parent = parent.parentNode; + } + return false; + } + + /** + * Returns boolean indicating if nodeB is following nodeA in the document tree. + * + * @see https://dom.spec.whatwg.org/#concept-tree-following + * @param nodeA Node A. + * @param nodeB Node B. + * @returns "true" if following. + */ + public static isFollowing(nodeA: INode, nodeB: INode): boolean { + let current: INode = nodeA.nextSibling; + while (current) { + if (current === nodeB) { + return true; + } + const nextSibling = current.nextSibling; + current = nextSibling ? nextSibling : current.parentNode; + } + return false; + } +} diff --git a/packages/happy-dom/src/range/Range.ts b/packages/happy-dom/src/range/Range.ts index 992563833..4dd4d571d 100644 --- a/packages/happy-dom/src/range/Range.ts +++ b/packages/happy-dom/src/range/Range.ts @@ -3,6 +3,9 @@ import IDocument from '../nodes/document/IDocument'; import IDocumentFragment from '../nodes/document-fragment/IDocumentFragment'; import DOMRect from '../nodes/element/DOMRect'; import RangeHowEnum from './RangeHowEnum'; +import DOMException from '../exception/DOMException'; +import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; +import RangeUtility from './RangeUtility'; /** * Range. @@ -24,13 +27,15 @@ export default class Range { public readonly endOffset: number = 0; public readonly startContainer: INode = null; public readonly endContainer: INode = null; + public readonly _ownerDocument: IDocument = null; /** * Constructor. */ constructor() { - this.startContainer = (this.constructor)._ownerDocument; - this.endContainer = (this.constructor)._ownerDocument; + this._ownerDocument = (this.constructor)._ownerDocument; + this.startContainer = this._ownerDocument; + this.endContainer = this._ownerDocument; } /** @@ -78,7 +83,7 @@ export default class Range { * @param toStart A boolean value: true collapses the Range to its start, false to its end. If omitted, it defaults to false. */ public collapse(toStart = false): void { - if (toStart) { + if (toStart && !this.startContainer.contains(this.endContainer)) { (this.endContainer) = this.startContainer; (this.endOffset) = this.startOffset; } else { @@ -90,13 +95,67 @@ export default class Range { /** * Compares the boundary points of the Range with those of another range. * - * @param _how How. - * @param _sourceRange Range. + * @param how How. + * @param sourceRange Range. * @returns A number, -1, 0, or 1, indicating whether the corresponding boundary-point of the Range is respectively before, equal to, or after the corresponding boundary-point of sourceRange. */ - public compareBoundaryPoints(_how: RangeHowEnum, _sourceRange: Range): number { - // TODO: Implement - return 0; + public compareBoundaryPoints(how: RangeHowEnum, sourceRange: Range): number { + if ( + how !== RangeHowEnum.startToStart && + how !== RangeHowEnum.startToEnd && + how !== RangeHowEnum.endToEnd && + how !== RangeHowEnum.endToStart + ) { + throw new DOMException( + `The comparison method provided must be one of 'START_TO_START', 'START_TO_END', 'END_TO_END' or 'END_TO_START'.`, + DOMExceptionNameEnum.notSupportedError + ); + } + + if (this._ownerDocument !== sourceRange._ownerDocument) { + throw new DOMException( + `The two Ranges are not in the same tree.`, + DOMExceptionNameEnum.wrongDocumentError + ); + } + + const thisPoint: { node: INode; offset: number } = { + node: null, + offset: 0 + }; + const sourcePoint: { node: INode; offset: number } = { + node: null, + offset: 0 + }; + + switch (how) { + case RangeHowEnum.startToStart: + thisPoint.node = this.startContainer; + thisPoint.offset = this.startOffset; + sourcePoint.node = sourceRange.startContainer; + sourcePoint.offset = sourceRange.startOffset; + break; + case RangeHowEnum.startToEnd: + thisPoint.node = this.endContainer; + thisPoint.offset = this.endOffset; + sourcePoint.node = sourceRange.startContainer; + sourcePoint.offset = sourceRange.startOffset; + break; + case RangeHowEnum.endToEnd: + thisPoint.node = this.endContainer; + thisPoint.offset = this.endOffset; + sourcePoint.node = sourceRange.endContainer; + sourcePoint.offset = sourceRange.endOffset; + break; + case RangeHowEnum.endToStart: + thisPoint.node = this.startContainer; + thisPoint.offset = this.startOffset; + sourcePoint.node = sourceRange.endContainer; + sourcePoint.offset = sourceRange.endOffset; + break; + } + + return RangeUtility.compareBoundaryPointsPosition(thisPoint, sourcePoint); } /** @@ -222,10 +281,22 @@ export default class Range { /** * Sets the Range to contain the Node and its contents. * - * @param _referenceNode Reference node. + * @param referenceNode Reference node. */ - public selectNode(_referenceNode: INode): void { - // TODO: Implement + public selectNode(referenceNode: INode): void { + if (!referenceNode.parentNode) { + throw new DOMException( + `Failed to select node. Reference node is missing a parent node.`, + DOMExceptionNameEnum.invalidNodeTypeError + ); + } + + const index = referenceNode.parentNode.childNodes.indexOf(referenceNode); + + (this.startContainer) = referenceNode.parentNode; + (this.endContainer) = referenceNode.parentNode; + (this.startOffset) = index; + (this.endOffset) = index + 1; } /** @@ -237,7 +308,7 @@ export default class Range { (this.startContainer) = referenceNode; (this.endContainer) = referenceNode; (this.startOffset) = 0; - (this.endOffset) = referenceNode.textContent.length > 0 ? 1 : 0; + (this.endOffset) = referenceNode.childNodes.length; } /** @@ -247,6 +318,20 @@ export default class Range { * @param endOffset End offset. */ public setEnd(endNode: INode, endOffset = 0): void { + if (!endNode) { + throw new DOMException( + `Failed to execute 'endNode' on 'Range': parameter 1 is not of type 'Node'.` + ); + } + if ( + endNode.nodeType !== endNode.TEXT_NODE && + endOffset > 0 && + endNode.childNodes.length < endOffset + ) { + throw new DOMException( + `Failed to execute 'setEnd' on 'Range': There is no child at offset ${endOffset}.` + ); + } (this.endContainer) = endNode; (this.endOffset) = endOffset; } @@ -258,6 +343,20 @@ export default class Range { * @param startOffset Start offset. */ public setStart(startNode: INode, startOffset = 0): void { + if (!startNode) { + throw new DOMException( + `Failed to execute 'setStart' on 'Range': parameter 1 is not of type 'Node'.` + ); + } + if ( + startNode.nodeType !== startNode.TEXT_NODE && + startOffset > 0 && + startNode.childNodes.length < startOffset + ) { + throw new DOMException( + `Failed to execute 'setStart' on 'Range': There is no child at offset ${startOffset}.` + ); + } (this.startContainer) = startNode; (this.startOffset) = startOffset; } @@ -270,7 +369,7 @@ export default class Range { public setEndAfter(referenceNode: INode): void { const sibling = referenceNode.nextSibling; if (!sibling) { - throw new Error( + throw new DOMException( 'Failed to set range end. "referenceNode" does not have any nodes after itself.' ); } @@ -285,7 +384,7 @@ export default class Range { public setEndBefore(referenceNode: INode): void { const sibling = referenceNode.previousSibling; if (!sibling) { - throw new Error( + throw new DOMException( 'Failed to set range end. "referenceNode" does not have any nodes before itself.' ); } @@ -300,7 +399,7 @@ export default class Range { public setStartAfter(referenceNode: INode): void { const sibling = referenceNode.nextSibling; if (!sibling) { - throw new Error( + throw new DOMException( 'Failed to set range start. "referenceNode" does not have any nodes after itself.' ); } @@ -315,7 +414,7 @@ export default class Range { public setStartBefore(referenceNode: INode): void { const sibling = referenceNode.previousSibling; if (!sibling) { - throw new Error( + throw new DOMException( 'Failed to set range start. "referenceNode" does not have any nodes before itself.' ); } diff --git a/packages/happy-dom/src/range/RangeUtility.ts b/packages/happy-dom/src/range/RangeUtility.ts new file mode 100644 index 000000000..e85eb7d88 --- /dev/null +++ b/packages/happy-dom/src/range/RangeUtility.ts @@ -0,0 +1,52 @@ +import INode from '../nodes/node/INode'; +import NodeUtility from '../nodes/node/NodeUtility'; + +type BoundaryPoint = { node: INode; offset: number }; + +/** + * Range utility. + */ +export default class RangeUtility { + /** + * Compares boundary points. + * + * @see https://dom.spec.whatwg.org/#concept-range-bp-after + * @param pointA Point A. + * @param pointB Point B. + * @returns A number, -1, 0, or 1, indicating whether the corresponding boundary-point of the Range is respectively before, equal to, or after the corresponding boundary-point of sourceRange. + */ + public static compareBoundaryPointsPosition( + pointA: BoundaryPoint, + pointB: BoundaryPoint + ): number { + if (pointA.node === pointB.node) { + if (pointA.offset === pointB.offset) { + return 0; + } else if (pointA.offset < pointB.offset) { + return -1; + } + + return 1; + } + + if (NodeUtility.isFollowing(pointA.node, pointB.node)) { + return this.compareBoundaryPointsPosition(pointB, pointA) === -1 ? 1 : -1; + } + + if (NodeUtility.isInclusiveAncestor(pointA.node, pointB.node)) { + let child = pointB.node; + + while (child.parentNode !== pointA.node) { + child = child.parentNode; + } + + const index = child.parentNode.childNodes.indexOf(child); + + if (index < pointA.offset) { + return 1; + } + } + + return -1; + } +} diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index e0eb944b4..466c7c013 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -79,6 +79,7 @@ import IRequest from '../fetch/IRequest'; import IHeaders from '../fetch/IHeaders'; import IRequestInit from '../fetch/IRequestInit'; import IResponse from '../fetch/IResponse'; +import Range from '../range/Range'; import MediaQueryList from '../match-media/MediaQueryList'; import Window from './Window'; import { URLSearchParams } from 'url'; @@ -173,6 +174,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly Headers: { new (init?: string[][] | Record | IHeaders): IHeaders }; readonly Request: { new (input: string | IRequest, init?: IRequestInit): IRequest }; readonly Response: { new (body?: unknown | null, init?: IResponseInit): IResponse }; + readonly Range: typeof Range; // Events onload: (event: Event) => void; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 2303b373c..d0e58e993 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -87,6 +87,7 @@ import Plugin from '../navigator/Plugin'; import PluginArray from '../navigator/PluginArray'; import { URLSearchParams } from 'url'; import FetchHandler from '../fetch/FetchHandler'; +import Range from '../range/Range'; import VMGlobalPropertyScript from './VMGlobalPropertyScript'; import * as PerfHooks from 'perf_hooks'; import VM from 'vm'; @@ -192,6 +193,7 @@ export default class Window extends EventTarget implements IWindow { public readonly Response: { new (body?: NodeJS.ReadableStream | null, init?: IResponseInit): IResponse; } = Response; + public readonly Range = Range; // Events public onload: (event: Event) => void = null; @@ -303,6 +305,7 @@ export default class Window extends EventTarget implements IWindow { Image.ownerDocument = this.document; Request._ownerDocument = this.document; Response._ownerDocument = this.document; + Range._ownerDocument = this.document; for (const eventType of NonImplementedEventTypes) { if (!this[eventType]) { diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts index d30115a0c..a80aa0e6d 100644 --- a/packages/happy-dom/test/nodes/element/Element.test.ts +++ b/packages/happy-dom/test/nodes/element/Element.test.ts @@ -7,7 +7,7 @@ import ShadowRoot from '../../../src/nodes/shadow-root/ShadowRoot'; import IDocument from '../../../src/nodes/document/IDocument'; import Text from '../../../src/nodes/text/Text'; import DOMRect from '../../../src/nodes/element/DOMRect'; -import Range from '../../../src/nodes/element/Range'; +import Range from '../../../src/range/Range'; import NamespaceURI from '../../../src/config/NamespaceURI'; import ParentNodeUtility from '../../../src/nodes/parent-node/ParentNodeUtility'; import QuerySelector from '../../../src/query-selector/QuerySelector'; diff --git a/packages/happy-dom/test/range/Range.test.ts b/packages/happy-dom/test/range/Range.test.ts index c5a69f7b0..13aa0c981 100644 --- a/packages/happy-dom/test/range/Range.test.ts +++ b/packages/happy-dom/test/range/Range.test.ts @@ -11,8 +11,7 @@ describe('Range', () => { beforeEach(() => { window = new Window(); document = window.document; - Range._ownerDocument = document; - range = new Range(); + range = document.createRange(); }); describe('get collapsed()', () => { @@ -47,6 +46,7 @@ describe('Range', () => { describe('get commonAncestorContainer()', () => { it('Returns ancestor parent container when end container is set to a child of start container.', () => { const container = document.createElement('div'); + container.innerHTML = `Hello world!`; range.setStart(container, 0); range.setEnd(container.children[0], 0); @@ -135,5 +135,72 @@ describe('Range', () => { expect(range.endOffset).toBe(1); expect(range.collapsed).toBe(true); }); + + it('Collapses the Range to the start container if the toStart parameter is set to true.', () => { + const container = document.createElement('div'); + const span = document.createElement('span'); + const span2 = document.createElement('span'); + + span.textContent = 'hello'; + span2.textContent = 'world'; + + container.appendChild(span); + container.appendChild(span2); + + range.setStart(span.childNodes[0], 1); + range.setEnd(span2.childNodes[0], 2); + + range.collapse(true); + + expect(range.startContainer === span.childNodes[0]).toBe(true); + expect(range.endContainer === span.childNodes[0]).toBe(true); + expect(range.startOffset).toBe(1); + expect(range.endOffset).toBe(1); + expect(range.collapsed).toBe(true); + }); + }); + + describe('compareBoundaryPoints()', () => { + it('Returns -1 when pointB is after pointA and "how" is set to "START_TO_END".', () => { + const sourceRange = document.createRange(); + + document.body.innerHTML = ` +
This is the Range 1 Content
+
This is the Range 2 Content
+ `; + + range.selectNode(document.body.children[0]); + sourceRange.selectNode(document.body.children[1]); + + expect(range.compareBoundaryPoints(Range.START_TO_END, sourceRange)).toBe(-1); + }); + + it('Returns 1 when pointA is after pointB and "how" is set to "START_TO_END".', () => { + const sourceRange = document.createRange(); + + document.body.innerHTML = ` +
This is the Range 1 Content
+
This is the Range 2 Content
+ `.trim(); + + range.selectNode(document.body.children[1]); + sourceRange.selectNode(document.body.children[0]); + + expect(range.compareBoundaryPoints(Range.START_TO_END, sourceRange)).toBe(1); + }); + + it('Returns 1 when pointA is the same as pointB and "how" is set to "START_TO_END".', () => { + const sourceRange = document.createRange(); + + document.body.innerHTML = ` +
This is the Range 1 Content
+
This is the Range 2 Content
+ `.trim(); + + range.selectNode(document.body.children[0]); + sourceRange.selectNode(document.body.children[0]); + + expect(range.compareBoundaryPoints(Range.START_TO_END, sourceRange)).toBe(1); + }); }); });