summaryrefslogtreecommitdiff
path: root/desk/lib/kaji.js
diff options
context:
space:
mode:
Diffstat (limited to 'desk/lib/kaji.js')
-rw-r--r--desk/lib/kaji.js723
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
+ }]);
+ };