Browse Source

handle workers in editor and use shared editor

Gregg Tavares 6 years ago
parent
commit
2f7df6f53a

+ 88 - 0
threejs/resources/editor-settings.js

@@ -0,0 +1,88 @@
+
+(function() {  // eslint-disable-line strict
+'use strict';  // eslint-disable-line strict
+
+function dirname(path) {
+  const ndx = path.lastIndexOf('/');
+  return path.substring(0, ndx + 1);
+}
+
+function getPrefix(url) {
+  const u = new URL(url, window.location.href);
+  const prefix = u.origin + dirname(u.pathname);
+  return prefix;
+}
+
+function fixSourceLinks(url, source) {
+  const srcRE = /(src=)"(.*?)"/g;
+  const linkRE = /(href=)"(.*?)"/g;
+  const imageSrcRE = /((?:image|img)\.src = )"(.*?)"/g;
+  const loaderLoadRE = /(loader\.load[a-z]*\s*\(\s*)('|")(.*?)('|")/ig;
+  const loaderArrayLoadRE = /(loader\.load[a-z]*\(\[)([\s\S]*?)(\])/ig;
+  const loadFileRE = /(loadFile\s*\(\s*)('|")(.*?)('|")/ig;
+  const threejsfundamentalsUrlRE = /(.*?)('|")(.*?)('|")(.*?)(\/\*\s+threejsfundamentals:\s+url\s+\*\/)/ig;
+  const arrayLineRE = /^(\s*["|'])([\s\S]*?)(["|']*$)/;
+  const urlPropRE = /(url:\s*)('|")(.*?)('|")/g;
+  const workerRE = /(new\s+Worker\s*\(\s*)('|")(.*?)('|")/g;
+  const importScriptsRE = /(importScripts\s*\(\s*)('|")(.*?)('|")/g;
+  const prefix = getPrefix(url);
+
+  function addPrefix(url) {
+    return url.indexOf('://') < 0 && url[0] !== '?' ? (prefix + url) : url;
+  }
+  function makeLinkFQed(match, p1, url) {
+    return p1 + '"' + addPrefix(url) + '"';
+  }
+  function makeLinkFDedQuotes(match, fn, q1, url, q2) {
+    return fn + q1 + addPrefix(url) + q2;
+  }
+  function makeTaggedFDedQuotes(match, start, q1, url, q2, suffix) {
+    return start + q1 + addPrefix(url) + q2 + suffix;
+  }
+  function makeArrayLinksFDed(match, prefix, arrayStr, suffix) {
+    const lines = arrayStr.split(',').map((line) => {
+      const m = arrayLineRE.exec(line);
+      return m
+          ? `${m[1]}${addPrefix(m[2])}${m[3]}`
+          : line;
+    });
+    return `${prefix}${lines.join(',')}${suffix}`;
+  }
+
+  source = source.replace(srcRE, makeLinkFQed);
+  source = source.replace(linkRE, makeLinkFQed);
+  source = source.replace(imageSrcRE, makeLinkFQed);
+  source = source.replace(urlPropRE, makeLinkFDedQuotes);
+  source = source.replace(loadFileRE, makeLinkFDedQuotes);
+  source = source.replace(loaderLoadRE, makeLinkFDedQuotes);
+  source = source.replace(workerRE, makeLinkFDedQuotes);
+  source = source.replace(importScriptsRE, makeLinkFDedQuotes);
+  source = source.replace(loaderArrayLoadRE, makeArrayLinksFDed);
+  source = source.replace(threejsfundamentalsUrlRE, makeTaggedFDedQuotes);
+
+  return source;
+}
+
+function extraHTMLParsing(html /* , htmlParts */) {
+  return html;
+}
+
+function fixJSForCodeSite(js) {
+  // not yet needed for three.js
+  return js;
+}
+
+window.lessonEditorSettings = {
+  extraHTMLParsing,
+  fixSourceLinks,
+  fixJSForCodeSite,
+  runOnResize: false,
+  lessonSettings: {
+    glDebug: false,
+  },
+  tags: ['three.js', 'threejsfundamentals.org'],
+  name: 'threejsfundamentals',
+  icon: '/threejs/lessons/resources/threejsfundamentals-icon-256.png',
+};
+
+}());

+ 36 - 11
threejs/resources/editor.html

@@ -44,15 +44,39 @@ iframe {
 .panes>div {
     display: none;
     flex: 1 1 50%;
-    position: relative;
-    overflow: hidden;
+    flex-direction: column;
+    min-width: 0;
     /*
     this calc is to get things to work in safari.
-    but firefox and chrome will let chidren of
-    the panes go 100% size but not safarai
+    but firefox and chrome will let children of
+    the panes go 100% size but not safari
     */
     height: calc(100vh - 2.5em - 5px - 1px);
 }
+.panes .code {
+    flex: 1 1 auto;
+    position: relative;
+    height: 100%;
+}
+.panes .code > div {
+    height: 100%;
+}
+.panes .files {
+    position: relative;
+}
+.panes .fileSelected {
+    color: white;
+    background: #666;
+}
+.panes .files > div {
+    border: 1px solid black;
+    font-family: monospace;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    width: 100%;
+    white-space: nowrap;
+    cursor: pointer;
+}
 .panes>div.other {
     display: block
 }
@@ -103,6 +127,7 @@ iframe {
     border-bottom: 5px solid black;
     margin: 0.25em;
     width: 5em;
+    cursor: pointer;
 }
 .panebuttons>button:focus {
     outline: none;
@@ -166,24 +191,24 @@ iframe {
             <button class="button-fullscreen">&nbsp;</button>
         </div>
         <div class="panelogo">
-          <div><a href="/">threejsfundamentals&nbsp;</a><a href="/"><img src="/threejs/lessons/resources/threejsfundamentals-icon-256.png" width="32" height="32"/></a></div>
+          <div><a href="/"><span data-subst="textContent|name"></span>&nbsp;</a><a href="/"><img data-subst="src|icon" width="32" height="32"/></a></div>
         </div>
     </div>
     <div class="panes">
-        <div class="js"></div>
-        <div class="html"></div>
-        <div class="css"></div>
+        <div class="js"><div class="files"></div><div class="code"></div></div>
+        <div class="html"><div class="files"></div><div class="code"></div></div>
+        <div class="css"><div class="files"></div><div class="code"></div></div>
         <div class="result"><iframe></iframe></div>
         <div class="other">
           <div>
-            <div><a href="/">threejsfundamentals&nbsp;</a><a href="/"><img src="/threejs/lessons/resources/threejsfundamentals-icon-256.png" width="64" height="64"/></a></div>
-            <div class="loading">
-            </div>
+            <div><a href="/"><span data-subst="textContent|name"></span>&nbsp;</a><a href="/"><img data-subst="src|icon" width="64" height="64"/></a></div>
+            <div class="loading"></div>
           </div>
         </div>
     </div>
 </div>
 </body>
 <script src="/monaco-editor/min/vs/loader.js"></script>
+<script src="editor-settings.js"></script>
 <script src="editor.js"></script>
 

+ 437 - 165
threejs/resources/editor.js

@@ -1,9 +1,17 @@
-(function() {  // eslint-disable-line
-'use strict';  // eslint-disable-line
+(function() {  // eslint-disable-line strict
+'use strict';  // eslint-disable-line strict
 
-/* global monaco, require */
+/* global monaco, require, lessonEditorSettings */
 
-const lessonHelperScriptRE = /<script src="[^"]+threejs-lessons-helper\.js"><\/script>/;
+const {
+  fixSourceLinks,
+  fixJSForCodeSite,
+  extraHTMLParsing,
+  runOnResize,
+  lessonSettings,
+} = lessonEditorSettings;
+
+const lessonHelperScriptRE = /<script src="[^"]+lessons-helper\.js"><\/script>/;
 
 function getQuery(s) {
   s = s === undefined ? window.location.search : s;
@@ -24,82 +32,22 @@ function getSearch(url) {
   return s < 0 ? {} : getQuery(url.substring(s));
 }
 
-const getFQUrl = (function() {
-  const a = document.createElement('a');
-  return function getFQUrl(url) {
-    a.href = url;
-    return a.href;
-  };
-}());
+function getFQUrl(path, baseUrl) {
+  const url = new URL(path, baseUrl || window.location.href);
+  return url.href;
+}
 
-function getHTML(url, callback) {
-  const req = new XMLHttpRequest();
-  req.open('GET', url, true);
-  req.addEventListener('load', function() {
-    const success = req.status === 200 || req.status === 0;
-    callback(success ? null : 'could not load: ' + url, req.responseText);
-  });
-  req.addEventListener('timeout', function() {
-    callback('timeout get: ' + url);
-  });
-  req.addEventListener('error', function() {
-    callback('error getting: ' + url);
-  });
-  req.send('');
+async function getHTML(url) {
+  const req = await fetch(url);
+  return await req.text();
 }
 
 function getPrefix(url) {
-  const u = new URL(window.location.origin + url);
+  const u = new URL(url, window.location.href);
   const prefix = u.origin + dirname(u.pathname);
   return prefix;
 }
 
-function fixSourceLinks(url, source) {
-  const srcRE = /(src=)"(.*?)"/g;
-  const linkRE = /(href=)"(.*?)"/g;
-  const imageSrcRE = /((?:image|img)\.src = )"(.*?)"/g;
-  const loaderLoadRE = /(loader\.load[a-z]*\s*\(\s*)('|")(.*?)('|")/ig;
-  const loaderArrayLoadRE = /(loader\.load[a-z]*\(\[)([\s\S]*?)(\])/ig;
-  const loadFileRE = /(loadFile\s*\(\s*)('|")(.*?)('|")/ig;
-  const threejsfundamentalsUrlRE = /(.*?)('|")(.*?)('|")(.*?)(\/\*\s+threejsfundamentals:\s+url\s+\*\/)/ig;
-  const arrayLineRE = /^(\s*["|'])([\s\S]*?)(["|']*$)/;
-  const urlPropRE = /(url:\s*)('|")(.*?)('|")/g;
-  const prefix = getPrefix(url);
-
-  function addPrefix(url) {
-    return url.indexOf('://') < 0 && url[0] !== '?' ? (prefix + url) : url;
-  }
-  function makeLinkFQed(match, p1, url) {
-    return p1 + '"' + addPrefix(url) + '"';
-  }
-  function makeLinkFDedQuotes(match, fn, q1, url, q2) {
-    return fn + q1 + addPrefix(url) + q2;
-  }
-  function makeTaggedFDedQuotes(match, start, q1, url, q2, suffix) {
-    return start + q1 + addPrefix(url) + q2 + suffix;
-  }
-  function makeArrayLinksFDed(match, prefix, arrayStr, suffix) {
-    const lines = arrayStr.split(',').map((line) => {
-      const m = arrayLineRE.exec(line);
-      return m
-          ? `${m[1]}${addPrefix(m[2])}${m[3]}`
-          : line;
-    });
-    return `${prefix}${lines.join(',')}${suffix}`;
-  }
-
-  source = source.replace(srcRE, makeLinkFQed);
-  source = source.replace(linkRE, makeLinkFQed);
-  source = source.replace(imageSrcRE, makeLinkFQed);
-  source = source.replace(urlPropRE, makeLinkFDedQuotes);
-  source = source.replace(loadFileRE, makeLinkFDedQuotes);
-  source = source.replace(loaderLoadRE, makeLinkFDedQuotes);
-  source = source.replace(loaderArrayLoadRE, makeArrayLinksFDed);
-  source = source.replace(threejsfundamentalsUrlRE, makeTaggedFDedQuotes);
-
-  return source;
-}
-
 function fixCSSLinks(url, source) {
   const cssUrlRE = /(url\()(.*?)(\))/g;
   const prefix = getPrefix(url);
@@ -115,19 +63,59 @@ function fixCSSLinks(url, source) {
   return source;
 }
 
+/**
+ * @typedef {Object} Globals
+ * @property {SourceInfo} rootScriptInfo
+ * @property {Object<string, SourceInfo} scriptInfos
+ */
+
+/** @type {Globals} */
 const g = {
   html: '',
 };
 
+/**
+ * This is what's in the sources array
+ * @typedef {Object} SourceInfo
+ * @property {string} source The source text (html, css, js)
+ * @property {string} name The filename or "main page"
+ * @property {ScriptInfo} scriptInfo The associated ScriptInfo
+ * @property {string} fqURL ??
+ * @property {Editor} editor in instance of Monaco editor
+ *
+ */
+
+/**
+ * @typedef {Object} EditorInfo
+ * @property {HTMLElement} div The div holding the monaco editor
+ * @property {Editor} editor an instance of a monaco editor
+ */
+
+/**
+ * What's under each language
+ * @typedef {Object} HTMLPart
+ * @property {string} language Name of language
+ * @property {SourceInfo} sources array of SourceInfos. Usually 1 for HTML, 1 for CSS, N for JS
+ * @property {HTMLElement} pane the pane for these editors
+ * @property {HTMLElement} code the div holding the files
+ * @property {HTMLElement} files the div holding the divs holding the monaco editors
+ * @property {HTMLElement} button the element to click to show this pane
+ * @property {EditorInfo} editors
+ */
+
+/** @type {Object<string, HTMLPart>} */
 const htmlParts = {
   js: {
     language: 'javascript',
+    sources: [],
   },
   css: {
     language: 'css',
+    sources: [],
   },
   html: {
     language: 'html',
+    sources: [],
   },
 };
 
@@ -138,7 +126,6 @@ function forEachHTMLPart(fn) {
   });
 }
 
-
 function getHTMLPart(re, obj, tag) {
   let part = '';
   obj.html = obj.html.replace(re, function(p0, p1) {
@@ -148,7 +135,86 @@ function getHTMLPart(re, obj, tag) {
   return part.replace(/\s*/, '');
 }
 
-function parseHTML(url, html) {
+// doesn't handle multi-line comments or comments with { or } in them
+function formatCSS(css) {
+  let indent = '';
+  return css.split('\n').map((line) => {
+    let currIndent = indent;
+    if (line.includes('{')) {
+      indent = indent + '  ';
+    } else if (line.includes('}')) {
+      indent = indent.substring(0, indent.length - 2);
+      currIndent = indent;
+    }
+    return `${currIndent}${line.trim()}`;
+  }).join('\n');
+}
+
+async function getScript(url, scriptInfos) {
+  // check it's an example script, not some other lib
+  if (!scriptInfos[url].source) {
+    const source = await getHTML(url);
+    const fixedSource = fixSourceLinks(url, source);
+    const {text} = await getWorkerScripts(fixedSource, url, scriptInfos);
+    scriptInfos[url].source = text;
+  }
+}
+
+/**
+ * @typedef {Object} ScriptInfo
+ * @property {string} fqURL The original fully qualified URL
+ * @property {ScriptInfo[]} deps Array of other ScriptInfos this is script dependant on
+ * @property {boolean} isWorker True if this script came from `new Worker('someurl')` vs `import` or `importScripts`
+ * @property {string} blobUrl The blobUrl for this script if one has been made
+ * @property {number} blobGenerationId Used to not visit things twice while recursing.
+ * @property {string} source The source as extracted. Updated from editor by getSourcesFromEditor
+ * @property {string} munged The source after urls have been replaced with blob urls etc... (the text send to new Blob)
+ */
+
+async function getWorkerScripts(text, baseUrl, scriptInfos = {}) {
+  const parentScriptInfo = scriptInfos[baseUrl];
+  const workerRE = /(new\s+Worker\s*\(\s*)('|")(.*?)('|")/g;
+  const importScriptsRE = /(importScripts\s*\(\s*)('|")(.*?)('|")/g;
+
+  const newScripts = [];
+  const slashRE = /\/threejs\/[^/]+$/;
+
+  function replaceWithUUID(match, prefix, quote, url) {
+    const fqURL = getFQUrl(url, baseUrl);
+    if (!slashRE.test(fqURL)) {
+      return match.toString();
+    }
+
+    if (!scriptInfos[url]) {
+      scriptInfos[fqURL] = {
+        fqURL,
+        deps: [],
+        isWorker: prefix.indexOf('Worker') >= 0,
+      };
+      newScripts.push(fqURL);
+    }
+    parentScriptInfo.deps.push(scriptInfos[fqURL]);
+
+    return `${prefix}${quote}${fqURL}${quote}`;
+  }
+
+  text = text.replace(workerRE, replaceWithUUID);
+  text = text.replace(importScriptsRE, replaceWithUUID);
+
+  await Promise.all(newScripts.map((url) => {
+    return getScript(url, scriptInfos);
+  }));
+
+  return {text, scriptInfos};
+}
+
+// hack: scriptInfo is undefined for html and css
+// should try to include html and css in scriptInfos
+function addSource(type, name, source, scriptInfo) {
+  htmlParts[type].sources.push({source, name, scriptInfo});
+}
+
+async function parseHTML(url, html) {
   html = fixSourceLinks(url, html);
 
   html = html.replace(/<div class="description">[^]*?<\/div>/, '');
@@ -164,32 +230,49 @@ function parseHTML(url, html) {
   const hrefRE = /href="([^"]+)"/;
 
   const obj = { html: html };
-  htmlParts.css.source = fixCSSLinks(url, getHTMLPart(styleRE, obj, '<style>\n${css}</style>'));
-  htmlParts.html.source = getHTMLPart(bodyRE, obj, '<body>${html}</body>');
-  htmlParts.js.source = getHTMLPart(inlineScriptRE, obj, '<script>${js}</script>');
+  addSource('css', 'css', formatCSS(fixCSSLinks(url, getHTMLPart(styleRE, obj, '<style>\n${css}</style>'))));
+  addSource('html', 'html', getHTMLPart(bodyRE, obj, '<body>${html}</body>'));
+  const rootScript = getHTMLPart(inlineScriptRE, obj, '<script>${js}</script>');
   html = obj.html;
 
+  const fqURL = getFQUrl(url);
+  /** @type Object<string, SourceInfo> */
+  const scriptInfos = {};
+  g.rootScriptInfo = {
+    fqURL,
+    deps: [],
+    source: rootScript,
+  };
+  scriptInfos[fqURL] = g.rootScriptInfo;
+
+  const {text} = await getWorkerScripts(rootScript, fqURL, scriptInfos);
+  g.rootScriptInfo.source = text;
+  g.scriptInfos = scriptInfos;
+  for (const [fqURL, scriptInfo] of Object.entries(scriptInfos)) {
+    addSource('js', basename(fqURL), scriptInfo.source, scriptInfo);
+  }
+
   const tm = titleRE.exec(html);
   if (tm) {
     g.title = tm[1];
   }
 
-  let scripts = '';
+  const scripts = [];
   html = html.replace(externalScriptRE, function(p0, p1, p2) {
     p1 = p1 || '';
-    scripts += '\n' + p1 + '<script src="' + p2 + '"></script>';
+    scripts.push(`${p1}<script src="${p2}"></script>`);
     return '';
   });
 
-  let dataScripts = '';
+  const dataScripts = [];
   html = html.replace(dataScriptRE, function(p0, p1, p2, p3) {
     p1 = p1 || '';
-    dataScripts += '\n' + p1 + '<script ' + p2 + '>' + p3 + '</script>';
+    dataScripts.push(`${p1}<script ${p2}>${p3}</script>`);
     return '';
   });
 
-  htmlParts.html.source += dataScripts;
-  htmlParts.html.source += scripts + '\n';
+  htmlParts.html.sources[0].source += dataScripts.join('\n');
+  htmlParts.html.sources[0].source += scripts.join('\n');
 
   // add style section if there is non
   if (html.indexOf('${css}') < 0) {
@@ -201,6 +284,8 @@ function parseHTML(url, html) {
   // query params but that only works in Firefox >:(
   html = html.replace('</head>', '<script id="hackedparams">window.hackedParams = ${hackedParams}\n</script>\n</head>');
 
+  html = extraHTMLParsing(html, htmlParts);
+
   let links = '';
   html = html.replace(cssLinkRE, function(p0, p1) {
     if (isCSSLinkRE.test(p1)) {
@@ -214,7 +299,7 @@ function parseHTML(url, html) {
     }
   });
 
-  htmlParts.css.source = links + htmlParts.css.source;
+  htmlParts.css.sources[0].source = links + htmlParts.css.sources[0].source;
 
   g.html = html;
 }
@@ -224,62 +309,116 @@ function cantGetHTML(e) {  // eslint-disable-line
   console.log("TODO: don't run editor if can't get HTML");  // eslint-disable-line
 }
 
-function main() {
+async function main() {
   const query = getQuery();
   g.url = getFQUrl(query.url);
   g.query = getSearch(g.url);
-  getHTML(query.url, function(err, html) {
-    if (err) {
-      console.log(err);  // eslint-disable-line
-      return;
-    }
-    parseHTML(query.url, html);
-    setupEditor(query.url);
-    if (query.startPane) {
-      const button = document.querySelector('.button-' + query.startPane);
-      toggleSourcePane(button);
-    }
-  });
+  let html;
+  try {
+    html = await getHTML(query.url);
+  } catch (err) {
+    console.log(err);  // eslint-disable-line
+    return;
+  }
+  await parseHTML(query.url, html);
+  setupEditor(query.url);
+  if (query.startPane) {
+    const button = document.querySelector('.button-' + query.startPane);
+    toggleSourcePane(button);
+  }
+}
+
+function getJavaScriptBlob(source) {
+  const blob = new Blob([source], {type: 'application/javascript'});
+  return URL.createObjectURL(blob);
 }
 
+let blobGeneration = 0;
+function makeBlobURLsForSources(scriptInfo) {
+  ++blobGeneration;
 
-let blobUrl;
-function getSourceBlob(htmlParts, options) {
-  options = options || {};
-  if (blobUrl) {
-    URL.revokeObjectURL(blobUrl);
+  function makeBlobURLForSourcesImpl(scriptInfo) {
+    if (scriptInfo.blobGenerationId !== blobGeneration) {
+      scriptInfo.blobGenerationId = blobGeneration;
+      if (scriptInfo.blobUrl) {
+        URL.revokeObjectURL(scriptInfo.blobUrl);
+      }
+      scriptInfo.deps.forEach(makeBlobURLForSourcesImpl);
+      let text = scriptInfo.source;
+      scriptInfo.deps.forEach((depScriptInfo) => {
+        text = text.split(depScriptInfo.fqURL).join(depScriptInfo.blobUrl);
+      });
+      scriptInfo.numLinesBeforeScript = 0;
+      if (scriptInfo.isWorker) {
+        const extra = `self.lessonSettings = ${JSON.stringify(lessonSettings)};
+importScripts('${dirname(scriptInfo.fqURL)}/resources/lessons-worker-helper.js')`;
+        scriptInfo.numLinesBeforeScript = extra.split('\n').length;
+        text = `${extra}\n${text}`;
+      }
+      scriptInfo.blobUrl = getJavaScriptBlob(text);
+      scriptInfo.munged = text;
+    }
   }
+  makeBlobURLForSourcesImpl(scriptInfo);
+}
+
+function getSourceBlob(htmlParts) {
+  g.rootScriptInfo.source = htmlParts.js;
+  makeBlobURLsForSources(g.rootScriptInfo);
+
   const prefix = dirname(g.url);
   let source = g.html;
   source = source.replace('${hackedParams}', JSON.stringify(g.query));
   source = source.replace('${html}', htmlParts.html);
   source = source.replace('${css}', htmlParts.css);
-  source = source.replace('${js}', htmlParts.js);
-  source = source.replace('<head>', '<head>\n<script match="false">threejsLessonSettings = ' + JSON.stringify(options) + ';</script>');
+  source = source.replace('${js}', g.rootScriptInfo.munged); //htmlParts.js);
+  source = source.replace('<head>', `<head>
+  <link rel="stylesheet" href="${prefix}/resources/lesson-helper.css" type="text/css">
+  <script match="false">self.lessonSettings = ${JSON.stringify(lessonSettings)}</script>`);
 
-  source = source.replace('</head>', '<script src="' + prefix + '/resources/threejs-lessons-helper.js"></script>\n</head>');
+  source = source.replace('</head>', `<script src="${prefix}/resources/lessons-helper.js"></script>
+  </head>`);
   const scriptNdx = source.indexOf('<script>');
-  g.numLinesBeforeScript = (source.substring(0, scriptNdx).match(/\n/g) || []).length;
+  g.rootScriptInfo.numLinesBeforeScript = (source.substring(0, scriptNdx).match(/\n/g) || []).length;
 
   const blob = new Blob([source], {type: 'text/html'});
-  blobUrl = URL.createObjectURL(blob);
+  // This seems hacky. We are combining html/css/js into one html blob but we already made
+  // a blob for the JS so let's replace that blob. That means it will get auto-released when script blobs
+  // are regenerated. It also means error reporting will work
+  const blobUrl = URL.createObjectURL(blob);
+  URL.revokeObjectURL(g.rootScriptInfo.blobUrl);
+  g.rootScriptInfo.blobUrl = blobUrl;
   return blobUrl;
 }
 
-function getSourceBlobFromEditor(options) {
+function getSourcesFromEditor() {
+  for (const partTypeInfo of Object.values(htmlParts)) {
+    for (const source of partTypeInfo.sources) {
+      source.source = source.editor.getValue();
+      // hack: shouldn't store this twice. Also see other comment,
+      // should consolidate so scriptInfo is used for css and html
+      if (source.scriptInfo) {
+        source.scriptInfo.source = source.source;
+      }
+    }
+  }
+}
+function getSourceBlobFromEditor() {
+  getSourcesFromEditor();
+
   return getSourceBlob({
-    html: htmlParts.html.editor.getValue(),
-    css: htmlParts.css.editor.getValue(),
-    js: htmlParts.js.editor.getValue(),
-  }, options);
+    html: htmlParts.html.sources[0].source,
+    css: htmlParts.css.sources[0].source,
+    js: htmlParts.js.sources[0].source,
+  });
 }
 
-function getSourceBlobFromOrig(options) {
+function getSourceBlobFromOrig() {
   return getSourceBlob({
-    html: htmlParts.html.source,
-    css: htmlParts.css.source,
-    js: htmlParts.js.source,
-  }, options);
+    html: htmlParts.html.sources[0].source,
+    css: htmlParts.css.sources[0].source,
+    js: htmlParts.js.sources[0].source,
+  });
 }
 
 function dirname(path) {
@@ -287,30 +426,99 @@ function dirname(path) {
   return path.substring(0, ndx + 1);
 }
 
+function basename(path) {
+  const ndx = path.lastIndexOf('/');
+  return path.substring(ndx + 1);
+}
+
 function resize() {
   forEachHTMLPart(function(info) {
-    info.editor.layout();
+    info.editors.forEach((editorInfo) => {
+      editorInfo.editor.layout();
+    });
   });
 }
 
-function addCORSSupport(js) {
-  // not yet needed for three.js
-  return js;
+function makeScriptsForWorkers(scriptInfo) {
+  ++blobGeneration;
+
+  function makeScriptsForWorkersImpl(scriptInfo) {
+    const scripts = [];
+    if (scriptInfo.blobGenerationId !== blobGeneration) {
+      scriptInfo.blobGenerationId = blobGeneration;
+      scripts.push(...scriptInfo.deps.map(makeScriptsForWorkersImpl).flat());
+      let text = scriptInfo.source;
+      scriptInfo.deps.forEach((depScriptInfo) => {
+        text = text.split(depScriptInfo.fqURL).join(`worker-${basename(depScriptInfo.fqURL)}`);
+      });
+
+      scripts.push({
+        name: `worker-${basename(scriptInfo.fqURL)}`,
+        text,
+      });
+    }
+    return scripts;
+  }
+
+  const scripts = makeScriptsForWorkersImpl(scriptInfo);
+  const mainScript = scripts.pop().text;
+  if (!scripts.length) {
+    return {
+      js: mainScript,
+      html: '',
+    };
+  }
+
+  const workerName = scripts[scripts.length - 1].name;
+  const html = scripts.map((nameText) => {
+    const {name, text} = nameText;
+    return `<script id="${name}" type="x-worker">\n${text}\n</script>`;
+  }).join('\n');
+  const init = `
+
+
+
+// ------
+// Creates Blobs for the Worker Scripts so things can be self contained for snippets/JSFiddle/Codepen
+//
+function getWorkerBlob() {
+  const idsToUrls = [];
+  const scriptElements = [...document.querySelectorAll('script[type=x-worker]')];
+  for (const scriptElement of scriptElements) {
+    let text = scriptElement.text;
+    for (const {id, url} of idsToUrls) {
+      text = text.split(id).join(url);
+    }
+    const blob = new Blob([text], {type: 'application/javascript'});
+    const url = URL.createObjectURL(blob);
+    const id = scriptElement.id;
+    idsToUrls.push({id, url});
+  }
+  return idsToUrls.pop().url;
+}
+`;
+  return {
+    js: mainScript.split(`'${workerName}'`).join('getWorkerBlob()') + init,
+    html,
+  };
 }
 
 function openInCodepen() {
   const comment = `// ${g.title}
 // from ${g.url}
 
+
   `;
+  getSourcesFromEditor();
+  const scripts = makeScriptsForWorkers(g.rootScriptInfo);
   const pen = {
     title                 : g.title,
     description           : 'from: ' + g.url,
-    tags                  : ['three.js', 'threejsfundamentals.org'],
+    tags                  : lessonEditorSettings.tags,
     editors               : '101',
-    html                  : htmlParts.html.editor.getValue().replace(lessonHelperScriptRE, ''),
-    css                   : htmlParts.css.editor.getValue(),
-    js                    : comment + addCORSSupport(htmlParts.js.editor.getValue()),
+    html                  : scripts.html + htmlParts.html.sources[0].source.replace(lessonHelperScriptRE, ''),
+    css                   : htmlParts.css.sources[0].source,
+    js                    : comment + fixJSForCodeSite(scripts.js),
   };
 
   const elem = document.createElement('div');
@@ -331,15 +539,9 @@ function openInJSFiddle() {
 // from ${g.url}
 
   `;
-  // const pen = {
-  //   title                 : g.title,
-  //   description           : "from: " + g.url,
-  //   tags                  : ["three.js", "threejsfundamentals.org"],
-  //   editors               : "101",
-  //   html                  : htmlParts.html.editor.getValue(),
-  //   css                   : htmlParts.css.editor.getValue(),
-  //   js                    : comment + htmlParts.js.editor.getValue(),
-  // };
+
+  getSourcesFromEditor();
+  const scripts = makeScriptsForWorkers(g.rootScriptInfo);
 
   const elem = document.createElement('div');
   elem.innerHTML = `
@@ -352,24 +554,60 @@ function openInJSFiddle() {
       <input type="submit" />
     </form>
   `;
-  elem.querySelector('input[name=html]').value = htmlParts.html.editor.getValue().replace(lessonHelperScriptRE, '');
-  elem.querySelector('input[name=css]').value = htmlParts.css.editor.getValue();
-  elem.querySelector('input[name=js]').value = comment + addCORSSupport(htmlParts.js.editor.getValue());
+  elem.querySelector('input[name=html]').value = scripts.html + htmlParts.html.sources[0].source.replace(lessonHelperScriptRE, '');
+  elem.querySelector('input[name=css]').value = htmlParts.css.sources[0].source;
+  elem.querySelector('input[name=js]').value = comment + fixJSForCodeSite(scripts.js);
   elem.querySelector('input[name=title]').value = g.title;
   window.frameElement.ownerDocument.body.appendChild(elem);
   elem.querySelector('form').submit();
   window.frameElement.ownerDocument.body.removeChild(elem);
 }
 
+function selectFile(info, ndx, fileDivs) {
+  if (info.editors.length <= 1) {
+    return;
+  }
+  info.editors.forEach((editorInfo, i) => {
+    const selected = i === ndx;
+    editorInfo.div.style.display = selected ? '' : 'none';
+    editorInfo.editor.layout();
+    addRemoveClass(fileDivs.children[i], 'fileSelected', selected);
+  });
+}
+
+function showEditorSubPane(type, ndx) {
+  const info = htmlParts[type];
+  selectFile(info, ndx, info.files);
+}
+
 function setupEditor() {
 
   forEachHTMLPart(function(info, ndx, name) {
-    info.parent = document.querySelector('.panes>.' + name);
-    info.editor = runEditor(info.parent, info.source, info.language);
+    info.pane = document.querySelector('.panes>.' + name);
+    info.code = info.pane.querySelector('.code');
+    info.files = info.pane.querySelector('.files');
+    info.editors = info.sources.map((sourceInfo, ndx) => {
+      if (info.sources.length > 1) {
+        const div = document.createElement('div');
+        div.textContent = basename(sourceInfo.name);
+        info.files.appendChild(div);
+        div.addEventListener('click', () => {
+          selectFile(info, ndx, info.files);
+        });
+      }
+      const div = document.createElement('div');
+      info.code.appendChild(div);
+      const editor = runEditor(div, sourceInfo.source, info.language);
+      sourceInfo.editor = editor;
+      return {
+        div,
+        editor,
+      };
+    });
     info.button = document.querySelector('.button-' + name);
     info.button.addEventListener('click', function() {
       toggleSourcePane(info.button);
-      run();
+      runIfNeeded();
     });
   });
 
@@ -389,7 +627,7 @@ function setupEditor() {
   g.resultButton = document.querySelector('.button-result');
   g.resultButton.addEventListener('click', function() {
      toggleResultPane();
-     run();
+     runIfNeeded();
   });
   g.result.style.display = 'none';
   toggleResultPane();
@@ -400,23 +638,30 @@ function setupEditor() {
 
   window.addEventListener('resize', resize);
 
+  showEditorSubPane('js', 0);
   showOtherIfAllPanesOff();
   document.querySelector('.other .loading').style.display = 'none';
 
   resize();
-  run({glDebug: false});
+  run();
 }
 
 function toggleFullscreen() {
   try {
     toggleIFrameFullscreen(window);
     resize();
-    run();
+    runIfNeeded();
   } catch (e) {
     console.error(e);  // eslint-disable-line
   }
 }
 
+function runIfNeeded() {
+  if (runOnResize) {
+    run();
+  }
+}
+
 function run(options) {
   g.setPosition = false;
   const url = getSourceBlobFromEditor(options);
@@ -478,11 +723,11 @@ function toggleSourcePane(pressedButton) {
     const pressed = pressedButton === info.button;
     if (pressed && !info.showing) {
       addClass(info.button, 'show');
-      info.parent.style.display = 'block';
+      info.pane.style.display = 'flex';
       info.showing = true;
     } else {
       removeClass(info.button, 'show');
-      info.parent.style.display = 'none';
+      info.pane.style.display = 'none';
       info.showing = false;
     }
   });
@@ -509,19 +754,35 @@ function showOtherIfAllPanesOff() {
   g.other.style.display = paneOn ? 'none' : 'block';
 }
 
-function getActualLineNumberAndMoveTo(lineNo, colNo) {
-  const actualLineNo = lineNo - g.numLinesBeforeScript;
-  if (!g.setPosition) {
-    // Only set the first position
-    g.setPosition = true;
-    htmlParts.js.editor.setPosition({
-      lineNumber: actualLineNo,
-      column: colNo,
-    });
-    htmlParts.js.editor.revealLineInCenterIfOutsideViewport(actualLineNo);
-    htmlParts.js.editor.focus();
+// seems like we should probably store a map
+function getEditorNdxByBlobUrl(type, url) {
+  return htmlParts[type].sources.findIndex(source => source.scriptInfo.blobUrl === url);
+}
+
+function getActualLineNumberAndMoveTo(url, lineNo, colNo) {
+  let origUrl = url;
+  let actualLineNo = lineNo;
+  const scriptInfo = Object.values(g.scriptInfos).find(scriptInfo => scriptInfo.blobUrl === url);
+  if (scriptInfo) {
+    actualLineNo = lineNo - scriptInfo.numLinesBeforeScript;
+    origUrl = basename(scriptInfo.fqURL);
+    if (!g.setPosition) {
+      // Only set the first position
+      g.setPosition = true;
+      const editorNdx = getEditorNdxByBlobUrl('js', url);
+      if (editorNdx >= 0) {
+        showEditorSubPane('js', editorNdx);
+        const editor = htmlParts.js.editors[editorNdx].editor;
+        editor.setPosition({
+          lineNumber: actualLineNo,
+          column: colNo,
+        });
+        editor.revealLineInCenterIfOutsideViewport(actualLineNo);
+        editor.focus();
+      }
+    }
   }
-  return actualLineNo;
+  return {origUrl, actualLineNo};
 }
 
 window.getActualLineNumberAndMoveTo = getActualLineNumberAndMoveTo;
@@ -539,17 +800,27 @@ function runEditor(parent, source, language) {
   });
 }
 
-function runAsBlob() {
+async function runAsBlob() {
   const query = getQuery();
   g.url = getFQUrl(query.url);
   g.query = getSearch(g.url);
-  getHTML(query.url, function(err, html) {
-    if (err) {
-      console.log(err);  // eslint-disable-line
-      return;
-    }
-    parseHTML(query.url, html);
-    window.location.href = getSourceBlobFromOrig();
+  let html;
+  try {
+    html = await getHTML(query.url);
+  } catch (err) {
+    console.log(err);  // eslint-disable-line
+    return;
+  }
+  await parseHTML(query.url, html);
+  window.location.href = getSourceBlobFromOrig();
+}
+
+function applySubstitutions() {
+  [...document.querySelectorAll('[data-subst]')].forEach((elem) => {
+    elem.dataset.subst.split('&').forEach((pair) => {
+      const [attr, key] = pair.split('|');
+      elem[attr] = lessonEditorSettings[key];
+    });
   });
 }
 
@@ -562,6 +833,7 @@ function start() {
     // var url = query.url;
     // window.location.href = url;
   } else {
+    applySubstitutions();
     require.config({ paths: { 'vs': '/monaco-editor/min/vs' }});
     require(['vs/editor/editor.main'], main);
   }

+ 19 - 0
threejs/resources/lesson-helper.css

@@ -0,0 +1,19 @@
+.contextlost {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100vw;
+  height: 100vh;
+  background: red;
+  color: white;
+  font-family: monospace;
+  font-weight: bold;
+  display: flex;
+  align-items: center;
+  justify-content: center;  
+}
+.contextlost>div {
+  padding: 0.5em;
+  background: darkred;
+  cursor: pointer;
+}

+ 183 - 97
threejs/resources/threejs-lessons-helper.js → threejs/resources/lessons-helper.js

@@ -39,11 +39,12 @@
     });
   } else {
     // Browser globals
-    root.threejsLessonsHelper = factory.call(root);
+    root.lessonsHelper = factory.call(root);
   }
 }(this, function() {
   'use strict';  // eslint-disable-line
 
+  const lessonSettings = window.lessonSettings || {};
   const topWindow = this;
 
   /**
@@ -204,28 +205,186 @@
     window.console.error = wrapFunc(window.console, 'error', 'red', '❌');
   }
 
+  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) {
+        super(url);
+        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 (!fn) {
+            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.
+   * @param {module:webgl-utils.GetWebGLContextOptions} [opt_options] options
    * @memberOf module:webgl-utils
    */
-  let setupLesson = function(canvas) {
+  let setupLesson = function(canvas, opt_options) {
     // only once
     setupLesson = function() {};
 
+    const options = opt_options || {};
+
     if (canvas) {
       canvas.addEventListener('webglcontextlost', function(e) {
           // the default is to do nothing. Preventing the default
           // means allowing context to be restored
           e.preventDefault();
-          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);
+          addContextLostHTML();
       });
       canvas.addEventListener('webglcontextrestored', function() {
           // just reload the page. Easiest.
@@ -633,8 +792,9 @@
    */
   function glEnumToString(value) {
     const name = glEnums[value];
-    return (name !== undefined) ? ('gl.' + name) :
-        ('/*UNKNOWN WebGL ENUM*/ 0x' + value.toString(16) + '');
+    return (name !== undefined)
+        ? `gl.${name}`
+        : `/*UNKNOWN WebGL ENUM*/ 0x${value.toString(16)}`;
   }
 
   /**
@@ -727,8 +887,7 @@
     options.sharedState = sharedState;
 
     const errorFunc = options.errorFunc || function(err, functionName, args) {
-      console.error("WebGL error " + glEnumToString(err) + " in " + functionName +  // eslint-disable-line
-          '(' + glFunctionArgsToString(functionName, args) + ')');
+      console.error(`WebGL error ${glEnumToString(err)} in ${functionName}(${glFunctionArgsToString(functionName, args)})`);  /* eslint-disable-line no-console */
     };
 
     // Holds booleans for each GL error so after we get the error ourselves
@@ -785,7 +944,7 @@
           ext = wrapped.apply(ctx, arguments);
           if (ext) {
             const origExt = ext;
-            ext = makeDebugContext(ext, options);
+            ext = makeDebugContext(ext, Object.assign({}, options, {errCtx: ctx}));
             sharedState.wrappers[extensionName] = { wrapper: ext, orig: origExt };
             addEnumsForContext(origExt, extensionName);
           }
@@ -839,17 +998,9 @@
     window.addEventListener('error', function(e) {
       const msg = e.message || e.error;
       const url = e.filename;
-      let lineNo = e.lineno || 1;
+      const lineNo = e.lineno || 1;
       const colNo = e.colno || 1;
-      const isUserScript = (url === window.location.href);
-      if (isUserScript) {
-        try {
-          lineNo = window.parent.getActualLineNumberAndMoveTo(lineNo, colNo);
-        } catch (ex) {
-          origConsole.error(ex);
-        }
-      }
-      console.error("line:", lineNo, ":", msg);  // eslint-disable-line
+      reportJSError(url, lineNo, colNo, msg);
       origConsole.error(e.error);
     });
   }
@@ -927,79 +1078,13 @@
                 }
                 return str;
               });
-
-              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;
-                };
+              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
               }
-
-              let lineInfo = '';
-              if (matcher) {
-                try {
-                  const error = new Error();
-                  const lines = error.stack.split('\n');
-                  // window.fooLines = lines;
-                  // lines.forEach(function(line, ndx) {
-                  //   origConsole.log("#", ndx, line);
-                  // });
-                  const info = matcher(lines[lineNdx]);
-                  if (info) {
-                    let lineNo = info.lineNo;
-                    const colNo = info.colNo;
-                    const url = info.url;
-                    const isUserScript = (url === window.location.href);
-                    if (isUserScript) {
-                      lineNo = window.parent.getActualLineNumberAndMoveTo(lineNo, colNo);
-                    }
-                    lineInfo = ' line:' + lineNo + ':' + colNo;
-                  }
-                } catch (e) {
-                  origConsole.error(e);
-                }
-              }
-
-              console.error(  // eslint-disable-line
-                  'WebGL error' + lineInfo, glEnumToString(err), 'in',
-                  funcName, '(', enumedArgs.join(', '), ')');
-
             },
           });
         }
@@ -1011,9 +1096,10 @@
   installWebGLLessonSetup();
 
   if (isInEditor()) {
+    setupWorkerSupport();
     setupConsole();
     captureJSErrors();
-    if (window.threejsLessonSettings === undefined || window.threejsLessonSettings.glDebug !== false) {
+    if (lessonSettings.glDebug !== false) {
       installWebGLDebugContextCreator();
     }
   }

+ 699 - 0
threejs/resources/lessons-worker-helper.js

@@ -0,0 +1,699 @@
+/*
+ * Copyright 2019, 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 */
+'use strict';
+
+(function() {
+
+  const lessonSettings = self.lessonSettings || {};
+
+  function isInEditor() {
+    return self.location.href.substring(0, 4) === 'blob';
+  }
+
+  function sendMessage(data) {
+    self.postMessage({
+      type: '__editor__',
+      data,
+    });
+  }
+
+  const origConsole = {};
+
+  function setupConsole() {
+    function wrapFunc(obj, logType) {
+      const origFunc = obj[logType].bind(obj);
+      origConsole[logType] = origFunc;
+      return function(...args) {
+        origFunc(...args);
+        sendMessage({
+          type: 'log',
+          logType,
+          msg: [...args].join(' '),
+        });
+      };
+    }
+    self.console.log = wrapFunc(self.console, 'log');
+    self.console.warn = wrapFunc(self.console, 'warn');
+    self.console.error = wrapFunc(self.console, 'error');
+  }
+
+  /**
+   * Gets a WebGL context.
+   * makes its backing store the size it is displayed.
+   * @param {OffscreenCanvas} canvas a canvas element.
+   * @memberOf module:webgl-utils
+   */
+  let setupLesson = function(canvas) {
+    // only once
+    setupLesson = function() {};
+
+    if (canvas) {
+      canvas.addEventListener('webglcontextlost', function(e) {
+          // the default is to do nothing. Preventing the default
+          // means allowing context to be restored
+          e.preventDefault();
+          sendMessage({
+            type: 'lostContext',
+          });
+      });
+    }
+
+  };
+
+
+  //------------ [ from https://github.com/KhronosGroup/WebGLDeveloperTools ]
+
+  /*
+  ** Copyright (c) 2012 The Khronos Group Inc.
+  **
+  ** Permission is hereby granted, free of charge, to any person obtaining a
+  ** copy of this software and/or associated documentation files (the
+  ** "Materials"), to deal in the Materials without restriction, including
+  ** without limitation the rights to use, copy, modify, merge, publish,
+  ** distribute, sublicense, and/or sell copies of the Materials, and to
+  ** permit persons to whom the Materials are 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 Materials.
+  **
+  ** THE MATERIALS ARE 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
+  ** MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS.
+  */
+
+  /**
+   * Types of contexts we have added to map
+   */
+  const mappedContextTypes = {};
+
+  /**
+   * Map of numbers to names.
+   * @type {Object}
+   */
+  const glEnums = {};
+
+  /**
+   * Map of names to numbers.
+   * @type {Object}
+   */
+  const enumStringToValue = {};
+
+  /**
+   * Initializes this module. Safe to call more than once.
+   * @param {!WebGLRenderingContext} ctx A WebGL context. If
+   *    you have more than one context it doesn't matter which one
+   *    you pass in, it is only used to pull out constants.
+   */
+  function addEnumsForContext(ctx, type) {
+    if (!mappedContextTypes[type]) {
+      mappedContextTypes[type] = true;
+      for (const propertyName in ctx) {
+        if (typeof ctx[propertyName] === 'number') {
+          glEnums[ctx[propertyName]] = propertyName;
+          enumStringToValue[propertyName] = ctx[propertyName];
+        }
+      }
+    }
+  }
+
+  function enumArrayToString(enums) {
+    const enumStrings = [];
+    if (enums.length) {
+      for (let i = 0; i < enums.length; ++i) {
+        enums.push(glEnumToString(enums[i]));  // eslint-disable-line
+      }
+      return '[' + enumStrings.join(', ') + ']';
+    }
+    return enumStrings.toString();
+  }
+
+  function makeBitFieldToStringFunc(enums) {
+    return function(value) {
+      let orResult = 0;
+      const orEnums = [];
+      for (let i = 0; i < enums.length; ++i) {
+        const enumValue = enumStringToValue[enums[i]];
+        if ((value & enumValue) !== 0) {
+          orResult |= enumValue;
+          orEnums.push(glEnumToString(enumValue));  // eslint-disable-line
+        }
+      }
+      if (orResult === value) {
+        return orEnums.join(' | ');
+      } else {
+        return glEnumToString(value);  // eslint-disable-line
+      }
+    };
+  }
+
+  const destBufferBitFieldToString = makeBitFieldToStringFunc([
+    'COLOR_BUFFER_BIT',
+    'DEPTH_BUFFER_BIT',
+    'STENCIL_BUFFER_BIT',
+  ]);
+
+  /**
+   * Which arguments are enums based on the number of arguments to the function.
+   * So
+   *    'texImage2D': {
+   *       9: { 0:true, 2:true, 6:true, 7:true },
+   *       6: { 0:true, 2:true, 3:true, 4:true },
+   *    },
+   *
+   * means if there are 9 arguments then 6 and 7 are enums, if there are 6
+   * arguments 3 and 4 are enums. Maybe a function as well in which case
+   * value is passed to function and returns a string
+   *
+   * @type {!Object.<number, (!Object.<number, string>|function)}
+   */
+  const glValidEnumContexts = {
+    // Generic setters and getters
+
+    'enable': {1: { 0:true }},
+    'disable': {1: { 0:true }},
+    'getParameter': {1: { 0:true }},
+
+    // Rendering
+
+    'drawArrays': {3:{ 0:true }},
+    'drawElements': {4:{ 0:true, 2:true }},
+    'drawArraysInstanced': {4: { 0:true }},
+    'drawElementsInstanced': {5: {0:true, 2: true }},
+    'drawRangeElements': {6: {0:true, 4: true }},
+
+    // Shaders
+
+    'createShader': {1: { 0:true }},
+    'getShaderParameter': {2: { 1:true }},
+    'getProgramParameter': {2: { 1:true }},
+    'getShaderPrecisionFormat': {2: { 0: true, 1:true }},
+
+    // Vertex attributes
+
+    'getVertexAttrib': {2: { 1:true }},
+    'vertexAttribPointer': {6: { 2:true }},
+    'vertexAttribIPointer': {5: { 2:true }},  // WebGL2
+
+    // Textures
+
+    'bindTexture': {2: { 0:true }},
+    'activeTexture': {1: { 0:true }},
+    'getTexParameter': {2: { 0:true, 1:true }},
+    'texParameterf': {3: { 0:true, 1:true }},
+    'texParameteri': {3: { 0:true, 1:true, 2:true }},
+    'texImage2D': {
+       9: { 0:true, 2:true, 6:true, 7:true },
+       6: { 0:true, 2:true, 3:true, 4:true },
+       10: { 0:true, 2:true, 6:true, 7:true },  // WebGL2
+    },
+    'texImage3D': {
+      10: { 0:true, 2:true, 7:true, 8:true },  // WebGL2
+      11: { 0:true, 2:true, 7:true, 8:true },  // WebGL2
+    },
+    'texSubImage2D': {
+      9: { 0:true, 6:true, 7:true },
+      7: { 0:true, 4:true, 5:true },
+      10: { 0:true, 6:true, 7:true },  // WebGL2
+    },
+    'texSubImage3D': {
+      11: { 0:true, 8:true, 9:true },  // WebGL2
+      12: { 0:true, 8:true, 9:true },  // WebGL2
+    },
+    'texStorage2D': { 5: { 0:true, 2:true }},  // WebGL2
+    'texStorage3D': { 6: { 0:true, 2:true }},  // WebGL2
+    'copyTexImage2D': {8: { 0:true, 2:true }},
+    'copyTexSubImage2D': {8: { 0:true }},
+    'copyTexSubImage3D': {9: { 0:true }},  // WebGL2
+    'generateMipmap': {1: { 0:true }},
+    'compressedTexImage2D': {
+      7: { 0: true, 2:true },
+      8: { 0: true, 2:true },  // WebGL2
+    },
+    'compressedTexSubImage2D': {
+      8: { 0: true, 6:true },
+      9: { 0: true, 6:true },  // WebGL2
+    },
+    'compressedTexImage3D': {
+      8: { 0: true, 2: true, },  // WebGL2
+      9: { 0: true, 2: true, },  // WebGL2
+    },
+    'compressedTexSubImage3D': {
+      9: { 0: true, 8: true, },  // WebGL2
+      10: { 0: true, 8: true, },  // WebGL2
+    },
+
+    // Buffer objects
+
+    'bindBuffer': {2: { 0:true }},
+    'bufferData': {
+      3: { 0:true, 2:true },
+      4: { 0:true, 2:true },  // WebGL2
+      5: { 0:true, 2:true },  // WebGL2
+    },
+    'bufferSubData': {
+      3: { 0:true },
+      4: { 0:true },  // WebGL2
+      5: { 0:true },  // WebGL2
+    },
+    'copyBufferSubData': {
+      5: { 0:true },  // WeBGL2
+    },
+    'getBufferParameter': {2: { 0:true, 1:true }},
+    'getBufferSubData': {
+      3: { 0: true, },  // WebGL2
+      4: { 0: true, },  // WebGL2
+      5: { 0: true, },  // WebGL2
+    },
+
+    // Renderbuffers and framebuffers
+
+    'pixelStorei': {2: { 0:true, 1:true }},
+    'readPixels': {
+      7: { 4:true, 5:true },
+      8: { 4:true, 5:true },  // WebGL2
+    },
+    'bindRenderbuffer': {2: { 0:true }},
+    'bindFramebuffer': {2: { 0:true }},
+    'blitFramebuffer': {10: { 8: destBufferBitFieldToString, 9:true }},  // WebGL2
+    'checkFramebufferStatus': {1: { 0:true }},
+    'framebufferRenderbuffer': {4: { 0:true, 1:true, 2:true }},
+    'framebufferTexture2D': {5: { 0:true, 1:true, 2:true }},
+    'framebufferTextureLayer': {5: {0:true, 1:true }},  // WebGL2
+    'getFramebufferAttachmentParameter': {3: { 0:true, 1:true, 2:true }},
+    'getInternalformatParameter': {3: {0:true, 1:true, 2:true }},  // WebGL2
+    'getRenderbufferParameter': {2: { 0:true, 1:true }},
+    'invalidateFramebuffer': {2: { 0:true, 1: enumArrayToString, }},  // WebGL2
+    'invalidateSubFramebuffer': {6: {0: true, 1: enumArrayToString, }},  // WebGL2
+    'readBuffer': {1: {0: true}},  // WebGL2
+    'renderbufferStorage': {4: { 0:true, 1:true }},
+    'renderbufferStorageMultisample': {5: { 0: true, 2: true }},  // WebGL2
+
+    // Frame buffer operations (clear, blend, depth test, stencil)
+
+    'clear': {1: { 0: destBufferBitFieldToString }},
+    'depthFunc': {1: { 0:true }},
+    'blendFunc': {2: { 0:true, 1:true }},
+    'blendFuncSeparate': {4: { 0:true, 1:true, 2:true, 3:true }},
+    'blendEquation': {1: { 0:true }},
+    'blendEquationSeparate': {2: { 0:true, 1:true }},
+    'stencilFunc': {3: { 0:true }},
+    'stencilFuncSeparate': {4: { 0:true, 1:true }},
+    'stencilMaskSeparate': {2: { 0:true }},
+    'stencilOp': {3: { 0:true, 1:true, 2:true }},
+    'stencilOpSeparate': {4: { 0:true, 1:true, 2:true, 3:true }},
+
+    // Culling
+
+    'cullFace': {1: { 0:true }},
+    'frontFace': {1: { 0:true }},
+
+    // ANGLE_instanced_arrays extension
+
+    'drawArraysInstancedANGLE': {4: { 0:true }},
+    'drawElementsInstancedANGLE': {5: { 0:true, 2:true }},
+
+    // EXT_blend_minmax extension
+
+    'blendEquationEXT': {1: { 0:true }},
+
+    // Multiple Render Targets
+
+    'drawBuffersWebGL': {1: {0: enumArrayToString, }},  // WEBGL_draw_bufers
+    'drawBuffers': {1: {0: enumArrayToString, }},  // WebGL2
+    'clearBufferfv': {
+      4: {0: true },  // WebGL2
+      5: {0: true },  // WebGL2
+    },
+    'clearBufferiv': {
+      4: {0: true },  // WebGL2
+      5: {0: true },  // WebGL2
+    },
+    'clearBufferuiv': {
+      4: {0: true },  // WebGL2
+      5: {0: true },  // WebGL2
+    },
+    'clearBufferfi': { 4: {0: true}},  // WebGL2
+
+    // QueryObjects
+
+    'beginQuery': { 2: { 0: true }},  // WebGL2
+    'endQuery': { 1: { 0: true }},  // WebGL2
+    'getQuery': { 2: { 0: true, 1: true }},  // WebGL2
+    'getQueryParameter': { 2: { 1: true }},  // WebGL2
+
+    //  Sampler Objects
+
+    'samplerParameteri': { 3: { 1: true }},  // WebGL2
+    'samplerParameterf': { 3: { 1: true }},  // WebGL2
+    'getSamplerParameter': { 2: { 1: true }},  // WebGL2
+
+    //  Sync objects
+
+    'clientWaitSync': { 3: { 1: makeBitFieldToStringFunc(['SYNC_FLUSH_COMMANDS_BIT']) }},  // WebGL2
+    'fenceSync': { 2: { 0: true }},  // WebGL2
+    'getSyncParameter': { 2: { 1: true }},  // WebGL2
+
+    //  Transform Feedback
+
+    'bindTransformFeedback': { 2: { 0: true }},  // WebGL2
+    'beginTransformFeedback': { 1: { 0: true }},  // WebGL2
+
+    // Uniform Buffer Objects and Transform Feedback Buffers
+    'bindBufferBase': { 3: { 0: true }},  // WebGL2
+    'bindBufferRange': { 5: { 0: true }},  // WebGL2
+    'getIndexedParameter': { 2: { 0: true }},  // WebGL2
+    'getActiveUniforms': { 3: { 2: true }},  // WebGL2
+    'getActiveUniformBlockParameter': { 3: { 2: true }},  // WebGL2
+  };
+
+  /**
+   * Gets an string version of an WebGL enum.
+   *
+   * Example:
+   *   var str = WebGLDebugUtil.glEnumToString(ctx.getError());
+   *
+   * @param {number} value Value to return an enum for
+   * @return {string} The string version of the enum.
+   */
+  function glEnumToString(value) {
+    const name = glEnums[value];
+    return (name !== undefined) ? ('gl.' + name) :
+        ('/*UNKNOWN WebGL ENUM*/ 0x' + value.toString(16) + '');
+  }
+
+  /**
+   * Returns the string version of a WebGL argument.
+   * Attempts to convert enum arguments to strings.
+   * @param {string} functionName the name of the WebGL function.
+   * @param {number} numArgs the number of arguments passed to the function.
+   * @param {number} argumentIndx the index of the argument.
+   * @param {*} value The value of the argument.
+   * @return {string} The value as a string.
+   */
+  function glFunctionArgToString(functionName, numArgs, argumentIndex, value) {
+    const funcInfos = glValidEnumContexts[functionName];
+    if (funcInfos !== undefined) {
+      const funcInfo = funcInfos[numArgs];
+      if (funcInfo !== undefined) {
+        const argType = funcInfo[argumentIndex];
+        if (argType) {
+          if (typeof argType === 'function') {
+            return argType(value);
+          } else {
+            return glEnumToString(value);
+          }
+        }
+      }
+    }
+    if (value === null) {
+      return 'null';
+    } else if (value === undefined) {
+      return 'undefined';
+    } else {
+      return value.toString();
+    }
+  }
+
+  /**
+   * Converts the arguments of a WebGL function to a string.
+   * Attempts to convert enum arguments to strings.
+   *
+   * @param {string} functionName the name of the WebGL function.
+   * @param {number} args The arguments.
+   * @return {string} The arguments as a string.
+   */
+  function glFunctionArgsToString(functionName, args) {
+    // apparently we can't do args.join(",");
+    const argStrs = [];
+    const numArgs = args.length;
+    for (let ii = 0; ii < numArgs; ++ii) {
+      argStrs.push(glFunctionArgToString(functionName, numArgs, ii, args[ii]));
+    }
+    return argStrs.join(', ');
+  }
+
+  function makePropertyWrapper(wrapper, original, propertyName) {
+    wrapper.__defineGetter__(propertyName, function() {  // eslint-disable-line
+      return original[propertyName];
+    });
+    // TODO(gmane): this needs to handle properties that take more than
+    // one value?
+    wrapper.__defineSetter__(propertyName, function(value) {  // eslint-disable-line
+      original[propertyName] = value;
+    });
+  }
+
+  /**
+   * Given a WebGL context returns a wrapped context that calls
+   * gl.getError after every command and calls a function if the
+   * result is not gl.NO_ERROR.
+   *
+   * @param {!WebGLRenderingContext} ctx The webgl context to
+   *        wrap.
+   * @param {!function(err, funcName, args): void} opt_onErrorFunc
+   *        The function to call when gl.getError returns an
+   *        error. If not specified the default function calls
+   *        console.log with a message.
+   * @param {!function(funcName, args): void} opt_onFunc The
+   *        function to call when each webgl function is called.
+   *        You can use this to log all calls for example.
+   * @param {!WebGLRenderingContext} opt_err_ctx The webgl context
+   *        to call getError on if different than ctx.
+   */
+  function makeDebugContext(ctx, options) {
+    options = options || {};
+    const errCtx = options.errCtx || ctx;
+    const onFunc = options.funcFunc;
+    const sharedState = options.sharedState || {
+      numDrawCallsRemaining: options.maxDrawCalls || -1,
+      wrappers: {},
+    };
+    options.sharedState = sharedState;
+
+    const errorFunc = options.errorFunc || function(err, functionName, args) {
+      console.error(`WebGL error ${glEnumToString(err)} in ${functionName}(${glFunctionArgsToString(functionName, args)})`);  /* eslint-disable-line no-console */
+    };
+
+    // Holds booleans for each GL error so after we get the error ourselves
+    // we can still return it to the client app.
+    const glErrorShadow = { };
+    const wrapper = {};
+
+    function removeChecks() {
+      Object.keys(sharedState.wrappers).forEach(function(name) {
+        const pair = sharedState.wrappers[name];
+        const wrapper = pair.wrapper;
+        const orig = pair.orig;
+        for (const propertyName in wrapper) {
+          if (typeof wrapper[propertyName] === 'function') {
+            wrapper[propertyName] = orig[propertyName].bind(orig);
+          }
+        }
+      });
+    }
+
+    function checkMaxDrawCalls() {
+      if (sharedState.numDrawCallsRemaining === 0) {
+        removeChecks();
+      }
+      --sharedState.numDrawCallsRemaining;
+    }
+
+    function noop() {
+    }
+
+    // Makes a function that calls a WebGL function and then calls getError.
+    function makeErrorWrapper(ctx, functionName) {
+      const check = functionName.substring(0, 4) === 'draw' ? checkMaxDrawCalls : noop;
+      return function() {
+        if (onFunc) {
+          onFunc(functionName, arguments);
+        }
+        const result = ctx[functionName].apply(ctx, arguments);
+        const err = errCtx.getError();
+        if (err !== 0) {
+          glErrorShadow[err] = true;
+          errorFunc(err, functionName, arguments);
+        }
+        check();
+        return result;
+      };
+    }
+
+    function makeGetExtensionWrapper(ctx, wrapped) {
+      return function() {
+        const extensionName = arguments[0];
+        let ext = sharedState.wrappers[extensionName];
+        if (!ext) {
+          ext = wrapped.apply(ctx, arguments);
+          if (ext) {
+            const origExt = ext;
+            ext = makeDebugContext(ext, options);
+            sharedState.wrappers[extensionName] = { wrapper: ext, orig: origExt };
+            addEnumsForContext(origExt, extensionName);
+          }
+        }
+        return ext;
+      };
+    }
+
+    // Make a an object that has a copy of every property of the WebGL context
+    // but wraps all functions.
+    for (const propertyName in ctx) {
+      if (typeof ctx[propertyName] === 'function') {
+        if (propertyName !== 'getExtension') {
+          wrapper[propertyName] = makeErrorWrapper(ctx, propertyName);
+        } else {
+          const wrapped = makeErrorWrapper(ctx, propertyName);
+          wrapper[propertyName] = makeGetExtensionWrapper(ctx, wrapped);
+        }
+      } else {
+        makePropertyWrapper(wrapper, ctx, propertyName);
+      }
+    }
+
+    // Override the getError function with one that returns our saved results.
+    if (wrapper.getError) {
+      wrapper.getError = function() {
+        for (const err in glErrorShadow) {
+          if (glErrorShadow.hasOwnProperty(err)) {
+            if (glErrorShadow[err]) {
+              glErrorShadow[err] = false;
+              return err;
+            }
+          }
+        }
+        return ctx.NO_ERROR;
+      };
+    }
+
+    if (wrapper.bindBuffer) {
+      sharedState.wrappers['webgl'] = { wrapper: wrapper, orig: ctx };
+      addEnumsForContext(ctx, ctx.bindBufferBase ? 'WebGL2' : 'WebGL');
+    }
+
+    return wrapper;
+  }
+
+  //------------
+
+  function captureJSErrors() {
+    // capture JavaScript Errors
+    self.addEventListener('error', function(e) {
+      const msg = e.message || e.error;
+      const url = e.filename;
+      const lineNo = e.lineno || 1;
+      const colNo = e.colno || 1;
+      sendMessage({
+        type: 'jsError',
+        lineNo,
+        colNo,
+        url,
+        msg,
+      });
+    });
+  }
+
+
+  const isWebGLRE = /^(webgl|webgl2|experimental-webgl)$/i;
+  function installWebGLLessonSetup() {
+    OffscreenCanvas.prototype.getContext = (function(oldFn) {
+      return function() {
+        const type = arguments[0];
+        const isWebGL = isWebGLRE.test(type);
+        if (isWebGL) {
+          setupLesson(this);
+        }
+        const args = [].slice.apply(arguments);
+        args[1] = Object.assign({
+          powerPreference: 'low-power',
+        }, args[1]);
+        return oldFn.apply(this, args);
+      };
+    }(OffscreenCanvas.prototype.getContext));
+  }
+
+  function installWebGLDebugContextCreator() {
+    // capture GL errors
+    OffscreenCanvas.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 error = new Error();
+                sendMessage({
+                  type: 'jsErrorWithStack',
+                  stack: error.stack,
+                  msg: `${glEnumToString(err)} in ${funcName}(${enumedArgs.join(', ')})`,
+                });
+              }
+            },
+          });
+        }
+        return ctx;
+      };
+    }(OffscreenCanvas.prototype.getContext));
+  }
+
+  installWebGLLessonSetup();
+
+  if (isInEditor()) {
+    setupConsole();
+    captureJSErrors();
+    if (lessonSettings.glDebug !== false) {
+      installWebGLDebugContextCreator();
+    }
+  }
+
+}());
+