p5.js-web-editor/client/modules/IDE/components/PreviewFrame.jsx
Cassie Tarakajian 81475c0e16 Squashed commit of the following:
commit fb5e82cea930b011792983c7d1cc9f6ecacc7dd4
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Wed Nov 16 12:28:10 2016 -0500

    add server side rendering, untested

commit 5c60fb30c46ea49a8d9a0ecb56f39ec778464a8b
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Tue Nov 15 18:26:06 2016 -0500

    add redux-form bandage post react update, should probably update to redux-form 6 at some point

commit 057b5871e7137179abc93f7821a9690f0ea52c92
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Tue Nov 15 16:30:09 2016 -0500

    remove passing jsFiles and cssFiles to PreviewFrame, fix rendering bug

commit 88c56fd36d3a8d88902c79642171988ce37825f2
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Tue Nov 15 16:21:59 2016 -0500

    code cleanup, untested

commit 82e5dcf8bca461892f1daf06d38f1eaebe72983f
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Tue Nov 15 15:53:50 2016 -0500

    update react and react router, fix a few bugs in rendering code, add ability to parse inline js and css

commit e02f4b67803ea45328eff4e53659222f3149964c
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Tue Nov 15 14:43:38 2016 -0500

    add almost full code to create preview html correctly, untested

commit 12f61b2a1aed4607fab24d01572b647ca6210262
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Wed Nov 2 17:09:26 2016 -0400

    refactor some of the preview html generation code

commit 111825846703d5c8959cb18795a3aadb7ebe505c
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Wed Nov 2 11:06:36 2016 -0400

    add comments as plan of action

commit 1cc2cf5203674732b4057382f1937de38b687078
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Thu Oct 27 19:34:55 2016 -0400

    add href parsing

commit e67189298cda9b70645f454ecd541a363980f0e4
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Thu Oct 27 10:48:36 2016 -0400

    continue parsing html

commit 1458fb940a15a3dc5d74890211a3073e920b84b8
Author: Cassie Tarakajian <ctarakajian@gmail.com>
Date:   Wed Oct 26 17:40:31 2016 -0400

    start to add html parsing
2016-11-16 13:18:46 -05:00

368 lines
12 KiB
JavaScript

