s4s-packager / src /scaffolding /scaffolding.js
soiz1's picture
Upload 225 files
7aec436 verified
import VM from 'scratch-vm';
import Renderer from 'scratch-render';
import Storage from './storage';
import AudioEngine from 'scratch-audio';
import {BitmapAdapter} from 'scratch-svg-renderer';
import JSZip from 'jszip';
import {EventTarget} from '../common/event-target';
import VideoProvider from './video';
import Cloud from './cloud';
import Question from './question';
import {ListMonitor, VariableMonitor} from './monitor';
import ControlBar from './control-bar';
import {isValidListValue, isValidVariableValue} from './verify-value';
import defaultMessages from './messages.json';
import styles from './style.css';
const getEventXY = (e) => {
if (e.touches && e.touches[0]) {
return {x: e.touches[0].clientX, y: e.touches[0].clientY};
} else if (e.changedTouches && e.changedTouches[0]) {
return {x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY};
}
return {x: e.clientX, y: e.clientY};
};
const wrapAsFunctionIfNotFunction = (value) => {
if (typeof value === 'function') {
return value;
}
return () => value;
};
class Scaffolding extends EventTarget {
constructor () {
super();
this.width = 480;
this.height = 360;
this.resizeMode = 'preserve-ratio';
this.editableLists = false;
this.shouldConnectPeripherals = true;
this.usePackagedRuntime = false;
this.messages = defaultMessages;
this._monitors = new Map();
this._mousedownPosition = null;
this._draggingId = null;
this._draggingStartMousePosition = null;
this._draggingStartSpritePosition = null;
this._offsetFromTop = 0;
this._offsetFromBottom = 0;
this._offsetFromLeft = 0;
this._offsetFromRight = 0;
this._root = document.createElement('div');
this._root.className = styles.root;
this._layers = document.createElement('div');
this._layers.className = styles.layers;
this._root.appendChild(this._layers);
this._canvas = document.createElement('canvas');
this._canvas.className = styles.canvas;
this._addLayer(this._canvas);
this._overlays = document.createElement('div');
this._overlays.className = styles.scaledOverlaysInner;
this._overlaysOuter = document.createElement('div');
this._overlaysOuter.className = styles.scaledOverlaysOuter;
this._overlaysOuter.appendChild(this._overlays);
this._addLayer(this._overlaysOuter);
this._monitorOverlay = document.createElement('div');
this._monitorOverlay.className = styles.monitorOverlay;
this._overlays.appendChild(this._monitorOverlay);
this._topControls = new ControlBar();
this._layers.appendChild(this._topControls.root);
document.addEventListener('mousemove', this._onmousemove.bind(this));
this._canvas.addEventListener('mousedown', this._onmousedown.bind(this));
document.addEventListener('mouseup', this._onmouseup.bind(this));
this._canvas.addEventListener('touchstart', this._ontouchstart.bind(this));
document.addEventListener('touchmove', this._ontouchmove.bind(this));
document.addEventListener('touchend', this._ontouchend.bind(this));
this._canvas.addEventListener('contextmenu', this._oncontextmenu.bind(this));
this._canvas.addEventListener('wheel', this._onwheel.bind(this));
document.addEventListener('keydown', this._onkeydown.bind(this));
document.addEventListener('keyup', this._onkeyup.bind(this));
window.addEventListener('resize', this._onresize.bind(this));
}
_addLayer (el) {
this._layers.appendChild(el);
}
_scratchCoordinates (x, y) {
return {
x: (this.width / this.layersRect.width) * (x - (this.layersRect.width / 2)),
y: -(this.height / this.layersRect.height) * (y - (this.layersRect.height / 2))
};
}
_onmousemove (e) {
const {x, y} = getEventXY(e);
const data = {
x: x - this.layersRect.left,
y: y - this.layersRect.top,
canvasWidth: this.layersRect.width,
canvasHeight: this.layersRect.height
};
if (this._mousedownPosition && !this._draggingId) {
const distance = Math.sqrt(
Math.pow(data.x - this._mousedownPosition.x, 2) +
Math.pow(data.y - this._mousedownPosition.y, 2)
);
if (distance > 3) {
this._startDragging(data.x, data.y);
this._cancelDragTimeout();
}
} else if (this._draggingId) {
const position = this._scratchCoordinates(data.x, data.y);
this.vm.postSpriteInfo({
x: position.x - this._draggingStartMousePosition.x + this._draggingStartSpritePosition.x,
y: position.y - this._draggingStartMousePosition.y + this._draggingStartSpritePosition.y,
force: true
});
}
this.vm.postIOData('mouse', data);
}
_startDragging (x, y) {
if (this._draggingId) return;
const drawableId = this.renderer.pick(x, y);
if (drawableId === null) return;
const targetId = this.vm.getTargetIdForDrawableId(drawableId);
if (targetId === null) return;
const target = this.vm.runtime.getTargetById(targetId);
if (!target.draggable) return;
target.goToFront();
this._draggingId = targetId;
this._draggingStartMousePosition = this._scratchCoordinates(x, y);
this._draggingStartSpritePosition = {
x: target.x,
y: target.y
};
this.vm.startDrag(targetId);
}
_cancelDragTimeout () {
clearTimeout(this._dragTimeout);
this._dragTimeout = null;
}
_onmousedown (e) {
const {x, y} = getEventXY(e);
const data = {
x: x - this.layersRect.left,
y: y - this.layersRect.top,
button: e.button,
canvasWidth: this.layersRect.width,
canvasHeight: this.layersRect.height,
isDown: true
};
const isTouchEvent = typeof TouchEvent !== 'undefined' && e instanceof TouchEvent;
if (e.button === 0 || isTouchEvent) {
this._dragTimeout = setTimeout(this._startDragging.bind(this, data.x, data.y), 400);
}
if (isTouchEvent) {
e.preventDefault();
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
}
this._mousedownPosition = {
x: data.x,
y: data.y
};
this.vm.postIOData('mouse', data);
}
_onmouseup (e) {
this._cancelDragTimeout();
const {x, y} = getEventXY(e);
const data = {
x: x - this.layersRect.left,
y: y - this.layersRect.top,
button: e.button,
canvasWidth: this.layersRect.width,
canvasHeight: this.layersRect.height,
isDown: false,
wasDragged: this._draggingId !== null
};
this._mousedownPosition = null;
this.vm.postIOData('mouse', data);
if (this._draggingId) {
this.vm.stopDrag(this._draggingId);
this._draggingStartMousePosition = null;
this._draggingStartSpritePosition = null;
this._draggingId = null;
}
}
_ontouchstart (e) {
this._onmousedown(e);
}
_ontouchmove (e) {
this._onmousemove(e);
}
_ontouchend (e) {
this._onmouseup(e);
}
_oncontextmenu (e) {
e.preventDefault();
}
_onwheel (e) {
const data = {
deltaX: e.deltaX,
deltaY: e.deltaY
};
this.vm.postIOData('mouseWheel', data);
}
_onkeydown (e) {
if (e.target !== document && e.target !== document.body) {
return;
}
const data = {
key: e.key,
keyCode: e.keyCode,
isDown: true
};
this.vm.postIOData('keyboard', data);
if (e.keyCode === 32 || (e.keyCode >= 37 && e.keyCode <= 40) || e.keyCode === 8 || e.keyCode === 222 || e.keyCode === 191) {
e.preventDefault();
}
}
_onkeyup (e) {
const data = {
key: e.key,
keyCode: e.keyCode,
isDown: false
};
this.vm.postIOData('keyboard', data);
if (e.target !== document && e.target !== document.body) {
e.preventDefault();
}
}
_onresize () {
this.relayout();
}
relayout () {
const totalWidth = Math.max(1, this._root.offsetWidth);
const totalHeight = Math.max(1, this._root.offsetHeight);
const offsetFromTop = this._offsetFromTop + this._topControls.computeHeight();
const offsetFromBottom = this._offsetFromBottom;
const offsetFromLeft = this._offsetFromLeft;
const offsetFromRight = this._offsetFromRight;
const projectAreaWidth = Math.max(1, totalWidth - offsetFromLeft - offsetFromRight);
const projectAreaHeight = Math.max(1, totalHeight - offsetFromTop - offsetFromBottom);
if (this.resizeMode === 'dynamic-resize') {
// setStageSize is a TurboWarp-specific method
if (this.vm.setStageSize) {
this.width = projectAreaWidth;
this.height = projectAreaHeight;
this.vm.setStageSize(this.width, this.height);
} else {
console.warn('dynamic-resize not supported: vm does not implement setStageSize');
}
}
let width = projectAreaWidth;
let height = projectAreaHeight;
if (this.resizeMode !== 'stretch') {
width = height / this.height * this.width;
if (width > projectAreaWidth) {
height = projectAreaWidth / this.width * this.height;
width = projectAreaWidth;
}
}
const distanceFromTop = totalHeight - height;
const distanceFromLeft = totalWidth - width;
const translateY = (distanceFromLeft - offsetFromLeft - offsetFromRight) / 2 + offsetFromLeft - (distanceFromLeft / 2);
const translateX = (distanceFromTop - offsetFromTop - offsetFromBottom) / 2 + offsetFromTop - (distanceFromTop / 2);
this._layers.style.transform = `translate(${translateY}px, ${translateX}px)`;
this._layers.style.width = `${width}px`;
this._layers.style.height = `${height}px`;
this._overlays.style.transform = `scale(${width / this.width}, ${height / this.height})`;
this.renderer.resize(width, height);
this.layersRect = this._layers.getBoundingClientRect();
}
appendTo (element) {
element.appendChild(this._root);
this.relayout();
}
setup () {
this.vm = new VM();
this.vm.setCompatibilityMode(true);
this.vm.setLocale(navigator.language);
this.vm.on('MONITORS_UPDATE', this._onmonitorsupdate.bind(this));
this.vm.runtime.on('QUESTION', this._onquestion.bind(this));
this.vm.on('PROJECT_RUN_START', () => this.dispatchEvent(new Event('PROJECT_RUN_START')));
this.vm.on('PROJECT_RUN_STOP', () => this.dispatchEvent(new Event('PROJECT_RUN_STOP')));
// TurboWarp-specific VM extensions
if (this.usePackagedRuntime && this.vm.convertToPackagedRuntime) {
this.vm.convertToPackagedRuntime();
}
if (this.vm.setStageSize) {
this.vm.setStageSize(this.width, this.height);
}
if (this.vm.runtime.cloudOptions) {
this.vm.runtime.cloudOptions.limit = Infinity;
}
// TODO: remove when https://github.com/TurboWarp/packager/issues/213 is fixed
this.vm.on('STAGE_SIZE_CHANGED', (width, height) => {
if (this.width !== width || this.height !== height) {
this.width = width;
this.height = height;
this.relayout();
}
});
this.cloudManager = new Cloud.CloudManager(this);
this.renderer = new Renderer(
this._canvas,
-this.width / 2,
this.width / 2,
-this.height / 2,
this.height / 2
);
this.vm.attachRenderer(this.renderer);
// TurboWarp-specific renderer extensions
if (this.renderer.overlayContainer) {
this._layers.insertBefore(this.renderer.overlayContainer, this._overlaysOuter);
}
this.storage = new Storage();
this.vm.attachStorage(this.storage);
if (typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined') {
this.audioEngine = new AudioEngine();
this.vm.attachAudioEngine(this.audioEngine);
} else {
console.warn('AudioContext not supported. Sound will not work.');
}
this.bitmapAdapter = new BitmapAdapter();
this.vm.attachV2BitmapAdapter(this.bitmapAdapter);
this.videoProvider = new VideoProvider();
this.vm.setVideoProvider(this.videoProvider);
}
async _connectPeripherals () {
const scanExtension = (extensionId) => new Promise((resolve) => {
const onListUpdate = (peripherals) => {
const peripheralArray = Object.keys(peripherals).map((id) => peripherals[id]);
if (peripheralArray.length > 0) {
const peripheral = peripheralArray[0];
console.log('Connecting to peripheral', peripheral);
this.vm.connectPeripheral(extensionId, peripheral.peripheralId);
} else {
console.error('No peripherals found for', extensionId);
}
done();
};
const onScanTimeout = () => {
console.error('Peripheral scan timed out for', extensionId);
done();
};
const done = () => {
this.vm.removeListener('PERIPHERAL_LIST_UPDATE', onListUpdate);
this.vm.removeListener('PERIPHERAL_SCAN_TIMEOUT', onScanTimeout);
resolve();
};
this.vm.on('PERIPHERAL_LIST_UPDATE', onListUpdate);
this.vm.on('PERIPHERAL_SCAN_TIMEOUT', onScanTimeout);
this.vm.scanForPeripheral(extensionId);
});
for (const extensionId of Object.keys(this.vm.runtime.peripheralExtensions)) {
await scanExtension(extensionId);
}
}
_onmonitorsupdate (monitors) {
for (const monitorData of monitors.valueSeq()) {
const id = monitorData.get('id');
if (!this._monitors.has(id)) {
const visible = monitorData.get('visible');
if (!visible) {
// Would be a waste to make it now
continue;
}
// TODO: add to DOM in same order as appears in list
const mode = monitorData.get('mode');
if (mode === 'list') {
this._monitors.set(id, new ListMonitor(this, monitorData));
} else {
this._monitors.set(id, new VariableMonitor(this, monitorData));
}
}
const monitorObject = this._monitors.get(id);
monitorObject.update(monitorData);
}
}
ask (text) {
this._question = new Question(this, text);
return this._question.answer();
}
_onquestion (question) {
if (this._question) {
this._question.destroy()
}
if (question !== null) {
this.ask(question)
.then((answer) => {
this.vm.runtime.emit('ANSWER', answer);
});
}
}
loadProject (data) {
return this.vm.loadProject(data)
.then(() => {
this.vm.setCloudProvider(this.cloudManager);
this.cloudManager.projectReady();
this.renderer.draw();
// Render again after a short delay because some costumes are loaded async
setTimeout(() => {
this.renderer.draw();
});
if (this.shouldConnectPeripherals) {
this._connectPeripherals();
}
});
}
setUsername (username) {
this._username = username;
this.vm.postIOData('userData', {
username
});
}
addCloudProvider (provider) {
this.cloudManager.addProvider(provider);
}
addCloudProviderOverride (name, provider) {
this.cloudManager.addProviderOverride(name, provider);
}
addControlButton({element, where}) {
if (where === 'top-left') {
this._topControls.addToStart(element);
} else if (where === 'top-right') {
this._topControls.addToEnd(element);
} else {
throw new Error(`Unknown 'where': ${where}`);
}
this.relayout();
}
getMessage (id) {
return this.messages[id] || id;
}
/**
* Change primary accent color.
* @param {string} color Color in the format #abcdef
*/
setAccentColor (color) {
this._root.style.setProperty('--sc-accent-color', color);
this._root.style.setProperty('--sc-accent-color-transparent', `${color}59`);
}
start () {
this.vm.start();
this.vm.greenFlag();
}
greenFlag () {
this.start();
}
stopAll () {
this.vm.stopAll();
}
_lookupVariable(name, type) {
const variable = this.vm.runtime.getTargetForStage().lookupVariableByNameAndType(name, type);
if (!variable) throw new Error(`Global ${type || 'variable'} does not exist: ${name}`);
return variable;
}
setExtensionSecurityManager (newManager) {
const securityManager = this.vm.extensionManager.securityManager;
if (!securityManager) {
console.warn('setExtensionSecurityManager not supported: there is no security manager');
return;
}
for (const [methodName, fn] of Object.entries(newManager)) {
securityManager[methodName] = wrapAsFunctionIfNotFunction(fn);
}
}
getVariable (name) {
return this._lookupVariable(name, '').value;
}
setVariable(name, value) {
if (!isValidVariableValue(value)) {
throw new Error('Invalid variable value');
}
this._lookupVariable(name, '').value = value;
}
getList(name) {
return this._lookupVariable(name, 'list').value;
}
setList(name, value) {
if (!isValidListValue(value)) {
throw new Error('Invalid list value');
}
this._lookupVariable(name, 'list').value = value;
}
}
export {
Scaffolding,
Cloud,
VM,
Renderer,
Storage,
AudioEngine,
JSZip
};