Spaces:
Running
Running
| /* | |
| * Copyright (c) 2013-2025 Vanessa Freudenberg | |
| * | |
| * 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. | |
| */ | |
| ; | |
| ////////////////////////////////////////////////////////////////////////////// | |
| // load vm, plugins, and other libraries | |
| ////////////////////////////////////////////////////////////////////////////// | |
| import "./globals.js"; | |
| import "./vm.js"; | |
| import "./vm.object.js"; | |
| import "./vm.object.spur.js"; | |
| import "./vm.image.js"; | |
| import "./vm.interpreter.js"; | |
| import "./vm.interpreter.proxy.js"; | |
| import "./vm.instruction.stream.js"; | |
| import "./vm.instruction.stream.sista.js"; | |
| import "./vm.instruction.printer.js"; | |
| import "./vm.primitives.js"; | |
| import "./jit.js"; | |
| import "./vm.audio.browser.js"; | |
| import "./vm.display.js"; | |
| import "./vm.display.browser.js"; | |
| import "./vm.files.browser.js"; | |
| import "./vm.input.js"; | |
| import "./vm.input.browser.js"; | |
| import "./vm.plugins.js"; | |
| import "./vm.plugins.ffi.js"; | |
| import "./vm.plugins.javascript.js"; | |
| import "./vm.plugins.obsolete.js"; | |
| import "./vm.plugins.drop.browser.js"; | |
| import "./vm.plugins.file.browser.js"; | |
| import "./vm.plugins.jpeg2.browser.js"; | |
| import "./vm.plugins.scratch.browser.js"; | |
| import "./vm.plugins.sound.browser.js"; | |
| import "./plugins/ADPCMCodecPlugin.js"; | |
| import "./plugins/B2DPlugin.js"; | |
| import "./plugins/B3DAcceleratorPlugin.js"; | |
| import "./plugins/BitBltPlugin.js"; | |
| import "./plugins/CroquetPlugin.js"; | |
| import "./plugins/FFTPlugin.js"; | |
| import "./plugins/FloatArrayPlugin.js"; | |
| import "./plugins/GeniePlugin.js"; | |
| import "./plugins/JPEGReaderPlugin.js"; | |
| import "./plugins/KedamaPlugin.js"; | |
| import "./plugins/KedamaPlugin2.js"; | |
| import "./plugins/Klatt.js"; | |
| import "./plugins/LargeIntegers.js"; | |
| import "./plugins/Matrix2x3Plugin.js"; | |
| import "./plugins/MiscPrimitivePlugin.js"; | |
| import "./plugins/MIDIPlugin.js"; | |
| import "./plugins/ScratchPlugin.js"; | |
| import "./plugins/SocketPlugin.js"; | |
| import "./plugins/SpeechPlugin.js"; | |
| import "./plugins/SqueakSSL.js"; | |
| import "./plugins/SoundGenerationPlugin.js"; | |
| import "./plugins/StarSqueakPlugin.js"; | |
| import "./plugins/ZipPlugin.js"; | |
| import "./ffi/libc.js"; | |
| import "./ffi/opengl.js"; | |
| import "./lib/lz-string.js"; | |
| import "./lib/jszip.js"; | |
| import "./lib/FileSaver.js"; | |
| import "./lib/sha1.js"; | |
| Object.extend(Squeak, { | |
| vmPath: "/", | |
| platformSubtype: "Browser", | |
| osVersion: navigator.userAgent, // might want to parse | |
| windowSystem: "HTML", | |
| }); | |
| // UI namespace | |
| window.SqueakJS = {}; | |
| ////////////////////////////////////////////////////////////////////////////// | |
| // display & event setup | |
| ////////////////////////////////////////////////////////////////////////////// | |
| function setupFullscreen(display, canvas, options) { | |
| // Fullscreen can only be enabled in an event handler. So we check the | |
| // fullscreen flag on every mouse down/up and keyboard event. | |
| var box = canvas.parentElement, | |
| fullscreenEvent = "fullscreenchange", | |
| fullscreenElement = "fullscreenElement", | |
| fullscreenEnabled = "fullscreenEnabled"; | |
| if (!box.requestFullscreen) { | |
| [ // Fullscreen support is still very browser-dependent | |
| {req: box.webkitRequestFullscreen, exit: document.webkitExitFullscreen, | |
| evt: "webkitfullscreenchange", elem: "webkitFullscreenElement", enable: "webkitFullscreenEnabled"}, | |
| {req: box.mozRequestFullScreen, exit: document.mozCancelFullScreen, | |
| evt: "mozfullscreenchange", elem: "mozFullScreenElement", enable: "mozFullScreenEnabled"}, | |
| {req: box.msRequestFullscreen, exit: document.msExitFullscreen, | |
| evt: "MSFullscreenChange", elem: "msFullscreenElement", enable: "msFullscreenEnabled"}, | |
| ].forEach(function(browser) { | |
| if (browser.req) { | |
| box.requestFullscreen = browser.req; | |
| document.exitFullscreen = browser.exit; | |
| fullscreenEvent = browser.evt; | |
| fullscreenElement = browser.elem; | |
| fullscreenEnabled = browser.enable; | |
| } | |
| }); | |
| } | |
| // If the user canceled fullscreen, turn off the fullscreen flag so | |
| // we don't try to enable it again in the next event | |
| function fullscreenChange(fullscreen) { | |
| display.fullscreen = fullscreen; | |
| var fullwindow = fullscreen || options.fullscreen; | |
| box.style.background = fullwindow ? 'black' : ''; | |
| box.style.border = fullwindow ? 'none' : ''; | |
| box.style.borderRadius = fullwindow ? '0px' : ''; | |
| setTimeout(onresize, 0); | |
| } | |
| var checkFullscreen; | |
| if (box.requestFullscreen) { | |
| document.addEventListener(fullscreenEvent, function(){fullscreenChange(box == document[fullscreenElement]);}); | |
| checkFullscreen = function() { | |
| if (document[fullscreenEnabled] && (box == document[fullscreenElement]) != display.fullscreen) { | |
| if (display.fullscreen) box.requestFullscreen(); | |
| else document.exitFullscreen(); | |
| } | |
| }; | |
| } else { | |
| var isFullscreen = false; | |
| checkFullscreen = function() { | |
| if (isFullscreen != display.fullscreen) { | |
| isFullscreen = display.fullscreen; | |
| fullscreenChange(isFullscreen); | |
| } | |
| }; | |
| } | |
| return checkFullscreen; | |
| } | |
| function recordModifiers(evt, display) { | |
| var shiftPressed = evt.shiftKey, | |
| ctrlPressed = evt.ctrlKey && !evt.altKey, | |
| cmdPressed = (display.isMac ? evt.metaKey : evt.altKey && !evt.ctrlKey) | |
| || display.cmdButtonTouched, | |
| modifiers = | |
| (shiftPressed ? Squeak.Keyboard_Shift : 0) + | |
| (ctrlPressed ? Squeak.Keyboard_Ctrl : 0) + | |
| (cmdPressed ? Squeak.Keyboard_Cmd : 0); | |
| display.buttons = (display.buttons & ~Squeak.Keyboard_All) | modifiers; | |
| return modifiers; | |
| } | |
| var canUseMouseOffset = null; | |
| function updateMousePos(evt, canvas, display) { | |
| if (canUseMouseOffset === null) { | |
| // Per https://caniuse.com/mdn-api_mouseevent_offsetx, essentially all *current* | |
| // browsers support `offsetX`/`offsetY`, but it does little harm to fall back to the | |
| // older `layerX`/`layerY` for now. | |
| canUseMouseOffset = 'offsetX' in evt; | |
| } | |
| var evtX = canUseMouseOffset ? evt.offsetX : evt.layerX, | |
| evtY = canUseMouseOffset ? evt.offsetY : evt.layerY; | |
| if (display.cursorCanvas) { | |
| display.cursorCanvas.style.left = (evtX + canvas.offsetLeft + display.cursorOffsetX) + "px"; | |
| display.cursorCanvas.style.top = (evtY + canvas.offsetTop + display.cursorOffsetY) + "px"; | |
| } | |
| var x = (evtX * canvas.width / canvas.offsetWidth) | 0, | |
| y = (evtY * canvas.height / canvas.offsetHeight) | 0, | |
| w = display.width || canvas.width, | |
| h = display.height || canvas.height; | |
| // clamp to display size | |
| display.mouseX = Math.max(0, Math.min(w, x)); | |
| display.mouseY = Math.max(0, Math.min(h, y)); | |
| } | |
| function recordMouseEvent(what, evt, canvas, display, options) { | |
| updateMousePos(evt, canvas, display); | |
| if (!display.vm) return; | |
| var buttons = display.buttons & Squeak.Mouse_All; | |
| switch (what) { | |
| case 'mousedown': | |
| switch (evt.button || 0) { | |
| case 0: buttons = Squeak.Mouse_Red; break; // left | |
| case 1: buttons = Squeak.Mouse_Yellow; break; // middle | |
| case 2: buttons = Squeak.Mouse_Blue; break; // right | |
| } | |
| if (buttons === Squeak.Mouse_Red && (evt.altKey || evt.metaKey) || display.cmdButtonTouched) | |
| buttons = Squeak.Mouse_Yellow; // emulate middle-click | |
| if (options.swapButtons) | |
| if (buttons == Squeak.Mouse_Yellow) buttons = Squeak.Mouse_Blue; | |
| else if (buttons == Squeak.Mouse_Blue) buttons = Squeak.Mouse_Yellow; | |
| break; | |
| case 'mousemove': | |
| break; // nothing more to do | |
| case 'mouseup': | |
| buttons = 0; | |
| break; | |
| } | |
| display.buttons = buttons | recordModifiers(evt, display); | |
| if (display.eventQueue) { | |
| display.eventQueue.push([ | |
| Squeak.EventTypeMouse, | |
| evt.timeStamp, // converted to Squeak time in makeSqueakEvent() | |
| display.mouseX, | |
| display.mouseY, | |
| display.buttons & Squeak.Mouse_All, | |
| display.buttons >> 3, | |
| ]); | |
| if (display.signalInputEvent) | |
| display.signalInputEvent(); | |
| } | |
| display.idle = 0; | |
| if (what === 'mouseup') display.runFor(100, what); // process copy/paste or fullscreen flag change | |
| else display.runNow(what); // don't wait for timeout to run | |
| } | |
| function recordWheelEvent(evt, display) { | |
| if (!display.vm) return; | |
| if (!display.eventQueue || !display.vm.image.isSpur) { | |
| // for old images, queue wheel events as ctrl+up/down | |
| fakeCmdOrCtrlKey(evt.deltaY > 0 ? 31 : 30, evt.timeStamp, display); | |
| return; | |
| // TODO: use or set VM parameter 48 (see vmParameterAt) | |
| } | |
| var squeakEvt = [ | |
| Squeak.EventTypeMouseWheel, | |
| evt.timeStamp, // converted to Squeak time in makeSqueakEvent() | |
| evt.deltaX, | |
| -evt.deltaY, | |
| display.buttons & Squeak.Mouse_All, | |
| display.buttons >> 3, | |
| ]; | |
| display.eventQueue.push(squeakEvt); | |
| if (display.signalInputEvent) | |
| display.signalInputEvent(); | |
| display.idle = 0; | |
| if (display.runNow) display.runNow('wheel'); // don't wait for timeout to run | |
| } | |
| // Squeak traditional keycodes are MacRoman | |
| var MacRomanToUnicode = [ | |
| 0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, | |
| 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, | |
| 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, | |
| 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, | |
| 0x2020, 0x00B0, 0x00A2, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF, | |
| 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, | |
| 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, | |
| 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, | |
| 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, | |
| 0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, | |
| 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, | |
| 0x00FF, 0x0178, 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, | |
| 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, | |
| 0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, 0x00CC, 0x00D3, 0x00D4, | |
| 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, | |
| 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7, | |
| ]; | |
| var UnicodeToMacRoman = {}; | |
| for (var i = 0; i < MacRomanToUnicode.length; i++) | |
| UnicodeToMacRoman[MacRomanToUnicode[i]] = i + 128; | |
| function recordKeyboardEvent(unicode, timestamp, display) { | |
| if (!display.vm) return; | |
| var macCode = UnicodeToMacRoman[unicode] || (unicode < 128 ? unicode : 0); | |
| var modifiersAndKey = (display.buttons >> 3) << 8 | macCode; | |
| if (display.eventQueue) { | |
| display.eventQueue.push([ | |
| Squeak.EventTypeKeyboard, | |
| timestamp, // converted to Squeak time in makeSqueakEvent() | |
| macCode, // MacRoman | |
| Squeak.EventKeyChar, | |
| display.buttons >> 3, | |
| unicode, // Unicode | |
| ]); | |
| if (display.signalInputEvent) | |
| display.signalInputEvent(); | |
| // There are some old images that use both event-based | |
| // and polling primitives. To make those work, keep the | |
| // last key event | |
| display.keys[0] = modifiersAndKey; | |
| } else if (modifiersAndKey === display.vm.interruptKeycode) { | |
| display.vm.interruptPending = true; | |
| } else { | |
| // no event queue, queue keys the old-fashioned way | |
| display.keys.push(modifiersAndKey); | |
| } | |
| display.idle = 0; | |
| if (display.runNow) display.runNow('keyboard'); // don't wait for timeout to run | |
| } | |
| function recordDragDropEvent(type, evt, canvas, display) { | |
| if (!display.vm || !display.eventQueue) return; | |
| updateMousePos(evt, canvas, display); | |
| display.eventQueue.push([ | |
| Squeak.EventTypeDragDropFiles, | |
| evt.timeStamp, // converted to Squeak time in makeSqueakEvent() | |
| type, | |
| display.mouseX, | |
| display.mouseY, | |
| display.buttons >> 3, | |
| display.droppedFiles.length, | |
| ]); | |
| if (display.signalInputEvent) | |
| display.signalInputEvent(); | |
| display.idle = 0; | |
| if (display.runNow) display.runNow('drag-drop'); // don't wait for timeout to run | |
| } | |
| function fakeCmdOrCtrlKey(key, timestamp, display) { | |
| // set both Cmd and Ctrl bit, because we don't know what the image wants | |
| display.buttons &= ~Squeak.Keyboard_All; // remove all modifiers | |
| display.buttons |= Squeak.Keyboard_Cmd | Squeak.Keyboard_Ctrl; | |
| display.keys = []; // flush other keys | |
| recordKeyboardEvent(key, timestamp, display); | |
| } | |
| function makeSqueakEvent(evt, sqEvtBuf, sqTimeOffset) { | |
| sqEvtBuf[0] = evt[0]; | |
| sqEvtBuf[1] = (evt[1] - sqTimeOffset) & Squeak.MillisecondClockMask; | |
| for (var i = 2; i < evt.length; i++) | |
| sqEvtBuf[i] = evt[i]; | |
| } | |
| function createSqueakDisplay(canvas, options) { | |
| options = options || {}; | |
| if (options.fullscreen) { | |
| document.body.style.margin = 0; | |
| document.body.style.backgroundColor = 'black'; | |
| canvas.style.border = 'none'; | |
| canvas.style.borderRadius = '0px'; | |
| document.ontouchmove = function(evt) { evt.preventDefault(); }; | |
| } | |
| var display = { | |
| context: canvas.getContext("2d"), | |
| fullscreen: false, | |
| width: 0, // if 0, VM uses canvas.width | |
| height: 0, // if 0, VM uses canvas.height | |
| scale: 1, // VM will use window.devicePixelRatio if highdpi is enabled, also changes when touch-zooming | |
| highdpi: options.highdpi, // TODO: use or set VM parameter 48 (see vmParameterAt) | |
| mouseX: 0, | |
| mouseY: 0, | |
| buttons: 0, | |
| keys: [], | |
| cmdButtonTouched: null, // touchscreen button pressed (touch ID) | |
| eventQueue: null, // only used if image uses event primitives | |
| clipboardString: '', | |
| clipboardStringChanged: false, | |
| handlingEvent: '', // set to 'mouse' or 'keyboard' while handling an event | |
| cursorCanvas: options.cursor !== false && document.getElementById("sqCursor") || document.createElement("canvas"), | |
| cursorOffsetX: 0, | |
| cursorOffsetY: 0, | |
| droppedFiles: [], | |
| signalInputEvent: null, // function set by VM | |
| changedCallback: null, // invoked when display size/scale changes | |
| // additional functions added below | |
| }; | |
| if (options.pixelated) { | |
| canvas.classList.add("pixelated"); | |
| display.cursorCanvas && display.cursorCanvas.classList.add("pixelated"); | |
| } | |
| display.reset = function() { | |
| display.eventQueue = null; | |
| display.signalInputEvent = null; | |
| display.lastTick = 0; | |
| display.getNextEvent = function(firstEvtBuf, firstOffset) { | |
| // might be called from VM to get queued event | |
| display.eventQueue = []; // create queue on first call | |
| display.eventQueue.push = function(evt) { | |
| display.eventQueue.offset = Date.now() - evt[1]; // get epoch from first event | |
| delete display.eventQueue.push; // use original push from now on | |
| display.eventQueue.push(evt); | |
| }; | |
| display.getNextEvent = function(evtBuf, timeOffset) { | |
| var evt = display.eventQueue.shift(); | |
| if (evt) makeSqueakEvent(evt, evtBuf, timeOffset - display.eventQueue.offset); | |
| else evtBuf[0] = Squeak.EventTypeNone; | |
| }; | |
| display.getNextEvent(firstEvtBuf, firstOffset); | |
| }; | |
| }; | |
| display.reset(); | |
| var checkFullscreen = setupFullscreen(display, canvas, options); | |
| display.fullscreenRequest = function(fullscreen, thenDo) { | |
| // called from primitive to change fullscreen mode | |
| if (display.fullscreen != fullscreen) { | |
| display.fullscreen = fullscreen; | |
| display.resizeTodo = thenDo; // called after resizing | |
| display.resizeTodoTimeout = setTimeout(display.resizeDone, 1000); | |
| checkFullscreen(); | |
| } else thenDo(); | |
| }; | |
| display.resizeDone = function() { | |
| clearTimeout(display.resizeTodoTimeout); | |
| var todo = display.resizeTodo; | |
| if (todo) { | |
| display.resizeTodo = null; | |
| todo(); | |
| } | |
| }; | |
| display.clear = function() { | |
| canvas.width = canvas.width; | |
| }; | |
| display.setTitle = function(title) { | |
| document.title = title; | |
| }; | |
| display.showBanner = function(msg, style) { | |
| style = style || display.context.canvas.style || {}; | |
| var ctx = display.context; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = style.color || "#F90"; | |
| ctx.font = style.font || "bold 48px sans-serif"; | |
| if (!style.font && ctx.measureText(msg).width > canvas.width) | |
| ctx.font = "bold 24px sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "middle"; | |
| ctx.fillText(msg, canvas.width / 2, canvas.height / 2); | |
| }; | |
| display.showProgress = function(value, style) { | |
| style = style || display.context.canvas.style || {}; | |
| var ctx = display.context, | |
| w = (canvas.width / 3) | 0, | |
| h = 24, | |
| x = canvas.width * 0.5 - w / 2, | |
| y = canvas.height * 0.5 + 2 * h; | |
| ctx.fillStyle = style.background || "#000"; | |
| ctx.fillRect(x, y, w, h); | |
| ctx.lineWidth = 2; | |
| ctx.strokeStyle = style.stroke || "#F90"; | |
| ctx.strokeRect(x, y, w, h); | |
| ctx.fillStyle = style.fill || "#F90"; | |
| ctx.fillRect(x, y, w * value, h); | |
| }; | |
| display.executeClipboardPasteKey = function(text, timestamp) { | |
| if (!display.vm) return true; | |
| try { | |
| display.clipboardString = text; | |
| // simulate paste event for Squeak | |
| fakeCmdOrCtrlKey('v'.charCodeAt(0), timestamp, display); | |
| } catch(err) { | |
| console.error("paste error " + err); | |
| } | |
| }; | |
| display.executeClipboardCopyKey = function(key, timestamp) { | |
| if (!display.vm) return true; | |
| // simulate copy event for Squeak so it places its text in clipboard | |
| display.clipboardStringChanged = false; | |
| fakeCmdOrCtrlKey((key || 'c').charCodeAt(0), timestamp, display); | |
| var start = Date.now(); | |
| // now interpret until Squeak has copied to the clipboard | |
| while (!display.clipboardStringChanged && Date.now() - start < 500) | |
| display.vm.interpret(20); | |
| if (!display.clipboardStringChanged) return; | |
| // got it, now copy to the system clipboard | |
| try { | |
| return display.clipboardString; | |
| } catch(err) { | |
| console.error("copy error " + err); | |
| } | |
| }; | |
| canvas.onmousedown = function(evt) { | |
| checkFullscreen(); | |
| recordMouseEvent('mousedown', evt, canvas, display, options); | |
| evt.preventDefault(); | |
| return false; | |
| }; | |
| canvas.onmouseup = function(evt) { | |
| recordMouseEvent('mouseup', evt, canvas, display, options); | |
| checkFullscreen(); | |
| evt.preventDefault(); | |
| }; | |
| canvas.onmousemove = function(evt) { | |
| recordMouseEvent('mousemove', evt, canvas, display, options); | |
| evt.preventDefault(); | |
| }; | |
| canvas.onwheel = function(evt) { | |
| recordWheelEvent(evt, display); | |
| evt.preventDefault(); | |
| }; | |
| canvas.oncontextmenu = function() { | |
| return false; | |
| }; | |
| // touch event handling | |
| var touch = { | |
| state: 'idle', | |
| button: 0, | |
| x: 0, | |
| y: 0, | |
| dist: 0, | |
| down: {}, | |
| }; | |
| function touchToMouse(evt) { | |
| if (evt.touches.length) { | |
| // average all touch positions | |
| // but ignore the cmd button touch | |
| var x = 0, y = 0, n = 0; | |
| for (var i = 0; i < evt.touches.length; i++) { | |
| if (evt.touches[i].identifier === display.cmdButtonTouched) continue; | |
| x += evt.touches[i].pageX; | |
| y += evt.touches[i].pageY; | |
| n++; | |
| } | |
| if (n > 0) { | |
| touch.x = x / n; | |
| touch.y = y / n; | |
| } | |
| } | |
| return { | |
| timeStamp: evt.timeStamp, | |
| button: touch.button, | |
| offsetX: touch.x - canvas.offsetLeft, | |
| offsetY: touch.y - canvas.offsetTop, | |
| }; | |
| } | |
| function dd(ax, ay, bx, by) {var x = ax - bx, y = ay - by; return Math.sqrt(x*x + y*y);} | |
| function dist(a, b) {return dd(a.pageX, a.pageY, b.pageX, b.pageY);} | |
| function dent(n, l, t, u) { return n < l ? n + t - l : n > u ? n + t - u : t; } | |
| function adjustCanvas(l, t, w, h) { | |
| var cursorCanvas = display.cursorCanvas, | |
| cssScale = w / canvas.width, | |
| ratio = display.highdpi ? window.devicePixelRatio : 1, | |
| pixelScale = cssScale * ratio; | |
| canvas.style.left = (l|0) + "px"; | |
| canvas.style.top = (t|0) + "px"; | |
| canvas.style.width = (w|0) + "px"; | |
| canvas.style.height = (h|0) + "px"; | |
| if (cursorCanvas) { | |
| cursorCanvas.style.left = (l + display.cursorOffsetX + display.mouseX * cssScale|0) + "px"; | |
| cursorCanvas.style.top = (t + display.cursorOffsetY + display.mouseY * cssScale|0) + "px"; | |
| cursorCanvas.style.width = (cursorCanvas.width * pixelScale|0) + "px"; | |
| cursorCanvas.style.height = (cursorCanvas.height * pixelScale|0) + "px"; | |
| } | |
| // if pixelation is not forced, turn it on for integer scales | |
| if (!options.pixelated) { | |
| if (pixelScale % 1 === 0 || pixelScale > 5) { | |
| canvas.classList.add("pixelated"); | |
| cursorCanvas && cursorCanvas.classList.add("pixelated"); | |
| } else { | |
| canvas.classList.remove("pixelated"); | |
| cursorCanvas && display.cursorCanvas.classList.remove("pixelated"); | |
| } | |
| } | |
| display.css = { | |
| left: l, | |
| top: t, | |
| width: w, | |
| height: h, | |
| scale: cssScale, | |
| pixelScale: pixelScale, | |
| ratio: ratio, | |
| }; | |
| if (display.changedCallback) display.changedCallback(); | |
| return cssScale; | |
| } | |
| // zooming/panning with two fingers | |
| var maxZoom = 5; | |
| function zoomStart(evt) { | |
| touch.dist = dist(evt.touches[0], evt.touches[1]); | |
| touch.down.x = touch.x; | |
| touch.down.y = touch.y; | |
| touch.down.dist = touch.dist; | |
| touch.down.left = canvas.offsetLeft; | |
| touch.down.top = canvas.offsetTop; | |
| touch.down.width = canvas.offsetWidth; | |
| touch.down.height = canvas.offsetHeight; | |
| // store original canvas bounds | |
| if (!touch.orig) touch.orig = { | |
| left: touch.down.left, | |
| top: touch.down.top, | |
| right: touch.down.left + touch.down.width, | |
| bottom: touch.down.top + touch.down.height, | |
| width: touch.down.width, | |
| height: touch.down.height, | |
| }; | |
| } | |
| function zoomMove(evt) { | |
| if (evt.touches.length < 2) return; | |
| touch.dist = dist(evt.touches[0], evt.touches[1]); | |
| var minScale = touch.orig.width / touch.down.width, | |
| //nowScale = dent(touch.dist / touch.down.dist, 0.8, 1, 1.5), | |
| nowScale = touch.dist / touch.down.dist, | |
| scale = Math.min(Math.max(nowScale, minScale * 0.95), minScale * maxZoom), | |
| w = touch.down.width * scale, | |
| h = touch.orig.height * w / touch.orig.width, | |
| l = touch.down.left - (touch.down.x - touch.down.left) * (scale - 1) + (touch.x - touch.down.x), | |
| t = touch.down.top - (touch.down.y - touch.down.top) * (scale - 1) + (touch.y - touch.down.y); | |
| // allow to rubber-band by 20px for feedback | |
| l = Math.max(Math.min(l, touch.orig.left + 20), touch.orig.right - w - 20); | |
| t = Math.max(Math.min(t, touch.orig.top + 20), touch.orig.bottom - h - 20); | |
| adjustCanvas(l, t, w, h); | |
| } | |
| function zoomEnd(evt) { | |
| var l = canvas.offsetLeft, | |
| t = canvas.offsetTop, | |
| w = canvas.offsetWidth, | |
| h = canvas.offsetHeight; | |
| w = Math.min(Math.max(w, touch.orig.width), touch.orig.width * maxZoom); | |
| h = touch.orig.height * w / touch.orig.width; | |
| l = Math.max(Math.min(l, touch.orig.left), touch.orig.right - w); | |
| t = Math.max(Math.min(t, touch.orig.top), touch.orig.bottom - h); | |
| var scale = adjustCanvas(l, t, w, h); | |
| if ((scale - display.scale) < 0.0001) { | |
| touch.orig = null; | |
| onresize(); | |
| } | |
| } | |
| // State machine to distinguish between 1st/2nd mouse button and zoom/pan: | |
| // * if moved, or no 2nd finger within 100ms of 1st down, start mousing | |
| // * if fingers moved significantly within 200ms of 2nd down, start zooming | |
| // * if touch ended within this time, generate click (down+up) | |
| // * otherwise, start mousing with 2nd button | |
| // * also, ignore finger on cmd button | |
| // When mousing, always generate a move event before down event so that | |
| // mouseover eventhandlers in image work better | |
| canvas.ontouchstart = function(evt) { | |
| evt.preventDefault(); | |
| var e = touchToMouse(evt); | |
| for (var i = 0; i < evt.changedTouches.length; i++) { | |
| if (evt.changedTouches[i].identifier === display.cmdButtonTouched) continue; | |
| switch (touch.state) { | |
| case 'idle': | |
| touch.state = 'got1stFinger'; | |
| touch.first = e; | |
| setTimeout(function(){ | |
| if (touch.state !== 'got1stFinger') return; | |
| touch.state = 'mousing'; | |
| touch.button = e.button = 0; | |
| recordMouseEvent('mousemove', e, canvas, display, options); | |
| recordMouseEvent('mousedown', e, canvas, display, options); | |
| }, 100); | |
| break; | |
| case 'got1stFinger': | |
| touch.state = 'got2ndFinger'; | |
| zoomStart(evt); | |
| setTimeout(function(){ | |
| if (touch.state !== 'got2ndFinger') return; | |
| var didMove = Math.abs(touch.down.dist - touch.dist) > 10 || | |
| dd(touch.down.x, touch.down.y, touch.x, touch.y) > 10; | |
| if (didMove) { | |
| touch.state = 'zooming'; | |
| } else { | |
| touch.state = 'mousing'; | |
| touch.button = e.button = 2; | |
| recordMouseEvent('mousemove', e, canvas, display, options); | |
| recordMouseEvent('mousedown', e, canvas, display, options); | |
| } | |
| }, 200); | |
| break; | |
| } | |
| } | |
| }; | |
| canvas.ontouchmove = function(evt) { | |
| evt.preventDefault(); | |
| var e = touchToMouse(evt); | |
| switch (touch.state) { | |
| case 'got1stFinger': | |
| touch.state = 'mousing'; | |
| touch.button = e.button = 0; | |
| recordMouseEvent('mousemove', e, canvas, display, options); | |
| recordMouseEvent('mousedown', e, canvas, display, options); | |
| break; | |
| case 'mousing': | |
| recordMouseEvent('mousemove', e, canvas, display, options); | |
| break; | |
| case 'got2ndFinger': | |
| if (evt.touches.length > 1) | |
| touch.dist = dist(evt.touches[0], evt.touches[1]); | |
| break; | |
| case 'zooming': | |
| zoomMove(evt); | |
| break; | |
| } | |
| }; | |
| canvas.ontouchend = function(evt) { | |
| evt.preventDefault(); | |
| checkFullscreen(); | |
| var e = touchToMouse(evt); | |
| var n = evt.touches.length; | |
| if (Array.from(evt.touches).findIndex(t => t.identifier === display.cmdButtonTouched) >= 0) n--; | |
| for (var i = 0; i < evt.changedTouches.length; i++) { | |
| if (evt.changedTouches[i].identifier === display.cmdButtonTouched) { | |
| continue; | |
| } | |
| switch (touch.state) { | |
| case 'mousing': | |
| if (n > 0) break; | |
| touch.state = 'idle'; | |
| recordMouseEvent('mouseup', e, canvas, display, options); | |
| break; | |
| case 'got1stFinger': | |
| touch.state = 'idle'; | |
| touch.button = e.button = 0; | |
| recordMouseEvent('mousemove', e, canvas, display, options); | |
| recordMouseEvent('mousedown', e, canvas, display, options); | |
| recordMouseEvent('mouseup', e, canvas, display, options); | |
| break; | |
| case 'got2ndFinger': | |
| touch.state = 'mousing'; | |
| touch.button = e.button = 2; | |
| recordMouseEvent('mousemove', e, canvas, display, options); | |
| recordMouseEvent('mousedown', e, canvas, display, options); | |
| break; | |
| case 'zooming': | |
| if (n > 0) break; | |
| touch.state = 'idle'; | |
| zoomEnd(evt); | |
| break; | |
| } | |
| } | |
| }; | |
| canvas.ontouchcancel = function(evt) { | |
| canvas.ontouchend(evt); | |
| }; | |
| // cursorCanvas shows Squeak cursor | |
| if (display.cursorCanvas) { | |
| var absolute = window.getComputedStyle(canvas).position === "absolute"; | |
| display.cursorCanvas.style.display = "block"; | |
| display.cursorCanvas.style.position = absolute ? "absolute": "fixed"; | |
| display.cursorCanvas.style.cursor = "none"; | |
| display.cursorCanvas.style.background = "transparent"; | |
| display.cursorCanvas.style.pointerEvents = "none"; | |
| canvas.parentElement.appendChild(display.cursorCanvas); | |
| canvas.style.cursor = "none"; | |
| } | |
| // keyboard stuff | |
| // create hidden input field to capture not only keyboard events | |
| // but also copy/paste and input events (for dead keys) | |
| var input = document.createElement("input"); | |
| input.setAttribute("autocomplete", "off"); | |
| input.setAttribute("autocorrect", "off"); | |
| input.setAttribute("autocapitalize", "off"); | |
| input.setAttribute("spellcheck", "false"); | |
| input.style.position = "absolute"; | |
| input.style.left = "-1000px"; | |
| canvas.parentElement.appendChild(input); | |
| // touch-keyboard buttons | |
| if ('ontouchstart' in document) { | |
| // button to show on-screen keyboard | |
| var keyboardButton = document.createElement('div'); | |
| keyboardButton.innerHTML = '<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg width="50px" height="50px" viewBox="0 0 150 150" version="1.1" xmlns="http://www.w3.org/2000/svg"><g id="Page-1" stroke="none" fill="#000000"><rect x="33" y="105" width="10" height="10" rx="1"></rect><rect x="26" y="60" width="10" height="10" rx="1"></rect><rect x="41" y="60" width="10" height="10" rx="1"></rect><rect x="56" y="60" width="10" height="10" rx="1"></rect><rect x="71" y="60" width="10" height="10" rx="1"></rect><rect x="86" y="60" width="10" height="10" rx="1"></rect><rect x="101" y="60" width="10" height="10" rx="1"></rect><rect x="116" y="60" width="10" height="10" rx="1"></rect><rect x="108" y="105" width="10" height="10" rx="1"></rect><rect x="33" y="75" width="10" height="10" rx="1"></rect><rect x="48" y="75" width="10" height="10" rx="1"></rect><rect x="63" y="75" width="10" height="10" rx="1"></rect><rect x="78" y="75" width="10" height="10" rx="1"></rect><rect x="93" y="75" width="10" height="10" rx="1"></rect><rect x="108" y="75" width="10" height="10" rx="1"></rect><rect x="41" y="90" width="10" height="10" rx="1"></rect><rect x="26" y="90" width="10" height="10" rx="1"></rect><rect x="56" y="90" width="10" height="10" rx="1"></rect><rect x="71" y="90" width="10" height="10" rx="1"></rect><rect x="86" y="90" width="10" height="10" rx="1"></rect><rect x="101" y="90" width="10" height="10" rx="1"></rect><rect x="116" y="90" width="10" height="10" rx="1"></rect><rect x="48" y="105" width="55" height="10" rx="1"></rect><path d="M20.0056004,51 C18.3456532,51 17.0000001,52.3496496 17.0000001,54.0038284 L17.0000001,85.6824519 L17,120.003453 C17.0000001,121.6584 18.3455253,123 20.0056004,123 L131.9944,123 C133.654347,123 135,121.657592 135,119.997916 L135,54.0020839 C135,52.3440787 133.654475,51 131.9944,51 L20.0056004,51 Z" fill="none" stroke="#000000" stroke-width="2"></path><path d="M52.0410156,36.6054687 L75.5449219,21.6503905 L102.666016,36.6054687" id="Line" stroke="#000000" stroke-width="3" stroke-linecap="round" fill="none"></path></g></svg>'; | |
| keyboardButton.setAttribute('style', 'position:fixed;right:0;bottom:0;background-color:rgba(128,128,128,0.5);border-radius:5px'); | |
| canvas.parentElement.appendChild(keyboardButton); | |
| keyboardButton.onmousedown = function(evt) { | |
| // show on-screen keyboard | |
| input.focus({ preventScroll: true }); | |
| evt.preventDefault(); | |
| } | |
| keyboardButton.ontouchstart = keyboardButton.onmousedown; | |
| // modifier button for CMD key | |
| var cmdButton = document.createElement('div'); | |
| cmdButton.innerHTML = '⌘'; | |
| cmdButton.setAttribute('style', 'position:fixed;left:0;background-color:rgba(128,128,128,0.5);width:50px;height:50px;font-size:30px;text-align:center;vertical-align:middle;line-height:50px;border-radius:5px'); | |
| if (window.visualViewport) { | |
| // fix position of button when virtual keyboard is shown | |
| const vv = window.visualViewport; | |
| const fixPosition = () => cmdButton.style.top = `${vv.height}px`; | |
| vv.addEventListener('resize', fixPosition); | |
| cmdButton.style.transform = `translateY(-100%)`; | |
| fixPosition(); | |
| } else { | |
| cmdButton.style.bottom = '0'; | |
| } | |
| canvas.parentElement.appendChild(cmdButton); | |
| cmdButton.ontouchstart = function(evt) { | |
| display.cmdButtonTouched = evt.changedTouches[0].identifier; | |
| cmdButton.style.backgroundColor = 'rgba(255,255,255,0.5)'; | |
| evt.preventDefault(); | |
| evt.stopPropagation(); | |
| } | |
| cmdButton.ontouchend = function(evt) { | |
| display.cmdButtonTouched = null; | |
| cmdButton.style.backgroundColor = 'rgba(128,128,128,0.5)'; | |
| evt.preventDefault(); | |
| evt.stopPropagation(); | |
| } | |
| cmdButton.ontouchcancel = cmdButton.ontouchend; | |
| } else { | |
| // keep focus on input field | |
| input.onblur = function() { input.focus({ preventScroll: true }); }; | |
| input.focus({ preventScroll: true }); | |
| } | |
| display.isMac = navigator.userAgent.includes("Mac"); | |
| // emulate keypress events | |
| var deadKey = false, // true if last keydown was a dead key | |
| deadChars = []; | |
| input.oninput = function(evt) { | |
| if (!display.vm) return true; | |
| if (evt.inputType === "insertText" // regular key, or Chrome | |
| || evt.inputType === "insertCompositionText" // Firefox, Chrome | |
| || evt.inputType === "insertFromComposition") // Safari | |
| { | |
| // generate backspace to delete inserted dead chars | |
| var hadDeadChars = deadChars.length > 0; | |
| if (hadDeadChars) { | |
| var oldButtons = display.buttons; | |
| display.buttons &= ~Squeak.Keyboard_All; // remove all modifiers | |
| for (var i = 0; i < deadChars.length; i++) { | |
| recordKeyboardEvent(8, evt.timeStamp, display); | |
| } | |
| display.buttons = oldButtons; | |
| deadChars = []; | |
| } | |
| // generate keyboard events for each character | |
| // single input could be many characters, e.g. for emoji | |
| var chars = Array.from(evt.data); // split by surrogate pairs | |
| for (var i = 0; i < chars.length; i++) { | |
| var unicode = chars[i].codePointAt(0); // codePointAt combines pair into unicode | |
| recordKeyboardEvent(unicode, evt.timeStamp, display); | |
| } | |
| if (!hadDeadChars && evt.isComposing && evt.inputType === "insertCompositionText") { | |
| deadChars = deadChars.concat(chars); | |
| } | |
| } | |
| if (!deadChars.length) resetInput(); | |
| }; | |
| input.onkeydown = function(evt) { | |
| checkFullscreen(); | |
| if (!display.vm) return true; | |
| deadKey = evt.key === "Dead"; | |
| if (deadKey) return; // let browser handle dead keys | |
| recordModifiers(evt, display); | |
| var squeakCode = ({ | |
| 8: 8, // Backspace | |
| 9: 9, // Tab | |
| 13: 13, // Return | |
| 27: 27, // Escape | |
| 32: 32, // Space | |
| 33: 11, // PageUp | |
| 34: 12, // PageDown | |
| 35: 4, // End | |
| 36: 1, // Home | |
| 37: 28, // Left | |
| 38: 30, // Up | |
| 39: 29, // Right | |
| 40: 31, // Down | |
| 45: 5, // Insert | |
| 46: 127, // Delete | |
| })[evt.keyCode]; | |
| if (squeakCode) { // special key pressed | |
| recordKeyboardEvent(squeakCode, evt.timeStamp, display); | |
| return evt.preventDefault(); | |
| } | |
| // copy/paste new-style | |
| if (display.isMac ? evt.metaKey : evt.ctrlKey) { | |
| switch (evt.key) { | |
| case "c": | |
| case "x": | |
| if (!navigator.clipboard) return; // fire document.oncopy/oncut | |
| var text = display.executeClipboardCopyKey(evt.key, evt.timeStamp); | |
| if (typeof text === 'string') { | |
| navigator.clipboard.writeText(text) | |
| .catch(function(err) { console.error("display: copy error " + err.message); }); | |
| } | |
| return evt.preventDefault(); | |
| case "v": | |
| if (!navigator.clipboard) return; // fire document.onpaste | |
| navigator.clipboard.readText() | |
| .then(function(text) { | |
| display.executeClipboardPasteKey(text, evt.timeStamp); | |
| }) | |
| .catch(function(err) { console.error("display: paste error " + err.message); }); | |
| return evt.preventDefault(); | |
| } | |
| } | |
| if (evt.key.length !== 1) return; // let browser handle other keys | |
| if (display.buttons & (Squeak.Keyboard_Cmd | Squeak.Keyboard_Ctrl)) { | |
| var code = evt.key.toLowerCase().charCodeAt(0); | |
| if ((display.buttons & Squeak.Keyboard_Ctrl) && code >= 96 && code < 127) code &= 0x1F; // ctrl-<key> | |
| recordKeyboardEvent(code, evt.timeStamp, display); | |
| return evt.preventDefault(); | |
| } | |
| }; | |
| input.onkeyup = function(evt) { | |
| if (!display.vm) return true; | |
| recordModifiers(evt, display); | |
| }; | |
| function resetInput() { | |
| input.value = "**"; | |
| input.selectionStart = 1; | |
| input.selectionEnd = 1; | |
| } | |
| resetInput(); | |
| // hack to generate arrow keys when moving the cursor (e.g. via spacebar on iPhone) | |
| // we're not getting any events for that but the cursor (selection) changes | |
| if ('ontouchstart' in document) { | |
| let count = 0; // count how often the interval has run after the first move | |
| setInterval(() => { | |
| const direction = input.selectionStart - 1; | |
| if (direction === 0) { | |
| count = 0; | |
| return; | |
| } | |
| // move cursor once, then not for 500ms, then every 250ms | |
| if (count === 0 || count > 2) { | |
| const key = direction < 1 ? 28 : 29; // arrow left or right | |
| recordKeyboardEvent(key, Date.now(), display); | |
| } | |
| input.selectionStart = 1; | |
| input.selectionEnd = 1; | |
| count++; | |
| }, 250); | |
| } | |
| // more copy/paste | |
| if (navigator.clipboard) { | |
| // new-style copy/paste (all modern browsers) | |
| display.readFromSystemClipboard = () => display.handlingEvent && | |
| navigator.clipboard.readText() | |
| .then(text => display.clipboardString = text) | |
| .catch(err => console.error("readFromSystemClipboard " + err.message)); | |
| display.writeToSystemClipboard = () => display.handlingEvent && | |
| navigator.clipboard.writeText(display.clipboardString) | |
| .then(() => display.clipboardStringChanged = false) | |
| .catch(err => console.error("writeToSystemClipboard " + err.message)); | |
| } else { | |
| // old-style copy/paste | |
| document.oncopy = function(evt, key) { | |
| var text = display.executeClipboardCopyKey(key, evt.timeStamp); | |
| if (typeof text === 'string') { | |
| evt.clipboardData.setData("Text", text); | |
| } | |
| evt.preventDefault(); | |
| }; | |
| document.oncut = function(evt) { | |
| if (!display.vm) return true; | |
| document.oncopy(evt, 'x'); | |
| }; | |
| document.onpaste = function(evt) { | |
| var text = evt.clipboardData.getData('Text'); | |
| display.executeClipboardPasteKey(text, evt.timeStamp); | |
| evt.preventDefault(); | |
| }; | |
| } | |
| // do not use addEventListener, we want to replace any previous drop handler | |
| function dragEventHasFiles(evt) { | |
| for (var i = 0; i < evt.dataTransfer.types.length; i++) | |
| if (evt.dataTransfer.types[i] == 'Files') return true; | |
| return false; | |
| } | |
| document.ondragover = function(evt) { | |
| evt.preventDefault(); | |
| if (!dragEventHasFiles(evt)) { | |
| evt.dataTransfer.dropEffect = 'none'; | |
| } else { | |
| evt.dataTransfer.dropEffect = 'copy'; | |
| recordDragDropEvent(Squeak.EventDragMove, evt, canvas, display); | |
| } | |
| }; | |
| document.ondragenter = function(evt) { | |
| if (!dragEventHasFiles(evt)) return; | |
| recordDragDropEvent(Squeak.EventDragEnter, evt, canvas, display); | |
| }; | |
| document.ondragleave = function(evt) { | |
| if (!dragEventHasFiles(evt)) return; | |
| recordDragDropEvent(Squeak.EventDragLeave, evt, canvas, display); | |
| }; | |
| document.ondrop = function(evt) { | |
| evt.preventDefault(); | |
| if (!dragEventHasFiles(evt)) return false; | |
| var files = [].slice.call(evt.dataTransfer.files), | |
| loaded = [], | |
| image, imageName = null; | |
| display.droppedFiles = []; | |
| files.forEach(function(f) { | |
| var path = options.root + f.name; | |
| display.droppedFiles.push(path); | |
| var reader = new FileReader(); | |
| reader.onload = function () { | |
| var buffer = this.result; | |
| Squeak.filePut(path, buffer); | |
| loaded.push(path); | |
| if (!image && /.*image$/.test(path) && (!display.vm || confirm("Run " + f.name + " now?\n(cancel to use as file)"))) { | |
| image = buffer; | |
| imageName = path; | |
| } | |
| if (loaded.length == files.length) { | |
| if (image) { | |
| if (display.vm) { | |
| display.quitFlag = true; | |
| options.onQuit = function(vm, display, options) { | |
| options.onQuit = null; | |
| SqueakJS.appName = imageName.replace(/.*\//,'').replace(/\.image$/,''); | |
| SqueakJS.runImage(image, imageName, display, options); | |
| } | |
| } else { | |
| SqueakJS.appName = imageName.replace(/.*\//,'').replace(/\.image$/,''); | |
| SqueakJS.runImage(image, imageName, display, options); | |
| } | |
| } else { | |
| recordDragDropEvent(Squeak.EventDragDrop, evt, canvas, display); | |
| } | |
| } | |
| }; | |
| reader.readAsArrayBuffer(f); | |
| }); | |
| return false; | |
| }; | |
| var debounceTimeout; | |
| function onresize() { | |
| if (touch.orig) return; // manually resized | |
| // call resizeDone only if window size didn't change for 300ms | |
| var debounceWidth = window.innerWidth, | |
| debounceHeight = window.innerHeight; | |
| clearTimeout(debounceTimeout); | |
| debounceTimeout = setTimeout(function() { | |
| if (debounceWidth == window.innerWidth && debounceHeight == window.innerHeight) | |
| display.resizeDone(); | |
| else | |
| onresize(); | |
| }, 300); | |
| // CSS won't let us do what we want so we will layout the canvas ourselves. | |
| var x = 0, | |
| y = 0, | |
| w = window.innerWidth, | |
| h = window.innerHeight, | |
| paddingX = 0, // padding outside canvas | |
| paddingY = 0; | |
| // above are the default values for laying out the canvas | |
| if (!options.fixedWidth) { // set canvas resolution | |
| if (!options.minWidth) options.minWidth = 700; | |
| if (!options.minHeight) options.minHeight = 700; | |
| var defaultScale = display.highdpi ? window.devicePixelRatio : 1, | |
| scaleW = w < options.minWidth ? options.minWidth / w : defaultScale, | |
| scaleH = h < options.minHeight ? options.minHeight / h : defaultScale, | |
| scale = Math.max(scaleW, scaleH); | |
| display.width = Math.floor(w * scale); | |
| display.height = Math.floor(h * scale); | |
| display.scale = w / display.width; | |
| } else { // fixed resolution and aspect ratio | |
| display.width = options.fixedWidth; | |
| display.height = options.fixedHeight; | |
| var wantRatio = display.width / display.height, | |
| haveRatio = w / h; | |
| if (haveRatio > wantRatio) { | |
| paddingX = w - Math.floor(h * wantRatio); | |
| } else { | |
| paddingY = h - Math.floor(w / wantRatio); | |
| } | |
| display.scale = (w - paddingX) / display.width; | |
| } | |
| // set resolution | |
| if (canvas.width != display.width || canvas.height != display.height) { | |
| var preserveScreen = options.fixedWidth || !display.resizeTodo, // preserve unless changing fullscreen | |
| imgData = preserveScreen && display.context.getImageData(0, 0, canvas.width, canvas.height); | |
| canvas.width = display.width; | |
| canvas.height = display.height; | |
| if (imgData) display.context.putImageData(imgData, 0, 0); | |
| } | |
| // set canvas and cursor canvas size, position, pixelation | |
| adjustCanvas( | |
| x + Math.floor(paddingX / 2), | |
| y + Math.floor(paddingY / 2), | |
| w - paddingX, | |
| h - paddingY | |
| ); | |
| }; | |
| if (!options.embedded) { | |
| onresize(); | |
| window.onresize = onresize; | |
| } | |
| return display; | |
| } | |
| function setupSpinner(vm, options) { | |
| var spinner = options.spinner; | |
| if (!spinner) return null; | |
| spinner.onmousedown = function(evt) { | |
| if (confirm(SqueakJS.appName + " is busy. Interrupt?")) | |
| vm.interruptPending = true; | |
| }; | |
| return spinner.style; | |
| } | |
| var spinnerAngle = 0, | |
| becameBusy = 0; | |
| function updateSpinner(spinner, idleMS, vm, display) { | |
| var busy = idleMS === 0, | |
| animating = vm.lastTick - display.lastTick < 500; | |
| if (!busy || animating) { | |
| spinner.display = "none"; | |
| becameBusy = 0; | |
| } else { | |
| if (becameBusy === 0) { | |
| becameBusy = vm.lastTick; | |
| } else if (vm.lastTick - becameBusy > 1000) { | |
| spinner.display = "block"; | |
| spinnerAngle = (spinnerAngle + 30) % 360; | |
| spinner.webkitTransform = spinner.transform = "rotate(" + spinnerAngle + "deg)"; | |
| } | |
| } | |
| } | |
| ////////////////////////////////////////////////////////////////////////////// | |
| // main loop | |
| ////////////////////////////////////////////////////////////////////////////// | |
| var loop; // holds timeout for main loop | |
| SqueakJS.runImage = function(buffer, name, display, options) { | |
| window.onbeforeunload = function(evt) { | |
| var msg = SqueakJS.appName + " is still running"; | |
| evt.returnValue = msg; | |
| return msg; | |
| }; | |
| window.clearTimeout(loop); | |
| display.reset(); | |
| display.clear(); | |
| display.showBanner("Loading " + SqueakJS.appName); | |
| display.showProgress(0); | |
| window.setTimeout(function readImageAsync() { | |
| var image = new Squeak.Image(name); | |
| image.readFromBuffer(buffer, function startRunning() { | |
| display.quitFlag = false; | |
| var vm = new Squeak.Interpreter(image, display, options); | |
| SqueakJS.vm = vm; | |
| Squeak.Settings["squeakImageName"] = name; | |
| display.clear(); | |
| display.showBanner("Starting " + SqueakJS.appName); | |
| var spinner = setupSpinner(vm, options); | |
| function run() { | |
| try { | |
| if (display.quitFlag) SqueakJS.onQuit(vm, display, options); | |
| else vm.interpret(50, function runAgain(ms) { | |
| if (ms == "sleep") ms = 200; | |
| if (spinner) updateSpinner(spinner, ms, vm, display); | |
| loop = window.setTimeout(run, ms); | |
| }); | |
| } catch(error) { | |
| console.error(error); | |
| alert(error); | |
| } | |
| } | |
| display.runNow = function(event) { | |
| window.clearTimeout(loop); | |
| display.handlingEvent = event; | |
| run(); | |
| display.handlingEvent = ''; | |
| }; | |
| display.runFor = function(milliseconds, event) { | |
| var stoptime = Date.now() + milliseconds; | |
| do { | |
| if (display.quitFlag) return; | |
| display.runNow(event); | |
| } while (Date.now() < stoptime); | |
| }; | |
| if (options.onStart) options.onStart(vm, display, options); | |
| run(); | |
| }, | |
| function readProgress(value) {display.showProgress(value);}); | |
| }, 0); | |
| }; | |
| function processOptions(options) { | |
| var search = (location.hash || location.search).slice(1), | |
| args = search && search.split("&"); | |
| if (args) for (var i = 0; i < args.length; i++) { | |
| var keyAndVal = args[i].split("="), | |
| key = keyAndVal[0], | |
| val = true; | |
| if (keyAndVal.length > 1) { | |
| val = decodeURIComponent(keyAndVal.slice(1).join("=")); | |
| if (val.match(/^(true|false|null|[0-9"[{].*)$/)) | |
| try { val = JSON.parse(val); } catch(e) { | |
| if (val[0] === "[") val = val.slice(1,-1).split(","); // handle string arrays | |
| // if not JSON use string itself | |
| } | |
| } | |
| options[key] = val; | |
| } | |
| var root = Squeak.splitFilePath(options.root || "/").fullname; | |
| Squeak.dirCreate(root, true); | |
| if (!/\/$/.test(root)) root += "/"; | |
| options.root = root; | |
| if (options.w) options.fixedWidth = options.w; | |
| if (options.h) options.fixedHeight = options.h; | |
| if (options.fixedWidth && !options.fixedHeight) options.fixedHeight = options.fixedWidth * 3 / 4 | 0; | |
| if (options.fixedHeight && !options.fixedWidth) options.fixedWidth = options.fixedHeight * 4 / 3 | 0; | |
| if (options.fixedWidth && options.fixedHeight) options.fullscreen = true; | |
| SqueakJS.options = options; | |
| } | |
| function fetchTemplates(options) { | |
| if (options.templates) { | |
| if (options.templates.constructor === Array) { | |
| var templates = {}; | |
| options.templates.forEach(function(path){ templates[path] = path; }); | |
| options.templates = templates; | |
| } | |
| for (var path in options.templates) { | |
| var dir = path[0] == "/" ? path : options.root + path, | |
| baseUrl = new URL(options.url, document.baseURI).href.split(/[?#]/)[0], | |
| url = Squeak.splitUrl(options.templates[path], baseUrl).full; | |
| if (url.endsWith("/")) url = url.slice(0,-1); | |
| if (url.endsWith("/.")) url = url.slice(0,-2); | |
| Squeak.fetchTemplateDir(dir, url); | |
| } | |
| } | |
| } | |
| function processFile(file, display, options, thenDo) { | |
| Squeak.filePut(options.root + file.name, file.data, function() { | |
| console.log("Stored " + options.root + file.name); | |
| if (file.zip) { | |
| processZip(file, display, options, thenDo); | |
| } else { | |
| thenDo(); | |
| } | |
| }); | |
| } | |
| function processZip(file, display, options, thenDo) { | |
| display.showBanner("Analyzing " + file.name); | |
| JSZip.loadAsync(file.data, { createFolders: true }).then(function(zip) { | |
| var todo = []; | |
| zip.forEach(function(filename, meta) { | |
| if (filename.startsWith("__MACOSX/") || filename.endsWith(".DS_Store")) return; // skip macOS metadata | |
| if (meta.dir) { | |
| filename = filename.replace(/\/$/, ""); | |
| Squeak.dirCreate(options.root + filename, true); | |
| return; | |
| } | |
| if (!options.image.name && filename.match(/\.image$/)) | |
| options.image.name = filename; | |
| if (options.forceDownload || !Squeak.fileExists(options.root + filename)) { | |
| todo.push(filename); | |
| } else if (options.image.name === filename) { | |
| // image exists, need to fetch it from storage | |
| var _thenDo = thenDo; | |
| thenDo = function() { | |
| Squeak.fileGet(options.root + filename, function(data) { | |
| options.image.data = data; | |
| return _thenDo(); | |
| }, function onError() { | |
| Squeak.fileDelete(options.root + file.name); | |
| return processZip(file, display, options, _thenDo); | |
| }); | |
| } | |
| } | |
| }); | |
| if (todo.length === 0) return thenDo(); | |
| var done = 0; | |
| display.showBanner("Unzipping " + file.name); | |
| display.showProgress(0); | |
| todo.forEach(function(filename){ | |
| console.log("Inflating " + file.name + ": " + filename); | |
| function progress(x) { display.showProgress((x.percent / 100 + done) / todo.length); } | |
| zip.file(filename).async("arraybuffer", progress).then(function(buffer){ | |
| console.log("Expanded size of " + filename + ": " + buffer.byteLength + " bytes"); | |
| var unzipped = {}; | |
| if (options.image.name === filename) | |
| unzipped = options.image; | |
| unzipped.name = filename; | |
| unzipped.data = buffer; | |
| processFile(unzipped, display, options, function() { | |
| if (++done === todo.length) thenDo(); | |
| }); | |
| }); | |
| }); | |
| }); | |
| } | |
| function checkExisting(file, display, options, ifExists, ifNotExists) { | |
| if (!Squeak.fileExists(options.root + file.name)) | |
| return ifNotExists(); | |
| if (file.image || file.zip) { | |
| // if it's the image or a zip, load from file storage | |
| Squeak.fileGet(options.root + file.name, function(data) { | |
| file.data = data; | |
| if (file.zip) processZip(file, display, options, ifExists); | |
| else ifExists(); | |
| }, function onError() { | |
| // if error, download it | |
| Squeak.fileDelete(options.root + file.name); | |
| return ifNotExists(); | |
| }); | |
| } else { | |
| // for all other files assume they're okay | |
| ifExists(); | |
| } | |
| } | |
| function downloadFile(file, display, options, thenDo) { | |
| display.showBanner("Downloading " + file.name); | |
| var rq = new XMLHttpRequest(), | |
| proxy = options.proxy || ""; | |
| rq.open('GET', proxy + file.url); | |
| if (options.ajax) rq.setRequestHeader("X-Requested-With", "XMLHttpRequest"); | |
| rq.responseType = 'arraybuffer'; | |
| rq.onprogress = function(e) { | |
| if (e.lengthComputable) display.showProgress(e.loaded / e.total); | |
| }; | |
| rq.onload = function(e) { | |
| if (this.status == 200) { | |
| file.data = this.response; | |
| processFile(file, display, options, thenDo); | |
| } | |
| else this.onerror(this.statusText); | |
| }; | |
| rq.onerror = function(e) { | |
| if (options.proxy) { | |
| console.error(Squeak.bytesAsString(new Uint8Array(this.response))); | |
| return alert("Failed to download:\n" + file.url); | |
| } | |
| var proxy = Squeak.defaultCORSProxy, | |
| retry = new XMLHttpRequest(); | |
| console.warn('Retrying with CORS proxy: ' + proxy + file.url); | |
| retry.open('GET', proxy + file.url); | |
| if (options.ajax) retry.setRequestHeader("X-Requested-With", "XMLHttpRequest"); | |
| retry.responseType = rq.responseType; | |
| retry.onprogress = rq.onprogress; | |
| retry.onload = rq.onload; | |
| retry.onerror = function() { | |
| console.error(Squeak.bytesAsString(new Uint8Array(this.response))); | |
| alert("Failed to download:\n" + file.url)}; | |
| retry.send(); | |
| }; | |
| rq.send(); | |
| } | |
| function fetchFiles(files, display, options, thenDo) { | |
| // check if files exist locally and download if nessecary | |
| function getNextFile() { | |
| if (files.length === 0) return thenDo(); | |
| var file = files.shift(), | |
| forceDownload = options.forceDownload || file.forceDownload; | |
| if (forceDownload) downloadFile(file, display, options, getNextFile); | |
| else checkExisting(file, display, options, | |
| function ifExists() { | |
| getNextFile(); | |
| }, | |
| function ifNotExists() { | |
| downloadFile(file, display, options, getNextFile); | |
| }); | |
| } | |
| getNextFile(); | |
| } | |
| SqueakJS.runSqueak = function(imageUrl, canvas, options={}) { | |
| if (!canvas) { | |
| canvas = document.createElement("canvas"); | |
| canvas.style.position = "absolute"; | |
| canvas.style.left = "0"; | |
| canvas.style.top = "0"; | |
| canvas.style.width = "100%"; | |
| canvas.style.height = "100%"; | |
| document.body.appendChild(canvas); | |
| } | |
| // we need to fetch all files first, then run the image | |
| processOptions(options); | |
| if (imageUrl && imageUrl.endsWith(".zip")) { | |
| options.zip = imageUrl.match(/[^\/]*$/)[0]; | |
| options.url = imageUrl.replace(/[^\/]*$/, ""); | |
| imageUrl = null; | |
| } | |
| if (!imageUrl && options.image) imageUrl = options.image; | |
| var baseUrl = options.url || ""; | |
| if (!baseUrl && imageUrl && imageUrl.replace(/[^\/]*$/, "")) { | |
| baseUrl = imageUrl.replace(/[^\/]*$/, ""); | |
| imageUrl = imageUrl.replace(/^.*\//, ""); | |
| } | |
| options.url = baseUrl; | |
| if (baseUrl[0] === "/" && baseUrl[1] !== "/" && baseUrl.length > 1 && options.root === "/") { | |
| options.root = baseUrl; | |
| } | |
| fetchTemplates(options); | |
| var display = createSqueakDisplay(canvas, options), | |
| image = {url: null, name: null, image: true, data: null}, | |
| files = []; | |
| display.argv = options.argv; | |
| if (imageUrl) { | |
| var url = Squeak.splitUrl(imageUrl, baseUrl); | |
| image.url = url.full; | |
| image.name = url.filename; | |
| } | |
| if (options.files) { | |
| options.files.forEach(function(f) { | |
| var url = Squeak.splitUrl(f, baseUrl); | |
| if (image.name === url.filename) {/* pushed after other files */} | |
| else if (!image.url && f.match(/\.image$/)) { | |
| image.name = url.filename; | |
| image.url = url.full; | |
| } else { | |
| files.push({url: url.full, name: url.filename}); | |
| } | |
| }); | |
| } | |
| if (options.zip) { | |
| var zips = typeof options.zip === "string" ? [options.zip] : options.zip; | |
| zips.forEach(function(zip) { | |
| var url = Squeak.splitUrl(zip, baseUrl); | |
| var prefix = ""; | |
| // if filename has no version info, but full url has it, use full url as prefix | |
| if (!url.filename.match(/[0-9]/) && url.uptoslash.match(/[0-9]/)) { | |
| prefix = url.uptoslash.replace(/^[^:]+:\/\//, "").replace(/[^a-zA-Z0-9]/g, "_"); | |
| } | |
| files.push({url: url.full, name: prefix + url.filename, zip: true}); | |
| }); | |
| } | |
| if (image.url) files.push(image); | |
| if (options.document) { | |
| var url = Squeak.splitUrl(options.document, baseUrl); | |
| files.push({url: url.full, name: url.filename, forceDownload: options.forceDownload !== false}); | |
| display.documentName = options.root + url.filename; | |
| } | |
| options.image = image; | |
| fetchFiles(files, display, options, function thenDo() { | |
| Squeak.fsck(); // will run async | |
| var image = options.image; | |
| if (!image.name) return alert("could not find an image"); | |
| if (!image.data) return alert("could not find image " + image.name); | |
| SqueakJS.appName = options.appName || image.name.replace(/(.*\/|\.image$)/g, ""); | |
| SqueakJS.runImage(image.data, options.root + image.name, display, options); | |
| }); | |
| return display; | |
| }; | |
| SqueakJS.quitSqueak = function() { | |
| SqueakJS.vm.quitFlag = true; | |
| }; | |
| SqueakJS.onQuit = function(vm, display, options) { | |
| window.onbeforeunload = null; | |
| display.vm = null; | |
| if (options.spinner) options.spinner.style.display = "none"; | |
| if (options.onQuit) options.onQuit(vm, display, options); | |
| else display.showBanner(SqueakJS.appName + " stopped."); | |
| }; | |
| ////////////////////////////////////////////////////////////////////////////// | |
| // browser stuff | |
| ////////////////////////////////////////////////////////////////////////////// | |
| if (window.applicationCache) { | |
| applicationCache.addEventListener('updateready', function() { | |
| // use original appName from options | |
| var appName = window.SqueakJS && SqueakJS.options && SqueakJS.options.appName || "SqueakJS"; | |
| if (confirm(appName + ' has been updated. Restart now?')) { | |
| window.onbeforeunload = null; | |
| window.location.reload(); | |
| } | |
| }); | |
| } | |