- {consoleEvents.map((consoleEvent) => {
- const { method, times } = consoleEvent;
- return (
-
);
diff --git a/client/modules/IDE/components/ConsoleInput.jsx b/client/modules/IDE/components/ConsoleInput.jsx
new file mode 100644
index 0000000000..d70fcec087
--- /dev/null
+++ b/client/modules/IDE/components/ConsoleInput.jsx
@@ -0,0 +1,139 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import CodeMirror from 'codemirror';
+import { Encode } from 'console-feed';
+
+import RightArrowIcon from '../../../images/right-arrow.svg';
+import { dispatch } from '../../../utils/dispatcher';
+
+// heavily inspired by
+// https://github.com/codesandbox/codesandbox-client/blob/92a1131f4ded6f7d9c16945dc7c18aa97c8ada27/packages/app/src/app/components/Preview/DevTools/Console/Input/index.tsx
+
+class ConsoleInput extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ commandHistory: [],
+ commandCursor: -1
+ };
+ }
+
+ componentDidMount() {
+ this._cm = CodeMirror(this.codemirrorContainer, { // eslint-disable-line
+ theme: `p5-${this.props.theme}`,
+ scrollbarStyle: null,
+ keymap: 'sublime',
+ mode: 'javascript',
+ inputStyle: 'contenteditable'
+ });
+
+ this._cm.on('keydown', (cm, e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ e.stopPropagation();
+ const value = cm.getValue();
+ if (value.trim(' ') === '') {
+ return false;
+ }
+ const messages = [
+ { log: Encode({ method: 'command', data: [value] }) }
+ ];
+ const consoleEvent = [{ method: 'command', data: [value] }];
+ dispatch({
+ source: 'console',
+ messages
+ });
+ this.props.dispatchConsoleEvent(consoleEvent);
+ cm.setValue('');
+ this.setState((state) => ({
+ commandCursor: -1,
+ commandHistory: [value, ...state.commandHistory]
+ }));
+ } else if (e.key === 'ArrowUp') {
+ const lineNumber = this._cm.getDoc().getCursor().line;
+ if (lineNumber !== 0) {
+ return false;
+ }
+
+ this.setState((state) => {
+ const newCursor = Math.min(
+ state.commandCursor + 1,
+ state.commandHistory.length - 1
+ );
+ this._cm.getDoc().setValue(state.commandHistory[newCursor] || '');
+ const cursorPos = this._cm.getDoc().getLine(0).length - 1;
+ this._cm.getDoc().setCursor({ line: 0, ch: cursorPos });
+ return { commandCursor: newCursor };
+ });
+ } else if (e.key === 'ArrowDown') {
+ const lineNumber = this._cm.getDoc().getCursor().line;
+ const lineCount = this._cm.getValue().split('\n').length;
+ if (lineNumber + 1 !== lineCount) {
+ return false;
+ }
+
+ this.setState((state) => {
+ const newCursor = Math.max(state.commandCursor - 1, -1);
+ this._cm.getDoc().setValue(state.commandHistory[newCursor] || '');
+ const newLineCount = this._cm.getValue().split('\n').length;
+ const newLine = this._cm.getDoc().getLine(newLineCount);
+ const cursorPos = newLine ? newLine.length - 1 : 1;
+ this._cm.getDoc().setCursor({ line: lineCount, ch: cursorPos });
+ return { commandCursor: newCursor };
+ });
+ }
+ return true;
+ });
+
+ this._cm.getWrapperElement().style[
+ 'font-size'
+ ] = `${this.props.fontSize}px`;
+ }
+
+ componentDidUpdate(prevProps) {
+ this._cm.setOption('theme', `p5-${this.props.theme}`);
+ this._cm.getWrapperElement().style[
+ 'font-size'
+ ] = `${this.props.fontSize}px`;
+ this._cm.refresh();
+ }
+
+ componentWillUnmount() {
+ this._cm = null;
+ }
+
+ render() {
+ return (
+
+
+
+
+
{
+ this.codemirrorContainer = element;
+ }}
+ className="console__editor"
+ />
+
+ );
+ }
+}
+
+ConsoleInput.propTypes = {
+ theme: PropTypes.string.isRequired,
+ dispatchConsoleEvent: PropTypes.func.isRequired,
+ fontSize: PropTypes.number.isRequired
+};
+
+export default ConsoleInput;
diff --git a/client/modules/IDE/components/Editor.jsx b/client/modules/IDE/components/Editor.jsx
index 6ba1a6c5d0..5be24e3ff9 100644
--- a/client/modules/IDE/components/Editor.jsx
+++ b/client/modules/IDE/components/Editor.jsx
@@ -222,6 +222,7 @@ class Editor extends React.Component {
const oldDoc = this._cm.swapDoc(this._docs[this.props.file.id]);
this._docs[prevProps.file.id] = oldDoc;
this._cm.focus();
+
if (!prevProps.unsavedChanges) {
setTimeout(() => this.props.setUnsavedChanges(false), 400);
}
diff --git a/client/modules/IDE/components/PreviewFrame.jsx b/client/modules/IDE/components/PreviewFrame.jsx
index 2762cd0bac..ea5cee18b0 100644
--- a/client/modules/IDE/components/PreviewFrame.jsx
+++ b/client/modules/IDE/components/PreviewFrame.jsx
@@ -1,14 +1,13 @@
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
-import { isEqual } from 'lodash';
+// import escapeStringRegexp from 'escape-string-regexp';
import srcDoc from 'srcdoc-polyfill';
import loopProtect from 'loop-protect';
import { JSHINT } from 'jshint';
import decomment from 'decomment';
import classNames from 'classnames';
import { connect } from 'react-redux';
-import { Decode } from 'console-feed';
import { getBlobUrl } from '../actions/files';
import { resolvePathToFile } from '../../../../server/utils/filePath';
import {
@@ -24,6 +23,7 @@ import {
startTag,
getAllScriptOffsets
} from '../../../utils/consoleUtils';
+import { registerFrame } from '../../../utils/dispatcher';
import { getHTMLFile } from '../reducers/files';
@@ -35,6 +35,7 @@ import {
} from '../actions/preferences';
import { setBlobUrl } from '../actions/files';
import { clearConsole, dispatchConsoleEvent } from '../actions/console';
+import getConfig from '../../../utils/getConfig';
const shouldRenderSketch = (props, prevProps = undefined) => {
const { isPlaying, previewIsRefreshing, fullView } = props;
@@ -57,18 +58,18 @@ const shouldRenderSketch = (props, prevProps = undefined) => {
class PreviewFrame extends React.Component {
constructor(props) {
super(props);
- this.handleConsoleEvent = this.handleConsoleEvent.bind(this);
+
+ this.iframe = React.createRef();
}
componentDidMount() {
- window.addEventListener('message', this.handleConsoleEvent);
-
const props = {
...this.props,
previewIsRefreshing: this.props.previewIsRefreshing,
isAccessibleOutputPlaying: this.props.isAccessibleOutputPlaying
};
if (shouldRenderSketch(props)) this.renderSketch();
+ registerFrame(this.iframe.current.contentWindow);
}
componentDidUpdate(prevProps) {
@@ -78,60 +79,12 @@ class PreviewFrame extends React.Component {
}
componentWillUnmount() {
- window.removeEventListener('message', this.handleConsoleEvent);
- const iframeBody = this.iframeElement.contentDocument.body;
+ const iframeBody = this.iframe.current.contentDocument.body;
if (iframeBody) {
ReactDOM.unmountComponentAtNode(iframeBody);
}
}
- handleConsoleEvent(messageEvent) {
- if (Array.isArray(messageEvent.data)) {
- const decodedMessages = messageEvent.data.map((message) =>
- Object.assign(Decode(message.log), {
- source: message.source
- })
- );
-
- decodedMessages.every((message, index, arr) => {
- const { data: args } = message;
- let hasInfiniteLoop = false;
- Object.keys(args).forEach((key) => {
- if (
- typeof args[key] === 'string' &&
- args[key].includes('Exiting potential infinite loop')
- ) {
- this.props.stopSketch();
- this.props.expandConsole();
- hasInfiniteLoop = true;
- }
- });
- if (hasInfiniteLoop) {
- return false;
- }
- if (index === arr.length - 1) {
- Object.assign(message, { times: 1 });
- return false;
- }
- const cur = Object.assign(message, { times: 1 });
- const nextIndex = index + 1;
- while (
- isEqual(cur.data, arr[nextIndex].data) &&
- cur.method === arr[nextIndex].method
- ) {
- cur.times += 1;
- arr.splice(nextIndex, 1);
- if (nextIndex === arr.length) {
- return false;
- }
- }
- return true;
- });
-
- this.props.dispatchConsoleEvent(decodedMessages);
- }
- }
-
addLoopProtect(sketchDoc) {
const scriptsInHTML = sketchDoc.getElementsByTagName('script');
const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML);
@@ -216,7 +169,7 @@ class PreviewFrame extends React.Component {
}
const previewScripts = sketchDoc.createElement('script');
- previewScripts.src = '/previewScripts.js';
+ previewScripts.src = getConfig('PREVIEW_SCRIPTS_URL');
sketchDoc.head.appendChild(previewScripts);
const sketchDocString = `\n${sketchDoc.documentElement.outerHTML}`;
@@ -382,7 +335,7 @@ class PreviewFrame extends React.Component {
}
renderSketch() {
- const doc = this.iframeElement;
+ const doc = this.iframe.current;
const localFiles = this.injectLocalFiles();
if (this.props.isPlaying) {
this.props.clearConsole();
@@ -411,9 +364,7 @@ class PreviewFrame extends React.Component {
role="main"
frameBorder="0"
title="sketch preview"
- ref={(element) => {
- this.iframeElement = element;
- }}
+ ref={this.iframe}
sandbox={sandboxAttributes}
/>
);
@@ -437,13 +388,10 @@ PreviewFrame.propTypes = {
id: PropTypes.string.isRequired
})
).isRequired,
- dispatchConsoleEvent: PropTypes.func.isRequired,
endSketchRefresh: PropTypes.func.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired,
fullView: PropTypes.bool,
setBlobUrl: PropTypes.func.isRequired,
- stopSketch: PropTypes.func.isRequired,
- expandConsole: PropTypes.func.isRequired,
clearConsole: PropTypes.func.isRequired,
cmController: PropTypes.shape({
getContent: PropTypes.func
diff --git a/client/utils/custom-hooks.js b/client/modules/IDE/hooks/custom-hooks.js
similarity index 100%
rename from client/utils/custom-hooks.js
rename to client/modules/IDE/hooks/custom-hooks.js
diff --git a/client/modules/IDE/pages/MobileIDEView.jsx b/client/modules/IDE/pages/MobileIDEView.jsx
index 128db1a12a..201ad4e5c0 100644
--- a/client/modules/IDE/pages/MobileIDEView.jsx
+++ b/client/modules/IDE/pages/MobileIDEView.jsx
@@ -46,7 +46,7 @@ import { getIsUserOwner } from '../selectors/users';
import {
useEffectWithComparison,
useEventListener
-} from '../../../utils/custom-hooks';
+} from '../hooks/custom-hooks';
import * as device from '../../../utils/device';
diff --git a/client/styles/abstracts/_variables.scss b/client/styles/abstracts/_variables.scss
index 8176a275a5..b9a16d87a3 100644
--- a/client/styles/abstracts/_variables.scss
+++ b/client/styles/abstracts/_variables.scss
@@ -65,7 +65,11 @@ $themes: (
icon-toast-hover-color: $lightest,
shadow-color: rgba(0, 0, 0, 0.16),
console-background-color: $light,
- console-color: $lightest,
+ console-input-background-color: $lightest,
+ console-color: $darker,
+ console-logged-times-color: $lightest,
+ console-arrow-color: $middle-gray,
+ console-active-arrow-color: #0071AD,
console-header-background-color: $medium-light,
console-header-color: $darker,
console-info-background-color: #5276B7,
@@ -143,7 +147,11 @@ $themes: (
icon-toast-hover-color: $lightest,
shadow-color: rgba(0, 0, 0, 0.16),
console-background-color: $dark,
+ console-input-background-color: $darker,
console-color: $lightest,
+ console-logged-times-color: $dark,
+ console-arrow-color: $medium-light,
+ console-active-arrow-color: #097BB3,
console-header-background-color: $medium-dark,
console-header-color: $lightest,
console-info-background-color: #5276B7,
@@ -219,7 +227,11 @@ $themes: (
icon-toast-hover-color: $yellow,
shadow-color: rgba(0, 0, 0, 0.16),
console-background-color: $dark,
- console-color: $black,
+ console-input-background-color: $darker,
+ console-color: $lightest,
+ console-logged-times-color: $darker,
+ console-arrow-color: $lightest,
+ console-active-arrow-color: $dodgerblue,
console-header-background-color: $medium-dark,
console-header-color: $lightest,
console-info-background-color: $lightsteelblue,
diff --git a/client/styles/components/_console-input.scss b/client/styles/components/_console-input.scss
new file mode 100644
index 0000000000..84db1f3387
--- /dev/null
+++ b/client/styles/components/_console-input.scss
@@ -0,0 +1,52 @@
+.console__input {
+ width: 100%;
+ display: flex;
+ align-items: start;
+ @include themify() {
+ background-color: getThemifyVariable('console-input-background-color');
+ }
+}
+
+.console__input .console-active__arrow {
+ width: auto;
+ height: 38%;
+ & path {
+ @include themify() {
+ fill: getThemifyVariable('console-active-arrow-color');
+ opacity: 1;
+ }
+ }
+}
+
+.console-active__arrow-container {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: #{10 / $base-font-size}rem;
+}
+
+.console__editor {
+ margin-left: #{15 / $base-font-size}rem;
+ flex: 1;
+ & .CodeMirror {
+ height: auto;
+ }
+ & .CodeMirror-lines {
+ padding-top: #{2 / $base-font-size}rem;
+ }
+}
+
+.console__editor .CodeMirror {
+ border: none;
+ font-family: Inconsolata,monospace;
+ @include themify() {
+ background-color: getThemifyVariable('console-input-background-color');
+ }
+
+ .CodeMirror-line {
+ @include themify() {
+ color: getThemifyVariable('console-color');
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/styles/components/_console.scss b/client/styles/components/_console.scss
index 29e26f62c5..3874102c16 100644
--- a/client/styles/components/_console.scss
+++ b/client/styles/components/_console.scss
@@ -3,12 +3,11 @@
background: getThemifyVariable('console-background-color');
border-color: getThemifyVariable('ide-border-color');
}
- border-left: 1px solid;
- border-right: 1px solid;
+ border-left: #{1 / $base-font-size}rem solid;
+ border-right: #{1 / $base-font-size}rem solid;
width: 100%;
height: 100%;
z-index: 1000;
- overflow: hidden;
display: flex;
flex-direction: column;
@@ -16,14 +15,6 @@
position:relative;
text-align:left;
}
-
- .preview-console__message {
- @include themify() {
- color: getThemifyVariable('console-color');
- }
- flex: 1 0 auto;
- position: relative;
- }
}
.preview-console__header {
@@ -49,6 +40,7 @@
.preview-console__messages {
display: flex;
+ flex: 1;
flex-direction: column;
overflow-y: auto;
}
@@ -109,6 +101,7 @@
font-weight: bold;
margin: #{2 / $base-font-size}rem 0 0 #{8 / $base-font-size}rem;
+ border-radius: #{10 / $base-font-size}rem;
padding: #{1 / $base-font-size}rem #{4 / $base-font-size}rem;
z-index: 100;
left: 0;
@@ -135,3 +128,16 @@
}
}
}
+
+.preview-console__body {
+ display: flex;
+ flex-direction: column;
+ height: calc(100% - #{30 / $base-font-size}rem);
+
+ .preview-console__message {
+ position: relative;
+ @include themify() {
+ color: getThemifyVariable('console-logged-times-color');
+ }
+ }
+}
diff --git a/client/styles/main.scss b/client/styles/main.scss
index 8ba66af71e..b9e4fa53c4 100644
--- a/client/styles/main.scss
+++ b/client/styles/main.scss
@@ -44,6 +44,7 @@
@import 'components/keyboard-shortcuts';
@import 'components/copyable-input';
@import 'components/feedback';
+@import 'components/console-input';
@import 'components/loader';
@import 'components/uploader';
@import 'components/tabs';
diff --git a/client/utils/dispatcher.js b/client/utils/dispatcher.js
new file mode 100644
index 0000000000..6745840999
--- /dev/null
+++ b/client/utils/dispatcher.js
@@ -0,0 +1,48 @@
+// Inspired by
+// https://github.com/codesandbox/codesandbox-client/blob/master/packages/codesandbox-api/src/dispatcher/index.ts
+
+let frame = null;
+let listener = null;
+const { origin } = window;
+
+export function registerFrame(newFrame) {
+ frame = newFrame;
+}
+
+function notifyListener(message) {
+ if (listener) listener(message);
+}
+
+function notifyFrame(message) {
+ const rawMessage = JSON.parse(JSON.stringify(message));
+ if (frame && frame.postMessage) {
+ frame.postMessage(rawMessage, origin);
+ }
+}
+
+export function dispatch(message) {
+ if (!message) return;
+
+ notifyListener(message);
+ notifyFrame(message);
+}
+
+/**
+ * Call callback to remove listener
+ */
+export function listen(callback) {
+ listener = callback;
+ return () => {
+ listener = null;
+ };
+}
+
+function eventListener(e) {
+ const { data } = e;
+
+ if (data && e.origin === origin) {
+ notifyListener(data);
+ }
+}
+
+window.addEventListener('message', eventListener);
diff --git a/client/utils/evaluateExpression.js b/client/utils/evaluateExpression.js
new file mode 100644
index 0000000000..6e277d5d0f
--- /dev/null
+++ b/client/utils/evaluateExpression.js
@@ -0,0 +1,29 @@
+function __makeEvaluateExpression(evalInClosure) {
+ return (expr) =>
+ evalInClosure(`
+ ${expr}`);
+}
+
+function evaluateExpression() {
+ return __makeEvaluateExpression((expr) => {
+ let newExpr = expr;
+ let result = null;
+ let error = false;
+ try {
+ try {
+ const wrapped = `(${expr})`;
+ const validate = new Function(wrapped); // eslint-disable-line
+ newExpr = wrapped; // eslint-disable-line
+ } catch (e) {
+ // We shouldn't wrap the expression
+ }
+ result = (0, eval)(newExpr); // eslint-disable-line
+ } catch (e) {
+ result = `${e.name}: ${e.message}`;
+ error = true;
+ }
+ return { result, error };
+ });
+}
+
+export default evaluateExpression();
diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js
index 59858938b5..4d560d9e20 100644
--- a/client/utils/previewEntry.js
+++ b/client/utils/previewEntry.js
@@ -1,5 +1,6 @@
import loopProtect from 'loop-protect';
-import { Hook } from 'console-feed';
+import { Hook, Decode, Encode } from 'console-feed';
+import evaluateExpression from './evaluateExpression';
window.loopProtect = loopProtect;
@@ -7,13 +8,41 @@ const consoleBuffer = [];
const LOGWAIT = 500;
Hook(window.console, (log) => {
consoleBuffer.push({
- log,
- source: 'sketch'
+ log
});
});
setInterval(() => {
if (consoleBuffer.length > 0) {
- window.parent.postMessage(consoleBuffer, '*');
+ const message = {
+ messages: consoleBuffer,
+ source: 'sketch'
+ };
+ window.parent.postMessage(message, window.origin);
consoleBuffer.length = 0;
}
}, LOGWAIT);
+
+function handleMessageEvent(e) {
+ if (window.origin !== e.origin) return;
+ const { data } = e;
+ const { source, messages } = data;
+ if (source === 'console' && Array.isArray(messages)) {
+ const decodedMessages = messages.map((message) => Decode(message.log));
+ decodedMessages.forEach((message) => {
+ const { data: args } = message;
+ const { result, error } = evaluateExpression(args);
+ const resultMessages = [
+ { log: Encode({ method: error ? 'error' : 'result', data: [result] }) }
+ ];
+ window.parent.postMessage(
+ {
+ messages: resultMessages,
+ source: 'sketch'
+ },
+ window.origin
+ );
+ });
+ }
+}
+
+window.addEventListener('message', handleMessageEvent);
diff --git a/server/views/index.js b/server/views/index.js
index ecc269c064..e60199e51d 100644
--- a/server/views/index.js
+++ b/server/views/index.js
@@ -34,7 +34,7 @@ export function renderIndex() {
window.process.env.UPLOAD_LIMIT = ${process.env.UPLOAD_LIMIT ? `${process.env.UPLOAD_LIMIT}` : undefined};
window.process.env.MOBILE_ENABLED = ${process.env.MOBILE_ENABLED ? `${process.env.MOBILE_ENABLED}` : undefined};
window.process.env.TRANSLATIONS_ENABLED = ${process.env.TRANSLATIONS_ENABLED === 'true' ? true : false};
-
+ window.process.env.PREVIEW_SCRIPTS_URL = '${process.env.NODE_ENV === 'production' ? `${assetsManifest['/previewScripts.js']}` : '/previewScripts.js'}';
diff --git a/webpack/config.prod.js b/webpack/config.prod.js
index 5e804b40b6..4939d291a2 100644
--- a/webpack/config.prod.js
+++ b/webpack/config.prod.js
@@ -11,6 +11,8 @@ if (process.env.NODE_ENV === "development") {
require('dotenv').config();
}
+const sharedObj = {};
+
module.exports = [{
devtool: 'source-map',
mode: 'production',
@@ -156,6 +158,8 @@ module.exports = [{
plugins: [
new WebpackManifestPlugin({
basePath: '/',
+ filename: 'manifest.json',
+ seed: sharedObj
}),
new MiniCssExtractPlugin({
filename: 'app.[hash].css',
@@ -170,7 +174,7 @@ module.exports = [{
},
{
entry: {
- app: [
+ previewScripts: [
path.resolve(__dirname, '../client/utils/previewEntry.js')
]
},
@@ -179,7 +183,7 @@ module.exports = [{
mode: 'production',
output: {
path: path.resolve(__dirname, '../dist/static'),
- filename: 'previewScripts.js',
+ filename: 'previewScripts.[hash].js',
publicPath: '/'
},
resolve: {
@@ -208,4 +212,11 @@ module.exports = [{
parallel: true
})],
},
+ plugins: [
+ new WebpackManifestPlugin({
+ basePath: '/',
+ filename: 'manifest.json',
+ seed: sharedObj
+ })
+ ]
}];