|
| 1 | +import debug from "debug"; |
| 2 | +const log = debug("WebIO:DomNode") |
| 3 | + |
| 4 | +import WebIONode, {WebIODomElement, WebIONodeDataBase, WebIONodeParams, WebIONodeType} from "./Node"; |
| 5 | +import WebIOScope from "./Scope"; |
| 6 | +import {createWebIOEventListener} from "./events"; |
| 7 | +import createNode from "./createNode"; |
| 8 | + |
| 9 | +const enum DomNamespace { |
| 10 | + // "html" should actually be "http://www.w3.org/1999/xhtml" but it's okay |
| 11 | + HTML = "html", |
| 12 | + SVG = "http://www.w3.org/2000/svg", |
| 13 | +} |
| 14 | + |
| 15 | +/** |
| 16 | + * A map of style (CSS) attributes to the associated value. |
| 17 | + */ |
| 18 | +interface StylesMap { |
| 19 | + [attributeName: string]: string; |
| 20 | +} |
| 21 | + |
| 22 | +/** |
| 23 | + * A map of event names to listeners (or function definitions of listeners). |
| 24 | + */ |
| 25 | +interface EventsMap { |
| 26 | + [eventName: string]: string | EventListener; |
| 27 | +} |
| 28 | + |
| 29 | +/** |
| 30 | + * A map of (DOM?) attributes to their values (or null if they should be unset). |
| 31 | + */ |
| 32 | +interface AttributesMap { |
| 33 | + [attributeName: string]: string | null; |
| 34 | +} |
| 35 | + |
| 36 | +/** |
| 37 | + * A map of namespaced (DOM) attributes to their values (or null if they should |
| 38 | + * be unset). |
| 39 | + * |
| 40 | + * This doesn't seem to be implemented on the Julia side of things. |
| 41 | + */ |
| 42 | +interface AttributesNSMap { |
| 43 | + [attributeName: string]: { |
| 44 | + namespace: DomNamespace; |
| 45 | + value: string | null; |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +/** |
| 50 | + * Props associated with WebIO DOM nodes. |
| 51 | + */ |
| 52 | +interface DomNodeProps { |
| 53 | + style?: StylesMap; |
| 54 | + events?: EventsMap; |
| 55 | + attributes?: AttributesMap; |
| 56 | + attributesNS?: AttributesNSMap; |
| 57 | + setInnerHtml?: string; |
| 58 | + |
| 59 | + /** |
| 60 | + * Miscellaneous attributes that will be set on the DOM element. |
| 61 | + */ |
| 62 | + [otherProp: string]: any; |
| 63 | +} |
| 64 | + |
| 65 | +/** |
| 66 | + * Data associated with a DOM node. |
| 67 | + */ |
| 68 | +export interface DomNodeData extends WebIONodeDataBase { |
| 69 | + nodeType: WebIONodeType.DOM; |
| 70 | + |
| 71 | + /** |
| 72 | + * Information about the type of DOM node (e.g. a <div /> or SVG document). |
| 73 | + */ |
| 74 | + instanceArgs: { |
| 75 | + namespace: DomNamespace; |
| 76 | + tag: string; |
| 77 | + } |
| 78 | + props: DomNodeProps; |
| 79 | +} |
| 80 | + |
| 81 | +class WebIODomNode extends WebIONode { |
| 82 | + readonly element: WebIODomElement; |
| 83 | + children: Array<WebIOScope | WebIODomNode>; |
| 84 | + private eventListeners: {[eventType: string]: EventListenerOrEventListenerObject | undefined} = {}; |
| 85 | + |
| 86 | + private static createElement(data: DomNodeData) { |
| 87 | + const {namespace, tag} = data.instanceArgs; |
| 88 | + switch (namespace) { |
| 89 | + case DomNamespace.HTML: |
| 90 | + return document.createElement(tag); |
| 91 | + case DomNamespace.SVG: |
| 92 | + return document.createElementNS(DomNamespace.SVG, tag); |
| 93 | + default: |
| 94 | + throw new Error(`Unknown DOM namespace: ${namespace}.`); |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + constructor(nodeData: DomNodeData, options: WebIONodeParams) { |
| 99 | + super(nodeData, options); |
| 100 | + log("Creating WebIODomNode", {nodeData, options}); |
| 101 | + this.element = WebIODomNode.createElement(nodeData); |
| 102 | + this.applyProps(nodeData.props); |
| 103 | + |
| 104 | + // Create children and append to this node's element. |
| 105 | + this.children = nodeData.children.map((nodeData) => ( |
| 106 | + createNode(nodeData, {webIO: this.webIO, scope: this.scope}) |
| 107 | + )); |
| 108 | + for (const child of this.children) { |
| 109 | + this.element.appendChild(child.element); |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * Apply "props" to the underlying DOM element. |
| 115 | + * |
| 116 | + * @param props - The props to apply. |
| 117 | + */ |
| 118 | + applyProps(props: DomNodeProps) { |
| 119 | + log("applyProps", props); |
| 120 | + const {style, events, attributes, attributesNS, setInnerHtml, ...rest} = props; |
| 121 | + style && this.applyStyles(style); |
| 122 | + events && this.applyEvents(events); |
| 123 | + attributes && this.applyAttributes(attributes); |
| 124 | + attributesNS && this.applyAttributesNS(attributesNS); |
| 125 | + setInnerHtml && this.setInnerHTML(setInnerHtml); |
| 126 | + this.applyMiscellaneousProps(rest); |
| 127 | + } |
| 128 | + |
| 129 | + /** |
| 130 | + * Apply all props that don't have special meaning. |
| 131 | + * |
| 132 | + * This should really be refactored so that all these "miscellaneous" props |
| 133 | + * are delivered in a separate object (e.g. have props.miscProps on the same |
| 134 | + * level as props.style and props.events et al.). |
| 135 | + * @param props - The object of miscellaneous props and their values. |
| 136 | + */ |
| 137 | + applyMiscellaneousProps(props: {[propName: string]: any}) { |
| 138 | + log("applyMiscellaneousProps", props); |
| 139 | + for (const propName of Object.keys(props)) { |
| 140 | + (this.element as any)[propName] = props[propName]; |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + applyStyles(styles: StylesMap) { |
| 145 | + for (const attributeName of Object.keys(styles)) { |
| 146 | + this.element.style[attributeName as any] = styles[attributeName]; |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + /** |
| 151 | + * Apply (add/remove) event listeners to the underlying DOM element. |
| 152 | + * |
| 153 | + * @param events - A map object from event names to event listeners. If an |
| 154 | + * event name is specified (e.g. `click`) that didn't exist before, the |
| 155 | + * associated handler (e.g. `events["click"]`) is added as a listener; if |
| 156 | + * the event name has already been specified (even if the listener function |
| 157 | + * changed!), then nothing happens; if the event name is absent (or null) in |
| 158 | + * the map, then any previously setup listeners (if any) are removed. |
| 159 | + */ |
| 160 | + applyEvents(events: EventsMap) { |
| 161 | + for (const eventName of Object.keys(events)) { |
| 162 | + const oldListener = this.eventListeners[eventName]; |
| 163 | + const newListenerSource = events[eventName]; |
| 164 | + const newListener = newListenerSource && createWebIOEventListener( |
| 165 | + this.element, |
| 166 | + newListenerSource, |
| 167 | + this.scope, |
| 168 | + ); |
| 169 | + |
| 170 | + if (oldListener && !newListener) { |
| 171 | + // We want to just remove the old listener. |
| 172 | + this.element.removeEventListener(eventName, oldListener); |
| 173 | + delete this.eventListeners[eventName]; |
| 174 | + } else if (!oldListener && newListener) { |
| 175 | + this.element.addEventListener(eventName, newListener); |
| 176 | + this.eventListeners[eventName] = newListener; |
| 177 | + } |
| 178 | + |
| 179 | + // If the listener is just changed, we don't really handle that. |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + /** |
| 184 | + * Apply DOM attributes to the underlying DOM element. |
| 185 | + * |
| 186 | + * @param attributes - The map of attributes to apply. |
| 187 | + */ |
| 188 | + applyAttributes(attributes: AttributesMap) { |
| 189 | + for (const key of Object.keys(attributes)) { |
| 190 | + const value = attributes[key]; |
| 191 | + if (value === null) { |
| 192 | + this.element.removeAttribute(key); |
| 193 | + } else { |
| 194 | + this.element.setAttribute(key, value); |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + /** |
| 200 | + * Apply namespaced DOM attributes to the underlying DOM element. |
| 201 | + * |
| 202 | + * @param attributes - The `{attributeName: {namespace, value}}` map to apply. |
| 203 | + */ |
| 204 | + applyAttributesNS(attributes: AttributesNSMap) { |
| 205 | + for (const key of Object.keys(attributes)) { |
| 206 | + const {namespace, value} = attributes[key]; |
| 207 | + if (value === null) { |
| 208 | + this.element.removeAttributeNS(namespace, key); |
| 209 | + } else { |
| 210 | + this.element.setAttributeNS(namespace, key, value); |
| 211 | + } |
| 212 | + } |
| 213 | + } |
| 214 | + |
| 215 | + /** |
| 216 | + * Set the value associated with the node's element. |
| 217 | + * |
| 218 | + * This generally only works with `<input />` elements. |
| 219 | + * |
| 220 | + * @param value |
| 221 | + * @throws Will throw an error if the element doesn't have a `value` attribute. |
| 222 | + */ |
| 223 | + setValue(value: any) { |
| 224 | + if ("value" in this.element) { |
| 225 | + // If the value hasn't changed, don't re-set it. |
| 226 | + if (this.element.value !== value) { |
| 227 | + this.element.value = value; |
| 228 | + } |
| 229 | + } else { |
| 230 | + throw new Error("Cannot set value on an HTMLElement that doesn't support it."); |
| 231 | + } |
| 232 | + } |
| 233 | +} |
| 234 | + |
| 235 | +export default WebIODomNode; |
0 commit comments