/* Copyright (c) William Toohey * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ (function(window, document) { "use strict"; let WAVE_PIXELS_PER_SECOND = 100; let WAVE_HEIGHT_PIXELS = 20; function HuesEditor(core, huesWin) { this.buildEditSize = 80; // pixels, including header this.buildEdit = null; this.loopEdit = null; this.editArea = null; this.wrapAt = 32; this.hilightWidth = 0; this.hilightHeight = 0; this.undoBuffer = []; this.redoBuffer = []; // Will be an array if many actions are performed in one undo this.batchUndoArray = null; // For rendering the waveform this.buildWave = null; this.loopWave = null; this.buildWaveBuff = null; this.loopWaveBuff = null; this.waveContext = null; this.waveCanvas = null; // for storing respacks created with "new" this.respack = null; // when we're actually following the playing song this.linked = false; this.core = core; if(core.settings.defaults.enableWindow) { this.initUI(); core.addEventListener("beat", this.onBeat.bind(this)); core.addEventListener("newsong", this.onNewSong.bind(this)); huesWin.addTab("EDITOR", this.root); } } HuesEditor.prototype.initUI = function() { this.root = document.createElement("div"); this.root.className = "editor"; let titleButtons = document.createElement("div"); titleButtons.className = "editor__title-buttons"; this.root.appendChild(titleButtons); this.saveBtn = this.createButton("Save XML", titleButtons, true); this.saveBtn.addEventListener("click", this.saveXML.bind(this)); this.copyBtn = this.createButton("Copy XML", titleButtons, true); this.copyBtn.addEventListener("click", this.copyXML.bind(this)); this.undoBtn = this.createButton("Undo", titleButtons, true); this.undoBtn.addEventListener("click", this.undo.bind(this)); this.redoBtn = this.createButton("Redo", titleButtons, true); this.redoBtn.addEventListener("click", this.redo.bind(this)); let help = this.createButton("Help?", titleButtons); help.style.backgroundColor = "rgba(0,160,0,0.3)"; help.addEventListener("click", () => { window.open("https://github.com/mon/0x40-web/tree/master/docs/Editor.md", '_blank'); }); this.statusMsg = document.createElement("span"); this.statusMsg.className = "editor__status-msg"; titleButtons.appendChild(this.statusMsg); this.topBar = document.createElement("div"); this.topBar.className = "editor__top-bar"; this.root.appendChild(this.topBar); this.uiCreateInfo(); this.uiCreateImport(); this.root.appendChild(document.createElement("hr")); this.uiCreateEditArea(); this.uiCreateControls(); this.uiCreateVisualiser(); document.addEventListener("keydown", e => { e = e || window.event; if(e.defaultPrevented) { return true; } let key = e.keyCode || e.which; if (e.ctrlKey) { if(key == 90) { // Z this.undo(); } else if(key == 89) { // Y this.redo(); } if(key == 90 || key == 89) { e.preventDefault(); return false; } } return true; }); window.addEventListener('resize', this.resize.bind(this)); // Fix Chrome rendering - redraw on tab load // tabselected passes the name of the selected tab, we force noHilightCalc to false this.core.window.addEventListener("tabselected", this.resize.bind(this, false)); this.resize(); }; HuesEditor.prototype.resize = function(noHilightCalc) { this.root.style.height = (window.innerHeight - 200) + "px"; let boxHeight = this.editArea.offsetHeight; let bHeadHeight = this.buildEdit._header.offsetHeight; let lHeadHeight = this.loopEdit._header.offsetHeight; let handleHeight = this.resizeHandle.offsetHeight; let minHeight = bHeadHeight; let maxHeight = boxHeight - handleHeight - lHeadHeight - bHeadHeight; let buildHeight = Math.min(maxHeight, Math.max(minHeight, this.buildEditSize - handleHeight)); this.buildEdit.style.height = buildHeight + "px"; this.buildEdit._box.style.height = (buildHeight - bHeadHeight) + "px"; let loopHeight = maxHeight - buildHeight + lHeadHeight; this.loopEdit.style.height = loopHeight + "px"; this.loopEdit._box.style.height = (loopHeight - lHeadHeight) + "px"; // For window resizing down situation if(this.editArea.offsetHeight != boxHeight) { this.resize(); } // Resize the time lock this.timeLock.style.height = (buildHeight + handleHeight) + "px"; // Save to fix Chrome rendering and to enable right click to seek // We only resize on a window resize event, not when dragging the handle if(!noHilightCalc) { let hilight = document.createElement("div"); hilight.className = "beat-hilight"; // Because clientWidth is rounded, we need to take the average. 100 is plenty. let grid = ""; // height goes to 99 because we always have 1 line for(let i = 0; i < 99; i++) { grid += "
"; } // width for(let i = 0; i < 100; i++) { grid += " "; } hilight.innerHTML = grid; this.loopEdit.appendChild(hilight); this.hilightWidth = hilight.clientWidth / 100; this.hilightHeight = hilight.clientHeight / 100; this.loopEdit.removeChild(hilight); this.waveCanvas.width = this.waveCanvas.clientWidth; } }; HuesEditor.prototype.getOther = function(editor) { return editor == this.loopEdit ? this.buildEdit : this.loopEdit; }; HuesEditor.prototype.onNewSong = function(song) { if(this.linked) { if(song == this.song) { // Because you can "edit current" before it loads this.updateInfo(); this.updateWaveform(); } else { this.linked = false; // Clear beat hilight this.buildEdit._hilight.innerHTML = "█"; this.loopEdit._hilight.innerHTML = "█"; this.buildEdit._hilight.className = "beat-hilight invisible"; this.loopEdit._hilight.className = "beat-hilight invisible"; // Clear the waveform this.waveContext.clearRect(0, 0, this.waveCanvas.width, WAVE_HEIGHT_PIXELS); } } else if(song == this.song) { // went to another song then came back this.linked = true; } }; HuesEditor.prototype.onBeat = function(map, index) { if(!this.song || this.core.currentSong != this.song) { return; } let editor; if(index < 0) { index += this.core.currentSong.buildupRhythm.length; editor = this.buildEdit; this.loopEdit._hilight.className = "beat-hilight invisible"; } else { editor = this.loopEdit; if(this.song.buildup) { this.buildEdit._hilight.className = "beat-hilight invisible"; } } editor._hilight.className = "beat-hilight"; let offsetX = index % this.wrapAt; let offsetY = Math.floor(index / this.wrapAt); // Not computing width/height here due to Chrome bug editor._hilight.style.left = Math.floor(offsetX * this.hilightWidth) + "px"; editor._hilight.style.top = Math.floor(offsetY * this.hilightHeight) + "px"; }; HuesEditor.prototype.reflow = function(editor, map) { if(!map) { // NOTHING TO SEE HERE editor._beatmap.textContent = ""; editor._hilight.textContent = "[none]"; editor._hilight.className = "beat-hilight"; editor._hilight.style.top = "0"; editor._hilight.style.left = "0"; editor._beatCount.textContent = "0 beats"; return; } else { editor._hilight.innerHTML = "█"; } editor._beatCount.textContent = map.length + " beats"; // http://stackoverflow.com/a/27012001 // if it's too long to wrap, scroll in the x direction let regex = new RegExp("(.{" + this.wrapAt + "})", "g"); editor._beatmap.innerHTML = map.replace(regex, "$1
"); }; HuesEditor.prototype.updateInfo = function() { // Avoid a bunch of nested elses this.seekStart.classList.add("hues-button--disabled"); this.seekLoop.classList.add("hues-button--disabled"); this.saveBtn.classList.add("hues-button--disabled"); this.copyBtn.classList.add("hues-button--disabled"); this.buildEdit._removeBtn.classList.add("hues-button--disabled"); this.loopEdit._removeBtn.classList.add("hues-button--disabled"); if(!this.song) { return; } this.saveBtn.classList.remove("hues-button--disabled"); this.copyBtn.classList.remove("hues-button--disabled"); if(this.song.independentBuild) { this.timeLock._locker.innerHTML = ""; this.timeLock.classList.add("unlocked"); } else { this.timeLock._locker.innerHTML = ""; this.timeLock.classList.remove("unlocked"); } if(this.song.sound) { this.seekLoop.classList.remove("hues-button--disabled"); this.loopEdit._removeBtn.classList.remove("hues-button--disabled"); } if(this.song.buildup) { this.seekStart.classList.remove("hues-button--disabled"); this.buildEdit._removeBtn.classList.remove("hues-button--disabled"); } if(!this.linked) { return; } let loopLen = this.core.soundManager.loopLength; let buildLen = this.core.soundManager.buildLength; let beatLen = (loopLen / this.song.rhythm.length) * 1000; this.loopLen.textContent = loopLen.toFixed(2); this.buildLen.textContent = buildLen.toFixed(2); this.beatLen.textContent = beatLen.toFixed(2); }; HuesEditor.prototype.loadAudio = function(editor) { if(editor._fileInput.files.length < 1) { return; } // If first load, this makes fresh, gets the core synced up this.newSong(this.song); // Have we just added a build to a song with a rhythm, or vice versa? // If so, link their lengths let newlyLinked = !this.song[editor._sound] && !!this.song[this.getOther(editor)._sound]; // Disable load button TODO let file = editor._fileInput.files[0]; // load audio this.blobToArrayBuffer(file) .then(buffer => { // Is this buffer even decodable? let testSong = {test: buffer}; return this.core.soundManager.loadBuffer(testSong, "test") // keep the buffer moving through the chain // remember it's been passed through to a worker, so we update the reference .then(() => { return testSong.test; }); }).then(buffer => { this.song[editor._sound] = buffer; // Save filename for XML export let noExt = file.name.replace(/\.[^/.]+$/, ""); if(editor._sound == "sound") { this.song.name = noExt; } else { this.song.buildupName = noExt; } // make empty map if needed if(!this.getText(editor)) { this.setText(editor, "x...o...x...o..."); } // Do we have a loop to play? if(this.song.sound) { // Force refresh return this.core.soundManager.playSong(this.song, true, true); } }).then(() => { if(newlyLinked) { this.setIndependentBuild(false); } this.updateInfo(); this.core.updateBeatLength(); // We may have to go backwards in time this.core.recalcBeatIndex(); this.updateWaveform(); }).catch(error => { console.log(error); this.alert("Couldn't load song! Is it a LAME encoded MP3?"); }); }; HuesEditor.prototype.removeAudio = function(editor) { if(!this.song) { return; } this.song[editor._sound] = null; this.song[editor._rhythm] = ""; this.setIndependentBuild(true); this.reflow(editor, ""); // Is the loop playable? if(this.song.sound && this.linked) { this.core.soundManager.playSong(this.song, true, true) .then(() => { this.updateWaveform(); }); } else { this.core.soundManager.stop(); this.updateWaveform(); } this.updateInfo(); this.updateHalveDoubleButtons(editor); }; HuesEditor.prototype.blobToArrayBuffer = function(blob) { return new Promise((resolve, reject) => { let fr = new FileReader(); fr.onload = () => { resolve(fr.result); }; fr.onerror = () => { reject(new Error("File read failed!")); }; fr.readAsArrayBuffer(blob); }); }; HuesEditor.prototype.newSong = function(song) { if(!song) { song = {"name":"Name", "title":"Title", "rhythm":"", "source":"", "sound":null, "enabled":true, "filename":null, "charsPerBeat": null, // Because new songs are empty "independentBuild": true}; if(!this.respack) { this.respack = new Respack(); this.respack.name = "Editor Respack"; this.respack.author = "You!"; this.respack.description = "An internal resourcepack for editing new songs"; this.core.resourceManager.addPack(this.respack); } this.respack.songs.push(song); this.core.resourceManager.rebuildArrays(); this.core.resourceManager.rebuildEnabled(); this.core.setSongOject(song); } // Clear instructions this.buildEdit._hilight.className = "beat-hilight invisible"; this.loopEdit._hilight.className = "beat-hilight invisible"; // Clear helpful glows this.newSongBtn.classList.remove("hues-button--glow"); this.fromSongBtn.classList.remove("hues-button--glow"); // Enable title edits this.title.disabled = false; this.source.disabled = false; this.clearUndoRedo(); this.song = song; this.reflow(this.buildEdit, song.buildupRhythm); this.reflow(this.loopEdit, song.rhythm); this.title.value = song.title; this.source.value = song.source; // Force independent build if only 1 source is present this.updateIndependentBuild(); // Unlock beatmap lengths this.setLocked(this.buildEdit, 0); this.setLocked(this.loopEdit, 0); this.linked = true; this.updateInfo(); this.updateWaveform(); }; HuesEditor.prototype.updateIndependentBuild = function() { // Force independent build if only 1 source is present // Effectively buildup XOR loop - does only 1 exist? let hasBuild = !!this.song.buildup; let hasLoop = !!this.song.sound; if(hasBuild != hasLoop) { this.setIndependentBuild(true); } }; HuesEditor.prototype.setIndependentBuild = function(indep) { this.song.independentBuild = indep; if(!indep) { // If both are locked, we lock the result, otherwise unlock both let lock = this.loopEdit._locked && this.buildEdit._locked; // Then unlock both so text adjustment can occur this.loopEdit._locked = 0; this.buildEdit._locked = 0; // Correct the lengths this.setText(this.loopEdit, this.getText(this.loopEdit)); // Restore locked state if(lock) { this.loopEdit._locked = this.song.rhythm.length; this.buildEdit._locked = this.song.buildupRhythm.length; } } this.updateInfo(); }; HuesEditor.prototype.batchUndo = function() { if(!this.batchUndoArray) this.batchUndoArray = []; }; HuesEditor.prototype.commitUndo = function() { if(this.batchUndoArray) { this.undoBuffer.push(this.batchUndoArray); this.trimUndo(); this.batchUndoArray = null; this.updateUndoUI(); } }; HuesEditor.prototype.pushUndo = function(name, editor, oldText, newText) { if(oldText == newText) { return; } this.redoBuffer = []; let undoObj = {songVar: name, editor: editor, text: oldText, indep: this.song.independentBuild}; if(this.batchUndoArray) { this.batchUndoArray.push(undoObj); } else { // 1 element array so undoRedo is neater this.undoBuffer.push([undoObj]); this.trimUndo(); } this.updateUndoUI(); }; HuesEditor.prototype.trimUndo = function() { while(this.undoBuffer.length > 50) { this.undoBuffer.shift(); } }; HuesEditor.prototype.undo = function() { this.undoRedo(this.undoBuffer, this.redoBuffer); }; HuesEditor.prototype.redo = function() { this.undoRedo(this.redoBuffer, this.undoBuffer); }; HuesEditor.prototype.undoRedo = function(from, to) { if(from.length === 0 || !this.song) { return; } // Remove old data let fromArray = from.pop(); let toArray = []; for(let i = 0; i < fromArray.length; i++) { let fromData = fromArray[i]; // Make restore from current toArray.push({songVar: fromData.songVar, editor: fromData.editor, text: this.song[fromData.songVar], indep: this.song.independentBuild}); // Restore to editor this.song[fromData.songVar] = fromData.text; this.song.independentBuild = fromData.indep; // Don't have weird behaviour there if(fromData.editor._locked) { fromData.editor._locked = fromData.text.length; } this.reflow(fromData.editor, this.song[fromData.songVar]); this.updateHalveDoubleButtons(fromData.editor); } to.push(toArray); this.updateUndoUI(); this.updateInfo(); this.core.updateBeatLength(); this.core.recalcBeatIndex(); }; HuesEditor.prototype.clearUndoRedo = function() { this.undoBuffer = []; this.redoBuffer = []; this.updateUndoUI(); }; HuesEditor.prototype.updateUndoUI = function() { this.undoBtn.className = "hues-button hues-button--disabled"; this.redoBtn.className = "hues-button hues-button--disabled"; if(this.undoBuffer.length > 0) { this.undoBtn.classList.remove("hues-button--disabled"); } if(this.redoBuffer.length > 0) { this.redoBtn.classList.remove("hues-button--disabled"); } }; HuesEditor.prototype.halveBeats = function(editor) { let commit = false; if(!this.song.independentBuild) { commit = true; this.batchUndo(); // halve them both let other = this.getOther(editor); this.song.independentBuild = true; this.halveBeats(other); } this.setText(editor, this.song[editor._rhythm].replace(/(.)./g, "$1")); if(commit) { this.commitUndo(); // We set it so any rounding is padded this.setIndependentBuild(false); } }; HuesEditor.prototype.doubleBeats = function(editor) { let commit = false; if(!this.song.independentBuild) { commit = true; this.batchUndo(); // Double them both let other = this.getOther(editor); this.song.independentBuild = true; this.doubleBeats(other); } this.setText(editor, this.song[editor._rhythm].replace(/(.)/g, "$1.")); if(commit) { this.commitUndo(); // We set it so any rounding is padded this.setIndependentBuild(false); } }; HuesEditor.prototype.updateHalveDoubleButtons = function(editor) { editor._halveBtn.className = "hues-button hues-button--disabled"; editor._doubleBtn.className = "hues-button hues-button--disabled"; if(!editor._locked) { let txtLen = this.getText(editor).length; if(!this.song.independentBuild) { let other = this.getOther(editor); txtLen = Math.min(txtLen, this.getText(other).length); } if(txtLen > 0) { editor._doubleBtn.className = "hues-button"; } if(txtLen > 1) { editor._halveBtn.className = "hues-button"; } } }; HuesEditor.prototype.createTextInput = function(label, subtitle, parent) { let div = document.createElement("div"); div.className = "editor__label"; let caption = document.createElement("label"); caption.innerHTML = label; div.appendChild(caption); let container = document.createElement("span"); container.className = "editor__textinput-container"; let input = document.createElement("input"); input.className = "editor__textinput"; input.type = "text"; input.value = subtitle; container.appendChild(input); div.appendChild(container); parent.appendChild(div); return input; }; HuesEditor.prototype.createButton = function(label, parent, disabled, extraClass) { let button = document.createElement("span"); button.className = "hues-button"; if(disabled) { button.classList.add("hues-button--disabled"); } if(extraClass) { button.className += " " + extraClass; } // Automagically make disabled buttons ignore clicks button.addEventListener("click", event => { if(button.classList.contains("hues-button--disabled")) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); return false; } else { return true; } }); button.innerHTML = label.toUpperCase(); parent.appendChild(button); return button; }; HuesEditor.prototype.uiCreateInfo = function() { let info = document.createElement("div"); this.topBar.appendChild(info); info.className = "editor__info"; let songUpdate = function(name) { if(!this.song ) { return; } this.song[name] = this[name].value; if(this.song != this.core.currentSong) { return; } this.core.callEventListeners("newsong", this.song); }; this.title = this.createTextInput("Title:", "Song name", info); this.title.oninput = songUpdate.bind(this, "title"); this.title.disabled = true; this.source = this.createTextInput("Link: ", "Source link", info); this.source.oninput = songUpdate.bind(this, "source"); this.source.disabled = true; }; HuesEditor.prototype.uiCreateImport = function() { let imports = document.createElement("div"); this.topBar.appendChild(imports); imports.className = "editor__imports"; let songEdits = document.createElement("div"); imports.appendChild(songEdits); let newSongBtn = this.createButton("New song", songEdits, false, "hues-button--glow"); newSongBtn.addEventListener("click", () => { this.newSong(); }); this.newSongBtn = newSongBtn; let fromSong = this.createButton("Edit current song", songEdits, false, "hues-button--glow"); fromSong.addEventListener("click", () => { if(this.core.currentSong) { this.newSong(this.core.currentSong); } }); this.fromSongBtn = fromSong; let songInfos = document.createElement("div"); songInfos.className = "settings-individual editor__song-stats"; imports.appendChild(songInfos); this.loopLen = this.uiCreateSongStat("Loop length (s):", "0.00", songInfos); this.buildLen = this.uiCreateSongStat("Build length (s):", "0.00", songInfos); this.beatLen = this.uiCreateSongStat("Beat length (ms):", "0.00", songInfos); }; HuesEditor.prototype.uiCreateSongStat = function(name, value, parent) { let container = document.createElement("div"); parent.appendChild(container); let label = document.createElement("span"); label.textContent = name; container.appendChild(label); let valueSpan = document.createElement("span"); valueSpan.textContent = value; valueSpan.className = "editor__song-stats__value"; container.appendChild(valueSpan); return valueSpan; }; HuesEditor.prototype.uiCreateEditArea = function() { let editArea = document.createElement("div"); this.editArea = editArea; editArea.className = "edit-area"; this.root.appendChild(editArea); // Lock build/loop lengths this.timeLock = document.createElement("div"); editArea.appendChild(this.timeLock); this.timeLock.className = "hues-icon edit-area__timelock edit-area__timelock--unlocked"; // CHAIN-BROKEN, use  for CHAIN let locker = this.createButton("", this.timeLock); locker.addEventListener("click", () => { // Only allow if both song bits exist if(!this.song || !this.song.buildup || !this.song.sound) { return; } this.setIndependentBuild(!this.song.independentBuild); }); this.timeLock._locker = locker; this.buildEdit = this.uiCreateSingleEditor("Buildup", "buildup", "buildupRhythm", editArea); this.seekStart = this.buildEdit._seek; // FIRST |<< this.seekStart.innerHTML = ""; this.seekStart.addEventListener("click", () => { this.core.soundManager.seek(-this.core.soundManager.buildLength); }); // drag handle let handleContainer = document.createElement("div"); handleContainer.className = "resize-handle"; editArea.appendChild(handleContainer); let handle = document.createElement("div"); handle.className = 'hues-icon resize-handle__handle'; handle.innerHTML = ""; // DRAG HANDLE handleContainer.appendChild(handle); this.resizeHandle = handleContainer; handleContainer.addEventListener("mousedown", (e) => { e.preventDefault(); let editTop = this.editArea.getBoundingClientRect().top; let handleSize = this.resizeHandle.clientHeight; let resizer = (e) => { this.buildEditSize = Math.floor(e.clientY - editTop + handleSize/2); this.resize(true); }; let mouseup = function(e) { document.removeEventListener("mousemove", resizer); document.removeEventListener("mouseup", mouseup); }; document.addEventListener("mousemove", resizer); document.addEventListener("mouseup", mouseup); }); this.loopEdit = this.uiCreateSingleEditor("Rhythm ", "sound", "rhythm", editArea); this.seekLoop = this.loopEdit._seek; // FIRST |<< this.seekLoop.innerHTML = ""; this.seekLoop.addEventListener("click", () => { this.core.soundManager.seek(0); }); this.buildEdit._hilight.textContent = "[none]"; this.loopEdit._hilight.innerHTML = '
' + 'Click [LOAD RHYTHM] to load a loop! LAME encoded MP3s work best.
' + '(LAME is important for seamless MP3 loops)
' + '
' + '[DOUBLE] doubles the selected map length by padding it with "."s.
' + '[HALVE] shortens the map length by removing every other character.
' + '
' + 'You can also add a buildup with [LOAD BUILDUP], or remove it
' + 'with [REMOVE].
' + '
' + '[NEW SONG] adds a completely empty song for you to edit, and
' + '[EDIT CURRENT SONG] takes the current playing song to the editor.
' + '
' + '[COPY/SAVE XML] allow for storing the rhythms and easy
' + 'inclusion into a Resource Pack!'; }; HuesEditor.prototype.uiCreateSingleEditor = function(title, soundName, rhythmName, parent) { let container = document.createElement("div"); parent.appendChild(container); let header = document.createElement("div"); header.className = "edit-area__header"; container.appendChild(header); let nameLabel = document.createElement("span"); header.appendChild(nameLabel); nameLabel.innerHTML = title; let seek = this.createButton("", header, true, "hues-icon"); header.appendChild(seek); container._seek = seek; let beatCount = document.createElement("span"); header.appendChild(beatCount); beatCount.className = "edit-area__beat-count"; beatCount.textContent = "0 beats"; container._lockedBtn = this.createButton("", header, false, "hues-icon"); container._lockedBtn.addEventListener("click", () => { if(container._locked) { this.setLocked(container, 0); } else { let textLen = this.getText(container).length; this.setLocked(container, textLen); } }); let rightHeader = document.createElement("span"); rightHeader.className = "edit-area__header__right"; header.appendChild(rightHeader); container._halveBtn = this.createButton("Halve", rightHeader, true); container._halveBtn.addEventListener("click", this.halveBeats.bind(this, container)); container._doubleBtn = this.createButton("Double", rightHeader, true); container._doubleBtn.addEventListener("click", this.doubleBeats.bind(this, container)); let fileInput = document.createElement("input"); fileInput.type ="file"; fileInput.accept=".mp3, .wav, .ogg"; fileInput.multiple = false; fileInput.onchange = this.loadAudio.bind(this, container); let load = this.createButton("Load " + title.replace(/ /g,""), rightHeader); load.addEventListener("click", () => {fileInput.click();}); container._removeBtn = this.createButton("Remove", rightHeader, true); container._removeBtn.addEventListener("click", this.removeAudio.bind(this, container)); let editBox = document.createElement("div"); editBox.className = "edit-area__box"; let beatmap = document.createElement("div"); beatmap.className = "edit-area__beatmap"; beatmap.contentEditable = true; beatmap.spellcheck = false; beatmap.oninput = this.textUpdated.bind(this, container); beatmap.oncontextmenu = this.rightClick.bind(this, container); let beatHilight = document.createElement("div"); beatHilight.className = "beat-hilight"; editBox.appendChild(beatHilight); editBox.appendChild(beatmap); container.appendChild(editBox); container._header = header; container._beatCount = beatCount; container._box = editBox; container._beatmap = beatmap; container._hilight = beatHilight; container._fileInput = fileInput; container._sound = soundName; container._rhythm = rhythmName; // Are we in insert mode? Default = no container._locked = 0; return container; }; HuesEditor.prototype.uiCreateControls = function() { let controls = document.createElement("div"); controls.className = "edit__controls"; this.root.appendChild(controls); let changeRate = function(change) { let rate = this.core.soundManager.playbackRate; rate += change; this.core.soundManager.setRate(rate); // In case it gets clamped, check let newRate = this.core.soundManager.playbackRate; playRateLab.textContent = newRate.toFixed(2) + "x"; }; let speedControl = document.createElement("div"); controls.appendChild(speedControl); // BACKWARD let speedDown = this.createButton("", speedControl, false, "hues-icon"); speedDown.addEventListener("click", changeRate.bind(this, -0.25)); // FORWARD let speedUp = this.createButton("", speedControl, false, "hues-icon"); speedUp.addEventListener("click", changeRate.bind(this, 0.25)); let playRateLab = document.createElement("span"); playRateLab.className = "settings-individual"; playRateLab.textContent = "1.00x"; speedControl.appendChild(playRateLab); let wrapControl = document.createElement("div"); controls.appendChild(wrapControl); let wrapLab = document.createElement("span"); wrapLab.className = "settings-individual"; wrapLab.textContent = "New line at beat "; wrapControl.appendChild(wrapLab); let wrapAt = document.createElement("input"); wrapAt.className = "settings-input"; wrapAt.value = this.wrapAt; wrapAt.type = "text"; wrapAt.oninput = () => { wrapAt.value = wrapAt.value.replace(/\D/g,''); if(wrapAt.value === "" || wrapAt.value < 1) { wrapAt.value = ""; return; } this.wrapAt = parseInt(wrapAt.value); this.reflow(this.buildEdit, this.song.buildupRhythm); this.reflow(this.loopEdit, this.song.rhythm); }; wrapControl.appendChild(wrapAt); }; HuesEditor.prototype.uiCreateVisualiser = function() { let wave = document.createElement("canvas"); wave.className = "waveform"; wave.height = WAVE_HEIGHT_PIXELS; this.root.appendChild(wave); this.waveCanvas = wave; this.waveContext = wave.getContext("2d"); this.core.addEventListener("frame", this.drawWave.bind(this)); }; HuesEditor.prototype.rightClick = function(editor, event) { if(!this.linked) { return; } // If the right click is also a focus event, caret doesn't move, so we have to use coords let coords = this.getTextCoords(event); if(coords.x > this.wrapAt) return true; let caret = coords.y * this.wrapAt + coords.x; let totalLen = this.getText(editor).length; if(caret > totalLen) return true; // in case of focus event this.setCaret(editor._beatmap, caret); let percent = caret / totalLen; let seekTime = 0; if(editor == this.loopEdit) { // loop seekTime = this.core.soundManager.loopLength * percent; } else { // build let bLen = this.core.soundManager.buildLength; seekTime = -bLen + bLen * percent; } this.core.soundManager.seek(seekTime); event.preventDefault(); return false; }; HuesEditor.prototype.getTextCoords = function(event) { // http://stackoverflow.com/a/10816667 let el = event.target, x = 0, y = 0; while (el && !isNaN(el.offsetLeft) && !isNaN(el.offsetTop)) { x += el.offsetLeft - el.scrollLeft; y += el.offsetTop - el.scrollTop; el = el.offsetParent; } x = Math.floor((event.clientX - x) / this.hilightWidth); y = Math.floor((event.clientY - y) / this.hilightHeight); return {x: x, y: y}; }; HuesEditor.prototype.textUpdated = function(editor) { if(!this.song || !this.song[editor._sound]) { this.reflow(editor, ""); return; } // Space at start of line is nonbreaking, get it with \u00a0 let input = editor._beatmap.textContent.replace(/ |\u00a0/g, ""); if(input.length === 0) { input = "."; } this.setText(editor, input); }; HuesEditor.prototype.getText = function(editor) { if(!this.song || !this.song[editor._rhythm]) { return ""; } else { return this.song[editor._rhythm]; } }; HuesEditor.prototype.setText = function(editor, text, caretFromEnd) { if(!this.song || !this.song[editor._sound]) { this.reflow(editor, ""); return; } let commitUndo = false; let caret = caretFromEnd ? text.length : this.getCaret(editor._beatmap); if(editor._locked) { caret = Math.min(editor._locked, caret); if(text.length > editor._locked) { // Works for pastes too! Removes the different between sizes from the caret position text = text.slice(0, caret) + text.slice(caret + (text.length - editor._locked), text.length); } else { while(text.length < editor._locked) { text += "."; } } // time to scale things to fit } else if(!this.song.independentBuild && this.song.buildupRhythm && this.song.rhythm) { let ratio; if(editor == this.loopEdit) { ratio = this.core.soundManager.loopLength / this.core.soundManager.buildLength; } else { ratio = this.core.soundManager.buildLength / this.core.soundManager.loopLength; } let newLen = Math.round(text.length / ratio); // We've tried to make the other map impossibly short, force us to be longer while(newLen === 0) { text += "."; newLen = Math.round(text.length / ratio); } let otherMap = this.getOther(editor); let wasLocked = otherMap._locked; // clamp the length otherMap._locked = newLen; // Make undos also sync this.batchUndo(); commitUndo = true; // avoid infinite loop this.song.independentBuild = true; // Use setText to update undo state and fill/clamp beats this.setText(otherMap, this.song[otherMap._rhythm], true); // Restore this.song.independentBuild = false; // Otherwise we'll lose the new length on the next edit if(!wasLocked) { otherMap._locked = 0; } // Fix the buttons this.updateHalveDoubleButtons(otherMap); } this.pushUndo(editor._rhythm, editor, this.song[editor._rhythm], text); // If we were linked, commit our 2 edits as 1 undo state if(commitUndo) { this.commitUndo(); } // Make sure you can't accidentally close the tab window.onbeforeunload = this.confirmLeave; this.song[editor._rhythm] = text; this.reflow(editor, this.song[editor._rhythm]); this.setCaret(editor._beatmap, caret); this.updateHalveDoubleButtons(editor); this.core.updateBeatLength(); // We may have to go backwards in time this.core.recalcBeatIndex(); this.updateInfo(); }; HuesEditor.prototype.getCaret = function(editable) { let caret = 0; let sel = window.getSelection(); if (sel.rangeCount) { let range = sel.getRangeAt(0); //
elements are empty, and pastes do weird things. // So don't go up in multiples of 2 for getCaret for(let i = 0; i < editable.childNodes.length; i++) { if (range.commonAncestorContainer == editable.childNodes[i]) { caret += range.endOffset; return caret; } else { caret += editable.childNodes[i].textContent.length; } } } return 0; }; HuesEditor.prototype.setCaret = function(editable, caret) { let range = document.createRange(); let sel = window.getSelection(); //
elements mean children go up in multiples of 2 for(let i = 0; i < editable.childNodes.length; i+= 2) { let textLen = editable.childNodes[i].textContent.length; if(caret > textLen) { caret -= textLen; } else { range.setStart(editable.childNodes[i], caret); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); break; } } }; HuesEditor.prototype.setLocked = function(editor, locked) { editor._locked = locked; if(locked) { editor._lockedBtn.innerHTML = ""; // LOCKED } else { editor._lockedBtn.innerHTML = ""; // UNLOCKED } // Synchronise locks when lengths are linked if(!this.song.independentBuild) { let other = this.getOther(editor); let otherLock = locked ? this.getText(other).length : 0; this.song.independentBuild = true; this.setLocked(other, otherLock); this.song.independentBuild = false; } this.updateHalveDoubleButtons(editor); }; HuesEditor.prototype.updateWaveform = function() { if(this.buildWaveBuff != this.core.soundManager.buildup) { this.buildWaveBuff = this.core.soundManager.buildup; this.buildWave = this.renderWave(this.buildWaveBuff, this.core.soundManager.buildLength); } if(this.loopWaveBuff != this.core.soundManager.loop) { this.loopWaveBuff = this.core.soundManager.loop; this.loopWave = this.renderWave(this.loopWaveBuff, this.core.soundManager.loopLength); } }; HuesEditor.prototype.renderWave = function(buffer, length) { if(!buffer) { return null; } // The individual wave section let wave = document.createElement("canvas"); let waveContext = wave.getContext("2d"); wave.height = WAVE_HEIGHT_PIXELS; wave.width = Math.floor(WAVE_PIXELS_PER_SECOND * length); let samplesPerPixel = Math.floor(buffer.sampleRate / WAVE_PIXELS_PER_SECOND); let waveData = []; for(let i = 0; i < buffer.numberOfChannels; i++) { waveData.push(buffer.getChannelData(i)); } let channels = buffer.numberOfChannels; // Half pixel offset makes things look crisp let pixel = 0.5; let halfHeight = WAVE_HEIGHT_PIXELS/2; for(let i = 0; i < buffer.length; i += samplesPerPixel) { let min = 0, max = 0, avgHi = 0, avgLo = 0; let j; for(j = 0; j < samplesPerPixel && i + j < buffer.length; j++) { for(let chan = 0; chan < channels; chan++) { let sample = waveData[chan][i+j]; if(sample > 0) { avgHi += sample; } else { avgLo += sample; } if(sample > max) max = sample; if(sample < min) min = sample; } } let maxPix = Math.floor(halfHeight + max * halfHeight); // Min is negative, addition is correct let minPix = Math.floor(halfHeight + min * halfHeight); waveContext.strokeStyle = "black"; waveContext.globalAlpha = "1"; waveContext.beginPath(); waveContext.moveTo(pixel, maxPix); waveContext.lineTo(pixel, minPix); waveContext.stroke(); // Draw the average too, gives a better feel for the wave avgHi /= j * channels; avgLo /= j * channels; let maxAvg = Math.floor(halfHeight + avgHi * halfHeight); let minAvg = Math.floor(halfHeight + avgLo * halfHeight); waveContext.strokeStyle = "white"; waveContext.globalAlpha = "0.5"; waveContext.beginPath(); waveContext.moveTo(pixel, maxAvg); waveContext.lineTo(pixel, minAvg); waveContext.stroke(); pixel+=1; } return wave; }; HuesEditor.prototype.drawWave = function() { if((!this.buildWave && !this.loopWave) || !this.linked) return; let width = this.waveCanvas.width; let now = this.core.soundManager.currentTime(); let timespan = width / WAVE_PIXELS_PER_SECOND / 2; let minTime = now - timespan; let maxTime = now + timespan; let bLen = this.core.soundManager.buildLength; let loopLen = this.core.soundManager.loopLength; let drawTime, drawOffset; if(bLen) { drawTime = Math.max(minTime, -bLen); } else { drawTime = Math.max(minTime, 0); } // drawOffset is "pixels from the left" drawOffset = Math.floor((drawTime - minTime) * WAVE_PIXELS_PER_SECOND); this.waveContext.clearRect(0, 0, width, WAVE_HEIGHT_PIXELS); if(this.buildWave && bLen && minTime < 0) { // Bit of legwork to convert negative to positive let waveOffset = Math.floor((1 - drawTime / -bLen) * (this.buildWave.width-1)); try { drawOffset = this.drawOneWave(this.buildWave, waveOffset, drawOffset, width); } catch (err) { console.log(this.waveCanvas); } // If there's more to draw after the build, it'll be from the start of the wave drawTime = 0; } let loopPoints = []; if(this.loopWave && loopLen && maxTime > 0) { while(drawOffset < width) { if(drawTime === 0) { loopPoints.push(drawOffset); } let waveOffset = Math.floor((drawTime / loopLen) * (this.loopWave.width-1)); drawOffset = this.drawOneWave(this.loopWave, waveOffset, drawOffset, width); // If we're drawing more than 1 loop it's starting at 0 drawTime = 0; } } // trackbar this.drawWaveBar("red", width/2); // Signify loop point with a green bar, drawing over the wave for(let point of loopPoints) { this.drawWaveBar("green", point); } }; HuesEditor.prototype.drawOneWave = function(wave, waveOffset, drawOffset, width) { let drawWidth = Math.min(width - drawOffset, wave.width - waveOffset); this.waveContext.drawImage(wave, waveOffset, 0, // source x/y drawWidth, WAVE_HEIGHT_PIXELS, // source width/height drawOffset, 0, // dest x/y drawWidth, WAVE_HEIGHT_PIXELS); // dest width/height return drawOffset + drawWidth; }; HuesEditor.prototype.drawWaveBar = function(colour, offset) { this.waveContext.strokeStyle = colour; this.waveContext.lineWidth = 2; this.waveContext.beginPath(); this.waveContext.moveTo(offset, 0); this.waveContext.lineTo(offset, WAVE_HEIGHT_PIXELS); this.waveContext.stroke(); }; HuesEditor.prototype.confirmLeave = function() { return "Unsaved beatmap - leave anyway?"; }; HuesEditor.prototype.alert = function(msg) { this.statusMsg.classList.remove("editor__status-msg--fade"); this.statusMsg.textContent = msg; // Trigger a reflow and thus restart the animation var useless = this.statusMsg.offsetWidth; this.statusMsg.classList.add("editor__status-msg--fade"); }; HuesEditor.prototype.generateXML = function() { if(!this.song) { return null; } // Yes, this is just a bunch of strings. Simple XML, simple method. let result = " \n"; result += " " + this.song.title + "\n"; if(this.song.source) { result += " " + this.song.source + "\n"; } result += " " + this.song.rhythm + "\n"; if(this.song.buildup) { result += " " + this.song.buildupName + "\n"; result += " " + this.song.buildupRhythm + "\n"; if(this.song.independentBuild) { result += " true\n"; } } result += " \n"; return result; }; HuesEditor.prototype.saveXML = function() { let xml = this.generateXML(); if(!xml) { return; } let result = "\n"; result += xml; result += "\n"; // http://stackoverflow.com/a/18197341 let element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(result)); element.setAttribute('download', "0x40Hues - " + this.song.name + ".xml"); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); window.onbeforeunload = null; }; // http://stackoverflow.com/a/30810322 HuesEditor.prototype.copyXML = function() { let text = this.generateXML(); // Clicking when disabled if(!text) { return; } let textArea = document.createElement("textarea"); textArea.className = "copybox"; textArea.value = text; document.body.appendChild(textArea); textArea.select(); let success; try { success = document.execCommand('copy'); } catch (err) { success = false; } document.body.removeChild(textArea); if(success) { this.alert("Beatmap XML copied to clipboard!"); } else { this.alert("Copy failed! Try saving instead"); } }; window.HuesEditor = HuesEditor; })(window, document);