├── .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 |
12 |
--------------------------------------------------------------------------------
/src/editor-unfullscreen-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |
293 |
294 |
295 |
296 |
297 |
Export To:
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
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}>${kScript}>`);
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}${kScript}>`);
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(/\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 |
--------------------------------------------------------------------------------