import ContextMenu from './context-menu'; import DropArea from './drop-area'; import styles from './style.css'; import {readAsText} from '../common/readers'; import downloadBlob from './download'; class Monitor { constructor (parent, monitor) { this.parent = parent; this.id = monitor.get('id'); this.spriteName = monitor.get('spriteName'); this.targetId = monitor.get('targetId'); this.opcode = monitor.get('opcode'); this.params = monitor.get('params'); this.root = document.createElement('div'); this.root.className = styles.monitorRoot; this.root.dataset.id = this.id; this.root.dataset.opcode = this.opcode; this.parent._monitorOverlay.appendChild(this.root); } getLabel () { let label; if (this.opcode === 'data_variable') { label = this.params.VARIABLE; } else if (this.opcode === 'data_listcontents') { label = this.params.LIST; } else if (this.opcode === 'motion_xposition') { label = this.parent.getMessage('var-x'); } else if (this.opcode === 'motion_yposition') { label = this.parent.getMessage('var-y'); } else if (this.opcode === 'motion_direction') { label = this.parent.getMessage('var-direction'); } else if (this.opcode === 'sensing_username') { label = this.parent.getMessage('var-username'); } else if (this.opcode === 'looks_costumenumbername') { if (this.params.NUMBER_NAME === 'number') { label = this.parent.getMessage('var-costume-number'); } else { label = this.parent.getMessage('var-costume-name'); } } else if (this.opcode === 'looks_backdropnumbername') { if (this.params.NUMBER_NAME === 'number') { label = this.parent.getMessage('var-backdrop-number'); } else { label = this.parent.getMessage('var-backdrop-name'); } } else if (this.opcode === 'looks_size') { label = this.parent.getMessage('var-size'); } else if (this.opcode === 'looks_stretchGetX') { label = this.parent.getMessage('var-stretch-x'); } else if (this.opcode === 'looks_stretchGetY') { label = this.parent.getMessage('var-stretch-y'); } else if (this.opcode === 'looks_sayWidth') { label = this.parent.getMessage('var-say-width'); } else if (this.opcode === 'looks_sayHeight') { label = this.parent.getMessage('var-say-height'); } else if (this.opcode === 'looks_getEffectValue') { const effect = this.params.EFFECT.toLowerCase(); label = this.parent.getMessage(`var-${effect}-effect`) || this.parent.getMessage("var-effect"); } else if (this.opcode === 'looks_tintColor') { label = this.parent.getMessage('var-tint-color'); } else if (this.opcode === 'looks_getSpriteVisible') { label = this.parent.getMessage('var-visible'); } else if (this.opcode === 'looks_layersGetLayer') { label = this.parent.getMessage('var-layer'); } else if (this.opcode === 'looks_size') { label = this.parent.getMessage('var-size'); } else if (this.opcode === 'sound_getEffectValue') { const effect = this.params.EFFECT.toLowerCase(); label = this.parent.getMessage(`var-${effect}-sound-effect`) || this.parent.getMessage("var-sound-effect"); } else if (this.opcode === 'control_get_counter') { label = this.parent.getMessage('var-get_counter'); } else if (this.opcode === 'sensing_answer') { label = this.parent.getMessage('var-answer'); } else if (this.opcode === 'sensing_mousedown') { label = this.parent.getMessage('var-mousedown'); } else if (this.opcode === 'sensing_mouseclicked') { label = this.parent.getMessage('var-mouseclicked'); } else if (this.opcode === 'sensing_mousex') { label = this.parent.getMessage('var-mousex'); } else if (this.opcode === 'sensing_mousey') { label = this.parent.getMessage('var-mousey'); } else if (this.opcode === 'sensing_loudness') { label = this.parent.getMessage('var-loudness'); } else if (this.opcode === 'sensing_timer') { label = this.parent.getMessage('var-timer'); } else if (this.opcode === 'sensing_dayssince2000') { label = this.parent.getMessage('var-dayssince2000'); } else if (this.opcode === 'sensing_getclipboard') { label = this.parent.getMessage('var-getclipboard'); } else if (this.opcode === 'sensing_getdragmode') { label = this.parent.getMessage('var-getdragmode'); } else if (this.opcode === 'sensing_loud') { label = this.parent.getMessage('var-loud'); } else if (this.opcode === 'sensing_loggedin') { label = this.parent.getMessage('var-loggedin'); } else if (this.opcode === 'sound_volume') { label = this.parent.getMessage('var-volume'); } else if (this.opcode === 'sensing_current') { const menu = this.params.CURRENTMENU.toLowerCase(); if (menu === 'year') { label = this.parent.getMessage('var-year'); } else if (menu === 'month') { label = this.parent.getMessage('var-month'); } else if (menu === 'date') { label = this.parent.getMessage('var-date'); } else if (menu === 'dayofweek') { label = this.parent.getMessage('var-day-of-week'); } else if (menu === 'hour') { label = this.parent.getMessage('var-hour'); } else if (menu === 'minute') { label = this.parent.getMessage('var-minute'); } else if (menu === 'second') { label = this.parent.getMessage('var-second'); } } else { const vmLabel = this.parent.vm.runtime.getLabelForOpcode(this.opcode); if (vmLabel) { label = vmLabel.label; } else { label = this.opcode; } } if (this.spriteName) { return `${this.spriteName}: ${label}`; } return label; } getTarget () { if (this.targetId) { return this.parent.vm.runtime.getTargetById(this.targetId); } return this.parent.vm.runtime.getTargetForStage(); } getVmVariable () { const target = this.getTarget(); return target.variables[this.id]; } update (monitor) { this.x = monitor.get('x'); this.y = monitor.get('y'); this.visible = monitor.get('visible'); this.root.style.transform = `translate(${Math.round(this.x)}px, ${Math.round(this.y)}px)`; this.root.style.display = this.visible ? '' : 'none'; } } class VariableMonitor extends Monitor { constructor (parent, monitor) { super(parent, monitor); this.mode = monitor.get('mode'); if (this.mode === 'large') { this.valueElement = document.createElement('div'); this.valueElement.className = styles.monitorLargeValue + ' ' + styles.monitorValueColor; this.root.appendChild(this.valueElement); } else { this.inner = document.createElement('div'); this.inner.className = styles.monitorInner; this.valueRow = document.createElement('div'); this.valueRow.className = styles.monitorRow; this.label = document.createElement('div'); this.label.className = styles.monitorLabel; this.label.textContent = this.getLabel(); this.valueElement = document.createElement('div'); this.valueElement.className = styles.monitorValue + ' ' + styles.monitorValueColor; this.valueRow.appendChild(this.label); this.valueRow.appendChild(this.valueElement); this.inner.appendChild(this.valueRow); if (this.mode === 'slider') { this.sliderRow = document.createElement('div'); this.sliderRow.className = styles.monitorRow; this.slider = document.createElement('input'); this.slider.className = styles.monitorSlider; this.slider.type = 'range'; this.slider.min = monitor.get('sliderMin'); this.slider.max = monitor.get('sliderMax'); this.slider.step = monitor.get('isDiscrete') ? 1 : 0.01; this.slider.addEventListener('input', this.onsliderchange.bind(this)); this.sliderRow.appendChild(this.slider); this.inner.appendChild(this.sliderRow); } this.root.appendChild(this.inner); } this.parent._monitorOverlay.appendChild(this.root); this._value = ''; } setVariableValue (value) { const variable = this.getVmVariable(); variable.value = value; if (variable.isCloud) { const runtime = this.parent.vm.runtime; runtime.ioDevices.cloud.requestUpdateVariable(variable.name, variable.value); } this._value = value; this.valueElement.textContent = value; } onsliderchange (e) { this.setVariableValue(+e.target.value); } update (monitor) { super.update(monitor); if (!this.visible) { return; } let value = monitor.get('value'); if (typeof value === 'number') { value = Number(value.toFixed(6)); } if (this._value !== value) { this._value = value; this.valueElement.textContent = value; if (this.slider) { this.slider.value = value; } } } } const ROW_HEIGHT = 24; class Row { constructor (monitor) { this.monitor = monitor; this.index = -1; this.value = ''; this.locked = false; this.root = document.createElement('label'); this.root.className = styles.monitorRowRoot; this.indexEl = document.createElement('div'); this.indexEl.className = styles.monitorRowIndex; this.valueOuter = document.createElement('div'); this.valueOuter.className = styles.monitorRowValueOuter; this.editable = this.monitor.editable; if (this.editable) { this.valueInner = document.createElement('input'); this.valueInner.tabIndex = -1; this.valueInner.className = styles.monitorRowValueInner; this.valueInner.readOnly = true; this.valueInner.addEventListener('click', this._onclickinput.bind(this)); this.valueInner.addEventListener('blur', this._onblurinput.bind(this)); this.valueInner.addEventListener('keypress', this._onkeypressinput.bind(this)); this.valueInner.addEventListener('keydown', this._onkeypressdown.bind(this)); this.valueInner.addEventListener('contextmenu', this._oncontextmenu.bind(this)); this.valueInner.addEventListener('input', this._oninput.bind(this)); this.valueOuter.appendChild(this.valueInner); this.deleteButton = document.createElement('button'); this.deleteButton.className = styles.monitorRowDelete; this.deleteButton.textContent = '×'; this.deleteButton.addEventListener('mousedown', this._onclickdelete.bind(this)); this.valueOuter.appendChild(this.deleteButton); } else { this.valueInner = document.createElement('div'); this.valueInner.className = styles.monitorRowValueInner; this.valueOuter.appendChild(this.valueInner); this.valueInner.addEventListener('contextmenu', this._oncontextmenuuneditable.bind(this)); } this.root.appendChild(this.indexEl); this.root.appendChild(this.valueOuter); } _onclickinput () { this.valueInner.focus(); if (this.locked) { return; } this.valueInner.select(); this.valueInner.readOnly = false; this.locked = true; this.root.classList.add(styles.monitorRowValueEditing); this.addNewValue = false; this.deleteValue = false; this.valueWasChanged = false; } _onblurinput () { if (!this.locked) { return; } this.unfocus(); if (this.deleteValue) { const value = [...this.monitor.value]; value.splice(this.index, 1); this.monitor.setValue(value); this.monitor.tryToFocusRow(Math.min(value.length - 1, this.index)) } else if (this.valueWasChanged || this.addNewValue) { const value = [...this.monitor.value]; value[this.index] = this.valueInner.value; if (this.addNewValue) { value.splice(this.index + 1, 0, ''); } this.monitor.setValue(value); if (this.addNewValue) { this.monitor.tryToFocusRow(this.index + 1); } } } _oninput () { this.valueWasChanged = true; } _onkeypressinput (e) { if (e.key === 'Enter') { this.addNewValue = true; this.valueInner.blur(); } } _onkeypressdown (e) { if (e.key === 'Escape') { this.valueInner.blur(); } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Tab') { e.preventDefault(); let index = this.index; if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) { index--; if (index < 0) index = this.monitor.value.length - 1; } else { index++; if (index >= this.monitor.value.length) index = 0; } this.monitor.tryToFocusRow(index); } } _onclickdelete (e) { e.preventDefault(); this.deleteValue = true; this.valueInner.blur(); } _oncontextmenu (e) { if (this.locked) { // Open native context menu instead of custom list one when editing e.stopPropagation(); } else { // Right clicking should not focus and highlight input e.preventDefault(); } } _oncontextmenuuneditable (e) { // When row has been highlighted, eg. by triple click, open native context menu instead of custom const selection = getSelection(); if (this.valueInner.contains(selection.anchorNode) && !selection.isCollapsed) { e.stopPropagation(); } } setIndex (index) { if (this.index !== index) { this.index = index; this.root.dataset.index = index; this.root.style.transform = `translateY(${index * ROW_HEIGHT}px)`; this.indexEl.textContent = index + 1; } } setValue (value) { if (this.value !== value && !this.locked) { this.value = value; if (this.editable) { this.valueInner.value = value; } else { this.valueInner.textContent = value; } } } focus () { this.valueInner.click(); if (document.activeElement !== this.valueInner) { setTimeout(() => this.valueInner.click()); } } unfocus () { if (this.locked) { this.locked = false; this.valueInner.readOnly = true; this.root.classList.remove(styles.monitorRowValueEditing); } } } class ListMonitor extends Monitor { constructor (parent, monitor) { super(parent, monitor); this.editable = parent.editableLists; this.rows = new Map(); this.cachedRows = []; this.scrollTop = 0; this.oldLength = -1; this.label = document.createElement('div'); this.label.className = styles.monitorListLabel; this.label.textContent = this.getLabel(); this.footer = document.createElement('div'); this.footer.className = styles.monitorListFooter; this.footerText = document.createElement('div'); this.footerText.className = styles.monitorListFooterText; this.rowsOuter = document.createElement('div'); this.rowsOuter.className = styles.monitorRowsOuter; this.rowsInner = document.createElement('div'); this.rowsInner.className = styles.monitorRowsInner; this.rowsInner.addEventListener('scroll', this._onscroll.bind(this), {passive: true}); this.endPoint = document.createElement('div'); this.endPoint.className = styles.monitorRowsEndpoint; this.emptyLabel = document.createElement('div'); this.emptyLabel.textContent = parent.getMessage('list-empty'); this.emptyLabel.className = styles.monitorEmpty; if (this.editable) { this.addButton = document.createElement('button'); this.addButton.className = styles.monitorListAdd; this.addButton.textContent = '+'; this.addButton.addEventListener('click', this._onclickaddbutton.bind(this)); this.footer.appendChild(this.addButton); } this.rowsInner.appendChild(this.endPoint); this.rowsInner.appendChild(this.emptyLabel); this.rowsOuter.appendChild(this.rowsInner); this.footer.appendChild(this.footerText); this.root.appendChild(this.label); this.root.appendChild(this.rowsOuter); this.root.appendChild(this.footer); this.dropper = new DropArea(this.rowsOuter, this.dropperCallback.bind(this)); this.handleImport = this.handleImport.bind(this); this.handleExport = this.handleExport.bind(this); this.root.addEventListener('contextmenu', this._oncontextmenu.bind(this)); } _onclickaddbutton (e) { this.setValue([...this.value, '']); this.tryToFocusRow(this.value.length - 1); } unfocusAllRows () { for (const row of this.rows.values()) { row.unfocus(); } } tryToFocusRow (index) { if (index >= 0 && index < this.value.length) { this.unfocusAllRows(); let row = this.rows.get(index); if (!row) { row = this.createRow(index); } row.focus(); } } _onscroll (e) { this.scrollTop = e.target.scrollTop; this.updateValue(this.value); } _oncontextmenu (e) { e.preventDefault(); const menu = new ContextMenu(this.parent); menu.add({ text: this.parent.getMessage('list-import'), callback: this.handleImport }); menu.add({ text: this.parent.getMessage('list-export'), callback: this.handleExport }); menu.show(e); } handleImport () { const fileSelector = document.createElement('input'); fileSelector.type = 'file'; fileSelector.accept = '.txt,.csv,.tsv'; fileSelector.style.display = 'none'; document.body.appendChild(fileSelector); fileSelector.addEventListener('change', (e) => { const files = e.target.files; if (files.length === 0) return; const file = files[0]; readAsText(file).then((text) => this.import(text)); }); fileSelector.click(); } import (text) { // TODO: Scratch uses a CSV parser const lines = text.split(/\r?\n/); this.setValue(lines); } handleExport () { const value = this.getValue(); const exported = value.join('\n'); const blob = new Blob([exported], { type: 'text/plain' }); downloadBlob(`${this.getLabel()}.txt`, blob); } dropperCallback (texts) { this.import(texts.join('\n')); } getValue () { return this.getVmVariable().value; } setValue (value) { const variable = this.getVmVariable(); variable.value = value; this.updateValue(value); } update (monitor) { super.update(monitor); if (!this.visible) { return; } this.width = monitor.get('width') || 100; this.height = monitor.get('height') || 200; this.root.style.width = `${this.width}px`; this.root.style.height = `${this.height}px`; this.updateValue(monitor.get('value')); } createRow (index) { const row = this.cachedRows.pop() || new Row(this); row.setIndex(index); row.setValue(this.value[index]); this.rows.set(index, row); let foundPlaceInDOM = false; for (const root of this.rowsInner.children) { const otherIndexString = root.dataset.index; if (!otherIndexString) { continue; } const otherIndexNumber = +otherIndexString; if (otherIndexNumber > index) { this.rowsInner.insertBefore(row.root, root); foundPlaceInDOM = true; break; } } if (!foundPlaceInDOM) { this.rowsInner.appendChild(row.root); } return row; } updateValue (value) { this.value = value; if (value.length !== this.oldLength) { this.oldLength = value.length; this.footerText.textContent = this.parent.getMessage('list-length').replace('{n}', value.length); this.endPoint.style.transform = `translateY(${value.length * ROW_HEIGHT}px)`; this.emptyLabel.style.display = value.length ? 'none' : ''; } let startIndex = Math.floor(this.scrollTop / ROW_HEIGHT) - 5; if (startIndex < 0) startIndex = 0; let endIndex = Math.ceil((this.scrollTop + this.height) / ROW_HEIGHT) + 3; if (endIndex > value.length - 1) endIndex = value.length - 1; for (const index of this.rows.keys()) { if (index < startIndex || index > endIndex) { const row = this.rows.get(index); if (!row.locked || index >= value.length) { row.unfocus(); row.root.remove(); this.rows.delete(index); if (this.cachedRows.length < 10) { this.cachedRows.push(row); } } } } for (let i = startIndex; i <= endIndex; i++) { const row = this.rows.get(i); if (row) { row.setValue(value[i]); } else { this.createRow(i); } } } } export { VariableMonitor, ListMonitor };