You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

861 lines
18 KiB
JavaScript

/*
* Copyright 2012, Gregg Tavares.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Gregg Tavares. nor the names of his
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/* global define */
(function(root, factory) { // eslint-disable-line
if ( typeof define === 'function' && define.amd ) {
// AMD. Register as an anonymous module.
define( [], function () {
return factory.call( root );
} );
} else {
// Browser globals
root.lessonsHelper = factory.call( root );
}
}( this, function () {
'use strict'; // eslint-disable-line
const lessonSettings = window.lessonSettings || {};
const topWindow = this;
/**
* Check if the page is embedded.
* @param {Window?) w window to check
* @return {boolean} True of we are in an iframe
*/
function isInIFrame( w ) {
w = w || topWindow;
return w !== w.top;
}
function updateCSSIfInIFrame() {
if ( isInIFrame() ) {
try {
document.getElementsByTagName( 'html' )[ 0 ].className = 'iframe';
} catch ( e ) {
// eslint-disable-line
}
try {
document.body.className = 'iframe';
} catch ( e ) {
// eslint-disable-line
}
}
}
function isInEditor() {
return window.location.href.substring( 0, 4 ) === 'blob';
}
/**
* Creates a webgl context. If creation fails it will
* change the contents of the container of the <canvas>
* tag to an error message with the correct links for WebGL.
* @param {HTMLCanvasElement} canvas. The canvas element to
* create a context from.
* @param {WebGLContextCreationAttributes} opt_attribs Any
* creation attributes you want to pass in.
* @return {WebGLRenderingContext} The created context.
* @memberOf module:webgl-utils
*/
function showNeedWebGL( canvas ) {
const doc = canvas.ownerDocument;
if ( doc ) {
const temp = doc.createElement( 'div' );
temp.innerHTML = `
<div style="
position: absolute;
left: 0;
top: 0;
background-color: #DEF;
width: 100%;
height: 100%;
display: flex;
flex-flow: column;
justify-content: center;
align-content: center;
align-items: center;
">
<div style="text-align: center;">
It doesn't appear your browser supports WebGL.<br/>
<a href="http://get.webgl.org" target="_blank">Click here for more information.</a>
</div>
</div>
`;
const div = temp.querySelector( 'div' );
doc.body.appendChild( div );
}
}
const origConsole = {};
function setupConsole() {
const style = document.createElement( 'style' );
style.innerText = `
.console {
font-family: monospace;
font-size: medium;
max-height: 50%;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
overflow: auto;
background: rgba(221, 221, 221, 0.9);
}
.console .console-line {
white-space: pre-line;
}
.console .log .warn {
color: black;
}
.console .error {
color: red;
}
`;
const parent = document.createElement( 'div' );
parent.className = 'console';
const toggle = document.createElement( 'div' );
let show = false;
Object.assign( toggle.style, {
position: 'absolute',
right: 0,
bottom: 0,
background: '#EEE',
'font-size': 'smaller',
cursor: 'pointer',
} );
toggle.addEventListener( 'click', showHideConsole );
function showHideConsole() {
show = ! show;
toggle.textContent = show ? '☒' : '☐';
parent.style.display = show ? '' : 'none';
}
showHideConsole();
const maxLines = 100;
const lines = [];
let added = false;
function addLine( type, str, prefix ) {
const div = document.createElement( 'div' );
div.textContent = ( prefix + str ) || ' ';
div.className = `console-line ${type}`;
parent.appendChild( div );
lines.push( div );
if ( ! added ) {
added = true;
document.body.appendChild( style );
document.body.appendChild( parent );
document.body.appendChild( toggle );
}
// scrollIntoView only works in Chrome
// In Firefox and Safari scrollIntoView inside an iframe moves
// that element into the view. It should arguably only move that
// element inside the iframe itself, otherwise that's giving
// any random iframe control to bring itself into view against
// the parent's wishes.
//
// note that even if we used a solution (which is to manually set
// scrollTop) there's a UI issue that if the user manually scrolls
// we want to stop scrolling automatically and if they move back
// to the bottom we want to pick up scrolling automatically.
// Kind of a PITA so TBD
//
// div.scrollIntoView();
}
function addLines( type, str, prefix ) {
while ( lines.length > maxLines ) {
const div = lines.shift();
div.parentNode.removeChild( div );
}
addLine( type, str, prefix );
}
const threePukeRE = /WebGLRenderer.*?extension not supported/;
function wrapFunc( obj, funcName, prefix ) {
const oldFn = obj[ funcName ];
origConsole[ funcName ] = oldFn.bind( obj );
return function ( ...args ) {
// three.js pukes all over so filter here
const src = [ ...args ].join( ' ' );
if ( ! threePukeRE.test( src ) ) {
addLines( funcName, src, prefix );
}
oldFn.apply( obj, arguments );
};
}
window.console.log = wrapFunc( window.console, 'log', '' );
window.console.warn = wrapFunc( window.console, 'warn', '⚠' );
window.console.error = wrapFunc( window.console, 'error', '❌' );
}
function reportJSError( url, lineNo, colNo, msg ) {
try {
const { origUrl, actualLineNo } = window.parent.getActualLineNumberAndMoveTo( url, lineNo, colNo );
url = origUrl;
lineNo = actualLineNo;
} catch ( ex ) {
origConsole.error( ex );
}
console.error(url, "line:", lineNo, ":", msg); // eslint-disable-line
}
/**
* @typedef {Object} StackInfo
* @property {string} url Url of line
* @property {number} lineNo line number of error
* @property {number} colNo column number of error
* @property {string} [funcName] name of function
*/
/**
* @parameter {string} stack A stack string as in `(new Error()).stack`
* @returns {StackInfo}
*/
const parseStack = function () {
const browser = getBrowser();
let lineNdx;
let matcher;
if ( ( /chrome|opera/i ).test( browser.name ) ) {
lineNdx = 3;
matcher = function ( line ) {
const m = /at ([^(]*?)\(*(.*?):(\d+):(\d+)/.exec( line );
if ( m ) {
let userFnName = m[ 1 ];
let url = m[ 2 ];
const lineNo = parseInt( m[ 3 ] );
const colNo = parseInt( m[ 4 ] );
if ( url === '' ) {
url = userFnName;
userFnName = '';
}
return {
url: url,
lineNo: lineNo,
colNo: colNo,
funcName: userFnName,
};
}
return undefined;
};
} else if ( ( /firefox|safari/i ).test( browser.name ) ) {
lineNdx = 2;
matcher = function ( line ) {
const m = /@(.*?):(\d+):(\d+)/.exec( line );
if ( m ) {
const url = m[ 1 ];
const lineNo = parseInt( m[ 2 ] );
const colNo = parseInt( m[ 3 ] );
return {
url: url,
lineNo: lineNo,
colNo: colNo,
};
}
return undefined;
};
}
return function stackParser( stack ) {
if ( matcher ) {
try {
const lines = stack.split( '\n' );
// window.fooLines = lines;
// lines.forEach(function(line, ndx) {
// origConsole.log("#", ndx, line);
// });
return matcher( lines[ lineNdx ] );
} catch ( e ) {
// do nothing
}
}
return undefined;
};
}();
function setupWorkerSupport() {
function log( data ) {
const { logType, msg } = data;
console[ logType ]( '[Worker]', msg ); /* eslint-disable-line no-console */
}
function lostContext( /* data */ ) {
addContextLostHTML();
}
function jsError( data ) {
const { url, lineNo, colNo, msg } = data;
reportJSError( url, lineNo, colNo, msg );
}
function jsErrorWithStack( data ) {
const { url, stack, msg } = data;
const errorInfo = parseStack( stack );
if ( errorInfo ) {
reportJSError( errorInfo.url || url, errorInfo.lineNo, errorInfo.colNo, msg );
} else {
console.error(errorMsg) // eslint-disable-line
}
}
const handlers = {
log,
lostContext,
jsError,
jsErrorWithStack,
};
const OrigWorker = self.Worker;
class WrappedWorker extends OrigWorker {
constructor( url, ...args ) {
super( url, ...args );
let listener;
this.onmessage = function ( e ) {
if ( ! e || ! e.data || e.data.type !== '___editor___' ) {
if ( listener ) {
listener( e );
}
return;
}
e.stopImmediatePropagation();
const data = e.data.data;
const fn = handlers[ data.type ];
if ( typeof fn !== 'function' ) {
origConsole.error( 'unknown editor msg:', data.type );
} else {
fn( data );
}
return;
};
Object.defineProperty( this, 'onmessage', {
get() {
return listener;
},
set( fn ) {
listener = fn;
},
} );
}
}
self.Worker = WrappedWorker;
}
function addContextLostHTML() {
const div = document.createElement( 'div' );
div.className = 'contextlost';
div.innerHTML = '<div>Context Lost: Click To Reload</div>';
div.addEventListener( 'click', function () {
window.location.reload();
} );
document.body.appendChild( div );
}
/**
* Gets a WebGL context.
* makes its backing store the size it is displayed.
* @param {HTMLCanvasElement} canvas a canvas element.
* @memberOf module:webgl-utils
*/
let setupLesson = function ( canvas ) {
// only once
setupLesson = function () {};
if ( canvas ) {
canvas.addEventListener( 'webglcontextlost', function () {
// the default is to do nothing. Preventing the default
// means allowing context to be restored
// e.preventDefault(); // can't do this because firefox bug - https://bugzilla.mozilla.org/show_bug.cgi?id=1633280
addContextLostHTML();
} );
/* can't do this because firefox bug - https://bugzilla.mozilla.org/show_bug.cgi?id=1633280
canvas.addEventListener('webglcontextrestored', function() {
// just reload the page. Easiest.
window.location.reload();
});
*/
}
if ( isInIFrame() ) {
updateCSSIfInIFrame();
}
};
// Replace requestAnimationFrame and cancelAnimationFrame with one
// that only executes when the body is visible (we're in an iframe).
// It's frustrating that th browsers don't do this automatically.
// It's half of the point of rAF that it shouldn't execute when
// content is not visible but browsers execute rAF in iframes even
// if they are not visible.
if ( topWindow.requestAnimationFrame ) {
topWindow.requestAnimationFrame = ( function ( oldRAF, oldCancelRAF ) {
let nextFakeRAFId = 1;
const fakeRAFIdToCallbackMap = new Map();
let rafRequestId;
let isBodyOnScreen;
function rAFHandler( time ) {
rafRequestId = undefined;
const ids = [ ...fakeRAFIdToCallbackMap.keys() ]; // WTF! Map.keys() iterates over live keys!
for ( const id of ids ) {
const callback = fakeRAFIdToCallbackMap.get( id );
fakeRAFIdToCallbackMap.delete( id );
if ( callback ) {
callback( time );
}
}
}
function startRAFIfIntersectingAndNeeded() {
if ( ! rafRequestId && isBodyOnScreen && fakeRAFIdToCallbackMap.size > 0 ) {
rafRequestId = oldRAF( rAFHandler );
}
}
function stopRAF() {
if ( rafRequestId ) {
oldCancelRAF( rafRequestId );
rafRequestId = undefined;
}
}
function initIntersectionObserver() {
const intersectionObserver = new IntersectionObserver( ( entries ) => {
entries.forEach( entry => {
isBodyOnScreen = entry.isIntersecting;
} );
if ( isBodyOnScreen ) {
startRAFIfIntersectingAndNeeded();
} else {
stopRAF();
}
} );
intersectionObserver.observe( document.body );
}
function betterRAF( callback ) {
const fakeRAFId = nextFakeRAFId ++;
fakeRAFIdToCallbackMap.set( fakeRAFId, callback );
startRAFIfIntersectingAndNeeded();
return fakeRAFId;
}
function betterCancelRAF( id ) {
fakeRAFIdToCallbackMap.delete( id );
}
topWindow.cancelAnimationFrame = betterCancelRAF;
return function ( callback ) {
// we need to lazy init this because this code gets parsed
// before body exists. We could fix it by moving lesson-helper.js
// after <body> but that would require changing 100s of examples
initIntersectionObserver();
topWindow.requestAnimationFrame = betterRAF;
return betterRAF( callback );
};
}( topWindow.requestAnimationFrame, topWindow.cancelAnimationFrame ) );
}
updateCSSIfInIFrame();
function captureJSErrors() {
// capture JavaScript Errors
window.addEventListener( 'error', function ( e ) {
const msg = e.message || e.error;
const url = e.filename;
const lineNo = e.lineno || 1;
const colNo = e.colno || 1;
reportJSError( url, lineNo, colNo, msg );
origConsole.error( e.error );
} );
}
// adapted from http://stackoverflow.com/a/2401861/128511
function getBrowser() {
const userAgent = navigator.userAgent;
let m = userAgent.match( /(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i ) || [];
if ( /trident/i.test( m[ 1 ] ) ) {
m = /\brv[ :]+(\d+)/g.exec( userAgent ) || [];
return {
name: 'IE',
version: m[ 1 ],
};
}
if ( m[ 1 ] === 'Chrome' ) {
const temp = userAgent.match( /\b(OPR|Edge)\/(\d+)/ );
if ( temp ) {
return {
name: temp[ 1 ].replace( 'OPR', 'Opera' ),
version: temp[ 2 ],
};
}
}
m = m[ 2 ] ? [ m[ 1 ], m[ 2 ] ] : [ navigator.appName, navigator.appVersion, '-?' ];
const version = userAgent.match( /version\/(\d+)/i );
if ( version ) {
m.splice( 1, 1, version[ 1 ] );
}
return {
name: m[ 0 ],
version: m[ 1 ],
};
}
const canvasesToTimeoutMap = new Map();
const isWebGLRE = /^(webgl|webgl2|experimental-webgl)$/i;
const isWebGL2RE = /^webgl2$/i;
function installWebGLLessonSetup() {
HTMLCanvasElement.prototype.getContext = ( function ( oldFn ) {
return function () {
const timeoutId = canvasesToTimeoutMap.get( this );
if ( timeoutId ) {
clearTimeout( timeoutId );
}
const type = arguments[ 0 ];
const isWebGL1or2 = isWebGLRE.test( type );
const isWebGL2 = isWebGL2RE.test( type );
if ( isWebGL1or2 ) {
setupLesson( this );
}
const args = [].slice.apply( arguments );
args[ 1 ] = {
powerPreference: 'low-power',
...args[ 1 ],
};
const ctx = oldFn.apply( this, args );
if ( ! ctx ) {
if ( isWebGL2 ) {
// three tries webgl2 then webgl1
// so wait 1/2 a second before showing the failure
// message. If we get success on the same canvas
// we'll cancel this.
canvasesToTimeoutMap.set( this, setTimeout( () => {
canvasesToTimeoutMap.delete( this );
showNeedWebGL( this );
}, 500 ) );
} else {
showNeedWebGL( this );
}
}
return ctx;
};
}( HTMLCanvasElement.prototype.getContext ) );
}
function installWebGLDebugContextCreator() {
if ( ! self.webglDebugHelper ) {
return;
}
const {
makeDebugContext,
glFunctionArgToString,
glEnumToString,
} = self.webglDebugHelper;
// capture GL errors
HTMLCanvasElement.prototype.getContext = ( function ( oldFn ) {
return function () {
let ctx = oldFn.apply( this, arguments );
// Using bindTexture to see if it's WebGL. Could check for instanceof WebGLRenderingContext
// but that might fail if wrapped by debugging extension
if ( ctx && ctx.bindTexture ) {
ctx = makeDebugContext( ctx, {
maxDrawCalls: 100,
errorFunc: function ( err, funcName, args ) {
const numArgs = args.length;
const enumedArgs = [].map.call( args, function ( arg, ndx ) {
let str = glFunctionArgToString( funcName, numArgs, ndx, arg );
// shorten because of long arrays
if ( str.length > 200 ) {
str = str.substring( 0, 200 ) + '...';
}
return str;
} );
const errorMsg = `WebGL error ${glEnumToString( err )} in ${funcName}(${enumedArgs.join( ', ' )})`;
const errorInfo = parseStack( ( new Error() ).stack );
if ( errorInfo ) {
reportJSError( errorInfo.url, errorInfo.lineNo, errorInfo.colNo, errorMsg );
} else {
console.error(errorMsg) // eslint-disable-line
}
},
} );
}
return ctx;
};
}( HTMLCanvasElement.prototype.getContext ) );
}
installWebGLLessonSetup();
if ( isInEditor() ) {
setupWorkerSupport();
setupConsole();
captureJSErrors();
if ( lessonSettings.glDebug !== false ) {
installWebGLDebugContextCreator();
}
}
return {
setupLesson: setupLesson,
showNeedWebGL: showNeedWebGL,
};
} ) );