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(); | |
} | |
}); | |
} | |