Files

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