// Copyright 2021 the V8 project authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import {delay, formatBytes} from './helper.mjs'; export class V8CustomElement extends HTMLElement { _updateTimeoutId; _updateCallback = this.forceUpdate.bind(this); constructor(templateText) { super(); const shadowRoot = this.attachShadow({mode: 'open'}); shadowRoot.innerHTML = templateText; } $(id) { return this.shadowRoot.querySelector(id); } querySelectorAll(query) { return this.shadowRoot.querySelectorAll(query); } requestUpdate(useAnimation = false) { if (useAnimation) { window.cancelAnimationFrame(this._updateTimeoutId); this._updateTimeoutId = window.requestAnimationFrame(this._updateCallback); } else { // Use timeout tasks to asynchronously update the UI without blocking. clearTimeout(this._updateTimeoutId); const kDelayMs = 5; this._updateTimeoutId = setTimeout(this._updateCallback, kDelayMs); } } forceUpdate() { this._updateTimeoutId = undefined; this._update(); } _update() { throw Error('Subclass responsibility'); } get isFocused() { return document.activeElement === this; } } export class FileReader extends V8CustomElement { constructor(templateText) { super(templateText); this.addEventListener('click', this.handleClick.bind(this)); this.addEventListener('dragover', this.handleDragOver.bind(this)); this.addEventListener('drop', this.handleChange.bind(this)); this.$('#file').addEventListener('change', this.handleChange.bind(this)); this.fileReader = this.$('#fileReader'); this.fileReader.addEventListener('keydown', this.handleKeyEvent.bind(this)); this.progressNode = this.$('#progress'); this.progressTextNode = this.$('#progressText'); } set error(message) { this._updateLabel(message); this.root.className = 'fail'; } _updateLabel(text) { this.$('#label').innerText = text; } handleKeyEvent(event) { if (event.key == 'Enter') this.handleClick(event); } handleClick(event) { this.$('#file').click(); } handleChange(event) { // Used for drop and file change. event.preventDefault(); const host = event.dataTransfer ? event.dataTransfer : event.target; this.readFile(host.files[0]); } handleDragOver(event) { event.preventDefault(); } connectedCallback() { this.fileReader.focus(); } get root() { return this.$('#root'); } setProgress(progress, processedBytes = 0) { this.progress = Math.max(0, Math.min(progress, 1)); this.processedBytes = processedBytes; } updateProgressBar() { // Create a circular progress bar, starting at 12 o'clock. this.progressNode.style.backgroundImage = `conic-gradient( var(--primary-color) 0%, var(--primary-color) ${this.progress * 100}%, var(--surface-color) ${this.progress * 100}%)`; this.progressTextNode.innerText = this.processedBytes ? formatBytes(this.processedBytes, 1) : ''; if (this.root.className == 'loading') { window.requestAnimationFrame(() => this.updateProgressBar()); } } readFile(file) { this.dispatchEvent(new CustomEvent('fileuploadstart', { bubbles: true, composed: true, detail: { progressCallback: this.setProgress.bind(this), totalSize: file.size, } })); if (!file) { this.error = 'Failed to load file.'; return; } this.fileReader.blur(); this.setProgress(0); this.root.className = 'loading'; // Delay the loading a bit to allow for CSS animations to happen. window.requestAnimationFrame(() => this.asyncReadFile(file)); } async asyncReadFile(file) { this.updateProgressBar(); const decoder = globalThis.TextDecoderStream; if (decoder) { await this._streamFile(file, decoder); } else { await this._readFullFile(file); } this._updateLabel(`Finished loading '${file.name}'.`); this.dispatchEvent( new CustomEvent('fileuploadend', {bubbles: true, composed: true})); this.root.className = 'done'; } async _readFullFile(file) { const text = await file.text(); this._handleFileChunk(text); } async _streamFile(file, decoder) { const stream = file.stream().pipeThrough(new decoder()); const reader = stream.getReader(); let chunk, readerDone; do { const readResult = await reader.read(); chunk = readResult.value; readerDone = readResult.done; if (!chunk) break; this._handleFileChunk(chunk); // Artificial delay to allow for layout updates. await delay(5); } while (!readerDone); } _handleFileChunk(chunk) { this.dispatchEvent(new CustomEvent('fileuploadchunk', { bubbles: true, composed: true, detail: chunk, })); } } export class DOM { static element(type, options) { const node = document.createElement(type); if (options === undefined) return node; if (typeof options === 'string') { // Old behaviour: options = class string node.className = options; } else if (Array.isArray(options)) { // Old behaviour: options = class array DOM.addClasses(node, options); } else { // New behaviour: options = attribute dict for (const [key, value] of Object.entries(options)) { if (key == 'className') { node.className = value; } else if (key == 'classList') { DOM.addClasses(node, value); } else if (key == 'textContent') { node.textContent = value; } else if (key == 'children') { for (const child of value) { node.appendChild(child); } } else { node.setAttribute(key, value); } } } return node; } static addClasses(node, classes) { const classList = node.classList; if (typeof classes === 'string') { classList.add(classes); } else { for (let i = 0; i < classes.length; i++) { classList.add(classes[i]); } } return node; } static text(string) { return document.createTextNode(string); } static button(label, clickHandler) { const button = DOM.element('button'); button.innerText = label; if (typeof clickHandler != 'function') { throw new Error( `DOM.button: Expected function but got clickHandler=${clickHandler}`); } button.onclick = clickHandler; return button; } static div(options) { return this.element('div', options); } static span(options) { return this.element('span', options); } static table(options) { return this.element('table', options); } static tbody(options) { return this.element('tbody', options); } static td(textOrNode, className) { const node = this.element('td'); if (typeof textOrNode === 'object') { node.appendChild(textOrNode); } else if (textOrNode) { node.innerText = textOrNode; } if (className) node.className = className; return node; } static tr(classes) { return this.element('tr', classes); } static removeAllChildren(node) { let range = document.createRange(); range.selectNodeContents(node); range.deleteContents(); } static defineCustomElement( path, nameOrGenerator, maybeGenerator = undefined) { let generator = nameOrGenerator; let name = nameOrGenerator; if (typeof nameOrGenerator == 'function') { console.assert(maybeGenerator === undefined); name = path.substring(path.lastIndexOf('/') + 1, path.length); } else { console.assert(typeof nameOrGenerator == 'string'); generator = maybeGenerator; } path = path + '-template.html'; fetch(path) .then(stream => stream.text()) .then( templateText => customElements.define(name, generator(templateText))); } }