UNPKG

catmagick

Version:

CatMagick is a framework to make websites easily.

776 lines (740 loc) 29 kB
/* CatMagick https://github.com/BoryaGames/CatMagick */ (() => { var CatMagick = {}; var components = {}; var currentPath = []; var visitedPaths = []; var currentComponent = null; var preMigration = null; var elementTypes = new Map; var keyPathMapping = new Map; var states = new Map; var effects = new Map; var memos = new Map; var caches = new Map; var events = new Map; var currentStateIndex = 0; var currentEffectIndex = 0; var currentMemoIndex = 0; var currentCacheIndex = 0; var currentEventIndex = 0; var textElementSymbol = Symbol("CatMagick.TextElement"); var referenceContainsSymbol = Symbol("CatMagick.ReferenceContains"); var virtualDom = document.createElement("body"); var rootElement = "Root"; var routes = {}; var routeParams = {}; var ws = null; var pingInterval = null; var renderStatus = 0; CatMagick.debug = !1; CatMagick.createElement = (type, props, ...children) => { props = (props || {}); children = children.flat(Infinity).filter(child => child !== void 0 && child !== null && child !== !1).map(child => (typeof child === "string" || typeof child === "number") ? { "type": textElementSymbol, "props": { "nodeValue": child.toString() }, "children": [] } : child); return [{ type, props, children }]; }; function debugLog(text) { if (CatMagick.debug) { console.debug("%c[CatMagick]", "background-color: black; color: red; padding: 4px; border-radius: 3px;", text); } } function createPathnameRegExp(path) { return new RegExp(`^${path.replace(/(\/|^)\$([A-Za-z0-9]+)(\/|$)/g, "$1(?<$2>[A-Za-z0-9-_\\$]+)$3").replace(/(\/|^)\$(\?)?(\/|$)/g, (_, prefix, optional, suffix) => `${prefix}(?:[A-Za-z0-9-_\\$]${optional ? "*" : "+"})${suffix}`).replace(/(\/|^)\$\$(\?)?(\/|$)/g, (_, prefix, optional, suffix) => `${prefix}(?:[A-Za-z0-9-_\\$/]${optional ? "*" : "+"})${suffix}`)}$`); } async function render(isRoot, elements, parent, fake, flags) { if (isRoot) { if (!components[rootElement]) { return; } debugLog("Rendering..."); var renderStarted = performance.now(); renderStatus = 1; preMigration = { "states": new Map(states), "effects": new Map(effects), "memos": new Map(memos), "caches": new Map(caches), "events": new Map(events) }; virtualDom = document.createElement("body"); currentStateIndex = 0; currentEffectIndex = 0; currentMemoIndex = 0; currentCacheIndex = 0; currentEventIndex = 0; elements = components[rootElement].render({}); parent = virtualDom; visitedPaths = []; } for (var elementIndex in elements) { currentPath.push(elementIndex); var element = elements[elementIndex]; visitedPaths.push(currentPath.join(";")); if (elementTypes.has(currentPath.join(";")) && elementTypes.get(currentPath.join(";")) != element.type) { if (effects.has(currentPath.join(";"))) { for (var effect of effects.get(currentPath.join(";")).values()) { if (typeof effect[2] === "function") { effect[2](); } } } states.delete(currentPath.join(";")); effects.delete(currentPath.join(";")); memos.delete(currentPath.join(";")); caches.delete(currentPath.join(";")); events.delete(currentPath.join(";")); } elementTypes.set(currentPath.join(";"), element.type); var originalElement = element; if (components[element.type]) { currentComponent = element; currentStateIndex = 0; currentEffectIndex = 0; currentMemoIndex = 0; currentCacheIndex = 0; currentEventIndex = 0; element = { "type": "div", "props": {}, "children": (components[element.type].render(currentComponent.props) || []) }; if (element.children.length == 1) { if (!components[element.children[0].type]) { element = element.children[0]; } } else { var filtered = element.children.filter(child => child.type != textElementSymbol || child.props.nodeValue.trim()); if (filtered.length == 1) { element = filtered[0]; } } if (element.props.key) { if (keyPathMapping.has(element.props.key) && keyPathMapping.get(element.props.key) != currentPath.join(";")) { var migrateFrom = keyPathMapping.get(element.props.key); var migrateTo = currentPath.join(";"); if (preMigration.states.has(migrateFrom)) { states.set(migrateTo, preMigration.states.get(migrateFrom)); } if (preMigration.effects.has(migrateFrom)) { effects.set(migrateTo, preMigration.effects.get(migrateFrom)); } if (preMigration.memos.has(migrateFrom)) { memos.set(migrateTo, preMigration.memos.get(migrateFrom)); } if (preMigration.caches.has(migrateFrom)) { caches.set(migrateTo, preMigration.caches.get(migrateFrom)); } if (preMigration.events.has(migrateFrom)) { events.set(migrateTo, preMigration.events.get(migrateFrom)); } currentStateIndex = 0; currentEffectIndex = 0; currentMemoIndex = 0; currentCacheIndex = 0; currentEventIndex = 0; element = { "type": "div", "props": {}, "children": (components[originalElement.type].render(currentComponent.props) || []) }; if (element.children.length == 1) { if (!components[element.children[0].type]) { element = element.children[0]; } } else { var filtered2 = element.children.filter(child => child.type != textElementSymbol || child.props.nodeValue.trim()); if (filtered2.length == 1) { element = filtered2[0]; } } } keyPathMapping.set(element.props.key, currentPath.join(";")); } } var domElement = (element.type == textElementSymbol) ? document.createTextNode("") : document.createElement(element.type); domElement._catmagickEvents = {}; if (typeof element.props.click === "function") { domElement._catmagickEvents.click = element.props.click; domElement.addEventListener("click", element.props.click); } if (typeof element.props.hover === "function") { domElement._catmagickEvents.mouseover = element.props.hover; domElement.addEventListener("mouseover", element.props.hover); } if (typeof element.props.hoverEnd === "function") { domElement._catmagickEvents.mouseout = element.props.hoverEnd; domElement.addEventListener("mouseout", element.props.hoverEnd); } if (typeof element.props.change === "function") { domElement._catmagickEvents.input = element.props.change; domElement.addEventListener("input", element.props.change); } domElement._catmagickProps = Object.assign({}, element.props); if (typeof element.props.ref === "function") { element.props.ref[referenceContainsSymbol] = domElement; } Object.keys(element.props).forEach(key => domElement[key] = element.props[key]); Object.keys((element.props.style || {})).forEach(key => domElement.style[key] = (typeof element.props.style[key] === "number") ? `${element.props.style[key]}px` : element.props.style[key]); var originalFlags = JSON.parse(JSON.stringify(flags)); if (originalElement.type == "Animation") { flags[originalElement.type] = { ...originalElement.props, "_pathIndex": currentPath.length }; } // TODO if (originalElement.type == "Animation" && flags.Animation && domElement.style) { domElement._catmagickProps.style = Object.assign({}, (domElement._catmagickProps.style || {}), { "viewTransitionName": (flags.Animation.name ? `${flags.Animation.name}-${currentPath.slice(flags.Animation._pathIndex).join("-")}` : `transition-${currentPath.join("-")}`), "viewTransitionClass": flags.Animation.animation }); domElement.style.viewTransitionName = (flags.Animation.name ? `${flags.Animation.name}-${currentPath.slice(flags.Animation._pathIndex).join("-")}` : `transition-${currentPath.join("-")}`); domElement.style.viewTransitionClass = flags.Animation.animation; } domElement._catmagickFlags = JSON.parse(JSON.stringify(flags)); domElement._catmagickKey = element.props.key; await render(!1, element.children, domElement, (originalElement.type == "Activity" && !originalElement.props.show), JSON.parse(JSON.stringify(flags))); if (!fake) { parent.appendChild(domElement); } currentPath.pop(); flags = originalFlags; } if (isRoot) { Array.from(states.keys()).filter(path => path).filter(path => !visitedPaths.includes(path)).forEach(path => { if (effects.has(path)) { for (var effect of effects.get(path).values()) { if (typeof effect[2] === "function") { effect[2](); } } } states.delete(path); effects.delete(path); memos.delete(path); caches.delete(path); events.delete(path); }); debugLog("Syncing..."); await syncDom(!0, virtualDom, document.body); debugLog(`Rendered in ${parseFloat((performance.now() - renderStarted).toFixed(1))}ms.`); for (var elementEffects of effects.values()) { for (var [effectId, effect2] of elementEffects.entries()) { if (effect2[1]) { elementEffects.set(effectId, [effect2[0], null, null]); elementEffects.set(effectId, [effect2[0], null, effect2[1]()]); } } } if (renderStatus == 2) { render(!0, null, null, !1, {}); } else { renderStatus = 0; } } } async function syncDom(isRoot, virtual, real) { var maxNodes = Math.max(virtual.childNodes.length, real.childNodes.length); var tasks = []; var transitionTasks = []; var virtualChildNodes = Array.from(virtual.childNodes); var realChildNodes = Array.from(real.childNodes); var virtualKeys = virtualChildNodes.map(node => node._catmagickKey).filter(key => key); var realKeys = realChildNodes.map(node => node._catmagickKey).filter(key => key); if (virtualKeys.length && virtualChildNodes.find(node => !node._catmagickKey)) { throw "Mixing elements with and without key is not allowed, including text and spaces between elements."; } for (var i = 0; i < maxNodes; i++) { var virtualNode = virtualChildNodes[i]; var realNode = realChildNodes[i]; if (realNode && realNode._catmagickKey && !virtualKeys.includes(realNode._catmagickKey)) { tasks.push({ "type": "remove", "node": realNode }); if (i === 0) { virtualChildNodes.unshift(null); } else { virtualChildNodes.splice((i - 1), 0, null); } } else if (virtualNode && virtualNode._catmagickKey && !realNode) { virtual.replaceChild(virtualNode.cloneNode(!0), virtualNode); tasks.push({ "type": "insert", "parent": real, "node": virtualNode, "position": realChildNodes[i + 1] }); } else if (virtualNode && virtualNode._catmagickKey && realNode && virtualNode._catmagickKey != realNode._catmagickKey) { if (realKeys.includes(virtualNode._catmagickKey)) { var realNode2 = Array.from(real.childNodes).find(node => node._catmagickKey == virtualNode._catmagickKey); tasks.push({ "type": "insert", "parent": real, "node": realNode2, "position": realNode }); } else { virtual.replaceChild(virtualNode.cloneNode(!0), virtualNode); tasks.push({ "type": "insert", "parent": real, "node": virtualNode, "position": realNode }); } if (i === 0) { realChildNodes.unshift(null); } else { realChildNodes.splice((i - 1), 0, null); } } else if (virtualNode && !realNode) { virtual.replaceChild(virtualNode.cloneNode(!0), virtualNode); tasks.push({ "type": "insert", "parent": real, "node": virtualNode }); } else if (!virtualNode && realNode) { tasks.push({ "type": "remove", "node": realNode }); } else if (virtualNode.nodeName != realNode.nodeName) { virtual.replaceChild(virtualNode.cloneNode(!0), virtualNode); tasks.push({ "type": "replace", "parent": real, "oldNode": realNode, "newNode": virtualNode }); } else if (realNode.nodeName == "#text") { if (virtualNode.textContent != realNode.textContent) { realNode.textContent = virtualNode.textContent; } } else { tasks.push({ "type": "update", virtualNode, realNode }); tasks.push(...(await syncDom(!1, virtualNode, realNode))); } } function doTask(task) { if (task.type == "insert") { if (task.position) { task.parent.insertBefore(task.node, task.position); } else { task.parent.appendChild(task.node); } } if (task.type == "replace") { task.parent.replaceChild(task.newNode, task.oldNode); } if (task.type == "update") { Object.keys(task.realNode._catmagickEvents).forEach(ev => task.realNode.removeEventListener(ev, task.realNode._catmagickEvents[ev])); Object.keys(task.virtualNode._catmagickEvents).forEach(ev => task.realNode.addEventListener(ev, task.virtualNode._catmagickEvents[ev])); task.realNode._catmagickEvents = task.virtualNode._catmagickEvents; task.realNode._catmagickKey = task.virtualNode._catmagickKey; for (var prop of new Set([...Object.keys(task.virtualNode._catmagickProps), ...Object.keys(task.realNode._catmagickProps)])) { if (!Object.keys(task.virtualNode._catmagickProps).includes(prop) && Object.keys(task.realNode._catmagickProps).includes(prop)) { task.realNode.removeAttribute((prop == "className") ? "class" : prop); } else if ((Object.keys(task.virtualNode._catmagickProps).includes(prop) && !Object.keys(task.realNode._catmagickProps).includes(prop)) || task.virtualNode._catmagickProps[prop] !== task.realNode._catmagickProps[prop]) { if (prop == "style") { for (var prop2 of new Set([...Object.keys(task.virtualNode._catmagickProps.style || {}), ...Object.keys(task.realNode._catmagickProps.style || {})])) { if (!Object.keys(task.virtualNode._catmagickProps.style || {}).includes(prop2) && Object.keys(task.realNode._catmagickProps.style || {}).includes(prop2)) { task.realNode.style[prop2] = ""; } else if ((Object.keys(task.virtualNode._catmagickProps.style || {}).includes(prop2) && !Object.keys(task.realNode._catmagickProps.style || {}).includes(prop2)) || task.virtualNode._catmagickProps.style[prop2] !== task.realNode._catmagickProps.style[prop2]) { task.realNode.style[prop2] = (typeof task.virtualNode._catmagickProps.style[prop2] === "number") ? `${task.virtualNode._catmagickProps.style[prop2]}px` : task.virtualNode._catmagickProps.style[prop2]; } } } else { task.realNode[prop] = task.virtualNode._catmagickProps[prop]; } } } task.realNode._catmagickProps = task.virtualNode._catmagickProps; if (typeof task.realNode._catmagickProps.ref === "function") { task.realNode._catmagickProps.ref[referenceContainsSymbol] = task.realNode; } } if (task.type == "remove") { if (task.node._catmagickProps && typeof task.node._catmagickProps.ref === "function") { task.node._catmagickProps.ref[referenceContainsSymbol] = null; } task.node.remove(); } } if (isRoot) { for (var task of tasks) { if (typeof document.startViewTransition === "function" && ((task.node && task.node._catmagickFlags && task.node._catmagickFlags.Animation) || (task.virtualNode && task.virtualNode._catmagickFlags && task.virtualNode._catmagickFlags.Animation))) { transitionTasks.push(task); } else { doTask(task); } } } if (transitionTasks.length) { try { await document.startViewTransition(() => { for (var task of transitionTasks) { doTask(task); } }).ready; } catch(err) { if (err.name != "AbortError") { throw err; } } } return tasks; } CatMagick.Component = class { constructor() { if (components[this.constructor.name]) { throw `Component with name <${this.constructor.name}> already exists.`; } components[this.constructor.name] = this; } }; function useContent() { return currentComponent.children; } function useState(defaultValue) { var localIndex = currentStateIndex++; var path = currentPath.join(";"); if (!states.has(path)) { states.set(path, new Map); } if (!states.get(path).has(localIndex)) { states.get(path).set(localIndex, defaultValue); } return [states.get(path).get(localIndex), value => { states.get(path).set(localIndex, value); if (renderStatus === 0) { render(!0, null, null, !1, {}); } else { renderStatus = 2; } }]; } function useEffect(execute, dependencies) { var localIndex = currentEffectIndex++; var path = currentPath.join(";"); if (!effects.has(path)) { effects.set(path, new Map); } if (!effects.get(path).has(localIndex)) { return effects.get(path).set(localIndex, [dependencies, execute, null]); } var lastEffect = effects.get(path).get(localIndex); if (dependencies === void 0 || dependencies === null || lastEffect[0] === void 0 || lastEffect[0] === null || dependencies.length != lastEffect[0].length || dependencies.findIndex((dependency, index) => dependency !== lastEffect[0][index]) > -1) { if (typeof lastEffect[2] === "function") { lastEffect[2](); } effects.get(path).set(localIndex, [dependencies, execute, null]); } } function useMemo(calculate, dependencies) { var localIndex = currentMemoIndex++; var path = currentPath.join(";"); if (!memos.has(path)) { memos.set(path, new Map); } if (!memos.get(path).has(localIndex) || dependencies === void 0 || dependencies === null || memos.get(path).get(localIndex)[0] === void 0 || memos.get(path).get(localIndex)[0] === null || dependencies.length != memos.get(path).get(localIndex)[0].length || dependencies.findIndex((dependency, index) => dependency !== memos.get(path).get(localIndex)[0][index]) > -1) { memos.get(path).set(localIndex, [dependencies, calculate()]); } return memos.get(path).get(localIndex)[1]; } function useCache(calculate, dependencies) { var localIndex = currentCacheIndex++; var path = currentPath.join(";"); if (!caches.has(path)) { caches.set(path, new Map); } if (!caches.get(path).has(localIndex)) { caches.get(path).set(localIndex, []); } var all = caches.get(path).get(localIndex); for (var one of all) { if (dependencies !== void 0 && dependencies !== null && one[0] !== void 0 && one[0] !== null && dependencies.length == one[0].length && dependencies.findIndex((dependency, index) => dependency !== one[0][index]) == -1) { return one[1]; } } var value = calculate(); all.push([dependencies, value]); caches.get(path).set(localIndex, all); return value; } function useLocation() { var { pathname, search, hash } = location; return { pathname, search, hash, "params": (routeParams || {}) }; } function useReference() { function getReference() { return getReference[referenceContainsSymbol]; } getReference.displayData = () => { if (!getReference[referenceContainsSymbol]) { return { "x": 0, "y": 0, "width": 0, "height": 0 }; } return getReference[referenceContainsSymbol].getBoundingClientRect(); }; getReference.set = value => { getReference[referenceContainsSymbol] = value; return value; }; getReference[referenceContainsSymbol] = null; return getReference; } function useEvent(event, callback) { var localIndex = currentEventIndex++; var path = currentPath.join(";"); if (!events.has(path)) { events.set(path, new Map); } events.get(path).set(localIndex, [event, callback]); } CatMagick.route = (path, root) => { debugLog(`Registered route "${path}".`); routes[path] = root; var match = location.pathname.match(createPathnameRegExp(path)); if (match) { rootElement = root; routeParams = match.groups; } }; CatMagick.goto = path => { debugLog(`Going to "${path}"...`); try { if ((new URL(path)).origin != location.origin) { location = path; return; } path = new URL(path); path = `${path.pathname}${path.search}${path.hash}`; } catch { // This path is not a full url then } history.pushState(null, null, path); for (var route of Object.keys(routes)) { var match = (new URL(location.origin + path)).pathname.match(createPathnameRegExp(route)); if (match) { for (var elementEffects of effects.values()) { for (var effect of elementEffects.values()) { if (typeof effect[2] === "function") { effect[2](); } } } states = new Map; effects = new Map; memos = new Map; caches = new Map; rootElement = routes[route]; routeParams = match.groups; render(!0, null, null, !1, {}); return; } } fetch(path).then(res => res.text()).then(async html => { for (var elementEffects of effects.values()) { for (var effect of elementEffects.values()) { if (typeof effect[2] === "function") { effect[2](); } } } delete window.CatMagick; document.documentElement.innerHTML = html; for (var element of Array.from(document.scripts)) { var parent = element.parentElement; var after = parent.children[(Array.from(parent.children).indexOf(element) + 1)]; element.remove(); var newElement = document.createElement(element.tagName.toLowerCase()); if (element.type) { newElement.type = element.type; } if (element.src) { newElement.src = element.src; } if (element.id) { newElement.id = element.id; } newElement.innerHTML = element.innerHTML; if (after) { parent.insertBefore(newElement, after); } else { parent.appendChild(newElement); } await new Promise(res => newElement.onload = res); } }); }; CatMagick.handleLink = event => { event.preventDefault(); event.stopPropagation(); CatMagick.goto(event.target.href); }; CatMagick.rerender = () => { if (renderStatus === 0) { render(!0, null, null, !1, {}); } else { renderStatus = 2; } }; CatMagick.fetch = (url, options) => { if (!options) { options = {}; } if (typeof options.body === "object") { options.headers = Object.assign({ "Content-Type": "application/json" }, (options.headers || {})); options.body = JSON.stringify(options.body); } return fetch(url, options); }; new class Activity extends CatMagick.Component { render(props) { return [{ "type": "div", "children": currentComponent.children, props }]; } } new class Animation extends CatMagick.Component { render(props) { return [{ "type": "div", "children": currentComponent.children, props }]; } } new class Captcha extends CatMagick.Component { render({ getToken, ...props }) { if (!CatMagick.captchaSiteKey) { throw "Captcha is not configured on the server."; } var captchaBox = useReference(); var widgetId = useReference(); getToken.set(() => { return (grecaptcha.getResponse(widgetId()) || null); }); useEffect(() => { if (typeof grecaptcha !== "undefined") { widgetId.set(grecaptcha.render(captchaBox(), { "sitekey": CatMagick.captchaSiteKey })); } }, [typeof grecaptcha]); return [{ "type": "div", "children": currentComponent.children, "props": { "ref": captchaBox, ...props } }]; } } function dispatchEvent(event, data) { ws.send(pako.deflate(JSON.stringify([event, data]))); } window.CatMagick = CatMagick; window.useContent = useContent; window.useState = useState; window.useEffect = useEffect; window.useMemo = useMemo; window.useCache = useCache; window.useLocation = useLocation; window.useReference = useReference; window.useEvent = useEvent; window.dispatchEvent = dispatchEvent; window.addEventListener("popstate", () => { for (var elementEffects of effects.values()) { for (var effect of elementEffects.values()) { if (typeof effect[2] === "function") { effect[2](); } } } states = new Map; effects = new Map; memos = new Map; caches = new Map; var routeFound = !1; for (var route of Object.keys(routes)) { var match = location.pathname.match(createPathnameRegExp(route)); if (match) { routeFound = !0; rootElement = routes[route]; routeParams = match.groups; break; } } if (!routeFound) { rootElement = "Root"; } render(!0, null, null, !1, {}); }); window.CatMagickHandleCaptcha = CatMagick.rerender; if (document.readyState == "loading") { window.addEventListener("DOMContentLoaded", () => render(!0, null, null, !1, {})); } else { render(!0, null, null, !1, {}); } function connectWS() { debugLog("Connecting to WebSocket..."); var connectionStarted = performance.now(); ws = new WebSocket("/events"); ws.binaryType = "arraybuffer"; ws.addEventListener("open", () => { debugLog(`Connected to WebSocket in ${parseFloat((performance.now() - connectionStarted).toFixed(1))}ms.`); pingInterval = setInterval(() => { ws.send("PING"); }, 6e4); }); ws.addEventListener("message", event => { if (event.data === "PONG") { return debugLog("WebSocket ping-pong completed."); } try { var message = JSON.parse(pako.inflate(event.data, { "to": "string" })); } catch { return debugLog("Received invalid message from server."); } for (var elementEvents of events.values()) { for (var elementEvent of elementEvents.values()) { if (message[0] == elementEvent[0]) { elementEvent[1](message[1]); } } } }); ws.addEventListener("close", event => { debugLog(`WebSocket was disconnected with code ${event.code}.`); clearInterval(pingInterval); connectWS(); }); } connectWS(); })();