dreamlandjs/src/core.js
2024-04-10 11:31:38 -04:00

450 lines
14 KiB
JavaScript

import { assert } from './asserts'
// enables a small terser optimization
let document = self.document
export const Fragment = Symbol()
// We add some extra properties into various objects throughout, better to use symbols and not interfere. this is just a tiny optimization
let [USE_MAPFN, TARGET, PROXY, STEPS, LISTENERS, IF] = [, , , , , ,]
.fill()
.map(Symbol)
// whether to return the true value from a stateful object or a "trap" containing the pointer
let __use_trap = false
// Say you have some code like
//// let state = stateful({
//// a: stateful({
//// b: 1
//// })
//// })
//// let elm = <p>{window.use(state.a.b)}</p>
//
// According to the standard, the order of events is as follows:
// - the getter for window.use gets called, setting __use_trap true
// - the proxy for state.a is triggered and instead of returning the normal value it returns the trap
// - the trap proxy is triggered, storing ["a", "b"] as the order of events
// - the function that the getter of `use` returns is called, setting __use_trap to false and restoring order
// - the JSX factory h() is now passed the trap, which essentially contains a set of pointers pointing to the theoretical value of b
// - with the setter on the stateful proxy, we can listen to any change in any of the nested layers and call whatever listeners registered
// - the result is full intuitive reactivity with minimal overhead
Object.defineProperty(window, 'use', {
get: () => {
__use_trap = true
return (ptr, mapping, ...rest) => {
/* FEATURE.USESTRING.START */
if (ptr instanceof Array) return usestr(ptr, mapping, ...rest)
/* FEATURE.USESTRING.END */
assert(
isDLPtr(ptr),
'a value was passed into use() that was not part of a stateful context'
)
__use_trap = false
if (mapping) ptr[USE_MAPFN] = mapping
return ptr
}
},
})
/* FEATURE.USESTRING.START */
const usestr = (strings, ...values) => {
__use_trap = false
let state = stateful({})
const flattened_template = []
for (const i in strings) {
flattened_template.push(strings[i])
if (values[i]) {
const prop = values[i]
if (isDLPtr(prop)) {
const current_i = flattened_template.length
let oldparsed
handle(prop, (val) => {
flattened_template[current_i] = String(val)
let parsed = flattened_template.join('')
if (parsed != oldparsed) state.string = parsed
oldparsed = parsed
})
} else {
flattened_template.push(String(prop))
}
}
}
state.string = flattened_template.join('')
return use(state.string)
}
/* FEATURE.USESTRING.END */
let TRAPS = new Map()
// This wraps the target in a proxy, doing 2 things:
// - whenever a property is accessed, return a "trap" that catches and records accessors
// - whenever a property is set, notify the subscribed listeners
// This is what makes our "pass-by-reference" magic work
export function stateful(target, hook) {
assert(isobj(target), 'stateful() requires an object')
target[LISTENERS] = []
target[TARGET] = target
let TOPRIMITIVE = Symbol.toPrimitive
let proxy = new Proxy(target, {
get(target, property, proxy) {
if (__use_trap) {
let sym = Symbol()
let trap = new Proxy(
{
[TARGET]: target,
[PROXY]: proxy,
[STEPS]: [property],
[TOPRIMITIVE]: (_) => sym,
},
{
get(target, property) {
if (
[
TARGET,
PROXY,
STEPS,
USE_MAPFN,
TOPRIMITIVE,
].includes(property)
)
return target[property]
property = TRAPS.get(property) || property
target[STEPS].push(property)
return trap
},
}
)
TRAPS.set(sym, trap)
return trap
}
return Reflect.get(target, property, proxy)
},
set(target, property, val) {
if (hook) hook(target, property, val)
let trap = Reflect.set(target, property, val)
for (let listener of target[LISTENERS]) {
listener(target, property, val)
}
return trap
},
})
return proxy
}
let isobj = (o) => o instanceof Object
let isfn = (o) => typeof o === 'function'
export function isDLPtr(arr) {
return isobj(arr) && TARGET in arr
}
export function $if(condition, then, otherwise) {
otherwise ??= document.createTextNode('')
if (!isDLPtr(condition)) return condition ? then : otherwise
return { [IF]: condition, then, otherwise }
}
// This lets you subscribe to a stateful object
export function handle(ptr, callback) {
assert(isDLPtr(ptr), 'handle() requires a stateful object')
assert(isfn(callback), 'handle() requires a callback function')
let step,
resolvedSteps = []
function update() {
let val = ptr[TARGET]
for (step of resolvedSteps) {
val = val[step]
if (!isobj(val)) break
}
let mapfn = ptr[USE_MAPFN]
if (mapfn) val = mapfn(val)
callback(val)
}
// inject ourselves into nested objects
let curry = (target, i) =>
function subscription(tgt, prop, val) {
if (prop === resolvedSteps[i] && target === tgt) {
update()
if (isobj(val)) {
let v = val[LISTENERS]
if (v && !v.includes(subscription)) {
v.push(curry(val[TARGET], i + 1))
}
}
}
}
// imagine we have a `use(state.a[state.b])`
// simply recursively resolve any of the intermediate steps until we get to the final value
// this will "misfire" occassionaly with a scenario like state.a[state.b][state.c] and call the listener more than needed
// it is up to the caller to not implode
for (let i in ptr[STEPS]) {
let step = ptr[STEPS][i]
if (isobj(step) && step[TARGET]) {
handle(step, (val) => {
resolvedSteps[i] = val
update()
})
continue
}
resolvedSteps[i] = step
}
let sub = curry(ptr[TARGET], 0)
ptr[TARGET][LISTENERS].push(sub)
sub(ptr[TARGET], resolvedSteps[0], ptr[TARGET][resolvedSteps[0]])
}
function JSXAddFixedWrapper(ptr, cb, $if) {
let before, appended, first, flag
handle(ptr, (val) => {
first = appended?.[0]
if (first) before = first.previousSibling || (flag = first.parentNode)
if (appended) appended.forEach((a) => a.remove())
appended = JSXAddChild(
$if ? (val ? $if.then : $if.otherwise) : val,
(el) => {
if (before) {
if (flag) {
before.prepend(el)
flag = null
} else before.after(el)
before = el
} else cb(el)
}
)
})
}
// returns a function that sets a reference
// the currying is a small optimization
let curryset = (ptr) => (val) => {
let next = ptr[PROXY]
let steps = ptr[STEPS]
let i = 0
for (; i < steps.length - 1; i++) {
next = next[steps[i]]
if (!isobj(next)) return
}
next[steps[i]] = val
}
// Actual JSX factory. Responsible for creating the HTML elements and all of the *reactive* syntactic sugar
export function h(type, props, ...children) {
if (type == Fragment) return children
if (typeof type == 'function') {
// functional components. create the stateful object
let newthis = stateful(Object.create(type.prototype))
for (let name in props) {
let ptr = props[name]
if (name.startsWith('bind:')) {
assert(
isDLPtr(ptr),
'bind: requires a reference pointer from use'
)
let set = curryset(ptr)
let propname = name.substring(5)
if (propname == 'this') {
set(newthis)
} else {
// component two way data binding!! (exact same behavior as svelte:bind)
let isRecursive = false
handle(ptr, (value) => {
if (isRecursive) {
isRecursive = false
return
}
isRecursive = true
newthis[propname] = value
})
handle(use(newthis[propname]), (value) => {
if (isRecursive) {
isRecursive = false
return
}
isRecursive = true
set(value)
})
}
delete props[name]
}
}
Object.assign(newthis, props)
newthis.children = []
for (let child of children) {
JSXAddChild(child, newthis.children.push.bind(newthis.children))
}
let elm = type.apply(newthis)
elm.$ = newthis
newthis.root = elm
if (newthis.css) {
let cl = elm.classList
cl.add(newthis.css)
cl.add('self')
}
elm.setAttribute('data-component', type.name)
if (typeof newthis.mount === 'function') newthis.mount()
return elm
}
let xmlns = props?.xmlns
let elm = xmlns
? document.createElementNS(xmlns, type)
: document.createElement(type)
for (let child of children) {
let cond = child && !isDLPtr(child) && child[IF]
let bappend = elm.append.bind(elm)
if (cond) {
JSXAddFixedWrapper(cond, bappend, child)
} else JSXAddChild(child, bappend)
}
if (!props) return elm
let useProp = (name, callback) => {
if (!(name in props)) return
let prop = props[name]
callback(prop)
delete props[name]
}
for (let name in props) {
let ptr = props[name]
if (name.startsWith('bind:')) {
assert(isDLPtr(ptr), 'bind: requires a reference pointer from use')
let propname = name.substring(5)
// create the function to set the value of the pointer
let set = curryset(ptr)
if (propname == 'this') {
set(elm)
} else if (propname == 'value') {
handle(ptr, (value) => (elm.value = value))
elm.addEventListener('change', () => set(elm.value))
} else if (propname == 'checked') {
handle(ptr, (value) => (elm.checked = value))
elm.addEventListener('click', () => set(elm.checked))
}
delete props[name]
}
if (name == 'style' && isobj(ptr)) {
for (let key in ptr) {
let prop = ptr[key]
if (isDLPtr(prop)) {
handle(prop, (value) => (elm.style[key] = value))
} else {
elm.style[key] = prop
}
}
delete props[name]
}
}
useProp('class', (classlist) => {
assert(
typeof classlist === 'string' || classlist instanceof Array,
'class must be a string or array'
)
if (typeof classlist === 'string') {
elm.setAttribute('class', classlist)
return
}
if (isDLPtr(classlist)) {
handle(classlist, (classname) =>
elm.setAttribute('class', classname)
)
return
}
for (let name of classlist) {
if (isDLPtr(name)) {
let oldvalue = null
handle(name, (value) => {
if (typeof oldvalue === 'string') {
elm.classList.remove(oldvalue)
}
elm.classList.add(value)
oldvalue = value
})
} else {
elm.classList.add(name)
}
}
})
// apply the non-reactive properties
for (let name in props) {
let prop = props[name]
if (isDLPtr(prop)) {
handle(prop, (val) => {
JSXAddAttributes(elm, name, val)
})
} else {
JSXAddAttributes(elm, name, prop)
}
}
// hack to fix svgs
if (xmlns) elm.innerHTML = elm.innerHTML
return elm
}
// glue for nested children
function JSXAddChild(child, cb) {
let childchild, elms, node
if (isDLPtr(child)) {
JSXAddFixedWrapper(child, cb)
} else if (child instanceof Node) {
cb(child)
return [child]
} else if (child instanceof Array) {
elms = []
for (childchild of child) {
elms = elms.concat(JSXAddChild(childchild, cb))
}
if (!elms[0]) elms = JSXAddChild('', cb)
return elms
} else {
node = document.createTextNode(child)
cb(node)
return [node]
}
}
// Where properties are assigned to elements, and where the *non-reactive* syntax sugar goes
function JSXAddAttributes(elm, name, prop) {
if (name.startsWith('on:')) {
assert(typeof prop === 'function', 'on: requires a function')
let names = name.substring(3)
for (let name of names.split('$')) {
elm.addEventListener(name, (...args) => {
self.$el = elm
prop(...args)
})
}
return
}
elm.setAttribute(name, prop)
}