diff --git a/packages/projector/README.md b/packages/projector/README.md new file mode 100644 index 0000000..c70f3b1 --- /dev/null +++ b/packages/projector/README.md @@ -0,0 +1,18 @@ +# Projector + +A DOM-abstraction communicator. Projector consumes patches to update the DOM in _frames_ and +provides sanitized event objects to subscribers. + +# Usage + +## Instantiation + +```js +const projector = Projector(rootElement): + +projector.subscribe(evt => { + console.log(`Received ${evt.type} event from:`, projector.getElement(evt.target)); +); + +projector.queuePatch(/* array of patches */); +``` diff --git a/packages/projector/package.json b/packages/projector/package.json new file mode 100644 index 0000000..32d0d19 --- /dev/null +++ b/packages/projector/package.json @@ -0,0 +1,39 @@ +{ + "name": "projector", + "version": "2.0.0", + "description": "A DOM-abstraction communicator", + "main": "lib/index.js", + "jsnext:main": "src/index.js", + "files": ["dist", "lib", "src"], + "scripts": { + "lint": "eslint src", + "clean": "rimraf dist lib", + "build:lib": "NODE_ENV=production babel src --presets=\"stage-0,es2015\" --out-dir lib", + "build:umd": "npm run build:lib && NODE_ENV=production rollup -c", + "build:umd:min": + "npm run build:umd && uglifyjs -m --screw-ie8 -c -o dist/projector.min.js dist/projector.js", + "build:umd:gzip": + "npm run build:umd:min && gzip -c9 dist/projector.min.js > dist/projector.min.js.gz", + "build": "npm run build:umd:gzip && ls -l dist/", + "prepublish": "npm run clean && npm run build", + "test": "npm run build:lib && jasmine --verbose" + }, + "keywords": ["router"], + "author": "Timothy Farrell (https://github.com/explorigin)", + "license": "Apache-2.0", + "devDependencies": { + "babel-cli": "6.18.0", + "babel-core": "6.21.0", + "babel-preset-es2015": "6.18.0", + "babel-preset-es2015-rollup": "3.0.0", + "babel-preset-stage-0": "6.16.0", + "jasmine": "^2.5.3", + "rimraf": "2.5.4", + "rollup-plugin-json": "2.1.0", + "rollup-plugin-babel": "^2.7.1", + "uglifyjs": "2.4.10" + }, + "dependencies": { + "trimkit": "^1.0.2" + } +} diff --git a/packages/projector/rollup.config.js b/packages/projector/rollup.config.js new file mode 100644 index 0000000..f08be84 --- /dev/null +++ b/packages/projector/rollup.config.js @@ -0,0 +1,19 @@ +import json from 'rollup-plugin-json'; +import babel from 'rollup-plugin-babel'; + +const babelConfig = { + env: { + es6: true, + browser: true + }, + plugins: [], + presets: ['stage-0', 'es2015-rollup'] +}; + +export default { + entry: 'src/index.js', + format: 'umd', + moduleName: 'Projector', + plugins: [json(), babel(babelConfig)], + dest: 'dist/projector.js' +}; diff --git a/packages/projector/spec/helpers/globals.js b/packages/projector/spec/helpers/globals.js new file mode 100644 index 0000000..c75fc65 --- /dev/null +++ b/packages/projector/spec/helpers/globals.js @@ -0,0 +1,3 @@ +self = window = { + location: {} +}; diff --git a/packages/projector/spec/projector.spec.js b/packages/projector/spec/projector.spec.js new file mode 100644 index 0000000..5835bb5 --- /dev/null +++ b/packages/projector/spec/projector.spec.js @@ -0,0 +1 @@ +const { Projector } = require('../lib/index.js'); diff --git a/packages/projector/spec/support/jasmine.json b/packages/projector/spec/support/jasmine.json new file mode 100644 index 0000000..b52c9e6 --- /dev/null +++ b/packages/projector/spec/support/jasmine.json @@ -0,0 +1,7 @@ +{ + "spec_dir": "spec", + "spec_files": ["**/*[sS]pec.js"], + "helpers": ["helpers/**/*.js"], + "stopSpecOnExpectationFailure": false, + "random": false +} diff --git a/packages/projector/src/index.js b/packages/projector/src/index.js new file mode 100644 index 0000000..bfaa71b --- /dev/null +++ b/packages/projector/src/index.js @@ -0,0 +1,168 @@ +import { isFunction } from 'trimkit'; + +import { sanitizeObject, supportsPassive } from './utils.js'; + + +const OVERRIDING_EVENTS = ['contextmenu','dragover','drop']; +function getEventList(element) { + return (element.getAttribute('evl') || '').split(';'); +} + +export function Projector(domRoot) { + const elementMap = new Map(); + const pendingFrames = []; + const eventCallbacks = []; + let runningNextFrame; + + function eventHandler(evt) { + if (OVERRIDING_EVENTS.includes(eventName)) { + evt.preventDefault(); + }; + + const fakeEvt = sanitizeObject(evt); + if (evt.target) { + fakeEvt.target = evt.target._id; + } + + eventCallbacks.forEach(cb => cb(fakeEvt)); + } + function removeEvent(eventSet, id, eventName) { + eventSet.remove(element._id); + if (!eventSet.size) { + domRoot.removeEventListener(eventName, eventHandler); + } + } + + function setAttributes(element, props) { + Object.entries((name, value) => { + if (name in element) { + if (name.startsWith('on')) { + const eventName = name.substr(2); + const eventSet = eventMap.get(eventName) || new Set(); + const eventList = getEventList(element); + if (value === null) { + // remove event + eventList.splice(eventList.indexOf(eventName), 1); + removeEvent(eventSet, element._id, eventName); + } else { + // add event + if (!eventSet.size) { + domRoot.addEventListener( + eventName, + eventHandler, + ( + (supportsPassive && !OVERRIDING_EVENTS.includes(eventName)) + ? { passive: true, capture: false } + : false + ) + ); + } + eventList.push(eventName); + eventSet.add(element._id); + if (!eventMap.has(eventName)) { + eventMap.set(eventName, eventSet); + } + } + element.setAttribute('evl', eventList.join(';')); + } else { + element[name] = value; + } + } else if (value === null) { + element.removeAttribute(name); + } else { + element.setAttribute(name, value); + } + }); + } + + function createElement({ + t as type, + n as name, + p as props, + i as id, + c as children + }) { + let element; + if (type === 3) { + element = document.createTextNode(props.textContent); + } else if (type === 1) { + element = document.createElement(name); + } + setAttributes(element, props); + elementMap.set(element._id = id, element); + + for (let i=0; i c._id))) + + getEventList(child).forEach(eventName => { + removeEvent( + eventMap.get(eventName), + child._id, + eventName + ); + }); + + parent.removeChild(child); + }); + } + + const ACTION_METHODS = [ + function addElement(parentId, data, nextSiblingId) { + getElement(parentId) + .insertBefore( + createElement(data), + getElement(nextSiblingId) + ); + }, + setAttributes, + function removeElement(parentId, childrenToRemove) { + _removeElement(getElement(parentId), childrenToRemove); + }, + ]; + + const queuePatch = (patchFrame) => { + if (!patchFrame || !patchFrame.length) { + return; + } + pendingFrames.unshift(patchFrame); + if (!runningNextFrame) { + updateFrame(); + } + }; + + const updateFrame = () => { + const patches = pendingFrames.pop(); + // console.group('PatchSet'); + let patch; + while (patch = patchSet.shift()) { + // console.log(ACTION_METHODS[patch[0]].name, JSON.stringify(patch)); + ACTION_METHODS[patch[0]](patchParams[1], patchParams[2], patchParams[3]); + } + + while(postProcessing.length) { postProcessing.pop()(); } + // console.groupEnd('PatchSet'); + + if (pendingFrames.length) { + runningNextFrame = requestAnimationFrame(updateFrame, domRoot); + } else { + runningNextFrame = null; + } + }; + + function getElement(id) { + return elementMap.get(id); + } + + return { + queuePatch, + getElement, + subscribe, + }; +} diff --git a/packages/projector/src/utils.js b/packages/projector/src/utils.js new file mode 100644 index 0000000..c866c05 --- /dev/null +++ b/packages/projector/src/utils.js @@ -0,0 +1,23 @@ +export const NORMAL_OBJECT_PROP_TYPES = ['number', 'string', 'boolean']; + +export function sanitizeObject(obj) { + const output = {}; + for (const key in evt) { + const value = evt[key]; + if (NORMAL_OBJECT_PROP_TYPES.includes(typeof value)) { + output[key] = value; + } + } + return output; +} + +// Extrapolated from https://github.com/zzarcon/default-passive-events/blob/master/default-passive-events.js +export let supportsPassive = false; +try { + const opts = Object.defineProperty({}, 'passive', { + get: function() { + supportsPassive = true; + } + }); + addEventListener('test', null, opts); +} catch (e) {}