Reactivity for state
This commit is contained in:
62
Test/observedobject.test.js
Normal file
62
Test/observedobject.test.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
window.testSuites.push( class testObservedObject {
|
||||||
|
|
||||||
|
FromJSONFailsWithoutAllFields() {
|
||||||
|
class Form extends ObservedObject {
|
||||||
|
id
|
||||||
|
path
|
||||||
|
$canvasPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let obj = Form.decode({id: "123"})
|
||||||
|
return "Not implemented"
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
FromJSONInitsAllFields() {
|
||||||
|
class Form extends ObservedObject {
|
||||||
|
id
|
||||||
|
path
|
||||||
|
$canvasPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj = Form.decode({id: "123", path: "/", canvasPosition: "25|25"})
|
||||||
|
if(!(obj && obj["id"] === "123" && obj["path"] === "/" && obj["canvasPosition"] === "25|25")) {
|
||||||
|
return "Not all fields initialized!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorksWithShadow() {
|
||||||
|
// window.Form = class Form extends ObservedObject {
|
||||||
|
// id
|
||||||
|
// path
|
||||||
|
// $canvasPosition
|
||||||
|
// }
|
||||||
|
|
||||||
|
// class File extends Shadow {
|
||||||
|
// $$form = Form.decode({id: "123", path: "/", canvasPosition: "25|25"})
|
||||||
|
// }
|
||||||
|
|
||||||
|
// window.register(File, "file-1")
|
||||||
|
// let file = window.File()
|
||||||
|
// console.log(file, p())
|
||||||
|
|
||||||
|
// return "Not yet"
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ChangingObjChangesInstance() {
|
||||||
|
// class Form extends ObservedObject {
|
||||||
|
// id
|
||||||
|
// path
|
||||||
|
// $canvasPosition
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let json = {id: "123", path: "/", canvasPosition: "25|25"}
|
||||||
|
// let obj = Form.decode({id: "123", path: "/", canvasPosition: "25|25"})
|
||||||
|
// json.id = "456"
|
||||||
|
// if(!(obj["id"] === "456")) {
|
||||||
|
// return "Change to original object was not reflected!"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
})
|
||||||
82
Test/render.test.js
Normal file
82
Test/render.test.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
window.testSuites.push( class testRender {
|
||||||
|
|
||||||
|
SimpleParagraphWithState() {
|
||||||
|
class File extends Shadow {
|
||||||
|
$form
|
||||||
|
|
||||||
|
render = () => {
|
||||||
|
console.log("render", window.rendering)
|
||||||
|
p(this.form.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.register(File, randomName("file"))
|
||||||
|
let form = {data: "asdf"}
|
||||||
|
const el = window.File(form)
|
||||||
|
|
||||||
|
if(!(el.firstChild?.matches("p"))) {
|
||||||
|
return `Child paragraph not rendered`
|
||||||
|
}
|
||||||
|
if(!(el.firstChild.innerText === "asdf")) {
|
||||||
|
return "Child para does not have inner text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ParagraphConstructorChangeState() {
|
||||||
|
register(class File extends Shadow {
|
||||||
|
$name
|
||||||
|
|
||||||
|
render = () => {
|
||||||
|
console.log("render", window.rendering)
|
||||||
|
p(this.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}, randomName("file"))
|
||||||
|
|
||||||
|
|
||||||
|
let name = "asdf"
|
||||||
|
const file = File(name)
|
||||||
|
file.name = "hey123"
|
||||||
|
|
||||||
|
if(file.firstChild.innerText !== "hey123") {
|
||||||
|
return "Paragraph did not react to change!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LiteralDoesNotCreateFalseReactivity() {
|
||||||
|
register(class File extends Shadow {
|
||||||
|
$name = "asd"
|
||||||
|
|
||||||
|
render = () => {
|
||||||
|
console.log("render", window.rendering)
|
||||||
|
p(this.name)
|
||||||
|
p("asd")
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}, randomName("file"))
|
||||||
|
|
||||||
|
|
||||||
|
const file = File()
|
||||||
|
file.name = "hey123"
|
||||||
|
|
||||||
|
let childs = Array.from(file.children)
|
||||||
|
childs.forEach((el) => {
|
||||||
|
console.log(el.innerText)
|
||||||
|
})
|
||||||
|
|
||||||
|
if(file.children[1].innerText === "hey123") {
|
||||||
|
return "Paragraph innertext falsely changed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
@@ -124,22 +124,42 @@ window.testSuites.push( class testShadow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RegisterThrowsIfNoConstructorParams() {
|
CannotAddUndefinedProperties() {
|
||||||
class File3 extends Shadow {
|
register(class File extends Shadow {
|
||||||
$form
|
|
||||||
|
render = () => {
|
||||||
|
p("boi")
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.hey = "unallowed"
|
||||||
|
}
|
||||||
|
}, randomName("file"))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = File()
|
||||||
|
return "Did not throw error!"
|
||||||
|
} catch(e) {
|
||||||
|
if(!e.message.includes("Extensible")) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SetNonStateFields() {
|
||||||
|
register(class File extends Shadow {
|
||||||
|
nonStateField
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
}
|
}, randomName("file"))
|
||||||
|
|
||||||
try {
|
const file = File("asd")
|
||||||
window.register(File3, "file3-el")
|
if(!file.nonStateField === "asd") {
|
||||||
} catch(e) {
|
return "Did not set field!"
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "Error not thrown!"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
27
Test/test.js
27
Test/test.js
@@ -3,6 +3,23 @@ window.testSuites = [];
|
|||||||
|
|
||||||
await import ("./parse.test.js")
|
await import ("./parse.test.js")
|
||||||
await import ("./shadow.test.js")
|
await import ("./shadow.test.js")
|
||||||
|
await import ("./observedobject.test.js")
|
||||||
|
await import ("./render.test.js")
|
||||||
|
|
||||||
|
window.randomName = function randomName(prefix) {
|
||||||
|
const sanitizedPrefix = prefix.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
|
||||||
|
// Generate a random suffix using numbers and lowercase letters
|
||||||
|
const suffixLength = 8; // You can adjust the length of the suffix
|
||||||
|
const suffixChars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let suffix = '';
|
||||||
|
for (let i = 0; i < suffixLength; i++) {
|
||||||
|
suffix += suffixChars.charAt(Math.floor(Math.random() * suffixChars.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine the prefix and suffix with a hyphen
|
||||||
|
return `${sanitizedPrefix}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
window.test = async function() {
|
window.test = async function() {
|
||||||
// window.testSuites.sort();
|
// window.testSuites.sort();
|
||||||
@@ -24,7 +41,15 @@ window.test = async function() {
|
|||||||
if(typeof suite[test] === 'function' && test !== "constructor") {
|
if(typeof suite[test] === 'function' && test !== "constructor") {
|
||||||
testNum++;
|
testNum++;
|
||||||
console.log(`%c${testNum}. ${test}`, "margin-top: 10px; border-top: 2px solid #e9c9a0; color: #e9c9a0; border-radius: 10px; padding: 10px;");
|
console.log(`%c${testNum}. ${test}`, "margin-top: 10px; border-top: 2px solid #e9c9a0; color: #e9c9a0; border-radius: 10px; padding: 10px;");
|
||||||
let fail = await suite[test]();
|
|
||||||
|
let fail;
|
||||||
|
try {
|
||||||
|
fail = await suite[test]()
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e)
|
||||||
|
fail = "Error"
|
||||||
|
}
|
||||||
|
|
||||||
if(fail) {
|
if(fail) {
|
||||||
failed++;
|
failed++;
|
||||||
let spaceNum = test.length - fail.length
|
let spaceNum = test.length - fail.length
|
||||||
|
|||||||
193
index.js
193
index.js
@@ -144,12 +144,24 @@ function getSafariVersion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* REGISTER */
|
/* REGISTER */
|
||||||
|
class ObservedObject {
|
||||||
window.ObservedObject = class ObservedObject {
|
static decode(obj) {
|
||||||
//
|
let instance = new this()
|
||||||
// "$" Variable: triggers a UI change
|
Object.keys(instance).forEach((key) => {
|
||||||
// Array: triggers a change if something is added, deleted, changed at the top level
|
if(key[0] === "$") {
|
||||||
//
|
key = key.substring(1)
|
||||||
|
}
|
||||||
|
console.log(key, obj[key])
|
||||||
|
if(obj[key]) {
|
||||||
|
instance[key] = obj[key]
|
||||||
|
} else {
|
||||||
|
if(!instance[key]) {
|
||||||
|
throw new Error(`ObservedObject: Non-default value "${key}" must be initialized!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return instance
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Page = class Page {
|
window.Page = class Page {
|
||||||
@@ -200,27 +212,39 @@ window.Registry = class Registry {
|
|||||||
static parseConstructor(classObject) {
|
static parseConstructor(classObject) {
|
||||||
let str = classObject.toString();
|
let str = classObject.toString();
|
||||||
const lines = str.split('\n');
|
const lines = str.split('\n');
|
||||||
|
let modifiedLines = [];
|
||||||
let braceDepth = 0;
|
let braceDepth = 0;
|
||||||
let constructorFound = false
|
let constructorFound = false
|
||||||
|
let superCallFound = false;
|
||||||
|
|
||||||
for (let line of lines) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let line = lines[i];
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
|
modifiedLines.push(line);
|
||||||
|
|
||||||
braceDepth += (trimmedLine.match(/{/g) || []).length;
|
braceDepth += (trimmedLine.match(/{/g) || []).length;
|
||||||
braceDepth -= (trimmedLine.match(/}/g) || []).length;
|
braceDepth -= (trimmedLine.match(/}/g) || []).length;
|
||||||
|
|
||||||
if(trimmedLine.startsWith('constructor(')) {
|
if (trimmedLine.startsWith('constructor(')) {
|
||||||
constructorFound = true
|
constructorFound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (braceDepth === 2) {
|
if (constructorFound && trimmedLine.startsWith('super(') && !superCallFound) {
|
||||||
if (trimmedLine.startsWith('super(')) {
|
superCallFound = true;
|
||||||
constructorFound = true
|
modifiedLines.push(` window.Registry.construct(this, window.Registry.currentStateVariables, ...window.Registry.currentParams);`);
|
||||||
var newLine = trimmedLine + "\nwindow.Registry.construct(this, window.Registry.currentStateVariables, ...window.Registry.currentParams)\n";
|
|
||||||
str = str.replace(line, newLine)
|
|
||||||
return eval('(' + str + ')');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (constructorFound && braceDepth === 1 && superCallFound) {
|
||||||
|
modifiedLines.splice(modifiedLines.length - 1, 0, ' Object.preventExtensions(this);');
|
||||||
|
modifiedLines.splice(modifiedLines.length - 1, 0, ' window.Registry.testInitialized(this);');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(superCallFound) {
|
||||||
|
let modifiedStr = modifiedLines.join('\n');
|
||||||
|
console.log(modifiedStr)
|
||||||
|
return eval('(' + modifiedStr + ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
if(constructorFound) {
|
if(constructorFound) {
|
||||||
@@ -229,7 +253,7 @@ window.Registry = class Registry {
|
|||||||
let constructorString = `
|
let constructorString = `
|
||||||
constructor(...params) {
|
constructor(...params) {
|
||||||
super(...params)
|
super(...params)
|
||||||
window.Registry.construct(this, window.Registry.currentStateVariables, ...window.Registry.currentParams)
|
window.Registry.construct(this)
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
let closingBracket = str.lastIndexOf("}");
|
let closingBracket = str.lastIndexOf("}");
|
||||||
@@ -238,6 +262,10 @@ window.Registry = class Registry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static testInitialized() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
static render = (el, parent) => {
|
static render = (el, parent) => {
|
||||||
let renderParent = window.rendering[window.rendering.length-1]
|
let renderParent = window.rendering[window.rendering.length-1]
|
||||||
if(renderParent) {
|
if(renderParent) {
|
||||||
@@ -248,62 +276,87 @@ window.Registry = class Registry {
|
|||||||
window.rendering.pop(el)
|
window.rendering.pop(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
static construct = (elem, stateNames, ...params) => {
|
static construct = (elem) => {
|
||||||
// State -> Attributes: set each state value as getter and setter
|
const params = window.Registry.currentParams
|
||||||
stateNames.forEach(name => {
|
const allNames = Object.keys(elem).filter(key => typeof elem[key] !== 'function')
|
||||||
const backingFieldName = `_${name}`;
|
const fieldNames = allNames.filter(field => /^[^\$]/.test(field))
|
||||||
|
const stateNames = allNames.filter(field => /^[$][^$]/.test(field)).map(str => str.substring(1));
|
||||||
Object.defineProperty(elem, name, {
|
const observedObjectNames = allNames.filter(field => /^[$][$][^$]/.test(field)).map(str => str.substring(2));
|
||||||
set: function(newValue) {
|
|
||||||
// console.log(`Setting attribute ${name} to `, newValue);
|
function makeState(elem, stateNames, params) {
|
||||||
elem[backingFieldName] = newValue; // Use the backing field to store the value
|
elem._observers = {}
|
||||||
elem.setAttribute(name, typeof newValue === "object" ? "{..}" : newValue); // Synchronize with the attribute
|
|
||||||
},
|
// State -> Attributes: set each state value as getter and setter
|
||||||
get: function() {
|
stateNames.forEach(name => {
|
||||||
// console.log("get: ", elem[backingFieldName])
|
const backingFieldName = `_${name}`;
|
||||||
return elem[backingFieldName]; // Provide a getter to access the backing field value
|
elem._observers[name] = new Map()
|
||||||
},
|
|
||||||
enumerable: true,
|
Object.defineProperty(elem, name, {
|
||||||
configurable: true
|
set: function(newValue) {
|
||||||
|
console.log(`Setting state ${name} to `, newValue);
|
||||||
|
elem[backingFieldName] = newValue; // Use the backing field to store the value
|
||||||
|
elem.setAttribute(name, typeof newValue === "object" ? "{..}" : newValue);
|
||||||
|
console.log(elem._observers)
|
||||||
|
for (let [observer, properties] of elem._observers[name]) {
|
||||||
|
for (let property of properties) {
|
||||||
|
observer[property] = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get: function() {
|
||||||
|
Registry.lastState = [name, elem[backingFieldName]]
|
||||||
|
// check which elements are observing the
|
||||||
|
return elem[backingFieldName]; // Provide a getter to access the backing field value
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if(elem["$" + name] !== undefined) { // User provided a default value
|
||||||
|
elem[name] = elem["$" + name]
|
||||||
|
delete elem["$" + name]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if(elem["$" + name] !== undefined) { // User provided a default value
|
// Match params to state names
|
||||||
elem[name] = elem["$" + name]
|
switch(stateNames.length) {
|
||||||
delete elem["$" + name]
|
case 0:
|
||||||
}
|
console.log("No state variables passed in, returning")
|
||||||
});
|
return
|
||||||
|
default:
|
||||||
|
let i = -1
|
||||||
|
for (let param of params) {
|
||||||
|
i++
|
||||||
|
|
||||||
|
if(i > stateNames.length) {
|
||||||
|
console.error(`${el.prototype.constructor.name}: too many parameters for state!`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Match params to state names
|
if(elem[stateNames[i]] === undefined) {
|
||||||
switch(stateNames.length) {
|
elem[stateNames[i]] = param
|
||||||
case 0:
|
}
|
||||||
console.log("No state variables passed in, returning")
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
let i = -1
|
|
||||||
for (let param of params) {
|
|
||||||
i++
|
|
||||||
|
|
||||||
if(i > stateNames.length) {
|
|
||||||
console.error(`${el.prototype.constructor.name}: too many parameters for state!`)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(elem[stateNames[i]] === undefined) {
|
// Check if all state variables are set. If not set, check if it is a user-initted value
|
||||||
elem[stateNames[i]] = param
|
for(let state of stateNames) {
|
||||||
|
if(elem[state] === undefined) {
|
||||||
|
console.error(`Quill: state "${state}" must be initialized`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if all state variables are set. If not set, check if it is a user-initted value
|
function makeObservedObjects(elem, observedObjectNames, params) {
|
||||||
for(let state of stateNames) {
|
|
||||||
if(elem[state] === undefined) {
|
|
||||||
console.error(`Quill: state "${state}" must be initialized`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
makeState(elem, stateNames, params)
|
||||||
|
makeObservedObjects(elem, observedObjectNames, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
static register = (el, tagname) => {
|
static register = (el, tagname) => {
|
||||||
let stateVariables = this.parseClassFields(el).filter(field => field.startsWith('$')).map(str => str.substring(1));
|
let stateVariables = this.parseClassFields(el).filter(field => /^[$][^$]/.test(field)).map(str => str.substring(1));
|
||||||
el = this.parseConstructor(el)
|
el = this.parseConstructor(el)
|
||||||
|
|
||||||
// Observe attributes
|
// Observe attributes
|
||||||
@@ -330,7 +383,6 @@ window.Registry = class Registry {
|
|||||||
|
|
||||||
// Actual Constructor
|
// Actual Constructor
|
||||||
window[el.prototype.constructor.name] = function (...params) {
|
window[el.prototype.constructor.name] = function (...params) {
|
||||||
window.Registry.currentStateVariables = stateVariables
|
|
||||||
window.Registry.currentParams = params
|
window.Registry.currentParams = params
|
||||||
let elIncarnate = new el(...params)
|
let elIncarnate = new el(...params)
|
||||||
Registry.render(elIncarnate)
|
Registry.render(elIncarnate)
|
||||||
@@ -345,6 +397,7 @@ window.a = function a({ href, name=href } = {}) {
|
|||||||
let link = document.createElement("a")
|
let link = document.createElement("a")
|
||||||
link.setAttribute('href', href);
|
link.setAttribute('href', href);
|
||||||
link.innerText = name
|
link.innerText = name
|
||||||
|
Registry.render(link)
|
||||||
return link
|
return link
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,24 +406,36 @@ window.img = function img({width="", height="", src=""}) {
|
|||||||
if(width) image.style.width = width
|
if(width) image.style.width = width
|
||||||
if(height) image.style.height = height
|
if(height) image.style.height = height
|
||||||
if(src) image.src = src
|
if(src) image.src = src
|
||||||
|
Registry.render(image)
|
||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
window.p = function p(innerText) {
|
window.p = function p(innerText) {
|
||||||
|
let parent = window.rendering[window.rendering.length-1]
|
||||||
let para = document.createElement("p")
|
let para = document.createElement("p")
|
||||||
para.innerText = innerText
|
para.innerText = innerText
|
||||||
|
if(Registry.lastState[0] && parent[Registry.lastState[0]] === innerText) {
|
||||||
|
if(!parent._observers[Registry.lastState[0]][para]) {
|
||||||
|
parent._observers[Registry.lastState[0]].set(para, [])
|
||||||
|
}
|
||||||
|
parent._observers[Registry.lastState[0]].get(para).push("innerText")
|
||||||
|
}
|
||||||
|
Registry.lastState = []
|
||||||
|
Registry.render(para)
|
||||||
return para
|
return para
|
||||||
}
|
}
|
||||||
|
|
||||||
window.div = function (innerText) {
|
window.div = function (innerText) {
|
||||||
let div = document.createElement("div")
|
let div = document.createElement("div")
|
||||||
div.innerText = innerText
|
div.innerText = innerText
|
||||||
|
Registry.render(div)
|
||||||
return div
|
return div
|
||||||
}
|
}
|
||||||
|
|
||||||
window.span = function (innerText) {
|
window.span = function (innerText) {
|
||||||
let span = document.createElement("span")
|
let span = document.createElement("span")
|
||||||
span.innerText = innerText
|
span.innerText = innerText
|
||||||
|
Registry.render(span)
|
||||||
return span
|
return span
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,8 +466,8 @@ HTMLElement.prototype.endingTag = function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HTMLElement.prototype.render = function (...els) {
|
HTMLElement.prototype.render = function (...els) {
|
||||||
this.innerHTML = ""
|
if(els.length > 0) {
|
||||||
if(els) {
|
this.innerHTML = ""
|
||||||
els.forEach((el) => {
|
els.forEach((el) => {
|
||||||
this.appendChild(el)
|
this.appendChild(el)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user