diff --git a/packages/projector/src/document.js b/packages/projector/src/document.js new file mode 100644 index 0000000..984050d --- /dev/null +++ b/packages/projector/src/document.js @@ -0,0 +1,212 @@ +// Originally Undom - https://github.com/developit/undom +// Copyright (c) 2016 Jason Miller +// License: MIT + +/* +const NODE_TYPES = { + ELEMENT_NODE: 1, + ATTRIBUTE_NODE: 2, + TEXT_NODE: 3, + CDATA_SECTION_NODE: 4, + ENTITY_REFERENCE_NODE: 5, + COMMENT_NODE: 6, + PROCESSING_INSTRUCTION_NODE: 7, + DOCUMENT_NODE: 9 +}; +*/ + +function toLower(str) { + return String(str).toLowerCase(); +} + +function splice(arr, item, add, byValueOnly) { + let i = arr ? findWhere(arr, item, true, byValueOnly) : -1; + if (~i) add ? arr.splice(i, 0, add) : arr.splice(i, 1); + return i; +} + +function findWhere(arr, fn, returnIndex, byValueOnly) { + let i = arr.length; + while (i--) if (typeof fn === 'function' && !byValueOnly ? fn(arr[i]) : arr[i] === fn) break; + return returnIndex ? i : arr[i]; +} + +function createAttributeFilter(ns, name) { + return o => o.ns === ns && toLower(o.name) === toLower(name); +} + +/** Create a minimally viable DOM Document + * @returns {Document} document + */ +export function CreateDocument() { + function isElement(node) { + return node.nodeType === 1; + } + + class Node { + constructor(nodeType, nodeName) { + this.nodeType = nodeType; + this.nodeName = nodeName; + this.childNodes = []; + } + get nextSibling() { + let p = this.parentNode; + if (p) return p.childNodes[findWhere(p.childNodes, this, true) + 1]; + } + get previousSibling() { + let p = this.parentNode; + if (p) return p.childNodes[findWhere(p.childNodes, this, true) - 1]; + } + get firstChild() { + return this.childNodes[0]; + } + get lastChild() { + return this.childNodes[this.childNodes.length - 1]; + } + appendChild(child) { + this.insertBefore(child); + } + insertBefore(child, ref) { + child.remove(); + child.parentNode = this; + if (!ref) this.childNodes.push(child); + else splice(this.childNodes, ref, child); + } + replaceChild(child, ref) { + if (ref.parentNode === this) { + this.insertBefore(child, ref); + ref.remove(); + } + } + removeChild(child) { + splice(this.childNodes, child); + } + remove() { + if (this.parentNode) this.parentNode.removeChild(this); + } + } + + class Text extends Node { + constructor(text) { + super(3, '#text'); // TEXT_NODE + this.nodeValue = text; + } + set textContent(text) { + this.nodeValue = text; + } + get textContent() { + return this.nodeValue; + } + } + + class Element extends Node { + constructor(nodeType, nodeName) { + super(nodeType || 1, nodeName); // ELEMENT_NODE + this.attributes = []; + this.__handlers = {}; + } + + get children() { + return this.childNodes.filter(isElement); + } + + setAttribute(key, value) { + this.setAttributeNS(null, key, value); + } + getAttribute(key) { + return this.getAttributeNS(null, key); + } + removeAttribute(key) { + this.removeAttributeNS(null, key); + } + + setAttributeNS(ns, name, value) { + let attr = findWhere(this.attributes, createAttributeFilter(ns, name)); + if (!attr) this.attributes.push((attr = { ns, name })); + attr.value = String(value); + } + getAttributeNS(ns, name) { + let attr = findWhere(this.attributes, createAttributeFilter(ns, name)); + return attr && attr.value; + } + removeAttributeNS(ns, name) { + splice(this.attributes, createAttributeFilter(ns, name)); + } + + addEventListener(type, handler) { + (this.__handlers[toLower(type)] || (this.__handlers[toLower(type)] = [])).push(handler); + } + removeEventListener(type, handler) { + splice(this.__handlers[toLower(type)], handler, 0, true); + } + dispatchEvent(event) { + let t = (event.currentTarget = this), + c = event.cancelable, + l, + i; + do { + l = t.__handlers[toLower(event.type)]; + if (l) + for (i = l.length; i--; ) { + if ((l[i](event) === false || event._end) && c) break; + } + } while (event.bubbles && !(c && event._stop) && (event.target = t = t.parentNode)); + return !event.defaultPrevented; + } + } + + class Document extends Element { + constructor() { + super(9, '#document'); // DOCUMENT_NODE + } + } + + class Event { + constructor(type, opts) { + this.type = type; + this.bubbles = !!opts.bubbles; + this.cancelable = !!opts.cancelable; + } + stopPropagation() { + this._stop = true; + } + stopImmediatePropagation() { + this._end = this._stop = true; + } + preventDefault() { + this.defaultPrevented = true; + } + } + + function createElement(type) { + return new Element(null, String(type).toUpperCase()); + } + + function createElementNS(ns, type) { + let element = createElement(type); + element.namespace = ns; + return element; + } + + function createTextNode(text) { + return new Text(text); + } + + function createDocument() { + let document = new Document(); + Object.assign( + document, + (document.defaultView = { document, Document, Node, Text, Element, SVGElement: Element, Event }) + ); + Object.assign(document, { + documentElement: document, + createElement, + createElementNS, + createTextNode + }); + document.appendChild((document.body = createElement('body'))); + return document; + } + + return createDocument(); +} diff --git a/packages/projector/src/index.js b/packages/projector/src/index.js index 1f0d95a..3037f86 100644 --- a/packages/projector/src/index.js +++ b/packages/projector/src/index.js @@ -1,158 +1,2 @@ -import { isFunction } from 'trimkit'; - -import { supportsPassive } from './utils.js'; - -const OVERRIDING_EVENTS = ['contextmenu', 'dragover', 'drop']; -function getEventList(element) { - const evtString = element.getAttribute('evl'); - return evtString ? evtString.split(';') : []; -} - -export function Projector(domRoot) { - const elementMap = new Map(); - const pendingFrames = []; - const eventCallbacks = []; - const eventMap = new Map(); - let runningNextFrame; - - function eventHandler(evt) { - const eventName = evt.type; - const eventSet = eventMap.get(eventName); - if (!eventSet || (evt.target && !eventSet.has(evt.target._id))) { - return; - } - - if (OVERRIDING_EVENTS.includes(eventName)) { - evt.preventDefault(); - } - - evt.stopPropagation(); - - eventCallbacks.forEach(cb => cb(evt)); - } - - function removeEvent(eventSet, id, eventName) { - eventSet.delete(id); - if (!eventSet.size) { - domRoot.removeEventListener(eventName, eventHandler); - // Probably unnecessary to remove the eventSet from the map. - // eventMap.delete(eventName); - } - } - - function setAttributes(element, props) { - Object.entries(props).forEach(([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: type, n: name, p: props, i: id, c: children }) { - let element; - if (type === 3) { - element = document.createTextNode(props.textContent); - } else if (type === 1) { - element = document.createElement(name); - } - elementMap.set((element._id = id), element); - setAttributes(element, props); - - for (let i = 0; i < children.length; i++) { - element.appendChild(createElement(children[i])); - } - return element; - } - - function removeElement(element) { - Array.from(element.childNodes).forEach(removeElement); - - getEventList(element).forEach(eventName => { - removeEvent(eventMap.get(eventName), element._id, eventName); - }); - - element.parentNode.removeChild(element); - elementMap.delete(element._id); - } - - const ACTION_METHODS = [ - function addElement(parent, data, nextSiblingId) { - parent.insertBefore(createElement(data), getElement(nextSiblingId)); - }, - setAttributes, - removeElement - ]; - - const queueFrame = patchFrame => { - if (!patchFrame || !patchFrame.length) { - return; - } - pendingFrames.unshift(patchFrame); - if (!runningNextFrame) { - updateFrame(); - } - }; - - const updateFrame = () => { - const patches = pendingFrames.pop(); - if (!patches) { - runningNextFrame = null; - return; - } - // console.group('PatchSet'); - let patch; - while ((patch = patches.shift())) { - // console.log(ACTION_METHODS[patch[0]].name, JSON.stringify(patch)); - ACTION_METHODS[patch[0]](getElement(patch[1]), patch[2], patch[3]); - } - // console.groupEnd('PatchSet'); - - runningNextFrame = requestAnimationFrame(updateFrame, domRoot); - }; - - function getElement(id) { - return id === null ? domRoot : elementMap.get(id); - } - - function subscribe(fn) { - eventCallbacks.push(fn); - } - - return { - queueFrame, - getElement, - subscribe - }; -} +export { Projector } from './projector.js'; +export { Scanner } from './scanner.js'; diff --git a/packages/projector/src/projector.js b/packages/projector/src/projector.js new file mode 100644 index 0000000..1f0d95a --- /dev/null +++ b/packages/projector/src/projector.js @@ -0,0 +1,158 @@ +import { isFunction } from 'trimkit'; + +import { supportsPassive } from './utils.js'; + +const OVERRIDING_EVENTS = ['contextmenu', 'dragover', 'drop']; +function getEventList(element) { + const evtString = element.getAttribute('evl'); + return evtString ? evtString.split(';') : []; +} + +export function Projector(domRoot) { + const elementMap = new Map(); + const pendingFrames = []; + const eventCallbacks = []; + const eventMap = new Map(); + let runningNextFrame; + + function eventHandler(evt) { + const eventName = evt.type; + const eventSet = eventMap.get(eventName); + if (!eventSet || (evt.target && !eventSet.has(evt.target._id))) { + return; + } + + if (OVERRIDING_EVENTS.includes(eventName)) { + evt.preventDefault(); + } + + evt.stopPropagation(); + + eventCallbacks.forEach(cb => cb(evt)); + } + + function removeEvent(eventSet, id, eventName) { + eventSet.delete(id); + if (!eventSet.size) { + domRoot.removeEventListener(eventName, eventHandler); + // Probably unnecessary to remove the eventSet from the map. + // eventMap.delete(eventName); + } + } + + function setAttributes(element, props) { + Object.entries(props).forEach(([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: type, n: name, p: props, i: id, c: children }) { + let element; + if (type === 3) { + element = document.createTextNode(props.textContent); + } else if (type === 1) { + element = document.createElement(name); + } + elementMap.set((element._id = id), element); + setAttributes(element, props); + + for (let i = 0; i < children.length; i++) { + element.appendChild(createElement(children[i])); + } + return element; + } + + function removeElement(element) { + Array.from(element.childNodes).forEach(removeElement); + + getEventList(element).forEach(eventName => { + removeEvent(eventMap.get(eventName), element._id, eventName); + }); + + element.parentNode.removeChild(element); + elementMap.delete(element._id); + } + + const ACTION_METHODS = [ + function addElement(parent, data, nextSiblingId) { + parent.insertBefore(createElement(data), getElement(nextSiblingId)); + }, + setAttributes, + removeElement + ]; + + const queueFrame = patchFrame => { + if (!patchFrame || !patchFrame.length) { + return; + } + pendingFrames.unshift(patchFrame); + if (!runningNextFrame) { + updateFrame(); + } + }; + + const updateFrame = () => { + const patches = pendingFrames.pop(); + if (!patches) { + runningNextFrame = null; + return; + } + // console.group('PatchSet'); + let patch; + while ((patch = patches.shift())) { + // console.log(ACTION_METHODS[patch[0]].name, JSON.stringify(patch)); + ACTION_METHODS[patch[0]](getElement(patch[1]), patch[2], patch[3]); + } + // console.groupEnd('PatchSet'); + + runningNextFrame = requestAnimationFrame(updateFrame, domRoot); + }; + + function getElement(id) { + return id === null ? domRoot : elementMap.get(id); + } + + function subscribe(fn) { + eventCallbacks.push(fn); + } + + return { + queueFrame, + getElement, + subscribe + }; +} diff --git a/packages/projector/src/scanner.js b/packages/projector/src/scanner.js new file mode 100644 index 0000000..c4c20bd --- /dev/null +++ b/packages/projector/src/scanner.js @@ -0,0 +1 @@ +export { CreateDocument } from './document.js';