1// Copyright 2021 the V8 project authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import {delay, formatBytes} from './helper.mjs'; 6 7export class V8CustomElement extends HTMLElement { 8 _updateTimeoutId; 9 _updateCallback = this.forceUpdate.bind(this); 10 11 constructor(templateText) { 12 super(); 13 const shadowRoot = this.attachShadow({mode: 'open'}); 14 shadowRoot.innerHTML = templateText; 15 } 16 17 $(id) { 18 return this.shadowRoot.querySelector(id); 19 } 20 21 querySelectorAll(query) { 22 return this.shadowRoot.querySelectorAll(query); 23 } 24 25 requestUpdate(useAnimation = false) { 26 if (useAnimation) { 27 window.cancelAnimationFrame(this._updateTimeoutId); 28 this._updateTimeoutId = 29 window.requestAnimationFrame(this._updateCallback); 30 } else { 31 // Use timeout tasks to asynchronously update the UI without blocking. 32 clearTimeout(this._updateTimeoutId); 33 const kDelayMs = 5; 34 this._updateTimeoutId = setTimeout(this._updateCallback, kDelayMs); 35 } 36 } 37 38 forceUpdate() { 39 this._updateTimeoutId = undefined; 40 this._update(); 41 } 42 43 _update() { 44 throw Error('Subclass responsibility'); 45 } 46 47 get isFocused() { 48 return document.activeElement === this; 49 } 50} 51 52export class FileReader extends V8CustomElement { 53 constructor(templateText) { 54 super(templateText); 55 this.addEventListener('click', this.handleClick.bind(this)); 56 this.addEventListener('dragover', this.handleDragOver.bind(this)); 57 this.addEventListener('drop', this.handleChange.bind(this)); 58 this.$('#file').addEventListener('change', this.handleChange.bind(this)); 59 this.fileReader = this.$('#fileReader'); 60 this.fileReader.addEventListener('keydown', this.handleKeyEvent.bind(this)); 61 this.progressNode = this.$('#progress'); 62 this.progressTextNode = this.$('#progressText'); 63 } 64 65 set error(message) { 66 this._updateLabel(message); 67 this.root.className = 'fail'; 68 } 69 70 _updateLabel(text) { 71 this.$('#label').innerText = text; 72 } 73 74 handleKeyEvent(event) { 75 if (event.key == 'Enter') this.handleClick(event); 76 } 77 78 handleClick(event) { 79 this.$('#file').click(); 80 } 81 82 handleChange(event) { 83 // Used for drop and file change. 84 event.preventDefault(); 85 const host = event.dataTransfer ? event.dataTransfer : event.target; 86 this.readFile(host.files[0]); 87 } 88 89 handleDragOver(event) { 90 event.preventDefault(); 91 } 92 93 connectedCallback() { 94 this.fileReader.focus(); 95 } 96 97 get root() { 98 return this.$('#root'); 99 } 100 101 setProgress(progress, processedBytes = 0) { 102 this.progress = Math.max(0, Math.min(progress, 1)); 103 this.processedBytes = processedBytes; 104 } 105 106 updateProgressBar() { 107 // Create a circular progress bar, starting at 12 o'clock. 108 this.progressNode.style.backgroundImage = `conic-gradient( 109 var(--primary-color) 0%, 110 var(--primary-color) ${this.progress * 100}%, 111 var(--surface-color) ${this.progress * 100}%)`; 112 this.progressTextNode.innerText = 113 this.processedBytes ? formatBytes(this.processedBytes, 1) : ''; 114 if (this.root.className == 'loading') { 115 window.requestAnimationFrame(() => this.updateProgressBar()); 116 } 117 } 118 119 readFile(file) { 120 this.dispatchEvent(new CustomEvent('fileuploadstart', { 121 bubbles: true, 122 composed: true, 123 detail: { 124 progressCallback: this.setProgress.bind(this), 125 totalSize: file.size, 126 } 127 })); 128 if (!file) { 129 this.error = 'Failed to load file.'; 130 return; 131 } 132 this.fileReader.blur(); 133 this.setProgress(0); 134 this.root.className = 'loading'; 135 // Delay the loading a bit to allow for CSS animations to happen. 136 window.requestAnimationFrame(() => this.asyncReadFile(file)); 137 } 138 139 async asyncReadFile(file) { 140 this.updateProgressBar(); 141 const decoder = globalThis.TextDecoderStream; 142 if (decoder) { 143 await this._streamFile(file, decoder); 144 } else { 145 await this._readFullFile(file); 146 } 147 this._updateLabel(`Finished loading '${file.name}'.`); 148 this.dispatchEvent( 149 new CustomEvent('fileuploadend', {bubbles: true, composed: true})); 150 this.root.className = 'done'; 151 } 152 153 async _readFullFile(file) { 154 const text = await file.text(); 155 this._handleFileChunk(text); 156 } 157 158 async _streamFile(file, decoder) { 159 const stream = file.stream().pipeThrough(new decoder()); 160 const reader = stream.getReader(); 161 let chunk, readerDone; 162 do { 163 const readResult = await reader.read(); 164 chunk = readResult.value; 165 readerDone = readResult.done; 166 if (!chunk) break; 167 this._handleFileChunk(chunk); 168 // Artificial delay to allow for layout updates. 169 await delay(5); 170 } while (!readerDone); 171 } 172 173 _handleFileChunk(chunk) { 174 this.dispatchEvent(new CustomEvent('fileuploadchunk', { 175 bubbles: true, 176 composed: true, 177 detail: chunk, 178 })); 179 } 180} 181 182export class DOM { 183 static element(type, options) { 184 const node = document.createElement(type); 185 if (options === undefined) return node; 186 if (typeof options === 'string') { 187 // Old behaviour: options = class string 188 node.className = options; 189 } else if (Array.isArray(options)) { 190 // Old behaviour: options = class array 191 DOM.addClasses(node, options); 192 } else { 193 // New behaviour: options = attribute dict 194 for (const [key, value] of Object.entries(options)) { 195 if (key == 'className') { 196 node.className = value; 197 } else if (key == 'classList') { 198 DOM.addClasses(node, value); 199 } else if (key == 'textContent') { 200 node.textContent = value; 201 } else if (key == 'children') { 202 for (const child of value) { 203 node.appendChild(child); 204 } 205 } else { 206 node.setAttribute(key, value); 207 } 208 } 209 } 210 return node; 211 } 212 213 static addClasses(node, classes) { 214 const classList = node.classList; 215 if (typeof classes === 'string') { 216 classList.add(classes); 217 } else { 218 for (let i = 0; i < classes.length; i++) { 219 classList.add(classes[i]); 220 } 221 } 222 return node; 223 } 224 225 static text(string) { 226 return document.createTextNode(string); 227 } 228 229 static button(label, clickHandler) { 230 const button = DOM.element('button'); 231 button.innerText = label; 232 if (typeof clickHandler != 'function') { 233 throw new Error( 234 `DOM.button: Expected function but got clickHandler=${clickHandler}`); 235 } 236 button.onclick = clickHandler; 237 return button; 238 } 239 240 static div(options) { 241 return this.element('div', options); 242 } 243 244 static span(options) { 245 return this.element('span', options); 246 } 247 248 static table(options) { 249 return this.element('table', options); 250 } 251 252 static tbody(options) { 253 return this.element('tbody', options); 254 } 255 256 static td(textOrNode, className) { 257 const node = this.element('td'); 258 if (typeof textOrNode === 'object') { 259 node.appendChild(textOrNode); 260 } else if (textOrNode) { 261 node.innerText = textOrNode; 262 } 263 if (className) node.className = className; 264 return node; 265 } 266 267 static tr(classes) { 268 return this.element('tr', classes); 269 } 270 271 static removeAllChildren(node) { 272 let range = document.createRange(); 273 range.selectNodeContents(node); 274 range.deleteContents(); 275 } 276 277 static defineCustomElement( 278 path, nameOrGenerator, maybeGenerator = undefined) { 279 let generator = nameOrGenerator; 280 let name = nameOrGenerator; 281 if (typeof nameOrGenerator == 'function') { 282 console.assert(maybeGenerator === undefined); 283 name = path.substring(path.lastIndexOf('/') + 1, path.length); 284 } else { 285 console.assert(typeof nameOrGenerator == 'string'); 286 generator = maybeGenerator; 287 } 288 path = path + '-template.html'; 289 fetch(path) 290 .then(stream => stream.text()) 291 .then( 292 templateText => 293 customElements.define(name, generator(templateText))); 294 } 295} 296