├── src ├── modules │ ├── state │ │ ├── internal │ │ │ ├── Noop.js │ │ │ ├── Binding.js │ │ │ ├── ReactiveWrapper.js │ │ │ ├── ReactiveObjectModel.js │ │ │ ├── ComputedProperty.js │ │ │ ├── StateTracker.js │ │ │ ├── Queue.js │ │ │ ├── ReactiveArrayInternals.js │ │ │ ├── Core.js │ │ │ └── ReactiveObjectInternals.js │ │ ├── create-state.js │ │ ├── ReactiveObject.js │ │ ├── PersistentStorage.js │ │ ├── ReactiveArray.js │ │ └── README.md │ ├── server │ │ ├── post-request.js │ │ ├── put-request.js │ │ ├── delete-request.js │ │ ├── get-request.js │ │ ├── on-request.js │ │ └── internal │ │ │ ├── cache.js │ │ │ └── make-call.js │ ├── utils │ │ ├── defer.js │ │ ├── throttle.js │ │ ├── hash-string.js │ │ ├── deep-clone.js │ │ └── deep-equal.js │ ├── component │ │ ├── create-element.js │ │ ├── internal │ │ │ ├── StateProvider.js │ │ │ ├── ComponentElement.js │ │ │ └── ComponentModel.js │ │ ├── on-dragover.js │ │ ├── on-resize.js │ │ ├── define-component.js │ │ ├── render-list.js │ │ └── README.md │ └── router │ │ └── router.js └── index.js ├── logo.jpg ├── .gitattributes ├── rollup.config.js ├── examples └── TODO MVC │ ├── todo_mvc.html │ ├── components │ ├── placeholder.svg │ ├── todo-item.js │ └── todo-editor.js │ └── app.js ├── LICENSE ├── gulpfile.js ├── README.md └── .gitignore /src/modules/state/internal/Noop.js: -------------------------------------------------------------------------------- 1 | export const NOOP = (o => o); -------------------------------------------------------------------------------- /logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monokee/Sekoia/HEAD/logo.jpg -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | input: 'src/index.js', 3 | output: { 4 | file: 'build/sekoia.js', 5 | format: 'esm' 6 | } 7 | } -------------------------------------------------------------------------------- /src/modules/server/post-request.js: -------------------------------------------------------------------------------- 1 | import { makeCall } from "./internal/make-call.js"; 2 | 3 | export function postRequest(url, data, token) { 4 | return makeCall(url, 'POST', token, data); 5 | } -------------------------------------------------------------------------------- /src/modules/server/put-request.js: -------------------------------------------------------------------------------- 1 | import { makeCall } from "./internal/make-call.js"; 2 | 3 | export function putRequest(url, data, token) { 4 | return makeCall(url, 'PUT', token, data); 5 | } -------------------------------------------------------------------------------- /src/modules/server/delete-request.js: -------------------------------------------------------------------------------- 1 | import { makeCall } from "./internal/make-call.js"; 2 | 3 | export function deleteRequest(url, data, token) { 4 | return makeCall(url, 'DELETE', token, data); 5 | } -------------------------------------------------------------------------------- /src/modules/utils/defer.js: -------------------------------------------------------------------------------- 1 | export function defer(callback, timeout = 100) { 2 | let pending = 0; 3 | return arg => { 4 | clearTimeout(pending); 5 | pending = setTimeout(callback, timeout, arg); 6 | } 7 | } -------------------------------------------------------------------------------- /src/modules/component/create-element.js: -------------------------------------------------------------------------------- 1 | const TEMPLATE = document.createElement('template'); 2 | 3 | export function createElement(node) { 4 | typeof node === 'function' && (node = node()); 5 | TEMPLATE.innerHTML = node; 6 | return TEMPLATE.content.firstElementChild; 7 | } -------------------------------------------------------------------------------- /examples/TODO MVC/todo_mvc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sekoia.js TODO MVC 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/modules/utils/throttle.js: -------------------------------------------------------------------------------- 1 | export function throttle (callback, interval) { 2 | let pending = 0; 3 | const reset = () => (pending = 0); 4 | return arg => { 5 | if (!pending) { 6 | callback(arg); 7 | pending = setTimeout(reset, interval); 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /src/modules/utils/hash-string.js: -------------------------------------------------------------------------------- 1 | export function hashString(str) { 2 | if (!str.length) return '0'; 3 | let hash = 0; 4 | for (let i = 0, char; i < str.length; i++) { 5 | char = str.charCodeAt(i); 6 | hash = ((hash << 5) - hash) + char; 7 | hash = hash & hash; 8 | } 9 | return hash + ''; 10 | } -------------------------------------------------------------------------------- /src/modules/state/create-state.js: -------------------------------------------------------------------------------- 1 | import { ReactiveArray } from "./ReactiveArray.js"; 2 | import { ReactiveObject } from "./ReactiveObject.js"; 3 | 4 | export function createState(objectOrArray, options) { 5 | if (Array.isArray(objectOrArray)) { 6 | return new ReactiveArray(objectOrArray, options); 7 | } else { 8 | return new ReactiveObject(objectOrArray); 9 | } 10 | } -------------------------------------------------------------------------------- /src/modules/component/internal/StateProvider.js: -------------------------------------------------------------------------------- 1 | export const StateProvider = { 2 | setState(item) { 3 | this.__cache.set(++this.__uid, item); 4 | return this.__uid; 5 | }, 6 | popState(uid) { 7 | uid = Number(uid); 8 | if (this.__cache.has(uid)) { 9 | const state = this.__cache.get(uid); 10 | this.__cache.delete(uid); 11 | return state; 12 | } 13 | }, 14 | __cache: new Map(), 15 | __uid: -1 16 | }; -------------------------------------------------------------------------------- /src/modules/utils/deep-clone.js: -------------------------------------------------------------------------------- 1 | export function deepClone(x) { 2 | 3 | if (!x || typeof x !== 'object') { 4 | return x; 5 | } 6 | 7 | if (Array.isArray(x)) { 8 | 9 | const y = []; 10 | 11 | for (let i = 0; i < x.length; i++) { 12 | y.push(deepClone(x[i])); 13 | } 14 | 15 | return y; 16 | 17 | } 18 | 19 | const y = {}; 20 | 21 | for (const key in x) { 22 | if (x.hasOwnProperty(key)) { 23 | y[key] = deepClone(x[key]); 24 | } 25 | } 26 | 27 | return y; 28 | 29 | } -------------------------------------------------------------------------------- /src/modules/server/get-request.js: -------------------------------------------------------------------------------- 1 | import { hashString } from "../utils/hash-string.js"; 2 | import { getCache, setCache } from "./internal/cache.js"; 3 | import { makeCall } from "./internal/make-call.js"; 4 | 5 | export function getRequest(url, cacheSeconds = 0, token = '') { 6 | const hash = hashString(url); 7 | return getCache(hash) 8 | .then(data => data) 9 | .catch(() => makeCall(url, 'GET', token).then(res => { 10 | cacheSeconds > 0 && setCache(hash, res, cacheSeconds); 11 | return res; 12 | })); 13 | } -------------------------------------------------------------------------------- /src/modules/component/on-dragover.js: -------------------------------------------------------------------------------- 1 | export function onDragOver(element, onDrop) { 2 | 3 | element.addEventListener('dragenter', e => { 4 | element.classList.add('dragover'); 5 | }); 6 | 7 | element.addEventListener('dragleave', e => { 8 | element.classList.remove('dragover'); 9 | }); 10 | 11 | element.addEventListener('dragover', e => { 12 | e.preventDefault(); 13 | }); 14 | 15 | element.addEventListener('drop', e => { 16 | e.preventDefault(); 17 | element.classList.remove('dragover'); 18 | onDrop(e.dataTransfer.files); 19 | }); 20 | 21 | } -------------------------------------------------------------------------------- /src/modules/server/on-request.js: -------------------------------------------------------------------------------- 1 | import { ON_REQUEST_START, ON_REQUEST_STOP } from "./internal/make-call.js"; 2 | 3 | export function onRequestStart(handler, urlIncludes = '*', once = false) { 4 | register(handler, urlIncludes, ON_REQUEST_START, once); 5 | } 6 | 7 | export function onRequestStop(handler, urlIncludes = '*', once = false) { 8 | register(handler, urlIncludes, ON_REQUEST_STOP, once); 9 | } 10 | 11 | function register(cb, includes, stack, once) { 12 | const event = { 13 | handler: once ? () => { 14 | cb(); 15 | stack.delete(event); 16 | } : cb, 17 | includes: includes 18 | }; 19 | stack.add(event); 20 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Sekoia.js 2 | Copyright (C) 2022 Jonathan M. Ochmann 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see https://www.gnu.org/licenses. -------------------------------------------------------------------------------- /src/modules/server/internal/cache.js: -------------------------------------------------------------------------------- 1 | import { PersistentStorage } from "../../state/PersistentStorage.js"; 2 | 3 | const CACHE = () => CACHE.$$ || (CACHE.$$ = new PersistentStorage({ 4 | name: 'sekoia::network::cache' 5 | })); 6 | 7 | export function setCache(hash, value, cacheSeconds) { 8 | return CACHE().set(hash, { 9 | value: value, 10 | expires: Date.now() + (cacheSeconds * 1000) 11 | }); 12 | } 13 | 14 | export function getCache(hash) { 15 | return CACHE().get(hash).then(entry => { 16 | if (entry) { 17 | if (entry.expires < Date.now()) { 18 | CACHE().delete(hash); 19 | throw false; 20 | } else { 21 | return entry.value; 22 | } 23 | } else { 24 | throw false; 25 | } 26 | }); 27 | } -------------------------------------------------------------------------------- /src/modules/component/on-resize.js: -------------------------------------------------------------------------------- 1 | let RESIZE_OBSERVER, HANDLERS, RESIZE_BUFFER; 2 | 3 | export function onResize(element, handler) { 4 | 5 | if (element === window || element === document || element === document.documentElement) { 6 | element = document.body; 7 | } 8 | 9 | if ((HANDLERS || (HANDLERS = new Map())).has(element)) { 10 | HANDLERS.get(element).push(handler); 11 | } else { 12 | HANDLERS.set(element, [handler]); 13 | } 14 | 15 | (RESIZE_OBSERVER || (RESIZE_OBSERVER = new ResizeObserver(entries => { 16 | clearTimeout(RESIZE_BUFFER); 17 | RESIZE_BUFFER = setTimeout(ON_RESIZE, 100, entries); 18 | }))).observe(element); 19 | 20 | } 21 | 22 | function ON_RESIZE(entries) { 23 | for (let i = 0, entry, handlers; i < entries.length; i++) { 24 | entry = entries[i]; 25 | handlers = HANDLERS.get(entry.target); 26 | if (handlers) { 27 | for (let k = 0; k < handlers.length; k++) { 28 | handlers[k](entry); 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/modules/utils/deep-equal.js: -------------------------------------------------------------------------------- 1 | export function deepEqual(a, b) { 2 | 3 | if (a === b) { 4 | return true; 5 | } 6 | 7 | if (a && b && typeof a === 'object' && typeof b === 'object') { 8 | 9 | if (a.constructor !== b.constructor) return false; 10 | 11 | if (Array.isArray(a)) { 12 | if (a.length !== b.length) return false; 13 | for (let i = a.length; i-- !== 0;) { 14 | if (!deepEqual(a[i], b[i])) return false; 15 | } 16 | return true; 17 | } 18 | 19 | const keys = Object.keys(a); 20 | const length = keys.length; 21 | 22 | if (length !== Object.keys(b).length) return false; 23 | 24 | for (let i = length; i-- !== 0;) { 25 | if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; 26 | } 27 | 28 | for (let i = length, key; i-- !== 0;) { 29 | key = keys[i]; 30 | if (!deepEqual(a[key], b[key])) return false; 31 | } 32 | 33 | return true; 34 | 35 | } 36 | 37 | return a !== a && b !== b; 38 | 39 | } -------------------------------------------------------------------------------- /src/modules/component/define-component.js: -------------------------------------------------------------------------------- 1 | import { ComponentModel } from "./internal/ComponentModel.js"; 2 | import { ComponentElement } from "./internal/ComponentElement.js"; 3 | import { ReactiveObject } from "../state/ReactiveObject.js"; 4 | 5 | export function defineComponent(name, config) { 6 | 7 | const model = new ComponentModel(name, config); 8 | 9 | const component = class extends ComponentElement { 10 | constructor() { 11 | super(model); 12 | } 13 | }; 14 | 15 | // add custom methods to prototype 16 | for (const key in config) { 17 | if (config.hasOwnProperty(key) && key !== 'initialize' && typeof config[key] === 'function') { 18 | component.prototype[key] = config[key]; 19 | } 20 | } 21 | 22 | window.customElements.define(name, component); 23 | 24 | // creates composable html tag with attributes 25 | const Factory = attributes => ComponentModel.createTag(name, attributes); 26 | 27 | // creates a new state object 28 | Factory.state = data => { 29 | if (model.setupStateOnce()) { 30 | return ReactiveObject._from_(model.state, data); 31 | } else { 32 | return data; 33 | } 34 | }; 35 | 36 | // creates dom node 37 | Factory.render = attributes => ComponentModel.createNode(name, attributes, Factory.state); 38 | 39 | return Factory; 40 | 41 | } -------------------------------------------------------------------------------- /src/modules/state/internal/Binding.js: -------------------------------------------------------------------------------- 1 | export class Binding { 2 | 3 | // a link between reactive objects that are not in the same state branch 4 | constructor(sourceInternals, key, readonly) { 5 | this.sourceInternals = sourceInternals; 6 | this.ownPropertyName = key; 7 | this.readonly = readonly; // true when bound to upstream computed property 8 | this.connectedObjectInternals = new Map(); // [reactiveObjectInternals -> key] 9 | } 10 | 11 | connect(reactiveObjectInternals, boundKey) { 12 | 13 | if (reactiveObjectInternals === this.sourceInternals) { 14 | throw new Error(`Failed to bind "${boundKey}". Cannot bind object to itself.`); 15 | } else if (this.connectedObjectInternals.has(reactiveObjectInternals)) { 16 | throw new Error(`Failed to bind "${boundKey}". Cannot bind to an object more than once.`); 17 | } else { 18 | this.connectedObjectInternals.set(reactiveObjectInternals, boundKey); 19 | } 20 | 21 | } 22 | 23 | observeSource(callback, cancelable, silent) { 24 | return this.sourceInternals.observe(this.ownPropertyName, callback, cancelable, silent); 25 | } 26 | 27 | getValue(writableOnly) { 28 | return this.sourceInternals.getDatum(this.ownPropertyName, writableOnly); 29 | } 30 | 31 | getDefaultValue() { 32 | return this.sourceInternals.getDefaultDatum(this.ownPropertyName); 33 | } 34 | 35 | setValue(value, silent) { 36 | this.sourceInternals.setDatum(this.ownPropertyName, value, silent); 37 | } 38 | 39 | } 40 | 41 | Binding.prototype._isBinding_ = true; -------------------------------------------------------------------------------- /src/modules/server/internal/make-call.js: -------------------------------------------------------------------------------- 1 | const PENDING_CALLS = new Map(); 2 | 3 | export const ON_REQUEST_START = new Set(); 4 | export const ON_REQUEST_STOP = new Set(); 5 | 6 | export function makeCall(url, method, token, data = {}) { 7 | 8 | if (PENDING_CALLS.has(url)) { 9 | 10 | return PENDING_CALLS.get(url); 11 | 12 | } else { 13 | 14 | const headers = { 15 | 'Content-Type': 'application/json' 16 | }; 17 | 18 | if (token) { 19 | headers['Authorization'] = `Bearer ${token}`; 20 | } 21 | 22 | fire(ON_REQUEST_START, url); 23 | 24 | return PENDING_CALLS.set(url, fetch(url, { 25 | method: method, 26 | mode: 'cors', 27 | cache: 'no-store', 28 | credentials: 'same-origin', 29 | headers: headers, 30 | redirect: 'follow', 31 | referrer: 'no-referrer', 32 | body: method === 'GET' ? null : typeof data === 'string' ? data : JSON.stringify(data) 33 | }).then(res => { 34 | const ct = res.headers.get('content-type'); 35 | const fn = ct && ct.includes('application/json') ? 'json' : 'text'; 36 | if (!res.ok) { 37 | return res[fn]().then(x => { 38 | throw x; 39 | }); 40 | } else { 41 | if (res.status === 204) { 42 | return {}; 43 | } else { 44 | return res[fn](); 45 | } 46 | } 47 | }).finally(() => { 48 | PENDING_CALLS.delete(url); 49 | fire(ON_REQUEST_STOP, url); 50 | })).get(url); 51 | 52 | } 53 | 54 | } 55 | 56 | function fire(events, url) { 57 | for (const event of events) { 58 | if (event.includes === '*' || url.includes(event.includes)) { 59 | event.handler(); 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/modules/state/internal/ReactiveWrapper.js: -------------------------------------------------------------------------------- 1 | import { deepClone } from "../../utils/deep-clone.js"; 2 | 3 | export class ReactiveWrapper { 4 | 5 | constructor(internal) { 6 | Object.defineProperty(this, '$$', { 7 | value: internal 8 | }); 9 | } 10 | 11 | get(key) { 12 | if (key === void 0) { 13 | return this.$$.getData(false); 14 | } else { 15 | return this.$$.getDatum(key, false); 16 | } 17 | } 18 | 19 | default(key) { 20 | // return deep clone of writable default values 21 | if (key === void 0) { 22 | return deepClone(this.$$.getDefaultData()); 23 | } else { 24 | return deepClone(this.$$.getDefaultDatum(key)); 25 | } 26 | } 27 | 28 | snapshot(key) { 29 | 30 | // return a deep clone of writable data 31 | if (key === void 0) { 32 | 33 | // getData(true) already returns a shallow copy... 34 | const copy = this.$$.getData(true); 35 | 36 | // ...make it deep 37 | if (Array.isArray(copy)) { 38 | for (let i = 0; i < copy.length; i++) { 39 | copy[i] = deepClone(copy[i]); 40 | } 41 | } else { 42 | for (const key in copy) { 43 | if (copy.hasOwnProperty(key)) { 44 | copy[key] = deepClone(copy[key]); 45 | } 46 | } 47 | } 48 | 49 | return copy; 50 | 51 | } else { 52 | 53 | return deepClone(this.$$.getDatum(key, true)); 54 | 55 | } 56 | 57 | } 58 | 59 | set(key, value) { 60 | if (typeof key === 'object') { 61 | this.$$.setData(key); 62 | } else { 63 | this.$$.setDatum(key, value); 64 | } 65 | } 66 | 67 | reset(key) { 68 | if (key === void 0) { 69 | this.set(this.default()); 70 | } else { 71 | this.set(key, this.default(key)); 72 | } 73 | } 74 | 75 | } -------------------------------------------------------------------------------- /src/modules/state/internal/ReactiveObjectModel.js: -------------------------------------------------------------------------------- 1 | import { Core } from "./Core.js"; 2 | import { ComputedProperty } from "./ComputedProperty.js"; 3 | 4 | export class ReactiveObjectModel { 5 | 6 | constructor(properties) { 7 | 8 | this.instances = 0; 9 | this.nativeData = {}; 10 | this.privateKeys = new Set(); 11 | this.boundProperties = new Map(); 12 | this.computedProperties = new Map(); 13 | 14 | let isPrivate; 15 | for (const key in properties) { 16 | 17 | if (properties.hasOwnProperty(key)) { 18 | 19 | if (key.indexOf('_') === 0) { 20 | this.privateKeys.add(key); 21 | isPrivate = true; 22 | } else { 23 | isPrivate = false; 24 | } 25 | 26 | const value = properties[key]; 27 | 28 | if (value?._isBinding_) { 29 | // It is possible to attach bindings to private, readonly computed properties. 30 | // It is not possible to attach bindings to non-computed private properties 31 | // since the private key value would leak to the bindings source object. 32 | if (isPrivate && !value.readonly) { 33 | throw new Error(`Can not bind("${value.ownPropertyName}") to private key "${key}" because it is only accessible by this object.`); 34 | } else { 35 | this.boundProperties.set(key, value); 36 | } 37 | } else if (typeof value === 'function') { 38 | this.computedProperties.set(key, new ComputedProperty(key, isPrivate, value, [], null)); 39 | } else { 40 | this.nativeData[key] = value; 41 | } 42 | 43 | } 44 | 45 | } 46 | 47 | if (this.computedProperties.size) { 48 | this.computedProperties = Core.setupComputedProperties(properties, this.computedProperties); 49 | } 50 | 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const {series, src, dest} = require('gulp'); 2 | const rollup = require('gulp-better-rollup'); 3 | const minify = require('gulp-terser'); 4 | const rename = require('gulp-rename'); 5 | const iife = require('gulp-iife'); 6 | const footer = require('gulp-footer'); 7 | const removeCode = require('gulp-remove-code'); 8 | 9 | function buildIIFE() { 10 | return src('src/index.js') 11 | .pipe(rollup({}, 'esm')) 12 | .pipe(rename('sekoia.js')) 13 | .pipe(iife({ 14 | useStrict: false, 15 | trimCode: true, 16 | prependSemicolon: false, 17 | bindThis: false, 18 | params: ['window'], 19 | args: ['window || this'] 20 | })) 21 | .pipe(dest('build')); 22 | } 23 | 24 | function minifyIIFE() { 25 | return src('build/sekoia.js') 26 | .pipe(minify({ 27 | mangle: { 28 | toplevel: true, 29 | keep_fnames: false, 30 | properties: { 31 | regex: new RegExp('^__') // properties and methods starting with two underscores are mangled 32 | } 33 | }, 34 | output: { 35 | comments: false 36 | } 37 | })) 38 | .pipe(rename('sekoia.min.js')) 39 | .pipe(dest('build')); 40 | } 41 | 42 | function buildModule() { 43 | return src('src/index.js') 44 | .pipe(rollup({}, 'esm')) 45 | .pipe(removeCode({esModule: true})) 46 | .pipe(footer(`export { 47 | createElement, 48 | defineComponent, 49 | onResize, 50 | onDragOver, 51 | renderList, 52 | Router, 53 | deleteRequest, 54 | getRequest, 55 | onRequestStart, 56 | onRequestStop, 57 | postRequest, 58 | putRequest, 59 | createState, 60 | PersistentStorage, 61 | ReactiveArray, 62 | ReactiveObject, 63 | deepClone, 64 | deepEqual, 65 | hashString, 66 | throttle, 67 | defer 68 | }`)) 69 | .pipe(rename('sekoia.module.js')) 70 | .pipe(dest('build')); 71 | } 72 | 73 | exports.build = series(buildIIFE, minifyIIFE, buildModule); 74 | -------------------------------------------------------------------------------- /src/modules/state/internal/ComputedProperty.js: -------------------------------------------------------------------------------- 1 | import { deepEqual } from "../../utils/deep-equal.js"; 2 | 3 | export class ComputedProperty { 4 | 5 | constructor(ownPropertyName, isPrivate, computation, sourceProperties, sourceProxy) { 6 | 7 | this.ownPropertyName = ownPropertyName; 8 | this.isPrivate = isPrivate; 9 | this.computation = computation; // the function that computes a result from data points on the source 10 | 11 | // Dependency Graph 12 | this.sourceProperties = sourceProperties; // property names this computedProperty depends on 13 | this.sourceProxy = sourceProxy; // proxy object 14 | 15 | // Value Cache 16 | this.intermediate = void 0; // intermediate computation result 17 | this.value = void 0; // current computation result 18 | 19 | // Optimization flags 20 | this.needsUpdate = true; // flag indicating that one or many dependencies have updated and value needs to re-compute 21 | this.hasChanged = false; // flag indicating that the computation has yielded a new result (used by event-queue) 22 | 23 | } 24 | 25 | clone(sourceProxy) { 26 | return new this.constructor(this.ownPropertyName, this.isPrivate, this.computation, this.sourceProperties, sourceProxy); 27 | } 28 | 29 | getValue() { 30 | 31 | if (this.needsUpdate) { // re-compute because dependencies have updated 32 | 33 | // call computation with first argument = source data proxy, second argument = current value 34 | this.intermediate = this.computation(this.sourceProxy, this.value); 35 | 36 | if (!deepEqual(this.intermediate, this.value)) { 37 | 38 | // Computations should never produce side-effects (non-enforced convention) 39 | // so we don't have to do defensive cloning here. Just swap the pointer or primitive. 40 | this.value = this.intermediate; 41 | 42 | this.hasChanged = true; 43 | 44 | } else { 45 | 46 | this.hasChanged = false; 47 | 48 | } 49 | 50 | this.needsUpdate = false; 51 | 52 | } 53 | 54 | return this.value; 55 | 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /src/modules/state/internal/StateTracker.js: -------------------------------------------------------------------------------- 1 | import { deepEqual } from "../../utils/deep-equal.js"; 2 | 3 | export class StateTracker { 4 | 5 | constructor(onTrack, maxEntries = 100) { 6 | this.__stack = []; 7 | this.__index = 0; 8 | this.__recursive = false; 9 | this.__max = maxEntries; 10 | this.__onTrack = onTrack; 11 | } 12 | 13 | prev() { 14 | return this.__index - 1; 15 | } 16 | 17 | next() { 18 | return this.__index + 1; 19 | } 20 | 21 | has(index) { 22 | if (index < 0 || !this.__stack.length) { 23 | return false; 24 | } else { 25 | return index <= this.__stack.length - 1; 26 | } 27 | } 28 | 29 | get(index) { 30 | 31 | if (index !== this.__index) { 32 | 33 | this.__recursive = true; 34 | this.__index = index; 35 | 36 | if (this.__onTrack) { 37 | // callback value, index, length 38 | this.__onTrack(this.__stack[index], index, this.__stack.length); 39 | } 40 | 41 | } 42 | 43 | return this.__stack[index]; 44 | 45 | } 46 | 47 | add(state, checkUniqueness) { 48 | 49 | if (this.__recursive) { 50 | 51 | this.__recursive = false; 52 | 53 | } else { 54 | 55 | state = state?.$$ ? state.snapshot() : state; 56 | 57 | if (checkUniqueness && deepEqual(state, this.__stack[this.__index])) { 58 | 59 | return false; 60 | 61 | } else { 62 | 63 | // history modification: remove everything after this point 64 | if (this.__index + 1 < this.__stack.length) { 65 | this.__stack.splice(this.__index + 1, this.__stack.length - this.__index - 1); 66 | } 67 | 68 | // maxed out: remove items from beginning 69 | if (this.__stack.length === this.__max) { 70 | this.__stack.shift(); 71 | } 72 | 73 | // append and move marker to last position 74 | this.__stack.push(state); 75 | this.__index = this.__stack.length - 1; 76 | 77 | if (this.__onTrack) { 78 | this.__onTrack(this.__stack[this.__index], this.__index, this.__stack.length); 79 | } 80 | 81 | return true; 82 | 83 | } 84 | 85 | } 86 | 87 | } 88 | 89 | } -------------------------------------------------------------------------------- /src/modules/state/internal/Queue.js: -------------------------------------------------------------------------------- 1 | const REQUEST = window.requestAnimationFrame; 2 | 3 | export const Queue = { 4 | 5 | tick: 0, 6 | keyEvents: new Map(), 7 | wildcardEvents: new Map(), 8 | computedProperties: new Set(), 9 | dependencies: new Set(), 10 | resolvedComputedProperties: new Set(), 11 | 12 | flush() { 13 | if (!this.__scheduled) { 14 | this.__scheduled = REQUEST(this.__flushOnNextTick); 15 | } 16 | }, 17 | 18 | throttle(fn, interval) { 19 | // creates a function throttled to internal flush tick 20 | let last = -9999999; 21 | return arg => { 22 | const now = this.tick; 23 | if (now - last > interval) { 24 | last = now; 25 | return fn(arg); 26 | } 27 | } 28 | }, 29 | 30 | // ----------------------------------- 31 | 32 | __scheduled: 0, 33 | __keyEvents: new Map(), 34 | __wildcardEvents: new Map(), 35 | 36 | __flushOnNextTick(tick) { 37 | 38 | this.tick = tick; 39 | 40 | const keyEvents = this.keyEvents; 41 | 42 | if (keyEvents.size) { 43 | 44 | // swap public buffer so events can re-populate 45 | // it in recursive write operations 46 | this.keyEvents = this.__keyEvents; 47 | this.__keyEvents = keyEvents; 48 | 49 | for (const [callback, value] of keyEvents) { 50 | callback(value); 51 | keyEvents.delete(callback); 52 | } 53 | 54 | } 55 | 56 | const wildcardEvents = this.wildcardEvents; 57 | 58 | if (wildcardEvents.size) { 59 | 60 | this.wildcardEvents = this.__wildcardEvents; 61 | this.__wildcardEvents = wildcardEvents; 62 | 63 | for (const [callback, owner] of wildcardEvents) { 64 | callback(owner); 65 | wildcardEvents.delete(callback); 66 | } 67 | 68 | } 69 | 70 | // events can re-populate these buffers because they are 71 | // allowed to change state in reaction to another state change. 72 | if (this.keyEvents.size || this.wildcardEvents.size) { 73 | this.__flushOnNextTick(tick); // pass same rAF tick 74 | } else { 75 | this.__scheduled = 0; 76 | } 77 | 78 | } 79 | 80 | }; 81 | 82 | Queue.__flushOnNextTick = Queue.__flushOnNextTick.bind(Queue); -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Component 2 | import { createElement } from "./modules/component/create-element.js"; 3 | import { defineComponent } from "./modules/component/define-component.js"; 4 | import { onResize } from "./modules/component/on-resize.js"; 5 | import { onDragOver } from "./modules/component/on-dragover.js"; 6 | import { renderList } from "./modules/component/render-list.js"; 7 | 8 | // Router 9 | import { Router } from "./modules/router/router.js"; 10 | 11 | // Server 12 | import { deleteRequest } from "./modules/server/delete-request.js"; 13 | import { getRequest } from "./modules/server/get-request.js"; 14 | import { onRequestStart, onRequestStop } from "./modules/server/on-request.js"; 15 | import { postRequest } from "./modules/server/post-request.js"; 16 | import { putRequest } from "./modules/server/put-request.js"; 17 | 18 | // Store 19 | import { createState } from "./modules/state/create-state.js"; 20 | import { PersistentStorage } from "./modules/state/PersistentStorage.js"; 21 | import { ReactiveArray } from "./modules/state/ReactiveArray.js"; 22 | import { ReactiveObject } from "./modules/state/ReactiveObject.js"; 23 | 24 | // Utils 25 | import { deepClone } from "./modules/utils/deep-clone.js"; 26 | import { deepEqual } from "./modules/utils/deep-equal.js"; 27 | import { hashString } from "./modules/utils/hash-string.js"; 28 | import { throttle } from "./modules/utils/throttle.js"; 29 | import { defer } from "./modules/utils/defer.js"; 30 | 31 | //removeIf(esModule) 32 | const Sekoia = { 33 | createElement, 34 | defineComponent, 35 | onResize, 36 | onDragOver, 37 | renderList, 38 | Router, 39 | deleteRequest, 40 | getRequest, 41 | onRequestStart, 42 | onRequestStop, 43 | postRequest, 44 | putRequest, 45 | createState, 46 | PersistentStorage, 47 | ReactiveArray, 48 | ReactiveObject, 49 | deepClone, 50 | deepEqual, 51 | hashString, 52 | throttle, 53 | defer 54 | }; 55 | 56 | if (typeof module === 'object' && typeof module.exports === 'object') { 57 | module.exports = Sekoia; 58 | } else if (typeof define === 'function' && define.amd) { 59 | define('Sekoia', [], function() { 60 | return Sekoia; 61 | }); 62 | } else { 63 | window.Sekoia = Sekoia; 64 | } 65 | //endRemoveIf(esModule) 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | Scalable reactive application architecture built on living web standards with a focus on micro-optimized performance, semantic APIs and customElements that don't suck. 🥺 👉👈 4 |

5 | All major browsers 6 |
Sekoia.js // Vanilla ES6 - All major browsers
7 | 8 | ### 🧬 Micro-optimized reactivity engine 9 | Sekoia is powered by an advanced reactivity engine that enhances serializable data with first-class observability, time travel, shape and type consistency and insanely optimized state diffing. 10 |
11 | - [Documentation](./src/modules/state) 12 | - [TodoMVC Live Demo](https://codepen.io/monokee/project/editor/XxWGbV#) 13 | - [TodoMVC Source](./examples/TODO%20MVC) 14 | 15 | ### ⚡️ Data driven custom elements 16 | Sekoia gives your UI code structure by making native customElements reactive and composable while facilitating architecturally clean access to the DOM. 17 |
18 | - [Documentation](./src/modules/component) 19 | - [TodoMVC Live Demo](https://codepen.io/monokee/project/editor/XxWGbV#) 20 | - [TodoMVC Source](./examples/TODO%20MVC) 21 | 22 | *** 23 | 24 | ### Router 25 | Pretty advanced hash-based router for SPAs with reactive route actions and 26 | route filtering for conditional re-routes. I don't have time to document it rn. 27 | 28 | ### Server 29 | Simple REST API helper with request buffering and indexedDB caching plus cache expiration. 30 | Nothing too fancy. 31 | 32 | ### Utils 33 | Common functions that are shared by internal modules and are also useful for implementation 34 | code of high-performance web apps. 35 | 36 | ### License 37 | ``` 38 | Sekoia.js 39 | Copyright (C) 2022 Jonathan M. Ochmann 40 | 41 | This program is free software: you can redistribute it and/or modify 42 | it under the terms of the GNU General Public License as published by 43 | the Free Software Foundation, either version 3 of the License, or 44 | (at your option) any later version. 45 | 46 | This program is distributed in the hope that it will be useful, 47 | but WITHOUT ANY WARRANTY; without even the implied warranty of 48 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 49 | GNU General Public License for more details. 50 | 51 | You should have received a copy of the GNU General Public License 52 | along with this program. If not, see https://www.gnu.org/licenses. 53 | ``` 54 | *** 55 | ### Author 56 | Jonathan M. Ochmann (@monokee) 57 | 63 | 64 | *** 65 | Made with ♥️ in CGN | (C) Patchflyer GmbH 2014-2049 -------------------------------------------------------------------------------- /examples/TODO MVC/components/placeholder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /examples/TODO MVC/app.js: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "../../src/modules/component/define-component.js"; 2 | import { TodoEditor } from "./components/todo-editor.js"; 3 | 4 | defineComponent('todo-mvc', { 5 | 6 | element: (` 7 |
8 |

9 | logo 10 |
11 | ${TodoEditor()} 12 | 16 | `), 17 | 18 | style: (` 19 | :root * { 20 | box-sizing: border-box!important; 21 | } 22 | :root body{ 23 | width: 100vw; 24 | height: 100vh; 25 | margin:0; 26 | font-family: Roboto, sans-serif; 27 | color: rgb(232, 235, 238); 28 | background-color: rgb(22,25,28); 29 | user-select: none; 30 | } 31 | :root ::-webkit-scrollbar{ 32 | width: 3px; 33 | } 34 | :root ::-webkit-scrollbar-track{ 35 | background: transparent; 36 | } 37 | :root ::-webkit-scrollbar-thumb{ 38 | background:rgba(235,235,235,0.35); 39 | border-radius: 3px; 40 | } 41 | $self { 42 | position: relative; 43 | width: clamp(15rem, 90%, 50rem); 44 | margin-left: auto; 45 | margin-right: auto; 46 | padding: 1.5rem 1.5rem 5.5rem 1.5rem; 47 | height: 100%; 48 | overflow: hidden; 49 | display: flex; 50 | flex-direction: column; 51 | align-items: center; 52 | justify-content: flex-start; 53 | } 54 | header { 55 | position: relative; 56 | width: 100%; 57 | display: flex; 58 | align-items: center; 59 | justify-content: space-between; 60 | margin-bottom: 3.5rem; 61 | } 62 | $headline { 63 | font-weight: 300; 64 | } 65 | $logo { 66 | height: 5.5em; 67 | object-fit: cover; 68 | border-radius: 16px; 69 | } 70 | $editorContainer { 71 | position: relative; 72 | z-index: 1; 73 | width: 650px; 74 | max-width: 95%; 75 | box-shadow: none; 76 | } 77 | footer { 78 | margin-top: auto; 79 | margin-bottom: 1em; 80 | font-size: 0.85em; 81 | opacity: 0.5; 82 | display: flex; 83 | flex-direction: column; 84 | align-items: center; 85 | justify-content: center; 86 | } 87 | footer p { 88 | margin: 0; 89 | } 90 | `), 91 | 92 | state: { 93 | 94 | appTitle: { 95 | value: 'Todo MVC', 96 | render({$headline}, value) { 97 | $headline.textContent = value; 98 | } 99 | }, 100 | 101 | appLogo: { 102 | value: '../../logo.jpg', 103 | render({$logo}, value) { 104 | $logo.src = value; 105 | } 106 | }, 107 | 108 | appAuthor: { 109 | value: 'monokee', 110 | render({$author}, value) { 111 | $author.textContent = `Written by ${value}`; 112 | } 113 | }, 114 | 115 | footerInfo: { 116 | value: 'No rights reserved.', 117 | render({$info}, value) { 118 | $info.textContent = value; 119 | } 120 | } 121 | 122 | } 123 | 124 | }); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Gitignore - monokee - color.io 3 | 4 | #Custom Excludes: 5 | 6 | ### Dreamweaver ### 7 | # DW Dreamweaver added files 8 | _notes 9 | _compareTemp 10 | configs/ 11 | dwsync.xml 12 | dw_php_codehinting.config 13 | *.mno 14 | 15 | ### Nwjs ### 16 | # NW.js (node-webkit) binary files to be excluded from git 17 | # https://nwjs.io/ 18 | # https://github.com/nwjs/nw.js 19 | 20 | ## Seen in standard and sdk versions 21 | credits.html 22 | locales/ 23 | libEGL.dll 24 | libGLEv2.dll 25 | node.dll 26 | nw.dll 27 | nw.exe 28 | natives_blob.bin 29 | nw_100_percent.pak 30 | nw_200_percent.pak 31 | nw_elf.dll 32 | snapshot_blob.bin 33 | resources.pak 34 | 35 | 36 | ## Seen only in standard 37 | d3dcompiler_47.dll 38 | ffmpeg.dll 39 | icudtl.dat 40 | 41 | 42 | ## Seen only in sdk 43 | pnacl/ 44 | chromedriver.exe 45 | nacl_irt_x86_64.nexe 46 | nwjc.exe 47 | payload.exe 48 | 49 | ### OSX ### 50 | *.DS_Store 51 | .AppleDouble 52 | .LSOverride 53 | 54 | # Icon must end with two \r 55 | Icon 56 | 57 | # Thumbnails 58 | ._* 59 | 60 | # Files that might appear in the root of a volume 61 | .DocumentRevisions-V100 62 | .fseventsd 63 | .Spotlight-V100 64 | .TemporaryItems 65 | .Trashes 66 | .VolumeIcon.icns 67 | .com.apple.timemachine.donotpresent 68 | 69 | # Directories potentially created on remote AFP share 70 | .AppleDB 71 | .AppleDesktop 72 | Network Trash Folder 73 | Temporary Items 74 | .apdisk 75 | 76 | ### WebStorm ### 77 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 78 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 79 | 80 | .idea 81 | # User-specific stuff: 82 | .idea/**/workspace.xml 83 | .idea/**/tasks.xml 84 | 85 | # Sensitive or high-churn files: 86 | .idea/**/dataSources/ 87 | .idea/**/dataSources.ids 88 | .idea/**/dataSources.xml 89 | .idea/**/dataSources.local.xml 90 | .idea/**/sqlDataSources.xml 91 | .idea/**/dynamic.xml 92 | .idea/**/uiDesigner.xml 93 | 94 | # Gradle: 95 | .idea/**/gradle.xml 96 | .idea/**/libraries 97 | 98 | # CMake 99 | cmake-build-debug/ 100 | 101 | # Mongo Explorer plugin: 102 | .idea/**/mongoSettings.xml 103 | 104 | ## File-based project format: 105 | *.iws 106 | 107 | ## Plugin-specific files: 108 | 109 | # IntelliJ 110 | /out/ 111 | 112 | # mpeltonen/sbt-idea plugin 113 | .idea_modules/ 114 | 115 | # JIRA plugin 116 | atlassian-ide-plugin.xml 117 | 118 | # Cursive Clojure plugin 119 | .idea/replstate.xml 120 | 121 | # Crashlytics plugin (for Android Studio and IntelliJ) 122 | com_crashlytics_export_strings.xml 123 | crashlytics.properties 124 | crashlytics-build.properties 125 | fabric.properties 126 | 127 | ### WebStorm Patch ### 128 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 129 | 130 | # *.iml 131 | # modules.xml 132 | # .idea/misc.xml 133 | # *.ipr 134 | 135 | # Sonarlint plugin 136 | .idea/sonarlint 137 | 138 | ### Windows ### 139 | # Windows thumbnail cache files 140 | Thumbs.db 141 | ehthumbs.db 142 | ehthumbs_vista.db 143 | 144 | # Folder config file 145 | Desktop.ini 146 | 147 | # Recycle Bin used on file shares 148 | $RECYCLE.BIN/ 149 | 150 | # Windows Installer files 151 | *.cab 152 | *.msi 153 | *.msm 154 | *.msp 155 | 156 | # Windows shortcuts 157 | *.lnk 158 | 159 | # End of Gitignore 160 | -------------------------------------------------------------------------------- /src/modules/component/internal/ComponentElement.js: -------------------------------------------------------------------------------- 1 | import { Queue } from "../../state/internal/Queue.js"; 2 | import { renderList } from "../render-list.js"; 3 | import { StateProvider } from "./StateProvider.js"; 4 | import { ReactiveObject } from "../../state/ReactiveObject.js"; 5 | 6 | export class ComponentElement extends HTMLElement { 7 | 8 | constructor(model) { 9 | 10 | super(); 11 | 12 | Object.defineProperties(this, { 13 | _initialized_: { 14 | value: false, 15 | writable: true 16 | }, 17 | _model_: { 18 | value: model 19 | } 20 | }); 21 | 22 | } 23 | 24 | connectedCallback() { 25 | 26 | if (this._initialized_ === false) { 27 | 28 | this._initialized_ = true; 29 | 30 | const MODEL = this._model_; 31 | 32 | const REFS = this.refs = new Proxy({$self: this}, { 33 | get: (target, key) => { 34 | return target[key] || (target[key] = this.getElementsByClassName(MODEL.refs.get(key))[0]); 35 | } 36 | }); 37 | 38 | // compile template 39 | MODEL.compileTemplateOnce(); 40 | 41 | // create inner component markup 42 | this.appendChild(MODEL.content.cloneNode(true)); 43 | 44 | if (MODEL.setupStateOnce()) { 45 | 46 | if (this.hasAttribute('provided-state')) { 47 | 48 | this.state = StateProvider.popState(this.getAttribute('provided-state')); 49 | this.removeAttribute('provided-state'); 50 | 51 | } else { 52 | 53 | this.state = this.state || ReactiveObject._from_(MODEL.state); 54 | 55 | if (this.hasAttribute('composed-state-data')) { 56 | this.state.$$.setData(StateProvider.popState(this.getAttribute('composed-state-data')), false); 57 | this.removeAttribute('composed-state-data'); 58 | } 59 | 60 | } 61 | 62 | // Register render callbacks 63 | for (const [key, callback] of MODEL.renderEvents) { 64 | // simple granular render functions: render({...$ref}, currentValue) 65 | this.state.$$.observe(key, value => callback(REFS, value)); 66 | } 67 | 68 | // Create automatic list renderings 69 | for (const [key, config] of MODEL.renderListConfigs) { 70 | 71 | const cfg = { 72 | parentElement: REFS[config.parentElement], 73 | createChild: config.createChild, 74 | updateChild: config.updateChild 75 | }; 76 | 77 | const reactiveArray = this.state.$$.getDatum(key); 78 | reactiveArray.$$.setStructuralObserver(value => { 79 | renderList(value, cfg); 80 | }); 81 | 82 | } 83 | 84 | } 85 | 86 | if (MODEL.initialize) { 87 | // schedule as wildcard handler so that init is called after everything else 88 | Queue.wildcardEvents.set(MODEL.initialize.bind(this), REFS); 89 | } 90 | 91 | } 92 | 93 | } 94 | 95 | cloneNode(withState = false) { 96 | 97 | if (withState && !this._initialized_) { 98 | throw new Error('Cannot clone component with state before initialization.'); 99 | } 100 | 101 | const instance = document.createElement(this.tagName); 102 | 103 | // copy top level attributes 104 | for (let i = 0, attribute; i < this.attributes.length; i++) { 105 | attribute = this.attributes[i]; 106 | instance.setAttribute(attribute.nodeName, attribute.nodeValue); 107 | } 108 | 109 | // copy state if required 110 | withState && this.state && instance.setAttribute( 111 | 'composed-state-data', 112 | StateProvider.setState(this.state.snapshot()) 113 | ); 114 | 115 | return instance; 116 | 117 | } 118 | 119 | } -------------------------------------------------------------------------------- /src/modules/state/ReactiveObject.js: -------------------------------------------------------------------------------- 1 | import { ReactiveObjectModel } from "./internal/ReactiveObjectModel.js"; 2 | import { ReactiveObjectInternals } from "./internal/ReactiveObjectInternals.js"; 3 | import { ReactiveWrapper } from "./internal/ReactiveWrapper.js"; 4 | import { StateTracker } from "./internal/StateTracker.js"; 5 | import { defer } from "../utils/defer.js"; 6 | import { Queue } from "./internal/Queue.js"; 7 | 8 | export class ReactiveObject extends ReactiveWrapper { 9 | 10 | static _from_(model, data) { 11 | 12 | const clone = Object.create(ReactiveObject.prototype); 13 | clone.$$ = new ReactiveObjectInternals(model); 14 | clone.$$.owner = clone; 15 | 16 | if (data) { 17 | clone.$$.setData(data, true); 18 | } 19 | 20 | return clone; 21 | 22 | } 23 | 24 | constructor(properties) { 25 | const model = new ReactiveObjectModel(properties); 26 | const internals = new ReactiveObjectInternals(model); 27 | super(internals); 28 | internals.owner = this; 29 | } 30 | 31 | clone(data) { 32 | return this.constructor._from_(this.$$.model, data); 33 | } 34 | 35 | observe(key, callback, options = {}) { 36 | 37 | if (typeof key === 'object') { 38 | 39 | // { ...key: callback } -> convenient but non cancelable, non silent 40 | for (const k in key) { 41 | if (key.hasOwnProperty(k)) { 42 | this.$$.observe(k, key[k], false, false); 43 | } 44 | } 45 | 46 | } else { 47 | 48 | if ((options.throttle || 0) > 0) { 49 | 50 | return this.$$.observe(key, Queue.throttle(callback, options.throttle), options.cancelable, options.silent); 51 | 52 | } else if ((options.defer || 0) > 0) { 53 | 54 | return this.$$.observe(key, defer(callback, options.defer), options.cancelable, options.silent); 55 | 56 | } else { 57 | 58 | return this.$$.observe(key, callback, options.cancelable, options.silent); 59 | 60 | } 61 | 62 | } 63 | 64 | } 65 | 66 | bind(key) { 67 | return this.$$.bind(key); 68 | } 69 | 70 | track(key, options = {}) { 71 | 72 | key || (key = '*'); 73 | 74 | const stateTrackers = this.$$.stateTrackers || (this.$$.stateTrackers = new Map()); 75 | 76 | if (stateTrackers.has(key)) { 77 | throw new Error(`Cannot track state of "${key}" because the property is already being tracked.`); 78 | } else if (this.$$.computedProperties.has(key)) { 79 | throw new Error(`Cannot track computed property "${key}". Only track writable properties.`); 80 | } 81 | 82 | const tracker = new StateTracker(options.onTrack, options.maxEntries); 83 | stateTrackers.set(key, tracker); 84 | 85 | // when tracking is throttled or deferred we have to check if the latest value 86 | // is different than the last value that was added to the tracker. this is because 87 | // state change detection is synchronous but when throttling or deferring, we might 88 | // trigger intermediate state changes but finally land on the initial state. By 89 | // setting a flag at install time we can avoid this check for all synchronous trackers. 90 | const checkUniqueness = (options.throttle || 0) > 0 || (options.defer || 0) > 0; 91 | 92 | // observer immediately tracks initial state 93 | return this.observe(key, val => tracker.add(val, checkUniqueness), options); 94 | 95 | } 96 | 97 | undo(key) { 98 | key || (key = '*'); 99 | this.restore(key, this.$$.stateTrackers?.get(key)?.prev()); 100 | } 101 | 102 | redo(key) { 103 | key || (key = '*'); 104 | this.restore(key, this.$$.stateTrackers?.get(key)?.next()); 105 | } 106 | 107 | restore(key, trackPosition) { 108 | 109 | if (trackPosition === void 0 && typeof key === 'number') { 110 | trackPosition = key; 111 | key = '*'; 112 | } 113 | 114 | const tracker = this.$$.stateTrackers?.get(key); 115 | 116 | if (tracker && tracker.has(trackPosition)) { 117 | if (key === '*') { 118 | this.$$.setData(tracker.get(trackPosition), false); 119 | } else { 120 | this.$$.setDatum(key, tracker.get(trackPosition), false); 121 | } 122 | } 123 | 124 | } 125 | 126 | } -------------------------------------------------------------------------------- /src/modules/state/PersistentStorage.js: -------------------------------------------------------------------------------- 1 | // In-Memory Fallback that mocks required IndexedDB patterns 2 | class AsyncMemoryRequest { 3 | 4 | constructor(result) { 5 | requestAnimationFrame(() => { 6 | this.onsuccess({target: { result: result }}) 7 | }); 8 | } 9 | 10 | onsuccess() {} 11 | 12 | } 13 | 14 | class MemoryObjectStore { 15 | 16 | constructor() { 17 | this.__data = new Map(); 18 | } 19 | 20 | get(key) { 21 | return new AsyncMemoryRequest(this.__data.get(key)); 22 | } 23 | 24 | getAll() { 25 | return new AsyncMemoryRequest(this.__data); 26 | } 27 | 28 | put(value, key) { 29 | return new AsyncMemoryRequest(this.__data.set(key, value)); 30 | } 31 | 32 | delete(key) { 33 | return new AsyncMemoryRequest(this.__data.delete(key)); 34 | } 35 | 36 | clear() { 37 | return new AsyncMemoryRequest(this.__data.clear()) 38 | } 39 | 40 | } 41 | 42 | class IndexedMemoryStorage { 43 | 44 | constructor() { 45 | 46 | const memo = new MemoryObjectStore(); 47 | 48 | this.__transaction = { 49 | objectStore: () => memo 50 | }; 51 | 52 | } 53 | 54 | transaction() { 55 | return this.__transaction; 56 | } 57 | 58 | } 59 | 60 | const OBJECT_STORE = 'store'; 61 | const TRANSACTION = [OBJECT_STORE]; 62 | 63 | // IndexedDB Abstraction that can be used like async Web Storage 64 | export class PersistentStorage { 65 | 66 | constructor(options = {}) { 67 | 68 | options = Object.assign({}, { 69 | name: location.origin, 70 | onUnavailable: null 71 | }, options); 72 | 73 | this.__name = options.name; 74 | 75 | this.__ready = new Promise(resolve => { 76 | 77 | try { 78 | 79 | let request = window.indexedDB.open(this.__name); 80 | let database; 81 | 82 | request.onupgradeneeded = e => { 83 | database = e.target.result; 84 | database.createObjectStore(OBJECT_STORE); 85 | }; 86 | 87 | request.onsuccess = e => { 88 | database = e.target.result; 89 | resolve(database); 90 | }; 91 | 92 | request.onerror = e => { 93 | database = new IndexedMemoryStorage(); 94 | resolve(database); 95 | } 96 | 97 | } catch(e) { 98 | 99 | console.warn('[PersistentStorage]: indexedDB not available. Falling back to memory.', e); 100 | typeof options.onUnavailable === 'function' && options.onUnavailable(e); 101 | resolve(new IndexedMemoryStorage()); 102 | 103 | } 104 | 105 | }); 106 | 107 | } 108 | 109 | has(key) { 110 | return this.__ready.then(db => new Promise((resolve, reject) => { 111 | const request = db.transaction(TRANSACTION, 'readonly').objectStore(OBJECT_STORE).count(key); 112 | request.onsuccess = e => resolve(!!e.target.result); 113 | request.onerror = reject; 114 | })); 115 | } 116 | 117 | get(key) { 118 | return this.__ready.then(db => new Promise((resolve, reject) => { 119 | const store = db.transaction(TRANSACTION, 'readonly').objectStore(OBJECT_STORE); 120 | const request = key === void 0 ? store.getAll() : store.get(key); 121 | request.onsuccess = e => resolve(e.target.result); 122 | request.onerror = reject; 123 | })); 124 | } 125 | 126 | set(key, value) { 127 | return this.__ready.then(db => new Promise((resolve, reject) => { 128 | const request = db.transaction(TRANSACTION, 'readwrite'); 129 | const store = request.objectStore(OBJECT_STORE); 130 | if (typeof key === 'object') { 131 | for (const k in key) { 132 | if (key.hasOwnProperty(k)) { 133 | store.put(key[k], k); 134 | } 135 | } 136 | } else { 137 | store.put(value, key); 138 | } 139 | request.onsuccess = resolve; 140 | request.onerror = reject; 141 | })); 142 | } 143 | 144 | delete(key) { 145 | 146 | if (key === void 0) { 147 | return this.clear(); 148 | } 149 | 150 | return this.__ready.then(db => new Promise((resolve, reject) => { 151 | const request = db.transaction(TRANSACTION, 'readwrite'); 152 | const store = request.objectStore(OBJECT_STORE); 153 | if (Array.isArray(key)) { 154 | key.forEach(k => store.delete(k)); 155 | } else { 156 | store.delete(key); 157 | } 158 | request.onsuccess = resolve; 159 | request.onerror = reject; 160 | })); 161 | 162 | } 163 | 164 | clear() { 165 | return this.__ready.then(db => new Promise((resolve, reject) => { 166 | const request = db.transaction(TRANSACTION, 'readwrite').objectStore(OBJECT_STORE).clear(); 167 | request.onsuccess = resolve; 168 | request.onerror = reject; 169 | })); 170 | } 171 | 172 | destroy() { 173 | return this.__ready.then(db => new Promise((resolve, reject) => { 174 | db.close(); 175 | const request = window.indexedDB.deleteDatabase(this.__name); 176 | request.onsuccess = resolve; 177 | request.onerror = reject; 178 | })); 179 | } 180 | 181 | } -------------------------------------------------------------------------------- /examples/TODO MVC/components/todo-item.js: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "../../../src/modules/component/define-component.js"; 2 | 3 | export const TodoItem = defineComponent('todo-item', { 4 | 5 | element: (` 6 |
7 |
8 |
9 |
10 |
11 | 12 |
edit
13 | `), 14 | 15 | style: (` 16 | $self { 17 | position: relative; 18 | overflow: hidden; 19 | width: 100%; 20 | flex: 0 0 auto; 21 | display: flex; 22 | justify-content: space-between; 23 | align-items: center; 24 | padding: 1.5em; 25 | background-color: #262b32; 26 | border-radius: 8px; 27 | border: 1px solid transparent; 28 | animation: splash 350ms normal forwards ease-in-out; 29 | animation-iteration-count: 1; 30 | } 31 | $self:first-child { 32 | 33 | } 34 | $self.selected { 35 | border-color: white; 36 | } 37 | $self::before { 38 | position: absolute; 39 | left: 0; 40 | z-index: 0; 41 | content: ''; 42 | width: 100%; 43 | height: 100%; 44 | background-color: rgba(0, 255, 0, 0.1); 45 | opacity: 0; 46 | transition: opacity 350ms ease-in-out; 47 | } 48 | $self.complete::before { 49 | opacity: 1; 50 | } 51 | $text { 52 | position: relative; 53 | height: 100%; 54 | background: transparent; 55 | border: 0; 56 | outline: 0; 57 | color: inherit; 58 | pointer-events: none; 59 | opacity: 1; 60 | text-align: center; 61 | transition: opacity 250ms; 62 | } 63 | $text.editing { 64 | pointer-events: auto; 65 | } 66 | $text:focus { 67 | outline: 0; 68 | border: 0; 69 | } 70 | $self.complete $text { 71 | opacity: 0.65; 72 | } 73 | $editButton { 74 | position: relative; 75 | font-size: 0.7em; 76 | font-weight: bold; 77 | text-decoration: underline; 78 | opacity: 0.3; 79 | transition: opacity 150ms; 80 | cursor: pointer; 81 | } 82 | $self:hover $editButton, 83 | $self:hover $checkbox { 84 | opacity: 1; 85 | } 86 | $self.complete $editButton { 87 | opacity: 0; 88 | pointer-events: none; 89 | } 90 | $checkbox { 91 | position: relative; 92 | display: inline-block; 93 | width: var(--iconSize); 94 | height: var(--iconSize); 95 | line-height: var(--iconSize); 96 | cursor: pointer; 97 | opacity: 0.3; 98 | transition: opacity 150ms; 99 | --iconSize: 16px; 100 | --iconHalf: calc(var(--iconSize) / 2); 101 | } 102 | $checkbox .bullet, 103 | $checkbox .tick { 104 | position: absolute; 105 | width: var(--iconSize); 106 | height: var(--iconSize); 107 | border-radius: 50%; 108 | } 109 | $checkbox .bullet { 110 | border: 3px solid #99a3ac; 111 | } 112 | $checkbox .tick { 113 | background: #fff; 114 | opacity: 0; 115 | transform: scale(0.33); 116 | transition: opacity 150ms, transform 150ms; 117 | } 118 | $checkbox.checked .tick { 119 | opacity: 1; 120 | transform: scale(0.55); 121 | } 122 | $checkbox .label { 123 | margin-left: 24px; 124 | padding-left: 12px; 125 | } 126 | @keyframes splash { 127 | from { 128 | opacity: 0; 129 | transform: scale(0.5, 0.5); 130 | } 131 | to { 132 | opacity: 1; 133 | transform: scale(1, 1); 134 | } 135 | } 136 | `), 137 | 138 | state: { 139 | 140 | text: { 141 | value: '', 142 | render({$text}, value) { 143 | $text.value = value; 144 | } 145 | }, 146 | 147 | complete: { 148 | value: false, 149 | render({$self, $checkbox, $editButton}, value) { 150 | $self.classList.toggle('complete', value); 151 | $checkbox.classList.toggle('checked', value); 152 | $editButton.classList.toggle('disabled', value); 153 | $self.setAttribute('data-complete', value); 154 | } 155 | }, 156 | 157 | _selected: { 158 | value: false, 159 | render({$self}, value) { 160 | $self.classList.toggle('selected', value); 161 | } 162 | }, 163 | 164 | _isEditing: { 165 | value: false, 166 | render({$self, $text, $editButton}, value) { 167 | $text.classList.toggle('editing', value); 168 | $editButton.textContent = value ? 'ok' : 'edit'; 169 | if (value) { 170 | $text.focus(); 171 | $text.select(); 172 | } else { 173 | $text.blur(); 174 | } 175 | } 176 | } 177 | 178 | }, 179 | 180 | initialize({$checkbox, $editButton, $text}) { 181 | 182 | this.addEventListener('click', e => { 183 | 184 | e.stopPropagation(); 185 | 186 | if ($checkbox.contains(e.target)) { 187 | 188 | const targetState = !this.state.get('complete'); 189 | this.state.set('complete', targetState); 190 | 191 | // handle completion on parent controller 192 | this.dispatchEvent(new CustomEvent('todo-item::complete', { 193 | bubbles: true, 194 | detail: { targetState } 195 | })); 196 | 197 | } else if ($editButton.contains(e.target)) { 198 | 199 | this.state.set('_isEditing', !this.state.get('_isEditing')); 200 | 201 | } else if (!this.state.get('_isEditing')) { 202 | 203 | // handle selection on parent controller 204 | this.dispatchEvent(new CustomEvent('todo-item::selected', { 205 | bubbles: true, 206 | detail: { itemState: this.state, originalEvent: e } 207 | })); 208 | 209 | } 210 | 211 | }); 212 | 213 | this.addEventListener('keyup', e => { 214 | if ($text.contains(e.target) && this.state.get('_isEditing')) { 215 | this.state.set('text', $text.value); 216 | if (e.key === 'Enter') { 217 | this.state.set('_isEditing', false); 218 | } 219 | } 220 | }); 221 | 222 | this.addEventListener('focusout', e => { 223 | if ($text.contains(e.target) && this.state.get('_isEditing')) { 224 | this.state.set('_isEditing', false); 225 | } 226 | }); 227 | 228 | } 229 | 230 | }); -------------------------------------------------------------------------------- /src/modules/state/internal/ReactiveArrayInternals.js: -------------------------------------------------------------------------------- 1 | import { Queue } from "./Queue.js"; 2 | import { deepClone } from "../../utils/deep-clone.js"; 3 | import { NOOP } from "./Noop.js"; 4 | 5 | export class ReactiveArrayInternals { 6 | 7 | constructor(sourceArray, options) { 8 | 9 | sourceArray || (sourceArray = []); 10 | 11 | this.nativeData = sourceArray; 12 | 13 | if (options?._model_) { // reuse (cloning) 14 | 15 | this.model = options._model_; 16 | 17 | } else if (typeof options?.model === 'function') { 18 | 19 | this.model = data => { 20 | const model = options.model(data); 21 | if (model?.$$) { 22 | model.$$.parentInternals = this; 23 | } 24 | return model; 25 | } 26 | 27 | } else { 28 | 29 | this.model = NOOP; 30 | 31 | } 32 | 33 | this.defaultData = []; 34 | 35 | for (let i = 0, item; i < sourceArray.length; i++) { 36 | item = sourceArray[i]; 37 | if (item?.$$) { 38 | item.$$.parentInternals = this; 39 | this.defaultData.push(deepClone(item.$$.getDefaultData())); 40 | } else { 41 | this.defaultData.push(deepClone(item)); 42 | } 43 | } 44 | 45 | this.wildcardEvents = []; 46 | this.events = new Map([['*', this.wildcardEvents]]); 47 | this.structuralObserver = NOOP; 48 | 49 | this.parentInternals = null; 50 | this.ownPropertyName = ''; 51 | 52 | } 53 | 54 | getDatum(index, writableOnly) { 55 | 56 | const item = this.nativeData[index]; 57 | 58 | if (writableOnly && item?.$$) { 59 | return item.$$.getData(writableOnly); 60 | } else { 61 | return item; 62 | } 63 | 64 | } 65 | 66 | getData(writableOnly) { 67 | 68 | const copy = []; 69 | 70 | for (let i = 0, item; i < this.nativeData.length; i++) { 71 | item = this.nativeData[i]; 72 | if (writableOnly && item?.$$) { 73 | copy.push(item.$$.getData(writableOnly)); 74 | } else { 75 | copy.push(item); 76 | } 77 | } 78 | 79 | return copy; 80 | 81 | } 82 | 83 | getDefaultDatum(index) { 84 | return this.defaultData[index]; 85 | } 86 | 87 | getDefaultData() { 88 | return this.defaultData; 89 | } 90 | 91 | setData(array, silent) { 92 | 93 | let didChange = array.length !== this.nativeData.length; 94 | 95 | this.nativeData.length = array.length; 96 | 97 | for (let i = 0, value, current; i < array.length; i++) { 98 | 99 | value = array[i]; 100 | current = this.nativeData[i]; 101 | 102 | if (current !== value) { 103 | 104 | if (current?.$$ && value && typeof value === 'object' && !value.$$) { 105 | 106 | current.$$.setData(value, silent); // patch object 107 | 108 | } else { // replace 109 | 110 | if (!value || value.$$ || typeof value !== 'object') { 111 | this.nativeData[i] = value; 112 | } else { 113 | this.nativeData[i] = this.model(value); 114 | } 115 | 116 | didChange = true; 117 | 118 | } 119 | 120 | } 121 | 122 | } 123 | 124 | if (didChange && !silent) { 125 | this.didMutate(); 126 | } 127 | 128 | } 129 | 130 | setDatum(index, value, silent) { 131 | 132 | const current = this.nativeData[index]; 133 | 134 | if (current !== value) { 135 | 136 | if (current?.$$ && value && typeof value === 'object' && !value.$$) { 137 | 138 | current.$$.setData(value, silent); // patch object 139 | 140 | } else { // replace 141 | 142 | if (!value || value.$$ || typeof value !== 'object') { 143 | this.nativeData[index] = value; 144 | } else { 145 | this.nativeData[index] = this.model(value); 146 | } 147 | 148 | if (!silent) { 149 | this.didMutate(); 150 | } 151 | 152 | } 153 | 154 | } 155 | 156 | } 157 | 158 | internalize(items) { 159 | for (let i = 0, item; i < items.length; i++) { 160 | item = items[i]; 161 | if (typeof item === 'object' && item && !item.$$) { 162 | items[i] = this.model(item); 163 | } 164 | } 165 | return items; 166 | } 167 | 168 | observe(wildcardKey, callback, unobservable, silent) { 169 | 170 | // ReactiveArrays have two types of observers: 171 | // (1) wildcard observers that fire on any array change, including public property 172 | // changes of nested objects. 173 | // (2) structural observers that only fire on structural array changes and never 174 | // on propagated child changes. 175 | 176 | this.events.get(wildcardKey).push(callback); 177 | 178 | if (!silent) { 179 | Queue.wildcardEvents.set(callback, this.owner); 180 | Queue.flush(); 181 | } 182 | 183 | if (unobservable) { 184 | return () => this.wildcardEvents.splice(this.wildcardEvents.indexOf(callback), 1); 185 | } 186 | 187 | } 188 | 189 | setStructuralObserver(callback) { 190 | 191 | // a special wildcard that is only fired for structural changes 192 | // but never on propagation of child objects. only 1 per instance 193 | 194 | if (this.__structuralObserver) { 195 | 196 | // replace callback 197 | this.__structuralObserver = callback; 198 | 199 | } else { 200 | 201 | // assign callback 202 | this.__structuralObserver = callback; 203 | 204 | // register as prioritized wildcard 205 | this.wildcardEvents.unshift(value => { 206 | this.structuralObserver(value); 207 | this.structuralObserver = NOOP; 208 | }); 209 | 210 | } 211 | 212 | } 213 | 214 | didMutate() { 215 | 216 | // The array buffer has been structurally modified. 217 | // Swap structural observer from noop to actual callback 218 | this.structuralObserver = this.__structuralObserver; 219 | 220 | // wildcards 221 | for (let i = 0; i < this.wildcardEvents.length; i++) { 222 | Queue.wildcardEvents.set(this.wildcardEvents[i], this.owner); 223 | } 224 | 225 | // parent 226 | if (this.parentInternals) { 227 | this.parentInternals.resolve(this.ownPropertyName, this.owner); 228 | } 229 | 230 | Queue.flush(); 231 | 232 | } 233 | 234 | resolve() { 235 | 236 | // wildcards 237 | for (let i = 0; i < this.wildcardEvents.length; i++) { 238 | Queue.wildcardEvents.set(this.wildcardEvents[i], this.owner); 239 | } 240 | 241 | // parent 242 | if (this.parentInternals) { 243 | this.parentInternals.resolve(this.ownPropertyName, this.owner); 244 | } 245 | 246 | // resolve() will only be called via propagating child objects 247 | // so we are already flushing, do nothing else here 248 | 249 | } 250 | 251 | } -------------------------------------------------------------------------------- /src/modules/state/ReactiveArray.js: -------------------------------------------------------------------------------- 1 | import { ReactiveWrapper } from "./internal/ReactiveWrapper.js"; 2 | import { ReactiveArrayInternals } from "./internal/ReactiveArrayInternals.js"; 3 | import { StateTracker } from "./internal/StateTracker.js"; 4 | import { Queue } from "./internal/Queue.js"; 5 | import { defer } from "../utils/defer.js"; 6 | 7 | export class ReactiveArray extends ReactiveWrapper { 8 | 9 | constructor(array, options) { 10 | super(new ReactiveArrayInternals(array, options)); 11 | this.$$.owner = this; 12 | } 13 | 14 | clone() { 15 | return new this.constructor(this.$$.defaultData, { 16 | _model_: this.$$.model 17 | }); 18 | } 19 | 20 | // Accessors & Iterators 21 | 22 | get length() { 23 | return this.$$.nativeData.length; 24 | } 25 | 26 | every(callbackFn) { 27 | return this.$$.nativeData.every(callbackFn); 28 | } 29 | 30 | some(callbackFn) { 31 | return this.$$.nativeData.some(callbackFn); 32 | } 33 | 34 | findIndex(callbackFn) { 35 | return this.$$.nativeData.findIndex(callbackFn); 36 | } 37 | 38 | findLastIndex(callbackFn) { 39 | const array = this.$$.nativeData; 40 | let i = array.length; 41 | while (i--) { 42 | if (callbackFn(array[i], i, array)) { 43 | return i; 44 | } 45 | } 46 | return -1; 47 | } 48 | 49 | includes(item) { 50 | return this.$$.nativeData.includes(item); 51 | } 52 | 53 | indexOf(item, fromIndex) { 54 | return this.$$.nativeData.indexOf(item, fromIndex); 55 | } 56 | 57 | lastIndexOf(item, fromIndex) { 58 | return this.$$.nativeData.lastIndexOf(item, fromIndex); 59 | } 60 | 61 | find(callbackFn) { 62 | return this.$$.nativeData.find(callbackFn); 63 | } 64 | 65 | slice(start) { 66 | return this.$$.nativeData.slice(start); 67 | } 68 | 69 | concat(...arrays) { 70 | return this.$$.nativeData.concat(...arrays); 71 | } 72 | 73 | forEach(callbackFn) { 74 | return this.$$.nativeData.forEach(callbackFn); 75 | } 76 | 77 | filter(compareFn) { 78 | return this.$$.nativeData.filter(compareFn); 79 | } 80 | 81 | map(callbackFn) { 82 | return this.$$.nativeData.map(callbackFn); 83 | } 84 | 85 | reduce(reducerFn, initialValue) { 86 | return this.$$.nativeData.reduce(reducerFn, initialValue); 87 | } 88 | 89 | // Mutators 90 | 91 | pop() { 92 | if (this.$$.nativeData.length) { 93 | const value = this.$$.nativeData.pop(); 94 | this.$$.didMutate(); 95 | return value; 96 | } 97 | } 98 | 99 | push(...items) { 100 | this.$$.nativeData.push(...this.$$.internalize(items)); 101 | this.$$.didMutate(); 102 | } 103 | 104 | shift() { 105 | if (this.$$.nativeData.length) { 106 | const value = this.$$.nativeData.shift(); 107 | this.$$.didMutate(); 108 | return value; 109 | } 110 | } 111 | 112 | unshift(...items) { 113 | this.$$.nativeData.unshift(...this.$$.internalize(items)); 114 | this.$$.didMutate(); 115 | } 116 | 117 | splice(start, deleteCount, ...items) { 118 | 119 | if (!deleteCount && !items.length) { // noop 120 | 121 | return []; 122 | 123 | } else if (!items.length) { // remove items 124 | 125 | const removedItems = this.$$.nativeData.splice(start, deleteCount); 126 | this.$$.didMutate(); 127 | return removedItems; 128 | 129 | } else { // remove/add 130 | 131 | const removedItems = this.$$.nativeData.splice(start, deleteCount, ...this.$$.internalize(items)); 132 | this.$$.didMutate(); 133 | return removedItems; 134 | 135 | } 136 | 137 | } 138 | 139 | reverse() { 140 | if (this.$$.nativeData.length > 1) { 141 | this.$$.nativeData.reverse(); 142 | this.$$.didMutate(); 143 | } 144 | } 145 | 146 | sort(compareFn) { 147 | 148 | const array = this.$$.nativeData; 149 | 150 | if (array.length > 1) { 151 | 152 | const copy = array.slice(0); 153 | array.sort(compareFn); 154 | 155 | for (let i = 0; i < array.length; i++) { 156 | if (array[i] !== copy[i]) { 157 | this.$$.didMutate(); 158 | break; 159 | } 160 | } 161 | 162 | } 163 | 164 | } 165 | 166 | filterInPlace(compareFn) { 167 | 168 | const array = this.$$.nativeData; 169 | 170 | let didChange = false; 171 | 172 | for (let i = array.length - 1; i >= 0; i--) { 173 | if (!compareFn(array[i], i, array)) { 174 | array.splice(i, 1); 175 | didChange = true; 176 | } 177 | } 178 | 179 | if (didChange) { 180 | this.$$.didMutate(); 181 | } 182 | 183 | } 184 | 185 | concatInPlace(array, prepend = false) { 186 | 187 | if (array?.length) { 188 | 189 | if (prepend) { 190 | this.$$.nativeData.unshift(...this.$$.internalize(array)); 191 | } else { 192 | this.$$.nativeData.push(...this.$$.internalize(array)); 193 | } 194 | 195 | this.$$.didMutate(); 196 | 197 | } 198 | 199 | } 200 | 201 | clear() { 202 | 203 | const array = this.$$.nativeData; 204 | 205 | if (array.length) { 206 | while (array.length) array.pop(); 207 | this.$$.didMutate(); 208 | } 209 | 210 | } 211 | 212 | // Observability 213 | 214 | observe(callback, options = {}) { 215 | if ((options.throttle || 0) > 0) { 216 | return this.$$.observe('*', Queue.throttle(callback, options.throttle), options.cancelable, options.silent); 217 | } else if ((options.defer || 0) > 0) { 218 | return this.$$.observe('*', defer(callback, options.defer), options.cancelable, options.silent); 219 | } else { 220 | return this.$$.observe('*', callback, options.cancelable, options.silent); 221 | } 222 | } 223 | 224 | // Time Travel 225 | 226 | track(options = {}) { 227 | 228 | if (this.$$.stateTracker) { 229 | throw new Error(`Cannot track state of ReactiveArray because it is already being tracked.`); 230 | } 231 | 232 | const tracker = this.$$.stateTracker = new StateTracker(options.onTrack, options.maxEntries); 233 | 234 | // check ReactiveObject.track() for explanation 235 | const checkUniqueness = (options.throttle || 0) > 0 || (options.defer || 0) > 0; 236 | 237 | // observer immediately tracks initial state 238 | this.$$.observe('*', val => tracker.add(val, checkUniqueness), false, false); 239 | 240 | } 241 | 242 | undo() { 243 | this.restore(this.$$.stateTracker?.prev()); 244 | } 245 | 246 | redo() { 247 | this.restore(this.$$.stateTracker?.next()); 248 | } 249 | 250 | restore(trackPosition) { 251 | const tracker = this.$$.stateTracker; 252 | if (tracker && tracker.has(trackPosition)) { 253 | this.$$.setData(tracker.get(trackPosition), false); 254 | } 255 | } 256 | 257 | } -------------------------------------------------------------------------------- /examples/TODO MVC/components/todo-editor.js: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "../../../src/modules/component/define-component.js"; 2 | import { ReactiveArray } from "../../../src/modules/state/ReactiveArray.js"; 3 | import { TodoItem } from "./todo-item.js"; 4 | 5 | export const TodoEditor = defineComponent('todo-editor', { 6 | 7 | element: (` 8 |
9 | 10 |
+
11 |
12 |
13 | 20 | `), 21 | 22 | style: (` 23 | $self { 24 | position: relative; 25 | width: 100%; 26 | height: 100%; 27 | display: flex; 28 | flex-direction: column; 29 | } 30 | .input-wrapper { 31 | width: 100%; 32 | height: 54px; 33 | display: flex; 34 | align-items: center; 35 | } 36 | $input { 37 | height: 100%; 38 | flex: 1 1 auto; 39 | padding: 0 0.5em; 40 | outline: 0; 41 | border: 0; 42 | color: white; 43 | background: transparent; 44 | } 45 | $input:focus { 46 | border: 0; 47 | outline: 0; 48 | } 49 | $submitButton { 50 | width: 35px; 51 | height: 35px; 52 | display: flex; 53 | align-items: center; 54 | justify-content: center; 55 | border-radius: 50%; 56 | color: white; 57 | background: rgb(0, 115, 255); 58 | cursor: pointer; 59 | transform: scale(0); 60 | pointer-events: none; 61 | transition: transform 250ms ease-in-out; 62 | } 63 | $submitButton.visible { 64 | transform: scale(1); 65 | pointer-events: auto; 66 | } 67 | $list { 68 | height: 60vh; 69 | overflow-y: overlay; 70 | border-top: 1px solid rgb(62,65,68); 71 | margin-bottom: 1em; 72 | display: flex; 73 | flex-direction: column; 74 | padding: 0.5em 0; 75 | gap: 0.5em; 76 | } 77 | $list:empty { 78 | background-image: url("components/placeholder.svg"); 79 | background-size: max(65%, 10em); 80 | background-position: center; 81 | background-repeat: no-repeat; 82 | } 83 | $list:focus { 84 | border: 0; 85 | border-top: 1px solid rgb(62,65,68); 86 | outline: 0; 87 | } 88 | .footer { 89 | display: flex; 90 | justify-content: space-between; 91 | padding: 1em 0; 92 | border-top: 1px solid rgb(62,65,68); 93 | } 94 | $history { 95 | display: flex; 96 | gap: 1em; 97 | } 98 | $history [data-action] { 99 | cursor: pointer; 100 | pointer-events: none; 101 | opacity: 0.5; 102 | transition: opacity 250ms ease-in-out; 103 | } 104 | $history [data-action].enabled { 105 | pointer-events: auto; 106 | opacity: 1; 107 | } 108 | `), 109 | 110 | state: { 111 | 112 | items: { 113 | value: new ReactiveArray([], { 114 | model: data => TodoItem.state(data) 115 | }), 116 | renderList: { 117 | parentElement: '$list', 118 | createChild: data => TodoItem.render({state: data}) 119 | } 120 | }, 121 | 122 | _text: { 123 | value: '', 124 | render({$input, $submitButton}, value) { 125 | $input.value = value; 126 | $submitButton.classList.toggle('visible', !!value); 127 | } 128 | }, 129 | 130 | _count: { 131 | value: ({items}) => items.filter(item => !item.get('complete')).length, 132 | render({$count}, value) { 133 | $count.textContent = `${value} thing${value === 1 ? '' : 's'} to do...`; 134 | } 135 | }, 136 | 137 | _historyState: { 138 | value: { undo: false, redo: false }, 139 | render({$history}, value) { 140 | $history.firstElementChild.classList.toggle('enabled', value.undo); 141 | $history.lastElementChild.classList.toggle('enabled', value.redo); 142 | } 143 | } 144 | 145 | }, 146 | 147 | initialize({$input, $submitButton, $list, $history}) { 148 | 149 | this.state.track('items', { 150 | maxEntries: 100, 151 | throttle: 500, 152 | onTrack: (value, index, total) => { 153 | this.state.set('_historyState', { 154 | undo: index > 0, 155 | redo: index + 1 < total 156 | }) 157 | } 158 | }); 159 | 160 | $history.addEventListener('click', e => { 161 | if (e.target.matches('[data-action="undo"]')) { 162 | this.state.undo('items'); 163 | } else if (e.target.matches('[data-action="redo"]')) { 164 | this.state.redo('items'); 165 | } 166 | }); 167 | 168 | document.addEventListener('click', e => { 169 | if ($submitButton.contains(e.target)) { 170 | this.addTodoItem($list); 171 | } 172 | this.state.get('items').forEach(item => item.set('_selected', false)); 173 | }); 174 | 175 | this.addEventListener('todo-item::selected', e => { 176 | this.handleItemSelection(e.detail.itemState, e.detail.originalEvent); 177 | }); 178 | 179 | this.addEventListener('todo-item::complete', e => { 180 | this.state.get('items').forEach(item => { 181 | if (item.get('_selected')) { 182 | item.set('complete', e.detail.targetState); 183 | } 184 | }); 185 | }); 186 | 187 | $input.addEventListener('input', () => { 188 | this.state.set('_text', $input.value.trim()); 189 | }); 190 | 191 | $input.addEventListener('keyup', e => { 192 | if (e.key === 'Enter') { 193 | this.addTodoItem($list); 194 | } 195 | }); 196 | 197 | $list.addEventListener('keyup', e => { 198 | if (e.key === 'Backspace' || e.key === 'Delete') { 199 | this.state.get('items').filterInPlace(item => item.get('_isEditing') || !item.get('_selected')); 200 | } else if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) { 201 | e.preventDefault(); 202 | this.state.get('items').forEach(item => { 203 | item.set('_selected', true); 204 | }); 205 | } 206 | }); 207 | 208 | }, 209 | 210 | addTodoItem() { 211 | 212 | const text = this.state.get('_text'); 213 | 214 | if (text.startsWith('many')) { 215 | 216 | const num = 500; 217 | 218 | this.state.get('items').set(new Array(num).fill(null).map((x, i) => ({ 219 | text: `Item ${i + 1}/${num}` 220 | }))); 221 | 222 | } else if (text) { 223 | 224 | this.state.get('items').unshift({ text }); 225 | 226 | } 227 | 228 | this.state.set('_text', ''); 229 | 230 | }, 231 | 232 | handleItemSelection(itemState, originalEvent) { 233 | 234 | const items = this.state.get('items'); 235 | const index = items.indexOf(itemState); 236 | 237 | if (originalEvent.shiftKey) { 238 | 239 | const base = Math.max(0, items.findLastIndex(item => item.get('_selected'))); 240 | const from = Math.min(index, base); 241 | const to = Math.max(index, base) + 1; 242 | items.forEach((item, i) => item.set('_selected', i >= from && i < to)); 243 | 244 | } else if (originalEvent.metaKey || originalEvent.ctrlKey) { 245 | 246 | itemState.set('_selected', !itemState.get('_selected')); 247 | 248 | } else { 249 | 250 | items.forEach((item, i) => { 251 | item.set('_selected', i === index); 252 | }); 253 | 254 | } 255 | 256 | } 257 | 258 | }); -------------------------------------------------------------------------------- /src/modules/component/render-list.js: -------------------------------------------------------------------------------- 1 | export function renderList(data, config) { 2 | 3 | // accept reactive arrays, normal arrays and convert plain objects, null and undefined to arrays 4 | const newArray = data?.$$ ? data.$$.nativeData.slice(0) : Array.isArray(data) ? data.slice(0) : Object.values(data || {}); 5 | const parent = config.parentElement; 6 | 7 | // keep reference to old data on element 8 | const oldArray = parent._renderListData_ || []; 9 | parent._renderListData_ = newArray; 10 | 11 | // optimize for simple cases 12 | if (newArray.length === 0) { 13 | 14 | parent.innerHTML = ''; 15 | 16 | } else if (oldArray.length === 0) { 17 | 18 | for (let i = 0; i < newArray.length; i++) { 19 | parent.appendChild(config.createChild(newArray[i], i, newArray)); 20 | } 21 | 22 | } else { 23 | 24 | reconcile(parent, oldArray, newArray, config.createChild, config.updateChild); 25 | 26 | } 27 | 28 | } 29 | 30 | function reconcile(parentElement, currentArray, newArray, createFn, updateFn) { 31 | 32 | // dom reconciliation algorithm that compares items in currentArray to items in 33 | // newArray by value. implementation based on: 34 | // https://github.com/localvoid/ivi 35 | // https://github.com/adamhaile/surplus 36 | // https://github.com/Freak613/stage0 37 | 38 | let prevStart = 0, newStart = 0; 39 | let loop = true; 40 | let prevEnd = currentArray.length - 1, newEnd = newArray.length - 1; 41 | let a, b; 42 | let prevStartNode = parentElement.firstChild, newStartNode = prevStartNode; 43 | let prevEndNode = parentElement.lastChild, newEndNode = prevEndNode; 44 | let afterNode = null; 45 | 46 | // scan over common prefixes, suffixes, and simple reversals 47 | outer : while (loop) { 48 | 49 | loop = false; 50 | 51 | let _node; 52 | 53 | // Skip prefix 54 | a = currentArray[prevStart]; 55 | b = newArray[newStart]; 56 | 57 | while (a === b) { 58 | 59 | updateFn && updateFn(prevStartNode, b); 60 | 61 | prevStart++; 62 | newStart++; 63 | 64 | newStartNode = prevStartNode = prevStartNode.nextSibling; 65 | 66 | if (prevEnd < prevStart || newEnd < newStart) { 67 | break outer; 68 | } 69 | 70 | a = currentArray[prevStart]; 71 | b = newArray[newStart]; 72 | 73 | } 74 | 75 | // Skip suffix 76 | a = currentArray[prevEnd]; 77 | b = newArray[newEnd]; 78 | 79 | while (a === b) { 80 | 81 | updateFn && updateFn(prevEndNode, b); 82 | 83 | prevEnd--; 84 | newEnd--; 85 | 86 | afterNode = prevEndNode; 87 | newEndNode = prevEndNode = prevEndNode.previousSibling; 88 | 89 | if (prevEnd < prevStart || newEnd < newStart) { 90 | break outer; 91 | } 92 | 93 | a = currentArray[prevEnd]; 94 | b = newArray[newEnd]; 95 | 96 | } 97 | 98 | // Swap backward 99 | a = currentArray[prevEnd]; 100 | b = newArray[newStart]; 101 | 102 | while (a === b) { 103 | 104 | loop = true; 105 | 106 | updateFn && updateFn(prevEndNode, b); 107 | 108 | _node = prevEndNode.previousSibling; 109 | parentElement.insertBefore(prevEndNode, newStartNode); 110 | newEndNode = prevEndNode = _node; 111 | 112 | newStart++; 113 | prevEnd--; 114 | 115 | if (prevEnd < prevStart || newEnd < newStart) { 116 | break outer; 117 | } 118 | 119 | a = currentArray[prevEnd]; 120 | b = newArray[newStart]; 121 | 122 | } 123 | 124 | // Swap forward 125 | a = currentArray[prevStart]; 126 | b = newArray[newEnd]; 127 | 128 | while (a === b) { 129 | 130 | loop = true; 131 | 132 | updateFn && updateFn(prevStartNode, b); 133 | 134 | _node = prevStartNode.nextSibling; 135 | parentElement.insertBefore(prevStartNode, afterNode); 136 | afterNode = newEndNode = prevStartNode; 137 | prevStartNode = _node; 138 | 139 | prevStart++; 140 | newEnd--; 141 | 142 | if (prevEnd < prevStart || newEnd < newStart) { 143 | break outer; 144 | } 145 | 146 | a = currentArray[prevStart]; 147 | b = newArray[newEnd]; 148 | 149 | } 150 | 151 | } 152 | 153 | // Remove Node(s) 154 | if (newEnd < newStart) { 155 | if (prevStart <= prevEnd) { 156 | let next; 157 | while (prevStart <= prevEnd) { 158 | if (prevEnd === 0) { 159 | parentElement.removeChild(prevEndNode); 160 | } else { 161 | next = prevEndNode.previousSibling; 162 | parentElement.removeChild(prevEndNode); 163 | prevEndNode = next; 164 | } 165 | prevEnd--; 166 | } 167 | } 168 | return; 169 | } 170 | 171 | // Add Node(s) 172 | if (prevEnd < prevStart) { 173 | if (newStart <= newEnd) { 174 | while (newStart <= newEnd) { 175 | afterNode 176 | ? parentElement.insertBefore(createFn(newArray[newStart], newStart, newArray), afterNode) 177 | : parentElement.appendChild(createFn(newArray[newStart], newStart, newArray)); 178 | newStart++ 179 | } 180 | } 181 | return; 182 | } 183 | 184 | // Simple cases don't apply. Prepare full reconciliation: 185 | 186 | // Collect position index of nodes in current DOM 187 | const positions = new Array(newEnd + 1 - newStart); 188 | // Map indices of current DOM nodes to indices of new DOM nodes 189 | const indices = new Map(); 190 | 191 | let i; 192 | 193 | for (i = newStart; i <= newEnd; i++) { 194 | positions[i] = -1; 195 | indices.set(newArray[i], i); 196 | } 197 | 198 | let reusable = 0, toRemove = []; 199 | 200 | for (i = prevStart; i <= prevEnd; i++) { 201 | 202 | if (indices.has(currentArray[i])) { 203 | positions[indices.get(currentArray[i])] = i; 204 | reusable++; 205 | } else { 206 | toRemove.push(i); 207 | } 208 | 209 | } 210 | 211 | // Full Replace 212 | if (reusable === 0) { 213 | 214 | parentElement.textContent = ''; 215 | 216 | for (i = newStart; i <= newEnd; i++) { 217 | parentElement.appendChild(createFn(newArray[i], i, newArray)); 218 | } 219 | 220 | return; 221 | 222 | } 223 | 224 | // Full Patch around longest increasing sub-sequence 225 | const snake = subSequence(positions, newStart); 226 | 227 | // gather nodes 228 | const nodes = []; 229 | let tmpC = prevStartNode; 230 | 231 | for (i = prevStart; i <= prevEnd; i++) { 232 | nodes[i] = tmpC; 233 | tmpC = tmpC.nextSibling 234 | } 235 | 236 | for (i = 0; i < toRemove.length; i++) { 237 | parentElement.removeChild(nodes[toRemove[i]]); 238 | } 239 | 240 | let snakeIndex = snake.length - 1, tempNode; 241 | for (i = newEnd; i >= newStart; i--) { 242 | 243 | if (snake[snakeIndex] === i) { 244 | 245 | afterNode = nodes[positions[snake[snakeIndex]]]; 246 | updateFn && updateFn(afterNode, newArray[i]); 247 | snakeIndex--; 248 | 249 | } else { 250 | 251 | if (positions[i] === -1) { 252 | tempNode = createFn(newArray[i], i, newArray); 253 | } else { 254 | tempNode = nodes[positions[i]]; 255 | updateFn && updateFn(tempNode, newArray[i]); 256 | } 257 | 258 | parentElement.insertBefore(tempNode, afterNode); 259 | afterNode = tempNode; 260 | 261 | } 262 | 263 | } 264 | 265 | } 266 | 267 | function subSequence(ns, newStart) { 268 | 269 | // inline-optimized implementation of longest-positive-increasing-subsequence algorithm 270 | // https://en.wikipedia.org/wiki/Longest_increasing_subsequence 271 | 272 | const seq = []; 273 | const is = []; 274 | const pre = new Array(ns.length); 275 | 276 | let l = -1, i, n, j; 277 | 278 | for (i = newStart; i < ns.length; i++) { 279 | 280 | n = ns[i]; 281 | 282 | if (n < 0) continue; 283 | 284 | let lo = -1, hi = seq.length, mid; 285 | 286 | if (hi > 0 && seq[hi - 1] <= n) { 287 | 288 | j = hi - 1; 289 | 290 | } else { 291 | 292 | while (hi - lo > 1) { 293 | 294 | mid = Math.floor((lo + hi) / 2); 295 | 296 | if (seq[mid] > n) { 297 | hi = mid; 298 | } else { 299 | lo = mid; 300 | } 301 | 302 | } 303 | 304 | j = lo; 305 | 306 | } 307 | 308 | if (j !== -1) { 309 | pre[i] = is[j]; 310 | } 311 | 312 | if (j === l) { 313 | l++; 314 | seq[l] = n; 315 | is[l] = i; 316 | } else if (n < seq[j + 1]) { 317 | seq[j + 1] = n; 318 | is[j + 1] = i; 319 | } 320 | 321 | } 322 | 323 | for (i = is[l]; l >= 0; i = pre[i], l--) { 324 | seq[l] = i; 325 | } 326 | 327 | return seq; 328 | 329 | } -------------------------------------------------------------------------------- /src/modules/component/internal/ComponentModel.js: -------------------------------------------------------------------------------- 1 | import { ReactiveObjectModel } from "../../state/internal/ReactiveObjectModel.js"; 2 | import { StateProvider } from "./StateProvider.js"; 3 | 4 | // Regex matches when $self is: 5 | // - immediately followed by css child selector (space . : # [ > + ~) OR 6 | // - immediately followed by opening bracket { OR 7 | // - immediately followed by chaining comma , 8 | // - not followed by anything (end of line) 9 | const $SELF_REGEXP = /(\$self(?=[\\040,{.:#[>+~]))|\$self\b/g; 10 | const CHILD_SELECTORS = [' ','.',':','#','[','>','+','~']; 11 | let CLASS_COUNTER = -1; 12 | 13 | const CSS_COMPILER = document.head.appendChild(document.createElement('style')); 14 | const CSS_COMPONENTS = document.head.appendChild(document.createElement('style')); 15 | 16 | const DEFINED_TAGNAMES = new Set(); 17 | 18 | export class ComponentModel { 19 | 20 | static createTag(name, attributes) { 21 | 22 | let tag = '<' + name; 23 | 24 | if (attributes) { 25 | 26 | for (const attribute in attributes) { 27 | 28 | if (attributes.hasOwnProperty(attribute)) { 29 | 30 | const value = attributes[attribute]; 31 | 32 | if (typeof value === 'string') { 33 | 34 | tag += ' ' + attribute + '="' + value + '"'; 35 | 36 | } else if (attribute === 'state') { 37 | 38 | if (value && value.$$) { 39 | tag += ' provided-state="' + StateProvider.setState(value) + '"'; 40 | } else { 41 | tag += ' composed-state-data="' + StateProvider.setState(value) + '"'; 42 | } 43 | 44 | } 45 | 46 | } 47 | 48 | } 49 | 50 | } 51 | 52 | return tag + '>'; 53 | 54 | } 55 | 56 | static createNode(name, attributes, createState) { 57 | 58 | const element = document.createElement(name); 59 | 60 | if (attributes) { 61 | if (attributes.$$) { // fast path 62 | element.state = attributes; 63 | } else { 64 | for (const attribute in attributes) { 65 | if (attributes.hasOwnProperty(attribute)) { 66 | const value = attributes[attribute]; 67 | if (attribute === 'state') { 68 | if (value && value.$$) { 69 | element.state = value; 70 | } else { 71 | element.state = createState(value); 72 | } 73 | } else { 74 | element.setAttribute(attribute, attributes[attribute]); 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | return element; 82 | 83 | } 84 | 85 | constructor(name, config) { 86 | 87 | DEFINED_TAGNAMES.add(name.toUpperCase()); 88 | 89 | this.__name = name; 90 | 91 | if (config.style || config.element) { 92 | this.__style = config.style; 93 | this.__element = config.element || ''; 94 | this.__templateReady = false; 95 | } else { 96 | this.__templateReady = true; 97 | } 98 | 99 | if (config.state) { 100 | this.__state = config.state; 101 | } 102 | 103 | this.initialize = config.initialize; 104 | 105 | } 106 | 107 | setupStateOnce() { 108 | 109 | if (this.state) { 110 | 111 | return true; 112 | 113 | } else if (this.__state) { 114 | 115 | const properties = {}; 116 | const renderEvents = new Map(); 117 | const renderListConfigs = new Map(); 118 | 119 | for (const key in this.__state) { 120 | 121 | if (this.__state.hasOwnProperty(key)) { 122 | 123 | const entry = this.__state[key]; 124 | 125 | properties[key] = entry.value; 126 | 127 | if (typeof entry.render === 'function') { 128 | 129 | renderEvents.set(key, entry.render); 130 | 131 | } else if (typeof entry.renderList === 'object') { 132 | 133 | renderListConfigs.set(key, entry.renderList); 134 | 135 | } 136 | 137 | } 138 | 139 | } 140 | 141 | this.state = new ReactiveObjectModel(properties); 142 | this.renderEvents = renderEvents; 143 | this.renderListConfigs = renderListConfigs; 144 | 145 | this.__state = null; // de-ref 146 | 147 | return true; 148 | 149 | } else { 150 | 151 | return false; 152 | 153 | } 154 | 155 | } 156 | 157 | compileTemplateOnce() { 158 | 159 | if (!this.__templateReady) { 160 | 161 | // create template element and collect refs 162 | const template = document.createElement('template'); 163 | template.innerHTML = this.__element || ''; 164 | this.content = template.content; 165 | this.refs = new Map(); // $ref -> replacementClass 166 | this.__collectElementReferences(this.content.children); 167 | 168 | // create scoped styles 169 | if (this.__style) { 170 | 171 | let style = this.__style; 172 | 173 | // Re-write $self to component-name 174 | style = style.replace($SELF_REGEXP, this.__name); 175 | 176 | // Re-write $refName(s) in style text to class selector 177 | for (const [$ref, classReplacement] of this.refs) { 178 | // replace $refName with internal .class when $refName is: 179 | // - immediately followed by css child selector (space . : # [ > + ~) OR 180 | // - immediately followed by opening bracket { OR 181 | // - immediately followed by chaining comma , 182 | // - not followed by anything (end of line) 183 | style = style.replace(new RegExp("(\\" + $ref + "(?=[\\40{,.:#[>+~]))|\\" + $ref + "\b", 'g'), '.' + classReplacement); 184 | } 185 | 186 | CSS_COMPILER.innerHTML = style; 187 | const tmpSheet = CSS_COMPILER.sheet; 188 | 189 | let styleNodeInnerHTML = '', styleQueries = ''; 190 | for (let i = 0, rule; i < tmpSheet.rules.length; i++) { 191 | 192 | rule = tmpSheet.rules[i]; 193 | 194 | if (rule.type === 7 || rule.type === 8) { // do not scope @keyframes 195 | styleNodeInnerHTML += rule.cssText; 196 | } else if (rule.type === 1) { // style rule 197 | styleNodeInnerHTML += this.__constructScopedStyleRule(rule); 198 | } else if (rule.type === 4 || rule.type === 12) { // @media/@supports query 199 | styleQueries += this.__constructScopedStyleQuery(rule); 200 | } else { 201 | console.warn(`CSS Rule of type "${rule.type}" is not supported.`); 202 | } 203 | 204 | } 205 | 206 | // write queries to the end of the rules AFTER the other rules for specificity (issue #13) 207 | // and add styles to global stylesheet 208 | CSS_COMPONENTS.innerHTML += (styleNodeInnerHTML + styleQueries); 209 | CSS_COMPILER.innerHTML = this.__style = ''; 210 | 211 | } 212 | 213 | this.__templateReady = true; 214 | 215 | } 216 | 217 | } 218 | 219 | __collectElementReferences(children) { 220 | 221 | for (let i = 0, child, ref, cls1, cls2; i < children.length; i++) { 222 | 223 | child = children[i]; 224 | 225 | ref = child.getAttribute('$'); 226 | 227 | if (ref) { 228 | cls1 = child.getAttribute('class'); 229 | cls2 = ref + ++CLASS_COUNTER; 230 | this.refs.set('$' + ref, cls2); 231 | child.setAttribute('class', cls1 ? cls1 + ' ' + cls2 : cls2); 232 | child.removeAttribute('$'); 233 | } 234 | 235 | if (child.firstElementChild && !DEFINED_TAGNAMES.has(child.tagName)) { 236 | this.__collectElementReferences(child.children); 237 | } 238 | 239 | } 240 | 241 | } 242 | 243 | __constructScopedStyleQuery(query, cssText = '') { 244 | 245 | if (query.type === 4) { 246 | cssText += '@media ' + query.media.mediaText + ' {'; 247 | } else { 248 | cssText += '@supports ' + query.conditionText + ' {'; 249 | } 250 | 251 | let styleQueries = ''; 252 | 253 | for (let i = 0, rule; i < query.cssRules.length; i++) { 254 | 255 | rule = query.cssRules[i]; 256 | 257 | if (rule.type === 7 || rule.type === 8) { // @keyframes 258 | cssText += rule.cssText; 259 | } else if (rule.type === 1) { 260 | cssText += this.__constructScopedStyleRule(rule, cssText); 261 | } else if (rule.type === 4 || rule.type === 12) { // nested query 262 | styleQueries += this.__constructScopedStyleQuery(rule); 263 | } else { 264 | console.warn(`CSS Rule of type "${rule.type}" is not currently supported by Components.`); 265 | } 266 | 267 | } 268 | 269 | // write nested queries to the end of the surrounding query (see issue #13) 270 | cssText += styleQueries + ' }'; 271 | 272 | return cssText; 273 | 274 | } 275 | 276 | __constructScopedStyleRule(rule) { 277 | 278 | let cssText = ''; 279 | 280 | if (rule.selectorText.indexOf(',') > -1) { 281 | 282 | const selectors = rule.selectorText.split(','); 283 | const scopedSelectors = []; 284 | 285 | for (let i = 0, selector; i < selectors.length; i++) { 286 | 287 | selector = selectors[i].trim(); 288 | 289 | if (selector.lastIndexOf(':root', 0) === 0) { // escape context (dont scope) :root notation 290 | scopedSelectors.push(selector.replace(':root', '')); 291 | } else if (this.__isTopLevelSelector(selector, this.__name)) { // dont scope component-name 292 | scopedSelectors.push(selector); 293 | } else { // prefix with component-name to create soft scoping 294 | scopedSelectors.push(this.__name + ' ' + selector); 295 | } 296 | 297 | } 298 | 299 | cssText += scopedSelectors.join(', ') + rule.cssText.substr(rule.selectorText.length); 300 | 301 | } else { 302 | 303 | if (rule.selectorText.lastIndexOf(':root', 0) === 0) { // escape context (dont scope) :root notation 304 | cssText += rule.cssText.replace(':root', ''); // remove first occurrence of :root 305 | } else if (this.__isTopLevelSelector(rule.selectorText)) { // dont scope component-name 306 | cssText += rule.cssText; 307 | } else { // prefix with component-name to create soft scoping 308 | cssText += this.__name + ' ' + rule.cssText; 309 | } 310 | 311 | } 312 | 313 | return cssText; 314 | 315 | } 316 | 317 | __isTopLevelSelector(selectorText) { 318 | if (selectorText === this.__name) { 319 | return true; 320 | } else if (selectorText.lastIndexOf(this.__name, 0) === 0) { // starts with componentName 321 | return CHILD_SELECTORS.indexOf(selectorText.charAt(this.__name.length)) > -1; // character following componentName is valid child selector 322 | } else { // nada 323 | return false; 324 | } 325 | } 326 | 327 | } -------------------------------------------------------------------------------- /src/modules/state/internal/Core.js: -------------------------------------------------------------------------------- 1 | import { deepClone } from "../../utils/deep-clone.js"; 2 | 3 | export const Core = { 4 | 5 | patchData(source, target, parent, key) { 6 | this.__systemDataDidChange = false; 7 | this.__deepApplyStrict(source, target, parent, key); 8 | return this.__systemDataDidChange; 9 | }, 10 | 11 | setupComputedProperties(allProperties, computedProperties) { 12 | return this.__resolveDependencies(this.__installDependencies(allProperties, computedProperties)); 13 | }, 14 | 15 | buildDependencyGraph(computedProperties, targetMap) { 16 | 17 | for (const computedProperty of computedProperties.values()) { 18 | 19 | for(let i = 0, sourceProperty; i < computedProperty.sourceProperties.length; i++) { 20 | 21 | sourceProperty = computedProperty.sourceProperties[i]; 22 | 23 | if (targetMap.has(sourceProperty)) { 24 | targetMap.get(sourceProperty).push(computedProperty); 25 | } else { 26 | targetMap.set(sourceProperty, [ computedProperty ]); 27 | } 28 | 29 | } 30 | 31 | } 32 | 33 | return targetMap; 34 | 35 | }, 36 | 37 | // --------------------------------------------- 38 | 39 | __systemDataDidChange: false, 40 | __resolverSource: null, 41 | __resolverVisited: [], 42 | __currentlyInstallingProperties: null, 43 | __currentlyInstallingProperty: null, 44 | __dependencyProxyHandler: { 45 | 46 | get(target, sourceProperty) { 47 | 48 | const computedProperty = Core.__currentlyInstallingProperty; 49 | 50 | if (!target.hasOwnProperty(sourceProperty)) { 51 | throw { 52 | type: 'sekoia-internal', 53 | message: `Cannot resolve computed property "${computedProperty.ownPropertyName}" because dependency "${sourceProperty}" doesn't exist.` 54 | } 55 | } 56 | 57 | if (!computedProperty.sourceProperties.includes(sourceProperty)) { 58 | computedProperty.sourceProperties.push(sourceProperty); 59 | } 60 | 61 | } 62 | 63 | }, 64 | 65 | __deepApplyStrict(source, target, parent, key) { 66 | 67 | // Assigns target data to parent[key] if target matches the type (in case of primitives) or shape (in case of objects) of source. 68 | // The algorithm works with arbitrarily nested data structures consisting of { plain: objects } and [ plain, arrays ]. 69 | 70 | // Equality rules: 71 | // When source and target are both primitives, their type must match but their value must be different in order to be assigned. 72 | // When source and target are objects, the algorithm recursively applies the target object's properties to the source object's properties. 73 | // The target object must deeply match the source object's shape. This means that the property keys must match and the property values 74 | // must match type. In other words, target objects are not allowed to add or remove properties from source object (when both are plain objects) 75 | // and the property values of target must recursively match the shape or type of the source object's property values. 76 | // Any target property value that does not match it's corresponding source property value does not get assigned. 77 | // Mismatches do not throw errors - the algorithm will default to the source property value and continue to attempt to 78 | // assign any remaining target property values that match. When an actual assignment happens, the boolean returned by the 79 | // wrapping patchData() function is set to true and can be used by other parts of the system to determine if internal state has changed. 80 | // Arrays are treated similar to plain objects with an important distinction: 81 | // Arrays are allowed to change length. When source is an empty array, we push any items from the target array 82 | // into source because we have no way to compare existing items. When source is an array that has items and target is an array 83 | // that has more items than source, any added items must match the shape or type of the last item in the source array. 84 | // When the target array is shorter than or equal in length to the source array, we deepApply() each item recursively. 85 | 86 | // Implementation details: 87 | // This patching algorithm is wrapped by patchData function for change detection and has been implemented with 88 | // fast-path optimizations that short-circuit patch operations that are guaranteed to not change state. 89 | 90 | if (source === target) { 91 | return; 92 | } 93 | 94 | const typeSource = typeof source; 95 | const typeTarget = typeof target; 96 | 97 | // both are objects 98 | if (source && target && typeSource === 'object' && typeTarget === 'object') { 99 | 100 | if (source.constructor !== target.constructor) { 101 | return; 102 | } 103 | 104 | if (Array.isArray(source)) { 105 | 106 | if (!Array.isArray(target)) { 107 | 108 | return; 109 | 110 | } else { 111 | 112 | const sourceLength = source.length; 113 | 114 | if (sourceLength === 0) { 115 | 116 | if (target.length === 0) { 117 | 118 | return; 119 | 120 | } else { 121 | 122 | for (let i = 0; i < target.length; i++) { 123 | // we're pushing a value that might not be primitive 124 | // so we deepClone to ensure internal store data integrity. 125 | source.push(deepClone(target[i])); 126 | } 127 | 128 | this.__systemDataDidChange = true; 129 | 130 | return; 131 | 132 | } 133 | 134 | } else { 135 | 136 | if (target.length <= sourceLength) { 137 | 138 | source.length = target.length; 139 | 140 | for (let i = 0; i < source.length; i++) { 141 | this.__deepApplyStrict(source[i], target[i], source, i); 142 | } 143 | 144 | } else { 145 | 146 | // new array might get bigger 147 | // added items must match the shape and type of last item in array 148 | const lastSourceIndex = sourceLength - 1; 149 | const lastSourceItem = source[lastSourceIndex]; 150 | 151 | for (let i = 0; i < target.length; i++) { 152 | if (i <= lastSourceIndex) { 153 | this.__deepApplyStrict(source[i], target[i], source, i); 154 | } else if (this.__matches(lastSourceItem, target[i])) { 155 | // we're pushing a value that might not be primitive 156 | // so we deepClone to ensure internal store data integrity. 157 | source.push(deepClone(target[i])); 158 | } 159 | } 160 | 161 | } 162 | 163 | if (sourceLength !== source.length) { 164 | this.__systemDataDidChange = true; 165 | } 166 | 167 | return; 168 | 169 | } 170 | 171 | } 172 | 173 | } 174 | 175 | // must be object 176 | for (const key in source) { 177 | if (source.hasOwnProperty(key)) { 178 | this.__deepApplyStrict(source[key], target[key], source, key); 179 | } 180 | } 181 | 182 | return; 183 | 184 | } 185 | 186 | // both are primitive but type doesn't match 187 | if (typeTarget !== typeSource) { 188 | return; 189 | } 190 | 191 | // both are primitive and of same type. 192 | // assign the primitive to the parent 193 | parent[key] = target; 194 | 195 | this.__systemDataDidChange = true; 196 | 197 | }, 198 | 199 | __matches(source, target) { 200 | 201 | // This function checks whether two values match type (in case of primitives) 202 | // or shape (in case of objects). It uses the equality rules defined by 203 | // deepApplyStrict() and implements the same fast path optimizations. 204 | 205 | if (source === target) { 206 | return true; 207 | } 208 | 209 | const typeSource = typeof source; 210 | const typeTarget = typeof target; 211 | 212 | if (source && target && typeSource === 'object' && typeTarget === 'object') { 213 | 214 | if (source.constructor !== target.constructor) { 215 | return false; 216 | } 217 | 218 | if (Array.isArray(source)) { 219 | 220 | if (!Array.isArray(target)) { 221 | 222 | return false; 223 | 224 | } else { 225 | 226 | const sourceLength = source.length; 227 | 228 | if (sourceLength === 0) { 229 | 230 | return true; // both are arrays, source is empty -> match 231 | 232 | } else { 233 | 234 | if (target.length <= sourceLength) { 235 | 236 | // same length or shorter -> compare items directly 237 | for (let i = 0; i < target.length; i++) { 238 | if (!this.__matches(source[i], target[i])) { 239 | return false; 240 | } 241 | } 242 | 243 | } else { 244 | 245 | // target is longer. added elements must match last source element 246 | const lastSourceIndex = sourceLength - 1; 247 | const lastSourceItem = source[lastSourceIndex]; 248 | 249 | for (let i = lastSourceIndex; i < target.length; i++) { 250 | if (!this.__matches(lastSourceItem, target[i])) { 251 | return false; 252 | } 253 | } 254 | 255 | } 256 | 257 | return true; 258 | 259 | } 260 | 261 | } 262 | 263 | } 264 | 265 | // both are objects, compare items directly 266 | for (const key in source) { 267 | if (source.hasOwnProperty(key) && !this.__matches(source[key], target[key])) { 268 | return false; 269 | } 270 | } 271 | 272 | return true; 273 | 274 | } 275 | 276 | return typeTarget === typeSource; 277 | 278 | }, 279 | 280 | __installDependencies(allProperties, computedProperties) { 281 | 282 | // set the current installer payload 283 | this.__currentlyInstallingProperties = computedProperties; 284 | 285 | // intercept get requests to props object to grab sourceProperties 286 | const installer = new Proxy(allProperties, this.__dependencyProxyHandler); 287 | 288 | // call each computation which will trigger the intercepted get requests 289 | for (const computedProperty of computedProperties.values()) { 290 | 291 | this.__currentlyInstallingProperty = computedProperty; 292 | 293 | try { 294 | // the computation itself will most definitely fail but we only care about the property dependencies so we can safely ignore all errors. 295 | computedProperty.computation(installer); 296 | } catch(e) { 297 | if (e.type && e.type === 'sekoia-internal') { 298 | throw new Error(e.message); 299 | } 300 | } 301 | 302 | } 303 | 304 | // kill pointers 305 | this.__currentlyInstallingProperty = null; 306 | this.__currentlyInstallingProperties = null; 307 | 308 | return computedProperties; 309 | 310 | }, 311 | 312 | __resolveDependencies(computedProperties) { 313 | 314 | this.__resolverSource = computedProperties; 315 | 316 | const target = new Map(); 317 | 318 | for (const sourceProperty of computedProperties.keys()) { 319 | this.__visitDependency(sourceProperty, [], target); 320 | } 321 | 322 | this.__resolverSource = null; 323 | while (this.__resolverVisited.length) { 324 | this.__resolverVisited.pop(); 325 | } 326 | 327 | return target; 328 | 329 | }, 330 | 331 | __visitDependency(sourceProperty, dependencies, target) { 332 | 333 | if (this.__resolverSource.has(sourceProperty)) { 334 | 335 | dependencies.push(sourceProperty); 336 | this.__resolverVisited.push(sourceProperty); 337 | 338 | const computedProperty = this.__resolverSource.get(sourceProperty); 339 | 340 | for (let i = 0, name; i < computedProperty.sourceProperties.length; i++) { 341 | 342 | name = computedProperty.sourceProperties[i]; 343 | 344 | if (dependencies.includes(name)) { 345 | throw new Error(`Circular dependency. "${computedProperty.ownPropertyName}" is required by "${name}": ${dependencies.join(' -> ')}`); 346 | } 347 | 348 | if (!this.__resolverVisited.includes(name)) { 349 | this.__visitDependency(name, dependencies, target); 350 | } 351 | 352 | } 353 | 354 | if (!target.has(sourceProperty)) { 355 | target.set(sourceProperty, computedProperty); 356 | } 357 | 358 | } 359 | 360 | } 361 | 362 | }; -------------------------------------------------------------------------------- /src/modules/state/internal/ReactiveObjectInternals.js: -------------------------------------------------------------------------------- 1 | import { Core } from "./Core.js"; 2 | import { Queue } from "./Queue.js"; 3 | import { Binding } from "./Binding.js"; 4 | import { deepClone } from "../../utils/deep-clone.js"; 5 | 6 | export class ReactiveObjectInternals { 7 | 8 | constructor(model) { 9 | 10 | // clone from the second instance on 11 | const cloneChildren = ++model.instances > 1; 12 | 13 | this.model = model; 14 | this.privateKeys = model.privateKeys; 15 | this.boundProperties = model.boundProperties; 16 | this.modelNativeData = model.nativeData; 17 | 18 | this.nativeData = {}; 19 | this.allProperties = {}; 20 | this.settableProperties = {}; 21 | this.bindings = new Map(); 22 | this.events = new Map(); 23 | this.computedProperties = new Map(); 24 | this.dependencyGraph = new Map(); 25 | 26 | this.parentInternals = null; 27 | this.ownPropertyName = ''; 28 | 29 | for (const key in this.modelNativeData) { 30 | 31 | if (this.modelNativeData.hasOwnProperty(key)) { 32 | 33 | const value = this.modelNativeData[key]; 34 | 35 | if (value && value.$$) { 36 | 37 | if (cloneChildren) { 38 | this.nativeData[key] = value.$$.owner.clone(); 39 | } else { 40 | this.nativeData[key] = value; 41 | } 42 | 43 | this.nativeData[key].$$.parentInternals = this; 44 | this.nativeData[key].$$.ownPropertyName = key; 45 | 46 | } else { 47 | 48 | this.nativeData[key] = deepClone(value); 49 | 50 | } 51 | 52 | } 53 | 54 | } 55 | 56 | // register internals on the binding so that when 57 | // nativeData changes in the implementing object, this one gets notified 58 | for (const [key, binding] of model.boundProperties) { 59 | binding.connect(this, key); 60 | } 61 | 62 | if (model.computedProperties.size) { 63 | 64 | // install a single data proxy into all computations. 65 | const proxy = new Proxy(this.allProperties, { 66 | get: (target, key) => this.getDatum(key, false) 67 | }); 68 | 69 | for (const [key, computedProperty] of model.computedProperties) { 70 | this.computedProperties.set(key, computedProperty.clone(proxy)); 71 | } 72 | 73 | Core.buildDependencyGraph(this.computedProperties, this.dependencyGraph); 74 | 75 | } 76 | 77 | } 78 | 79 | getDefaultDatum(key) { 80 | 81 | if (this.modelNativeData.hasOwnProperty(key)) { 82 | 83 | const value = this.modelNativeData[key]; 84 | 85 | if (value?.$$) { 86 | return value.$$.getDefaultData(); 87 | } else { 88 | return value; 89 | } 90 | 91 | } else if (this.boundProperties.has(key)) { 92 | 93 | return this.boundProperties.get(key).getDefaultValue(); 94 | 95 | } 96 | 97 | } 98 | 99 | getDefaultData() { 100 | 101 | let key, val; 102 | 103 | for ([key, val] of this.boundProperties) { 104 | if (!val.readonly) { // skip bindings that resolve to computed properties 105 | this.settableProperties[key] = val.getDefaultValue(); 106 | } 107 | } 108 | 109 | for (key in this.modelNativeData) { 110 | if (this.modelNativeData.hasOwnProperty(key)) { 111 | val = this.modelNativeData[key]; 112 | if (val?.$$) { 113 | this.settableProperties[key] = val.$$.getDefaultData(); 114 | } else { 115 | this.settableProperties[key] = val; 116 | } 117 | } 118 | } 119 | 120 | return this.settableProperties; 121 | 122 | } 123 | 124 | getDatum(key, writableOnly) { 125 | 126 | if (this.nativeData.hasOwnProperty(key)) { 127 | 128 | const value = this.nativeData[key]; 129 | 130 | if (writableOnly) { 131 | if (value?.$$) { 132 | return value.$$.getData(writableOnly); 133 | } else if (!this.privateKeys.has(key)) { 134 | return value; 135 | } 136 | } else { 137 | return value; 138 | } 139 | 140 | } else if (this.boundProperties.has(key)) { 141 | 142 | return this.boundProperties.get(key).getValue(writableOnly); 143 | 144 | } else if (!writableOnly && this.computedProperties.has(key)) { 145 | 146 | return this.computedProperties.get(key).getValue(); 147 | 148 | } 149 | 150 | } 151 | 152 | getData(writableOnly) { 153 | 154 | const wrapper = {}; 155 | 156 | let key, val; 157 | 158 | for (key in this.nativeData) { 159 | if (this.nativeData.hasOwnProperty(key)) { 160 | val = this.nativeData[key]; 161 | if (writableOnly) { 162 | if (val?.$$) { 163 | wrapper[key] = val.$$.getData(writableOnly); 164 | } else if (!this.privateKeys.has(key)) { 165 | wrapper[key] = val; 166 | } 167 | } else { 168 | wrapper[key] = val; 169 | } 170 | } 171 | } 172 | 173 | for ([key, val] of this.boundProperties) { 174 | if (writableOnly && !val.readonly) { 175 | wrapper[key] = val.getValue(writableOnly); 176 | } 177 | } 178 | 179 | if (!writableOnly) { 180 | for (const [key, val] of this.computedProperties) { 181 | wrapper[key] = val.getValue(); 182 | } 183 | } 184 | 185 | return wrapper; 186 | 187 | } 188 | 189 | setDatum(key, value, silent) { 190 | 191 | if (this.nativeData.hasOwnProperty(key)) { 192 | 193 | if (this.nativeData[key]?.$$) { 194 | 195 | this.nativeData[key].$$.setData(value, silent); 196 | 197 | } else if (Core.patchData(this.nativeData[key], value, this.nativeData, key) && !silent) { 198 | 199 | this.resolve(key, value); 200 | 201 | } 202 | 203 | } else if (this.boundProperties.has(key)) { 204 | 205 | this.boundProperties.get(key).setValue(value, silent); 206 | 207 | } 208 | 209 | } 210 | 211 | setData(data, silent) { 212 | for (const key in data) { 213 | if (data.hasOwnProperty(key)) { 214 | this.setDatum(key, data[key], silent); 215 | } 216 | } 217 | } 218 | 219 | observe(key, callback, unobservable, silent) { 220 | 221 | if (this.boundProperties.has(key)) { 222 | 223 | // forward the subscription to the bound object 224 | return this.boundProperties.get(key).observeSource(callback, unobservable, silent); 225 | 226 | } else if (this.nativeData[key]?.$$) { 227 | 228 | // use wildcard listener on child object 229 | return this.nativeData[key].$$.observe('*', callback, unobservable, silent); 230 | 231 | } else { 232 | 233 | // (0) Install Observer so it can run in the future whenever observed key changes 234 | 235 | const callbacks = this.events.get(key); 236 | 237 | if (callbacks) { 238 | callbacks.push(callback); 239 | } else { 240 | this.events.set(key, [ callback ]); 241 | } 242 | 243 | // (1) Schedule an immediate observation 244 | if (!silent) { 245 | 246 | if (key === '*') { 247 | 248 | Queue.wildcardEvents.set(callback, this.owner); 249 | 250 | } else { 251 | 252 | const computedProperty = this.computedProperties.get(key); 253 | 254 | if (computedProperty) { 255 | Queue.keyEvents.set(callback, computedProperty.getValue()); 256 | } else { 257 | Queue.keyEvents.set(callback, this.nativeData[key]); 258 | } 259 | 260 | } 261 | 262 | Queue.flush(); 263 | 264 | } 265 | 266 | // (2) Return unobserve function if required 267 | if (unobservable) { 268 | return () => { 269 | const callbacks = this.events.get(key); 270 | if (callbacks && callbacks.includes(callback)) { 271 | callbacks.splice(callbacks.indexOf(callback), 1); 272 | } 273 | } 274 | } 275 | 276 | } 277 | 278 | } 279 | 280 | bind(key) { 281 | 282 | // returns a Binding that can be implemented as a property value on other objects. 283 | // the Binding forwards all read/write operations to this object which is the single 284 | // source of truth. Binding is two-way unless it is readonly (computed). 285 | 286 | // internal.bindings: key bindings that are bound to other objects that get/set nativeData in this object. 287 | // when a bound property in another object changes, the actual data changes here. 288 | // when the data changes here, all bound objects are notified. 289 | // 290 | // internal.boundProperties: key bindings that are bound to this object that get/set nativeData in another object. 291 | // when data changes here, the actual data changes in the other object. 292 | // when data changes in the other object, this object and it's bound cousins are notified. 293 | 294 | if (this.boundProperties.has(key)) { 295 | throw new Error(`Cannot bind("${key}") because that property is already bound itself. Instead of chaining, bind directly to the source.`); 296 | } else if (this.privateKeys.has(key)) { 297 | throw new Error(`Cannot bind("${key}") because it is private and only available to the source object internally.`); 298 | } 299 | 300 | if (!this.bindings.has(key)) { 301 | const readonly = this.computedProperties.has(key); 302 | this.bindings.set(key, new Binding(this, key, readonly)); 303 | } 304 | 305 | return this.bindings.get(key); 306 | 307 | } 308 | 309 | resolve(key, value) { 310 | 311 | let onlyPrivateKeysChanged = this.privateKeys.has(key); 312 | 313 | const OWN_DEPENDENCY_GRAPH = this.dependencyGraph; 314 | const OWN_EVENTS = this.events; 315 | 316 | const QUEUE_KEY_EVENTS = Queue.keyEvents; 317 | 318 | // (0) ---------- RESOLVE COMPUTED DEPENDENCIES 319 | 320 | const KEY_DEPENDENCIES = OWN_DEPENDENCY_GRAPH.get(key); 321 | 322 | if (KEY_DEPENDENCIES) { 323 | 324 | const QUEUE_COMPUTATIONS = Queue.computedProperties; 325 | const QUEUE_RESOLVED = Queue.resolvedComputedProperties; 326 | const QUEUE_DEPENDENCIES = Queue.dependencies; 327 | 328 | // queue up unique computed properties that immediately depend on changed key 329 | for (let i = 0; i < KEY_DEPENDENCIES.length; i++) { 330 | QUEUE_COMPUTATIONS.add(KEY_DEPENDENCIES[i]); 331 | } 332 | 333 | while (QUEUE_COMPUTATIONS.size) { 334 | 335 | for (const computedProperty of QUEUE_COMPUTATIONS) { 336 | 337 | if (!QUEUE_RESOLVED.has(computedProperty)) { 338 | 339 | // force re-computation because dependency has updated 340 | computedProperty.needsUpdate = true; 341 | 342 | const result = computedProperty.getValue(); 343 | 344 | if (computedProperty.hasChanged === true) { 345 | 346 | // Queue KEY events of computed property 347 | const keyEvents = OWN_EVENTS.get(computedProperty.ownPropertyName); 348 | if (keyEvents) { 349 | for (let i = 0; i < keyEvents.length; i++) { 350 | QUEUE_KEY_EVENTS.set(keyEvents[i], result); 351 | } 352 | } 353 | 354 | // flag wildcards to queue if computed property is not private 355 | if (!computedProperty.isPrivate) { 356 | onlyPrivateKeysChanged = false; 357 | } 358 | 359 | // add this computed property as a DEPENDENCY 360 | QUEUE_DEPENDENCIES.add(computedProperty); 361 | 362 | } 363 | 364 | QUEUE_RESOLVED.add(computedProperty); 365 | 366 | } 367 | 368 | // empty initial queue 369 | QUEUE_COMPUTATIONS.delete(computedProperty); 370 | 371 | } 372 | 373 | // add any unresolved dependencies back into the queue 374 | for (const computedProperty of QUEUE_DEPENDENCIES) { 375 | 376 | const dependencies = OWN_DEPENDENCY_GRAPH.get(computedProperty.ownPropertyName); 377 | 378 | if (dependencies) { 379 | for (let i = 0; i < dependencies.length; i++) { 380 | // add this dependency back onto the queue so that 381 | // the outer while loop can continue to resolve 382 | QUEUE_COMPUTATIONS.add(dependencies[i]); 383 | } 384 | } 385 | 386 | // empty dependency queue 387 | QUEUE_DEPENDENCIES.delete(computedProperty); 388 | 389 | } 390 | 391 | } 392 | 393 | // Clear the resolved queue 394 | QUEUE_RESOLVED.clear(); 395 | 396 | } 397 | 398 | // (1) ------------ QUEUE EVENTS 399 | 400 | // ...queue key events 401 | const KEY_EVENTS = OWN_EVENTS.get(key); 402 | if (KEY_EVENTS) { 403 | for (let i = 0; i < KEY_EVENTS.length; i++) { 404 | QUEUE_KEY_EVENTS.set(KEY_EVENTS[i], value); 405 | } 406 | } 407 | 408 | // when public properties where affected... 409 | if (!onlyPrivateKeysChanged) { 410 | 411 | // ...queue wildcard events 412 | const WILDCARD_EVENTS = OWN_EVENTS.get('*'); 413 | if (WILDCARD_EVENTS) { 414 | const QUEUE_WILDCARDS = Queue.wildcardEvents; 415 | for (let i = 0; i < WILDCARD_EVENTS.length; i++) { 416 | QUEUE_WILDCARDS.set(WILDCARD_EVENTS[i], this.owner); 417 | } 418 | } 419 | 420 | // ...resolve connected objects 421 | const KEY_BINDING = this.bindings.get(key); 422 | if (KEY_BINDING) { 423 | for (const [connectedObjectInternals, boundKey] of KEY_BINDING.connectedObjectInternals) { 424 | connectedObjectInternals.resolve(boundKey, value); 425 | } 426 | } 427 | 428 | // ...resolve parent 429 | if (this.parentInternals) { 430 | this.parentInternals.resolve(this.ownPropertyName, this.owner); 431 | } 432 | 433 | } 434 | 435 | Queue.flush(); 436 | 437 | } 438 | 439 | } -------------------------------------------------------------------------------- /src/modules/component/README.md: -------------------------------------------------------------------------------- 1 | # Sekoia Components 2 | 3 | ##### Data driven custom elements that don't suck. 4 | 5 | > Sekoia Components give your UI code structure by making native customElements reactive and composable while enforcing one-way data flow and facilitating 6 | architecturally clean access to the DOM. 7 | Built on living web standards, micro-optimized for performance while providing clean and scalable application design conventions. 8 | 9 | - Composable, data-driven customElements 10 | - Zero use of shadow DOM, soft-scoped CSS 11 | - Zero use of {{ templating }} and zero logic in markup 12 | - Architecturally clean, programmatic DOM access 13 | - Micro-optimized, async rendering and pooled models 14 | 15 | *** 16 | ### 👨‍💻 Getting Started 17 | Let's take a look at a really simple Component written with Sekoia: 18 | 19 | ```javascript 20 | import { defineComponent, createElement } from './build/sekoia.module.js'; 21 | 22 | export const MyComponent = defineComponent('my-component', { 23 | 24 | element: (` 25 |

26 | 27 | 28 | `), 29 | 30 | style: (` 31 | $self { 32 | position: absolute; 33 | } 34 | $self:hover { 35 | opacity: 0.75; 36 | } 37 | $title { 38 | text-transform: uppercase; 39 | } 40 | `), 41 | 42 | state: { 43 | users: { 44 | value: new ReactiveArray([ 45 | {firstName: 'John', lastName: 'Williams'}, 46 | {firstName: 'Hans', lastName: 'Zimmer'} 47 | ]), 48 | render: { 49 | parentElement: '$userList', 50 | createChild: user => createElement(`
  • ${user.firstName} ${user.lastName}
  • `) 51 | } 52 | }, 53 | title: { 54 | value: ({users}) => users.length ? 'Our Users' : 'We have no users...', 55 | render({$title}, value) { 56 | $title.textContent = value; 57 | } 58 | } 59 | }, 60 | 61 | initialize({$deleteButton}) { 62 | $deleteButton.addEventListener('click', () => { 63 | this.state.get('users').clear(); 64 | }); 65 | } 66 | 67 | }); 68 | ``` 69 | 70 | Let's break down the Component piece by piece: 71 | 72 | ### 🖼 Component.element 73 | Plain old static HTML - your components' skeleton. 74 | Your markup should be truly static. Don't include anything that should dynamically change here. We'll take care of dynamic parts later. 75 | ```javascript 76 | { 77 | element: ` 78 |

    79 | 80 | 81 | ` 82 | } 83 | ``` 84 | 85 | > Note the special "$" attribute. Sekoia automatically parses your component and passes these "$refs" to all render and lifecycle callbacks 86 | for pre-cached programmatic access. (Yes, inspired by jQuery, and I'm not ashamed to admit it). 87 | *** 88 | ### 🎨 Component.style 89 | Plain old CSS - with a twist. 90 | The CSS written here will be softly scoped to your component. Soft scoping means that outside, global CSS can still reach into the 91 | component for global theming etc via classes. Sekoia simply prepends all selectors with the tag name of the component. 92 | Refs like "$title" can be used as style selectors as is. 93 | 94 | With that in mind Sekoia will internally convert: 95 | ```css 96 | $self { 97 | position: absolute; 98 | } 99 | $self:hover { 100 | opacity: 0.75; 101 | } 102 | $title { 103 | text-transform: uppercase; 104 | } 105 | ``` 106 | into: 107 | 108 | ```css 109 | my-component { 110 | position: absolute; 111 | } 112 | my-component:hover { 113 | opacity: 0.75; 114 | } 115 | my-component .title0 { 116 | text-transform: uppercase; 117 | } 118 | ``` 119 | ...and append these rules to a global stylesheet that is used by all instances of the component. 120 | Note that $title has been re-written to a runtime globally unique classname. 121 | 122 | ##### Escaping scope 123 | You may want to style your components based on global classes attached to an ancestor element like 124 | ```body``` while keeping all of your style definitions inside of the component. That's easy: 125 | ```css 126 | /* Escape component scope via :root */ 127 | :root body.isLandscape $self { 128 | position: absolute; 129 | } 130 | /* Becomes -> */ 131 | body.isLandscape my-component { 132 | position: absolute; 133 | } 134 | ``` 135 | 136 | *** 137 | 138 | ### 🧬 Component.state 139 | 140 | Think of state as a simple, high-level description of the moving parts of your component. 141 | This is the data that components need to somehow display to the user. 142 | 143 | > Internally components consume Sekoia's observable reactive state modules. For an in-depth 144 | understanding of state modeling and reactivity concepts see: 145 | [Reactive State Documentation](../state) 146 | 147 | ### 🎞 Rendering 148 | 149 | *A built-in shortcut for state.observe(property)*
    150 | 151 | Render callbacks are reactions that fire in response to data changes and update fragments of DOM. 152 | ```javascript 153 | state: { 154 | 155 | title: { 156 | value: ({users}) => users.length ? 'Our Users' : 'We have no users...', 157 | render({$title}, value) { 158 | $title.textContent = value; 159 | } 160 | }, 161 | 162 | users: { 163 | value: [ 164 | {firstName: 'John', lastName: 'Williams'}, 165 | {firstName: 'Hans', lastName: 'Zimmer'} 166 | ], 167 | render({$userList}, user) { 168 | $userList.innerHTML = value.map(user => (`
  • ${user.firstName}
  • `)); 169 | } 170 | } 171 | 172 | } 173 | ``` 174 | Render callbacks receive an object of all "$ref" elements as their first argument. For convenience, you can destructure the elements 175 | you need to manipulate directly in the parameter. 176 | The second parameter is the value of the data property that has changed in the state and subsequently triggered the reaction. 177 | 178 | The single responsibility of reactions is to update the DOM. You cannot access "this" inside of reactions for "this" reason. 179 | All you should need is the $ref elements you want to update with the value of the data property that has changed. 180 | 181 | > When you work with $refs, you directly target real DOM nodes - there is no abstraction layer - render callbacks thus offer 182 | incredible performance. 183 | And because these callbacks are only running in response to actual changes of the data model, even complex Sekoia Components never become 184 | slow, hard to predict or maintain. 185 | 186 | #### List rendering and reconciliation 187 | For high-performance list rendering the ```render``` property should be a configuration object 188 | that Sekoia internally passes to the high performance DOM reconciler. 189 |
    190 | - See the [TodoMVC Example](../../../examples/TODO%20MVC) for an implementation 191 | ````javascript 192 | users: { 193 | value: new ReactiveArray([ 194 | {firstName: 'John', lastName: 'Williams'}, 195 | {firstName: 'Hans', lastName: 'Zimmer'} 196 | ]), 197 | render: { 198 | parentElement: '$userList', // ref name 199 | createChild: user => { 200 | // requried: receives data entry from array and returns a DOM node 201 | return createElement(`
  • ${user.firstName} ${user.lastName}
  • `) 202 | }, 203 | updateChild: ($child, user) => { 204 | // optional: update child node in-place with new data 205 | } 206 | } 207 | } 208 | ```` 209 | The ```value``` property should be an instance of ```ReactiveArray```. When the child elements are Sekoia 210 | Components with their own internal state, pass the state factory to ```ReactiveArray``` as an instantiation model. 211 | 212 | The render configuration object requires a ```createChild``` method which turns each entry in the data array into a DOM Node. 213 | Sekoia is powered by an ultra-fast reconciliation algorithm under the hood that updates only those parts of the DOM that 214 | were affected by changes in data. It works for mutable and immutable data and will be fast whether you push() into an 215 | array or whether you prefer immutable data and replace the entire array. You can optionally speed up the reconciliation 216 | even further by providing an ```"updateElement"``` function which updates elements in-place instead of replacing them. 217 | *** 218 | 219 | ### ⚙️ Setting and getting state 220 | You can read and write state data inside of lifecycle and custom top-level methods via ```this.state```. 221 | 222 | ```MyComponent.state.get('property') | MyComponent.state.set('property', value) | MyComponent.state.set({prop: value, prop: value})``` 223 | 224 | Whenever the value of a state property changes, its corresponding render callback is added to Sekoia's asynchronous render queue. 225 | Sekoia automatically determines if the value has actually changed and only then queues up the reaction. 226 | And if for whatever reason the value changes one million times before the next frame is rendered, 227 | the reaction will still only be fired once with the most recent value - thanks to Sekoia's powerful auto-buffering renderer. 228 | 229 | > Note: State only accepts data that matches the shape and type of the state model's default data. 230 | See [Reactive State Documentation](../state) for details 231 | 232 | *** 233 | 234 | ### 👋 Component.initialize 235 | The only Lifecycle method Sekoia Components need is called once per component instance, after the component has been 236 | inserted into the DOM. Receives "$refs" as first and only argument. Typically, this is where you would bind input events, 237 | retrieve server data etc. 238 | 239 | ```javascript 240 | initialize({$self, $toggle, $list}) { 241 | 242 | // bind events to $refs... 243 | $toggle.addEventListener('click', e => { 244 | this.handleClick(); 245 | }); 246 | 247 | // ... or delegate to the component itself 248 | this.addEventListener('click', e => { 249 | if ($toggle.contains(e.target)) { 250 | this.handleClick(); 251 | } 252 | }); 253 | 254 | console.assert($self === this); 255 | 256 | }, 257 | 258 | handleClick() { 259 | // The state's render callback will update the DOM in response. 260 | this.state.set('active', !this.state.get('active')); 261 | } 262 | ``` 263 | 264 | *** 265 | 266 | ### 🪆 Composing Components 267 | There are multiple ways Sekoia helps with composing customElements. The first and probably least obvious, is that 268 | Sekoia Components do not use shadow DOM. Everything inside ```element``` is composed into the elements light DOM. That 269 | allows us to easily compose other components into our markup by including the components tag name. 270 | 271 | #### Factory({...attributes}) 272 | For convenience and first-class ES module support, ```defineComponent``` returns a factory function that 273 | can be used to render a component's tag name along with attributes into the markup of another component. 274 | A common pattern looks like this: 275 | 276 | child-component.js: 277 | 278 | ```javascript 279 | export const ChildComponent = defineComponent('child-component', { 280 | element: (` 281 |

    282 | 283 | 284 | `) 285 | }); 286 | ``` 287 | 288 | parent-component.js: 289 | 290 | ```javascript 291 | import { ChildComponent } from './child-component.js'; 292 | 293 | const ParentComponent = defineComponent('parent-component', { 294 | element: (` 295 |
    Hello world
    296 |
    ${ChildComponent({class: 'pos-rel flex-row'})}
    297 | `) 298 | }); 299 | ``` 300 | 301 | You can pass an attribute object to the factory function returned by ```defineComponent``` 302 | and it's properties will be mapped to standard DOM attributes. It's also possible to inject state 303 | into a component via the attributes object. You don't have to serialize the state object - Sekoia 304 | uses temporary attribute reflection to inject the state into the component when it is attached 305 | to the DOM. 306 | ```javascript 307 | ChildComponent({ 308 | class: 'pos-rel pd-250', 309 | style: 'will-change: opacity', // use inline dom-strings 310 | state: { // state can be object 311 | user: { 312 | name: 'Jonathan' 313 | } 314 | } 315 | }); 316 | 317 | // returns: 'deferred until the last possible moment. Multiple instances of 338 | components share the same ComponentModel prototype and template for greatly optimized memory consumption and rendering performance. 339 | When the first instance of a Component is attached to the DOM for the first time, the ComponentModel sets itself up once by 340 | scoping the CSS, collecting $refs and setting up the state model. All subsequent instances of the Component pull from the 341 | ComponentModel's pooled template, styles and state model with no additional roundtrips to expensive DOM APIs or state resolvers. 342 | 343 | > The extensive pooling and caching of resolved data structures allows Sekoia Components to outperform most frameworks and 344 | > even native customElements while providing clean architectural conventions for scalable application design. 345 | 346 | 347 | 348 | ### License 349 | ``` 350 | Sekoia.js 351 | Copyright (C) 2022 Jonathan M. Ochmann 352 | 353 | This program is free software: you can redistribute it and/or modify 354 | it under the terms of the GNU General Public License as published by 355 | the Free Software Foundation, either version 3 of the License, or 356 | (at your option) any later version. 357 | 358 | This program is distributed in the hope that it will be useful, 359 | but WITHOUT ANY WARRANTY; without even the implied warranty of 360 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 361 | GNU General Public License for more details. 362 | 363 | You should have received a copy of the GNU General Public License 364 | along with this program. If not, see https://www.gnu.org/licenses. 365 | ``` 366 | *** 367 | ### Author 368 | Jonathan M. Ochmann (@monokee) 369 | 375 | 376 | *** 377 | Made with ♥️ in CGN | (C) Patchflyer GmbH 2014-2049 -------------------------------------------------------------------------------- /src/modules/state/README.md: -------------------------------------------------------------------------------- 1 | # Sekoia Reactivity Engine 2 | Reactive, observable state with pooled models and fully automatic type safety. 3 | 4 | - Micro-optimized, fully observable Objects and Arrays 5 | - Computed properties with smart caching 6 | - External state bindings 7 | - ATS Automatic Type Safety 8 | - Built-in time travel (undo, redo, etc) 9 | - Semantic Private Properties 10 | - Async Observer resolution 11 | - Built-in defer/throttle hooks 12 | 13 | Sekoia exports ```ReactiveObject``` and ```ReactiveArray```. These classes are driving the 14 | reactivity of Sekoia Components but are designed as general purpose state containers that 15 | can be used for any kind of reactive and observable data. 16 | 17 | ## 👨‍💻 Getting started 18 | 19 | Let's create a simple reactive state tree: 20 | ```javascript 21 | import { ReactiveObject, ReactiveArray } from './store'; 22 | 23 | const State = new ReactiveObject({ 24 | firstName: 'Jonathan', 25 | lastName: 'Ochmann', 26 | nickName: 'Jon', 27 | fullName: ({firstName, lastName}) => `${firstName} ${lastName}`, 28 | greeting: ({fullName, nickName}) => `Hey, I'm ${fullName} but my friends call me ${nickName}!`, 29 | friends: new ReactiveArray([], { 30 | model: data => State.clone(data) 31 | }) 32 | }); 33 | ``` 34 | Now we can [observe](#observekey-callback-options--cancelable-false-throttle-0--defer-0) any changes to this state, [track](#trackkey-options--maxentries-100-ontrack-fn-throttle-0--defer-0) mutations over time, stamp out serializable 35 | [snapshots](#snapshotkey), [reset](#resetkey) to default and create new instances of the state model via [cloning](#clonedata). 36 | 37 | ### Overview 38 | 39 | There are a few concepts that distinguish ReactiveObjects from regular objects and Sekoia's reactive architecture from 40 | traditional state modeling: 41 | - Calling new ReactiveObject() creates both an instance and a model based on the property object passed into the constructor. 42 | - Additional instances of the object are created by [cloning](#clonedata) the object with new data. 43 | - This gives your state default data - so it can be [reset](#resetkey) with one line of code. 44 | - Defaults allow Sekoia to [infer the type of primitives and the shape of objects](#setkey-value) in your model. No need for additional type checking. 45 | - Based on the type and shape information, Sekoia will automatically reject data that doesn't [fit the model.](#setkey-value) 46 | 47 | Sekoia caches property types, shape information and computed property resolution in a pooled model that is re-used by all instances of the reactive object. 48 | Nested reactive objects have to be created explicitly - normal objects and arrays inside of reactive state models are treated like immutable primitives and are deep-compared by value, 49 | meaning they can only be overwritten in their entirety. Nested reactive objects bubble their internal changes to their reactive parent objects. 50 | Bubbling can be prevented by making properties [private](#-private-properties). 51 | 52 | #### 🧮 Computed Properties 53 | You can specify computed properties simply by adding a pure function as a property value. 54 | ```javascript 55 | const State = new ReactiveObject({ 56 | prop: 1, 57 | plusOne: ({prop}) => prop + 1 58 | }); 59 | ``` 60 | Computed property functions receive an object containing all other properties (including other computed properties) as the 61 | first and only argument. You should always destructure the properties your computation depends on 62 | directly in the parameter. This ensures that all dependencies can be resolved. 63 | The computation should return its result. 64 | Computed properties can be derived from other computed properties, a mix of computed and non-computed properties etc. Evaluation 65 | of computed properties is optimized so that they are only re-evaluated when any of their immediate dependencies have changed. 66 | Nothing in Sekoia is ever re-computed or observed when it's not absolutely necessary. 67 | 68 | #### 🔁 Bound Properties 69 | You can directly [bind](#bindkey) any property in your state model to other reactive objects. 70 | This allows different data stores to share and access the exact same data. When the data in a store changes, 71 | all objects which bind to the changed property in the store are automatically updated. 72 | ```javascript 73 | const MyStore = new ReactiveObject({ 74 | thePropertyInTheStore: 123 75 | }); 76 | 77 | const HerStore = new ReactiveObject({ 78 | storeBound: MyStore.bind('thePropertyInTheStore') 79 | }); 80 | ``` 81 | Note that any computations and observers will be fired when the data in the store changes. The data can change 82 | by directly calling ```ExternalStore.set('thePropertyInTheStore', value)``` or, when dealing with internal [Component State](../component) which is bound to the Store via 83 | ```Component.state.set('storeBound', value)``` 84 | It is not possible to bind _private properties. It is only possible to bind to private properties if the bound 85 | property is a readonly computed property. 86 | 87 | #### 🥷 Private Properties 88 | Prefixing keys with an underscore marks properties in ReactiveObjects as private. 89 | Private properties: 90 | 91 | - Do not trigger [wildcard observers](#observekey-callback-options--cancelable-false-throttle-0--defer-0). 92 | - Do not propagate changes to parent ReactiveObject or ReactiveArray 93 | - Can not be bound to other objects 94 | - Can bind to non-private computed properties on other ReactiveObjects 95 | - Are accessible like any other properties via get('_property') 96 | 97 | *** 98 | 99 | ## Methods 100 | These methods are available on all instances of ReactiveObject and ReactiveArray. 101 | The expected behaviour is documented for ReactiveObject first with some [special exceptions for ReactiveArray](#reactivearray-specialties) at the bottom of the section. 102 | 103 | ##### get([key]) 104 | returns data of key or entire object if key is undefined. 105 | > When retrieving nested ReactiveObjects or ReactiveArrays, the actual Reactive interface is returned. 106 | > To retrieve plain, serializable data call snapshot() on the returned interface or retrieve a snapshot 107 | > directly from the reactive parent. 108 | 109 | ##### default([key]) 110 | Returns a deep clone the initial data value that was passed when the model was defined on the first object instantiation. 111 | Returns entire object when no key is provided. 112 | 113 | ##### snapshot([key]) 114 | Returns a deep clone of writable (serializable) properties. Useful for persistence and immutable state updates. 115 | ```javascript 116 | State.snapshot() === { 117 | firstName: 'Jonathan', 118 | lastName: 'Ochmann', 119 | nickname: 'Jon', 120 | friends: [] 121 | } 122 | ``` 123 | 124 | ##### set([key], value) 125 | Assigns target data to object if target matches the type (in case of primitives) or shape (in case of objects) of the state model. 126 | The algorithm works with arbitrarily nested data structures consisting of { plain: objects } and [ plain, arrays ]. 127 | ```javascript 128 | State.set('firstName', 'Terry'); 129 | 130 | State.set({ 131 | firstName: 'Terry', 132 | lastName: 'Gilliam', 133 | whatever: [] // will be ignored because it's not part of the model 134 | }); 135 | 136 | // won't do anything. firstName must be String. 137 | State.set('firstName', 123); 138 | 139 | // will throw - Do not set computed properties (duh!) 140 | State.set('greeting', 'Goodbye'); 141 | ``` 142 | > **Equality rules for patching data:** 143 | > Let source be the ReactiveObject and target be invading data. 144 | > When source and target are both primitives, their type must match, but their value must be different in order to be assigned. 145 | > When source and target are objects, the algorithm recursively applies the target object's properties to the source object's properties. 146 | > The target object must deeply match the source object's shape. This means that the property keys must match, and the property values 147 | > must match type. In other words, target objects are not allowed to add or remove properties from source object (when both are plain objects) 148 | > and the property values of target must recursively match the shape or type of the source object's property values. 149 | > Any target property value that does not match it's corresponding source property value does not get assigned. 150 | > Mismatches do not throw errors - the algorithm will default to the source property value and continue to attempt to 151 | > assign any remaining target property values that match. When an actual assignment happens, and dependent observers are queued. 152 | > Arrays are treated similar to plain objects with an important distinction: 153 | > Arrays are allowed to change length. When source is an empty array, we push any items from the target array 154 | > into source because we have no way to compare existing items. When source is an array that has items and target is an array 155 | > that has more items than source, any added items must match the shape or type of the last item in the source array. 156 | > When the target array is shorter than or equal in length to the source array, we patch each item recursively. 157 | 158 | ##### reset([key]) 159 | Resets a state object to the data passed with the initial model definition (i.e the first object creation) 160 | 161 | ##### clone([data]) 162 | Creates a new instance of the object model and immediately assigns the passed data. Data must match the model's shape. 163 | 164 | ```javascript 165 | // use "State" as a model to instantiate 166 | // a new person by passing in new props. note 167 | // that we're not providing computed properties or 168 | // nested reactive objects - only plain serializable snapshots 169 | const User = State.clone({ 170 | firstName: 'Paul', 171 | lastName: 'Anderson', 172 | nickName: 'PTA', 173 | friends: [ 174 | State.snapshot(), // create a snapshot of the first person 175 | {firstName: 'Snoop', lastName: 'Dog', nickName: 'Lion'} 176 | ] 177 | }); 178 | ``` 179 | 180 | ##### observe(key, callback, [options = {cancelable: false, throttle: 0 / defer: 0}]) 181 | Registers a callback function that is fired when the observed keys' value changed. 182 | The callback receives the current value as the only argument. 183 | Observers fire once immediately after registration. 184 | When throttle or defer > 0 the observer is throttled or deferred to the provided interval in milliseconds. 185 | Returns unobserve function when ```options.cancelable = true```, undefined otherwise. 186 |
    187 | 188 | ```javascript 189 | State.observe('greeting', value => { 190 | // fires whenever greeting computes a new value 191 | // and once, immediately after registration 192 | }); 193 | 194 | const unobserve = State.observe('*', value => { 195 | // wildcard listener fires when any 196 | // public property (see "Private Properties") 197 | // in the object has changed, including propagated 198 | // changes from nested reactive objects. 199 | }, { 200 | cancelable: true, // make observe() return an unobserve function 201 | throttle: 250 // execute no more than once every 250ms 202 | }); 203 | ``` 204 | > It is theoretically possible that throttled or deferred observers may fire 205 | the same state value they fired previously. Consider this scenario:
    206 | (1) Observed state changes to a new value.
    207 | (2) Sekoia queues the state observer.
    208 | (3) The observer is waiting for it's defer/throttle timeout to pass.
    209 | (4) The state value changes back to the initial value while the observer is waiting.
    210 | (5) The observer fires with the same value it fired previously.
    211 | 212 | ##### bind(key) 213 | Returns a binding which can be implemented as a property value on other objects. Source property must 214 | be public. Target property must be public unless it is bound to a [readonly computed property](#-computed-properties). 215 | ```javascript 216 | // The "source of truth" object 217 | const StateA = new ReactiveObject({ 218 | foo: 123, 219 | _baz: 456, 220 | uff: ({foo, _baz}) => foo + baz 221 | }); 222 | 223 | // The object bound to source object 224 | const StateB = new ReactiveObject({ 225 | bar: StateA.bind('foo'), 226 | boo: StateA.bind('_baz'), // -> THROWS. Do not bind private properties. See "Private Properties" 227 | _baz: StateA.bind('foo'), // -> THROWS. Do not bind private properties to writable properties. 228 | _ok: StateA.bind('uff') // -> Works because "uff" is readonly (and public) 229 | }); 230 | 231 | ``` 232 | 233 | ##### track(key, [options = {maxEntries: 100, onTrack: fn, throttle: 0 / defer: 0}]) 234 | Record all state changes for time-travel. Records entire object when 235 | key is wildcard. 236 |
    237 | ```options.maxEntries``` determines how many state changes 238 | are recorded before old state changes get removed from the beginning of the 239 | internal history track.
    240 | ```options.onTrack(𝑓)``` is a callback function that is invoked 241 | whenever you time-travel to a tracked state. 242 |
    243 | ```javascript 244 | State.track('prop', { 245 | maxEntries: 500, 246 | throttle: 250, // EITHER: track no more than once every 250ms 247 | defer: 250, // OR: track 250ms after the last state change has occured 248 | onTrack: (state, trackPosition, trackLength) => { 249 | // update the ui or something 250 | } 251 | }) 252 | ``` 253 | > Recorded states are guaranteed to be unique - even when asynchronously throttled or deferred. 254 | 255 | ##### undo(key) 256 | Time travel to the last tracked state change. Requires that object or key is being tracked. 257 | 258 | ##### redo(key) 259 | Time travel to next tracked state change. Requires that object or key is being tracked. 260 | 261 | ##### restore(key, trackPosition) 262 | Time travel to specified trackPosition. trackPosition is an index in the internal state 263 | history array. 264 | 265 | ## ReactiveArray Specialties 266 | ReactiveArrays have some important distinctions from ReactiveObjects: 267 | 268 | - Most obviously, ReactiveArrays can change lengths, or, in other words, as opposed to ReactiveObjects, their property keys (indices) are allowed to change. 269 | - Individual indices cannot be observed. ```ReactiveArray.observe(handler => {})``` does not receive a "key" argument. All observers are wildcard observers that react to anything happening within the array. 270 | - ReactiveArray can be instantiated with a model creation function that transforms any data that is added to the array at runtime. 271 | - ReactiveArrays have no computed properties, no bindings and no private keys. 272 | 273 | In addition to the methods from ReactiveObject, ReactiveArray also implements reactive versions of all methods from Array.prototype 274 |
    275 | ##### Accessors and Iterators 276 | 277 | - every 278 | - some 279 | - findIndex 280 | - findLastIndex 281 | - includes 282 | - indexOf 283 | - lastIndexOf 284 | - find 285 | - slice 286 | - concat 287 | - forEach 288 | - map 289 | - filter 290 | - reduce 291 | 292 | ##### Mutators 293 | 294 | - pop 295 | - push 296 | - shift 297 | - unshift 298 | - splice 299 | - reverse 300 | - sort 301 | - filterInPlace (just like filter() but mutating) 302 | - concatInPlace(array, prepend = false) -> add array contents to end or beginning 303 | - clear (removes all items from array) 304 | 305 | > When ReactiveArrays contain nested ReactiveObjects, the object patching rules described above apply for all mutators as well. 306 | 307 | *** 308 | 309 | #### 💾 Persistence 310 | Sekoia provides a simple Promise-based IndexedDB abstraction for client-side data persistence. 311 | 312 | ```javascript 313 | const storage = new PeristentStorage({ 314 | name: 'userData', 315 | onUnavailable: error => { 316 | // will fall-back to in-memory storage. 317 | alert('Failed to save data to disk. Your data will be gone when you reload the page.') 318 | } 319 | }); 320 | 321 | storage.set('key', anything).then(() => { 322 | console.log('stored successfully'); 323 | }); 324 | 325 | storage.has('key').then(exists => { 326 | console.log('key exist ===', exists); 327 | }); 328 | 329 | storage.get('key').then(value); 330 | 331 | storage.delete('key').then(itsDeleted); 332 | storage.clear().then(itsEmpty); 333 | storage.destroy().then(itsGone); 334 | ``` 335 | 336 | 337 | ### License 338 | ``` 339 | Sekoia.js 340 | Copyright (C) 2022 Jonathan M. Ochmann 341 | 342 | This program is free software: you can redistribute it and/or modify 343 | it under the terms of the GNU General Public License as published by 344 | the Free Software Foundation, either version 3 of the License, or 345 | (at your option) any later version. 346 | 347 | This program is distributed in the hope that it will be useful, 348 | but WITHOUT ANY WARRANTY; without even the implied warranty of 349 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 350 | GNU General Public License for more details. 351 | 352 | You should have received a copy of the GNU General Public License 353 | along with this program. If not, see https://www.gnu.org/licenses. 354 | ``` 355 | *** 356 | ### Author 357 | Jonathan M. Ochmann (@monokee) 358 | 364 | 365 | *** 366 | Made with ♥️ in CGN | (C) Patchflyer GmbH 2014-2049 -------------------------------------------------------------------------------- /src/modules/router/router.js: -------------------------------------------------------------------------------- 1 | import { deepClone } from "../utils/deep-clone.js"; 2 | 3 | const LOCATION = window.location; 4 | const HISTORY = window.history; 5 | const ORIGIN = LOCATION.origin + LOCATION.pathname; 6 | 7 | const ABSOLUTE_ORIGIN_NAMES = [ORIGIN, LOCATION.hostname, LOCATION.hostname + '/', LOCATION.origin]; 8 | if (ORIGIN[ORIGIN.length - 1] !== '/') { 9 | ABSOLUTE_ORIGIN_NAMES.push(ORIGIN + '/'); 10 | } 11 | if (LOCATION.pathname && LOCATION.pathname !== '/') { 12 | ABSOLUTE_ORIGIN_NAMES.push(LOCATION.pathname); 13 | } 14 | 15 | const ALLOWED_ORIGIN_NAMES = ['/', '#', '/#', '/#/', ...ABSOLUTE_ORIGIN_NAMES]; 16 | const ORIGIN_URL = new URL(ORIGIN); 17 | const CLEAN_ORIGIN = removeTrailingSlash(ORIGIN); 18 | 19 | const REGISTERED_FILTERS = new Map(); 20 | const REGISTERED_ACTIONS = new Set(); 21 | 22 | const WILDCARD_ACTIONS = []; 23 | let WILDCARD_FILTER = null; 24 | 25 | const ROUTES_STRUCT = {}; 26 | 27 | const DEFAULT_TRIGGER_OPTIONS = { 28 | params: {}, 29 | keepQuery: true, 30 | forceReload: false, 31 | history: 'pushState' 32 | }; 33 | 34 | let HAS_POPSTATE_LISTENER = false; 35 | let CURRENT_QUERY_PARAMETERS = buildParamsFromQueryString(LOCATION.search); 36 | let CURRENT_ROUTE_FRAGMENTS = ['/']; 37 | if (LOCATION.hash) { 38 | CURRENT_ROUTE_FRAGMENTS.push(...LOCATION.hash.split('/')); 39 | } 40 | 41 | export const Router = { 42 | 43 | registerRedirects(redirects) { 44 | 45 | /** 46 | * A higher level abstraction over Router.before. 47 | * Register dynamic redirect hooks for individual routes. 48 | * Use wildcard * to redirect any request conditionally. 49 | * Example: 50 | * redirects = { 51 | * '/': '#home', -> redirect every root request to #home 52 | * '#public': false -> don't redirect. same as omitting property completely 53 | * '#protected': queryParams => { 54 | * if (System.currentUser.role !== 'admin') { 55 | * return '#403' -> redirect non-admins to 403 page. else undefined is returned so we don't redirect. 56 | * } 57 | * } 58 | *} 59 | * */ 60 | 61 | const requestPermission = (path, params, respondWith) => { 62 | 63 | // when no filter is registered for this path we allow it 64 | if (!redirects.hasOwnProperty(path)) { 65 | return respondWith(path); 66 | } 67 | 68 | const filter = redirects[path]; 69 | const redirect = typeof filter === 'function' ? filter(params) : filter; 70 | 71 | if (!redirect || typeof redirect !== 'string') { // falsy values don't redirect 72 | respondWith(path); 73 | } else { // redirect non-empty strings 74 | respondWith(redirect); 75 | } 76 | 77 | }; 78 | 79 | for (const path in redirects) { 80 | if (redirects.hasOwnProperty(path) && !this.hasFilter(path)) { 81 | this.before(path, requestPermission); 82 | } 83 | } 84 | 85 | return this; 86 | 87 | }, 88 | 89 | before(route, filter) { 90 | 91 | if (typeof route === 'object') { 92 | 93 | for (const rt in route) { 94 | if (route.hasOwnProperty(rt)) { 95 | this.on(rt, route[rt]); 96 | } 97 | } 98 | 99 | } else { 100 | 101 | addPopStateListenerOnce(); 102 | 103 | if (route === '*') { 104 | 105 | if (WILDCARD_FILTER !== null) { 106 | console.warn('Router.before(*, filter) - overwriting previously registered wildcard filter (*)'); 107 | } 108 | 109 | WILDCARD_FILTER = filter; 110 | 111 | } else { 112 | 113 | const { hash } = getRouteParts(route); 114 | 115 | if (REGISTERED_FILTERS.has(hash)) { 116 | throw new Error(`Router.beforeRoute() already has a filter for ${hash === '#' ? `${route} (root url)` : route}`); 117 | } 118 | 119 | REGISTERED_FILTERS.set(hash, filter); 120 | 121 | } 122 | 123 | } 124 | 125 | return this; 126 | 127 | }, 128 | 129 | on(route, action) { 130 | 131 | if (typeof route === 'object') { 132 | 133 | for (const rt in route) { 134 | if (route.hasOwnProperty(rt)) { 135 | this.on(rt, route[rt]); 136 | } 137 | } 138 | 139 | } else { 140 | 141 | addPopStateListenerOnce(); 142 | 143 | if (route === '*') { 144 | 145 | if (WILDCARD_ACTIONS.indexOf(action) === -1) { 146 | WILDCARD_ACTIONS.push(action); 147 | } 148 | 149 | } else { 150 | 151 | const {hash} = getRouteParts(route); 152 | 153 | if (REGISTERED_ACTIONS.has(hash)) { 154 | throw new Error('Router.onRoute() already has a action for "' + hash === '#' ? (route + ' (root url)') : route + '".'); 155 | } 156 | 157 | REGISTERED_ACTIONS.add(hash); 158 | 159 | assignActionToRouteStruct(hash, action); 160 | 161 | } 162 | 163 | } 164 | 165 | return this; 166 | 167 | }, 168 | 169 | resolve(options = {}) { 170 | // should be called once after all filters and actions have been registered 171 | this.navigate(LOCATION.href, options); 172 | return this; 173 | }, 174 | 175 | hasFilter(route) { 176 | if (route === '*') { 177 | return WILDCARD_FILTER !== null; 178 | } else { 179 | const { hash } = getRouteParts(route); 180 | return REGISTERED_FILTERS.has(hash); 181 | } 182 | }, 183 | 184 | hasAction(route) { 185 | if (route === '*') { 186 | return WILDCARD_ACTIONS.length > 0; 187 | } else { 188 | const { hash } = getRouteParts(route); 189 | return REGISTERED_ACTIONS.has(hash); 190 | } 191 | }, 192 | 193 | navigate(route, options = {}) { 194 | 195 | if (route.lastIndexOf('http', 0) === 0 && route !== LOCATION.href) { 196 | 197 | LOCATION.href = route; 198 | 199 | } else { 200 | 201 | const {hash, query, rel} = getRouteParts(route); 202 | 203 | options = Object.assign({}, DEFAULT_TRIGGER_OPTIONS, options); 204 | 205 | if (options.keepQuery === true) { 206 | Object.assign(CURRENT_QUERY_PARAMETERS, buildParamsFromQueryString(query)); 207 | } else { 208 | CURRENT_QUERY_PARAMETERS = buildParamsFromQueryString(query); 209 | } 210 | 211 | // Filters 212 | if (WILDCARD_FILTER) { // 1.0 - Apply wildcard filter 213 | 214 | WILDCARD_FILTER(rel, CURRENT_QUERY_PARAMETERS, response => { 215 | 216 | if (response !== rel) { 217 | 218 | reRoute(response); 219 | 220 | } else { 221 | 222 | if (REGISTERED_FILTERS.has(hash)) { // 1.1 - Apply route filters 223 | 224 | REGISTERED_FILTERS.get(hash)(rel, CURRENT_QUERY_PARAMETERS, response => { 225 | 226 | if (response && typeof response === 'string') { // only continue if response is truthy and string 227 | 228 | if (response !== rel) { 229 | 230 | reRoute(response); 231 | 232 | } else { 233 | 234 | performNavigation(hash, query, options.keepQuery, options.history); 235 | 236 | } 237 | 238 | } 239 | 240 | }); 241 | 242 | } else { 243 | 244 | performNavigation(hash, query, options.keepQuery, options.history); 245 | 246 | } 247 | 248 | } 249 | 250 | }); 251 | 252 | } else if (REGISTERED_FILTERS.has(hash)) { // 2.0 - Apply route filters 253 | 254 | REGISTERED_FILTERS.get(hash)(rel, CURRENT_QUERY_PARAMETERS, response => { 255 | 256 | if (response && typeof response === 'string') { 257 | 258 | if (response !== rel) { 259 | 260 | reRoute(response); 261 | 262 | } else { 263 | 264 | performNavigation(hash, query, options.keepQuery, options.history); 265 | 266 | } 267 | 268 | } 269 | 270 | }); 271 | 272 | } else { 273 | 274 | performNavigation(hash, query, options.keepQuery, options.history); 275 | 276 | } 277 | 278 | } 279 | 280 | return this; 281 | 282 | }, 283 | 284 | getQueryParameters(key) { 285 | if (!key) { 286 | return Object.assign({}, CURRENT_QUERY_PARAMETERS); 287 | } else { 288 | return CURRENT_QUERY_PARAMETERS[key]; 289 | } 290 | }, 291 | 292 | addQueryParameters(key, value) { 293 | 294 | if (typeof key === 'object') { 295 | for (const k in key) { 296 | if (key.hasOwnProperty(k)) { 297 | CURRENT_QUERY_PARAMETERS[k] = key[k]; 298 | } 299 | } 300 | } else { 301 | CURRENT_QUERY_PARAMETERS[key] = value; 302 | } 303 | 304 | updateQueryString(); 305 | 306 | return this; 307 | 308 | }, 309 | 310 | setQueryParameters(params) { 311 | CURRENT_QUERY_PARAMETERS = deepClone(params); 312 | updateQueryString(); 313 | return this; 314 | }, 315 | 316 | removeQueryParameters(key) { 317 | 318 | if (!key) { 319 | CURRENT_QUERY_PARAMETERS = {}; 320 | } else if (Array.isArray(key)) { 321 | key.forEach(k => { 322 | if (CURRENT_QUERY_PARAMETERS[k]) { 323 | delete CURRENT_QUERY_PARAMETERS[k]; 324 | } 325 | }); 326 | } else if (CURRENT_QUERY_PARAMETERS[key]) { 327 | delete CURRENT_QUERY_PARAMETERS[key]; 328 | } 329 | 330 | updateQueryString(); 331 | 332 | return this; 333 | 334 | } 335 | 336 | }; 337 | 338 | function addPopStateListenerOnce() { 339 | 340 | if (!HAS_POPSTATE_LISTENER) { 341 | 342 | HAS_POPSTATE_LISTENER = true; 343 | 344 | // never fired on initial page load in all up-to-date browsers 345 | window.addEventListener('popstate', () => { 346 | Router.navigate(LOCATION.href, { 347 | history: 'replaceState', 348 | forceReload: false 349 | }); 350 | }); 351 | 352 | } 353 | 354 | } 355 | 356 | function performNavigation(hash, query, keepQuery, historyMode) { 357 | 358 | executeWildCardActions(hash); 359 | executeRouteActions(hash); 360 | 361 | ORIGIN_URL.hash = hash; 362 | ORIGIN_URL.search = keepQuery ? buildQueryStringFromParams(CURRENT_QUERY_PARAMETERS) : query; 363 | HISTORY[historyMode](null, document.title, ORIGIN_URL.toString()); 364 | 365 | } 366 | 367 | function updateQueryString() { 368 | ORIGIN_URL.search = buildQueryStringFromParams(CURRENT_QUERY_PARAMETERS); 369 | HISTORY.replaceState(null, document.title, ORIGIN_URL.toString()); 370 | } 371 | 372 | function reRoute(newRoute) { 373 | if (newRoute.lastIndexOf('http', 0) === 0) { 374 | return LOCATION.href = newRoute; 375 | } else { 376 | return Router.navigate(newRoute, { 377 | history: 'replaceState', 378 | forceReload: false 379 | }); 380 | } 381 | } 382 | 383 | function executeWildCardActions(hash) { 384 | 385 | hash = hash === '#' ? '' : hash; 386 | const completePath = CLEAN_ORIGIN + hash; 387 | 388 | for (let i = 0; i < WILDCARD_ACTIONS.length; i++) { 389 | WILDCARD_ACTIONS[i](completePath, CURRENT_QUERY_PARAMETERS); 390 | } 391 | 392 | } 393 | 394 | function executeRouteActions(hash) { 395 | 396 | const routeFragments = ['/']; 397 | 398 | if (hash !== '#') { 399 | routeFragments.push(...hash.split('/')); 400 | } 401 | 402 | // find the intersection between the last route and the next route 403 | const intersection = getArrayIntersection(CURRENT_ROUTE_FRAGMENTS, routeFragments); 404 | 405 | // recompute the last intersecting fragment + any tail that might have been added 406 | const fragmentsToRecompute = [intersection[intersection.length - 1]]; 407 | 408 | if (routeFragments.length > intersection.length) { 409 | fragmentsToRecompute.push(...getArrayTail(intersection, routeFragments)); 410 | } 411 | 412 | // find the first node that needs to be recomputed 413 | let currentRouteNode = ROUTES_STRUCT; 414 | let fragment; 415 | 416 | for (let i = 0; i < intersection.length; i ++) { 417 | 418 | fragment = intersection[i]; 419 | 420 | if (fragment === fragmentsToRecompute[0]) { // detect overlap 421 | fragment = fragmentsToRecompute.shift(); // remove first element (only there for overlap detection) 422 | break; 423 | } else if (currentRouteNode && currentRouteNode[fragment]) { 424 | currentRouteNode = currentRouteNode[fragment].children; 425 | } 426 | 427 | } 428 | 429 | // execute actions 430 | while (currentRouteNode[fragment] && fragmentsToRecompute.length) { 431 | 432 | // call action with joined remaining fragments as "path" argument 433 | if (currentRouteNode[fragment].action) { 434 | currentRouteNode[fragment].action(fragmentsToRecompute.join('/'), CURRENT_QUERY_PARAMETERS); 435 | } 436 | 437 | currentRouteNode = currentRouteNode[fragment].children; 438 | fragment = fragmentsToRecompute.shift(); 439 | 440 | } 441 | 442 | // execute last action with single trailing slash as "path" argument 443 | if (currentRouteNode[fragment] && currentRouteNode[fragment].action) { 444 | currentRouteNode[fragment].action('/', CURRENT_QUERY_PARAMETERS); 445 | } 446 | 447 | // update current route fragments 448 | CURRENT_ROUTE_FRAGMENTS = routeFragments; 449 | 450 | } 451 | 452 | function assignActionToRouteStruct(hash, action) { 453 | 454 | // create root struct if it doesnt exist 455 | const structOrigin = ROUTES_STRUCT['/'] || (ROUTES_STRUCT['/'] = { 456 | action: void 0, 457 | children: {} 458 | }); 459 | 460 | // register the route structurally so that its callbacks can be resolved in order of change 461 | if (hash === '#') { // is root 462 | 463 | structOrigin.action = action; 464 | 465 | } else { 466 | 467 | const hashParts = hash.split('/'); 468 | const leafPart = hashParts[hashParts.length - 1]; 469 | 470 | hashParts.reduce((branch, part) => { 471 | 472 | if (branch[part]) { 473 | 474 | if (part === leafPart) { 475 | branch[part].action = action; 476 | } 477 | 478 | return branch[part].children; 479 | 480 | } else { 481 | 482 | return (branch[part] = { 483 | action: part === leafPart ? action : void 0, 484 | children: {} 485 | }).children; 486 | 487 | } 488 | 489 | }, structOrigin.children); 490 | 491 | } 492 | 493 | } 494 | 495 | function getRouteParts(route) { 496 | 497 | if (ALLOWED_ORIGIN_NAMES.indexOf(route) > -1) { 498 | return { 499 | rel: '/', 500 | abs: ORIGIN, 501 | hash: '#', 502 | query: '' 503 | } 504 | } 505 | 506 | if (route[0] === '?' || route[0] === '#') { 507 | const {hash, query} = getHashAndQuery(route); 508 | return { 509 | rel: convertHashToRelativePath(hash), 510 | abs: ORIGIN + hash, 511 | hash: hash || '#', 512 | query: query 513 | } 514 | } 515 | 516 | route = removeAllowedOriginPrefix(route); 517 | 518 | if (route [0] !== '?' && route[0] !== '#') { 519 | throw new Error('Invalid Route: "' + route + '". Non-root paths must start with ? query or # hash.'); 520 | } 521 | 522 | const {hash, query} = getHashAndQuery(route); 523 | 524 | return { 525 | rel: convertHashToRelativePath(hash), 526 | abs: ORIGIN + hash, 527 | hash: hash || '#', 528 | query: query 529 | } 530 | 531 | } 532 | 533 | function getHashAndQuery(route) { 534 | 535 | const indexOfHash = route.indexOf('#'); 536 | const indexOfQuestion = route.indexOf('?'); 537 | 538 | if (indexOfHash === -1) { // url has no hash 539 | return { 540 | hash: '', 541 | query: removeTrailingSlash(new URL(route, ORIGIN).search) 542 | } 543 | } 544 | 545 | if (indexOfQuestion === -1) { // url has no query 546 | return { 547 | hash: removeTrailingSlash(new URL(route, ORIGIN).hash), 548 | query: '' 549 | } 550 | } 551 | 552 | const url = new URL(route, ORIGIN); 553 | 554 | if (indexOfQuestion < indexOfHash) { // standard compliant url with query before hash 555 | return { 556 | hash: removeTrailingSlash(url.hash), 557 | query: removeTrailingSlash(url.search) 558 | } 559 | } 560 | 561 | // non-standard url with hash before query (query is inside the hash) 562 | let hash = url.hash; 563 | const query = hash.slice(hash.indexOf('?')); 564 | hash = hash.replace(query, ''); 565 | 566 | return { 567 | hash: removeTrailingSlash(hash), 568 | query: removeTrailingSlash(query) 569 | } 570 | 571 | } 572 | 573 | function convertHashToRelativePath(hash) { 574 | return (hash === '#' ? '/' : hash) || '/'; 575 | } 576 | 577 | function removeTrailingSlash(str) { 578 | return str[str.length - 1] === '/' ? str.substring(0, str.length - 1) : str; 579 | } 580 | 581 | function removeAllowedOriginPrefix(route) { 582 | const lop = getLongestOccurringPrefix(route, ALLOWED_ORIGIN_NAMES); 583 | const hashPart = lop ? route.substr(lop.length) : route; 584 | return hashPart.lastIndexOf('/', 0) === 0 ? hashPart.substr(1) : hashPart; 585 | } 586 | 587 | function getLongestOccurringPrefix(s, prefixes) { 588 | return prefixes 589 | .filter(x => s.lastIndexOf(x, 0) === 0) 590 | .sort((a, b) => b.length - a.length)[0]; 591 | } 592 | 593 | function getArrayIntersection(a, b) { 594 | 595 | const intersection = []; 596 | 597 | for (let x = 0; x < a.length; x++) { 598 | for (let y = 0; y < b.length; y++) { 599 | if (a[x] === b[y]) { 600 | intersection.push(a[x]); 601 | break; 602 | } 603 | } 604 | } 605 | 606 | return intersection; 607 | 608 | } 609 | 610 | function getArrayTail(a, b) { 611 | 612 | const tail = []; 613 | 614 | for (let i = a.length; i < b.length; i++) { 615 | tail.push(b[i]); 616 | } 617 | 618 | return tail; 619 | 620 | } 621 | 622 | function buildParamsFromQueryString(queryString) { 623 | 624 | const params = {}; 625 | 626 | if (queryString.length > 1) { 627 | const queries = queryString.substring(1).replace(/\+/g, ' ').replace(/;/g, '&').split('&'); 628 | for (let i = 0, kv, key; i < queries.length; i++) { 629 | kv = queries[i].split('=', 2); 630 | key = decodeURIComponent(kv[0]); 631 | if (key) { 632 | params[key] = kv.length > 1 ? decodeURIComponent(kv[1]) : true; 633 | } 634 | } 635 | } 636 | 637 | return params; 638 | 639 | } 640 | 641 | function buildQueryStringFromParams(params) { 642 | 643 | let querystring = '?'; 644 | 645 | for (const key in params) { 646 | if (params.hasOwnProperty(key)) { 647 | querystring += encodeURIComponent(key) + '=' + encodeURIComponent(params[key]) + '&'; 648 | } 649 | } 650 | 651 | if (querystring === '?') { 652 | querystring = ''; 653 | } else if (querystring[querystring.length - 1] === '&') { 654 | querystring = querystring.substring(0, querystring.length - 1); 655 | } 656 | 657 | return querystring; 658 | 659 | } --------------------------------------------------------------------------------