272 lines
10 KiB
JavaScript
272 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
class MiniElement {
|
|
constructor(tagName = 'div') {
|
|
this.tagName = String(tagName).toUpperCase();
|
|
this.nodeType = this.tagName === '#TEXT' ? 3 : 1;
|
|
this.parentNode = null;
|
|
this.children = [];
|
|
this.attributes = {};
|
|
this.dataset = {};
|
|
this.listeners = {};
|
|
this.offsetLeft = 0;
|
|
this.offsetTop = 0;
|
|
this.offsetHeight = 0;
|
|
this.offsetWidth = 0;
|
|
this.scrollHeight = 0;
|
|
this.scrollWidth = 0;
|
|
this.clientWidth = 0;
|
|
this.scrollLeft = 0;
|
|
this.rectTop = 0;
|
|
this.rectLeft = 0;
|
|
this.rectWidth = 0;
|
|
this.visible = true;
|
|
this.scrollCalls = [];
|
|
this._text = '';
|
|
this._classes = new Set();
|
|
this.classList = {
|
|
add: (...tokens) => tokens.forEach((token) => this._classes.add(token)),
|
|
remove: (...tokens) => tokens.forEach((token) => this._classes.delete(token)),
|
|
contains: (token) => this._classes.has(token),
|
|
toggle: (token, force) => {
|
|
const shouldAdd = force === undefined ? !this._classes.has(token) : Boolean(force);
|
|
if (shouldAdd) this._classes.add(token);
|
|
else this._classes.delete(token);
|
|
return shouldAdd;
|
|
},
|
|
};
|
|
}
|
|
|
|
get className() { return Array.from(this._classes).join(' '); }
|
|
set className(value) { this._classes = new Set(String(value || '').split(/\s+/).filter(Boolean)); }
|
|
get id() { return this.getAttribute('id') || ''; }
|
|
set id(value) { this.setAttribute('id', value); }
|
|
get type() { return this.getAttribute('type') || ''; }
|
|
set type(value) { this.setAttribute('type', value); }
|
|
get parentElement() { return this.parentNode && this.parentNode.tagName !== '#DOCUMENT' ? this.parentNode : null; }
|
|
get firstChild() { return this.children[0] || null; }
|
|
get firstElementChild() { return this.children.find((child) => child.nodeType === 1) || null; }
|
|
get nextElementSibling() {
|
|
if (!this.parentNode) return null;
|
|
const siblings = this.parentNode.children.filter((child) => child.nodeType === 1);
|
|
return siblings[siblings.indexOf(this) + 1] || null;
|
|
}
|
|
get textContent() { return this._text + this.children.map((child) => child.textContent).join(''); }
|
|
set textContent(value) { this._text = String(value); this.replaceChildren(); }
|
|
get innerHTML() { return this.children.map((child) => child.outerHTML || child.textContent).join(''); }
|
|
set innerHTML(markup) {
|
|
this._text = '';
|
|
this.replaceChildren();
|
|
parseMarkup(String(markup || ''), this);
|
|
}
|
|
|
|
setAttribute(name, value) {
|
|
const text = String(value);
|
|
this.attributes[name] = text;
|
|
if (name === 'class') this.className = text;
|
|
if (name.startsWith('data-')) this.dataset[toDatasetKey(name.slice(5))] = text;
|
|
}
|
|
|
|
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
|
hasAttribute(name) { return Object.hasOwn(this.attributes, name); }
|
|
removeAttribute(name) {
|
|
delete this.attributes[name];
|
|
if (name === 'class') this.className = '';
|
|
if (name.startsWith('data-')) delete this.dataset[toDatasetKey(name.slice(5))];
|
|
}
|
|
|
|
appendChild(child) {
|
|
if (child.parentNode) child.remove();
|
|
child.parentNode = this;
|
|
child.ownerDocument = this.ownerDocument;
|
|
this.children.push(child);
|
|
return child;
|
|
}
|
|
|
|
insertBefore(child, anchor) {
|
|
if (!anchor || anchor.parentNode !== this) return this.appendChild(child);
|
|
if (child.parentNode) child.remove();
|
|
child.parentNode = this;
|
|
child.ownerDocument = this.ownerDocument;
|
|
this.children.splice(this.children.indexOf(anchor), 0, child);
|
|
return child;
|
|
}
|
|
|
|
replaceChildren(...nodes) {
|
|
this.children.forEach((child) => { child.parentNode = null; });
|
|
this.children = [];
|
|
nodes.forEach((node) => this.appendChild(node));
|
|
}
|
|
|
|
remove() {
|
|
if (!this.parentNode) return;
|
|
const siblings = this.parentNode.children;
|
|
const index = siblings.indexOf(this);
|
|
if (index !== -1) siblings.splice(index, 1);
|
|
this.parentNode = null;
|
|
}
|
|
|
|
addEventListener(type, callback, options = {}) {
|
|
(this.listeners[type] ||= []).push({ callback, once: Boolean(options && options.once) });
|
|
}
|
|
|
|
removeEventListener(type, callback) {
|
|
this.listeners[type] = (this.listeners[type] || []).filter((item) => item.callback !== callback);
|
|
}
|
|
|
|
dispatch(type, properties = {}) {
|
|
const event = {
|
|
type,
|
|
target: this,
|
|
defaultPrevented: false,
|
|
preventDefault() { this.defaultPrevented = true; },
|
|
stopPropagation() { this.propagationStopped = true; },
|
|
...properties,
|
|
};
|
|
let node = this;
|
|
while (node) {
|
|
const listeners = (node.listeners[type] || []).slice();
|
|
for (const listener of listeners) {
|
|
listener.callback(event);
|
|
if (listener.once) node.removeEventListener(type, listener.callback);
|
|
}
|
|
if (event.propagationStopped) break;
|
|
node = node.parentNode;
|
|
}
|
|
return event;
|
|
}
|
|
|
|
dispatchEvent(event) { return this.dispatch(event.type, event); }
|
|
click() { return this.dispatch('click'); }
|
|
focus() { this.ownerDocument.activeElement = this; }
|
|
|
|
closest(selector) {
|
|
for (let node = this; node; node = node.parentNode) {
|
|
if (matchesSelector(node, selector)) return node;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
matches(selector) { return matchesSelector(this, selector); }
|
|
|
|
querySelectorAll(selector) {
|
|
const directOnly = selector.startsWith(':scope > ');
|
|
const query = directOnly ? selector.slice(9) : selector;
|
|
const nodes = directOnly ? this.children.slice() : descendants(this);
|
|
return nodes.filter((node) => matchesSelector(node, query));
|
|
}
|
|
|
|
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
|
|
|
insertAdjacentHTML(position, markup) {
|
|
const fragment = this.ownerDocument.createElement('div');
|
|
fragment.innerHTML = markup;
|
|
const nodes = fragment.children.slice();
|
|
if (position === 'afterbegin') nodes.reverse().forEach((node) => this.insertBefore(node, this.firstChild));
|
|
else if (position === 'beforebegin' && this.parentNode) nodes.forEach((node) => this.parentNode.insertBefore(node, this));
|
|
else nodes.forEach((node) => this.appendChild(node));
|
|
}
|
|
|
|
scrollTo(options) { this.scrollCalls.push(options); }
|
|
getBoundingClientRect() { return { top: this.rectTop, left: this.rectLeft, width: this.rectWidth || this.offsetWidth }; }
|
|
getClientRects() { return this.visible ? [this.getBoundingClientRect()] : []; }
|
|
}
|
|
|
|
class MiniDocument extends MiniElement {
|
|
constructor() {
|
|
super('#document');
|
|
this.nodeType = 9;
|
|
this.ownerDocument = this;
|
|
this.readyState = 'complete';
|
|
this.body = this.createElement('body');
|
|
this.appendChild(this.body);
|
|
this.activeElement = null;
|
|
}
|
|
|
|
createElement(tagName) {
|
|
const element = new MiniElement(tagName);
|
|
element.ownerDocument = this;
|
|
return element;
|
|
}
|
|
|
|
getElementById(id) { return descendants(this).find((node) => node.id === String(id)) || null; }
|
|
}
|
|
|
|
function toDatasetKey(value) { return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); }
|
|
|
|
function descendants(node) {
|
|
return node.children.flatMap((child) => [child, ...descendants(child)]);
|
|
}
|
|
|
|
function matchesCompound(node, selector) {
|
|
if (!selector || selector === '*') return true;
|
|
const tag = selector.match(/^[a-z][a-z0-9-]*/i);
|
|
if (tag && node.tagName.toLowerCase() !== tag[0].toLowerCase()) return false;
|
|
const id = selector.match(/#([a-zA-Z_][\w-]*)/);
|
|
if (id && node.getAttribute('id') !== id[1]) return false;
|
|
for (const [, className] of selector.matchAll(/\.([a-zA-Z_][\w-]*)/g)) {
|
|
if (!node.classList.contains(className)) return false;
|
|
}
|
|
for (const [, name, operator, expected] of selector.matchAll(/\[([\w-]+)(\^=|\$=|\*=|=)?(?:\s*["']?([^\]"']*)["']?)?\]/g)) {
|
|
if (!node.hasAttribute(name)) return false;
|
|
if (operator === '=') {
|
|
if (node.getAttribute(name) !== expected) return false;
|
|
} else if (operator === '^=' && !node.getAttribute(name).startsWith(expected)) return false;
|
|
else if (operator === '$=' && !node.getAttribute(name).endsWith(expected)) return false;
|
|
else if (operator === '*=' && !node.getAttribute(name).includes(expected)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function matchesSingleSelector(node, selector) {
|
|
const tokens = selector.replace(/>/g, ' > ').trim().split(/\s+/);
|
|
let cursor = node;
|
|
if (!matchesCompound(cursor, tokens.pop())) return false;
|
|
while (tokens.length) {
|
|
const combinator = tokens.pop();
|
|
const previous = combinator === '>' ? tokens.pop() : combinator;
|
|
if (combinator === '>') {
|
|
cursor = cursor.parentNode;
|
|
if (!cursor || !matchesCompound(cursor, previous)) return false;
|
|
} else {
|
|
cursor = cursor.parentNode;
|
|
while (cursor && !matchesCompound(cursor, previous)) cursor = cursor.parentNode;
|
|
if (!cursor) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function matchesSelector(node, selector) {
|
|
return String(selector).split(',').some((part) => matchesSingleSelector(node, part.trim()));
|
|
}
|
|
|
|
function parseMarkup(markup, parent) {
|
|
const tokens = markup.match(/<!--[\s\S]*?-->|<[^>]+>|[^<]+/g) || [];
|
|
const stack = [parent];
|
|
const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
for (const token of tokens) {
|
|
if (token.startsWith('<!--') || token.trim() === '') continue;
|
|
if (token.startsWith('</')) {
|
|
if (stack.length > 1) stack.pop();
|
|
continue;
|
|
}
|
|
if (!token.startsWith('<')) {
|
|
const textNode = parent.ownerDocument.createElement('#text');
|
|
textNode._text = token;
|
|
stack[stack.length - 1].appendChild(textNode);
|
|
continue;
|
|
}
|
|
const open = token.match(/^<([a-z][\w-]*)([\s\S]*?)\/?\s*>$/i);
|
|
if (!open) continue;
|
|
const node = parent.ownerDocument.createElement(open[1]);
|
|
for (const [, name, doubleQuoted, singleQuoted, bare] of open[2].matchAll(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
node.setAttribute(name, doubleQuoted ?? singleQuoted ?? bare ?? '');
|
|
}
|
|
stack[stack.length - 1].appendChild(node);
|
|
if (!voidTags.has(node.tagName.toLowerCase()) && !token.endsWith('/>')) stack.push(node);
|
|
}
|
|
}
|
|
|
|
module.exports = { MiniDocument, MiniElement };
|