import React, { PropTypes } from 'react';
import ReactDOM from 'react-dom';
// import escapeStringRegexp from 'escape-string-regexp';
import srcDoc from 'srcdoc-polyfill';
import loopProtect from 'loop-protect';
import { getBlobUrl } from '../actions/files';
import { resolvePathToFile } from '../../../../server/utils/filePath';
const startTag = '@fs-';
const MEDIA_FILE_REGEX = /^('|")(?!(http:\/\/|https:\/\/)).*\.(png|jpg|jpeg|gif|bmp|mp3|wav|aiff|ogg|json|txt|csv|svg|obj|mp4|ogg|webm|mov)('|")$/i;
const MEDIA_FILE_REGEX_NO_QUOTES = /^(?!(http:\/\/|https:\/\/)).*\.(png|jpg|jpeg|gif|bmp|mp3|wav|aiff|ogg|json|txt|csv|svg|obj|mp4|ogg|webm|mov)$/i;
const STRING_REGEX = /(['"])((\\\1|.)*?)\1/gm;
const TEXT_FILE_REGEX = /(.+\.json$|.+\.txt$|.+\.csv$)/i;
const NOT_EXTERNAL_LINK_REGEX = /^(?!(http:\/\/|https:\/\/))/;
const EXTERNAL_LINK_REGEX = /^(http:\/\/|https:\/\/)/;
function getAllScriptOffsets(htmlFile) {
const offs = [];
let found = true;
let lastInd = 0;
let ind = 0;
let endFilenameInd = 0;
let filename = '';
let lineOffset = 0;
while (found) {
ind = htmlFile.indexOf(startTag, lastInd);
if (ind === -1) {
found = false;
} else {
endFilenameInd = htmlFile.indexOf('.js', ind + startTag.length + 3);
filename = htmlFile.substring(ind + startTag.length, endFilenameInd);
lineOffset = htmlFile.substring(0, ind).split('\n').length;
offs.push([lineOffset, filename]);
lastInd = ind + 1;
}
}
return offs;
}
function hijackConsoleErrorsScript(offs) {
const s = `
function getScriptOff(line) {
var offs = ${offs};
var l = 0;
var file = '';
for (var i=0; i<offs.length; i++) {
var n = offs[i][0];
if (n < line && n > l) {
l = n;
file = offs[i][1];
}
}
return [line - l, file];
}
// catch reference errors, via http://stackoverflow.com/a/12747364/2994108
window.onerror = function (msg, url, lineNumber, columnNo, error) {
var string = msg.toLowerCase();
var substring = "script error";
var data = {};
if (string.indexOf(substring) !== -1){
data = 'Script Error: See Browser Console for Detail';
} else {
var fileInfo = getScriptOff(lineNumber);
data = msg + ' (' + fileInfo[1] + ': line ' + fileInfo[0] + ')';
}
window.parent.postMessage([{
method: 'error',
arguments: data,
source: 'sketch'
}], '*');
return false;
};
`;
return s;
}
class PreviewFrame extends React.Component {
componentDidMount() {
if (this.props.isPlaying) {
this.renderFrameContents();
}
if (this.props.dispatchConsoleEvent) {
window.addEventListener('message', (msg) => {
this.props.dispatchConsoleEvent(msg);
});
}
}
componentDidUpdate(prevProps) {
// if sketch starts or stops playing, want to rerender
if (this.props.isPlaying !== prevProps.isPlaying) {
this.renderSketch();
return;
}
// if the user explicitly clicks on the play button
if (this.props.isPlaying && this.props.previewIsRefreshing) {
this.renderSketch();
return;
}
// if user switches textoutput preferences
if (this.props.isTextOutputPlaying !== prevProps.isTextOutputPlaying) {
this.renderSketch();
return;
}
if (this.props.textOutput !== prevProps.textOutput) {
this.renderSketch();
return;
}
if (this.props.fullView && this.props.files[0].id !== prevProps.files[0].id) {
this.renderSketch();
return;
}
// small bug - if autorefresh is on, and the usr changes files
// in the sketch, preview will reload
}
componentWillUnmount() {
ReactDOM.unmountComponentAtNode(ReactDOM.findDOMNode(this).contentDocument.body);
}
clearPreview() {
const doc = ReactDOM.findDOMNode(this);
doc.srcDoc = '';
}
injectLocalFiles() {
const htmlFile = this.props.htmlFile.content;
let scriptOffs = [];
const resolvedFiles = this.resolveJSAndCSSLinks(this.props.files);
const parser = new DOMParser();
const sketchDoc = parser.parseFromString(htmlFile, 'text/html');
this.resolvePathsForElementsWithAttribute('src', sketchDoc, resolvedFiles);
this.resolvePathsForElementsWithAttribute('href', sketchDoc, resolvedFiles);
// should also include background, data, poster, but these are used way less often
this.resolveScripts(sketchDoc, resolvedFiles);
this.resolveStyles(sketchDoc, resolvedFiles);
let scriptsToInject = [
'/loop-protect.min.js',
'/hijackConsole.js'
];
if (this.props.isTextOutputPlaying) {
let interceptorScripts = [];
if (this.props.textOutput === 1) {
interceptorScripts = [
'/interceptor/loadData.js',
'/interceptor/intercept-helper-functions.js',
'/interceptor/textInterceptor/interceptor-functions.js',
'/interceptor/textInterceptor/intercept-p5.js',
'/interceptor/ntc.min.js'
];
} else if (this.props.textOutput === 2) {
interceptorScripts = [
'/interceptor/loadData.js',
'/interceptor/intercept-helper-functions.js',
'/interceptor/gridInterceptor/interceptor-functions.js',
'/interceptor/gridInterceptor/intercept-p5.js',
'/interceptor/ntc.min.js'
];
} else if (this.props.textOutput === 3) {
interceptorScripts = [
'/interceptor/loadData.js',
'/interceptor/soundInterceptor/intercept-p5.js'
];
}
scriptsToInject = scriptsToInject.concat(interceptorScripts);
}
scriptsToInject.forEach(scriptToInject => {
const script = sketchDoc.createElement('script');
script.src = scriptToInject;
sketchDoc.head.appendChild(script);
});
const sketchDocString = `<!DOCTYPE HTML>\n${sketchDoc.documentElement.outerHTML}`;
scriptOffs = getAllScriptOffsets(sketchDocString);
const consoleErrorsScript = sketchDoc.createElement('script');
consoleErrorsScript.innerHTML = hijackConsoleErrorsScript(JSON.stringify(scriptOffs));
sketchDoc.head.appendChild(consoleErrorsScript);
return `<!DOCTYPE HTML>\n${sketchDoc.documentElement.outerHTML}`;
}
resolvePathsForElementsWithAttribute(attr, sketchDoc, files) {
const elements = sketchDoc.querySelectorAll(`[${attr}]`);
elements.forEach(element => {
if (element.getAttribute(attr).match(MEDIA_FILE_REGEX_NO_QUOTES)) {
const resolvedFile = resolvePathToFile(element.getAttribute(attr), files);
if (resolvedFile) {
element.setAttribute(attr, resolvedFile.url);
}
}
});
}
resolveJSAndCSSLinks(files) {
const newFiles = [];
files.forEach(file => {
const newFile = { ...file };
if (file.name.match(/.*\.js$/i)) {
newFile.content = this.resolveJSLinksInString(newFile.content, files);
} else if (file.name.match(/.*\.css$/i)) {
newFile.content = this.resolveCSSLinksInString(newFile.content, files);
}
newFiles.push(newFile);
});
return newFiles;
}
resolveJSLinksInString(content, files) {
let newContent = content;
let jsFileStrings = content.match(STRING_REGEX);
jsFileStrings = jsFileStrings || [];
jsFileStrings.forEach(jsFileString => {
if (jsFileString.match(MEDIA_FILE_REGEX)) {
const filePath = jsFileString.substr(1, jsFileString.length - 2);
const resolvedFile = resolvePathToFile(filePath, files);
if (resolvedFile) {
if (resolvedFile.url) {
newContent = newContent.replace(filePath, resolvedFile.url);
} else if (resolvedFile.name.match(TEXT_FILE_REGEX)) {
// could also pull file from API instead of using bloburl
const blobURL = getBlobUrl(resolvedFile);
this.props.setBlobUrl(resolvedFile, blobURL);
newContent = newContent.replace(filePath, blobURL);
}
}
}
});
newContent = loopProtect(newContent);
return newContent;
}
resolveCSSLinksInString(content, files) {
let newContent = content;
let cssFileStrings = content.match(STRING_REGEX);
cssFileStrings = cssFileStrings || [];
cssFileStrings.forEach(cssFileString => {
if (cssFileString.match(MEDIA_FILE_REGEX)) {
const filePath = cssFileString.substr(1, cssFileString.length - 2);
const resolvedFile = resolvePathToFile(filePath, files);
if (resolvedFile) {
if (resolvedFile.url) {
newContent = newContent.replace(filePath, resolvedFile.url);
}
}
}
});
return newContent;
}
resolveScripts(sketchDoc, files) {
const scriptsInHTML = sketchDoc.getElementsByTagName('script');
const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML);
scriptsInHTMLArray.forEach(script => {
if (script.getAttribute('src') && script.getAttribute('src').match(NOT_EXTERNAL_LINK_REGEX) !== null) {
const resolvedFile = resolvePathToFile(script.getAttribute('src'), files);
if (resolvedFile) {
if (resolvedFile.url) {
script.setAttribute('src', resolvedFile.url);
} else {
script.removeAttribute('src');
script.innerHTML = resolvedFile.content; // eslint-disable-line
}
}
} else if (!(script.getAttribute('src') && script.getAttribute('src').match(EXTERNAL_LINK_REGEX)) !== null) {
script.innerHTML = this.resolveJSLinksInString(script.innerHTML, files); // eslint-disable-line
}
});
}
resolveStyles(sketchDoc, files) {
const inlineCSSInHTML = sketchDoc.getElementsByTagName('style');
const inlineCSSInHTMLArray = Array.prototype.slice.call(inlineCSSInHTML);
inlineCSSInHTMLArray.forEach(style => {
style.innerHTML = this.resolveCSSLinksInString(style.innerHTML, files); // eslint-disable-line
});
const cssLinksInHTML = sketchDoc.querySelectorAll('link[rel="stylesheet"]');
cssLinksInHTML.forEach(css => {
if (css.getAttribute('href') && css.getAttribute('href').match(NOT_EXTERNAL_LINK_REGEX) !== null) {
const resolvedFile = resolvePathToFile(css.getAttribute('href'), files);
if (resolvedFile) {
if (resolvedFile.url) {
css.href = resolvedFile.url; // eslint-disable-line
} else {
const style = sketchDoc.createElement('style');
style.innerHTML = `\n${resolvedFile.content}`;
sketchDoc.head.appendChild(style);
css.parentElement.removeChild(css);
}
}
}
});
}
renderSketch() {
const doc = ReactDOM.findDOMNode(this);
if (this.props.isPlaying) {
srcDoc.set(doc, this.injectLocalFiles());
if (this.props.endSketchRefresh) {
this.props.endSketchRefresh();
}
} else {
doc.srcdoc = '';
srcDoc.set(doc, ' ');
}
}
renderFrameContents() {
const doc = ReactDOM.findDOMNode(this).contentDocument;
if (doc.readyState === 'complete') {
this.renderSketch();
} else {
setTimeout(this.renderFrameContents, 0);
}
}
render() {
return (
<iframe
className="preview-frame"
aria-label="sketch output"
role="main"
tabIndex="0"
frameBorder="0"
ref="iframe"
title="sketch output"
sandbox="allow-scripts allow-pointer-lock allow-same-origin allow-popups allow-forms"
/>
);
}
}
PreviewFrame.propTypes = {
isPlaying: PropTypes.bool.isRequired,
isTextOutputPlaying: PropTypes.bool.isRequired,
textOutput: PropTypes.number.isRequired,
content: PropTypes.string,
htmlFile: PropTypes.shape({
content: PropTypes.string.isRequired
}),
files: PropTypes.array.isRequired,
dispatchConsoleEvent: PropTypes.func,
children: PropTypes.element,
autorefresh: PropTypes.bool.isRequired,
endSketchRefresh: PropTypes.func.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired,
fullView: PropTypes.bool,
setBlobUrl: PropTypes.func.isRequired
};
export default PreviewFrame;