diff --git a/src/js/SuiWeb.js b/src/js/SuiWeb.js index 9df8812..2720cb0 100644 --- a/src/js/SuiWeb.js +++ b/src/js/SuiWeb.js @@ -1,81 +1,376 @@ +// @ts-nocheck +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ /** - * Provides component for rendering elements written in SJDON notation. + * SuiWeb + * Simple User Interface Tool for Web Exercises + * + * @author bkrt + * @version 0.3.4 + * @date 11.12.2021 + * + * 0.3.4 - only null or undefined qualify as uninitialized state + * 0.3.3 - parseSJDON rewritten to use createElement + * - flatten children array for props.children in JSX + * 0.3.2 - save and restore of active element improved + * 0.3.1 - parseSJDON rewritten + * 0.3.0 - style property + * 0.2.3 - ES6 modules + * 0.2.2 - component state and hooks + * 0.2.1 - parse SJDON rewritten, function components + * 0.2.0 - render single SJDON structures to DOM + * + * Based on ideas and code from + * Rodrigo Pombo: Build your own React + * https://pomb.us/build-your-own-react/ + * + * Thanks to Rodrigo Pombo for a great tutorial and for sharing the + * code of the Didact library. Didact is a much more sophisticated + * re-implementtation of React's basics than the simple SuiWeb. */ -export class SuiWeb + +/* ===================================================================== + * SJDON - Conversion + * ===================================================================== +*/ + +// parseSJDON: convert SJDON to createElement calls +// +// note: this function can also help to use React with SJDON syntax +// +// to simplify calls add something like this to your components: +// let s = (data) => reactFromSJDON(data, React.createElement) +// +function parseSJDON([type, ...rest], create = createElement) { - /** - * Renders the specified {@link data `data`} and appends it to the specified {@link element `element`}. - * - * @param {NodeDescriptor} data - * The node to render written in SJDON notation. - * - * @param {HTMLElement} element - * The element to add the rendered node to. - * - * @returns {Node} - * The resulting element. - */ - static render(data, element) + const isObj = (obj) => typeof (obj) === 'object' && !Array.isArray(obj) + const children = rest.filter(item => !isObj(item)) + const repr = create(type, + Object.assign({}, ...rest.filter(isObj)), + ...children.map(ch => Array.isArray(ch) ? parseSJDON(ch, create) : ch) + ) + repr.sjdon = children + return repr +} + + +// create an element representation +// +function createElement(type, props, ...children) +{ + return { + type, + props: { + ...props, + children: children.flat().map(child => + typeof child === "object" + ? child + : createTextElement(child) + ), + }, + } +} + + +// create a text element representation +// +function createTextElement(text) +{ + return { + type: "TEXT_ELEMENT", + props: { + nodeValue: text, + children: [], + }, + } +} + +/* ===================================================================== + * Render node tree to DOM + * ===================================================================== +*/ + +// global context +let contextStore = createStore(); + + +// remove children and render new subtree +// +function render(element, container) +{ + while (container.firstChild) { - if (Array.isArray(data)) - { - let descriptor = data[0]; - let args = data.slice(1); + container.removeChild(container.lastChild); + } + renderInit(element, container, 0); +} - if (typeof descriptor === "function" || typeof descriptor === "string") + +// render subtree +// and call effects after rendering +// +function renderInit(element, container, n, childIndex) +{ + contextStore("effects", []); + + // save focus and cursor position of active element + let [focusSelector, position] = getFocusInput(); + + // ** render the element ** + renderElem(element, container, n, childIndex); + + // restore focus and cursor position of active element + setFocusInput(focusSelector, position); + + // run effects + contextStore("effects").forEach(fun => fun()); + contextStore("effects", []); +} + + +// render an element +// - if it is in SJDON form: parse first +// - render function or host component +// +function renderElem(element, container, n, childIndex) +{ + if (Array.isArray(element)) + { + element = parseSJDON(element); + } + + if (element.type instanceof Function) + { + updateFunctionComponent(element, container, n, childIndex); + } else + { + updateHostComponent(element, container, childIndex); + } +} + + +// function component +// - run function to get child node +// - render child node +// +function updateFunctionComponent(element, container, n, childIndex) +{ + // save re-render function to context + contextStore("re-render", () => renderInit(element, container, n, n)); + let children = element.sjdon ?? element.props.children; + let node = element.type({ ...element.props, children }); + renderElem(node, container, n, childIndex); +} + + +// host component +// - create dom node +// - assign properties +// - render child nodes +// - add host to dom +// +function updateHostComponent(element, container, childIndex) +{ + + // create DOM node + const dom = + element.type == "TEXT_ELEMENT" + ? document.createTextNode("") + : document.createElement(element.type) + + // assign the element props + const isProperty = key => key !== "children" + Object.keys(element.props) + .filter(isProperty) + .forEach(name => + { + if (name == "style") { - /** - * @type {HTMLElement} - */ - let result; - - if (typeof descriptor === "function") - { - /** - * @type {any[]} - */ - let arg = []; - - if (typeof args[0] === "object" && !Array.isArray(args[0])) - { - arg = [args[0]]; - args = args.slice(1); - } - - result = /** @type {HTMLElement} */ (SuiWeb.render(descriptor(...arg), element)); - } - else - { - result = element.ownerDocument.createElement(descriptor); - element.appendChild(result); - } - - for (let arg of args) - { - if (typeof arg === "object" && !Array.isArray(arg)) - { - Object.assign(result, arg); - } - else - { - SuiWeb.render(arg, result); - } - } - - return result; - } - else + updateStyleAttribute(dom, element.props.style); + } else { - throw new SyntaxError(); + dom[name] = element.props[name]; } - } - else if (typeof data === "string") + }) + + // render children + element.props.children.forEach((child, index) => + { + renderElem(child, dom, index); + }); + + if (typeof (childIndex) == 'number') + { + // re-render: replace node + container.replaceChild(dom, container.childNodes[childIndex]); + } else + { + // add node to container + container.appendChild(dom); + } +} + + +// update style attribute, value can be: +// - a CSS string: set to style attribute +// - an object: merged to style attribute +// - an array of objects: merged to style attribute +// +function updateStyleAttribute(dom, styles) +{ + if (typeof (styles) == "string") + { + dom.style = styles; + } else if (Array.isArray(styles)) + { + Object.assign(dom.style, ...styles); + } else if (typeof (styles) == "object") + { + Object.assign(dom.style, styles); + } +} + + +/* ===================================================================== + * Handling state + * ===================================================================== +*/ + +// element state +let stateHooks = createStore(); + + +// state hook +// - access state via id and key +// - return state and update function +// +function useState(id, key, init) +{ + let idKey = "id:" + id + "-key:" + key; + + // prepare render function + let renderFunc = contextStore("re-render"); + + // define function to update state + function updateValue(updateFun, rerender = true) + { + stateHooks(idKey, updateFun(stateHooks(idKey))); + if (rerender) renderFunc(); + } + + // new state: set initial value + if ([undefined, null].includes(stateHooks(idKey))) + { + stateHooks(idKey, init); + } + + return [stateHooks(idKey), updateValue]; +} + + +// effect hook +// add function to effects array +// +function useEffect(fun) +{ + contextStore("effects", [...contextStore("effects"), fun]); +} + + +// create a key-value-store +// return accessor function +// +function createStore() +{ + let data = {}; + function access(key, ...value) + { + if (value.length === 0) { - return element.appendChild(element.ownerDocument.createTextNode(data)); - } - else + return data[key]; + } else { - throw new SyntaxError(); + data[key] = value[0]; + return value[0]; + } + } + return access; +} + + +/* ===================================================================== + * Get and set focus and position in certain elements + * Note: this is a quick&dirty solution that probably fails when + * elements are added or removed + * ===================================================================== +*/ + +// create a CSS selector for a given node +// +function getSelectorOf(node) +{ + let selector = "" + while (node != document.body) + { + if (selector != "") selector = ">" + selector + + if (node.id) + { + selector = "#" + node.id + selector + break + } + + let index = Array.from(node.parentNode.children) + .filter(item => item.tagName == node.tagName) + .findIndex(item => item == node) + + selector = node.tagName + ":nth-of-type(" + (index + 1) + ")" + selector + node = node.parentNode + } + return selector +} + +// find a selector for the element that has focus and the cursor +// position in the element +// +function getFocusInput() +{ + const active = document.activeElement; + let sel = active ? getSelectorOf(active) : undefined + let position = active ? active.selectionStart : undefined; + return [sel, position]; +} + +// set focus to an element in a list of elements matching a +// selector and position cursor in the element +// +function setFocusInput(selector, position) +{ + if (selector && typeof (selector) == 'string') + { + console.log("Sel:" + selector) + let el = document.querySelector(selector); + if (el) el.focus(); + if (el && "selectionStart" in el + && "selectionEnd" in el + && position !== undefined) + { + el.selectionStart = position; + el.selectionEnd = position; } } } + + +/* ===================================================================== + * Module export + * ===================================================================== +*/ + +export { render, createElement, useState, useEffect }; + + +/* ===================================================================== + * EOF + * ===================================================================== +*/