fix(widgets): restore article table of contents behavior and styles
This commit is contained in:
1 parent
fc04bf65e6
commit
ea80cd96cc
15 files changed
+965
-407
No files matched your search
@@ -0,0 +1,271 @@
|
||||
'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 };
|
||||
Reference in new issue
Block a user