);
diff --git a/client/modules/IDE/components/ConsoleInput.jsx b/client/modules/IDE/components/ConsoleInput.jsx
new file mode 100644
index 0000000000..274845192f
--- /dev/null
+++ b/client/modules/IDE/components/ConsoleInput.jsx
@@ -0,0 +1,132 @@
+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 3c838175c0..927dd248af 100644
--- a/client/modules/IDE/components/Editor.jsx
+++ b/client/modules/IDE/components/Editor.jsx
@@ -189,6 +189,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 ef33bb3f64..142cb5ece7 100644
--- a/client/modules/IDE/components/PreviewFrame.jsx
+++ b/client/modules/IDE/components/PreviewFrame.jsx
@@ -2,13 +2,11 @@ import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
// import escapeStringRegexp from 'escape-string-regexp';
-import { isEqual } from 'lodash';
import srcDoc from 'srcdoc-polyfill';
import loopProtect from 'loop-protect';
import { JSHINT } from 'jshint';
import decomment from 'decomment';
import classNames from 'classnames';
-import { Decode } from 'console-feed';
import { getBlobUrl } from '../actions/files';
import { resolvePathToFile } from '../../../../server/utils/filePath';
import {
@@ -21,6 +19,7 @@ import {
} from '../../../../server/utils/fileUtils';
import { hijackConsoleErrorsScript, startTag, getAllScriptOffsets }
from '../../../utils/consoleUtils';
+import { registerFrame } from '../../../utils/dispatcher';
const shouldRenderSketch = (props, prevProps = undefined) => {
@@ -42,18 +41,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) {
@@ -63,51 +62,10 @@ 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);
@@ -326,7 +284,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();
@@ -355,7 +313,7 @@ class PreviewFrame extends React.Component {
role="main"
frameBorder="0"
title="sketch preview"
- ref={(element) => { this.iframeElement = element; }}
+ ref={this.iframe}
sandbox={sandboxAttributes}
/>
);
@@ -377,17 +335,14 @@ PreviewFrame.propTypes = {
url: PropTypes.string,
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
- }),
+ })
};
PreviewFrame.defaultProps = {
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/hooks/useHandleMessageEvent.js b/client/modules/IDE/hooks/useHandleMessageEvent.js
new file mode 100644
index 0000000000..2bd22168b7
--- /dev/null
+++ b/client/modules/IDE/hooks/useHandleMessageEvent.js
@@ -0,0 +1,47 @@
+import { useDispatch } from 'react-redux';
+import { Decode } from 'console-feed';
+import { isEqual } from 'lodash';
+import { dispatchConsoleEvent } from '../actions/console';
+import { stopSketch, expandConsole } from '../actions/console';
+
+export default function useHandleMessageEvent() {
+ const dispatch = useDispatch();
+
+ const handleMessageEvent = (data) => {
+ const { source, messages } = data;
+ if (source === 'sketch' && Array.isArray(messages)) {
+ const decodedMessages = messages.map(message => Decode(message.log));
+ 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')) {
+ dispatch(stopSketch());
+ dispatch(expandConsole());
+ hasInfiniteLoop = true;
+ }
+ });
+ if (hasInfiniteLoop) {
+ return false;
+ }
+ if (index === arr.length - 1) {
+ Object.assign(message, { times: 1 });
+ return false;
+ }
+ // this should be done in the reducer probs
+ 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;
+ });
+ dispatch(dispatchConsoleEvent(decodedMessages));
+ }
+ };
+ return handleMessageEvent;
+}
diff --git a/client/modules/IDE/pages/MobileIDEView.jsx b/client/modules/IDE/pages/MobileIDEView.jsx
index 0d58887e04..963b4fbafa 100644
--- a/client/modules/IDE/pages/MobileIDEView.jsx
+++ b/client/modules/IDE/pages/MobileIDEView.jsx
@@ -37,7 +37,7 @@ import Dropdown from '../../../components/Dropdown';
import { getIsUserOwner } from '../selectors/users';
-import { useEffectWithComparison, useEventListener } from '../../../utils/custom-hooks';
+import { useEffectWithComparison, useEventListener } 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 d0f62b27b4..d356c68168 100644
--- a/client/styles/abstracts/_variables.scss
+++ b/client/styles/abstracts/_variables.scss
@@ -64,7 +64,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,
@@ -140,7 +144,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,
@@ -214,7 +222,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..e3854a6764 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,8 +40,9 @@
.preview-console__messages {
display: flex;
+ flex: 1;
flex-direction: column;
- overflow-y: auto;
+ overflow-y: scroll;
}
.preview-console__collapse {
@@ -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/components/_p5-contrast-codemirror-theme.scss b/client/styles/components/_p5-contrast-codemirror-theme.scss
index 0ec609b834..b9f837cc15 100644
--- a/client/styles/components/_p5-contrast-codemirror-theme.scss
+++ b/client/styles/components/_p5-contrast-codemirror-theme.scss
@@ -131,3 +131,7 @@ $p5-contrast-activeline: #999999;
background-color: white;
color: #333;
}
+
+.cm-s-p5-contrast .CodeMirror-cursor {
+ border-left: 1px solid $p5-contrast-white;
+}
diff --git a/client/styles/components/_p5-dark-codemirror-theme.scss b/client/styles/components/_p5-dark-codemirror-theme.scss
index 9e16951432..51879fdfd9 100644
--- a/client/styles/components/_p5-dark-codemirror-theme.scss
+++ b/client/styles/components/_p5-dark-codemirror-theme.scss
@@ -138,3 +138,7 @@ $p5-dark-error: #df3a3d;
background-color: white;
color: #333;
}
+
+.cm-s-p5-dark .CodeMirror-cursor {
+ border-left: 1px solid $p5-dark-white;
+}
diff --git a/client/styles/components/_p5-light-codemirror-theme.scss b/client/styles/components/_p5-light-codemirror-theme.scss
index 485aa7beef..28345a34e0 100644
--- a/client/styles/components/_p5-light-codemirror-theme.scss
+++ b/client/styles/components/_p5-light-codemirror-theme.scss
@@ -131,3 +131,7 @@ $p5-light-activeline: rgb(207, 207, 207);
background-color: #333;
color: white;
}
+
+.cm-s-p5-light .CodeMirror-cursor {
+ border-left: 1px solid $p5-light-black;
+}
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..570adf6fa2
--- /dev/null
+++ b/client/utils/evaluateExpression.js
@@ -0,0 +1,28 @@
+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..7db7fb478d 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,36 @@ 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);