diff options
Diffstat (limited to 'desk/lib/kaji.js')
-rw-r--r-- | desk/lib/kaji.js | 723 |
1 files changed, 723 insertions, 0 deletions
diff --git a/desk/lib/kaji.js b/desk/lib/kaji.js new file mode 100644 index 0000000..a37dada --- /dev/null +++ b/desk/lib/kaji.js @@ -0,0 +1,723 @@ + const tabId = `${Date.now()}${Math.floor(Math.random() * 100)}`; + const channelId = `kaji-${tabId}`; + const channelPath = `/~/channel/${channelId}`; + let channelMessageId = 0; + const parser = new DOMParser(); + + addEventListener('DOMContentLoaded', async () => { + await openChannel(); + scanElements(document); + handleDefaultScroll(); + }); + + function handleDefaultScroll(){ + const params = new URLSearchParams(window.location.search); + const goto = params.get('goto'); + if (goto) { + const target = document.getElementById(goto); + if (target) target.scrollIntoView({block: "center"}); + else (console.log(goto, "target not found")) + } + } + function insertAndScan(parent, htmlString){ + parent.innerHTML = htmlString; + scanElements(parent); + } + function scanElements(el){ + const isEl = el instanceof HTMLElement; + if (!el) return + const elements = el.querySelectorAll('[kaji]'); + if (isEl && el.getAttribute("kaji")) setListener(el); + elements.forEach(el => setListener(el)); + } + function loadScripts(el){ + const scripts = el.querySelectorAll("script"); + for (let scr of scripts){ + const newScript = document.createElement('script'); + if (scr.src) newScript.src = scr.src + else if (scr.textContent) newScript.textContent = scr.textContent + document.body.appendChild(newScript); + } + } + function setLocaleTime(el){ + const els = el.querySelectorAll(".locale-dt"); + for (let de of els){ + const timestamp = Number(de.textContent); + // TODO error handle the parsing + const date = new Date(timestamp); + // TODO set the format at the server + let localDate = ""; + if (de.classList.includes("time")) + localDate = date.toLocaleDateString(); + if (de.classList.includes("date")) + localDate = date.toLocaleTimeString(); + else localDate = date.toLocaleString(); + de.textContent = localDate; + } + + } + async function openChannel(){ + console.log(window.ship, "opening channel") + const sub1 = makeSubscribeBody(window.ship, window.app, window.liveUI); + const ssePath = `/sse/${tabId}`; + const sub2 = makeSubscribeBody(window.ship, window.app, ssePath); + const pok = makePokeBody(window.ship, "hood", "helm-hi", "hai") + const body = JSON.stringify([sub1, sub2, pok]) + await fetch(channelPath, { + method: 'PUT', + body + }); + eventSource = new EventSource(channelPath); + eventSource.addEventListener('message', handleChannelStream); + } + async function put(bodies){ + console.log({channelPath, bodies}, "put body") + const res = await fetch(channelPath, { + method: "PUT", + body: JSON.stringify(bodies) + }) + return await res + } + function subscribe(path){ + document.addEventListener('DOMContentLoaded', () => { + const sub = makeSubscribeBody(window.ship, window.app, path); + put([sub]) + }); + } + function handleChannelStream(e){ + const streamResponse = JSON.parse(e.data); + if (streamResponse.response !== 'diff') return; + sendAck(streamResponse.id) + const res = streamResponse.json; + if (!res || res?.length === 0) return; + for (let effect of res){ + console.log(effect, "sse command") + if ("refresh" in effect) clearBrowserCache() + if ("redi" in effect) redirect(effect.redi) + if ("focus" in effect) handleFocus(effect.focus) + if ("scroll" in effect) handleScroll(effect.scroll) + if ("url" in effect) updateURL(effect.url) + if ("custom" in effect) dispatchFact(effect.custom) + else { + const key = Object.keys(effect)[0]; + const htmlData = effect[key].manx; + const nel = parser.parseFromString(htmlData, 'text/html').body.firstChild; + if ("swap" in effect) swapTarget(null, nel, effect.swap.sel, effect.swap.inner); + if ("add" in effect) addEls(null, nel, effect.add.container, effect.add.where) + if ("modal" in effect) showModal(nel) + if ("alert" in effect) showAlert(nel, effect.alert.duration) + } + } + } + + function clearBrowserCache(){ + window.location.reload(true); + } + function handleFocus(sel){ + const el = document.querySelector(sel); + if (el) el.focus(); + } + function handleScroll(sel){ + const el = document.querySelector(sel); + if (el) el.scrollIntoView(); + } + function updateURL(url){ + window.history.pushState(null, null, url) + } + function redirect(url){ + // document.body.innerHTML = html; + // scanElements(document.body); + // history.pushState({}, '', url) + console.log(url, "redirecting") + window.location.href = url; + } + function dispatchFact(detail){ + const myCustomEvent = new CustomEvent("kaji-fact", { detail }); + document.dispatchEvent(myCustomEvent); + } + + function partialManx(newElement, selector){ + const existing = document.getElementById(newElement.id); + if (newElement && existing) + existing.parentNode.replaceChild(newElement, existing); + else { + const toReplace = document.querySelector(selector); + toReplace.parentNode.replaceChild(newElement, toReplace); + } + } + function addManx(newElement, selector){ + const container = document.getElementById(selector); + container.appendChild(newElement); + } + function wrapInModal(el){ + const background = document.createElement("div"); + background.id = "kaji-modal-bg"; + const foreground = document.createElement("div"); + foreground.id = "kaji-modal-fg"; + // Append modal content to modal + foreground.appendChild(el); + background.appendChild(foreground); + document.body.appendChild(background); + // Click outside to close + window.onclick = function(event) { + if (event.target == background) background.remove(); + } + } + function showModal(html){ + const background = document.createElement("div"); + background.id = "kaji-modal-bg"; + const foreground = document.createElement("div"); + foreground.id = "kaji-modal-fg"; + insertAndScan(foreground, html) + // Append modal content to modal + background.appendChild(foreground); + document.body.appendChild(background); + // Click outside to close + window.onclick = function(event) { + if (event.target == background) background.remove(); + } + } + function showAlert(element, duration){ + const div = document.createElement("div"); + div.id = "kaji-alert"; + div.appendChild(element); + document.body.appendChild(div); + setTimeout(() => document.body.removeChild(div), duration) + } + function passAttributes(parent, child){ + for (let attr of parent.attributes){ + const n = attr.name; + const v = attr.value; + if (n === "class" || n === "id") continue; + const haveIt = child.getAttribute(n); + if (v && !haveIt) child.setAttribute(n, v) + } + } + + function findForm(el){ + const is = isFormChild(el); + if (!is) return + let form; + let parent = el.parentElement; + while (!form){ + if (!parent) break; + passAttributes(parent, el) + const tag = parent.tagName.toLowerCase(); + if (tag === "form") form = parent; + else parent = parent.parentElement; + } + if (form) el.params = getFormData(form) + } + + function isFormChild(el){ + const tag = el.tagName.toLowerCase(); + return (tag === "input" + || tag === "select" + || tag === "option" + || tag === "textarea" + || tag === "button") + } + + function setListener(el){ + const kaji = el.getAttribute('kaji'); + // immediate calls + // we're not gonna bother with settings params here. + if (kaji === "mobile") fetchMobile(el) + else if (kaji === "fetch") fetchSource(el) + else if (kaji === "iscroll") infiniteScroll(el) + else if (kaji === "search") handleLiveSearch(el) + else if (kaji === "clickaway") clickaway(el) + // event listeners + else { + const trigger = el.getAttribute("trigger") || defaultTrigger(el); + el.addEventListener(trigger, (e) => { + routeKaji(e, el, kaji) + }) + } + } + + function routeKaji(e, el, kaji){ + + if (kaji === "navi") navigate(e, el) + if (kaji === "modal") mmodal(e, el) + if (kaji === "toggle") toggle(e, el) + if (kaji === "destroy") destroy(e, el) + // urbit stuff + if (kaji === "scry") scry2(e) + if (kaji === "watch") watch(e, el) + if (kaji === "poke") poke(e, el) + if (kaji === "thread") thread(e, el) + } + + function clickaway(el){ + window.onclick = function(event) { + if (el.contains(event.target)) return + else el.hidden = true; + } + } + + function destroy(e, el){ + e.stopPropagation(); + const targ = e.target.getAttribute("targ"); + if (!targ) return + const targs = targ.split("/").map(m => lookabove(e.target, m)); + for (let t of targs){ + if (t) t.parentElement.removeChild(t); + } + } + function bail(string){ + console.log(string) + return + } + function handleLiveSearch(el){ + const bounceSetting = el.getAttribute("bounce"); + const num = Number(bounceSetting || 0); + if (Number.isNaN(num)) return bail("bounce setting NaN"); + // + const debounce = (fn, delay) => { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), delay) + } + } + const handler = (e) => scry(e, el); + + const debounced = debounce(handler, num); + el.addEventListener("keyup", debounced); + } + + + function isNarrow(){ + return window.innerWidth < 768; // TODO + } + async function fetchMobile(el){ + const path = el.getAttribute('path'); + if (!isNarrow()) return + const res = await fetch(path); + const html = await res.text(); + const nel = parser.parseFromString(html, 'text/html').body.firstChild; + scanElements(nel); + el.replaceWith(nel); + } + async function fetchSource(el){ + const res = await fetch(el.getAttribute("src")); + const html = await res.text(); + const nel = parser.parseFromString(html, 'text/html').body.firstChild; + el.replaceWith(nel) + } + function infiniteScroll(el){ + let observer = new IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const spinner = replaceWithSpinner(entry.target); + const url = getURLwithParams(el); + routerScry(url).then(nel => { + if (!nel) return + const cont = entry.target.getAttribute("cont"); + // TODO + addEls(el, nel, cont, addWhere(el)) + dispatchScry({swapMode: "add", container: cont, element: nel}) + spinner.remove() + }); + observer.unobserve(entry.target); + } + }) + }); + observer.observe(el); + } + function replaceWithSpinner(el){ + const spinner = document.createElement("img"); + spinner.src = "spinner.svg"; + spinner.classList.add("iscroll-spinner") + el.replaceWith(spinner); + return spinner; + } + function handleError(res, el){ + if(!el) { + console.log(res, "No element found scrying") + return true + } + if (el.id === "kaji-alert") { + const dur = el.getAttribute("dur"); + const num = Number(dur); + showAlert(el, num); + return true + } + const err = el.getAttribute("kaji-error"); + if (!err) return false + showError(el); + return true + } + function showError(el){ + return + } + function lookabove(el, sel){ + console.log(el, "looking above") + console.log(sel, "sel") + if (!sel) { + console.log(sel, "selector not found") + return null + } + if (sel[0] === "#") return document.getElementById(sel.slice(1)); + if (!el) return null + const found = el.querySelector(sel); + if (!found) return lookabove(el.parentElement, sel) + else return found + } + function toggle(e, el){ + e.stopPropagation(); + const targ = e.target.getAttribute("targ"); + if (!targ) return + const targs = targ.split("/").map(m => lookabove(e.target, m)); + targs.forEach(t => { + if (e.target.getAttribute("modal")) wrapInModal(t); + if (t) t.hidden = !t.hidden + }) + + } + async function routerScry(path){ + console.log(path, "router scry") + const res = await fetch(path) + const html = await res.text() + const nel = parser.parseFromString(html, 'text/html').body.firstChild; + if (handleError(html, nel)) return null + scanElements(nel); + loadScripts(nel); + return nel + } + function dispatchScry(detail){ + const myCustomEvent = new CustomEvent("kaji-scry", { detail }); + document.dispatchEvent(myCustomEvent); + } + + function addWhere(el){ + const where = el.getAttribute("where") + if (!where) return { bottom: null } + if (where === "bottom") return { bottom: null } + if (where === "top") return { top: null } + if (where === "before") { + const sibling = el.getAttribute("sibling"); + if (!sibling) return null + else return { before: sibling } + } + else return null + } + function addEls(originEl, nel, selector, where){ + if (!where) bail("no where given to addEls") + const container = originEl + ? lookabove(originEl, selector) + : document.querySelector(selector); + if (!container) bail("no container given to addEls") + while (nel.firstChild){ + if ("top" in where) container.prepend(nel.firstChild) + else if ("bottom" in where) container.appendChild(nel.firstChild) + else if ("before" in where){ + const sibling = document.querySelector(where.before); + if (!sibling) bail ("sibling at addEls doesn't exist") + container.insertBefore(nel.firstChild, sibling); + } + } + } + + + function defaultTrigger(el){ + const tag = el.tagName.toLowerCase(); + if (tag === "form") return "submit" + if (tag === "input" || tag === "textarea" || tag === "select") return "change" + else return "click"; + } + function toggleIndicator(el, on){ + const indicator = el.getAttribute('indicator'); + const indEl = document.querySelector(indicator); + if (indEl) + indEl.style.display = on ? "block" : "none"; + } + + function getURLwithParams(el){ + findForm(el); + const spath = el.getAttribute("path"); + const path = spath || window.location.href; + // decide on priority when conflict + const current = new URLSearchParams(window.location.search); + const [pat, pars] = path.split("?"); + if (pars) { + const param = new URLSearchParams(pars); + for (let [k, v] of param.entries()){ + current.set(k, v) + } + } + const name = el.getAttribute("name"); + const params = el.params + ? el.params + : el.tagName === 'FORM' + ? getFormData(el) + : {}; + const second = new URLSearchParams(params); + const final = new URLSearchParams(); + for (let [k, v] of current.entries()){ + final.set(k, v) + } + if (name && el.value) final.set(name, el.value); + for (let [k, v] of second.entries()){ + final.set(k, v) + } + const url = `${pat}?${final.toString()}`; + return url + } + + function consolidateAttributes(el){ + let parent = el.parentElement; + while (parent){ + passAttributes(parent, el); + parent = parent.parentElement; + } + } + async function scry2(e){ + console.log(e, "scry event") + // this should go to kaji root + const el = e.target; + consolidateAttributes(el); + // + // this should go to url building function + const path = el.getAttribute("path") || window.location.href; + const params = new URLSearchParams(window.location.search); + const [pat, parString] = path.split("?"); + if (parString) { + const newParams = new URLSearchParams(parString); + for (let [k, v] of newParams.entries()){ + params.set(k, v) + } + } + const name = el.getAttribute("name"); + if (name) params.set(name, el.value); + const url = `${path}?${params.toString()}`; + // scry proper + e.preventDefault(); + const swapMode = el.getAttribute("swap") || "swap"; + toggleIndicator(el, true); + const nel = await routerScry(url); + console.log(nel, "nel") + if (!nel) return + const showParams = el.getAttribute("show-params"); + if (showParams) raiseParams(url); + let container; + if (swapMode === "swap"){ + toggleTab(el); + const target = el.getAttribute('targ') + const swhere = el.getAttribute('where'); + const where = !swhere ? true : (swhere === "inner") + container = swapTarget(el, nel, target, where) + } + else if (swapMode === "add"){ + const cont = el.getAttribute('cont') + container = await addEls(el, nel, cont, addWhere(el)); + } + toggleIndicator(el, false); + if (container) dispatchScry({swapMode, container}) + + + + } + async function scry(e, el){ + console.log(e, "scry event") + e.preventDefault(); + const url = getURLwithParams(el); + console.log(url, "url") + toggleIndicator(el, true); + const swapMode = el.getAttribute("swap"); + const nel = await routerScry(url); + if (!nel) return + const showParams = el.getAttribute("show-params"); + if (showParams) raiseParams(url); + let container; + if (swapMode === "swap"){ + toggleTab(el); + const target = el.getAttribute('targ') + const swhere = el.getAttribute('where'); + const where = !swhere ? true : (swhere === "inner") + container = swapTarget(el, nel, target, where) + } + else if (swapMode === "add"){ + const cont = el.getAttribute('cont') + container = await addEls(el, nel, cont, addWhere(el)); + } + toggleIndicator(el, false); + if (container) dispatchScry({swapMode, container}) + } + + function raiseParams(url){ + const params = url.split("?")[1] || ""; + const displayURL = `${window.location.pathname}?${params}`; + updateURL(displayURL) + } + + async function navigate(e, el){ + e.preventDefault(); + const path = el.getAttribute('path'); + const href = findAnchor(e.target); + const uri = path ? path : href; + if (!uri) throw new Error("no fetch path found") + const res = await fetch(uri); + const html = await res.text() + // If in a modal, replace the modal content with this + const modal = e.target.closest("#kaji-modal-fg"); + if (modal) insertAndScan(modal, html) + else insertAndScan(document.querySelector("body"), html); + } + function findAnchor(el){ + const a = el.closest("a"); + if (a) return a.href + else return null + } + async function mmodal(e, el){ + const path = el.getAttribute('path'); + const href = findAnchor(e.target); + const uri = path ? path : href; + if (!uri) throw new Error("no fetch path found") + const res = await fetch(uri); + const html = await res.text(); + showModal(html); + } + + function hasSetTarget(el){ + return !!el.attributes.targ + } + function toggleTab(el){ + if (!el.classList.contains("tab")) return; + const par = el.parentElement; + if (!el.classList.contains("active")){ + const curr = el.parentElement.querySelector(".active"); + curr.classList.remove("active"); + el.classList.add("active"); + } + } + function swapTarget(originEl, nel, targetSelector, inner){ + const domEL = originEl + ? lookabove(originEl, targetSelector) + : document.querySelector(targetSelector); + if (!domEL) { + console.log("no container", targetSelector) + return + } + if (inner){ + domEL.innerHTML = ''; + while (nel.firstChild){ + domEL.appendChild(nel.firstChild); + } + } else domEL.parentElement.replaceChild(nel, domEL) + return domEL + } + async function poke(e, el){ + e.preventDefault(); + const ship = el.getAttribute('ship'); + const app = el.getAttribute('app'); + const mark = el.getAttribute('mark') || 'kaji'; + const json = el.getAttribute('json'); + console.log(el.params, "poke params?") + + const tgt = e.currentTarget; + const button = tgt.tagName === 'FORM' + ? tgt.querySelector('input[type="submit"]') + : tgt; + // TODO add flag to configure this + button.disabled = true; + // wipe form + const action = tgt.getAttribute('action'); + const name = tgt.getAttribute("name"); + const payload = tgt.getAttribute('payload'); + // const value = payload ? payload : tgt.value; + const body = json ? json + : (tgt.tagName === 'FORM') + ? {action, ...getFormData(tgt)} + : action ? // if we set an action we send {action, name: value} + {action, [name]: payload} + : {action: name, [name]: payload}; + e.stopPropagation(); // this cancels currentTarget going up and up + if (tgt.tagName === 'FORM') wipeForm(tgt); + const b = {...body, origin: window.location.pathname, tab: tabId}; + console.log(b, "poke params") + const bodies = [makePokeBody(window.ship, window.app, mark, b)] + await put(bodies); + button.disabled = false + } + function wipeForm(form){ + const wipe = form.getAttribute("wipe"); + if (!wipe) return + const inputs = [ + ...form.getElementsByTagName("input"), + ...form.getElementsByTagName("textarea") + ]; + for (let input of inputs){ + if (input.type.toLowerCase() === 'text') input.value = ''; + if (input.tagName === "TEXTAREA") input.value = ''; + } + } + + function getFormData(form){ + const d = new FormData(form); + return Object.fromEntries(d) + } + + async function watch(e, el){ + const ship = el.getAttribute('ship'); + const app = el.getAttribute('app'); + const path = el.getAttribute('path'); + const body = makeSubscribeBody(ship, app, path); + return await fetch(channelPath, { + method: 'PUT', + body + }); + } + // threads don't support noun payloads yet + async function thread(e, el){ + console.log(el.params, "thread params?") + const desk = el.getAttribute('desk'); + const thread = el.getAttribute('thread'); + const inputMark = el.getAttribute('input-mark') || 'kaji'; + const outputMark = el.getAttribute('output-mark') || 'kaji'; + const noun = el.getAttribute('noun'); + + fetch(`/spider/${desk}/${inputMark}/${thread}/${outputMark}.kaji`, { + headers: { + 'Content-type': 'application/x-urb-jam' + }, + method: 'POST', + body: noun + }); + } + async function sendAck(id){ + return await fetch(channelPath, { + method: 'PUT', + body: makeAck(id) + }); + } + function makeSubscribeBody(ship, app, path) { + channelMessageId++; + return { + id: channelMessageId, + action: 'subscribe', + ship: ship.slice(1), // fucking tilde crap + app, + path + }; + }; + function makePokeBody(ship, app, mark, json) { + channelMessageId++; + return { + id: channelMessageId, + action: 'poke', + ship: ship.slice(1), // fucking tilde crap + app, + mark, + json + }; + }; + function makeAck(eventId) { + channelMessageId++; + return JSON.stringify([{ + id: channelMessageId, + action: 'ack', + "event-id": eventId + }]); + }; |