From 0929719a845897cc8567cf972e07a69a71f0fa6f Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Thu, 30 Nov 2023 13:29:08 -0500 Subject: Migrate to a full rails app --- app/assets/javascripts/utilities.js.erb | 498 ++++++++++++++++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 app/assets/javascripts/utilities.js.erb (limited to 'app/assets/javascripts/utilities.js.erb') diff --git a/app/assets/javascripts/utilities.js.erb b/app/assets/javascripts/utilities.js.erb new file mode 100644 index 0000000..0414ce8 --- /dev/null +++ b/app/assets/javascripts/utilities.js.erb @@ -0,0 +1,498 @@ +function namespace(code) { + code() +} + +namespace(function() { + +/*** Start cross-compatibility ***/ +// Used to detect if IDs include a direction, e.g. resize-top-left +if (!String.prototype.includes) { + String.prototype.includes = function() { + return String.prototype.indexOf.apply(this, arguments) !== -1 + } +} +Event.prototype.movementX = Event.prototype.movementX || Event.prototype.mozMovementX +Event.prototype.movementY = Event.prototype.movementY || Event.prototype.mozMovementY +Event.prototype.isRightClick = function() { + return this.which === 3 || (this.touches && this.touches.length > 1) +} +Element.prototype.disable = function() { + this.disabled = true + this.style.pointerEvents = 'none' + this.className = 'noselect' +} +Element.prototype.enable = function() { + this.disabled = false + this.style.pointerEvents = null + this.className = null +} +Object.defineProperty(Event.prototype, 'position', { + 'get': function() { + return { + 'x': event.pageX || event.clientX || (event.touches && event.touches[0].pageX) || null, + 'y': event.pageY || event.clientY || (event.touches && event.touches[0].pageY) || null, + } + } +}) +/*** End cross-compatibility ***/ + +var proxy = { + 'get': function(_, key) { + try { + return this._map[key] + } catch (e) { + return null + } + }, + 'set': function(_, key, value) { + if (value == null) { + delete this._map[key] + } else { + this._map[key] = value.toString() + window.localStorage.setItem('settings', JSON.stringify(this._map)) + } + }, + 'init': function() { + this._map = {} + try { + var j = window.localStorage.getItem('settings') + if (j != null) this._map = JSON.parse(j) + } catch (e) {/* Do nothing */} + + function setIfNull(map, key, value) { + if (map[key] == null) map[key] = value + } + + // Set any values which are not defined + setIfNull(this._map, 'theme', 'light') + setIfNull(this._map, 'volume', '0.12') + setIfNull(this._map, 'sensitivity', '0.7') + setIfNull(this._map, 'expanded', 'false') + setIfNull(this._map, 'customMechanics', 'false') + return this + }, +} +window.settings = new Proxy({}, proxy.init()) + +var tracks = { + 'start': new Audio(src = '<%= asset_url("panel_start_tracing.aac") %>'), + 'success': new Audio(src = '<%= asset_url("panel_success.aac") %>'), + 'fail': new Audio(src = '<%= asset_url("panel_failure.aac") %>'), + 'abort': new Audio(src = '<%= asset_url("panel_abort_tracing.aac") %>'), +} + +var currentAudio = null +window.PLAY_SOUND = function(name) { + if (currentAudio) currentAudio.pause() + var audio = tracks[name] + audio.load() + audio.volume = parseFloat(window.settings.volume) + audio.play().then(function() { + currentAudio = audio + }).catch(function() { + // Do nothing. + }) +} + +window.LINE_PRIMARY = '#8FF' +window.LINE_SECONDARY = '#FF2' + +if (window.settings.theme == 'night') { + window.BACKGROUND = '#221' + window.OUTER_BACKGROUND = '#070704' + window.FOREGROUND = '#751' + window.BORDER = '#666' + window.LINE_DEFAULT = '#888' + window.LINE_SUCCESS = '#BBB' + window.LINE_FAIL = '#000' + window.CURSOR = '#FFF' + window.TEXT_COLOR = '#AAA' + window.PAGE_BACKGROUND = '#000' + window.ALT_BACKGROUND = '#333' // An off-black. Good for mild contrast. + window.ACTIVE_COLOR = '#555' // Color for 'while the element is being pressed' +} else if (window.settings.theme == 'light') { + window.BACKGROUND = '#0A8' + window.OUTER_BACKGROUND = '#113833' + window.FOREGROUND = '#344' + window.BORDER = '#000' + window.LINE_DEFAULT = '#AAA' + window.LINE_SUCCESS = '#FFF' + window.LINE_FAIL = '#000' + window.CURSOR = '#FFF' + window.TEXT_COLOR = '#000' + window.PAGE_BACKGROUND = '#FFF' + window.ALT_BACKGROUND = '#EEE' // An off-white. Good for mild contrast. + window.ACTIVE_COLOR = '#DDD' // Color for 'while the element is being pressed' +} + +window.LINE_NONE = 0 +window.LINE_BLACK = 1 +window.LINE_BLUE = 2 +window.LINE_YELLOW = 3 +window.LINE_OVERLAP = 4 +window.DOT_NONE = 0 +window.DOT_BLACK = 1 +window.DOT_BLUE = 2 +window.DOT_YELLOW = 3 +window.DOT_INVISIBLE = 4 +window.GAP_NONE = 0 +window.GAP_BREAK = 1 +window.GAP_FULL = 2 + +var animations = '' +var l = function(line) {animations += line + '\n'} +// pointer-events: none; allows for events to bubble up (so that editor hooks still work) +l('.line-1 {') +l(' fill: ' + window.LINE_DEFAULT + ';') +l(' pointer-events: none;') +l('}') +l('.line-2 {') +l(' fill: ' + window.LINE_PRIMARY + ';') +l(' pointer-events: none;') +l('}') +l('.line-3 {') +l(' fill: ' + window.LINE_SECONDARY + ';') +l(' pointer-events: none;') +l('}') +l('.line-4 {') +l(' display: none;') +l(' pointer-events: none;') +l('}') +l('@keyframes line-success {to {fill: ' + window.LINE_SUCCESS + ';}}') +l('@keyframes line-fail {to {fill: ' + window.LINE_FAIL + ';}}') +l('@keyframes error {to {fill: red;}}') +l('@keyframes fade {to {opacity: 0.35;}}') +l('@keyframes start-grow {from {r:12;} to {r:24;}}') +// Neutral button style +l('button {') +l(' background-color: ' + window.ALT_BACKGROUND + ';') +l(' border: 1px solid ' + window.BORDER + ';') +l(' border-radius: 2px;') +l(' color: ' + window.TEXT_COLOR + ';') +l(' display: inline-block;') +l(' margin: 0px;') +l(' outline: none;') +l(' opacity: 1.0;') +l(' padding: 1px 6px;') +l(' -moz-appearance: none;') +l(' -webkit-appearance: none;') +l('}') +// Active (while held down) button style +l('button:active {background-color: ' + window.ACTIVE_COLOR + ';}') +// Disabled button style +l('button:disabled {opacity: 0.5;}') +// Selected button style (see https://stackoverflow.com/a/63108630) +l('button:focus {outline: none;}') +l = null + +var style = document.createElement('style') +style.type = 'text/css' +style.title = 'animations' +style.appendChild(document.createTextNode(animations)) +document.head.appendChild(style) + +// Custom logging to allow leveling +var consoleError = console.error +var consoleWarn = console.warn +var consoleInfo = console.log +var consoleLog = console.log +var consoleDebug = console.log +var consoleSpam = console.log +var consoleGroup = console.group +var consoleGroupEnd = console.groupEnd + +window.setLogLevel = function(level) { + console.error = function() {} + console.warn = function() {} + console.info = function() {} + console.log = function() {} + console.debug = function() {} + console.spam = function() {} + console.group = function() {} + console.groupEnd = function() {} + + if (level === 'none') return + + // Instead of throw, but still red flags and is easy to find + console.error = consoleError + if (level === 'error') return + + // Less serious than error, but flagged nonetheless + console.warn = consoleWarn + if (level === 'warn') return + + // Default visible, important information + console.info = consoleInfo + if (level === 'info') return + + // Useful for debugging (mainly validation) + console.log = consoleLog + if (level === 'log') return + + // Useful for serious debugging (mainly graphics/misc) + console.debug = consoleDebug + if (level === 'debug') return + + // Useful for insane debugging (mainly tracing/recursion) + console.spam = consoleSpam + console.group = consoleGroup + console.groupEnd = consoleGroupEnd + if (level === 'spam') return +} +setLogLevel('info') + +window.deleteElementsByClassName = function(rootElem, className) { + var elems = [] + while (true) { + elems = rootElem.getElementsByClassName(className) + if (elems.length === 0) break + elems[0].remove() + } +} + +// Automatically solve the puzzle +window.solvePuzzle = function() { + if (window.setSolveMode) window.setSolveMode(false) + document.getElementById('solutionViewer').style.display = 'none' + document.getElementById('progressBox').style.display = null + document.getElementById('solveAuto').innerText = 'Cancel Solving' + document.getElementById('solveAuto').onpointerdown = function() { + this.innerText = 'Cancelling...' + this.onpointerdown = null + window.setTimeout(window.cancelSolving, 0) + } + + window.solve(window.puzzle, function(percent) { + document.getElementById('progressPercent').innerText = percent + '%' + document.getElementById('progress').style.width = percent + '%' + }, function(paths) { + document.getElementById('progressBox').style.display = 'none' + document.getElementById('solutionViewer').style.display = null + document.getElementById('progressPercent').innerText = '0%' + document.getElementById('progress').style.width = '0%' + document.getElementById('solveAuto').innerText = 'Solve (automatically)' + document.getElementById('solveAuto').onpointerdown = solvePuzzle + + window.puzzle.autoSolved = true + paths = window.onSolvedPuzzle(paths) + window.showSolution(window.puzzle, paths, 0) + }) +} + +window.showSolution = function(puzzle, paths, num, suffix) { + if (suffix == null) { + var previousSolution = document.getElementById('previousSolution') + var solutionCount = document.getElementById('solutionCount') + var nextSolution = document.getElementById('nextSolution') + } else if (suffix instanceof Array) { + var previousSolution = document.getElementById('previousSolution-' + suffix[0]) + var solutionCount = document.getElementById('solutionCount-' + suffix[0]) + var nextSolution = document.getElementById('nextSolution-' + suffix[0]) + } else { + var previousSolution = document.getElementById('previousSolution-' + suffix) + var solutionCount = document.getElementById('solutionCount-' + suffix) + var nextSolution = document.getElementById('nextSolution-' + suffix) + } + + if (paths.length === 0) { // 0 paths, arrows are useless + solutionCount.innerText = '0 of 0' + previousSolution.disable() + nextSolution.disable() + return + } + + while (num < 0) num = paths.length + num + while (num >= paths.length) num = num - paths.length + + if (paths.length === 1) { // 1 path, arrows are useless + solutionCount.innerText = '1 of 1' + if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' + previousSolution.disable() + nextSolution.disable() + } else { + solutionCount.innerText = (num + 1) + ' of ' + paths.length + if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' + previousSolution.enable() + nextSolution.enable() + previousSolution.onpointerdown = function(event) { + if (event.shiftKey) { + window.showSolution(puzzle, paths, num - 10, suffix) + } else { + window.showSolution(puzzle, paths, num - 1, suffix) + } + } + nextSolution.onpointerdown = function(event) { + if (event.shiftKey) { + window.showSolution(puzzle, paths, num + 10, suffix) + } else { + window.showSolution(puzzle, paths, num + 1, suffix) + } + } + } + + if (paths[num] != null) { + if (puzzle instanceof Array) { // Special case for multiple related panels + for (var i = 0; i < puzzle.length; i++) { + // Save the current path on the puzzle object (so that we can pass it along with publishing) + puzzle.path = paths[num][i] + // Draws the given path, and also updates the puzzle to have path annotations on it. + window.drawPath(puzzle[i], paths[num][i], suffix[i]) + } + } else { // Default case for a single panel + // Save the current path on the puzzle object (so that we can pass it along with publishing) + puzzle.path = paths[num] + // Draws the given path, and also updates the puzzle to have path annotations on it. + window.drawPath(puzzle, paths[num], suffix) + } + } +} + +window.createCheckbox = function() { + var checkbox = document.createElement('div') + checkbox.style.width = '22px' + checkbox.style.height = '22px' + checkbox.style.borderRadius = '6px' + checkbox.style.display = 'inline-block' + checkbox.style.verticalAlign = 'text-bottom' + checkbox.style.marginRight = '6px' + checkbox.style.borderWidth = '1.5px' + checkbox.style.borderStyle = 'solid' + checkbox.style.borderColor = window.BORDER + checkbox.style.background = window.PAGE_BACKGROUND + checkbox.style.color = window.TEXT_COLOR + return checkbox +} + +// Required global variables/functions: <-- HINT: This means you're writing bad code. +// window.puzzle +// window.onSolvedPuzzle() +// window.MAX_SOLUTIONS // defined by solve.js +window.addSolveButtons = function() { + var parent = document.currentScript.parentElement + + var solveMode = createCheckbox() + solveMode.id = 'solveMode' + parent.appendChild(solveMode) + + solveMode.onpointerdown = function() { + this.checked = !this.checked + this.style.background = (this.checked ? window.BORDER : window.PAGE_BACKGROUND) + document.getElementById('solutionViewer').style.display = 'none' + if (window.setSolveMode) window.setSolveMode(this.checked) + } + + var solveManual = document.createElement('label') + parent.appendChild(solveManual) + solveManual.id = 'solveManual' + solveManual.onpointerdown = function() {solveMode.onpointerdown()} + solveManual.innerText = 'Solve (manually)' + solveManual.style = 'margin-right: 8px' + + var solveAuto = document.createElement('button') + parent.appendChild(solveAuto) + solveAuto.id = 'solveAuto' + solveAuto.innerText = 'Solve (automatically)' + solveAuto.onpointerdown = solvePuzzle + solveAuto.style = 'margin-right: 8px' + + var div = document.createElement('div') + parent.appendChild(div) + div.style = 'display: inline-block; vertical-align:top' + + var progressBox = document.createElement('div') + div.appendChild(progressBox) + progressBox.id = 'progressBox' + progressBox.style = 'display: none; width: 220px; border: 1px solid black; margin-top: 2px' + + var progressPercent = document.createElement('label') + progressBox.appendChild(progressPercent) + progressPercent.id = 'progressPercent' + progressPercent.style = 'float: left; margin-left: 4px' + progressPercent.innerText = '0%' + + var progress = document.createElement('div') + progressBox.appendChild(progress) + progress.id = 'progress' + progress.style = 'z-index: -1; height: 38px; width: 0%; background-color: #390' + + var solutionViewer = document.createElement('div') + div.appendChild(solutionViewer) + solutionViewer.id = 'solutionViewer' + solutionViewer.style = 'display: none' + + var previousSolution = document.createElement('button') + solutionViewer.appendChild(previousSolution) + previousSolution.id = 'previousSolution' + previousSolution.innerHTML = '←' + + var solutionCount = document.createElement('label') + solutionViewer.appendChild(solutionCount) + solutionCount.id = 'solutionCount' + solutionCount.style = 'padding: 6px' + + var nextSolution = document.createElement('button') + solutionViewer.appendChild(nextSolution) + nextSolution.id = 'nextSolution' + nextSolution.innerHTML = '→' +} + +var SECONDS_PER_LOOP = 1 +window.httpGetLoop = function(url, maxTimeout, action, onError, onSuccess) { + if (maxTimeout <= 0) { + onError() + return + } + + sendHttpRequest('GET', url, SECONDS_PER_LOOP, null, function(httpCode, response) { + if (httpCode >= 200 && httpCode <= 299) { + var output = action(JSON.parse(response)) + if (output) { + onSuccess(output) + return + } // Retry if action returns null + } // Retry on non-success HTTP codes + + window.setTimeout(function() { + httpGetLoop(url, maxTimeout - SECONDS_PER_LOOP, action, onError, onSuccess) + }, 1000) + }) +} + +window.fireAndForget = function(verb, url, body) { + sendHttpRequest(verb, url, 600, body, function() {}) +} + +// Only used for errors +var HTTP_STATUS = { + 401: '401 unauthorized', 403: '403 forbidden', 404: '404 not found', 409: '409 conflict', 413: '413 payload too large', + 500: '500 internal server error', +} + +var etagCache = {} +function sendHttpRequest(verb, url, timeoutSeconds, data, onResponse) { + currentHttpRequest = new XMLHttpRequest() + currentHttpRequest.onreadystatechange = function() { + if (this.readyState != XMLHttpRequest.DONE) return + etagCache[url] = this.getResponseHeader('ETag') + currentHttpRequest = null + onResponse(this.status, this.responseText || HTTP_STATUS[this.status]) + } + currentHttpRequest.ontimeout = function() { + currentHttpRequest = null + onResponse(0, 'Request timed out') + } + currentHttpRequest.timeout = timeoutSeconds * 1000 + currentHttpRequest.open(verb, url, true) + currentHttpRequest.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') + + var etag = etagCache[url] + if (etag != null) currentHttpRequest.setRequestHeader('If-None-Match', etag) + + currentHttpRequest.send(data) +} + +function sendFeedback(feedback) { + console.error('Please disregard the following CORS exception. It is expected and the request will succeed regardless.') +} + +}) -- cgit 1.4.1