/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
const SVGNS = 'http://www.w3.org/2000/svg';
type EventHandlerMap = {
[key in keyof HTMLElementEventMap]: (event: HTMLElementEventMap[key]) => unknown;
}
type StyleMap = {
[key in keyof CSSStyleDeclaration]: string;
}
export interface CreateElementOptions {
/**
* FIXME: can we lock this down a bit?
*
* actually that might not be wise since we sometimes use "scope" type
* attributes that are not standard (maybe we should fix that, instead?)
*/
attrs?: Record;
/**
* optionally style as a map
*/
style?: Partial;
/** dataset or data-* attributes */
data?: Record;
/** innerText */
text?: string;
/** innerHTML */
html?: string;
/** event handlers */
events?: Partial;
}
/**
* we still need a good way to represent the 'no-DOM' context,
* ideally without throwing exceptions.
*
*/
export class DOMContext {
protected static instances: DOMContext[] = [];
/**
* FIXME: how about we default to document, so it won't break?
* that will make it harder to debug though.
*/
public static GetInstance(doc?: Document) {
for (const instance of this.instances) {
if (instance.doc === doc) {
return instance;
}
}
// not found, create
const instance = new DOMContext(doc);
this.instances.push(instance);
return instance;
}
/** ugh sloppy */
public doc?: Document; // placeholder temp
/** ugh sloppy */
public view?: (Window & typeof globalThis) | null;
/*
public get document(): Document {
return this.doc;
}
public get window(): (Window & typeof globalThis) {
return this.view;
}
*/
/** class for `instanceof` comparison */
public get HTMLElement() {
return this.view?.HTMLElement;
}
protected constructor(doc?: Document) {
if (doc) {
this.doc = doc;
this.view = doc?.defaultView;
}
}
/** wrapper for window.getSelection */
public GetSelection() {
return this.view?.getSelection();
}
/** creates a div and assigns class name/names */
public Div(classes?: string|string[], parent?: HTMLElement, options?: CreateElementOptions): HTMLDivElement {
return this.Create('div', classes, parent, options);
}
public ClassNames(element: HTMLElement|SVGElement, classes: string|string[]) {
element.classList.add(...(Array.isArray(classes) ? classes : [classes]).reduce((arr: string[], entry) => [...arr, ...entry.split(/\s+/g)], []));
}
public SVG(
tag: K,
classes?: string|string[],
parent?: HTMLElement|SVGElement|DocumentFragment
): SVGElementTagNameMap[K] {
const element = (this.doc as Document).createElementNS(SVGNS, tag);
if (classes) {
this.ClassNames(element, classes);
}
if (parent) {
parent.appendChild(element);
}
return element;
}
/**
* this is a wrapper for createTextNode. but if we want to expose
* the element/node classes (@see HTMLElement, above) then this
* should properly be the Text class and not a method. So we should
* rename it.
*
* @param data
* @returns
*/
public Text(data: string) {
return (this.doc as Document).createTextNode(data);
}
public Fragment() {
return (this.doc as Document).createDocumentFragment();
}
/** better typing */
public Create(
tag: K,
classes?: string|string[],
parent?: HTMLElement|DocumentFragment,
options?: CreateElementOptions
): HTMLElementTagNameMap[K] {
const element = (this.doc as Document).createElement(tag);
if (classes) {
this.ClassNames(element, classes);
}
if (options) {
if (options.attrs) {
for (const [key, value] of Object.entries(options.attrs)) {
element.setAttribute(key, value);
}
}
if (options.data) {
for (const [key, value] of Object.entries(options.data)) {
element.dataset[key] = value;
}
}
if (options.text) {
element.textContent = options.text;
}
if (options.html) {
element.innerHTML = options.html;
}
if (options.events) {
for (const [key, value] of Object.entries(options.events)) {
element.addEventListener(key, value as (event: unknown) => void); // typing works well up until this point
}
}
if (options.style) {
for (const [key, value] of Object.entries(options.style)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(element.style as any)[key] = value; // more sloppy typing
}
}
}
if (parent) {
parent.appendChild(element);
}
return element;
}
}