import { CTOR_KEY } from "../constructor-lock.ts"; import { fragmentNodesFromString } from "../deserialize.ts"; import { Node, NodeType, Text, Comment } from "./node.ts"; import { NodeList, nodeListMutatorSym } from "./node-list.ts"; export class DOMTokenList extends Set { #onChange: (className: string) => void; constructor(onChange: (className: string) => void) { super(); this.#onChange = onChange; } add(token: string): this { if (token === "" || /[\t\n\f\r ]/.test(token)) { throw new Error(`DOMTokenList.add: Invalid class token "${token}"`); } super.add(token); this.#onChange([...this].join(" ")); return this; } clear() { super.clear(); this.#onChange(""); } remove(...tokens: string[]): this { for (const token of tokens) { super.delete(token) } this.#onChange([...this].join(" ")); return this; } delete(token: string): boolean { const deleted = super.delete(token); if (deleted) { this.#onChange([...this].join(" ")); } return deleted; } contains(token: string): boolean { return this.has(token); } _update(value: string | null) { // Using super.clear() and super.add() rather than the overriden methods so // onChange doesn't fire while updating. super.clear(); if (value !== null) { for (const token of value.split(/[\t\n\f\r ]+/g)) { // The only empty strings resulting from splitting should correspond to // whitespace at either end of `value`. if (token === "") continue; super.add(token); } } } } export class Attr { #namedNodeMap: NamedNodeMap | null = null; #name: string = ""; constructor(map: NamedNodeMap, name: string, key: typeof CTOR_KEY) { if (key !== CTOR_KEY) { throw new TypeError("Illegal constructor"); } this.#name = name; this.#namedNodeMap = map; } get name() { return this.#name; } get value() { return (<{[attribute: string]: string}> this.#namedNodeMap)[this.#name]; } } export class NamedNodeMap { #attrObjCache: { [attribute: string]: Attr; } = {}; private newAttr(attribute: string): Attr { return new Attr(this, attribute, CTOR_KEY); } getNamedItem(attribute: string) { return this.#attrObjCache[attribute] ?? (this.#attrObjCache[attribute] = this.newAttr(attribute)); } setNamedItem(...args: any) { // TODO } } export class Element extends Node { #classList = new DOMTokenList((className) => { if (this.hasAttribute("class") || className !== "") { this.attributes["class"] = className; } }); public attributes: NamedNodeMap & {[attribute: string]: string} = new NamedNodeMap(); #currentId = ""; constructor( public tagName: string, parentNode: Node | null, attributes: [string, string][], key: typeof CTOR_KEY, ) { super( tagName, NodeType.ELEMENT_NODE, parentNode, key, ); for (const attr of attributes) { this.attributes[attr[0]] = attr[1]; switch (attr[0]) { case "class": this.#classList._update(attr[1]); break; case "id": this.#currentId = attr[1]; break; } } this.tagName = this.nodeName = tagName.toUpperCase(); } _shallowClone(): Node { const attributes: [string, string][] = []; for (const attribute of this.getAttributeNames()) { attributes.push([attribute, this.attributes[attribute]]) } return new Element(this.nodeName, null, attributes, CTOR_KEY); } get className(): string { return this.getAttribute("class") ?? ""; } get classList(): DOMTokenList { return this.#classList; } set className(className: string) { this.setAttribute("class", className); this.#classList._update(className); } get outerHTML(): string { const tagName = this.tagName.toLowerCase(); const attributes = this.attributes; let out = "<" + tagName; for (const attribute of this.getAttributeNames()) { out += ` ${ attribute.toLowerCase() }`; // escaping: https://html.spec.whatwg.org/multipage/parsing.html#escapingString if (attributes[attribute] != null) { out += `="${ attributes[attribute] .replace(/&/g, "&") .replace(/\xA0/g, " ") .replace(/"/g, """) }"`; } } // Special handling for void elements switch (tagName) { case "area": case "base": case "br": case "col": case "embed": case "hr": case "img": case "input": case "link": case "meta": case "param": case "source": case "track": case "wbr": out += ">"; break; default: out += ">" + this.innerHTML + ``; break; } return out; } set outerHTML(html: string) { // TODO: Someday... } get innerHTML(): string { let out = ""; for (const child of this.childNodes) { switch (child.nodeType) { case NodeType.ELEMENT_NODE: out += (child as Element).outerHTML; break; case NodeType.COMMENT_NODE: out += ``; break; case NodeType.TEXT_NODE: // Special handling for rawtext-like elements. switch (this.tagName.toLowerCase()) { case "style": case "script": case "xmp": case "iframe": case "noembed": case "noframes": case "plaintext": out += (child as Text).data; break; default: // escaping: https://html.spec.whatwg.org/multipage/parsing.html#escapingString out += (child as Text).data .replace(/&/g, "&") .replace(/\xA0/g, " ") .replace(//g, ">"); break; } break; } } return out; } set innerHTML(html: string) { // Remove all children for (const child of this.childNodes) { child.parentNode = child.parentElement = null; } const mutator = this._getChildNodesMutator(); mutator.splice(0, this.childNodes.length); if (html.length) { const parsed = fragmentNodesFromString(html); mutator.push(...parsed.childNodes[0].childNodes); for (const child of this.childNodes) { child._setParent(null); child._setOwnerDocument(this.ownerDocument); } } } get innerText(): string { return this.textContent; } set innerText(text: string) { this.textContent = text; } get id(): string { return this.#currentId || ""; } set id(id: string) { this.setAttribute(id, this.#currentId = id); } getAttributeNames(): string[] { return Object.getOwnPropertyNames(this.attributes); } getAttribute(name: string): string | null { return this.attributes[name] ?? null; } setAttribute(name: string, value: any) { const strValue = "" + value; this.attributes[name] = strValue; if (name === "id") { this.#currentId = strValue; } else if (name === "class") { this.#classList._update(strValue); } } removeAttribute(name: string) { delete this.attributes[name]; if (name === "class") { this.#classList._update(null); } } hasAttribute(name: string): boolean { return this.attributes.hasOwnProperty(name); } hasAttributeNS(_namespace: string, name: string): boolean { // TODO: Use namespace return this.attributes.hasOwnProperty(name); } get nextElementSibling(): Element | null { const parent = this.parentNode; if (!parent) { return null; } const index = parent._getChildNodesMutator().indexOf(this); const childNodes = parent.childNodes; let next: Element | null = null; for (let i = index + 1; i < childNodes.length; i++) { const sibling = childNodes[i]; if (sibling.nodeType === NodeType.ELEMENT_NODE) { next = sibling; break; } } return next; } get previousElementSibling(): Element | null { const parent = this.parentNode; if (!parent) { return null; } const index = parent._getChildNodesMutator().indexOf(this); const childNodes = parent.childNodes; let prev: Element | null = null; for (let i = index - 1; i >= 0; i--) { const sibling = childNodes[i]; if (sibling.nodeType === NodeType.ELEMENT_NODE) { prev = sibling; break; } } return prev; } querySelector(selectors: string): Element | null { if (!this.ownerDocument) { throw new Error("Element must have an owner document"); } return this.ownerDocument!._nwapi.first(selectors, this); } querySelectorAll(selectors: string): NodeList { if (!this.ownerDocument) { throw new Error("Element must have an owner document"); } const nodeList = new NodeList(); const mutator = nodeList[nodeListMutatorSym](); mutator.push(...this.ownerDocument!._nwapi.select(selectors, this)) return nodeList; } matches(selectorString: string): boolean { return this.ownerDocument!._nwapi.match(selectorString, this) } // TODO: DRY!!! getElementById(id: string): Element | null { for (const child of this.childNodes) { if (child.nodeType === NodeType.ELEMENT_NODE) { if (( child).id === id) { return child; } const search = ( child).getElementById(id); if (search) { return search; } } } return null; } getElementsByTagName(tagName: string): Element[] { return this._getElementsByTagName(tagName.toUpperCase(), []); } private _getElementsByTagNameWildcard(search: Node[]): Node[] { for (const child of this.childNodes) { if (child.nodeType === NodeType.ELEMENT_NODE) { search.push(child); ( child)._getElementsByTagNameWildcard(search); } } return search; } private _getElementsByTagName(tagName: string, search: Node[]): Node[] { for (const child of this.childNodes) { if (child.nodeType === NodeType.ELEMENT_NODE) { if (( child).tagName === tagName) { search.push(child); } ( child)._getElementsByTagName(tagName, search); } } return search; } getElementsByClassName(className: string): Element[] { return this._getElementsByClassName(className, []); } getElementsByTagNameNS(_namespace: string, localName: string): Element[] { // TODO: Use namespace return this.getElementsByTagName(localName); } private _getElementsByClassName(className: string, search: Node[]): Node[] { for (const child of this.childNodes) { if (child.nodeType === NodeType.ELEMENT_NODE) { if (( child).classList.contains(className)) { search.push(child); } ( child)._getElementsByClassName(className, search); } } return search; } }