├── .eslintrc.js ├── .gitignore ├── README.md ├── index.js ├── package-lock.json ├── package.json └── src ├── editor-fullscreen-icon.svg ├── editor-unfullscreen-icon.svg ├── editor.html └── editor.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "parserOptions": { 7 | "ecmaVersion": 2019 8 | }, 9 | "plugins": [ 10 | "eslint-plugin-html", 11 | "eslint-plugin-optional-comma-spacing", 12 | "eslint-plugin-one-variable-per-var", 13 | "eslint-plugin-require-trailing-comma" 14 | ], 15 | "extends": "eslint:recommended", 16 | "rules": { 17 | "no-alert": 2, 18 | "no-array-constructor": 2, 19 | "no-caller": 2, 20 | "no-catch-shadow": 2, 21 | "no-const-assign": 2, 22 | "no-labels": 2, 23 | "no-eval": 2, 24 | "no-extend-native": 2, 25 | "no-extra-bind": 2, 26 | "no-implied-eval": 2, 27 | "no-iterator": 2, 28 | "no-label-var": 2, 29 | "no-labels": 2, 30 | "no-lone-blocks": 2, 31 | "no-loop-func": 2, 32 | "no-multi-str": 2, 33 | "no-native-reassign": 2, 34 | "no-new": 2, 35 | "no-new-func": 2, 36 | "no-new-object": 2, 37 | "no-new-wrappers": 2, 38 | "no-octal-escape": 2, 39 | "no-process-exit": 2, 40 | "no-proto": 2, 41 | "no-return-assign": 2, 42 | "no-script-url": 2, 43 | "no-sequences": 2, 44 | "no-shadow-restricted-names": 2, 45 | "no-spaced-func": 2, 46 | "no-trailing-spaces": 2, 47 | "no-undef-init": 2, 48 | "no-underscore-dangle": 2, 49 | "no-unused-expressions": 2, 50 | "no-use-before-define": 2, 51 | "no-with": 2, 52 | "consistent-return": 2, 53 | "curly": [2, "all"], 54 | "no-extra-parens": [2, "functions"], 55 | "eqeqeq": 2, 56 | "new-cap": 2, 57 | "new-parens": 2, 58 | "semi-spacing": [2, {"before": false, "after": true}], 59 | "space-infix-ops": 2, 60 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 61 | "strict": [2, "global"], 62 | "yoda": [2, "never"], 63 | 64 | "brace-style": [2, "1tbs", { "allowSingleLine": false }], 65 | "camelcase": [0], 66 | "comma-spacing": 0, 67 | "comma-dangle": 0, 68 | "comma-style": [2, "last"], 69 | "dot-notation": 0, 70 | "eol-last": [0], 71 | "global-strict": [0], 72 | "key-spacing": [0], 73 | "no-comma-dangle": [0], 74 | "no-irregular-whitespace": 2, 75 | "no-multi-spaces": [0], 76 | "no-obj-calls": 2, 77 | "no-shadow": [0], 78 | "no-undef": 2, 79 | "no-unreachable": 2, 80 | "one-variable-per-var/one-variable-per-var": [2], 81 | "optional-comma-spacing/optional-comma-spacing": [2, {"after": true}], 82 | "quotes": [2, "single"], 83 | "require-trailing-comma/require-trailing-comma": [2], 84 | "semi": [2, "always"], 85 | "space-before-function-paren": [2, "never"], 86 | "keyword-spacing": [1, {"before": true, "after": true, "overrides": {}} ] 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | node_modules 4 | out 5 | package-lock.json 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GFXFundamentals Live Editor 2 | 3 | This is the live code editor used on 4 | [WebGPUFundamentals](https://webgpufundamentals.org), 5 | [WebGLFundamentals](https://webglfundamentals.org), 6 | [WebGL2Fundamentals](https://webgl2fundamentals.org), and 7 | the [three.js manual](https://threejs.org/manual/#en/fundamentals). 8 | 9 | It's based on the [Monaco Editor](https://microsoft.github.io/monaco-editor/) 10 | which is the editor portion of Visual Studio Code 11 | 12 | The goal was to be similar to JSFiddle or Codepen but client side only 13 | so there's several hacks. The biggest one is you call it with a url 14 | encoded in the query parameters. It then fetches that URL and parses 15 | it with brittle regular expressions. Because the input is under my 16 | control, it's only samples I've written or approved, I'm not too worried 17 | about using brittle regular expressions. 18 | 19 | While parsing it needs to make all paths to external files to be 20 | fully qualified domain URLs. That is all links to images, videos, 21 | scripts, audio, CSS, workers, etc. It does some of this with 22 | user configured functions. The reason it needs to do this is because 23 | it runs the actual samples as blobs. There are no blob relative paths 24 | so all paths need to be fully qualified. 25 | 26 | It has some support for handling workers, something even codepen 27 | and jsfiddle don't seem to easily support. 28 | 29 | It also has support for providing editor line relative errors for 30 | JavaScript. In other words, JavaScript gets an error at line 475 31 | in the actual blob that is running but in the editor in JavaScript 32 | it might be line 17. The JavaScript errors can be caught via a 33 | helper that is inserted into the blob which are then sent to the 34 | editor which can generate a relative line number and then move 35 | the cursor to the appropriate line. 36 | 37 | The biggest drawback is the JavaScript debugger in the browser 38 | will lose all breakpoints every time the user's code is run since 39 | a new blob is generated so the debugger can't associate previous 40 | breakpoints. 41 | 42 | In its current form it is probably not entirely stand alone and is 43 | pretty hacky. I just separated out as I finally got tired of manually 44 | propogating changes between repos. 45 | 46 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // used to get path to module 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | 6 | function exists(filename) { 7 | try { 8 | const stat = fs.statSync(filename); 9 | return true; 10 | } catch (e) { 11 | return false; 12 | } 13 | } 14 | 15 | function getModulePath(name) { 16 | for (const dirname of require.resolve.paths(name)) { 17 | const filename = path.join(dirname, name); 18 | console.log('checking:', path.join(dirname, name)); 19 | if (exists(filename)) { 20 | return filename; 21 | } 22 | } 23 | } 24 | 25 | module.exports = { 26 | monacoEditor: getModulePath('monaco-editor'), 27 | }; 28 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gfxfundamentals/live-editor", 3 | "version": "1.5.1", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@gfxfundamentals/live-editor", 9 | "version": "1.5.1", 10 | "license": "MIT", 11 | "dependencies": { 12 | "monaco-editor": "^0.41.0" 13 | } 14 | }, 15 | "node_modules/monaco-editor": { 16 | "version": "0.41.0", 17 | "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.41.0.tgz", 18 | "integrity": "sha512-1o4olnZJsiLmv5pwLEAmzHTE/5geLKQ07BrGxlF4Ri/AXAc2yyDGZwHjiTqD8D/ROKUZmwMA28A+yEowLNOEcA==" 19 | } 20 | }, 21 | "dependencies": { 22 | "monaco-editor": { 23 | "version": "0.41.0", 24 | "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.41.0.tgz", 25 | "integrity": "sha512-1o4olnZJsiLmv5pwLEAmzHTE/5geLKQ07BrGxlF4Ri/AXAc2yyDGZwHjiTqD8D/ROKUZmwMA28A+yEowLNOEcA==" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gfxfundamentals/live-editor", 3 | "version": "1.5.1", 4 | "description": "live code editor", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/gfxfundamentals/live-editor.git" 12 | }, 13 | "keywords": [ 14 | "editor", 15 | "code" 16 | ], 17 | "author": "Gregg Tavares", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/gfxfundamentals/live-editor/issues" 21 | }, 22 | "homepage": "https://github.com/gfxfundamentals/live-editor#readme", 23 | "dependencies": { 24 | "monaco-editor": "^0.41.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/editor-fullscreen-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/editor-unfullscreen-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 265 | 266 | 267 |
268 |
269 |
270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 |
278 | 281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
 
290 |
291 |
292 |
293 |
294 |
295 | 306 | 312 | 313 | 314 | 315 | 316 | 317 | -------------------------------------------------------------------------------- /src/editor.js: -------------------------------------------------------------------------------- 1 | (function() { // eslint-disable-line strict 2 | 'use strict'; // eslint-disable-line strict 3 | 4 | /* global monaco, require, lessonEditorSettings */ 5 | 6 | const { 7 | fixSourceLinks, 8 | fixJSForCodeSite, 9 | fixHTMLForCodeSite, 10 | extraHTMLParsing, 11 | runOnResize, 12 | getWorkerPreamble, 13 | prepHTML, 14 | initEditor = () => { /* */ }, 15 | } = lessonEditorSettings; 16 | 17 | function getQuery(s) { 18 | s = s === undefined ? window.location.search : s; 19 | if (s[0] === '?' ) { 20 | s = s.substring(1); 21 | } 22 | const query = {}; 23 | s.split('&').forEach(function(pair) { 24 | const parts = pair.split('=').map(decodeURIComponent); 25 | query[parts[0]] = parts[1]; 26 | }); 27 | return query; 28 | } 29 | 30 | function getSearch(url) { 31 | // yea I know this is not perfect but whatever 32 | const s = url.indexOf('?'); 33 | return s < 0 ? {} : getQuery(url.substring(s)); 34 | } 35 | 36 | function getFQUrl(path, baseUrl) { 37 | const url = new URL(path, baseUrl || window.location.href); 38 | return url.href; 39 | } 40 | 41 | async function getHTML(url) { 42 | const req = await fetch(url); 43 | return await req.text(); 44 | } 45 | 46 | function getPrefix(url) { 47 | const u = new URL(url, window.location.href); 48 | const prefix = u.origin + dirname(u.pathname); 49 | return prefix; 50 | } 51 | 52 | function fixCSSLinks(url, source) { 53 | const cssUrlRE1 = /(url\(')(.*?)('\))/g; 54 | const cssUrlRE2 = /(url\()(.*?)(\))/g; 55 | const prefix = getPrefix(url); 56 | 57 | function addPrefix(url) { 58 | return url.indexOf('://') < 0 && !url.startsWith('data:') ? `${prefix}/${url}` : url; 59 | } 60 | function makeFQ(match, prefix, url, suffix) { 61 | return `${prefix}${addPrefix(url)}${suffix}`; 62 | } 63 | 64 | source = source.replace(cssUrlRE1, makeFQ); 65 | source = source.replace(cssUrlRE2, makeFQ); 66 | return source; 67 | } 68 | 69 | /** 70 | * @typedef {Object} Globals 71 | * @property {SourceInfo} rootScriptInfo 72 | * @property {Object} */ 121 | const htmlParts = { 122 | js: { 123 | language: 'javascript', 124 | sources: [], 125 | }, 126 | css: { 127 | language: 'css', 128 | sources: [], 129 | }, 130 | html: { 131 | language: 'html', 132 | sources: [], 133 | }, 134 | }; 135 | 136 | function forEachHTMLPart(fn) { 137 | Object.keys(htmlParts).forEach(function(name, ndx) { 138 | const info = htmlParts[name]; 139 | fn(info, ndx, name); 140 | }); 141 | } 142 | 143 | function getHTMLPart(re, obj, tag) { 144 | let part = ''; 145 | obj.html = obj.html.replace(re, function(p0, p1) { 146 | part = p1; 147 | return tag; 148 | }); 149 | const lines = part.replace(/\r\n/g, '\n').split('\n'); 150 | // remove leading blank lines 151 | while (lines.length && !lines[0].length) { 152 | lines.shift(); 153 | } 154 | // remove common indentation 155 | if (lines.length) { 156 | const firstLine = lines[0]; 157 | const m = /(\s*)\S/.exec(firstLine); 158 | if (m) { 159 | const indent = m[1]; 160 | lines.forEach((line, ndx) => { 161 | if (line.startsWith(indent)) { 162 | lines[ndx] = line.substring(indent.length); 163 | } 164 | }); 165 | } 166 | } 167 | return lines.join('\n'); 168 | } 169 | 170 | // doesn't handle multi-line comments or comments with { or } in them 171 | function formatCSS(css) { 172 | let indent = ''; 173 | return css.split('\n').map((line) => { 174 | let currIndent = indent; 175 | if (line.includes('{')) { 176 | indent = indent + ' '; 177 | } 178 | if (line.includes('}')) { 179 | indent = indent.substring(0, indent.length - 2); 180 | currIndent = indent; 181 | } 182 | return `${currIndent}${line.trim()}`; 183 | }).join('\n'); 184 | } 185 | 186 | async function getScript(url, scriptInfos) { 187 | // check it's an example script, not some other lib 188 | if (!scriptInfos[url].source) { 189 | const source = await getHTML(url); 190 | const fixedSource = fixSourceLinks(url, source); 191 | const {text} = await getWorkerScripts(fixedSource, url, scriptInfos); 192 | scriptInfos[url].source = text; 193 | } 194 | } 195 | 196 | /** 197 | * @typedef {Object} ScriptInfo 198 | * @property {string} fqURL The original fully qualified URL 199 | * @property {ScriptInfo[]} deps Array of other ScriptInfos this is script dependant on 200 | * @property {boolean} isWorker True if this script came from `new Worker('someurl')` vs `import` or `importScripts` 201 | * @property {string} blobUrl The blobUrl for this script if one has been made 202 | * @property {number} blobGenerationId Used to not visit things twice while recursing. 203 | * @property {string} source The source as extracted. Updated from editor by getSourcesFromEditor 204 | * @property {string} munged The source after urls have been replaced with blob urls etc... (the text send to new Blob) 205 | */ 206 | 207 | async function getWorkerScripts(text, baseUrl, scriptInfos = {}) { 208 | const parentScriptInfo = scriptInfos[baseUrl]; 209 | const workerRE = /(new\s+Worker\s*\(\s*)('|")(.*?)('|")/g; 210 | const importScriptsRE = /(importScripts\s*\(\s*)('|")(.*?)('|")/g; 211 | const importRE = /(import.*?)('|")(.*?)('|")/g; 212 | 213 | const newScripts = []; 214 | const slashRE = /\/threejs\/[^/]+$/; 215 | 216 | function replaceWithUUID(match, prefix, quote, url) { 217 | const fqURL = getFQUrl(url, baseUrl); 218 | if (!slashRE.test(fqURL)) { 219 | return match.toString(); 220 | } 221 | 222 | if (!scriptInfos[url]) { 223 | scriptInfos[fqURL] = { 224 | fqURL, 225 | deps: [], 226 | isWorker: prefix.indexOf('Worker') >= 0, 227 | }; 228 | newScripts.push(fqURL); 229 | } 230 | parentScriptInfo.deps.push(scriptInfos[fqURL]); 231 | 232 | return `${prefix}${quote}${fqURL}${quote}`; 233 | } 234 | 235 | function replaceWithUUIDModule(match, prefix, quote, url) { 236 | // modules are either relative, fully qualified, or a module name 237 | // Skip it if it's a module name 238 | return (url.startsWith('.') || url.includes('://')) 239 | ? replaceWithUUID(match, prefix, quote, url) 240 | : match.toString(); 241 | } 242 | 243 | text = text.replace(workerRE, replaceWithUUID); 244 | text = text.replace(importScriptsRE, replaceWithUUID); 245 | text = text.replace(importRE, replaceWithUUIDModule); 246 | 247 | await Promise.all(newScripts.map((url) => { 248 | return getScript(url, scriptInfos); 249 | })); 250 | 251 | return {text, scriptInfos}; 252 | } 253 | 254 | // hack: scriptInfo is undefined for html and css 255 | // should try to include html and css in scriptInfos 256 | function addSource(type, name, source, scriptInfo) { 257 | htmlParts[type].sources.push({source, name, scriptInfo}); 258 | } 259 | 260 | function safeStr(s) { 261 | return s === undefined ? '' : s; 262 | } 263 | 264 | async function parseHTML(url, html) { 265 | html = fixSourceLinks(url, html); 266 | 267 | html = html.replace(/
[^]*?<\/div>/, ''); 268 | 269 | const styleRE = /')))); 282 | addSource('html', 'html', getHTMLPart(bodyRE, obj, '${html}')); 283 | const rootScript = getHTMLPart(inlineScriptRE, obj, '') || 284 | getHTMLPart(inlineModuleScriptRE, obj, ''); 285 | html = obj.html; 286 | 287 | const fqURL = getFQUrl(url); 288 | /** @type Object */ 289 | const scriptInfos = {}; 290 | g.rootScriptInfo = { 291 | fqURL, 292 | deps: [], 293 | source: rootScript, 294 | }; 295 | scriptInfos[fqURL] = g.rootScriptInfo; 296 | 297 | const {text} = await getWorkerScripts(rootScript, fqURL, scriptInfos); 298 | g.rootScriptInfo.source = text; 299 | g.scriptInfos = scriptInfos; 300 | for (const [fqURL, scriptInfo] of Object.entries(scriptInfos)) { 301 | addSource('js', basename(fqURL), scriptInfo.source, scriptInfo); 302 | } 303 | 304 | const tm = titleRE.exec(html); 305 | if (tm) { 306 | g.title = tm[1]; 307 | } 308 | 309 | if (!g.title) { 310 | g.title = basename(new URL(getFQUrl(url)).pathname).replace(/-/g, ' ').replace(/\.html$/, ''); 311 | } 312 | 313 | const kScript = 'script'; 314 | const scripts = []; 315 | html = html.replace(externalScriptRE, function(match, blockComment, beforeType, type, src, afterType) { 316 | blockComment = blockComment || ''; 317 | scripts.push(`${blockComment}<${kScript} ${beforeType}${safeStr(type)}src="${src}"${afterType}>`); 318 | return ''; 319 | }); 320 | 321 | const prefix = getPrefix(url); 322 | const rootPrefix = getRootPrefix(url); 323 | 324 | function addCorrectPrefix(href) { 325 | return (href.startsWith('/')) 326 | ? `${rootPrefix}${href}` 327 | : removeDotDotSlash((`${prefix}/${href}`).replace(/\/.\//g, '/')); 328 | } 329 | 330 | function addPrefix(url) { 331 | return url.indexOf('://') < 0 && !url.startsWith('data:') && url[0] !== '?' 332 | ? removeDotDotSlash(addCorrectPrefix(url)) 333 | : url; 334 | } 335 | 336 | const importMapRE = /type\s*=["']importmap["']/; 337 | const dataScripts = []; 338 | html = html.replace(dataScriptRE, function(p0, blockComments, scriptTagAttrs, content) { 339 | blockComments = blockComments || ''; 340 | if (importMapRE.test(scriptTagAttrs)) { 341 | const imap = JSON.parse(content); 342 | const imports = imap.imports; 343 | if (imports) { 344 | for (let [k, url] of Object.entries(imports)) { 345 | if (url.indexOf('://') < 0 && !url.startsWith('data:')) { 346 | imports[k] = addPrefix(url); 347 | } 348 | } 349 | } 350 | content = JSON.stringify(imap, null, '\t'); 351 | } 352 | dataScripts.push(`${blockComments}<${kScript} ${scriptTagAttrs}>${content}`); 353 | return ''; 354 | }); 355 | 356 | htmlParts.html.sources[0].source += dataScripts.join('\n'); 357 | htmlParts.html.sources[0].source += scripts.join('\n'); 358 | 359 | // add style section if there is non 360 | if (html.indexOf('${css}') < 0) { 361 | html = html.replace('', '\n'); 362 | } 363 | 364 | // add hackedparams section. 365 | // We need a way to pass parameters to a blob. Normally they'd be passed as 366 | // query params but that only works in Firefox >:( 367 | html = html.replace('', '\n'); 368 | 369 | html = extraHTMLParsing(html, htmlParts); 370 | 371 | let links = ''; 372 | html = html.replace(cssLinkRE, function(match, link) { 373 | if (isCSSLinkRE.test(link)) { 374 | const m = hrefRE.exec(link); 375 | if (m) { 376 | links += `@import url("${m[1]}");\n`; 377 | } 378 | return ''; 379 | } else { 380 | return match; 381 | } 382 | }); 383 | 384 | htmlParts.css.sources[0].source = links + htmlParts.css.sources[0].source; 385 | 386 | g.html = html; 387 | } 388 | 389 | function cantGetHTML(e) { // eslint-disable-line 390 | console.log(e); // eslint-disable-line 391 | console.log("TODO: don't run editor if can't get HTML"); // eslint-disable-line 392 | } 393 | 394 | async function main() { 395 | if (typeof monaco !== 'undefined') { 396 | await initEditor(); 397 | } 398 | const query = getQuery(); 399 | g.url = getFQUrl(query.url); 400 | g.query = getSearch(g.url); 401 | let html; 402 | try { 403 | html = await getHTML(query.url); 404 | } catch (err) { 405 | console.log(err); // eslint-disable-line 406 | return; 407 | } 408 | await parseHTML(query.url, html); 409 | setupEditor(query.url); 410 | if (query.startPane) { 411 | const button = document.querySelector('.button-' + query.startPane); 412 | toggleSourcePane(button); 413 | } 414 | } 415 | 416 | function getJavaScriptBlob(source) { 417 | const blob = new Blob([source], {type: 'application/javascript'}); 418 | return URL.createObjectURL(blob); 419 | } 420 | 421 | let blobGeneration = 0; 422 | function makeBlobURLsForSources(scriptInfo) { 423 | ++blobGeneration; 424 | 425 | function makeBlobURLForSourcesImpl(scriptInfo) { 426 | if (scriptInfo.blobGenerationId !== blobGeneration) { 427 | scriptInfo.blobGenerationId = blobGeneration; 428 | if (scriptInfo.blobUrl) { 429 | URL.revokeObjectURL(scriptInfo.blobUrl); 430 | } 431 | scriptInfo.deps.forEach(makeBlobURLForSourcesImpl); 432 | let text = scriptInfo.source; 433 | scriptInfo.deps.forEach((depScriptInfo) => { 434 | text = text.split(depScriptInfo.fqURL).join(depScriptInfo.blobUrl); 435 | }); 436 | scriptInfo.numLinesBeforeScript = 0; 437 | if (scriptInfo.isWorker) { 438 | const workerPreamble = getWorkerPreamble(scriptInfo); 439 | scriptInfo.numLinesBeforeScript = workerPreamble.split('\n').length; 440 | text = `${workerPreamble}\n${text}`; 441 | } 442 | scriptInfo.blobUrl = getJavaScriptBlob(text); 443 | scriptInfo.munged = text; 444 | } 445 | } 446 | makeBlobURLForSourcesImpl(scriptInfo); 447 | } 448 | 449 | function getSourceBlob(htmlParts) { 450 | g.rootScriptInfo.source = htmlParts.js; 451 | makeBlobURLsForSources(g.rootScriptInfo); 452 | 453 | const dname = dirname(g.url); 454 | // HACK! for webgl-2d-vs... those examples are not in /webgl they're in /webgl/resources 455 | // We basically assume url is https://foo/base/example.html so there will be 4 slashes 456 | // If the path is longer than then we need '../' to back up so prefix works below 457 | const prefix = `${dname}${dname.split('/').slice(4).map(() => '/..').join('')}`; 458 | let source = g.html; 459 | source = source.replace('${hackedParams}', JSON.stringify(g.query)); 460 | source = source.replace('${html}', htmlParts.html); 461 | source = source.replace('${css}', htmlParts.css); 462 | source = source.replace('${js}', g.rootScriptInfo.munged); //htmlParts.js); 463 | source = prepHTML(source, prefix); 464 | const scriptNdx = source.search(//); 465 | g.rootScriptInfo.numLinesBeforeScript = (source.substring(0, scriptNdx).match(/\n/g) || []).length; 466 | 467 | const blob = new Blob([source], {type: 'text/html'}); 468 | // This seems hacky. We are combining html/css/js into one html blob but we already made 469 | // a blob for the JS so let's replace that blob. That means it will get auto-released when script blobs 470 | // are regenerated. It also means error reporting will work 471 | const blobUrl = URL.createObjectURL(blob); 472 | URL.revokeObjectURL(g.rootScriptInfo.blobUrl); 473 | g.rootScriptInfo.blobUrl = blobUrl; 474 | return blobUrl; 475 | } 476 | 477 | function getSourcesFromEditor() { 478 | for (const partTypeInfo of Object.values(htmlParts)) { 479 | for (const source of partTypeInfo.sources) { 480 | source.source = source.editor.getValue(); 481 | // hack: shouldn't store this twice. Also see other comment, 482 | // should consolidate so scriptInfo is used for css and html 483 | if (source.scriptInfo) { 484 | source.scriptInfo.source = source.source; 485 | } 486 | } 487 | } 488 | } 489 | function getSourceBlobFromEditor() { 490 | getSourcesFromEditor(); 491 | 492 | return getSourceBlob({ 493 | html: htmlParts.html.sources[0].source, 494 | css: htmlParts.css.sources[0].source, 495 | js: htmlParts.js.sources[0].source, 496 | }); 497 | } 498 | 499 | function getSourceBlobFromOrig() { 500 | return getSourceBlob({ 501 | html: htmlParts.html.sources[0].source, 502 | css: htmlParts.css.sources[0].source, 503 | js: htmlParts.js.sources[0].source, 504 | }); 505 | } 506 | 507 | function dirname(path) { 508 | const ndx = path.lastIndexOf('/'); 509 | return path.substring(0, ndx); 510 | } 511 | 512 | function basename(path) { 513 | const ndx = path.lastIndexOf('/'); 514 | return path.substring(ndx + 1); 515 | } 516 | 517 | function getRootPrefix(url) { 518 | const u = new URL(url, window.location.href); 519 | return u.origin; 520 | } 521 | 522 | function removeDotDotSlash(href) { 523 | // assumes a well formed URL. In other words: 'https://..//foo.html" is a bad URL and this code would fail. 524 | const url = new URL(href, window.location.href); 525 | const parts = url.pathname.split('/'); 526 | for (;;) { 527 | const dotDotNdx = parts.indexOf('..'); 528 | if (dotDotNdx < 0) { 529 | break; 530 | } 531 | parts.splice(dotDotNdx - 1, 2); 532 | } 533 | url.pathname = parts.join('/'); 534 | return url.toString(); 535 | } 536 | 537 | function resize() { 538 | forEachHTMLPart(function(info) { 539 | info.editors.forEach((editorInfo) => { 540 | editorInfo.editor.layout(); 541 | }); 542 | }); 543 | } 544 | 545 | function makeScriptsForWorkers(scriptInfo) { 546 | ++blobGeneration; 547 | 548 | function makeScriptsForWorkersImpl(scriptInfo) { 549 | const scripts = []; 550 | if (scriptInfo.blobGenerationId !== blobGeneration) { 551 | scriptInfo.blobGenerationId = blobGeneration; 552 | scripts.push(...scriptInfo.deps.map(makeScriptsForWorkersImpl).flat()); 553 | let text = scriptInfo.source; 554 | scriptInfo.deps.forEach((depScriptInfo) => { 555 | text = text.split(depScriptInfo.fqURL).join(`worker-${basename(depScriptInfo.fqURL)}`); 556 | }); 557 | 558 | scripts.push({ 559 | name: `worker-${basename(scriptInfo.fqURL)}`, 560 | text, 561 | }); 562 | } 563 | return scripts; 564 | } 565 | 566 | const scripts = makeScriptsForWorkersImpl(scriptInfo); 567 | if (scripts.length === 1) { 568 | return { 569 | js: scripts[0].text, 570 | html: '', 571 | }; 572 | } 573 | 574 | // scripts[last] = main script 575 | // scripts[last - 1] = worker 576 | const mainScriptInfo = scripts[scripts.length - 1]; 577 | const workerScriptInfo = scripts[scripts.length - 2]; 578 | const workerName = workerScriptInfo.name; 579 | mainScriptInfo.text = mainScriptInfo.text.split(`'${workerName}'`).join('getWorkerBlob()'); 580 | const html = scripts.map((nameText) => { 581 | const {name, text} = nameText; 582 | return `\n`; 583 | }).join('\n'); 584 | const init = ` 585 | 586 | 587 | 588 | // ------ 589 | // Creates Blobs for the Scripts so things can be self contained for snippets/JSFiddle/Codepen 590 | // even though they are using workers 591 | // 592 | (function() { 593 | const idsToUrls = []; 594 | const scriptElements = [...document.querySelectorAll('script[type=x-worker]')]; 595 | for (const scriptElement of scriptElements) { 596 | let text = scriptElement.text; 597 | for (const {id, url} of idsToUrls) { 598 | text = text.split(id).join(url); 599 | } 600 | const blob = new Blob([text], {type: 'application/javascript'}); 601 | const url = URL.createObjectURL(blob); 602 | const id = scriptElement.id; 603 | idsToUrls.push({id, url}); 604 | } 605 | window.getWorkerBlob = function() { 606 | return idsToUrls.pop().url; 607 | }; 608 | import(window.getWorkerBlob()); 609 | }()); 610 | `; 611 | return { 612 | js: init, 613 | html, 614 | }; 615 | } 616 | 617 | function openInCodepen() { 618 | const comment = `// ${g.title} 619 | // from ${g.url} 620 | 621 | 622 | `; 623 | getSourcesFromEditor(); 624 | const scripts = makeScriptsForWorkers(g.rootScriptInfo); 625 | const pen = { 626 | title : g.title, 627 | description : 'from: ' + g.url, 628 | tags : lessonEditorSettings.tags, 629 | editors : '101', 630 | html : scripts.html + fixHTMLForCodeSite(htmlParts.html.sources[0].source), 631 | css : htmlParts.css.sources[0].source, 632 | js : comment + fixJSForCodeSite(scripts.js), 633 | }; 634 | 635 | const elem = document.createElement('div'); 636 | elem.innerHTML = ` 637 | " 641 | `; 642 | elem.querySelector('input[name=data]').value = JSON.stringify(pen); 643 | window.frameElement.ownerDocument.body.appendChild(elem); 644 | elem.querySelector('form').submit(); 645 | window.frameElement.ownerDocument.body.removeChild(elem); 646 | } 647 | 648 | function openInJSFiddle() { 649 | const comment = `// ${g.title} 650 | // from ${g.url} 651 | 652 | `; 653 | 654 | getSourcesFromEditor(); 655 | const scripts = makeScriptsForWorkers(g.rootScriptInfo); 656 | 657 | const elem = document.createElement('div'); 658 | elem.innerHTML = ` 659 | 667 | `; 668 | elem.querySelector('input[name=html]').value = scripts.html + fixHTMLForCodeSite(htmlParts.html.sources[0].source); 669 | elem.querySelector('input[name=css]').value = htmlParts.css.sources[0].source; 670 | elem.querySelector('input[name=js]').value = comment + fixJSForCodeSite(scripts.js); 671 | elem.querySelector('input[name=title]').value = g.title; 672 | window.frameElement.ownerDocument.body.appendChild(elem); 673 | elem.querySelector('form').submit(); 674 | window.frameElement.ownerDocument.body.removeChild(elem); 675 | } 676 | 677 | function openInJSGist() { 678 | const comment = `// ${g.title} 679 | // from ${g.url} 680 | 681 | 682 | `; 683 | getSourcesFromEditor(); 684 | const scripts = makeScriptsForWorkers(g.rootScriptInfo); 685 | const gist = { 686 | name: g.title, 687 | settings: {}, 688 | files: [ 689 | { name: 'index.html', content: scripts.html + fixHTMLForCodeSite(htmlParts.html.sources[0].source), }, 690 | { name: 'index.css', content: htmlParts.css.sources[0].source, }, 691 | { name: 'index.js', content: comment + fixJSForCodeSite(scripts.js), }, 692 | ], 693 | }; 694 | 695 | window.open('https://jsgist.org/?newGist=true', '_blank'); 696 | const send = (e) => { 697 | e.source.postMessage({type: 'newGist', data: gist}, '*'); 698 | }; 699 | window.addEventListener('message', send, {once: true}); 700 | } 701 | 702 | /* 703 | 704 | 705 | 706 | 707 | 708 | console.log(); 709 | 710 | 711 | 712 | h1 { color: red; } 713 | 714 | 715 | 716 |

foo

717 | 718 | 719 | 720 | */ 721 | 722 | function indent4(s) { 723 | return s.split('\n').map(s => ` ${s}`).join('\n'); 724 | } 725 | 726 | function openInStackOverflow() { 727 | const comment = `// ${g.title} 728 | // from ${g.url} 729 | 730 | 731 | `; 732 | getSourcesFromEditor(); 733 | const scripts = makeScriptsForWorkers(g.rootScriptInfo); 734 | const mainHTML = scripts.html + fixHTMLForCodeSite(htmlParts.html.sources[0].source); 735 | const mainJS = comment + fixJSForCodeSite(scripts.js); 736 | const mainCSS = htmlParts.css.sources[0].source; 737 | const asModule = /\bimport\b/.test(mainJS); 738 | // Three.js wants us to use modules but Stack Overflow doesn't support them 739 | const text = asModule 740 | ? ` 741 | 742 | 743 | 744 | 745 | 746 | 747 | ${indent4(mainCSS)} 748 | 749 | 750 | 751 | ${indent4(mainHTML)} 752 | 755 | 756 | 757 | ` 758 | : ` 759 | 760 | 761 | 762 | 763 | ${indent4(mainJS)} 764 | 765 | 766 | 767 | ${indent4(mainCSS)} 768 | 769 | 770 | 771 | ${indent4(mainHTML)} 772 | 773 | 774 | `; 775 | const dialogElem = document.querySelector('.copy-dialog'); 776 | dialogElem.style.display = ''; 777 | const copyAreaElem = dialogElem.querySelector('.copy-area'); 778 | copyAreaElem.textContent = text; 779 | const linkElem = dialogElem.querySelector('a'); 780 | const tags = lessonEditorSettings.tags.filter(f => !f.endsWith('.org')).join(' '); 781 | linkElem.href = `https://stackoverflow.com/questions/ask?&tags=javascript ${tags}`; 782 | } 783 | 784 | document.querySelectorAll('.dialog').forEach(dialogElem => { 785 | dialogElem.addEventListener('click', function(e) { 786 | if (e.target === this) { 787 | this.style.display = 'none'; 788 | } 789 | }); 790 | dialogElem.addEventListener('keydown', function(e) { 791 | console.log(e.keyCode); 792 | if (e.keyCode === 27) { 793 | this.style.display = 'none'; 794 | } 795 | }) 796 | }); 797 | const exportDialogElem = document.querySelector('.export'); 798 | 799 | function openExport() { 800 | exportDialogElem.style.display = ''; 801 | exportDialogElem.firstElementChild.focus(); 802 | } 803 | 804 | function closeExport(fn) { 805 | return () => { 806 | exportDialogElem.style.display = 'none'; 807 | fn(); 808 | }; 809 | } 810 | document.querySelector('.button-export').addEventListener('click', openExport); 811 | 812 | function selectFile(info, ndx, fileDivs) { 813 | if (info.editors.length <= 1) { 814 | return; 815 | } 816 | info.editors.forEach((editorInfo, i) => { 817 | const selected = i === ndx; 818 | editorInfo.div.style.display = selected ? '' : 'none'; 819 | editorInfo.editor.layout(); 820 | addRemoveClass(fileDivs.children[i], 'fileSelected', selected); 821 | }); 822 | } 823 | 824 | function showEditorSubPane(type, ndx) { 825 | const info = htmlParts[type]; 826 | selectFile(info, ndx, info.files); 827 | } 828 | 829 | function setupEditor() { 830 | 831 | forEachHTMLPart(function(info, ndx, name) { 832 | info.pane = document.querySelector('.panes>.' + name); 833 | info.code = info.pane.querySelector('.code'); 834 | info.files = info.pane.querySelector('.files'); 835 | info.editors = info.sources.map((sourceInfo, ndx) => { 836 | if (info.sources.length > 1) { 837 | const div = document.createElement('div'); 838 | div.textContent = basename(sourceInfo.name); 839 | info.files.appendChild(div); 840 | div.addEventListener('click', () => { 841 | selectFile(info, ndx, info.files); 842 | }); 843 | } 844 | const div = document.createElement('div'); 845 | info.code.appendChild(div); 846 | const editor = runEditor(div, sourceInfo.source, info.language); 847 | sourceInfo.editor = editor; 848 | return { 849 | div, 850 | editor, 851 | }; 852 | }); 853 | info.button = document.querySelector('.button-' + name); 854 | info.button.addEventListener('click', function() { 855 | toggleSourcePane(info.button); 856 | runIfNeeded(); 857 | }); 858 | }); 859 | 860 | g.fullscreen = document.querySelector('.button-fullscreen'); 861 | g.fullscreen.addEventListener('click', toggleFullscreen); 862 | 863 | g.run = document.querySelector('.button-run'); 864 | g.run.addEventListener('click', run); 865 | 866 | g.iframe = document.querySelector('.result>iframe'); 867 | g.other = document.querySelector('.panes .other'); 868 | 869 | document.querySelector('.button-codepen').addEventListener('click', closeExport(openInCodepen)); 870 | document.querySelector('.button-jsfiddle').addEventListener('click', closeExport(openInJSFiddle)); 871 | document.querySelector('.button-jsgist').addEventListener('click', closeExport(openInJSGist)); 872 | document.querySelector('.button-stackoverflow').addEventListener('click', closeExport(openInStackOverflow)); 873 | 874 | g.result = document.querySelector('.panes .result'); 875 | g.resultButton = document.querySelector('.button-result'); 876 | g.resultButton.addEventListener('click', function() { 877 | toggleResultPane(); 878 | runIfNeeded(); 879 | }); 880 | g.result.style.display = 'none'; 881 | toggleResultPane(); 882 | 883 | if (window.innerWidth >= 1000) { 884 | toggleSourcePane(htmlParts.js.button); 885 | } 886 | 887 | window.addEventListener('resize', resize); 888 | 889 | showEditorSubPane('js', 0); 890 | showOtherIfAllPanesOff(); 891 | document.querySelector('.other .loading').style.display = 'none'; 892 | 893 | resize(); 894 | run(); 895 | } 896 | 897 | function toggleFullscreen() { 898 | try { 899 | toggleIFrameFullscreen(window); 900 | resize(); 901 | runIfNeeded(); 902 | } catch (e) { 903 | console.error(e); // eslint-disable-line 904 | } 905 | } 906 | 907 | function runIfNeeded() { 908 | if (runOnResize) { 909 | run(); 910 | } 911 | } 912 | 913 | function run(options) { 914 | g.setPosition = false; 915 | const url = getSourceBlobFromEditor(options); 916 | // g.iframe.src = url; 917 | // work around firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1828286 918 | g.iframe.contentWindow.location.replace(url); 919 | } 920 | 921 | function addClass(elem, className) { 922 | const parts = elem.className.split(' '); 923 | if (parts.indexOf(className) < 0) { 924 | elem.className = elem.className + ' ' + className; 925 | } 926 | } 927 | 928 | function removeClass(elem, className) { 929 | const parts = elem.className.split(' '); 930 | const numParts = parts.length; 931 | for (;;) { 932 | const ndx = parts.indexOf(className); 933 | if (ndx < 0) { 934 | break; 935 | } 936 | parts.splice(ndx, 1); 937 | } 938 | if (parts.length !== numParts) { 939 | elem.className = parts.join(' '); 940 | return true; 941 | } 942 | return false; 943 | } 944 | 945 | function toggleClass(elem, className) { 946 | if (removeClass(elem, className)) { 947 | return false; 948 | } else { 949 | addClass(elem, className); 950 | return true; 951 | } 952 | } 953 | 954 | function toggleIFrameFullscreen(childWindow) { 955 | const frame = childWindow.frameElement; 956 | if (frame) { 957 | const isFullScreen = toggleClass(frame, 'fullscreen'); 958 | frame.ownerDocument.body.style.overflow = isFullScreen ? 'hidden' : ''; 959 | } 960 | } 961 | 962 | 963 | function addRemoveClass(elem, className, add) { 964 | if (add) { 965 | addClass(elem, className); 966 | } else { 967 | removeClass(elem, className); 968 | } 969 | } 970 | 971 | function toggleSourcePane(pressedButton) { 972 | forEachHTMLPart(function(info) { 973 | const pressed = pressedButton === info.button; 974 | if (pressed && !info.showing) { 975 | addClass(info.button, 'show'); 976 | info.pane.style.display = 'flex'; 977 | info.showing = true; 978 | } else { 979 | removeClass(info.button, 'show'); 980 | info.pane.style.display = 'none'; 981 | info.showing = false; 982 | } 983 | }); 984 | showOtherIfAllPanesOff(); 985 | resize(); 986 | } 987 | 988 | function showingResultPane() { 989 | return g.result.style.display !== 'none'; 990 | } 991 | function toggleResultPane() { 992 | const showing = showingResultPane(); 993 | g.result.style.display = showing ? 'none' : 'block'; 994 | addRemoveClass(g.resultButton, 'show', !showing); 995 | showOtherIfAllPanesOff(); 996 | resize(); 997 | } 998 | 999 | function showOtherIfAllPanesOff() { 1000 | let paneOn = showingResultPane(); 1001 | forEachHTMLPart(function(info) { 1002 | paneOn = paneOn || info.showing; 1003 | }); 1004 | g.other.style.display = paneOn ? 'none' : 'block'; 1005 | } 1006 | 1007 | // seems like we should probably store a map 1008 | function getEditorNdxByBlobUrl(type, url) { 1009 | return htmlParts[type].sources.findIndex(source => source.scriptInfo.blobUrl === url); 1010 | } 1011 | 1012 | function getActualLineNumberAndMoveTo(url, lineNo, colNo) { 1013 | let origUrl = url; 1014 | let actualLineNo = lineNo; 1015 | const scriptInfo = Object.values(g.scriptInfos).find(scriptInfo => scriptInfo.blobUrl === url); 1016 | if (scriptInfo) { 1017 | actualLineNo = lineNo - scriptInfo.numLinesBeforeScript; 1018 | origUrl = basename(scriptInfo.fqURL); 1019 | if (!g.setPosition) { 1020 | // Only set the first position 1021 | g.setPosition = true; 1022 | const editorNdx = getEditorNdxByBlobUrl('js', url); 1023 | if (editorNdx >= 0) { 1024 | showEditorSubPane('js', editorNdx); 1025 | const editor = htmlParts.js.editors[editorNdx].editor; 1026 | editor.setPosition({ 1027 | lineNumber: actualLineNo, 1028 | column: colNo, 1029 | }); 1030 | editor.revealLineInCenterIfOutsideViewport(actualLineNo); 1031 | if (g.visible) { 1032 | editor.focus(); 1033 | } 1034 | } 1035 | } 1036 | } 1037 | return {origUrl, actualLineNo}; 1038 | } 1039 | 1040 | window.getActualLineNumberAndMoveTo = getActualLineNumberAndMoveTo; 1041 | 1042 | const darkMatcher = window.matchMedia("(prefers-color-scheme: dark)"); 1043 | darkMatcher.addEventListener('change', () => { 1044 | const isDarkMode = darkMatcher.matches; 1045 | monaco?.editor?.setTheme(isDarkMode ? 'vs-dark' : 'vs'); 1046 | }); 1047 | 1048 | function runEditor(parent, source, language) { 1049 | const isDarkMode = darkMatcher.matches; 1050 | return monaco.editor.create(parent, { 1051 | value: source, 1052 | language: language, 1053 | //lineNumbers: false, 1054 | theme: isDarkMode ? 'vs-dark' : 'vs', 1055 | disableTranslate3d: true, 1056 | // model: null, 1057 | scrollBeyondLastLine: false, 1058 | minimap: { enabled: false }, 1059 | }); 1060 | } 1061 | 1062 | async function runAsBlob() { 1063 | const query = getQuery(); 1064 | g.url = getFQUrl(query.url); 1065 | g.query = getSearch(g.url); 1066 | let html; 1067 | try { 1068 | html = await getHTML(query.url); 1069 | } catch (err) { 1070 | console.log(err); // eslint-disable-line 1071 | return; 1072 | } 1073 | await parseHTML(query.url, html); 1074 | window.location.href = getSourceBlobFromOrig(); 1075 | } 1076 | 1077 | function applySubstitutions() { 1078 | [...document.querySelectorAll('[data-subst]')].forEach((elem) => { 1079 | elem.dataset.subst.split('&').forEach((pair) => { 1080 | const [attr, key] = pair.split('|'); 1081 | elem[attr] = lessonEditorSettings[key]; 1082 | }); 1083 | }); 1084 | } 1085 | 1086 | function start() { 1087 | const parentQuery = getQuery(window.parent.location.search); 1088 | const isSmallish = window.navigator.userAgent.match(/Android|iPhone|iPod|Windows Phone/i); 1089 | const isEdge = window.navigator.userAgent.match(/Edge/i); 1090 | if (isEdge || isSmallish || parentQuery.editor === 'false') { 1091 | runAsBlob(); 1092 | // var url = query.url; 1093 | // window.location.href = url; 1094 | } else { 1095 | applySubstitutions(); 1096 | require.config({ paths: { 'vs': '/monaco-editor/min/vs' }}); 1097 | require(['vs/editor/editor.main'], main); 1098 | } 1099 | } 1100 | 1101 | start(); 1102 | }()); 1103 | 1104 | 1105 | 1106 | --------------------------------------------------------------------------------