diff --git a/src/attributes/custom-attributes.ts b/src/attributes/custom-attributes.ts new file mode 100644 index 0000000..c702b57 --- /dev/null +++ b/src/attributes/custom-attributes.ts @@ -0,0 +1,157 @@ +// Forked from https://github.com/lume/custom-attributes + +type Constructor = (new (...a: A) => T) & Static; + +const forEach = Array.prototype.forEach; + +export class CustomAttributeRegistry { + private _attrMap = new Map(); + private _elementMap = new WeakMap>(); + + private _observer: MutationObserver = new MutationObserver((mutations) => { + forEach.call(mutations, (m: MutationRecord) => { + if (m.type === 'attributes') { + const attr = this._getConstructor(m.attributeName!); + if (attr) this._handleChange(m.attributeName!, m.target as Element, m.oldValue); + } + + // childList + else { + forEach.call(m.removedNodes, this._elementDisconnected); + forEach.call(m.addedNodes, this._elementConnected); + } + }); + }); + + constructor(public ownerDocument: Document | ShadowRoot) { + if (!ownerDocument) throw new Error('Must be given a document'); + } + + define(attrName: string, Class: Constructor) { + this._attrMap.set(attrName, Class); + this._upgradeAttr(attrName); + this._reobserve(); + } + + get(element: Element, attrName: string) { + const map = this._elementMap.get(element); + if (!map) return; + return map.get(attrName); + } + + private _getConstructor(attrName: string) { + return this._attrMap.get(attrName); + } + + private _observe() { + this._observer.observe(this.ownerDocument, { + childList: true, + subtree: true, + attributes: true, + attributeOldValue: true, + attributeFilter: Array.from(this._attrMap.keys()), + // attributeFilter: [...this._attrMap.keys()], // Broken in Oculus + // attributeFilter: this._attrMap.keys(), // This works in Chrome, but TS complains, and not clear if it should work in all browsers yet: https://github.com/whatwg/dom/issues/1092 + }); + } + + private _unobserve() { + this._observer.disconnect(); + } + + private _reobserve() { + this._unobserve(); + this._observe(); + } + + private _upgradeAttr( + attrName: string, + node: Element | Document | ShadowRoot = this.ownerDocument + ) { + const matches = node.querySelectorAll('[' + attrName + ']'); + + // Possibly create custom attributes that may be in the given 'node' tree. + // Use a forEach as Edge doesn't support for...of on a NodeList + forEach.call(matches, (element: Element) => this._handleChange(attrName, element, null)); + } + + private _elementConnected = (element: Element) => { + if (element.nodeType !== 1) return; + + // For each of the connected element's attribute, possibly instantiate the custom attributes. + // Use a forEach as Safari 10 doesn't support for...of on NamedNodeMap (attributes) + forEach.call(element.attributes, (attr: Attr) => { + if (this._getConstructor(attr.name)) this._handleChange(attr.name, element, null); + }); + + // Possibly instantiate custom attributes that may be in the subtree of the connected element. + this._attrMap.forEach((_constructor, attr) => this._upgradeAttr(attr, element)); + }; + + private _elementDisconnected = (element: Element) => { + const map = this._elementMap.get(element); + if (!map) return; + + map.forEach((inst) => inst.disconnectedCallback?.(), this); + + this._elementMap.delete(element); + }; + + private _handleChange(attrName: string, el: Element, oldVal: string | null) { + let map = this._elementMap.get(el); + if (!map) this._elementMap.set(el, (map = new Map())); + + let inst = map.get(attrName); + const newVal = el.getAttribute(attrName); + + // Attribute is being created + if (!inst) { + const Constructor = this._getConstructor(attrName)!; + inst = new Constructor() as CustomAttribute; + map.set(attrName, inst); + inst.ownerElement = el; + inst.name = attrName; + if (newVal == null) throw new Error('Not possible!'); + inst.value = newVal; + inst.connectedCallback?.(); + return; + } + + // Attribute was removed + if (newVal == null) { + inst.disconnectedCallback?.(); + map.delete(attrName); + } + + // Attribute changed + else if (newVal !== inst.value) { + inst.value = newVal; + if (oldVal == null) throw new Error('Not possible!'); + inst.changedCallback?.(oldVal, newVal); + } + } +} + +export class CustomAttribute { + ownerElement: Element; + name: string; + value: string; + connectedCallback?(): void; + disconnectedCallback?(): void; + changedCallback?(oldValue: string, newValue: string): void; +} + +// Avoid errors trying to use DOM APIs in non-DOM environments (f.e. server-side rendering). +if (globalThis.window?.document) { + const _attachShadow = Element.prototype.attachShadow; + + Element.prototype.attachShadow = function attachShadow(options) { + const root = _attachShadow.call(this, options); + + if (!root.customAttributes) root.customAttributes = new CustomAttributeRegistry(root); + + return root; + }; +} + +export const customAttributes = new CustomAttributeRegistry(document); diff --git a/src/attributes/move.ts b/src/attributes/move.ts new file mode 100644 index 0000000..7e408f7 --- /dev/null +++ b/src/attributes/move.ts @@ -0,0 +1,48 @@ +import { CustomAttribute } from './custom-attributes'; + +export class Moveable extends CustomAttribute { + connectedCallback() { + this.ownerElement.addEventListener('pointerdown', this); + this.ownerElement.addEventListener('lostpointercapture', this); + this.ownerElement.addEventListener('touchstart', this); + this.ownerElement.addEventListener('dragstart', this); + } + + disconnectedCallback() { + this.ownerElement.removeEventListener('pointerdown', this); + this.ownerElement.removeEventListener('lostpointercapture', this); + this.ownerElement.removeEventListener('touchstart', this); + this.ownerElement.removeEventListener('dragstart', this); + } + + handleEvent(event: PointerEvent) { + switch (event.type) { + case 'pointerdown': { + if (event.button !== 0 || event.ctrlKey) return; + + this.ownerElement.addEventListener('pointermove', this); + this.ownerElement.setPointerCapture(event.pointerId); + (this.ownerElement as HTMLElement).style.userSelect = 'none'; + return; + } + case 'pointermove': { + const { left, top } = window.getComputedStyle(this.ownerElement); + let leftValue = parseInt(left); + let topValue = parseInt(top); + (this.ownerElement as HTMLElement).style.left = `${leftValue + event.movementX}px`; + (this.ownerElement as HTMLElement).style.top = `${topValue + event.movementY}px`; + return; + } + case 'lostpointercapture': { + (this.ownerElement as HTMLElement).style.userSelect = ''; + this.ownerElement.removeEventListener('pointermove', this); + return; + } + case 'touchstart': + case 'dragstart': { + event.preventDefault(); + return; + } + } + } +} diff --git a/src/attributes/resize.ts b/src/attributes/resize.ts new file mode 100644 index 0000000..b8cd7b0 --- /dev/null +++ b/src/attributes/resize.ts @@ -0,0 +1,45 @@ +import { CustomAttribute } from './custom-attributes'; + +export class Resizable extends CustomAttribute { + // Add resize handlers and CSS variables + connectedCallback() { + this.ownerElement.addEventListener('pointerdown', this); + this.ownerElement.addEventListener('lostpointercapture', this); + this.ownerElement.addEventListener('touchstart', this); + this.ownerElement.addEventListener('dragstart', this); + } + + disconnectedCallback() { + this.ownerElement.removeEventListener('pointerdown', this); + this.ownerElement.removeEventListener('lostpointercapture', this); + this.ownerElement.removeEventListener('touchstart', this); + this.ownerElement.removeEventListener('dragstart', this); + } + + handleEvent(event: PointerEvent) { + switch (event.type) { + case 'pointerdown': { + if (event.button !== 0 || event.ctrlKey) return; + + this.ownerElement.addEventListener('pointermove', this); + this.ownerElement.setPointerCapture(event.pointerId); + (this.ownerElement as HTMLElement).style.userSelect = 'none'; + return; + } + // TODO + case 'pointermove': { + return; + } + case 'lostpointercapture': { + (this.ownerElement as HTMLElement).style.userSelect = ''; + this.ownerElement.removeEventListener('pointermove', this); + return; + } + case 'touchstart': + case 'dragstart': { + event.preventDefault(); + return; + } + } + } +} diff --git a/src/attributes/rotate.ts b/src/attributes/rotate.ts new file mode 100644 index 0000000..c236a47 --- /dev/null +++ b/src/attributes/rotate.ts @@ -0,0 +1,45 @@ +import { CustomAttribute } from './custom-attributes'; + +export class Rotatable extends CustomAttribute { + // Add rotate handlers and CSS variables + connectedCallback() { + this.ownerElement.addEventListener('pointerdown', this); + this.ownerElement.addEventListener('lostpointercapture', this); + this.ownerElement.addEventListener('touchstart', this); + this.ownerElement.addEventListener('dragstart', this); + } + + disconnectedCallback() { + this.ownerElement.removeEventListener('pointerdown', this); + this.ownerElement.removeEventListener('lostpointercapture', this); + this.ownerElement.removeEventListener('touchstart', this); + this.ownerElement.removeEventListener('dragstart', this); + } + + handleEvent(event: PointerEvent) { + switch (event.type) { + case 'pointerdown': { + if (event.button !== 0 || event.ctrlKey) return; + + this.ownerElement.addEventListener('pointermove', this); + this.ownerElement.setPointerCapture(event.pointerId); + (this.ownerElement as HTMLElement).style.userSelect = 'none'; + return; + } + // TODO + case 'pointermove': { + return; + } + case 'lostpointercapture': { + (this.ownerElement as HTMLElement).style.userSelect = ''; + this.ownerElement.removeEventListener('pointermove', this); + return; + } + case 'touchstart': + case 'dragstart': { + event.preventDefault(); + return; + } + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e69de29